diff --git a/.github/agents/avm-owner-triage-agent.md b/.github/agents/avm-owner-triage-agent.md new file mode 100644 index 0000000..8cd8e6e --- /dev/null +++ b/.github/agents/avm-owner-triage-agent.md @@ -0,0 +1,682 @@ +--- +description: "Triage open GitHub issues across the Azure Verified Modules (AVM) repos an owner maintains. Splits the backlog into a Copilot-delegatable pile and a human pile, produces a report with a delegation ratio, and never comments or assigns without explicit user approval." +name: "AVM Owner Triage" +model: "Claude Opus 4.7" +tools: [vscode, execute, read, agent, edit, search, web, browser, 'github/*', 'microsoft.docs.mcp/*', 'terraform.mcp/*', todo] +argument-hint: "Start a deep or quick triage: , e.g., \"octocat quick\" or \"octocat deep\". Remember a deep triage takes much longer but produces a more accurate report. If you don't specify the mode, I'll ask you before I start." +--- + +# AVM Owner Triage Agent + +> ❗ **Step 0 - Ask for the owner alias.** Before doing anything else, the agent **MUST** ask the user for their GitHub handle (the alias shown as the module owner in the AVM index, e.g. `octocat`). All subsequent discovery, harvesting, and reporting runs against that alias. Do not assume; do not carry over an alias from a previous session. + +> ❓ **Step 0.5 - Ask for the analysis depth.** Immediately after the alias is confirmed and the module list is presented, the agent **MUST** ask the user to choose one of two modes: +> +> - **`quick`** (default) - Thread-only triage. Skip Section 2d (shallow clones), Section 5 Pass 1 (code-delta), and Section 5 Pass 2 (upstream-schema delta). Dependencies come from issue threads alone. Faster (minutes), lower-fidelity, fine for a first-pass weekly sweep. Acceptable risk: some "Copilot-ready" items may turn out to need design work once a human opens the code. +> - **`deep`** - Full three-pass dependency analysis. Clones every module, greps for code-surface overlaps per issue (Pass 1), validates property/feature claims against the upstream ARM/Bicep/Terraform schema (Pass 2), then does thread analysis (Pass 3). Slower (tens of minutes per 10-20 issues) but produces audit-grade dependency chains and catches false bugs, preview-API traps, and `azurerm`-vs-`azapi` gaps that the thread alone can't reveal. +> +> Present the choice exactly like this: +> +> > *"Before I start: do you want a `quick` triage (thread-only, faster) or a `deep` triage (clones the repos and validates claims against upstream schema, slower but catches false bugs and real dependency chains)? Reply `quick` or `deep`."* +> +> Record the choice in the report header so the consumer can see at a glance which mode produced the output. In `quick` mode, all references to "Pass 1 evidence", "Pass 2 evidence", or "code surface" in the report template collapse to "thread-claimed" and the corresponding columns state *"(quick mode - not analysed)"* rather than fabricating evidence. + +**Version:** 1.6 (2026-04-24) + +--- + +## Purpose + +A reusable, repeatable process any AVM module owner can run (themselves or via an agent) to triage open GitHub issues across the repos they own or co-own. + +The goal is to maximize the share of issues that can be safely delegated to a GitHub Copilot coding agent, so the owner spends their time only on what truly needs human judgment (complex root cause, design decisions, cross-issue conflicts). A good triage run splits the backlog into two piles: + +- **Delegate pile** - `Copilot-ready` items with unambiguous fix paths and no blocking dependencies. These get assigned to `app/copilot` after user approval. +- **Human pile** - `Needs investigation`, `Needs design decision`, or items tangled in intra-module dependencies that an autonomous agent cannot untangle. + +The percentage of the backlog that lands in the delegate pile is the quality metric for the triage. + +--- + +## Quick Start + +Invoke this agent and ask it to run a full triage across your modules. Provide your GitHub alias up front (e.g. `octocat`); if you don't, the agent asks once before proceeding. + +**Report output location.** If the caller does not specify a target path, the agent writes the report to: + +``` +./avm-triage--.md +``` + +in the current working directory. The dated, alias-qualified filename avoids clobbering prior runs and makes multi-owner or multi-day runs sort naturally. To override, pass an explicit path (for example `report.md`, or `~/triage//.md`). + +--- + +## Section 1 - Module Discovery + +Using the user-supplied alias ``, scan the four AVM module indexes and record every row where `` appears in the Owners column (as primary or co-owner): + +- https://azure.github.io/Azure-Verified-Modules/indexes/terraform/tf-resource-modules/#published-modules----- +- https://azure.github.io/Azure-Verified-Modules/indexes/terraform/tf-pattern-modules/#published-modules----- +- https://azure.github.io/Azure-Verified-Modules/indexes/bicep/bicep-resource-modules/#published-modules----- +- https://azure.github.io/Azure-Verified-Modules/indexes/bicep/bicep-pattern-modules/#published-modules----- + +### Raw-source fallback (**source of truth**) + +The rendered index pages above can fail to load, be truncated, or lag the canonical data. The authoritative source is the raw CSV/JSON in the AVM repo: + +- https://github.com/Azure/Azure-Verified-Modules/tree/main/docs/static/module-indexes + +Files (fetch the `raw.githubusercontent.com` version for parsing): + +| File | Covers | +|------|--------| +| `BicepResourceModules.csv` | Bicep `avm/res/*` modules | +| `BicepPatternModules.csv` | Bicep `avm/ptn/*` modules | +| `BicepUtilityModules.csv` | Bicep `avm/utl/*` modules | +| `BicepMARModules.json` | Mirrored MAR registry entries (machine-generated) | +| `TerraformResourceModules.csv` | Terraform `avm-res-*` modules | +| `TerraformPatternModules.csv` | Terraform `avm-ptn-*` modules | +| `TerraformUtilityModules.csv` | Terraform `avm-utl-*` modules | + +Canonical fetch + filter per alias: + +```bash +BASE="https://raw.githubusercontent.com/Azure/Azure-Verified-Modules/main/docs/static/module-indexes" +for f in BicepResourceModules.csv BicepPatternModules.csv BicepUtilityModules.csv \ + TerraformResourceModules.csv TerraformPatternModules.csv TerraformUtilityModules.csv; do + echo "== $f ==" + curl -sS "$BASE/$f" | awk -v a="" -F',' 'NR==1 || tolower($0) ~ tolower(a)' +done +``` + +Use the raw source whenever: +- A rendered index page times out, returns empty, or is clearly out of date. +- You need to script discovery (the CSVs parse deterministically; the HTML pages do not). +- An ownership transfer or new module has landed recently - raw CSV updates minutes after merge; the rendered site can lag a day. + +Cite which source produced the final module list in the report (rendered pages vs raw CSV) so the user can audit. + +For each owned module, resolve: +- **Repo URL** - Terraform modules live in their own `Azure/terraform-azurerm-avm--` repo; Bicep modules live collectively in `Azure/bicep-registry-modules`. +- **Role** - `primary` (sole or first-listed owner) vs `co-owner`. +- **Module type** - `res` (resource) or `ptn` (pattern). + +⚠️ **The AVM index can lag reality.** Ask the user whether they maintain any modules *not* listed under their alias (e.g., taking over an orphaned module for a customer, or an in-flight ownership transfer). Add those explicitly before harvesting. + +Capture the result as a table the user can confirm before moving to Section 2: + +| Repo | Type | Role | Notes | +|------|------|------|-------| +| `Azure/terraform-azurerm-avm-<...>` | res/ptn | primary/co-owner | | +| `Azure/bicep-registry-modules` - `avm//` | res/ptn | primary/co-owner | one row per Bicep module | + +--- + +## Section 1.5 - Parallelization (fleet / subagents) + +A triage run is embarrassingly parallel: each module's issues can be harvested, deep-read, and dependency-analysed independently (Section 5 is explicitly **intra-module only**, so no cross-module coordination is needed until the final merge into the report). For owners with 5+ modules, running serially wastes wall-clock time - especially in `deep` mode where every module is cloned and greppped. + +### Fan-out model + +The orchestrator (this agent) always owns: + +- Step 0 / 0.5 user dialogue (alias, mode choice). +- Section 1 module discovery and user confirmation. +- Section 7 approval gate and Section 8 execution (never delegated - a subagent must not assign Copilot or post comments). +- Section 9 final report assembly from worker outputs. + +Each **worker** (one per module) owns: + +- Section 2 harvest + Section 2c diff + Section 2d clone (deep mode). +- Section 3 deep read of every issue for that module. +- Section 4 classification. +- Section 5 dependency analysis (all active passes per mode). +- Section 6 bucket assignment. +- Returns a structured per-module payload (table rows + chain list + open questions) for the orchestrator to merge. + +### Concurrency guardrails + +- **Default fan-out:** 4 workers in parallel. Raise to 8 only if the owner has 10+ modules AND the session has authenticated `gh` (5000 req/h limit). Never exceed 8 - GitHub's secondary rate limiter trips fast on concurrent Search API calls. +- **Search API serialization:** the Bicep shared-repo path (Section 2b) uses `/search/issues`, which has a stricter secondary limit. Route all Search API calls for `Azure/bicep-registry-modules` through a single worker even if multiple Bicep modules are in scope; that worker sleeps ≥7s between queries. Dedicated TF repos (Section 2a) can fan out freely. +- **Clone disk budget (deep mode):** shallow clones are ~5-50 MB each. Cap total at ~2 GB; if the owner has more modules than that allows, batch in waves and delete clones between waves. +- **Authenticated token only:** every worker inherits the orchestrator's `gh auth token`. Do not spawn workers under a different account; SSO state won't propagate cleanly. +- **Idempotency:** a worker crash must not corrupt the run. Write per-module payloads to `/tmp/triage-/workers/.json` as the worker finishes; re-run only the failed workers on retry. + +### Local vs cloud execution + +The same fan-out works both ways: + +- **Local subagents** (this repo's [`runSubagent`](#) tool or Claude's Task tool): spawn one `Explore`-style subagent per module with a tightly scoped prompt ("triage issues in `Azure/` under mode ``, return JSON payload matching schema X"). Parallel subagents share the parent's MCP connections and auth, so no extra setup. +- **Cloud agents** (GitHub Copilot coding agents, one per module): use `gh issue edit --add-assignee Copilot` **only** for the final delegate-pile assignment in Section 8 - never for triage itself. Copilot coding agents are execution, not analysis. + +### Worker prompt template + +Use this prompt verbatim when spawning a subagent per module. Substitute `<...>` tokens: + +``` +You are a worker for the AVM Owner Triage Agent. +Scope: Azure/ (module: - Bicep only) +Mode: +Owner alias: + +Run Sections 2-6 of the playbook at segraef/Scripts/.github/agents/avm-owner-triage-agent.md +for this module only. Do NOT run Section 7 or 8 - return your findings only. + +Output: write /tmp/triage-/workers/.json with: +{ + "repo": "", + "issues": [ {"number":..., "title":..., "type":..., "priority":..., "action":..., "deps":..., "evidence":...}, ... ], + "chains": [ {"name":..., "order":[#a,#b,#c], "rationale":...}, ... ], + "excluded": [...], + "open_questions": [...], + "mode_used": "" +} + +Do not post comments. Do not assign Copilot. Do not modify any repo. Read-only clones OK in deep mode. +``` + +The orchestrator waits for all worker JSON files, then assembles the Section 9 report in one pass. + +--- + +## Section 2 - Issue Harvesting + +### 2a. Dedicated TF module repos (one module per repo) + +```bash +gh issue list --repo Azure/ --state open --limit 200 \ + --json number,title,labels,assignees,comments,createdAt,updatedAt +``` + +If `gh` reports SAML/SSO enforcement, authorize the Azure org session first (see Appendix C) rather than dropping to unauthenticated curl. Only as a last resort: + +```bash +curl -sS -H "Authorization: Bearer $(gh auth token)" \ + "https://api.github.com/repos/Azure//issues?state=open&per_page=100" +``` + +Filter PRs out with `[i for i in d if 'pull_request' not in i]`. + +### 2b. Shared repo `Azure/bicep-registry-modules` (many modules, one repo) + +Issues in the shared Bicep repo **do not have per-module labels**. Two search strategies are needed because title conventions differ: + +| Kind | Title convention | Search | +|------|------------------|--------| +| Failed pipeline | `[Failed pipeline] avm.res.` (dotted) | `"avm.res."` in:title | +| Bug / feature | `[AVM Module Issue]: `, module in body | `"avm/res/"` (slash) across title+body | + +Use the GitHub Search API, and sleep ~7s between queries to avoid the secondary rate limit: + +```bash +q='repo:Azure/bicep-registry-modules is:issue is:open "avm/res/"' +curl -sS "https://api.github.com/search/issues?q=$(python3 -c 'import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1]))' "$q")&per_page=100" +``` + +⚠️ **Body-match false positives:** an issue filed against `avm/res/sql/server` may reference `avm/res/network/private-endpoint` in a stack trace. Always open the issue and read the `### Module Name` field in the body to confirm the true subject module before including it in the triage. + +### 2c. Previous-triage diff (mandatory) + +Before classifying, diff the current open list against the previous report. Record: +- ✅ **Resolved** (closed since last run) - quick win to surface +- ➕ **New** (opened since last run) - needs deep read +- 🔄 **Updated** (new comments or label churn) - may need re-classification +- 🔁 **Re-opened duplicates** - primary resolved but dup still open → verify and close + +### 2d. Shallow clone of each module (**deep mode only**) + +> Skip this step if the user chose `quick` mode in Step 0.5. + +Dependency analysis needs the actual code, not just issue threads. For every module in scope, pull a read-only shallow clone: + +```bash +mkdir -p /tmp/triage-/repos +cd /tmp/triage-/repos +gh repo clone Azure/ -- --depth=1 # per module +``` + +Keep the clones for the duration of the triage. Section 5 Pass 1 (code-delta analysis) greps these clones to compute code-surface fingerprints per issue. + + +--- + +## Section 3 - Deep Read (Issue Thread Analysis) + +For **every** issue, read the full thread - body **and all comments in order**: + +```bash +gh issue view --repo Azure/ --comments +``` + +### 3a. Extract from the initial body + +- Reproduction steps, module version, correlation id +- Requested behaviour / suggested fix +- Severity signal (blocking prod? workaround available? nice-to-have?) + +### 3b. Extract from the comment thread (thread evolution) + +Issues rarely stay as-filed. The thread is where they change shape. For every comment, record: + +- **Scope creep** - new bug sub-parts added later ("added another bug with the module"). Flag for splitting (see Section 5 item 7). +- **Root cause shift** - reporter or maintainer reframes the problem. The title may now be misleading. +- **Additional context** - logs, stack traces, provider versions, tenant constraints, workarounds that narrow or widen the fix. +- **External artifacts** - linked PRs, fork branches (`github.com///tree/`), related issues, linked docs. These gate action (see Section 5 item 5). +- **Call-outs** - `@mentions` of the module owner, AVM core team, or another contributor. If owner was called out and didn't reply - priority bump. +- **Reporter follow-up** - reporter answers a maintainer question (unblocks action) or goes silent after a request (stalled; consider `needs-info` nudge). +- **Contradictions** - two participants proposing opposite fixes. Flag as "conflicting approaches" (Section 5 item 3). +- **Resolution drift** - reporter says "workaround is fine" or "we moved off this module" (candidate for `wont-fix` or close-as-stale). +- **Bot noise vs signal** - AVM policy bot comments (`Needs: Triage`, `Status: Response Overdue`, `Immediate Attention` tags) indicate SLA escalation, not content. Summarize staleness, don't echo each bot post. + +### 3c. Staleness signals + +- **Last human comment age** - under 7 days = active; 7-30 days = warming; 30-90 days = stale; over 90 days = cold (consider stale-close or ping). +- **Owner-silent streak** - owner never replied and bot has escalated to `Needs: Immediate Attention` - priority bump to at least Medium-high regardless of technical severity. +- **Reporter-silent streak** - maintainer asked for info, no response in 14+ days - `Needs: Info` with a close-in-30-days note. + +### 3d. Per-issue capture template + +For each issue write down: + +``` +# + first-filed: <date> + last-human-comment: <date> by <user> (age: <days>) + reporter-follow-up: yes/no/stalled + owner-responded: yes/no (if no, since: <date>) + pr-or-branch-linked: <url or none> + scope-changed-in-thread: yes/no (if yes: <what changed>) + external-mentions: [<@user>, ...] + bot-escalation-level: none/response-overdue/immediate-attention + key-signal: <one-line summary of what the thread added beyond the body> +``` + +This template feeds directly into classification (Section 4) and dependency analysis (Section 5). + +--- + +## Section 4 - Classification + +| Type | Description | +|------|-------------| +| `bug` | Module produces incorrect or failing behaviour | +| `provider-update` | AzureRM provider changed a resource/attribute | +| `feature-request` | New capability not currently supported | +| `documentation` | No code change needed | +| `enhancement` | Existing feature can be improved | +| `duplicate` | Same ask as another issue | +| `wont-fix` | Out of scope or consumer responsibility | + +Priority: 🔴 High (blocker, no workaround) | 🟠 Medium-high | 🟡 Medium | ⚪ Low + +--- + +## Section 5 - Cross-Issue Dependency Analysis (**MANDATORY**) + +> 🚫 **Scope: within a single module only.** Never link dependencies across modules/repos. Each module's backlog is triaged in isolation because a Copilot agent working on one repo has no visibility into another. Cross-module observations (e.g., "both AI Foundry and AI Landing Zone have DNS issues") are interesting for your roadmap but do **not** belong in the dependency matrix. + +Dependency analysis runs in up to **three passes** depending on the mode chosen in Step 0.5: + +- `quick` mode: **Pass 3 only** (thread-declared). Passes 1 and 2 are skipped. +- `deep` mode: **all three passes** (code-delta → upstream-schema delta → thread-declared). + +State the active mode at the top of this section in the final report so the reader knows which evidence types were actually consulted. + +### Pass 1 - Code-delta analysis (**deep mode only**) + +Issue threads only reveal *claimed* dependencies. Real dependencies live in the code: shared variables, shared resources, overlapping files, provider version pins, open PR branches against the same lines. A pure thread-based triage produces false positives (two "networking" issues that touch disjoint resources) and false negatives (two unrelated-sounding issues that both edit `locals.tf`). + +For each issue in the module, compute a **code surface fingerprint** before declaring dependencies. Use a shallow read-only clone or the GitHub API - do not modify anything: + +1. **File overlap.** What files would the fix plausibly touch? Infer from the issue body (resource names, variable names, module inputs mentioned) and grep the repo for those symbols: + ```bash + gh repo clone Azure/<repo> /tmp/triage-<repo> -- --depth=1 + cd /tmp/triage-<repo> + grep -rln "<symbol>" --include="*.tf" --include="*.bicep" --include="*.md" + ``` +2. **Symbol overlap.** Same variable, resource block, or module input across issues? A matching symbol in two issues is a hard signal they must be coordinated, regardless of what the threads say. +3. **Open-branch / PR conflict.** If a thread references a fork branch (`github.com/<user>/<fork>/tree/<branch>`) or a PR number, pull the diff and record which files it touches: + ```bash + gh pr view <N> --repo Azure/<repo> --json files --jq '.files[].path' + gh api repos/<user>/<fork>/compare/main...<branch> --jq '.files[].filename' + ``` + Any sibling issue whose surface overlaps that diff must ship **after** the PR merges or be folded into it. +4. **Provider / version pins.** Note any `required_providers`, `required_version`, preview-API usage, or upstream dependency referenced by the issue. Issues that require different pins of the same provider are a ship-order dependency even if the code surfaces don't overlap. + +Record per issue: `Code surface: <files>; symbols: <names>; overlaps: #<n>, #<n>; blocked by PR/branch: <ref or none>`. Two issues with overlapping surfaces become a chain even if the threads don't mention each other. Two issues in the same thematic cluster with disjoint surfaces can be **un**chained. + +### Pass 2 - Upstream-schema delta (**deep mode only**, for any issue citing a missing/unsupported property) + +An issue that claims *"property X is not supported"* or *"need to expose Y"* must be validated against the **authoritative resource-provider schema** before it can be marked Copilot-ready or chained. The module's own code is not the source of truth; the upstream schema is. Three sources, use all that apply. + +**Tool preference (use MCP first, curl fallback):** + +| Source | Primary tool | Fallback | +|--------|-------------|----------| +| Azure resource reference (Bicep / ARM / AzAPI schema on learn.microsoft.com) | `microsoft_docs_search` to locate the right page, then `microsoft_docs_fetch` for full schema | `curl -sS "https://learn.microsoft.com/.../<page>"` and parse the HTML; or `microsoft_code_sample_search` for usage snippets | +| Terraform registry - `azurerm` / `azapi` providers | `mcp_terraform_get_latest_provider_version`, `mcp_terraform_get_provider_details`, `mcp_terraform_get_provider_capabilities` | `curl -sS "https://registry.terraform.io/v1/providers/hashicorp/azurerm"` for version; browse `https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/<resource>` for attributes | + +If an MCP server is not enabled in the session, note the fallback you used in the issue's upstream-evidence line so the owner can audit which source was consulted. + +1. **Azure resource reference (Bicep / ARM / AzAPI).** Single canonical page per `{resource provider}/{api-version}/{resource type}`, with a language pivot. Confirms the property exists in that API version, its type, whether it's required, and its preview/GA status. + - Bicep: `https://learn.microsoft.com/azure/templates/{rp}/{api-version}/{resource}?pivots=deployment-language-bicep` + - AzAPI (Terraform): `https://learn.microsoft.com/azure/templates/{rp}/{api-version}/{resource}?pivots=deployment-language-terraform` + - ARM JSON: `...?pivots=deployment-language-arm-template` + - Example: `https://learn.microsoft.com/en-us/azure/templates/microsoft.cognitiveservices/2025-09-01/accounts?pivots=deployment-language-bicep` + - **Preferred:** call `microsoft_docs_search` with the resource type (e.g. `"Microsoft.CognitiveServices/accounts Bicep"`), then `microsoft_docs_fetch` on the returned URL. **Fallback:** `curl` the URL and grep the schema block; confirm the listed `apiVersion`. +2. **Terraform registry - `azurerm`.** For AVM Terraform modules backed by `azurerm`, the [Terraform registry](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs) is the source of truth for what the provider actually exposes today. **Preferred:** `mcp_terraform_get_latest_provider_version` + `mcp_terraform_get_provider_details` for `hashicorp/azurerm`. **Fallback:** `curl https://registry.terraform.io/v1/providers/hashicorp/azurerm` for the current version; for per-resource attributes, fetch `https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/<resource>` (public, no auth). A feature that exists in the ARM schema but not in `azurerm` means the module needs `azapi` or a provider-feature-request upstream - that is a real dependency, not a module bug. +3. **Terraform registry - `azapi`.** For the `azapi` fallback path, confirm the resource/type is supported and find the `type = "Microsoft.{rp}/{resource}@{api-version}"` form. **Preferred:** `mcp_terraform_get_provider_details` for `azure/azapi`. **Fallback:** `curl https://registry.terraform.io/providers/Azure/azapi/latest/docs`. This pins the exact api-version the fix must target. + +What this catches: + +- **False bugs.** "Property X is not supported" - schema shows X only exists in an api-version the module isn't using → issue becomes `Needs owner` (bump api-version decision) not `Copilot-ready`. +- **Preview-API traps.** Schema marks the property as preview → flag as `blocked: post-GA` automatically, matching the [#126](https://github.com/Azure/terraform-azurerm-avm-res-app-managedenvironment/issues/126) pattern. +- **azurerm vs azapi gap.** ARM schema has the property but `azurerm` provider doesn't → fix requires `azapi` refactor. Multiple issues with the same gap share a root cause and become one chain. +- **Stale issue detection.** Issue filed 6 months ago claiming *"not supported"* - schema at current api-version now includes it → promote to `Copilot-ready` with a "verify and implement" note. + +Record per issue: `Upstream: {rp}/{resource}@{api-version}; property present: yes/no; pivot: bicep|terraform; preview: yes/no; azurerm covers: yes/no; azapi type: Microsoft.X/Y@vZ`. + +### Pass 3 - Thread-declared analysis + +After the code-delta and upstream-schema passes, run a deliberate third pass over **that module's issues only** to identify: + +1. **Duplicates/overlaps** - mark one as dup, close after the other resolves +2. **Ordering dependencies** - A must land before B +3. **Conflicting approaches** - issues that pull in opposite directions +4. **Shared root cause** - multiple symptoms, one fix (confirmed when code-delta shows same surface or upstream-schema shows the same provider gap) +5. **Blocking PRs / fork branches** - linked PR must merge first; don't re-implement. Already surfaced by Pass 1 step 3. +6. **"Must ship together" pairs** - independent implementation would break UX (usually confirmed when code-delta shows the same file or resource block) +7. **Multi-part issues** - one issue reporting N distinct bugs → recommend splitting so each sub-part is individually tractable +8. **Dup-of-closed** - when a primary issue closes, reassess its former dups: pull a repro and close as "fixed upstream" OR promote to standalone if still failing + +Document as a dependency matrix **per module**, citing the Pass 1 / Pass 2 evidence (overlapping file, symbol, PR diff, or upstream schema api-version) for each edge. + +### Why this matters for Copilot delegation + +Any issue inside a dependency chain is **not Copilot-ready** until the blocking item is resolved. An autonomous agent given a downstream issue will either recreate work, produce a conflicting fix, or fail silently. Mark the blocked downstream items as `Copilot-ready (after #X)` so they enter the delegate pile only once the gate clears. The code-delta fingerprint and upstream-schema check together justify the "after #X" or "blocked: preview" label; a chain backed only by thread speculation is weak. + +--- + +## Section 6 - Recommended Action Assignment + +Every issue ends up in one of two buckets. The triage run is optimized to push as many as possible into the first. + +### Delegate pile (assign to `app/copilot` after user approval) + +| Action | Meaning | +|--------|---------| +| `Copilot-ready` | Mechanical, bounded, no design decision needed. Fix path is confirmed by the thread. | +| `Copilot-ready (after #X)` | Will be Copilot-ready once the named blocker clears. Do not assign yet. | +| `Document & close` | Docs change only; Copilot can draft the PR. | +| `Duplicate → close` | Closed with a link once the primary resolves. Copilot can close after primary ships. | + +**Copilot-ready criteria (all must be true):** + +1. Fix path is unambiguous - the thread points to specific files/attributes. +2. No design decision pending - API shape, variable names, and default behaviour are settled (or trivially obvious). +3. Change is bounded - fits in a single PR, no refactor required. +4. No blocking dependency inside the same module (see Section 5). +5. Reporter's ask is confirmed and actionable; no open questions. +6. No security/policy judgment required (SFI, compliance, CVE scoring) - those stay in the human pile. + +### Human pile (owner handles personally) + +| Action | Meaning | +|--------|---------| +| `Needs investigation` | Root cause not confirmed; requires repro or code reading | +| `Needs design decision` | Requires owner judgment on API shape, defaults, or boundaries | +| `Blocked` | External dependency (upstream provider, another team's PR, missing platform feature) | +| `Wont-fix → close` | Out of scope - owner writes the rationale comment | + +Escalate from Copilot-ready to the human pile if **any** of these apply: +- Issue is inside an unresolved intra-module dependency chain. +- Thread shows contradicting proposals and no consensus. +- Reporter stalled on a maintainer question (need info first). +- Fix would change a public variable contract or breaking behaviour. + +### Delegation ratio + +At the end of triage, report: + +``` +Total: <N> | Delegate pile: <D> (<D/N %>) | Human pile: <H> (<H/N %>) +Blocked waiting on another issue: <B> +``` + +This is the single metric that tells the owner how much the triage actually saved them. + +--- + +## Section 7 - Before Commenting or Assigning + +⚠️ **Do NOT post comments or assign Copilot without explicit user approval.** + +Present triage report → user confirms each action → then proceed. + +### 7a. Post-report delegation prompt (**MANDATORY**) + +After the report file has been written, the agent **MUST** ask the owner in chat (not inside the report) whether to hand the Copilot-ready-now shortlist to cloud Copilot coding agents now. The report is a static artifact; the delegation decision happens in the conversation. + +Use this prompt verbatim, substituting `<N>` with the count and listing the issue references as clickable chat links: + +> *"Report written to `<path>`. <N> issues are Copilot-ready right now:* +> *- `Azure/<repo>` [#<n>](<url>) - <one-line scope>* +> *- ...* +> +> *Do you want me to delegate all <N> to GitHub Copilot cloud agents now, delegate a subset, or hold? Reply:* +> *- `all` to assign every Copilot-ready-now issue* +> *- a space- or comma-separated list of issue numbers (e.g. `160 157 73`) to assign a subset* +> *- `hold` to do nothing and exit"* + +Rules: + +- Only list issues whose Action is exactly `Copilot-ready` (not `Copilot-ready (after #X)` - those are still blocked). +- If any of the shortlisted issues are already assigned to Copilot, call that out in the same prompt so the owner doesn't redundantly approve. +- Do not include this prompt text inside the report markdown. It belongs in the chat response that follows the write. +- Any grouping comments (e.g. "closing #58 into #56") mentioned in the Combined Action Plan must be surfaced for approval **before** the `gh issue edit --add-assignee Copilot` batch runs; post the grouping comments first, then assign. +- Exit cleanly on `hold`. On `all` or a subset list, proceed to Section 8. + +--- + +## Section 8 - Execution (After Approval) + +```bash +# Assign Copilot +gh issue edit <number> --repo Azure/<repo> --add-assignee app/copilot + +# Post comment (only after user approval of exact text) +gh issue comment <number> --repo Azure/<repo> --body "<approved text>" +gh issue close <number> --repo Azure/<repo> +``` + +--- + +## Section 9 - Report Output Template (**MANDATORY**) + +> Write the final report to `report.md` in the working directory. Follow this skeleton **exactly** - do not reorder sections, rename headings, or drop tables. Fill every `{{token}}`. Priority icons are 🔴 High · 🟡 Medium · ⚪ Low (3 tiers only). + +```markdown +# AVM Triage Report for owner `{{owner_alias}}` - {{YYYY-MM-DD}} + +**Mode:** `{{quick|deep}}` - {{"thread-only analysis" if quick else "full code-delta + upstream-schema + thread analysis"}} + +## Triage summary + +​``` +Total open: {{total}} +Copilot-ready now: {{unblocked}} ({{unblocked_pct}}%) - mechanical / well-specified, assignable today +Copilot-ready (blocked): {{blocked}} - waiting on another in-module issue or PR +Needs owner: {{H}} ({{H_pct}}%) - design, investigation, or judgement calls +​``` + +### Module issues analysed + +| Repo | Open | 🔴 High | 🟡 Medium | ⚪ Low | Copilot-ready now | Copilot-ready (blocked) | Needs owner | +|------|------|---------|-----------|--------|-------------------|-------------------------|-------------| +| {{repo}} | ... | +| **Total** | ... | + +The {{unblocked}} Copilot-ready items are the shortlist for assignment after user approval (Playbook Section 7). + +--- + +## All Issues - Flat List ({{total}} total) + +Group issues into one table **per repo** (H2 subsection per repo). Within each per-repo table, sort rows by priority descending, then by issue number ascending: + +1. 🔴 High +2. 🟡 Medium +3. ⚪ Low + +Within the same priority tier, lower issue numbers come first. Do not interleave repos; finish one repo's table before starting the next. Order the repo sections themselves by total open issue count descending (largest backlog first). + +### `Azure/{{repo}}` ({{open_count}} open) + +| # | Title | Type | Priority | Action | Dependencies / Code surface / Upstream | +|---|-------|------|----------|--------|---------------------------------------| +| [#{{n}}]({{url}}) | {{title}} | {{type}} | {{🔴/🟡/⚪}} {{priority}} | {{action}} | {{in deep mode: thread deps + code-delta evidence (overlapping files/symbols or PR diff) + upstream-schema evidence (api-version, preview flag, azurerm/azapi gap). In quick mode: thread-claimed deps only, annotate "(quick mode - code/schema not analysed)"}} | + +**Excluded (false positive):** {{list or "none"}} + +### Previous-triage diff (if applicable) + +- ✅ **Resolved since {{prev_date}}:** {{list}} +- ➕ **New since {{prev_date}}:** {{list}} +- 🔄 **Updated:** {{list}} +- 🔁 **Re-opened duplicates:** {{list}} + +--- + +## Combined Action Plan + +### 🔴 Act now +| Repo | # | Action | +|------|---|--------| +| {{repo}} | [#{{n}}]({{url}}) | {{what to do}} | + +### 🤖 Copilot-ready batch (pending approval per issue) +| Repo | Issues | +|------|--------| +| {{repo}} | [#{{n}}]({{url}}), ...; [#{{n}}]({{url}}) *(after #{{blocker}})* | + +### 🔗 PR-in-flight - review before assigning Copilot +| Repo | Issue | Note | +|------|-------|------| +| {{repo}} | [#{{n}}]({{url}}) | {{branch/PR link and rationale}} | + +### ⚠️ Duplicates to close (after primary resolves) +| Primary | Close as dup | +|---------|-------------| +| {{repo}} [#{{primary}}]({{url}}) | [#{{dup}}]({{url}}) | + +### ✅ Verify-and-close (fixed upstream) +| Issue | Reason | +|-------|--------| +| {{repo}} [#{{n}}]({{url}}) | {{upstream fix ref and verification step}} | + +### 📝 Document & close (draft text for approval first) +| Repo | Issues | Topic | +|------|--------|-------| +| {{repo}} | [#{{n}}]({{url}}), ... | {{one-line doc topic}} | + +### ⛓️ Ordering / "ship-together" chains +- **{{chain name}}:** #{{a}} → #{{b}} → #{{c}} - {{why (cite the overlapping file/symbol or blocking PR diff from Section 5 Pass 1)}} + +--- + +## Open questions for you + +1. {{question requiring owner judgment, not agent guess}} +2. ... + +--- + +## Next steps + +These issues are ready to assign to GitHub Copilot today - scope is clear, no in-module blockers, PR will run against the canonical AVM pipeline: + +- [#{{n}}]({{url}}) - {{one-line scope}} +- [#{{n}}]({{url}}) + [#{{n}}]({{url}}) - {{scope}} (assign **#{{primary}}**, group #{{secondary}} into the same PR) + +{{if any already-assigned: "[#{{n}}]({{url}}) is already assigned to Copilot."}} +``` + +**Template rules:** + +- Do not include a separate "Executive Summary" section. The Triage summary + Module issues analysed at the top are the summary. +- Use only 3 priority tiers: 🔴 High, 🟡 Medium, ⚪ Low. No "Med-High" or intermediate tiers - if in doubt, round up to High. +- Drop the "% unblocked delegate" column from the breakdown table; the Copilot-ready-now count in the Triage summary is sufficient. +- Column headers in the per-module table must match the Triage summary vocabulary: **Copilot-ready now**, **Copilot-ready (blocked)**, **Needs owner**. Do not use "Delegate" / "Human" column names. +- If a chain section (duplicates, verify-and-close, document-close, PR-in-flight) is empty, omit the section entirely rather than leaving an empty table. +- Every issue reference must be a markdown link to its GitHub URL on first mention in each section. Use bare `#N` for repeat references inside the same row. +- In the "Ordering / ship-together chains" and "Open questions for you" sections, link **every** `#N` reference - these sections are scanned for clickable navigation, so do not leave bare issue numbers. +- Keep "Open questions" to decisions only the owner can make (ownership, design trade-offs, ping-vs-close). Do not ask what the agent can infer from the thread. +- Place the report at the path the caller specifies. If none is given, default to `./avm-triage-<owner_alias>-<YYYY-MM-DD>.md` in the current working directory (see Quick Start). +- Include the `**Mode:**` line directly under the title; this is mandatory so consumers know whether dependency edges are evidence-backed (deep) or thread-claimed (quick). +- In the "All Issues - Flat List" section, produce one table per repo (H3 subsection headed `` ### `Azure/{{repo}}` ({{open_count}} open) ``), and sort rows within each table by priority descending (🔴 → 🟡 → ⚪), then by issue number ascending. Order the repo subsections themselves by total open issue count descending. Do not produce a single combined table. + +--- + +## Appendix A - AVM Bot Labels + +| Label | Meaning | +|-------|---------| +| `Needs: Triage 🔍` | Not yet reviewed by maintainer | +| `Status: Response Overdue 🚩` | No response within SLA | +| `Needs: Immediate Attention ‼️` | Further escalated | + +## Appendix B - Useful Commands + +```bash +# Harvest open issues (dedicated repos) +gh issue list --repo Azure/<repo> --state open --limit 200 \ + --json number,title,labels,assignees,createdAt,updatedAt + +# Authenticated curl fallback (after `gh auth refresh -s read:org` for SSO) +curl -sS -H "Authorization: Bearer $(gh auth token)" \ + "https://api.github.com/repos/Azure/<repo>/issues?state=open&per_page=100" + +# Bicep shared repo - search body+title for slash path +q='repo:Azure/bicep-registry-modules is:issue is:open "avm/res/<path>"' +curl -sS "https://api.github.com/search/issues?q=$(python3 -c 'import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1]))' "$q")&per_page=100" + +# Deep-read (issue body + comments) +gh issue view <number> --repo Azure/<repo> --comments +# or +curl -sS "https://api.github.com/repos/Azure/<repo>/issues/<number>" +curl -sS "https://api.github.com/repos/Azure/<repo>/issues/<number>/comments" + +# Confirm state of a previously-tracked issue (closed? re-opened?) +curl -sS "https://api.github.com/repos/Azure/<repo>/issues/<number>" \ + | python3 -c "import sys,json;d=json.load(sys.stdin);print(d['state'],d.get('closed_at'))" + +# Assign Copilot (only after user approval) +gh issue edit <number> --repo Azure/<repo> --add-assignee app/copilot +``` + +## Appendix C - Authentication, Rate-Limit & SSO Survival + +**Authenticate `gh` first.** Always prefer an authenticated `gh` session over unauthenticated `curl`: + +```bash +# One-time login (opens browser) +gh auth login -h github.com -p https -w + +# Authorize SAML/SSO for the Azure org (required for Azure/* repos) +gh auth refresh -h github.com -s read:org +gh auth status # confirm "Token scopes" includes the org under SSO +``` + +If `gh` commands against `Azure/*` return `SAML enforcement`, open the URL printed by `gh` and click **Authorize** for the Azure SSO session, then re-run. The higher authenticated rate limit (5000 req/h) is needed for any non-trivial triage run. + +- **Multiple `gh` accounts:** `gh auth status` shows all logged-in accounts. If the active account is not SSO-authorized for the Azure org but another account is, switch with `gh auth switch --user <authorized-account>` before harvesting. Check with: `gh issue list --repo Azure/bicep-registry-modules --limit 1` - a clean result confirms SSO is good for this session. + +- **Authenticated `curl` fallback:** if you must use `curl` (scripts, Search API), pass the token so you get the 5000/h limit and access to org-gated content: + ```bash + curl -sS -H "Authorization: Bearer $(gh auth token)" \ + "https://api.github.com/repos/Azure/<repo>/issues?state=open&per_page=100" + ``` +- **Unauthenticated `curl` is last-resort only:** works for public repos but hits the 60 req/h anonymous limit fast and will not see SSO-gated content. Do not use for a full triage. +- **Secondary rate limit on Search API:** sleep ≥7s between search queries even when authenticated. +- **Large JSON outputs:** pipe through `python3 -c` to filter early; don't dump raw JSON into the triage workspace. diff --git a/Python/VENV.txt b/Python/VENV.txt new file mode 100644 index 0000000..db4b046 --- /dev/null +++ b/Python/VENV.txt @@ -0,0 +1,17 @@ +Create the venv once: +python3 -m venv .venv + +Start the venv: +source .venv/bin/activate + +Install modules: +python -m pip install -r requirements.txt + +Run the script: +python book.py + +Leave the venv: +deactivate + +Shortcut without starting the venv: +./.venv/bin/python book.py diff --git a/Python/book.py b/Python/book.py new file mode 100644 index 0000000..4396f3a --- /dev/null +++ b/Python/book.py @@ -0,0 +1,573 @@ +#!/usr/bin/env python3 +""" +Meeting Room 3 Auto-Booker +Books Mon–Fri 08:00–17:00 for the next 4 weeks (platform max). + +Usage: + python book.py # book everything available + python book.py --dry-run # preview without submitting + python book.py --yes # skip per-batch confirmation +""" + +import argparse +import base64 +import hashlib +import json +import os +import random +import re +import secrets +import sys +import time +from datetime import date, timedelta +from pathlib import Path +from urllib.parse import parse_qs, urlsplit + +import requests + + +ENV_FILE = Path(__file__).with_name(".env") + +# ─── CONFIG ─────────────────────────────────────────────────────────────────── +EMAIL = os.environ.get("BOOKER_EMAIL", "") +PASSWORD = os.environ.get("BOOKER_PASSWORD", "") +BASE_URL = os.environ.get("BOOKER_BASE_URL", "") +OAUTH_AUTH_URL = os.environ.get("BOOKER_AUTH_URL", "").rstrip("/") +OAUTH_CLIENT_ID = os.environ.get("BOOKER_OAUTH_CLIENT_ID", "") +OAUTH_ACCESS_COOKIE = os.environ.get("BOOKER_OAUTH_ACCESS_COOKIE", "") +OAUTH_LOGIN_TOKEN_RE = re.compile(r'name="_token" value="([^"]+)"') + +AMENITY_ID = "66679a76a3f946d03c0c8ffc" +AMENITY_NAME = "Meeting Room 3" + +TARGET_SLOTS = [ + "08:00-09:00", "09:00-10:00", "10:00-11:00", + "11:00-12:00", "12:00-13:00", "13:00-14:00", + "14:00-15:00", "15:00-16:00", "16:00-17:00", +] + +# booking window is now read from the amenity's booking_available_days field + +# ─── RATE LIMITING ──────────────────────────────────────────────────────────── +DELAY_BETWEEN_REQUESTS = 1.5 # seconds between every API call +MIN_DELAY_BETWEEN_BOOKINGS = 16.0 # random pause after booking/save +MAX_DELAY_BETWEEN_BOOKINGS = 59.0 # random pause after booking/save +DELAY_ON_ERROR = 10.0 # back-off on any non-200 response +# ────────────────────────────────────────────────────────────────────────────── + +SESSION_HEADERS = { + "app-mb": "__@mbv4vbm@__", + "Content-Type": "application/json", + "Accept": "application/json, text/plain, */*", + "Accept-Language": "en-GB,en;q=0.9,en-US;q=0.8,de;q=0.7,de-DE;q=0.6", + "Accept-Encoding": "gzip, deflate, br, zstd", + "Cache-Control": "no-cache", + "Pragma": "no-cache", + "sec-ch-ua": '"Not:A-Brand";v="99", "Microsoft Edge";v="145", "Chromium";v="145"', + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"macOS"', + "sec-fetch-dest": "empty", + "sec-fetch-mode": "cors", + "sec-fetch-site": "same-origin", + "User-Agent": ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/145.0.0.0 Safari/537.36 Edg/145.0.0.0" + ), +} + + +def app_url_from_base(base_url: str) -> str: + parts = urlsplit(base_url) + if not parts.scheme or not parts.netloc: + return "" + return f"{parts.scheme}://{parts.netloc}" + + +def oauth_authorize_url(auth_url: str) -> str: + return f"{auth_url}/oauth/authorize" + + +def oauth_login_url(auth_url: str) -> str: + return f"{auth_url}/login" + + +def code_verifier() -> str: + return base64.urlsafe_b64encode(secrets.token_bytes(48)).decode().rstrip("=") + + +def code_challenge(verifier: str) -> str: + digest = hashlib.sha256(verifier.encode("utf-8")).digest() + return base64.urlsafe_b64encode(digest).decode().rstrip("=") + + +def get_cookie_value(session: requests.Session, name: str) -> str: + for cookie in session.cookies: + if cookie.name == name: + return cookie.value + return "" + + +def oauth_session_from(session: requests.Session) -> requests.Session: + auth_session = requests.Session() + for header in ( + "User-Agent", + "Accept-Language", + "Accept-Encoding", + "sec-ch-ua", + "sec-ch-ua-mobile", + "sec-ch-ua-platform", + ): + if header in session.headers: + auth_session.headers[header] = session.headers[header] + auth_session.headers["Accept"] = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" + return auth_session + + +def extract_oauth_code(url: str, expected_state: str, *, required: bool) -> str: + query = parse_qs(urlsplit(url).query) + error = query.get("error", [""])[0] + if error: + description = query.get("error_description", [""])[0] + if description: + raise RuntimeError(f"OAuth authorize failed: {error} ({description})") + raise RuntimeError(f"OAuth authorize failed: {error}") + + code = query.get("code", [""])[0] + if not code: + if required: + raise RuntimeError( + f"OAuth login did not reach callback URL. Final URL: {url}" + ) + return "" + + returned_state = query.get("state", [""])[0] + if returned_state != expected_state: + raise RuntimeError("OAuth state mismatch during login") + return code + + +def load_env_file(env_file: Path) -> None: + if not env_file.is_file(): + return + + for raw_line in env_file.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + if line.startswith("export "): + line = line[7:].strip() + if "=" not in line: + continue + + key, value = line.split("=", 1) + key = key.strip() + value = value.strip() + if not key: + continue + if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}: + value = value[1:-1] + os.environ.setdefault(key, value) + + +def refresh_config_from_env() -> None: + global EMAIL, PASSWORD, BASE_URL, OAUTH_AUTH_URL, OAUTH_CLIENT_ID, OAUTH_ACCESS_COOKIE + EMAIL = os.environ.get("BOOKER_EMAIL", "") + PASSWORD = os.environ.get("BOOKER_PASSWORD", "") + BASE_URL = os.environ.get("BOOKER_BASE_URL", "") + OAUTH_AUTH_URL = os.environ.get("BOOKER_AUTH_URL", "").rstrip("/") + OAUTH_CLIENT_ID = os.environ.get("BOOKER_OAUTH_CLIENT_ID", "") + OAUTH_ACCESS_COOKIE = os.environ.get("BOOKER_OAUTH_ACCESS_COOKIE", "") + + +def pause(seconds: float, label: str = "") -> None: + jitter = seconds * random.uniform(0.7, 1.3) + if label: + print(f" ⏳ waiting {jitter:.1f}s {label}…", end=" ", flush=True) + time.sleep(jitter) + if label: + print("done") + + +def pause_range(min_seconds: float, max_seconds: float, label: str = "") -> None: + wait = random.uniform(min_seconds, max_seconds) + if label: + print(f" ⏳ waiting {wait:.1f}s {label}…", end=" ", flush=True) + time.sleep(wait) + if label: + print("done") + + +def login(session: requests.Session) -> None: + app_url = app_url_from_base(BASE_URL) + if not app_url: + raise RuntimeError("Invalid base URL") + if not OAUTH_AUTH_URL: + raise RuntimeError("Missing BOOKER_AUTH_URL") + if not OAUTH_CLIENT_ID: + raise RuntimeError("Missing BOOKER_OAUTH_CLIENT_ID") + if not OAUTH_ACCESS_COOKIE: + raise RuntimeError("Missing BOOKER_OAUTH_ACCESS_COOKIE") + + session.headers.pop("Authorization", None) + auth_session = oauth_session_from(session) + authorize_url = oauth_authorize_url(OAUTH_AUTH_URL) + login_url = oauth_login_url(OAUTH_AUTH_URL) + + verifier = code_verifier() + state = secrets.token_urlsafe(16) + redirect_uri = f"{app_url}/auth/callback" + + authorize_resp = auth_session.get( + authorize_url, + params={ + "client_id": OAUTH_CLIENT_ID, + "redirect_uri": redirect_uri, + "response_type": "code", + "scope": "", + "state": state, + "code_challenge": code_challenge(verifier), + "code_challenge_method": "S256", + }, + allow_redirects=True, + ) + authorize_resp.raise_for_status() + + code = extract_oauth_code(authorize_resp.url, state, required=False) + if not code: + login_token_match = OAUTH_LOGIN_TOKEN_RE.search(authorize_resp.text) + if not login_token_match: + raise RuntimeError( + f"Could not find OAuth login form token. Final URL: {authorize_resp.url}" + ) + + login_resp = auth_session.post( + login_url, + data={ + "_token": login_token_match.group(1), + "email": EMAIL, + "password": PASSWORD, + }, + headers={ + "Content-Type": "application/x-www-form-urlencoded", + "Origin": OAUTH_AUTH_URL, + "Referer": authorize_resp.url, + }, + allow_redirects=True, + ) + login_resp.raise_for_status() + code = extract_oauth_code(login_resp.url, state, required=True) + + token_resp = auth_session.post( + f"{app_url}/oauth/token", + json={ + "client_id": OAUTH_CLIENT_ID, + "grant_type": "authorization_code", + "redirect_uri": redirect_uri, + "code_verifier": verifier, + "code": code, + }, + headers={ + "Origin": app_url, + "Referer": redirect_uri, + }, + ) + token_resp.raise_for_status() + + for cookie in auth_session.cookies: + session.cookies.set_cookie(cookie) + + token = get_cookie_value(session, OAUTH_ACCESS_COOKIE) + if not token: + raise RuntimeError( + "OAuth token cookie missing after token exchange. " + f"Response was: {token_resp.text[:300]}" + ) + + session.headers["Authorization"] = f"Bearer {token}" + print("✅ Logged in - OAuth session established") + pause(DELAY_BETWEEN_REQUESTS) + + +def api_post( + session: requests.Session, + path: str, + *, + payload: dict, + params: dict | None = None, +) -> requests.Response: + resp = session.post(f"{BASE_URL}{path}", params=params, json=payload) + if resp.status_code == 401: + print(" 🔐 Session expired, signing in again...") + login(session) + resp = session.post(f"{BASE_URL}{path}", params=params, json=payload) + return resp + + +def get_amenity(session: requests.Session) -> dict: + resp = api_post(session, "/resident/amenity/single", payload={"_id": AMENITY_ID}) + resp.raise_for_status() + amenity = resp.json() + print(f"✅ Amenity loaded: {amenity.get('name')}") + pause(DELAY_BETWEEN_REQUESTS) + return amenity + + +def get_profile(session: requests.Session) -> dict: + resp = api_post( + session, + "/resident/profile/me", + payload={"enablePendo": False, "enableAvatar": False}, + ) + resp.raise_for_status() + profile = resp.json() + print(f"✅ Profile loaded: {profile.get('first_name')} {profile.get('last_name')}") + pause(DELAY_BETWEEN_REQUESTS) + return profile + + +def get_enabled_dates(session: requests.Session, month: int, year: int) -> set: + resp = api_post( + session, + f"/resident/amenity/booking/{AMENITY_ID}", + params={"month": month, "year": year}, + payload={"id": AMENITY_ID, "m": month, "y": year}, + ) + resp.raise_for_status() + pause(DELAY_BETWEEN_REQUESTS) + return set(resp.json().get("enabled_dates", [])) + + +def get_slots(session: requests.Session, date_str: str, verbose: bool = False) -> dict: + resp = api_post( + session, + f"/resident/amenity/booking/{AMENITY_ID}/{date_str}", + payload={"id": AMENITY_ID, "date": date_str}, + ) + if resp.status_code != 200: + print(f" ⚠️ slot check returned {resp.status_code}, backing off {DELAY_ON_ERROR}s") + pause(DELAY_ON_ERROR) + return {} + pause(DELAY_BETWEEN_REQUESTS) + data = resp.json().get("data", []) + if verbose: + print(f" 📋 Raw slot data for {date_str}:") + for s in data: + icon = "🟢" if s.get("status") else "🔴" + print(f" {icon} {s['time']:14s} status={s.get('status')} available_slots={s.get('available_slots')}") + # status=True → available/bookable; status=False → already booked + return {s["time"]: s["status"] for s in data} + + +def book(session: requests.Session, amenity: dict, profile: dict, + date_str: str, slots: list, dry_run: bool) -> bool: + payload = { + **amenity, + "amenity_id": AMENITY_ID, + "date": date_str, + "time": slots, + "booker_first_name": profile.get("first_name", ""), + "booker_last_name": profile.get("last_name", ""), + "booker_email": profile.get("email", ""), + "booker_mobile": profile.get("mobile", ""), + "booker_phone": profile.get("phone", ""), + } + if dry_run: + print(f" 🔍 DRY RUN → {date_str}: {slots}") + pause(0.2) + return True + resp = api_post(session, "/resident/amenity/booking/save", payload=payload) + if resp.status_code == 200: + try: + msg = resp.json().get("message", "OK") + except Exception: + msg = resp.text[:80] if resp.text.strip() else "OK (empty body)" + print(f" ✅ {msg}") + pause_range(MIN_DELAY_BETWEEN_BOOKINGS, MAX_DELAY_BETWEEN_BOOKINGS, "after booking") + return True + else: + print(f" ❌ {resp.status_code}: {resp.text[:200]}") + pause(DELAY_ON_ERROR, "back-off after error") + return False + + +def weekdays_in_range(start: date, end: date, reverse: bool = False): + days = [] + d = start + while d <= end: + if d.weekday() < 5: + days.append(d) + d += timedelta(days=1) + return reversed(days) if reverse else iter(days) + + +def confirm(prompt: str) -> bool: + try: + answer = input(f"{prompt} [y/N] ").strip().lower() + except (EOFError, KeyboardInterrupt): + print() + return False + return answer in ("y", "yes") + + +def main(): + parser = argparse.ArgumentParser(description="Meeting Room 3 auto-booker") + parser.add_argument("--dry-run", action="store_true", + help="Preview without actually booking") + parser.add_argument("--yes", "-y", action="store_true", + help="Skip confirmation prompts") + parser.add_argument("--email", "-u", default=None, + help="Login email (overrides BOOKER_EMAIL env var)") + parser.add_argument("--password", "-p", default=None, + help="Login password (overrides BOOKER_PASSWORD env var)") + parser.add_argument("--base-url", "-b", default=None, + help="API base URL (overrides BOOKER_BASE_URL env var)") + args = parser.parse_args() + + load_env_file(ENV_FILE) + refresh_config_from_env() + + # Allow CLI config to override env vars + global EMAIL, PASSWORD, BASE_URL + if args.email: + EMAIL = args.email + if args.password: + PASSWORD = args.password + if args.base_url: + BASE_URL = args.base_url + + required_config = { + "BOOKER_EMAIL": EMAIL, + "BOOKER_PASSWORD": PASSWORD, + "BOOKER_BASE_URL": BASE_URL, + "BOOKER_AUTH_URL": OAUTH_AUTH_URL, + "BOOKER_OAUTH_CLIENT_ID": OAUTH_CLIENT_ID, + "BOOKER_OAUTH_ACCESS_COOKIE": OAUTH_ACCESS_COOKIE, + } + missing_config = [name for name, value in required_config.items() if not value] + if missing_config: + print("❌ Missing required configuration:") + for name in missing_config: + print(f" {name}") + print(f" Set them in {ENV_FILE}") + print(" CLI overrides exist for BOOKER_EMAIL / BOOKER_PASSWORD / BOOKER_BASE_URL") + sys.exit(1) + + app_url = app_url_from_base(BASE_URL) + if not app_url: + print("❌ Invalid base URL") + sys.exit(1) + + session = requests.Session() + session.headers.update(SESSION_HEADERS) + session.headers["Origin"] = app_url + session.headers["Referer"] = f"{app_url}/resident/amenity" + login(session) + + amenity = get_amenity(session) + profile = get_profile(session) + + # Use the platform's own booking_available_days limit instead of a hardcoded window + today = date.today() + avail_days = int(amenity.get("booking_available_days", 30)) + max_end = today + timedelta(days=avail_days) + print(f" ℹ️ Platform allows bookings up to {avail_days} days ahead (until {max_end})") + + # Ask user how many days to scan + if not args.yes: + print(f"\n📆 How far ahead do you want to scan for available slots?") + print(f" 1) Last 1 week (faster, ~{min(7, avail_days)}d)") + print(f" 2) Last 2 weeks (~{min(14, avail_days)}d)") + print(f" 3) Full {avail_days} days (slower, until {max_end})") + try: + choice = input(" Choice [1/2/3, default=3]: ").strip() or "3" + except (EOFError, KeyboardInterrupt): + choice = "3" + scan_days = {"1": 7, "2": 14}.get(choice, avail_days) + else: + scan_days = avail_days + + # Always end at the platform maximum; start is max_end - scan_days (but no earlier than today) + scan_start = max(today, max_end - timedelta(days=scan_days)) + end = max_end + print(f"\n📅 Window: {scan_start} → {end} ({(end - scan_start).days}d, newest first) | Mon–Fri | {TARGET_SLOTS[0]}–{TARGET_SLOTS[-1]}") + print(f"⏱ Delays: {DELAY_BETWEEN_REQUESTS}s between requests, " + f"{MIN_DELAY_BETWEEN_BOOKINGS:.0f}-{MAX_DELAY_BETWEEN_BOOKINGS:.0f}s random after each booking\n") + + # ── Phase 1: discovery (read-only) ──────────────────────────────────────── + print("🔍 Phase 1 — checking availability (no bookings yet)…\n") + + enabled_cache: dict[tuple, set] = {} + plan: list[tuple[date, list]] = [] # (day, slots_to_book) + + for day in weekdays_in_range(scan_start, end, reverse=True): + key = (day.month, day.year) + if key not in enabled_cache: + print(f" 📆 Fetching enabled dates for {day.strftime('%B %Y')}…") + enabled_cache[key] = get_enabled_dates(session, day.month, day.year) + + date_str = day.strftime("%Y-%m-%d") + label = f"{date_str} ({day.strftime('%a')})" + + if date_str not in enabled_cache[key]: + print(f" ⏭ {label} — date disabled by platform") + continue + + slot_avail = get_slots(session, date_str, verbose=True) + slots_to_book = [s for s in TARGET_SLOTS if slot_avail.get(s, False) is True] + taken = [s for s in TARGET_SLOTS if not slot_avail.get(s, False)] + + if not slots_to_book: + print(f" ⏭ {label} — fully booked already") + continue + + if taken: + print(f" ⚠️ {label} — {len(taken)} slot(s) taken, {len(slots_to_book)} free") + else: + print(f" ✔ {label} — {len(slots_to_book)} slots free") + + plan.append((day, slots_to_book)) + + # ── Phase 1 summary + gate ───────────────────────────────────────────── + print(f"\n{'─'*55}") + print(f"PLAN: {len(plan)} day(s) to book, " + f"up to {len(plan) * len(TARGET_SLOTS)} slot(s) total") + for day, slots in plan: + print(f" {day.strftime('%Y-%m-%d %a')} → {slots[0]} – {slots[-1]} ({len(slots)} slots)") + print(f"{'─'*55}\n") + + if not plan: + print("Nothing to book. Exiting.") + return + + if args.dry_run: + print("DRY RUN — no bookings will be made.") + return + + if not args.yes: + if not confirm(f"⚡ Proceed and book all {len(plan)} days?"): + print("Aborted.") + return + + # ── Phase 2: booking ────────────────────────────────────────────────────── + print( + f"\n🚀 Phase 2 — booking " + f"({MIN_DELAY_BETWEEN_BOOKINGS:.0f}-{MAX_DELAY_BETWEEN_BOOKINGS:.0f}s random cooldown between saves)…\n" + ) + booked, failed = 0, 0 + + for i, (day, slots) in enumerate(plan, 1): + date_str = day.strftime("%Y-%m-%d") + print(f" [{i}/{len(plan)}] {date_str} ({day.strftime('%a')}) — {len(slots)} slots") + ok = book(session, amenity, profile, date_str, slots, dry_run=False) + if ok: + booked += 1 + else: + failed += 1 + + print(f"\n{'─'*55}") + print(f"DONE: {booked} booked, {failed} failed") + + +if __name__ == "__main__": + main() diff --git a/Python/requirements.txt b/Python/requirements.txt new file mode 100644 index 0000000..f229360 --- /dev/null +++ b/Python/requirements.txt @@ -0,0 +1 @@ +requests