From 7083b379231eabb6c4cddad6efcd33389a4d5363 Mon Sep 17 00:00:00 2001 From: James Devine Date: Tue, 11 Aug 2026 20:49:42 +0100 Subject: [PATCH] feat(safeoutputs): add GitHub issue mutation family Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 132286b3-2728-4551-b357-14cf86e4362a --- AGENTS.md | 13 + README.md | 46 +- docs/safe-outputs.md | 223 +++- .../approval-summary/__tests__/index.test.ts | 25 +- .../approval-summary/__tests__/render.test.ts | 776 +++++++++++ .../ado-script/src/approval-summary/index.ts | 58 +- .../ado-script/src/approval-summary/render.ts | 567 +++++++- .../__tests__/github-client.test.ts | 249 ++++ .../__tests__/github-issue-scenarios.test.ts | 324 ++++- .../src/executor-e2e/__tests__/index.test.ts | 11 + .../src/executor-e2e/__tests__/runner.test.ts | 45 + .../src/executor-e2e/github-client.ts | 448 +++++++ scripts/ado-script/src/executor-e2e/runner.ts | 10 +- .../executor-e2e/scenarios/github-issue.ts | 695 +++++++++- src/compile/agentic_pipeline.rs | 178 ++- src/compile/common.rs | 292 +++- src/compile/types.rs | 684 ++++++++-- src/execute.rs | 97 +- src/inspect/catalog.rs | 34 +- src/mcp.rs | 244 +++- src/safe_outputs/add_build_tag.rs | 2 +- src/safe_outputs/add_github_issue_labels.rs | 575 ++++++++ src/safe_outputs/add_pr_comment.rs | 2 +- .../assign_github_issue_milestone.rs | 649 +++++++++ .../assign_github_issue_to_user.rs | 621 +++++++++ src/safe_outputs/close_github_issue.rs | 1142 ++++++++++++++++ src/safe_outputs/comment_on_github_issue.rs | 966 +++++++++++++ src/safe_outputs/comment_on_work_item.rs | 2 +- src/safe_outputs/create_branch.rs | 12 +- src/safe_outputs/create_git_tag.rs | 7 +- src/safe_outputs/create_github_issue.rs | 312 ++--- src/safe_outputs/create_pull_request.rs | 32 +- src/safe_outputs/create_wiki_page.rs | 12 +- src/safe_outputs/create_work_item.rs | 20 +- src/safe_outputs/github_api.rs | 898 +++++++++++++ src/safe_outputs/github_issue_common.rs | 1020 ++++++++++++++ src/safe_outputs/hide_github_issue_comment.rs | 1189 +++++++++++++++++ src/safe_outputs/link_github_sub_issue.rs | 726 ++++++++++ src/safe_outputs/link_work_items.rs | 5 +- src/safe_outputs/mod.rs | 59 +- src/safe_outputs/queue_build.rs | 8 +- .../remove_github_issue_labels.rs | 613 +++++++++ src/safe_outputs/reply_to_pr_comment.rs | 5 +- src/safe_outputs/resolve_pr_thread.rs | 2 +- src/safe_outputs/result.rs | 163 ++- src/safe_outputs/set_github_issue_field.rs | 937 +++++++++++++ src/safe_outputs/set_github_issue_type.rs | 420 ++++-- src/safe_outputs/submit_pr_review.rs | 16 +- .../unassign_github_issue_from_user.rs | 486 +++++++ src/safe_outputs/update_github_issue.rs | 1137 ++++++++++++++++ src/safe_outputs/update_pr.rs | 3 +- src/safe_outputs/update_wiki_page.rs | 6 +- src/safe_outputs/update_work_item.rs | 3 +- src/safe_outputs/upload_build_attachment.rs | 14 +- src/safe_outputs/upload_pipeline_artifact.rs | 28 +- .../upload_workitem_attachment.rs | 28 +- tests/compiler_tests.rs | 204 ++- 57 files changed, 16501 insertions(+), 842 deletions(-) create mode 100644 scripts/ado-script/src/executor-e2e/__tests__/github-client.test.ts create mode 100644 src/safe_outputs/add_github_issue_labels.rs create mode 100644 src/safe_outputs/assign_github_issue_milestone.rs create mode 100644 src/safe_outputs/assign_github_issue_to_user.rs create mode 100644 src/safe_outputs/close_github_issue.rs create mode 100644 src/safe_outputs/comment_on_github_issue.rs create mode 100644 src/safe_outputs/github_api.rs create mode 100644 src/safe_outputs/github_issue_common.rs create mode 100644 src/safe_outputs/hide_github_issue_comment.rs create mode 100644 src/safe_outputs/link_github_sub_issue.rs create mode 100644 src/safe_outputs/remove_github_issue_labels.rs create mode 100644 src/safe_outputs/set_github_issue_field.rs create mode 100644 src/safe_outputs/unassign_github_issue_from_user.rs create mode 100644 src/safe_outputs/update_github_issue.rs diff --git a/AGENTS.md b/AGENTS.md index 820d61cbb..dff209412 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -201,7 +201,12 @@ fail-closed and only pauses when the agent actually proposed a reviewed output. │ ├── safe_outputs/ # Safe-output MCP tool implementations (Stage 1 → NDJSON → Stage 3) │ │ ├── mod.rs │ │ ├── add_build_tag.rs +│ │ ├── add_github_issue_labels.rs │ │ ├── add_pr_comment.rs +│ │ ├── assign_github_issue_milestone.rs +│ │ ├── assign_github_issue_to_user.rs +│ │ ├── close_github_issue.rs +│ │ ├── comment_on_github_issue.rs │ │ ├── comment_on_work_item.rs │ │ ├── create_branch.rs │ │ ├── create_git_tag.rs @@ -209,17 +214,25 @@ fail-closed and only pauses when the agent actually proposed a reviewed output. │ │ ├── create_pull_request.rs │ │ ├── create_wiki_page.rs │ │ ├── create_work_item.rs +│ │ ├── github_api.rs # Shared GitHub REST/GraphQL client, endpoint derivation, pagination, and errors +│ │ ├── github_issue_common.rs # Shared repository selection, filters, issue/PR policy, and temporary-ID resolution +│ │ ├── hide_github_issue_comment.rs +│ │ ├── link_github_sub_issue.rs │ │ ├── link_work_items.rs │ │ ├── missing_data.rs │ │ ├── missing_tool.rs │ │ ├── noop.rs │ │ ├── queue_build.rs +│ │ ├── remove_github_issue_labels.rs │ │ ├── reply_to_pr_comment.rs │ │ ├── report_incomplete.rs │ │ ├── resolve_pr_thread.rs │ │ ├── result.rs +│ │ ├── set_github_issue_field.rs │ │ ├── set_github_issue_type.rs │ │ ├── submit_pr_review.rs +│ │ ├── unassign_github_issue_from_user.rs +│ │ ├── update_github_issue.rs │ │ ├── update_pr.rs │ │ ├── update_wiki_page.rs │ │ ├── update_work_item.rs diff --git a/README.md b/README.md index 4ea4ba0d4..19b8202ac 100644 --- a/README.md +++ b/README.md @@ -581,6 +581,17 @@ actions, and the executor processes them after threat analysis. | `upload-workitem-attachment` | Uploads a workspace file as an attachment to a work item | | `create-github-issue` | Creates a GitHub issue (Stage 3 only; needs a separate GitHub write token) | | `set-github-issue-type` | Sets or clears a native GitHub Issue Type on an issue | +| `comment-on-github-issue` | Comments on a GitHub issue or permitted pull request | +| `hide-github-issue-comment` | Minimizes a GitHub issue, PR, or discussion comment | +| `add-github-issue-labels` | Adds policy-approved labels to a GitHub issue or permitted PR | +| `remove-github-issue-labels` | Removes policy-approved labels from a GitHub issue | +| `close-github-issue` | Closes a GitHub issue, optionally with a comment or duplicate relationship | +| `update-github-issue` | Updates operator-enabled fields on a GitHub issue or pull request | +| `set-github-issue-field` | Sets a repository-defined GitHub issue field | +| `assign-github-issue-milestone` | Assigns an existing or policy-approved new milestone | +| `assign-github-issue-to-user` | Assigns policy-approved GitHub users | +| `unassign-github-issue-from-user` | Removes policy-approved GitHub assignees | +| `link-github-sub-issue` | Links two same-repository GitHub issues as parent and child | | `report-incomplete` | Reports that a task could not be completed | | `noop` | Reports no action was needed | | `missing-data` | Reports required data was unavailable | @@ -631,25 +642,50 @@ safe-outputs: ### Example: GitHub Issue Configuration -Unlike every other safe output, `create-github-issue` and `set-github-issue-type` -write to **GitHub**, not Azure DevOps, and only run in Stage 3 with a dedicated -GitHub write token — the Agent and Detection stages never see it: +The thirteen GitHub-qualified issue tools write to **GitHub**, not Azure +DevOps, and run only in Stage 3 with a dedicated GitHub write token — the Agent +and Detection stages never see it. Tools are configured-only; auth alone does +not expose routes. ```yaml safe-outputs: + require-approval: true create-github-issue: target-repo: octo-org/octo-repo # required unless the ADO build source is that GitHub repo + allowed-repos: [octo-org/other-repo] allowed-labels: ["agent-*", bug] require-temporary-id: true + comment-on-github-issue: + target-repo: octo-org/octo-repo + required-labels: [agent-managed] + hide-older-comments: true + pull-requests: false + add-github-issue-labels: + target-repo: octo-org/octo-repo + allowed: ["agent-*", bug] + blocked: [security] set-github-issue-type: target-repo: octo-org/octo-repo allowed: [Bug, Feature, Task] ``` Set the write token once with `ado-aw secrets set ADO_AW_GITHUB_TOKEN ` -(needs **Issues: read and write** on the target repo). See +(grant Issues, Pull requests, and Discussions write only for enabled +capabilities), or configure a shared/dedicated GitHub App; App tokens are +repository-scoped, minimum-permission, and revoked after execution. + +An agent may select `repository` only from exact `target-repo` / +`allowed-repos` values. Existing-object mutations can require all configured +labels and a title prefix before the first write. Same-run `#aw_...` temporary +IDs retain their creation repository, and every configured consumer must share +`create-github-issue`'s effective approval group. + +Most operations use REST. Comment minimization, duplicate marking, issue +fields, and sub-issues require GraphQL and may depend on the GHES version; +unsupported APIs fail explicitly rather than skipping the requested mutation. +See [the site reference](https://githubnext.github.io/ado-aw/reference/safe-outputs/#github-issue-safe-outputs) -for GitHub App auth and temporary-ID linkage between the two tools. +for the complete tool/configuration matrix, defaults, filters, and auth setup. ### Threat Detection (`threat-detection`) diff --git a/docs/safe-outputs.md b/docs/safe-outputs.md index fcb409276..c448dfd3c 100644 --- a/docs/safe-outputs.md +++ b/docs/safe-outputs.md @@ -421,10 +421,11 @@ provided by Azure Pipelines. Custom component code is trusted privileged code. ## GitHub issue safe outputs -`create-github-issue` and `set-github-issue-type` call GitHub only from Stage 3, after threat -detection. The GitHub write credential is never exposed to Agent or Detection. -The MCP routes are configured-only: GitHub auth by itself does not expose them -to the agent; each tool appears only when its own front-matter key is present. +The GitHub-qualified safe outputs call GitHub only from Stage 3, after threat +detection and any configured manual review. The GitHub write credential is +never exposed to Agent or Detection. All thirteen routes are +**configured-only**: authentication alone exposes no GitHub mutation tool; each +tool appears only when its exact front-matter key is present. ### Authentication @@ -443,8 +444,10 @@ Set it with: ado-aw secrets set ADO_AW_GITHUB_TOKEN ``` -The token needs **Issues: read and write** on the target repository. To use a -differently named secret, provide exactly one ADO macro: +The PAT needs read/write access for every enabled capability: **Issues** for +issues, **Pull requests** when a tool permits PR targets, and **Discussions** +when comment minimization permits discussion comments. To use a differently +named secret, provide exactly one ADO macro: ```yaml safe-outputs: @@ -485,8 +488,10 @@ safe-outputs: ``` Agent and Detection each mint their own explicitly read-only token. SafeOutputs -mints a separate token scoped to the configured target repository with only -`issues: write`, then revokes it after execution. Shared credentials are +mints a separate token scoped to the repositories used by that execution job, +requests only the required `issues: write`, `pull-requests: write`, and/or +`discussions: write` permissions, then revokes it after execution. Automatic +and reviewed jobs derive permissions independently. Shared credentials are rejected when the engine permission map is absent or contains a repository `write` permission. @@ -512,11 +517,42 @@ variable; it is not the PEM value or a `$(...)` macro. SafeOutputs derives the minimum permission request, so arbitrary `github-app.permissions` overrides are not accepted here. `github-app` and `github-token` are mutually exclusive. -### Target repository +### Repository selection -`target-repo` is operator-controlled. It may be omitted only when the ADO build -source provider is GitHub and `BUILD_REPOSITORY_NAME` is an `owner/repo` slug. -Azure Repos workflows must set it explicitly. +Every tool accepts operator-controlled `target-repo` and `allowed-repos`. +Agent parameters accept optional `repository` where selection is meaningful. + +1. An explicit agent `repository` must exactly equal `target-repo` or an entry + in `allowed-repos`. +2. Without an agent selection, `target-repo` is used. +3. Without either, ado-aw uses `BUILD_REPOSITORY_NAME` only for a GitHub or + GitHub Enterprise source build whose name is a valid `owner/repo` slug. + +Azure Repos workflows must therefore configure `target-repo`. Repository globs, +wildcards, and ADO runtime expressions are rejected; `allowed-repos` is an +exact operator allowlist. PAT auth may target repositories owned by different +accounts. GitHub App auth requires `target-repo` and every `allowed-repos` +entry to share one owner because one installation token is minted per job. + +`create-github-issue` supports repository selection but has no +`required-labels` or `required-title-prefix` filter because no target object +exists yet. + +### Mutation filters + +Existing-object tools support these shared fail-closed filters: + +| Field | Behavior | +|---|---| +| `required-labels` | The current target must have **all** listed labels before the first write. | +| `required-title-prefix` | The current title must start with this exact value before the first write. | +| `issues` | Permit issue targets (default `true` where the tool has an issue/PR switch). | +| `pull-requests` | Permit pull-request targets. Defaults to `false` for `comment-on-github-issue` and `add-github-issue-labels`, but `true` for `update-github-issue`; requests `pull-requests: write`. | +| `discussions` | Permit discussion comments where supported (default `false`; requests `discussions: write`). | + +Multi-call operations preflight repository policy, filters, capability +switches, and requested values before writing, so a denied later value cannot +leave a partial mutation. ### Temporary IDs and approval @@ -530,12 +566,16 @@ its real number exists: The ID format is `#aw_` plus 3-12 ASCII alphanumeric/underscore characters; the leading `#` is optional. `create-github-issue` must run first and succeed. -Duplicate, unresolved, cross-repository, or reversed references fail before an -API call. +Every tool whose `issue_number` parameter accepts a number also accepts a +same-run temporary ID. The ID retains the repository selected at creation; an +explicit `repository` on a consumer must match it. Duplicate, unresolved, +cross-repository, or reversed references fail before an API call. -When both tools are configured, they must have the same effective -`require-approval` setting so they execute in the same SafeOutputs job. A -section-level gate is the simplest form: +`create-github-issue` and **every configured temporary-ID consumer** must have +the same effective `require-approval` setting so they execute in the same +SafeOutputs process and share the in-memory ID map. Mixing automatic and +reviewed groups is rejected at compile time. A section-level gate is the +simplest form: ```yaml safe-outputs: @@ -547,6 +587,27 @@ safe-outputs: target-repo: octo-org/octo-repo ``` +The `ado-aw-safe-outputs` build-summary tab shows each proposal using the exact +tool name, repository, target IDs, requested state, labels/users/field values, +and a sanitized body excerpt. Reviewed GitHub proposals are grouped under +**Pending approval**; non-gated proposals remain under **Automatic**. + +### REST, GraphQL, and GitHub Enterprise + +Most mutations use GitHub REST. `hide-github-issue-comment`, +`close-github-issue` with `duplicate_of`, `set-github-issue-field`, and +`link-github-sub-issue` require GraphQL; `comment-on-github-issue` also uses +GraphQL when `hide-older-comments` is enabled. Numeric REST comment IDs are +resolved to GraphQL node IDs before minimization. + +For PAT auth, `github-api-url` selects the REST base. For App auth, use +`github-app.api-url`. ado-aw derives the corresponding GitHub.com or GHES +GraphQL endpoint. GitHub Enterprise availability depends on the server version +and enabled product features, especially issue fields, duplicate marking, +comment minimization, and sub-issues. Unsupported REST/GraphQL operations fail +the safe output with the sanitized API status/message; requested mutations are +never silently skipped. + ## Available Safe Output Tools ### create-github-issue @@ -557,6 +618,7 @@ Creates a GitHub issue. safe-outputs: create-github-issue: target-repo: octo-org/octo-repo + allowed-repos: [octo-org/other-repo] title-prefix: "[agent] " labels: [automation] allowed-labels: ["agent-*", bug] @@ -567,6 +629,8 @@ safe-outputs: - `target-repo` *(optional only for GitHub-backed builds)* - fixed `owner/repo` target. +- `allowed-repos` *(optional, default `[]`)* - exact additional repositories + the agent may select with `repository`. - `title-prefix` *(optional)* - prepended in Stage 3. - `labels` *(optional)* - static labels always applied. - `allowed-labels` *(optional)* - allowlist for agent labels. Empty/absent is @@ -577,7 +641,8 @@ safe-outputs: - `max` *(optional, default `1`)* - per-run creation budget. Agent parameters are `title`, `body`, optional `labels`, optional `assignees`, -and optional `temporary_id`. +optional `temporary_id`, and optional `repository`. The body receives the +stable ado-aw trace footer. ### set-github-issue-type @@ -604,7 +669,127 @@ safe-outputs: - `max` *(optional, default `5`)* - per-run update budget. Agent parameters are required `issue_number` (positive number or temporary ID) -and required `issue_type`. Pass `""` to clear the type. +and required `issue_type`, plus optional `repository`. Pass `""` to clear the +type. This tool also accepts the shared repository and mutation-filter fields. + +### GitHub mutation tool matrix + +Every row accepts `max`, `require-approval`, `target-repo`, `allowed-repos`, +`required-labels`, and `required-title-prefix` unless noted otherwise. Agent +JSON uses the snake_case parameter names below. + +| Tool | Agent parameters | Tool-specific configuration | Default `max` | +|---|---|---|---:| +| `comment-on-github-issue` | `issue_number`, `body`, optional `repository` | `hide-older-comments`, `allowed-reasons`, `issues`, `pull-requests`, `footer` | 1 | +| `hide-github-issue-comment` | `comment_id`, optional `reason`, optional `repository` | `allowed-reasons`, `discussions` | 5 | +| `add-github-issue-labels` | `issue_number`, `labels`, optional `repository` | `allowed`, `blocked`, `issues`, `pull-requests` | 5 | +| `remove-github-issue-labels` | `issue_number`, `labels`, optional `repository` | `allowed`, `blocked` | 5 | +| `close-github-issue` | `issue_number`, optional `body`, `state_reason`, `duplicate_of`, `repository` | `state-reason`, `allowed-state-reason`, `allow-body` | 1 | +| `update-github-issue` | `issue_number`, one or more of `status`/`title`/`body`/`labels`/`assignees`/`milestone`, optional body `operation`, optional `repository` | `status`, `title`, `body`, `labels`, `assignees`, `milestone`, `allowed-labels`, `footer`, `issues`, `pull-requests` | 1 | +| `set-github-issue-field` | `issue_number`, `value`, exactly one of `field_name`/`field_node_id`, optional `repository` | `allowed-fields` | 5 | +| `assign-github-issue-milestone` | `issue_number`, exactly one of `milestone_number`/`milestone_title`, optional `repository` | `allowed`, `auto-create` | 1 | +| `assign-github-issue-to-user` | `issue_number`, `assignee` or `assignees`, optional `repository` | `allowed`, `blocked`, `unassign-first` | 1 | +| `unassign-github-issue-from-user` | `issue_number`, `assignee` or `assignees`, optional `repository` | `allowed`, `blocked` | 1 | +| `link-github-sub-issue` | `parent_issue_number`, `sub_issue_number`, optional `repository` | `parent-required-labels`, `parent-title-prefix`, `sub-required-labels`, `sub-title-prefix` | 5 | + +#### Comments and comment minimization + +`comment-on-github-issue` posts a comment with a stable hidden ado-aw pipeline +marker and, by default, a trace footer (`footer: false` disables the visible +footer). `hide-older-comments: true` first minimizes older comments carrying +the same pipeline marker **and** authored by the authenticated actor. It never +trusts agent-authored marker text. `allowed-reasons` restricts minimization +reasons; supported reasons are `SPAM`, `ABUSE`, `OFF_TOPIC`, `OUTDATED` +(default), `RESOLVED`, and `LOW_QUALITY`. + +`hide-github-issue-comment` accepts either a numeric REST comment ID or a +GraphQL node ID. It resolves the owning issue, PR, or discussion and applies +repository/filter policy before calling `minimizeComment`. An omitted `reason` +uses `OUTDATED`. + +#### Labels + +Both label tools use gh-aw-compatible glob patterns. `blocked` is evaluated +first and always wins. When `allowed` is omitted, remaining labels are +unrestricted; an explicit allowlist narrows them. Removal treats a label that +is already absent as success. + +#### Closing and updating + +`close-github-issue` defaults to `completed`; the other state reasons are +`not_planned` and `duplicate`. Configure a fixed `state-reason`, or +`allowed-state-reason` to let the agent choose from a bounded set. +`allow-body` defaults to `true`; when false, no closing comment is posted. +`duplicate_of` is validated against repository policy before any comment or +close, then creates the native duplicate relationship. Already-closed targets +are idempotent success. + +`update-github-issue` requires at least one of `status`, `title`, `body`, +`labels`, `assignees`, or `milestone`. Every mutable field is independently +disabled by default: the operator must set the matching `status`, `title`, +`body`, `labels`, `assignees`, or `milestone` configuration flag to `true`. +For example, enabling all fields explicitly looks like: + +```yaml +safe-outputs: + update-github-issue: + target-repo: octo-org/octo-repo + status: true + title: true + body: true + labels: true + assignees: true + milestone: true + allowed-labels: [bug, "agent-*"] +``` + +`allowed-labels` additionally bounds replacement labels when non-empty; an +empty or omitted allowlist permits any label. Both issue and pull-request +targets are permitted by default (`issues: true` and `pull-requests: true`). +Set either switch to `false` to restrict the target kind; at least one must +remain enabled. + +Body `operation` modes are: + +- `append` *(default)* - add the new content after the current body, separated + by a horizontal rule. +- `prepend` - add the new content before the current body, separated by a + horizontal rule. +- `replace` - replace the entire current body. +- `replace-island` - replace only the single ado-aw status island for the + current pipeline definition; missing, duplicate, or out-of-order markers + fail the update. + +Body updates include the standard trace footer by default; set `footer: false` +to omit it. Status is `open` or `closed`. `labels` and `assignees` replace +their complete existing lists, and `milestone` selects an existing milestone +by positive number. All requested changes are preflighted before the first +write. + +#### Fields, milestones, and assignees + +`set-github-issue-field` rejects built-in fields and limits repository-defined +fields with `allowed-fields`. It discovers field metadata, then coerces +single-select, number, date, or text values. Repository/API versions without +the issue-field GraphQL feature fail explicitly. + +`assign-github-issue-milestone` resolves milestones by number or exact title +with pagination. `allowed` restricts titles. With `auto-create: true`, a +missing allowed milestone is created before assignment; otherwise the +assignment fails without creating it. + +Assignment tools accept one `assignee` or an `assignees` list, deduplicate +usernames, and apply `blocked` before `allowed` glob policy. Setting +`unassign-first: true` clears existing assignees before adding the approved +set. Removing an already-absent assignee is idempotent success. + +#### Sub-issues + +`link-github-sub-issue` requires distinct parent and child issues in the same +repository. Both targets are resolved and checked against their respective +label/title filters before the GraphQL mutation. An existing parent +relationship is handled idempotently; a child already linked to a different +parent fails without changing either issue. ### comment-on-work-item Adds a comment to an existing Azure DevOps work item. This is the ADO equivalent of gh-aw's `add-comment` tool. diff --git a/scripts/ado-script/src/approval-summary/__tests__/index.test.ts b/scripts/ado-script/src/approval-summary/__tests__/index.test.ts index eb874f241..7d8c9bc1d 100644 --- a/scripts/ado-script/src/approval-summary/__tests__/index.test.ts +++ b/scripts/ado-script/src/approval-summary/__tests__/index.test.ts @@ -3,7 +3,7 @@ import { mkdtempSync, readFileSync, writeFileSync, rmSync, existsSync } from "no import { tmpdir } from "node:os"; import { join } from "node:path"; -import { main, parseReviewed } from "../index.js"; +import { main, parseRepositoryPolicies, parseReviewed } from "../index.js"; const dirs: string[] = []; function freshDir(): string { @@ -22,6 +22,29 @@ describe("parseReviewed", () => { expect([...set].sort()).toEqual(["add-pr-comment", "create-pull-request"]); }); + describe("parseRepositoryPolicies", () => { + it("accepts compiler policy JSON and ignores malformed entries", () => { + const policies = parseRepositoryPolicies( + JSON.stringify({ + "create-github-issue": { + targetRepo: "octo/default", + allowedRepos: ["octo/other", 7], + }, + bad: "agent text", + }), + ); + expect(policies.get("create-github-issue")).toEqual({ + targetRepo: "octo/default", + allowedRepos: ["octo/other"], + }); + expect(policies.has("bad")).toBe(false); + }); + + it("fails closed for invalid JSON", () => { + expect(parseRepositoryPolicies("{ hostile").size).toBe(0); + }); + }); + it("does not split on commas (a comma may appear in a YAML map key)", () => { const set = parseReviewed("weird,tool-name"); expect([...set]).toEqual(["weird,tool-name"]); diff --git a/scripts/ado-script/src/approval-summary/__tests__/render.test.ts b/scripts/ado-script/src/approval-summary/__tests__/render.test.ts index e45eb3491..e01e3f522 100644 --- a/scripts/ado-script/src/approval-summary/__tests__/render.test.ts +++ b/scripts/ado-script/src/approval-summary/__tests__/render.test.ts @@ -7,12 +7,55 @@ import { sanitizeBlock, sanitizeInline, type Proposal, + type TrustedRepositoryContext, } from "../render.js"; function ndjson(...records: Record[]): string { return records.map((r) => JSON.stringify(r)).join("\n") + "\n"; } +function repositoryContext( + tool: string, + targetRepo = "octo-org/octo-repo", + allowedRepos: string[] = [], +): TrustedRepositoryContext { + return { + policies: new Map([ + [tool, { targetRepo, allowedRepos }], + ]), + currentRepository: "octo-org/current", + currentProvider: "GitHub", + githubApiUrl: "https://api.github.com", + }; +} + +function repositoryRows(markdown: string): string[] { + return markdown + .split("\n") + .filter((line) => line.startsWith("| Repository |")); +} + +function linkRepositoryContext(): TrustedRepositoryContext { + const context = repositoryContext("create-github-issue"); + context.policies = new Map([ + [ + "create-github-issue", + { + targetRepo: "octo/default", + allowedRepos: ["octo/alternate"], + }, + ], + [ + "link-github-sub-issue", + { + targetRepo: "octo/default", + allowedRepos: ["octo/alternate"], + }, + ], + ]); + return context; +} + describe("parseProposals", () => { it("parses one proposal per non-blank line with a string name", () => { const text = ndjson( @@ -179,6 +222,40 @@ describe("renderSummary — per-tool detail", () => { expect(md).not.toContain("obj"); }); + it("renders markers for present empty values but omits absent fields", () => { + const md = renderSummary( + parseProposals( + ndjson({ + name: "update-github-issue", + issue_number: 42, + title: "", + labels: [], + assignees: {}, + body: "", + }), + ), + new Set(), + repositoryContext("update-github-issue"), + ); + expect(md).toContain("| Title | <empty string> |"); + expect(md).toContain("| Labels | <empty array> |"); + expect(md).toContain("| Assignees | <empty object> |"); + expect(md).toContain("<empty string>"); + expect(md).not.toContain("| Status |"); + expect(md).not.toContain("| Milestone |"); + }); + + it("renders empty arrays and objects for generic tools", () => { + const md = renderSummary( + parseProposals( + ndjson({ name: "future-tool", empty_array: [], empty_object: {} }), + ), + new Set(), + ); + expect(md).toContain("| empty\\_array | <empty array> |"); + expect(md).toContain("| empty\\_object | <empty object> |"); + }); + it("surfaces diagnostic-tool free-text in a fenced body", () => { const md = renderSummary( parseProposals( @@ -204,6 +281,156 @@ describe("renderSummary — per-tool detail", () => { // missing-data shows the data-type field + reason body. expect(md).toContain("| Data type | schema |"); }); + + it.each([ + { + name: "create-github-issue", + record: { + title: "New issue", + repository: "octo-org/octo-repo", + temporary_id: "#aw_new1", + body: "Issue body", + }, + expected: [ + "Create GitHub issue", + "| Temporary ID | \\#aw\\_new1 |", + "Issue body", + ], + }, + { + name: "set-github-issue-type", + record: { + issue_number: "#aw_new1", + issue_type: "Bug", + repository: "octo-org/octo-repo", + }, + expected: ["Set GitHub issue type", "| Type | Bug |"], + }, + { + name: "comment-on-github-issue", + record: { + issue_number: 42, + repository: "octo-org/octo-repo", + body: "Status update", + }, + expected: ["Comment on GitHub issue", "| Issue | 42 |", "Status update"], + }, + { + name: "hide-github-issue-comment", + record: { + comment_id: 99, + reason: "OUTDATED", + repository: "octo-org/octo-repo", + }, + expected: ["Hide GitHub issue comment", "| Reason | OUTDATED |"], + }, + { + name: "add-github-issue-labels", + record: { + issue_number: 42, + labels: ["bug", "triage"], + repository: "octo-org/octo-repo", + }, + expected: ["Add GitHub issue labels", "| Labels | bug, triage |"], + }, + { + name: "remove-github-issue-labels", + record: { + issue_number: 42, + labels: ["stale"], + repository: "octo-org/octo-repo", + }, + expected: ["Remove GitHub issue labels", "| Labels | stale |"], + }, + { + name: "close-github-issue", + record: { + issue_number: 42, + state_reason: "duplicate", + duplicate_of: 7, + repository: "octo-org/octo-repo", + body: "Closing note", + }, + expected: ["Close GitHub issue", "| Duplicate of | 7 |", "Closing note"], + }, + { + name: "update-github-issue", + record: { + issue_number: 42, + status: "closed", + operation: "replace-island", + repository: "octo-org/octo-repo", + body: "Updated status block", + }, + expected: [ + "Update GitHub issue", + "| Operation | replace\\-island |", + "Updated status block", + ], + }, + { + name: "set-github-issue-field", + record: { + issue_number: 42, + field_name: "Priority", + value: "High", + repository: "octo-org/octo-repo", + }, + expected: [ + "Set GitHub issue field", + "| Field | Priority |", + "| Value | High |", + ], + }, + { + name: "assign-github-issue-milestone", + record: { + issue_number: 42, + milestone_title: "v1", + repository: "octo-org/octo-repo", + }, + expected: ["Assign GitHub issue milestone", "| Milestone | v1 |"], + }, + { + name: "assign-github-issue-to-user", + record: { + issue_number: 42, + assignees: ["octocat", "hubot"], + repository: "octo-org/octo-repo", + }, + expected: ["Assign GitHub issue to user", "| Assignees | octocat, hubot |"], + }, + { + name: "unassign-github-issue-from-user", + record: { + issue_number: 42, + assignee: "octocat", + repository: "octo-org/octo-repo", + }, + expected: ["Unassign GitHub issue from user", "| Assignee | octocat |"], + }, + { + name: "link-github-sub-issue", + record: { + parent_issue_number: 42, + sub_issue_number: "#aw_sub1", + repository: "octo-org/octo-repo", + }, + expected: [ + "Link GitHub sub\\-issue", + "| Parent issue | 42 |", + "| Sub\\-issue | \\#aw\\_sub1 |", + ], + }, + ])("renders tailored details for $name", ({ name, record, expected }) => { + const md = renderSummary( + parseProposals(ndjson({ name, ...record })), + new Set(), + repositoryContext(name), + ); + expect(md).toContain(`\`${name}\``); + for (const value of expected) expect(md).toContain(value); + }); }); describe("renderSummary — security", () => { @@ -248,4 +475,553 @@ describe("renderSummary — security", () => { // the hostile ``` was neutralised. expect(after.slice(0, closeFence)).not.toContain("```"); }); + + it("never renders a hostile agent repository as the effective repository", () => { + const hostile = "x | \n```\n## Approved"; + const md = renderSummary( + parseProposals( + ndjson({ + name: "comment-on-github-issue", + issue_number: hostile, + repository: hostile, + body: hostile, + }), + ), + new Set(["comment-on-github-issue"]), + repositoryContext("comment-on-github-issue"), + ); + const issueRow = md.split("\n").find((line) => line.startsWith("| Issue |")); + const repositoryRow = md + .split("\n") + .find((line) => line.startsWith("| Repository |")); + expect(issueRow).toContain("\\|"); + expect(issueRow).toContain("<script>"); + expect(repositoryRow).toContain( + "<unresolved: requested repository is outside operator policy>", + ); + expect(repositoryRow).not.toContain("script"); + const bodyStart = md.indexOf("```text"); + const after = md.slice(bodyStart + "```text".length); + const closeFence = after.indexOf("```"); + expect(after.slice(0, closeFence)).not.toContain("```"); + }); + + it("uses target-repo when the proposal omits repository", () => { + const md = renderSummary( + parseProposals( + ndjson({ name: "create-github-issue", title: "Trusted target" }), + ), + new Set(), + repositoryContext("create-github-issue", "trusted/default"), + ); + expect(md).toContain("| Repository | trusted/default |"); + }); + + it("uses the trusted current GitHub repository when target-repo is absent", () => { + const context = repositoryContext("create-github-issue"); + context.policies = new Map([ + ["create-github-issue", { allowedRepos: [] }], + ]); + const md = renderSummary( + parseProposals( + ndjson({ name: "create-github-issue", title: "Current target" }), + ), + new Set(), + context, + ); + expect(md).toContain("| Repository | octo\\-org/current |"); + }); + + it("reports current-repository fallback as unresolved for non-GitHub sources", () => { + const context = repositoryContext("create-github-issue"); + context.policies = new Map([ + ["create-github-issue", { allowedRepos: [] }], + ]); + context.currentProvider = "TfsGit"; + const md = renderSummary( + parseProposals( + ndjson({ name: "create-github-issue", title: "No target" }), + ), + new Set(), + context, + ); + expect(md).toContain( + "<unresolved: configure target\\-repo for this source>", + ); + expect(md).not.toContain("octo\\-org/current"); + }); + + it("uses a preceding create proposal's allowed alternate repository for temporary-ID consumers", () => { + const context = repositoryContext("create-github-issue"); + context.policies = new Map([ + [ + "create-github-issue", + { + targetRepo: "octo/default", + allowedRepos: ["octo/alternate"], + }, + ], + [ + "set-github-issue-type", + { + targetRepo: "octo/default", + allowedRepos: ["octo/alternate"], + }, + ], + ]); + const md = renderSummary( + parseProposals( + ndjson( + { + name: "create-github-issue", + title: "Alternate repository", + repository: "octo/alternate", + temporary_id: "#aw_alt1", + }, + { + name: "set-github-issue-type", + issue_number: "#aw_alt1", + issue_type: "Bug", + }, + ), + ), + new Set(["set-github-issue-type"]), + context, + ); + expect(repositoryRows(md)).toEqual([ + "| Repository | octo/alternate |", + "| Repository | octo/alternate |", + ]); + }); + + it("maps temporary-ID consumers to the preceding create proposal's default target", () => { + const context = repositoryContext("create-github-issue"); + context.policies = new Map([ + [ + "create-github-issue", + { targetRepo: "octo/default", allowedRepos: [] }, + ], + [ + "comment-on-github-issue", + { targetRepo: "octo/default", allowedRepos: [] }, + ], + ]); + const md = renderSummary( + parseProposals( + ndjson( + { + name: "create-github-issue", + title: "Default repository", + temporary_id: "#aw_def1", + }, + { + name: "comment-on-github-issue", + issue_number: "#aw_def1", + body: "A follow-up comment", + }, + ), + ), + new Set(), + context, + ); + expect(repositoryRows(md)).toEqual([ + "| Repository | octo/default |", + "| Repository | octo/default |", + ]); + }); + + it("does not resolve a temporary repository from a later create proposal", () => { + const context = repositoryContext("create-github-issue"); + context.policies = new Map([ + [ + "create-github-issue", + { targetRepo: "octo/default", allowedRepos: [] }, + ], + [ + "set-github-issue-type", + { targetRepo: "octo/default", allowedRepos: [] }, + ], + ]); + const md = renderSummary( + parseProposals( + ndjson( + { + name: "set-github-issue-type", + issue_number: "#aw_late1", + issue_type: "Bug", + }, + { + name: "create-github-issue", + title: "Created too late", + temporary_id: "#aw_late1", + }, + ), + ), + new Set(), + context, + ); + expect(repositoryRows(md)).toEqual([ + "| Repository | <unresolved: temporary repository not established by a preceding create\\-github\\-issue> |", + "| Repository | octo/default |", + ]); + }); + + it("does not let hostile create data establish a temporary repository", () => { + const hostile = "octo/evil | "; + const context = repositoryContext("create-github-issue"); + context.policies = new Map([ + [ + "create-github-issue", + { + targetRepo: "octo/default", + allowedRepos: ["octo/alternate"], + }, + ], + [ + "comment-on-github-issue", + { + targetRepo: "octo/default", + allowedRepos: ["octo/alternate"], + }, + ], + ]); + const md = renderSummary( + parseProposals( + ndjson( + { + name: "create-github-issue", + title: "Hostile repository", + repository: hostile, + temporary_id: "#aw_bad1", + }, + { + name: "comment-on-github-issue", + issue_number: "#aw_bad1", + body: "Follow-up", + }, + ), + ), + new Set(), + context, + ); + expect(repositoryRows(md)).toEqual([ + "| Repository | <unresolved: requested repository is outside operator policy> |", + "| Repository | <unresolved: temporary repository not established by a preceding create\\-github\\-issue> |", + ]); + expect(repositoryRows(md).join("\n")).not.toContain("script"); + expect(repositoryRows(md).join("\n")).not.toContain("octo/evil"); + }); + + it("renders hostile temporary-like references as explicitly unresolved", () => { + const context = repositoryContext( + "set-github-issue-type", + "octo/default", + ); + const md = renderSummary( + parseProposals( + ndjson({ + name: "set-github-issue-type", + issue_number: "#aw_bad", + expected: "<unresolved: invalid sub\\-issue link reference>", + }, + { + label: "missing preceding creation", + parent_issue_number: "#aw_none1", + expected: + "<unresolved: temporary repository not established by a preceding create\\-github\\-issue>", + }, + ])( + "renders an explicitly unresolved repository for a $label", + ({ parent_issue_number, expected }) => { + const md = renderSummary( + parseProposals( + ndjson({ + name: "link-github-sub-issue", + parent_issue_number, + sub_issue_number: 42, + }), + ), + new Set(), + linkRepositoryContext(), + ); + expect(repositoryRows(md)).toEqual([ + `| Repository | ${expected} |`, + ]); + expect(repositoryRows(md).join("\n")).not.toContain("script"); + }, + ); }); diff --git a/scripts/ado-script/src/approval-summary/index.ts b/scripts/ado-script/src/approval-summary/index.ts index 4485b7ae4..8ea05c972 100644 --- a/scripts/ado-script/src/approval-summary/index.ts +++ b/scripts/ado-script/src/approval-summary/index.ts @@ -22,6 +22,11 @@ * newline, not comma, because a comma can legally * appear in a YAML map key — see the Rust * `safe_outputs_summary_step` doc comment) + * - AW_GITHUB_REPOSITORY_POLICIES compiler-resolved GitHub repository policy + * JSON keyed by tool name + * - AW_CURRENT_REPOSITORY / AW_CURRENT_REPOSITORY_PROVIDER trusted ADO build + * metadata used only for GitHub-source fallback + * - AW_GITHUB_API_URL operator-resolved GitHub API URL * * Failure policy: best-effort. Any error is logged as a warning and the * program exits 0 — rendering the summary must never fail the build or block @@ -31,7 +36,12 @@ import { readFileSync, writeFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { logWarning, uploadSummary } from "../shared/vso-logger.js"; -import { parseProposals, renderSummary } from "./render.js"; +import { + parseProposals, + renderSummary, + type GithubRepositoryPolicy, + type TrustedRepositoryContext, +} from "./render.js"; /** * Parse the reviewed-tool list (newline-delimited — see the compiler's @@ -48,6 +58,44 @@ export function parseReviewed(value: string | undefined): Set { return out; } +export function parseRepositoryPolicies( + value: string | undefined, +): Map { + const policies = new Map(); + if (!value) return policies; + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + return policies; + } + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + return policies; + } + for (const [tool, rawPolicy] of Object.entries(parsed)) { + if ( + rawPolicy === null || + typeof rawPolicy !== "object" || + Array.isArray(rawPolicy) + ) { + continue; + } + const candidate = rawPolicy as Record; + const targetRepo = + typeof candidate.targetRepo === "string" + ? candidate.targetRepo + : undefined; + const allowedRepos = Array.isArray(candidate.allowedRepos) + ? candidate.allowedRepos.filter( + (repository): repository is string => + typeof repository === "string", + ) + : []; + policies.set(tool, { targetRepo, allowedRepos }); + } + return policies; +} + export function main(env: NodeJS.ProcessEnv = process.env): number { const ndjsonPath = env.AW_SAFE_OUTPUTS_NDJSON ?? ""; const outPath = env.AW_APPROVAL_SUMMARY_OUT ?? ""; @@ -77,7 +125,13 @@ export function main(env: NodeJS.ProcessEnv = process.env): number { } const reviewed = parseReviewed(env.AW_REVIEWED_TOOLS); - const markdown = renderSummary(proposals, reviewed); + const repositoryContext: TrustedRepositoryContext = { + policies: parseRepositoryPolicies(env.AW_GITHUB_REPOSITORY_POLICIES), + currentRepository: env.AW_CURRENT_REPOSITORY, + currentProvider: env.AW_CURRENT_REPOSITORY_PROVIDER, + githubApiUrl: env.AW_GITHUB_API_URL, + }; + const markdown = renderSummary(proposals, reviewed, repositoryContext); if (markdown.length === 0) { return 0; } diff --git a/scripts/ado-script/src/approval-summary/render.ts b/scripts/ado-script/src/approval-summary/render.ts index 59593eb5a..720bb7cb8 100644 --- a/scripts/ado-script/src/approval-summary/render.ts +++ b/scripts/ado-script/src/approval-summary/render.ts @@ -39,6 +39,30 @@ interface ToolSpec { fields: FieldSpec[]; /** Optional field whose (potentially long) value is shown as a body excerpt. */ body?: string; + /** Repository is resolved from compiler-provided policy, never agent text. */ + githubRepository?: boolean; +} + +export interface GithubRepositoryPolicy { + targetRepo?: string; + allowedRepos: readonly string[]; +} + +export interface TrustedRepositoryContext { + policies: ReadonlyMap; + currentRepository?: string; + currentProvider?: string; + githubApiUrl?: string; +} + +interface RepositoryResolution { + value: string; + resolved: boolean; +} + +interface TemporaryReference { + canonical?: string; + invalid: boolean; } /** Maximum characters of a body excerpt before truncation. */ @@ -135,15 +159,134 @@ const TOOL_SPECS: Record = { }, "create-github-issue": { title: "Create GitHub issue", - fields: [{ label: "Title", key: "title" }], + fields: [ + { label: "Title", key: "title" }, + { label: "Repository", key: "repository" }, + { label: "Temporary ID", key: "temporary_id" }, + ], body: "body", + githubRepository: true, }, "set-github-issue-type": { title: "Set GitHub issue type", fields: [ { label: "Issue", key: "issue_number" }, { label: "Type", key: "issue_type" }, + { label: "Repository", key: "repository" }, + ], + githubRepository: true, + }, + "comment-on-github-issue": { + title: "Comment on GitHub issue", + fields: [ + { label: "Issue", key: "issue_number" }, + { label: "Repository", key: "repository" }, + ], + body: "body", + githubRepository: true, + }, + "hide-github-issue-comment": { + title: "Hide GitHub issue comment", + fields: [ + { label: "Comment", key: "comment_id" }, + { label: "Reason", key: "reason" }, + { label: "Repository", key: "repository" }, + ], + githubRepository: true, + }, + "add-github-issue-labels": { + title: "Add GitHub issue labels", + fields: [ + { label: "Issue", key: "issue_number" }, + { label: "Labels", key: "labels" }, + { label: "Repository", key: "repository" }, + ], + githubRepository: true, + }, + "remove-github-issue-labels": { + title: "Remove GitHub issue labels", + fields: [ + { label: "Issue", key: "issue_number" }, + { label: "Labels", key: "labels" }, + { label: "Repository", key: "repository" }, + ], + githubRepository: true, + }, + "close-github-issue": { + title: "Close GitHub issue", + fields: [ + { label: "Issue", key: "issue_number" }, + { label: "State reason", key: "state_reason" }, + { label: "Duplicate of", key: "duplicate_of" }, + { label: "Repository", key: "repository" }, + ], + body: "body", + githubRepository: true, + }, + "update-github-issue": { + title: "Update GitHub issue", + fields: [ + { label: "Issue", key: "issue_number" }, + { label: "Status", key: "status" }, + { label: "Title", key: "title" }, + { label: "Operation", key: "operation" }, + { label: "Labels", key: "labels" }, + { label: "Assignees", key: "assignees" }, + { label: "Milestone", key: "milestone" }, + { label: "Repository", key: "repository" }, + ], + body: "body", + githubRepository: true, + }, + "set-github-issue-field": { + title: "Set GitHub issue field", + fields: [ + { label: "Issue", key: "issue_number" }, + { label: "Field", key: "field_name" }, + { label: "Field node ID", key: "field_node_id" }, + { label: "Value", key: "value" }, + { label: "Repository", key: "repository" }, + ], + githubRepository: true, + }, + "assign-github-issue-milestone": { + title: "Assign GitHub issue milestone", + fields: [ + { label: "Issue", key: "issue_number" }, + { label: "Milestone", key: "milestone_title" }, + { label: "Milestone number", key: "milestone_number" }, + { label: "Repository", key: "repository" }, + ], + githubRepository: true, + }, + "assign-github-issue-to-user": { + title: "Assign GitHub issue to user", + fields: [ + { label: "Issue", key: "issue_number" }, + { label: "Assignee", key: "assignee" }, + { label: "Assignees", key: "assignees" }, + { label: "Repository", key: "repository" }, + ], + githubRepository: true, + }, + "unassign-github-issue-from-user": { + title: "Unassign GitHub issue from user", + fields: [ + { label: "Issue", key: "issue_number" }, + { label: "Assignee", key: "assignee" }, + { label: "Assignees", key: "assignees" }, + { label: "Repository", key: "repository" }, + ], + githubRepository: true, + }, + "link-github-sub-issue": { + title: "Link GitHub sub-issue", + fields: [ + { label: "Parent issue", key: "parent_issue_number" }, + { label: "Sub-issue", key: "sub_issue_number" }, + { label: "Repository", key: "repository" }, ], + githubRepository: true, }, "create-wiki-page": { title: "Create wiki page", @@ -253,13 +396,358 @@ function genericFields(record: Record): FieldSpec[] { return ( typeof v === "string" || typeof v === "number" || - typeof v === "boolean" + typeof v === "boolean" || + isExplicitEmpty(v) ); }) .sort() .map((k) => ({ label: k, key: k })); } +function isExplicitEmpty(value: unknown): boolean { + if (value === null) return true; + if (typeof value === "string") return value.length === 0; + if (Array.isArray(value)) return value.length === 0; + return ( + typeof value === "object" && + value !== null && + Object.keys(value as Record).length === 0 + ); +} + +function emptyValueMarker(value: unknown): string | undefined { + if (value === null) return ""; + if (typeof value === "string" && value.length === 0) return ""; + if (Array.isArray(value) && value.length === 0) return ""; + if ( + typeof value === "object" && + value !== null && + Object.keys(value as Record).length === 0 + ) { + return ""; + } + return undefined; +} + +function renderInlineValue(value: unknown): string { + const marker = emptyValueMarker(value); + if (marker !== undefined) return sanitizeInline(marker); + const rendered = sanitizeInline(value); + return rendered.length > 0 ? rendered : sanitizeInline(""); +} + +function currentRepository( + context: TrustedRepositoryContext, +): RepositoryResolution { + const provider = context.currentProvider?.toLowerCase(); + const repository = context.currentRepository; + const githubEnterpriseConfigured = + provider === "githubenterprise" && + context.githubApiUrl !== undefined && + context.githubApiUrl !== "https://api.github.com"; + if ( + repository && + !repository.startsWith("$(") && + (provider === "github" || githubEnterpriseConfigured) + ) { + return { value: repository, resolved: true }; + } + return { + value: "", + resolved: false, + }; +} + +function repositoryFromPolicy( + proposal: Proposal, + context: TrustedRepositoryContext | undefined, +): RepositoryResolution { + const policy = context?.policies.get(proposal.name); + if (!context || !policy) { + return { + value: "", + resolved: false, + }; + } + + const hasRequested = Object.prototype.hasOwnProperty.call( + proposal.record, + "repository", + ); + const requested = proposal.record.repository; + if (hasRequested && requested !== null && requested !== undefined) { + if (typeof requested !== "string" || requested.length === 0) { + return { + value: "", + resolved: false, + }; + } + const configured = [ + ...(policy.targetRepo ? [policy.targetRepo] : []), + ...policy.allowedRepos, + ]; + const matched = configured.find( + (repository) => repository.toLowerCase() === requested.toLowerCase(), + ); + if (matched) return { value: matched, resolved: true }; + + const current = currentRepository(context); + if ( + !policy.targetRepo && + current.resolved && + current.value.toLowerCase() === requested.toLowerCase() + ) { + return current; + } + return { + value: "", + resolved: false, + }; + } + + if (policy.targetRepo) { + return { value: policy.targetRepo, resolved: true }; + } + return currentRepository(context); +} + +function temporaryReference(value: unknown): TemporaryReference | undefined { + if (typeof value !== "string") return undefined; + const bare = value.startsWith("#") ? value.slice(1) : value; + if (!bare.startsWith("aw_")) return undefined; + const suffix = bare.slice(3); + if ( + !(suffix.length >= 3 && suffix.length <= 12) || + !/^[A-Za-z0-9_]+$/.test(suffix) + ) { + return { invalid: true }; + } + return { canonical: `#${bare}`, invalid: false }; +} + +function proposalTemporaryReferences( + proposal: Proposal, +): TemporaryReference[] { + const keys = ["issue_number"]; + const references: TemporaryReference[] = []; + for (const key of keys) { + const reference = temporaryReference(proposal.record[key]); + if (reference) references.push(reference); + } + return references; +} + +function trustedTemporaryRepository( + proposal: Proposal, + context: TrustedRepositoryContext | undefined, + temporaryRepository: string, +): RepositoryResolution { + const policy = context?.policies.get(proposal.name); + if (!context || !policy) { + return { + value: "", + resolved: false, + }; + } + + if ( + Object.prototype.hasOwnProperty.call(proposal.record, "repository") && + proposal.record.repository !== null && + proposal.record.repository !== undefined + ) { + const requested = proposal.record.repository; + if ( + typeof requested !== "string" || + requested.toLowerCase() !== temporaryRepository.toLowerCase() + ) { + return { + value: + "", + resolved: false, + }; + } + } + + const configured = [ + ...(policy.targetRepo ? [policy.targetRepo] : []), + ...policy.allowedRepos, + ]; + const matched = configured.find( + (repository) => + repository.toLowerCase() === temporaryRepository.toLowerCase(), + ); + if (matched) return { value: matched, resolved: true }; + + const current = currentRepository(context); + if ( + !policy.targetRepo && + current.resolved && + current.value.toLowerCase() === temporaryRepository.toLowerCase() + ) { + return current; + } + return { + value: "", + resolved: false, + }; +} + +function linkIssueReference( + value: unknown, +): + | { kind: "numeric" } + | { kind: "temporary"; reference: TemporaryReference } + | { kind: "invalid" } { + if (typeof value === "number" && Number.isSafeInteger(value) && value > 0) { + return { kind: "numeric" }; + } + if (typeof value === "string" && /^[0-9]+$/.test(value)) { + try { + const number = BigInt(value); + if (number > 0n && number <= 18_446_744_073_709_551_615n) { + return { kind: "numeric" }; + } + } catch { + return { kind: "invalid" }; + } + } + const temporary = temporaryReference(value); + if (temporary && !temporary.invalid) { + return { kind: "temporary", reference: temporary }; + } + return { kind: "invalid" }; +} + +function repositoryFromLinkReferences( + proposal: Proposal, + context: TrustedRepositoryContext | undefined, + repositories: ReadonlyMap, +): RepositoryResolution | undefined { + const parent = linkIssueReference(proposal.record.parent_issue_number); + const child = linkIssueReference(proposal.record.sub_issue_number); + if (parent.kind === "invalid" || child.kind === "invalid") { + return { + value: "", + resolved: false, + }; + } + + const temporaryReferences = [parent, child].filter( + ( + reference, + ): reference is { kind: "temporary"; reference: TemporaryReference } => + reference.kind === "temporary", + ); + if (temporaryReferences.length === 0) return undefined; + + const resolved = temporaryReferences.map(({ reference }) => + repositories.get(reference.canonical!), + ); + if (resolved.some((repository) => repository === undefined)) { + return { + value: + "", + resolved: false, + }; + } + const temporaryRepository = resolved[0]!; + if ( + resolved.some( + (repository) => + repository!.toLowerCase() !== temporaryRepository.toLowerCase(), + ) + ) { + return { + value: + "", + resolved: false, + }; + } + + const trustedTemporary = trustedTemporaryRepository( + proposal, + context, + temporaryRepository, + ); + if (!trustedTemporary.resolved) return trustedTemporary; + + if (temporaryReferences.length === 1) { + const numericRepository = repositoryFromPolicy(proposal, context); + if (!numericRepository.resolved) return numericRepository; + if ( + numericRepository.value.toLowerCase() !== + trustedTemporary.value.toLowerCase() + ) { + return { + value: + "", + resolved: false, + }; + } + } + return trustedTemporary; +} + +function repositoryFromTemporary( + proposal: Proposal, + context: TrustedRepositoryContext | undefined, + repositories: ReadonlyMap, +): RepositoryResolution | undefined { + if (proposal.name === "link-github-sub-issue") { + return repositoryFromLinkReferences(proposal, context, repositories); + } + + const references = proposalTemporaryReferences(proposal); + if (references.length === 0) return undefined; + if (references.some((reference) => reference.invalid)) { + return { + value: "", + resolved: false, + }; + } + + const resolved = references.map((reference) => + repositories.get(reference.canonical!), + ); + if (resolved.some((repository) => repository === undefined)) { + return { + value: + "", + resolved: false, + }; + } + return trustedTemporaryRepository(proposal, context, resolved[0]!); +} + +function buildRepositoryResolutions( + proposals: Proposal[], + context: TrustedRepositoryContext | undefined, +): Map { + const resolutions = new Map(); + const temporaryRepositories = new Map(); + const ordered = [...proposals].sort((a, b) => a.index - b.index); + + for (const proposal of ordered) { + const resolution = + repositoryFromTemporary(proposal, context, temporaryRepositories) ?? + repositoryFromPolicy(proposal, context); + resolutions.set(proposal.index, resolution); + + if (proposal.name !== "create-github-issue" || !resolution.resolved) { + continue; + } + const temporary = temporaryReference(proposal.record.temporary_id); + if ( + temporary?.canonical && + !temporary.invalid && + !temporaryRepositories.has(temporary.canonical) + ) { + temporaryRepositories.set(temporary.canonical, resolution.value); + } + } + return resolutions; +} + /** * Escape a value for safe **inline** markdown display: collapse to a single * line, strip control characters, escape markdown/HTML metacharacters so the @@ -342,7 +830,10 @@ function truncate(s: string, max: number): string { } /** Render one proposal as a markdown fragment. */ -function renderProposal(p: Proposal): string { +function renderProposal( + p: Proposal, + repositoryResolutions: ReadonlyMap, +): string { const spec = TOOL_SPECS[p.name]; const title = spec ? spec.title : fallbackTitle(p.name); const fields = spec ? spec.fields : genericFields(p.record); @@ -358,10 +849,19 @@ function renderProposal(p: Proposal): string { const rows: string[] = []; for (const f of fields) { + if (f.key === "repository" && spec?.githubRepository) { + const repository = repositoryResolutions.get(p.index) ?? { + value: "", + resolved: false, + }; + rows.push( + `| ${sanitizeInline(f.label)} | ${sanitizeInline(repository.value)} |`, + ); + continue; + } + if (!Object.prototype.hasOwnProperty.call(p.record, f.key)) continue; const raw = p.record[f.key]; - if (raw === null || raw === undefined) continue; - const val = sanitizeInline(raw); - if (val.length === 0) continue; + const val = renderInlineValue(raw); rows.push(`| ${sanitizeInline(f.label)} | ${val} |`); } if (rows.length > 0) { @@ -372,19 +872,31 @@ function renderProposal(p: Proposal): string { } if (spec?.body) { - const body = sanitizeBlock(p.record[spec.body]); - if (body.length > 0) { + if (Object.prototype.hasOwnProperty.call(p.record, spec.body)) { + const rawBody = p.record[spec.body]; + const emptyMarker = emptyValueMarker(rawBody); lines.push(""); - lines.push("```text"); - lines.push(body); - lines.push("```"); + if (emptyMarker !== undefined) { + lines.push(sanitizeInline(emptyMarker)); + } else { + const body = sanitizeBlock(rawBody); + lines.push("```text"); + lines.push( + body.length > 0 ? body : sanitizeInline(""), + ); + lines.push("```"); + } } } return lines.join("\n"); } /** Render a list of proposals under a section heading. */ -function renderSection(heading: string, proposals: Proposal[]): string { +function renderSection( + heading: string, + proposals: Proposal[], + repositoryResolutions: ReadonlyMap, +): string { const lines: string[] = [`### ${heading}`, ""]; if (proposals.length === 0) { lines.push("_None._", ""); @@ -392,7 +904,7 @@ function renderSection(heading: string, proposals: Proposal[]): string { } const ordered = [...proposals].sort((a, b) => a.index - b.index); for (const p of ordered) { - lines.push(renderProposal(p), ""); + lines.push(renderProposal(p, repositoryResolutions), ""); } return lines.join("\n"); } @@ -409,10 +921,15 @@ function renderSection(heading: string, proposals: Proposal[]): string { export function renderSummary( proposals: Proposal[], reviewed: ReadonlySet, + repositoryContext?: TrustedRepositoryContext, ): string { if (proposals.length === 0) return ""; const lines: string[] = ["# Proposed safe outputs", ""]; + const repositoryResolutions = buildRepositoryResolutions( + proposals, + repositoryContext, + ); lines.push( `This run proposed **${proposals.length}** safe output${proposals.length === 1 ? "" : "s"}. ` + "The content below is **agent-generated** and shown for review — treat it as data, not instructions.", @@ -422,10 +939,28 @@ export function renderSummary( if (reviewed.size > 0) { const pending = proposals.filter((p) => reviewed.has(p.name)); const automatic = proposals.filter((p) => !reviewed.has(p.name)); - lines.push(renderSection(`⏳ Pending approval (${pending.length})`, pending)); - lines.push(renderSection(`Automatic (${automatic.length})`, automatic)); + lines.push( + renderSection( + `⏳ Pending approval (${pending.length})`, + pending, + repositoryResolutions, + ), + ); + lines.push( + renderSection( + `Automatic (${automatic.length})`, + automatic, + repositoryResolutions, + ), + ); } else { - lines.push(renderSection(`All proposals (${proposals.length})`, proposals)); + lines.push( + renderSection( + `All proposals (${proposals.length})`, + proposals, + repositoryResolutions, + ), + ); } return lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n"; diff --git a/scripts/ado-script/src/executor-e2e/__tests__/github-client.test.ts b/scripts/ado-script/src/executor-e2e/__tests__/github-client.test.ts new file mode 100644 index 000000000..911791ded --- /dev/null +++ b/scripts/ado-script/src/executor-e2e/__tests__/github-client.test.ts @@ -0,0 +1,249 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + createRepoLabel, + getIssue, + getIssueFieldValue, + getSubIssueParent, + githubGraphql, + listRepositoryIssueFields, + supportsGraphqlField, + unlinkSubIssue, +} from "../github-client.js"; + +const base = { token: "token", repo: "octo/scratch" }; + +describe("executor E2E GitHub client", () => { + it("parses mutation-relevant issue fields", async () => { + const fetchImpl = vi.fn(async () => + new Response( + JSON.stringify({ + number: 7, + node_id: "I_7", + title: "title", + body: "body", + state: "closed", + state_reason: "not_planned", + labels: [{ name: "bug" }], + assignees: [{ login: "octocat" }], + milestone: { number: 3, title: "v1" }, + }), + { status: 200 }, + ), + ) as unknown as typeof fetch; + + await expect(getIssue({ ...base, fetchImpl }, 7)).resolves.toEqual({ + number: 7, + nodeId: "I_7", + title: "title", + body: "body", + state: "closed", + stateReason: "not_planned", + labels: ["bug"], + assignees: ["octocat"], + milestone: { number: 3, title: "v1" }, + type: undefined, + }); + }); + + it("probes preview fields through GraphQL introspection", async () => { + const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)) as { variables: { type: string } }; + expect(body.variables.type).toBe("Mutation"); + return new Response( + JSON.stringify({ + data: { __type: { fields: [{ name: "minimizeComment" }] } }, + }), + { status: 200 }, + ); + }) as unknown as typeof fetch; + + await expect( + supportsGraphqlField({ ...base, fetchImpl }, "Mutation", "minimizeComment"), + ).resolves.toBe(true); + expect(fetchImpl).toHaveBeenCalledWith( + "https://api.github.com/graphql", + expect.objectContaining({ method: "POST" }), + ); + }); + + it("surfaces GraphQL product errors instead of treating them as unsupported", async () => { + const fetchImpl = vi.fn(async () => + new Response( + JSON.stringify({ errors: [{ message: "Resource not accessible by integration" }] }), + { status: 200 }, + ), + ) as unknown as typeof fetch; + + await expect( + githubGraphql({ ...base, fetchImpl }, "query { viewer { login } }"), + ).rejects.toThrow(/Resource not accessible/); + }); + + it("discovers supported repository issue fields", async () => { + const fetchImpl = vi.fn(async () => + new Response( + JSON.stringify({ + data: { + repository: { + issueFields: { + nodes: [ + { id: "IF_1", name: "Priority", __typename: "IssueFieldSingleSelect", + options: [{ id: "O_1", name: "High" }] }, + { id: null, name: "invalid", __typename: "IssueFieldText" }, + ], + }, + }, + }, + }), + { status: 200 }, + ), + ) as unknown as typeof fetch; + + await expect(listRepositoryIssueFields({ ...base, fetchImpl })).resolves.toEqual([ + { + id: "IF_1", + name: "Priority", + type: "IssueFieldSingleSelect", + options: [{ id: "O_1", name: "High" }], + }, + ]); + }); + + it("reads persisted repository issue field values with their concrete types", async () => { + const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)) as { + query: string; + variables: Record; + }; + expect(body.query).toContain("issueFieldValues"); + expect(body.variables).toEqual({ owner: "octo", repo: "scratch", number: 7 }); + return new Response( + JSON.stringify({ + data: { + repository: { + issue: { + issueFieldValues: { + nodes: [ + { + __typename: "IssueFieldTextValue", + value: "persisted text", + field: { + id: "IF_TEXT", + name: "Details", + __typename: "IssueFieldText", + }, + }, + { + __typename: "IssueFieldNumberValue", + value: 42, + field: { + id: "IF_NUMBER", + name: "Estimate", + __typename: "IssueFieldNumber", + }, + }, + { + __typename: "IssueFieldSingleSelectValue", + name: "High", + field: { + id: "IF_SELECT", + name: "Priority", + __typename: "IssueFieldSingleSelect", + }, + }, + ], + }, + }, + }, + }, + }), + { status: 200 }, + ); + }) as unknown as typeof fetch; + + await expect( + getIssueFieldValue({ ...base, fetchImpl }, 7, "IF_NUMBER"), + ).resolves.toEqual({ + fieldId: "IF_NUMBER", + fieldName: "Estimate", + fieldType: "IssueFieldNumber", + valueType: "IssueFieldNumberValue", + value: 42, + }); + await expect( + getIssueFieldValue({ ...base, fetchImpl }, 7, "IF_SELECT"), + ).resolves.toEqual({ + fieldId: "IF_SELECT", + fieldName: "Priority", + fieldType: "IssueFieldSingleSelect", + valueType: "IssueFieldSingleSelectValue", + value: "High", + }); + }); + + it("returns undefined when the selected issue field has no persisted value", async () => { + const fetchImpl = vi.fn(async () => + new Response( + JSON.stringify({ + data: { + repository: { issue: { issueFieldValues: { nodes: [] } } }, + }, + }), + { status: 200 }, + ), + ) as unknown as typeof fetch; + + await expect( + getIssueFieldValue({ ...base, fetchImpl }, 7, "IF_MISSING"), + ).resolves.toBeUndefined(); + }); + + it("reads a sub-issue's parent for independent assertion", async () => { + const fetchImpl = vi.fn(async () => + new Response( + JSON.stringify({ + data: { repository: { issue: { parent: { number: 11 } } } }, + }), + { status: 200 }, + ), + ) as unknown as typeof fetch; + + await expect(getSubIssueParent({ ...base, fetchImpl }, 12)).resolves.toBe(11); + }); + + it("treats a deterministic label left by a same-build retry as already set up", async () => { + const fetchImpl = vi.fn(async () => + new Response(JSON.stringify({ errors: [{ code: "already_exists" }] }), { + status: 422, + }), + ) as unknown as typeof fetch; + + await expect( + createRepoLabel({ ...base, fetchImpl }, "executor-e2e-77-add"), + ).resolves.toBeUndefined(); + }); + + it("unlinks scratch sub-issues during cleanup", async () => { + const fetchImpl = vi.fn(async (url: string, init?: RequestInit) => { + if (url.endsWith("/issues/11")) { + return new Response(JSON.stringify({ number: 11, node_id: "I_parent" }), { status: 200 }); + } + if (url.endsWith("/issues/12")) { + return new Response(JSON.stringify({ number: 12, node_id: "I_sub" }), { status: 200 }); + } + const payload = JSON.parse(String(init?.body)) as { + query: string; + variables: Record; + }; + expect(payload.query).toContain("removeSubIssue"); + expect(payload.variables).toEqual({ parentId: "I_parent", subIssueId: "I_sub" }); + return new Response( + JSON.stringify({ data: { removeSubIssue: { clientMutationId: null } } }), + { status: 200 }, + ); + }) as unknown as typeof fetch; + + await expect(unlinkSubIssue({ ...base, fetchImpl }, 11, 12)).resolves.toBeUndefined(); + }); +}); diff --git a/scripts/ado-script/src/executor-e2e/__tests__/github-issue-scenarios.test.ts b/scripts/ado-script/src/executor-e2e/__tests__/github-issue-scenarios.test.ts index e83324542..91ad0ace6 100644 --- a/scripts/ado-script/src/executor-e2e/__tests__/github-issue-scenarios.test.ts +++ b/scripts/ado-script/src/executor-e2e/__tests__/github-issue-scenarios.test.ts @@ -7,25 +7,51 @@ vi.mock("../github-client.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, + addIssueAssignees: vi.fn(async () => {}), closeIssue: vi.fn(async () => {}), + createIssueComment: vi.fn(async () => ({ id: 12, nodeId: "IC_12", body: "b", user: "u" })), createGitHubIssue: vi.fn(async () => "https://github.com/o/r/issues/123"), + createMilestone: vi.fn(async () => ({ number: 9, title: "m" })), + createRepoLabel: vi.fn(async () => {}), + deleteIssueComment: vi.fn(async () => {}), + deleteMilestone: vi.fn(async () => {}), + deleteRepoLabel: vi.fn(async () => {}), diagnoseGitHubAuthFailure: vi.fn(async () => {}), findOpenIssueByTitle: vi.fn(async () => undefined), + getAuthenticatedUser: vi.fn(async () => "octocat"), + getCommentMinimization: vi.fn(async () => ({ isMinimized: true })), getIssue: vi.fn(async () => undefined), + getIssueFieldValue: vi.fn(async () => undefined), + getSubIssueParent: vi.fn(async () => undefined), + listIssueComments: vi.fn(async () => []), listOrgIssueTypes: vi.fn(async () => []), + listRepositoryIssueFields: vi.fn(async () => []), patchIssue: vi.fn(async () => ({ ok: false, status: 404, body: "" })), + removeIssueAssignees: vi.fn(async () => {}), + supportsGraphqlField: vi.fn(async () => true), }; }); const gh = await import("../github-client.js"); const { + addGithubIssueLabels, + assignGithubIssueMilestone, + assignGithubIssueToUser, + closeGithubIssue, + commentOnGithubIssue, createGithubIssue, createGithubIssueLabelDenied, createGithubIssueTemporaryIdHandoff, githubIssueScenarios, + hideGithubIssueComment, + linkGithubSubIssue, recordForTool, + removeGithubIssueLabels, resolveGithubIssueEnv, + setGithubIssueField, setGithubIssueType, + unassignGithubIssueFromUser, + updateGithubIssue, } = await import("../scenarios/github-issue.js"); const TEMPORARY_ID = "#aw_e2e1"; @@ -75,17 +101,23 @@ const goodEnv = { beforeEach(() => { vi.mocked(gh.getIssue).mockReset(); + vi.mocked(gh.getIssueFieldValue).mockReset(); vi.mocked(gh.closeIssue).mockReset(); vi.mocked(gh.findOpenIssueByTitle).mockReset(); vi.mocked(gh.patchIssue).mockReset(); vi.mocked(gh.listOrgIssueTypes).mockReset(); + vi.mocked(gh.listRepositoryIssueFields).mockReset(); vi.mocked(gh.createGitHubIssue).mockReset(); + vi.mocked(gh.supportsGraphqlField).mockReset(); vi.mocked(gh.getIssue).mockResolvedValue(undefined); + vi.mocked(gh.getIssueFieldValue).mockResolvedValue(undefined); vi.mocked(gh.closeIssue).mockResolvedValue(undefined); vi.mocked(gh.findOpenIssueByTitle).mockResolvedValue(undefined); vi.mocked(gh.patchIssue).mockResolvedValue({ ok: false, status: 404, body: "" }); vi.mocked(gh.listOrgIssueTypes).mockResolvedValue([]); + vi.mocked(gh.listRepositoryIssueFields).mockResolvedValue([]); vi.mocked(gh.createGitHubIssue).mockResolvedValue("https://github.com/o/r/issues/123"); + vi.mocked(gh.supportsGraphqlField).mockResolvedValue(true); }); describe("resolveGithubIssueEnv", () => { @@ -139,15 +171,30 @@ describe("resolveGithubIssueEnv", () => { }); describe("registry", () => { - it("registers five GitHub issue scenarios with unique ids", () => { + it("registers the complete GitHub issue scenario family with unique ids", () => { const ids = githubIssueScenarios.map((s) => s.id ?? s.tool); - expect(new Set(ids).size).toBe(5); + expect(new Set(ids).size).toBe(20); expect(ids).toEqual([ "create-github-issue", "create-github-issue-label-denied", "set-github-issue-type", "set-github-issue-type-clear", "create-github-issue-temporary-id-handoff", + "comment-on-github-issue", + "hide-github-issue-comment", + "add-github-issue-labels", + "remove-github-issue-labels", + "close-github-issue", + "update-github-issue", + "set-github-issue-field", + "assign-github-issue-milestone", + "assign-github-issue-to-user", + "unassign-github-issue-from-user", + "link-github-sub-issue", + "comment-on-github-issue-repo-denied", + "add-github-issue-labels-blocked", + "update-github-issue-filter-denied", + "close-github-issue-state-denied", ]); }); @@ -157,8 +204,187 @@ describe("registry", () => { expect(env).toEqual({ ADO_AW_GITHUB_TOKEN: "tok" }); }); + describe("new GitHub mutation contracts", () => { + const base = { + repo: REPO, + token: "tok", + gh: { token: "tok", repo: REPO }, + title: "scratch", + issueNumber: 41, + }; + + it("registers exactly the signed-off eleven canonical tool names", () => { + const names = [ + commentOnGithubIssue, + hideGithubIssueComment, + addGithubIssueLabels, + removeGithubIssueLabels, + closeGithubIssue, + updateGithubIssue, + setGithubIssueField, + assignGithubIssueMilestone, + assignGithubIssueToUser, + unassignGithubIssueFromUser, + linkGithubSubIssue, + ].map((scenario) => scenario.tool); + expect(names).toEqual([ + "comment-on-github-issue", + "hide-github-issue-comment", + "add-github-issue-labels", + "remove-github-issue-labels", + "close-github-issue", + "update-github-issue", + "set-github-issue-field", + "assign-github-issue-milestone", + "assign-github-issue-to-user", + "unassign-github-issue-from-user", + "link-github-sub-issue", + ]); + }); + + it("uses the signed-off snake_case parameter objects", async () => { + expect(await commentOnGithubIssue.ndjson(fakeCtx(), base)).toMatchObject({ + issue_number: 41, + body: expect.any(String), + }); + expect( + await hideGithubIssueComment.ndjson(fakeCtx(), { + ...base, + commentId: 12, + commentNodeId: "IC_12", + }), + ).toEqual({ comment_id: 12, reason: "spam", repository: REPO }); + expect( + await addGithubIssueLabels.ndjson(fakeCtx(), { ...base, label: "e2e-label" }), + ).toEqual({ issue_number: 41, labels: ["e2e-label"] }); + expect( + await removeGithubIssueLabels.ndjson(fakeCtx(), { ...base, label: "e2e-label" }), + ).toEqual({ issue_number: 41, labels: ["e2e-label"] }); + expect(await closeGithubIssue.ndjson(fakeCtx(), base)).toMatchObject({ + issue_number: 41, + state_reason: "not_planned", + }); + expect( + await updateGithubIssue.ndjson(fakeCtx(), { + ...base, + updatedTitle: "new title", + updatedBody: "new body", + }), + ).toEqual({ + issue_number: 41, + title: "new title", + body: "new body", + operation: "replace", + }); + expect( + await setGithubIssueField.ndjson(fakeCtx(), { + ...base, + field: { id: "IF_1", name: "Priority", type: "IssueFieldText", options: [] }, + value: "high", + }), + ).toEqual({ issue_number: 41, field_name: "Priority", value: "high" }); + expect( + await assignGithubIssueMilestone.ndjson(fakeCtx(), { + ...base, + milestoneNumber: 7, + milestoneTitle: "m", + }), + ).toEqual({ issue_number: 41, milestone_number: 7 }); + expect( + await assignGithubIssueToUser.ndjson(fakeCtx(), { ...base, assignee: "octocat" }), + ).toEqual({ issue_number: 41, assignee: "octocat" }); + expect( + await unassignGithubIssueFromUser.ndjson(fakeCtx(), { ...base, assignee: "octocat" }), + ).toEqual({ issue_number: 41, assignee: "octocat" }); + expect( + await linkGithubSubIssue.ndjson(fakeCtx(), { + repo: REPO, + token: "tok", + gh: { token: "tok", repo: REPO }, + parentTitle: "parent", + subTitle: "sub", + }), + ).toEqual({ + parent_issue_number: "#aw_parent", + sub_issue_number: "#aw_sub", + }); + }); + + it("uses the signed-off kebab-case operator config", () => { + expect( + hideGithubIssueComment.config(fakeCtx(), { + ...base, + commentId: 12, + commentNodeId: "IC_12", + }), + ).toMatchObject({ "target-repo": REPO, "allowed-reasons": ["SPAM"] }); + expect( + closeGithubIssue.config(fakeCtx(), base), + ).toMatchObject({ "allow-body": true, "allowed-state-reason": ["not_planned"] }); + expect( + updateGithubIssue.config(fakeCtx(), { + ...base, + updatedTitle: "new", + updatedBody: "body", + }), + ).toMatchObject({ title: true, body: true }); + expect( + setGithubIssueField.config(fakeCtx(), { + ...base, + field: { id: "IF_1", name: "Priority", type: "IssueFieldText", options: [] }, + value: "high", + }), + ).toMatchObject({ "allowed-fields": ["Priority"] }); + expect( + assignGithubIssueMilestone.config(fakeCtx(), { + ...base, + milestoneNumber: 7, + milestoneTitle: "m", + }), + ).toMatchObject({ allowed: ["m"], "auto-create": false }); + expect( + assignGithubIssueToUser.config(fakeCtx(), { ...base, assignee: "octocat" }), + ).toMatchObject({ allowed: ["octocat"], blocked: [], "unassign-first": true }); + }); + + it("stages both parent and child creates before link-github-sub-issue", async () => { + const state = { + repo: REPO, + token: "tok", + gh: { token: "tok", repo: REPO }, + parentTitle: "parent", + subTitle: "sub", + }; + const prior = await linkGithubSubIssue.priorEntries!(fakeCtx(), state); + expect(prior.map((entry) => entry.entry.temporary_id)).toEqual([ + "#aw_parent", + "#aw_sub", + ]); + expect(prior.every((entry) => entry.tool === "create-github-issue")).toBe(true); + expect(prior.every((entry) => entry.config["require-temporary-id"] === true)).toBe(true); + }); + + it("skips preview GraphQL scenarios before creating issues when a field is unavailable", async () => { + vi.stubEnv("EXECUTOR_E2E_GITHUB_TOKEN", "tok"); + vi.stubEnv("EXECUTOR_E2E_ISSUE_REPO", REPO); + vi.mocked(gh.supportsGraphqlField).mockResolvedValue(false); + await expect(hideGithubIssueComment.setup(fakeCtx())).rejects.toThrow(SkipError); + expect(gh.createGitHubIssue).not.toHaveBeenCalled(); + vi.unstubAllEnvs(); + }); + }); + it("targets the configured repo explicitly rather than relying on resolution", () => { - const state = { repo: REPO, token: "tok", gh: { token: "tok", repo: REPO }, title: "t" }; + const state = { + repo: REPO, + token: "tok", + gh: { token: "tok", repo: REPO }, + title: "t", + label: "label", + field: { id: "IF_1", name: "Priority", type: "IssueFieldText", options: [] }, + milestoneTitle: "milestone", + assignee: "octocat", + }; for (const scenario of githubIssueScenarios) { const config = scenario.config(fakeCtx(), state as never); expect(config["target-repo"]).toBe(REPO); @@ -338,6 +564,98 @@ describe("set-github-issue-type", () => { }); }); +describe("set-github-issue-field", () => { + const field = { + id: "IF_1", + name: "Estimate", + type: "IssueFieldNumber", + options: [], + }; + const state = () => ({ + repo: REPO, + token: "tok", + gh: { token: "tok", repo: REPO }, + title: "ado-aw-det-77-set-github-issue-field scratch issue", + issueNumber: 123, + field, + value: "42", + }); + const output = () => + record("set_github_issue_field", { + field_name: field.name, + value: "42", + }); + + it("reads GitHub after execution and asserts the persisted field value and type", async () => { + const s = state(); + vi.mocked(gh.getIssueFieldValue).mockResolvedValue({ + fieldId: field.id, + fieldName: field.name, + fieldType: field.type, + valueType: "IssueFieldNumberValue", + value: 42, + }); + + await expect( + setGithubIssueField.assert(fakeCtx(), s, output(), []), + ).resolves.toBeUndefined(); + expect(gh.getIssueFieldValue).toHaveBeenCalledWith(s.gh, s.issueNumber, field.id); + }); + + it("fails when executor output is healthy but GitHub did not persist the value", async () => { + await expect( + setGithubIssueField.assert(fakeCtx(), state(), output(), []), + ).rejects.toThrow(/has no persisted value/); + }); + + it("fails when GitHub persisted a different field value type", async () => { + vi.mocked(gh.getIssueFieldValue).mockResolvedValue({ + fieldId: field.id, + fieldName: field.name, + fieldType: field.type, + valueType: "IssueFieldTextValue", + value: "42", + }); + + await expect( + setGithubIssueField.assert(fakeCtx(), state(), output(), []), + ).rejects.toThrow(/value type is 'IssueFieldTextValue'/); + }); + + it("fails when GitHub persisted a different value", async () => { + vi.mocked(gh.getIssueFieldValue).mockResolvedValue({ + fieldId: field.id, + fieldName: field.name, + fieldType: field.type, + valueType: "IssueFieldNumberValue", + value: 41, + }); + + await expect( + setGithubIssueField.assert(fakeCtx(), state(), output(), []), + ).rejects.toThrow(/persisted issue field value is 41, expected 42/); + }); + + it("closes the deterministic scratch issue during cleanup", async () => { + const s = state(); + await setGithubIssueField.cleanup(fakeCtx(), s); + expect(gh.closeIssue).toHaveBeenCalledWith(s.gh, s.issueNumber); + }); + + it("skips before creating an issue when the read-side preview API is unavailable", async () => { + vi.stubEnv("EXECUTOR_E2E_GITHUB_TOKEN", "tok"); + vi.stubEnv("EXECUTOR_E2E_ISSUE_REPO", REPO); + vi.mocked(gh.supportsGraphqlField).mockImplementation( + async (_opts, type, name) => !(type === "Issue" && name === "issueFieldValues"), + ); + vi.mocked(gh.listRepositoryIssueFields).mockResolvedValue([field]); + + await expect(setGithubIssueField.setup(fakeCtx())).rejects.toThrow(SkipError); + expect(gh.createGitHubIssue).not.toHaveBeenCalled(); + vi.unstubAllEnvs(); + }); +}); + describe("temporary-ID handoff", () => { const state = () => ({ repo: REPO, diff --git a/scripts/ado-script/src/executor-e2e/__tests__/index.test.ts b/scripts/ado-script/src/executor-e2e/__tests__/index.test.ts index 632918713..5ada7cf84 100644 --- a/scripts/ado-script/src/executor-e2e/__tests__/index.test.ts +++ b/scripts/ado-script/src/executor-e2e/__tests__/index.test.ts @@ -36,6 +36,17 @@ describe("scenario registry", () => { "set-github-issue-type", "set-github-issue-type-clear", "create-github-issue-temporary-id-handoff", + "comment-on-github-issue", + "hide-github-issue-comment", + "add-github-issue-labels", + "remove-github-issue-labels", + "close-github-issue", + "update-github-issue", + "set-github-issue-field", + "assign-github-issue-milestone", + "assign-github-issue-to-user", + "unassign-github-issue-from-user", + "link-github-sub-issue", ]) { expect(ids).toContain(id); } diff --git a/scripts/ado-script/src/executor-e2e/__tests__/runner.test.ts b/scripts/ado-script/src/executor-e2e/__tests__/runner.test.ts index 1ceddd339..665e0ea04 100644 --- a/scripts/ado-script/src/executor-e2e/__tests__/runner.test.ts +++ b/scripts/ado-script/src/executor-e2e/__tests__/runner.test.ts @@ -269,4 +269,49 @@ fs.writeFileSync(path.join(out, "safe-outputs-executed.ndjson"), JSON.stringify( await rm(dir, { recursive: true, force: true }); } }); + + it("requires one executed record for each repeated prior tool occurrence", async () => { + const dir = await mkdtemp(join(tmpdir(), "ado-aw-runner-prior-repeat-")); + try { + const bin = join(dir, "drop-second-prior.js"); + await writeFile( + bin, + `#!/usr/bin/env node +const fs = require("node:fs"); +const path = require("node:path"); +const out = process.argv[process.argv.indexOf("--safe-output-dir") + 1]; +fs.writeFileSync(path.join(out, "safe-outputs-executed.ndjson"), [ + { name: "create_github_issue", status: "succeeded", result: { number: 1 } }, + { name: "link_github_sub_issue", status: "succeeded", result: {} }, +].map(JSON.stringify).join("\\n") + "\\n"); +`, + { encoding: "utf8", mode: 0o755 }, + ); + const scenario: Scenario = { + id: "repeated-prior", + tool: "link-github-sub-issue", + config: () => ({ "target-repo": "o/r" }), + setup: async () => ({}), + priorEntries: async () => [ + { tool: "create-github-issue", config: {}, entry: { temporary_id: "#aw_parent" } }, + { tool: "create-github-issue", config: {}, entry: { temporary_id: "#aw_sub" } }, + ], + ndjson: async () => ({ + parent_issue_number: "#aw_parent", + sub_issue_number: "#aw_sub", + }), + assert: async () => {}, + cleanup: async () => {}, + }; + const res = await runScenario( + { ...fakeCtx(), adoAwBin: bin, workDir: dir }, + scenario, + ); + expect(res.ok).toBe(false); + expect(res.phase).toBe("execute"); + expect(res.message).toContain("occurrence 2"); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); }); diff --git a/scripts/ado-script/src/executor-e2e/github-client.ts b/scripts/ado-script/src/executor-e2e/github-client.ts index d00540447..818a9e137 100644 --- a/scripts/ado-script/src/executor-e2e/github-client.ts +++ b/scripts/ado-script/src/executor-e2e/github-client.ts @@ -51,6 +51,83 @@ function ghSignal(opts: GitHubClientOptions): AbortSignal { return AbortSignal.timeout(opts.timeoutMs ?? DEFAULT_GITHUB_TIMEOUT_MS); } +async function githubJson( + opts: GitHubClientOptions, + method: string, + path: string, + payload?: unknown, +): Promise { + const res = await ghFetch(opts)(`https://api.github.com${path}`, { + method, + headers: { + ...ghHeaders(opts.token), + ...(payload === undefined ? {} : { "Content-Type": "application/json" }), + }, + body: payload === undefined ? undefined : JSON.stringify(payload), + signal: ghSignal(opts), + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`GitHub ${method} ${path} failed: HTTP ${res.status}: ${text}`); + } + if (res.status === 204) return undefined as T; + return (await res.json()) as T; +} + +function repoPath(opts: GitHubClientOptions): string { + const { owner, name } = splitRepo(opts.repo); + return `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`; +} + +export interface GraphQLResponse { + data?: T; + errors?: { message?: string; type?: string }[]; +} + +/** Execute a GitHub GraphQL request and surface product errors verbatim. */ +export async function githubGraphql( + opts: GitHubClientOptions, + query: string, + variables: Record = {}, +): Promise { + const json = await githubJson>(opts, "POST", "/graphql", { + query, + variables, + }); + if (json.errors?.length) { + throw new Error( + `GitHub GraphQL failed: ${json.errors.map((e) => e.message ?? e.type ?? "unknown error").join("; ")}`, + ); + } + if (json.data === undefined) throw new Error("GitHub GraphQL response omitted data"); + return json.data; +} + +/** + * Probe a preview GraphQL field without mutating repository state. + * + * Callers turn a false result into SkipError. Once a field exists, later + * product/permission errors remain scenario failures rather than skips. + */ +export async function supportsGraphqlField( + opts: GitHubClientOptions, + typeName: string, + fieldName: string, +): Promise { + const data = await githubGraphql<{ + __type?: { fields?: { name?: string }[] | null } | null; + }>( + opts, + `query($type: String!) { + __type(name: $type) { + fields { name } + } + }`, + { type: typeName }, + ); + return Boolean(data.__type?.fields?.some((field) => field.name === fieldName)); +} + /** * Trim a pipeline env value, treating an UNEXPANDED ADO macro (e.g. the literal * `$(EXECUTOR_E2E_ISSUE_REPO)`) as absent. ADO passes a `$(VAR)` reference @@ -117,36 +194,407 @@ export async function createGitHubIssue( /** One GitHub issue, reduced to the fields the scenarios assert on. */ export interface GitHubIssue { number: number; + nodeId?: string; title: string; body: string | null; state: string; + stateReason?: string | null; labels: string[]; + assignees?: string[]; + milestone?: { number: number; title: string } | null; /** Native issue type name, or undefined when the issue has no type. */ type?: string; } interface RawIssue { number?: number; + node_id?: string; title?: string; body?: string | null; state?: string; + state_reason?: string | null; labels?: (string | { name?: string })[]; + assignees?: { login?: string }[]; + milestone?: { number?: number; title?: string } | null; type?: { name?: string } | null; } function toIssue(raw: RawIssue): GitHubIssue { return { number: typeof raw.number === "number" ? raw.number : 0, + nodeId: raw.node_id, title: raw.title ?? "", body: raw.body ?? null, state: raw.state ?? "", + stateReason: raw.state_reason, labels: (raw.labels ?? []) .map((l) => (typeof l === "string" ? l : (l.name ?? ""))) .filter((l) => l.length > 0), + assignees: (raw.assignees ?? []).map((a) => a.login ?? "").filter((a) => a.length > 0), + milestone: + raw.milestone?.number !== undefined && raw.milestone.title !== undefined + ? { number: raw.milestone.number, title: raw.milestone.title } + : null, type: raw.type?.name ?? undefined, }; } +export interface GitHubIssueComment { + id: number; + nodeId: string; + body: string; + user: string; +} + +export async function listIssueComments( + opts: GitHubClientOptions, + issueNumber: number, +): Promise { + const comments = await githubJson< + { id?: number; node_id?: string; body?: string; user?: { login?: string } }[] + >(opts, "GET", `${repoPath(opts)}/issues/${issueNumber}/comments?per_page=100`); + return comments + .filter((c) => typeof c.id === "number" && typeof c.node_id === "string") + .map((c) => ({ + id: c.id!, + nodeId: c.node_id!, + body: c.body ?? "", + user: c.user?.login ?? "", + })); +} + +export async function createIssueComment( + opts: GitHubClientOptions, + issueNumber: number, + body: string, +): Promise { + const comment = await githubJson<{ + id: number; + node_id: string; + body?: string; + user?: { login?: string }; + }>(opts, "POST", `${repoPath(opts)}/issues/${issueNumber}/comments`, { body }); + return { + id: comment.id, + nodeId: comment.node_id, + body: comment.body ?? "", + user: comment.user?.login ?? "", + }; +} + +export async function deleteIssueComment( + opts: GitHubClientOptions, + commentId: number, +): Promise { + await githubJson(opts, "DELETE", `${repoPath(opts)}/issues/comments/${commentId}`); +} + +export async function createRepoLabel( + opts: GitHubClientOptions, + name: string, + color = "5319e7", +): Promise { + const path = `${repoPath(opts)}/labels`; + const res = await ghFetch(opts)(`https://api.github.com${path}`, { + method: "POST", + headers: { ...ghHeaders(opts.token), "Content-Type": "application/json" }, + body: JSON.stringify({ + name, + color, + description: "Temporary ado-aw executor E2E label", + }), + signal: ghSignal(opts), + }); + if (res.ok) return; + const text = await res.text().catch(() => ""); + // A same-build job retry may encounter the deterministic label left by the + // interrupted attempt. Treat that exact GitHub conflict as "already set up". + if (res.status === 422 && /already_exists|already exists/i.test(text)) return; + throw new Error(`GitHub POST ${path} failed: HTTP ${res.status}: ${text}`); +} + +export async function deleteRepoLabel( + opts: GitHubClientOptions, + name: string, +): Promise { + await githubJson( + opts, + "DELETE", + `${repoPath(opts)}/labels/${encodeURIComponent(name)}`, + ); +} + +export interface GitHubMilestone { + number: number; + title: string; +} + +export async function createMilestone( + opts: GitHubClientOptions, + title: string, +): Promise { + const path = `${repoPath(opts)}/milestones`; + const res = await ghFetch(opts)(`https://api.github.com${path}`, { + method: "POST", + headers: { ...ghHeaders(opts.token), "Content-Type": "application/json" }, + body: JSON.stringify({ + title, + description: "Temporary ado-aw executor E2E milestone", + }), + signal: ghSignal(opts), + }); + if (res.ok) return (await res.json()) as GitHubMilestone; + const text = await res.text().catch(() => ""); + if (res.status === 422 && /already_exists|already exists/i.test(text)) { + const milestones = await githubJson( + opts, + "GET", + `${path}?state=all&per_page=100`, + ); + const existing = milestones.find((milestone) => milestone.title === title); + if (existing) return existing; + } + throw new Error(`GitHub POST ${path} failed: HTTP ${res.status}: ${text}`); +} + +export async function deleteMilestone( + opts: GitHubClientOptions, + milestoneNumber: number, +): Promise { + await githubJson( + opts, + "DELETE", + `${repoPath(opts)}/milestones/${milestoneNumber}`, + ); +} + +export async function getAuthenticatedUser(opts: GitHubClientOptions): Promise { + const user = await githubJson<{ login?: string }>(opts, "GET", "/user"); + if (!user.login) throw new Error("GitHub /user response omitted login"); + return user.login; +} + +export async function addIssueAssignees( + opts: GitHubClientOptions, + issueNumber: number, + assignees: string[], +): Promise { + await githubJson(opts, "POST", `${repoPath(opts)}/issues/${issueNumber}/assignees`, { + assignees, + }); +} + +export async function removeIssueAssignees( + opts: GitHubClientOptions, + issueNumber: number, + assignees: string[], +): Promise { + await githubJson(opts, "DELETE", `${repoPath(opts)}/issues/${issueNumber}/assignees`, { + assignees, + }); +} + +export interface GitHubIssueField { + id: string; + name: string; + type: string; + options: { id: string; name: string }[]; +} + +export interface GitHubIssueFieldValue { + fieldId: string; + fieldName: string; + fieldType: string; + valueType: string; + value: string | number; +} + +/** Discover repository issue fields using the same preview surface as the executor. */ +export async function listRepositoryIssueFields( + opts: GitHubClientOptions, +): Promise { + const { owner, name } = splitRepo(opts.repo); + const data = await githubGraphql<{ + repository?: { + issueFields?: { + nodes?: { + id?: string; + name?: string; + __typename?: string; + options?: { id?: string; name?: string }[]; + }[]; + }; + }; + }>( + opts, + `query($owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { + issueFields(first: 100) { + nodes { + __typename + ... on IssueFieldText { id name } + ... on IssueFieldNumber { id name } + ... on IssueFieldDate { id name } + ... on IssueFieldSingleSelect { id name options { id name } } + ... on IssueFieldMultiSelect { id name options { id name } } + } + } + } + }`, + { owner, repo: name }, + ); + return (data.repository?.issueFields?.nodes ?? []) + .filter((field) => typeof field.id === "string" && typeof field.name === "string") + .map((field) => ({ + id: field.id!, + name: field.name!, + type: field.__typename ?? "", + options: (field.options ?? []) + .filter((option) => typeof option.id === "string" && typeof option.name === "string") + .map((option) => ({ id: option.id!, name: option.name! })), + })); +} + +/** Read one persisted repository-defined field value from an issue. */ +export async function getIssueFieldValue( + opts: GitHubClientOptions, + issueNumber: number, + fieldId: string, +): Promise { + const { owner, name } = splitRepo(opts.repo); + const data = await githubGraphql<{ + repository?: { + issue?: { + issueFieldValues?: { + nodes?: { + __typename?: string; + value?: string | number; + name?: string; + field?: { + id?: string; + name?: string; + __typename?: string; + } | null; + }[]; + }; + } | null; + }; + }>( + opts, + `query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + issue(number: $number) { + issueFieldValues(first: 100) { + nodes { + __typename + ... on IssueFieldTextValue { + value + field { __typename ... on IssueFieldText { id name } } + } + ... on IssueFieldNumberValue { + value + field { __typename ... on IssueFieldNumber { id name } } + } + ... on IssueFieldDateValue { + value + field { __typename ... on IssueFieldDate { id name } } + } + ... on IssueFieldSingleSelectValue { + name + field { __typename ... on IssueFieldSingleSelect { id name } } + } + } + } + } + } + }`, + { owner, repo: name, number: issueNumber }, + ); + const node = (data.repository?.issue?.issueFieldValues?.nodes ?? []).find( + (candidate) => candidate.field?.id === fieldId, + ); + if (!node) return undefined; + + const fieldName = node.field?.name; + const fieldType = node.field?.__typename; + const valueType = node.__typename; + if (!fieldName || !fieldType || !valueType) { + throw new Error(`GitHub issue field value '${fieldId}' omitted type or field metadata`); + } + + const value = valueType === "IssueFieldSingleSelectValue" ? node.name : node.value; + if (typeof value !== "string" && typeof value !== "number") { + throw new Error(`GitHub issue field value '${fieldId}' omitted its persisted value`); + } + return { fieldId, fieldName, fieldType, valueType, value }; +} + +export async function getCommentMinimization( + opts: GitHubClientOptions, + nodeId: string, +): Promise<{ isMinimized: boolean; reason?: string | null }> { + const data = await githubGraphql<{ + node?: { isMinimized?: boolean; minimizedReason?: string | null } | null; + }>( + opts, + `query($id: ID!) { + node(id: $id) { + ... on Minimizable { + isMinimized + minimizedReason + } + } + }`, + { id: nodeId }, + ); + return { + isMinimized: data.node?.isMinimized === true, + reason: data.node?.minimizedReason, + }; +} + +export async function getSubIssueParent( + opts: GitHubClientOptions, + issueNumber: number, +): Promise { + const { owner, name } = splitRepo(opts.repo); + const data = await githubGraphql<{ + repository?: { issue?: { parent?: { number?: number } | null } | null }; + }>( + opts, + `query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + issue(number: $number) { parent { number } } + } + }`, + { owner, repo: name, number: issueNumber }, + ); + return data.repository?.issue?.parent?.number; +} + +export async function unlinkSubIssue( + opts: GitHubClientOptions, + parentIssueNumber: number, + subIssueNumber: number, +): Promise { + const [parent, sub] = await Promise.all([ + getIssue(opts, parentIssueNumber), + getIssue(opts, subIssueNumber), + ]); + if (!parent?.nodeId || !sub?.nodeId) { + throw new Error("cannot unlink sub-issue: GitHub issue node IDs are unavailable"); + } + await githubGraphql( + opts, + `mutation($parentId: ID!, $subIssueId: ID!) { + removeSubIssue(input: { issueId: $parentId, subIssueId: $subIssueId }) { + clientMutationId + } + }`, + { parentId: parent.nodeId, subIssueId: sub.nodeId }, + ); +} + /** Fetch a single issue. Returns undefined on 404. */ export async function getIssue( opts: GitHubClientOptions, diff --git a/scripts/ado-script/src/executor-e2e/runner.ts b/scripts/ado-script/src/executor-e2e/runner.ts index 998c690ba..d1d8b2708 100644 --- a/scripts/ado-script/src/executor-e2e/runner.ts +++ b/scripts/ado-script/src/executor-e2e/runner.ts @@ -103,13 +103,19 @@ export async function runScenario( // Prior entries are prerequisites, not the thing under test: surface a // broken one as its own execute-phase failure so it can never be mistaken // for an assertion failure in the primary tool. + const priorRecordOffsets = new Map(); for (const prior of priorEntries ?? []) { - const priorRecord = result.records.find((r) => r.name === prior.tool.replaceAll("-", "_")); + const priorName = prior.tool.replaceAll("-", "_"); + const offset = priorRecordOffsets.get(priorName) ?? 0; + const matchingRecords = result.records.filter((r) => r.name === priorName); + const priorRecord = matchingRecords[offset]; + priorRecordOffsets.set(priorName, offset + 1); if (!priorRecord) { return finish({ ok: false, phase: "execute", - message: `prior entry '${prior.tool}' produced no executed record`, + message: + `prior entry '${prior.tool}' occurrence ${offset + 1} produced no executed record`, }); } if (priorRecord.status !== "succeeded") { diff --git a/scripts/ado-script/src/executor-e2e/scenarios/github-issue.ts b/scripts/ado-script/src/executor-e2e/scenarios/github-issue.ts index 62afa4e70..395c21883 100644 --- a/scripts/ado-script/src/executor-e2e/scenarios/github-issue.ts +++ b/scripts/ado-script/src/executor-e2e/scenarios/github-issue.ts @@ -26,17 +26,33 @@ * Test-harness module; not shipped in `ado-script.zip`. */ import { + addIssueAssignees, cleanVar, closeIssue, + createIssueComment, createGitHubIssue, + createMilestone, + createRepoLabel, + deleteIssueComment, + deleteMilestone, + deleteRepoLabel, diagnoseGitHubAuthFailure, findOpenIssueByTitle, + getAuthenticatedUser, + getCommentMinimization, getIssue, + getIssueFieldValue, + getSubIssueParent, listOrgIssueTypes, + listIssueComments, + listRepositoryIssueFields, patchIssue, + removeIssueAssignees, splitRepo, + supportsGraphqlField, + unlinkSubIssue, } from "../github-client.js"; -import type { GitHubClientOptions } from "../github-client.js"; +import type { GitHubClientOptions, GitHubIssueField } from "../github-client.js"; import type { ExecutedRecord, PriorEntry, Scenario, ScenarioContext } from "../scenario.js"; import { SkipError } from "../scenario.js"; import { detBody, numResult, strResult, Teardown } from "./common.js"; @@ -551,10 +567,687 @@ export const createGithubIssueTemporaryIdHandoff: Scenario = { .run(), }; +// --------------------------------------------------------------------------- +// GitHub issue mutation family +// --------------------------------------------------------------------------- + +interface MutationIssueState extends GithubIssueEnv { + title: string; + issueNumber: number; + commentId?: number; +} + +async function seedMutationIssue( + ctx: ScenarioContext, + env: GithubIssueEnv, + id: string, + labels: string[] = [], +): Promise { + const title = issueTitle(ctx, id); + const leftover = await findOpenIssueByTitle(env.gh, title); + if (leftover !== undefined) await closeIssue(env.gh, leftover); + const url = await createGitHubIssue(env.gh, title, detBody(ctx, id), labels); + const match = url.match(/\/(\d+)$/); + if (!match) { + await closeByNumberOrTitle(env, undefined, title).catch(() => {}); + throw new Error(`could not parse an issue number out of '${url}'`); + } + return { ...env, title, issueNumber: Number(match[1]) }; +} + +async function setupMutationIssue( + ctx: ScenarioContext, + id: string, + labels: string[] = [], +): Promise { + const env = resolveGithubIssueEnv(id); + await requireIssueWrite(env, id); + return seedMutationIssue(ctx, env, id, labels); +} + +function mutationConfig( + state: GithubIssueEnv, + extra: Record = {}, +): Record { + return { "target-repo": state.repo, ...extra }; +} + +async function closeMutationIssue(state: MutationIssueState): Promise { + await closeByNumberOrTitle(state, state.issueNumber, state.title); +} + +async function deleteMatchingComments( + ctx: ScenarioContext, + state: MutationIssueState, + marker: string, +): Promise { + const comments = await listIssueComments(state.gh, state.issueNumber); + for (const comment of comments) { + if (comment.body.includes(detBody(ctx, marker))) { + await deleteIssueComment(state.gh, comment.id); + } + } +} + +async function requireGraphqlFeature( + env: GithubIssueEnv, + tool: string, + fields: readonly [type: string, field: string][], +): Promise { + for (const [type, field] of fields) { + if (!(await supportsGraphqlField(env.gh, type, field))) { + throw new SkipError( + `${tool}: GitHub GraphQL schema does not expose ${type}.${field}; ` + + `the required preview feature is unavailable on '${env.repo}'`, + ); + } + } +} + +export const commentOnGithubIssue: Scenario = { + id: "comment-on-github-issue", + tool: "comment-on-github-issue", + config: (_ctx, state) => mutationConfig(state), + setup: (ctx) => setupMutationIssue(ctx, "comment-on-github-issue"), + ndjson: async (ctx, state) => ({ + issue_number: state.issueNumber, + body: detBody(ctx, "comment-on-github-issue-comment"), + }), + env: async (_ctx, state) => executeEnv(state), + assert: async (ctx, state) => { + const expected = detBody(ctx, "comment-on-github-issue-comment"); + const comment = (await listIssueComments(state.gh, state.issueNumber)).find((c) => + c.body.includes(expected), + ); + if (!comment) throw new Error(`issue #${state.issueNumber} has no matching executor comment`); + if (!comment.body.includes(" @octocat".to_string(), + repository: Some("octo/re\u{0008}po".to_string()), + }; + result.sanitize_content_fields(); + assert!(!result.body.contains('\u{0007}')); + assert!(!result.body.contains("forged marker")); + assert!(result.body.contains("`@octocat`")); + assert_eq!(result.repository.as_deref(), Some("octo/repo")); + } + + #[tokio::test] + async fn dry_run_performs_no_http() { + let server = MockServer::start().await; + let mut ctx = context(&server, serde_json::json!({"target-repo": "octo/repo"})); + ctx.dry_run = true; + let mut result = make_result(GithubIssueNumber::Number(7)); + let execution = result.execute_sanitized(&ctx).await.unwrap(); + assert!(execution.success); + assert!(execution.message.contains("[DRY-RUN]")); + assert!(server.received_requests().await.unwrap().is_empty()); + } + + #[tokio::test] + async fn creates_comment_with_generic_marker_and_footer() { + let server = MockServer::start().await; + mount_issue_and_comment(&server, false).await; + let ctx = context(&server, serde_json::json!({"target-repo": "octo/repo"})); + let mut result = make_result(GithubIssueNumber::Number(7)); + let execution = result.execute_sanitized(&ctx).await.unwrap(); + assert!(execution.success, "{}", execution.message); + + let requests = server.received_requests().await.unwrap(); + let create = requests + .iter() + .find(|request| request.method.as_str() == "POST") + .unwrap(); + let payload: Value = serde_json::from_slice(&create.body).unwrap(); + let body = payload["body"].as_str().unwrap(); + assert!(body.contains("A useful status update.")); + assert!(body.contains(GITHUB_COMMENT_MARKER)); + assert!(!body.contains("pipeline-definition-id=123")); + assert!(body.contains("")); + assert!(body.contains("Pipeline: `Agent Pipeline`")); + } + + #[tokio::test] + async fn footer_can_be_disabled_but_generic_marker_cannot() { + let server = MockServer::start().await; + mount_issue_and_comment(&server, false).await; + let ctx = context( + &server, + serde_json::json!({ + "target-repo": "octo/repo", + "footer": false + }), + ); + let mut result = make_result(GithubIssueNumber::Number(7)); + let execution = result.execute_sanitized(&ctx).await.unwrap(); + assert!(execution.success); + let requests = server.received_requests().await.unwrap(); + let create = requests + .iter() + .find(|request| request.method.as_str() == "POST") + .unwrap(); + let payload: Value = serde_json::from_slice(&create.body).unwrap(); + let body = payload["body"].as_str().unwrap(); + assert!(body.contains(GITHUB_COMMENT_MARKER)); + assert!(!body.contains("pipeline-definition-id=123")); + assert!(!body.contains("Pipeline:")); + assert!(!body.contains("")); + } + + #[tokio::test] + async fn pull_requests_are_default_denied_and_can_be_enabled() { + let denied_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/repos/octo/repo/issues/7")) + .respond_with(ResponseTemplate::new(200).set_body_json(issue_json(7, true))) + .expect(1) + .mount(&denied_server) + .await; + let denied_ctx = context( + &denied_server, + serde_json::json!({"target-repo": "octo/repo"}), + ); + let mut denied = make_result(GithubIssueNumber::Number(7)); + let execution = denied.execute_sanitized(&denied_ctx).await.unwrap(); + assert!(!execution.success); + assert!(execution.message.contains("pull requests")); + assert_eq!(denied_server.received_requests().await.unwrap().len(), 1); + + let enabled_server = MockServer::start().await; + mount_issue_and_comment(&enabled_server, true).await; + let enabled_ctx = context( + &enabled_server, + serde_json::json!({ + "target-repo": "octo/repo", + "pull-requests": true + }), + ); + let mut enabled = make_result(GithubIssueNumber::Number(7)); + let execution = enabled.execute_sanitized(&enabled_ctx).await.unwrap(); + assert!(execution.success, "{}", execution.message); + } + + #[tokio::test] + async fn live_filter_failure_performs_no_write() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/repos/octo/repo/issues/7")) + .respond_with(ResponseTemplate::new(200).set_body_json(issue_json(7, false))) + .expect(1) + .mount(&server) + .await; + let ctx = context( + &server, + serde_json::json!({ + "target-repo": "octo/repo", + "required-labels": ["missing"] + }), + ); + let mut result = make_result(GithubIssueNumber::Number(7)); + let execution = result.execute_sanitized(&ctx).await.unwrap(); + assert!(!execution.success); + assert!(execution.message.contains("missing required labels")); + let requests = server.received_requests().await.unwrap(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].method.as_str(), "GET"); + } + + #[tokio::test] + async fn temporary_issue_id_resolves_before_commenting() { + let server = MockServer::start().await; + mount_issue_and_comment(&server, false).await; + let ctx = context(&server, serde_json::json!({"target-repo": "octo/repo"})); + let temporary_id = GithubTemporaryId::parse("#aw_issue1").unwrap(); + ctx.register_resolved_github_issue( + &temporary_id, + ResolvedGithubIssue { + repository: "octo/repo".to_string(), + number: 7, + url: "https://github.example/octo/repo/issues/7".to_string(), + }, + ) + .unwrap(); + let mut result = make_result(GithubIssueNumber::Temporary(temporary_id)); + let execution = result.execute_sanitized(&ctx).await.unwrap(); + assert!(execution.success, "{}", execution.message); + } + + #[tokio::test] + async fn hide_older_paginates_and_only_minimizes_matching_actor_and_marker() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/repos/octo/repo/issues/7")) + .respond_with(ResponseTemplate::new(200).set_body_json(issue_json(7, false))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/user")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "login": "ado-aw", + "id": 10, + "node_id": "U_10" + }))) + .expect(1) + .mount(&server) + .await; + let next = format!( + "<{}/page2/comments?per_page=100>; rel=\"next\"", + server.uri() + ); + Mock::given(method("GET")) + .and(path("/repos/octo/repo/issues/7/comments")) + .and(query_param("per_page", "100")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("Link", next) + .set_body_json(serde_json::json!([ + { + "id": 1, + "node_id": "IC_MATCH", + "body": "old\n", + "user": {"login": "ado-aw", "id": 10, "node_id": "U_10"} + }, + { + "id": 2, + "node_id": "IC_OTHER_ACTOR", + "body": "", + "user": {"login": "attacker", "id": 11, "node_id": "U_11"} + } + ])), + ) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/page2/comments")) + .and(query_param("per_page", "100")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([ + { + "id": 3, + "node_id": "IC_OTHER_PIPELINE", + "body": "", + "user": {"login": "ado-aw", "id": 10, "node_id": "U_10"} + } + ]))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/graphql")) + .and(body_json(serde_json::json!({ + "query": super::super::hide_github_issue_comment::MINIMIZE_COMMENT_MUTATION, + "variables": { + "input": {"subjectId": "IC_MATCH", "classifier": "OUTDATED"} + } + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { + "minimizeComment": { + "minimizedComment": {"isMinimized": true} + } + } + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/repos/octo/repo/issues/7/comments")) + .respond_with(ResponseTemplate::new(201).set_body_json(created_comment(99))) + .expect(1) + .mount(&server) + .await; + + let ctx = context( + &server, + serde_json::json!({ + "target-repo": "octo/repo", + "hide-older-comments": true, + "allowed-reasons": ["OUTDATED"] + }), + ); + let mut result = make_result(GithubIssueNumber::Number(7)); + let execution = result.execute_sanitized(&ctx).await.unwrap(); + assert!(execution.success, "{}", execution.message); + assert_eq!( + execution.data.as_ref().unwrap()["hidden_older_comments"], + serde_json::json!(1) + ); + } + + #[tokio::test] + async fn hide_older_derives_app_bot_identity_without_user_endpoint() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/repos/octo/repo/issues/7")) + .respond_with(ResponseTemplate::new(200).set_body_json(issue_json(7, false))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/user")) + .respond_with(ResponseTemplate::new(403).set_body_json(serde_json::json!({ + "message": "Resource not accessible by integration" + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/installation")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "app_slug": "ado-aw-app" + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/repos/octo/repo/issues/7/comments")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([ + { + "id": 1, + "node_id": "IC_APP", + "body": "", + "user": {"login": "ado-aw-app[bot]", "id": 10, "node_id": "BOT_10"} + }, + { + "id": 2, + "node_id": "IC_OTHER", + "body": "", + "user": {"login": "other-app[bot]", "id": 11, "node_id": "BOT_11"} + } + ]))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/graphql")) + .and(body_json(serde_json::json!({ + "query": super::super::hide_github_issue_comment::MINIMIZE_COMMENT_MUTATION, + "variables": { + "input": {"subjectId": "IC_APP", "classifier": "OUTDATED"} + } + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { + "minimizeComment": { + "minimizedComment": {"isMinimized": true} + } + } + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/repos/octo/repo/issues/7/comments")) + .respond_with(ResponseTemplate::new(201).set_body_json(created_comment(99))) + .expect(1) + .mount(&server) + .await; + + let ctx = context( + &server, + serde_json::json!({ + "target-repo": "octo/repo", + "hide-older-comments": true + }), + ); + let mut result = make_result(GithubIssueNumber::Number(7)); + let execution = result.execute_sanitized(&ctx).await.unwrap(); + assert!(execution.success, "{}", execution.message); + assert_eq!( + execution.data.as_ref().unwrap()["hidden_older_comments"], + serde_json::json!(1) + ); + } + + #[tokio::test] + async fn malformed_matching_old_comment_fails_before_any_write() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/repos/octo/repo/issues/7")) + .respond_with(ResponseTemplate::new(200).set_body_json(issue_json(7, false))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/user")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "login": "ado-aw", + "id": 10, + "node_id": "U_10" + }))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/repos/octo/repo/issues/7/comments")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([ + { + "id": 1, + "body": "", + "user": {"login": "ado-aw", "id": 10, "node_id": "U_10"} + } + ]))) + .mount(&server) + .await; + let ctx = context( + &server, + serde_json::json!({ + "target-repo": "octo/repo", + "hide-older-comments": true + }), + ); + let mut result = make_result(GithubIssueNumber::Number(7)); + let execution = result.execute_sanitized(&ctx).await.unwrap(); + assert!(!execution.success); + assert!(execution.message.contains("no GraphQL node_id")); + assert!( + server + .received_requests() + .await + .unwrap() + .iter() + .all(|request| request.method.as_str() == "GET") + ); + } + + #[tokio::test] + async fn hide_older_reason_policy_and_missing_marker_identity_fail_before_http() { + let server = MockServer::start().await; + let ctx = context( + &server, + serde_json::json!({ + "target-repo": "octo/repo", + "hide-older-comments": true, + "allowed-reasons": ["SPAM"] + }), + ); + let mut result = make_result(GithubIssueNumber::Number(7)); + let error = result.execute_sanitized(&ctx).await.unwrap_err(); + assert!(error.to_string().contains("allowed-reasons")); + assert!(server.received_requests().await.unwrap().is_empty()); + + let mut missing_id_ctx = context( + &server, + serde_json::json!({ + "target-repo": "octo/repo", + "hide-older-comments": true + }), + ); + missing_id_ctx.definition_id = None; + let mut result = make_result(GithubIssueNumber::Number(7)); + let execution = result.execute_sanitized(&missing_id_ctx).await.unwrap(); + assert!(!execution.success); + assert!(execution.message.contains("SYSTEM_DEFINITIONID")); + assert!(server.received_requests().await.unwrap().is_empty()); + } + + #[tokio::test] + async fn ordinary_comment_does_not_require_definition_id() { + let server = MockServer::start().await; + mount_issue_and_comment(&server, false).await; + let mut ctx = context(&server, serde_json::json!({"target-repo": "octo/repo"})); + ctx.definition_id = None; + let mut result = make_result(GithubIssueNumber::Number(7)); + let execution = result.execute_sanitized(&ctx).await.unwrap(); + assert!(execution.success, "{}", execution.message); + let requests = server.received_requests().await.unwrap(); + let create = requests + .iter() + .find(|request| request.method.as_str() == "POST") + .unwrap(); + let payload: Value = serde_json::from_slice(&create.body).unwrap(); + let body = payload["body"].as_str().unwrap(); + assert!(body.contains(GITHUB_COMMENT_MARKER)); + assert!(!body.contains("pipeline-definition-id=")); + } + + #[tokio::test] + async fn creation_api_failures_are_explicit() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/repos/octo/repo/issues/7")) + .respond_with(ResponseTemplate::new(200).set_body_json(issue_json(7, false))) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/repos/octo/repo/issues/7/comments")) + .respond_with(ResponseTemplate::new(403).set_body_json(serde_json::json!({ + "message": "Resource not accessible" + }))) + .mount(&server) + .await; + let ctx = context(&server, serde_json::json!({"target-repo": "octo/repo"})); + let mut result = make_result(GithubIssueNumber::Number(7)); + let execution = result.execute_sanitized(&ctx).await.unwrap(); + assert!(!execution.success); + assert!(execution.message.contains("HTTP 403")); + assert!(execution.message.contains("Resource not accessible")); + } + + #[tokio::test] + async fn missing_configuration_token_and_invalid_config_fail_cleanly() { + let mut result = make_result(GithubIssueNumber::Number(7)); + let execution = result + .execute_sanitized(&ExecutionContext::default()) + .await + .unwrap(); + assert!(!execution.success); + assert!(execution.message.contains("not configured")); + + let server = MockServer::start().await; + let mut no_token_ctx = context(&server, serde_json::json!({"target-repo": "octo/repo"})); + no_token_ctx.github_token = None; + let mut result = make_result(GithubIssueNumber::Number(7)); + let execution = result.execute_sanitized(&no_token_ctx).await.unwrap(); + assert!(!execution.success); + assert!(execution.message.contains("ADO_AW_GITHUB_TOKEN")); + + let invalid_ctx = context( + &server, + serde_json::json!({ + "target-repo": "octo/repo", + "issues": false, + "pull-requests": false + }), + ); + let mut result = make_result(GithubIssueNumber::Number(7)); + let error = result.execute_sanitized(&invalid_ctx).await.unwrap_err(); + assert!(error.to_string().contains("at least one")); + assert!(server.received_requests().await.unwrap().is_empty()); + } +} diff --git a/src/safe_outputs/comment_on_work_item.rs b/src/safe_outputs/comment_on_work_item.rs index 5d64b1160..31224d6b3 100644 --- a/src/safe_outputs/comment_on_work_item.rs +++ b/src/safe_outputs/comment_on_work_item.rs @@ -259,7 +259,7 @@ impl Executor for CommentOnWorkItemResult { .context("No access token available (SYSTEM_ACCESSTOKEN or AZURE_DEVOPS_EXT_PAT)")?; debug!("ADO org: {}, project: {}", org_url, project); - let config: CommentOnWorkItemConfig = ctx.get_tool_config("comment-on-work-item"); + let config: CommentOnWorkItemConfig = ctx.get_tool_config("comment-on-work-item")?; debug!("Target: {:?}", config.target); let target = match &config.target { diff --git a/src/safe_outputs/create_branch.rs b/src/safe_outputs/create_branch.rs index 4df10f187..0b4ac3042 100644 --- a/src/safe_outputs/create_branch.rs +++ b/src/safe_outputs/create_branch.rs @@ -212,7 +212,7 @@ impl Executor for CreateBranchResult { .context("No access token available (SYSTEM_ACCESSTOKEN or AZURE_DEVOPS_EXT_PAT)")?; debug!("ADO org: {}, project: {}", org_url, project); - let config: CreateBranchConfig = ctx.get_tool_config("create-branch"); + let config: CreateBranchConfig = ctx.get_tool_config("create-branch")?; debug!("Branch pattern: {:?}", config.branch_pattern); debug!("Allowed repositories: {:?}", config.allowed_repositories); debug!( @@ -463,10 +463,7 @@ mod tests { }; let result: Result = params.try_into(); let err = result.unwrap_err().to_string(); - assert!( - err.contains("source_commit"), - "unexpected error: {err}" - ); + assert!(err.contains("source_commit"), "unexpected error: {err}"); } #[test] @@ -479,10 +476,7 @@ mod tests { }; let result: Result = params.try_into(); let err = result.unwrap_err().to_string(); - assert!( - err.contains("repository"), - "unexpected error: {err}" - ); + assert!(err.contains("repository"), "unexpected error: {err}"); } #[test] diff --git a/src/safe_outputs/create_git_tag.rs b/src/safe_outputs/create_git_tag.rs index 2dd8f1e9a..a57527aad 100644 --- a/src/safe_outputs/create_git_tag.rs +++ b/src/safe_outputs/create_git_tag.rs @@ -240,7 +240,7 @@ impl Executor for CreateGitTagResult { .context("No access token available (SYSTEM_ACCESSTOKEN or AZURE_DEVOPS_EXT_PAT)")?; debug!("ADO org: {}, project: {}", org_url, project); - let config: CreateGitTagConfig = ctx.get_tool_config("create-git-tag"); + let config: CreateGitTagConfig = ctx.get_tool_config("create-git-tag")?; debug!("Tag pattern: {:?}", config.tag_pattern); debug!("Allowed repositories: {:?}", config.allowed_repositories); @@ -480,10 +480,7 @@ mod tests { }; let result: Result = params.try_into(); let err = result.unwrap_err().to_string(); - assert!( - err.contains("pipeline command"), - "unexpected error: {err}" - ); + assert!(err.contains("pipeline command"), "unexpected error: {err}"); } #[test] diff --git a/src/safe_outputs/create_github_issue.rs b/src/safe_outputs/create_github_issue.rs index a92462b02..b4e2fc9dc 100644 --- a/src/safe_outputs/create_github_issue.rs +++ b/src/safe_outputs/create_github_issue.rs @@ -5,24 +5,25 @@ //! [`ExecutionContext::github_token`]; Agent and Detection never see it. //! //! Notable design points: -//! * `target-repo` is operator-only — the agent never supplies it and cannot -//! redirect issues to a different repo. +//! * Agent repository selection is bounded by exact operator-configured +//! `target-repo` and `allowed-repos` entries. //! * Labels are merged from a static operator-configured list and an //! agent-supplied list. Agent labels are validated against `allowed-labels` //! (wildcard-aware via [`crate::safe_outputs::tag_matches_pattern`]). //! * Assignees are merged the same way without an allowlist gate (out of //! scope for v1). -use anyhow::{Context, ensure}; +use anyhow::ensure; use log::{debug, info}; -use percent_encoding::utf8_percent_encode; -use regex_lite::Regex; +use reqwest::Method; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use std::sync::OnceLock; -use super::PATH_SEGMENT; -use crate::safe_outputs::{ExecutionContext, ExecutionResult, Executor, Validate}; +use crate::safe_outputs::{ + ExecutionContext, ExecutionResult, Executor, GithubClient, GithubRepositoryPolicy, Validate, + build_github_trace_footer, merge_github_values, resolve_github_repository, + validate_github_repository, +}; use crate::sanitize::{SanitizeContent, sanitize as sanitize_text}; use crate::secure::GithubTemporaryId; use crate::tool_result; @@ -47,6 +48,11 @@ pub struct CreateGithubIssueParams { #[serde(default)] pub assignees: Vec, + /// Optional target repository. Must exactly match `target-repo` or an + /// `allowed-repos` entry. + #[serde(default)] + pub repository: Option, + /// Temporary identifier used by later safe outputs in the same run. #[serde(default)] pub temporary_id: Option, @@ -70,6 +76,9 @@ impl Validate for CreateGithubIssueParams { ensure!(!assignee.is_empty(), "assignee must not be empty"); reject_pipeline_injection(assignee, "create-github-issue.assignee")?; } + if let Some(repository) = self.repository.as_deref() { + validate_github_repository(repository)?; + } Ok(()) } } @@ -87,6 +96,8 @@ tool_result! { #[serde(default)] assignees: Vec, #[serde(default)] + repository: Option, + #[serde(default)] temporary_id: Option, } } @@ -101,6 +112,10 @@ impl SanitizeContent for CreateGithubIssueResult { for assignee in &mut self.assignees { *assignee = assignee.chars().filter(|c| !c.is_control()).collect(); } + self.repository = self + .repository + .as_deref() + .map(crate::sanitize::sanitize_config); } } @@ -113,6 +128,10 @@ pub struct CreateGithubIssueConfig { #[serde(default, rename = "target-repo")] pub target_repo: Option, + /// Additional exact repositories the agent may select. + #[serde(default, rename = "allowed-repos")] + pub allowed_repos: Vec, + /// Optional prefix prepended to every agent-supplied title (e.g. /// `"[pipeline-failure] "`). #[serde(default, rename = "title-prefix")] @@ -147,124 +166,6 @@ pub struct CreateGithubIssueConfig { pub max: Option, } -/// Compiled regex for `target-repo` validation. -/// -/// GitHub repo references take the form `owner/repo`. Owner segments -/// (logins of users or organisations) admit alphanumerics and hyphens -/// and must not start or end with a hyphen. Repository segments admit -/// alphanumerics, hyphens, dots, and underscores and must not be `.` -/// or `..`. We intentionally reject underscores and dots in the owner -/// because GitHub does too. -fn target_repo_regex() -> &'static Regex { - static RE: OnceLock = OnceLock::new(); - RE.get_or_init(|| { - Regex::new(r"^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?/[A-Za-z0-9._-]+$") - .expect("target_repo regex is well-formed") - }) -} - -/// Validate that `target-repo` is shaped like `owner/repo`. -pub(crate) fn validate_target_repo(target_repo: &str) -> anyhow::Result<()> { - ensure!( - !target_repo.is_empty(), - "target-repo is required (expected 'owner/repo')" - ); - ensure!( - target_repo_regex().is_match(target_repo), - "target-repo '{}' is not in 'owner/repo' format \ - (owner: alphanumerics/hyphens; repo: alphanumerics/dots/hyphens/underscores)", - target_repo - ); - if let Some((_owner, repo)) = target_repo.split_once('/') { - ensure!( - repo != "." && repo != "..", - "target-repo repo segment must not be '.' or '..'" - ); - } - Ok(()) -} - -pub(crate) fn resolve_target_repo( - configured: Option<&str>, - ctx: &ExecutionContext, -) -> Result { - let target = if let Some(target) = configured { - target.to_string() - } else { - let provider = ctx.repository_provider.as_deref().unwrap_or_default(); - if !provider.eq_ignore_ascii_case("github") - && !provider.eq_ignore_ascii_case("githubenterprise") - { - return Err(ExecutionResult::failure( - "target-repo is required when the Azure DevOps pipeline source is not GitHub", - )); - } - if provider.eq_ignore_ascii_case("githubenterprise") - && ctx.github_api_url.eq_ignore_ascii_case("https://api.github.com") - { - return Err(ExecutionResult::failure( - "safe-outputs.github-api-url or GitHub App api-url is required for a \ - GitHub Enterprise source", - )); - } - match ctx.repository_name.clone() { - Some(name) => name, - None => { - return Err(ExecutionResult::failure( - "BUILD_REPOSITORY_NAME is not set; configure target-repo explicitly", - )); - } - } - }; - validate_target_repo(&target).map_err(|e| ExecutionResult::failure(e.to_string()))?; - Ok(target) -} - -/// Build the auto-appended traceability footer. -/// -/// Embeds a stable `` marker so future tooling can locate -/// generated content without reflowing the body. -fn build_footer(ctx: &ExecutionContext) -> String { - let mut lines: Vec = Vec::new(); - lines.push("".to_string()); - lines.push("---".to_string()); - if let Some(name) = ctx.definition_name.as_ref() { - lines.push(format!("Pipeline: `{name}`")); - } - if let Some(build_id) = ctx.build_id { - if let (Some(org_url), Some(project)) = (ctx.ado_org_url.as_ref(), ctx.ado_project.as_ref()) - { - let url = format!( - "{}/{}/_build/results?buildId={}", - org_url.trim_end_matches('/'), - project, - build_id - ); - lines.push(format!("Run: <{url}>")); - } else { - lines.push(format!("Build: {build_id}")); - } - } - if let Some(reason) = ctx.build_reason.as_ref() { - lines.push(format!("Trigger: `{reason}`")); - } - lines.join("\n") -} - -/// Merge static + agent-supplied strings (case-insensitive dedupe). -fn merge_dedup_strings(static_items: &[String], agent_items: &[String]) -> Vec { - let mut all = static_items.to_vec(); - for item in agent_items { - if !all - .iter() - .any(|existing| existing.eq_ignore_ascii_case(item)) - { - all.push(item.clone()); - } - } - all -} - /// Sentinel pattern in `allowed-labels` that opts out of the default-deny /// behaviour and admits any agent-supplied label. const ALLOWED_LABELS_ANY: &str = "*"; @@ -304,14 +205,18 @@ impl Executor for CreateGithubIssueResult { } }; - let config: CreateGithubIssueConfig = ctx.get_tool_config("create-github-issue"); + let config: CreateGithubIssueConfig = ctx.get_tool_config("create-github-issue")?; if config.require_temporary_id && self.temporary_id.is_none() { return Ok(ExecutionResult::failure( "create-github-issue requires temporary_id because \ safe-outputs.create-github-issue.require-temporary-id is true", )); } - let target_repo = match resolve_target_repo(config.target_repo.as_deref(), ctx) { + let target_repo = match resolve_github_repository( + self.repository.as_deref(), + GithubRepositoryPolicy::new(config.target_repo.as_deref(), &config.allowed_repos), + ctx, + ) { Ok(target) => target, Err(result) => return Ok(result), }; @@ -382,21 +287,12 @@ impl Executor for CreateGithubIssueResult { final_title.len() ))); } - let body_with_footer = format!("{}\n\n{}", self.body, build_footer(ctx)); - let all_labels = merge_dedup_strings(&config.labels, &self.labels); - let all_assignees = merge_dedup_strings(&config.assignees, &self.assignees); - - // Split target-repo only after validation. - let (owner, repo) = target_repo - .split_once('/') - .context("target-repo must be 'owner/repo'")?; - - let url = format!( - "{}/repos/{}/{}/issues", - ctx.github_api_url.trim_end_matches('/'), - utf8_percent_encode(owner, PATH_SEGMENT), - utf8_percent_encode(repo, PATH_SEGMENT), - ); + let body_with_footer = format!("{}\n\n{}", self.body, build_github_trace_footer(ctx)); + let all_labels = merge_github_values(&config.labels, &self.labels); + let all_assignees = merge_github_values(&config.assignees, &self.assignees); + + let client = GithubClient::new(&ctx.github_api_url, token)?; + let url = client.issues_url(&target_repo)?; debug!("POSTing to {}", url); let payload = serde_json::json!({ @@ -406,25 +302,13 @@ impl Executor for CreateGithubIssueResult { "assignees": all_assignees, }); - let user_agent = format!("ado-aw/{}", env!("CARGO_PKG_VERSION")); - let client = reqwest::Client::new(); - let response = client - .post(&url) - .header("Accept", "application/vnd.github+json") - .header("X-GitHub-Api-Version", "2022-11-28") - .header("User-Agent", user_agent) - .bearer_auth(token) - .json(&payload) - .send() - .await - .context("Failed to send request to GitHub API")?; - - let status = response.status(); + let response = client.send(Method::POST, url, Some(&payload)).await?; + + let status = response.status; if status.is_success() { let body: serde_json::Value = response - .json() - .await - .context("Failed to parse GitHub API response")?; + .json("Failed to parse GitHub API response") + .map_err(anyhow::Error::new)?; let Some(number) = body .get("number") .and_then(|v| v.as_u64()) @@ -479,15 +363,10 @@ impl Executor for CreateGithubIssueResult { }), )) } else { - let body_text = response - .text() - .await - .unwrap_or_else(|_| "".to_string()); - Ok(ExecutionResult::failure(format!( - "Failed to file GitHub issue (HTTP {}): {}", - status, - crate::sanitize::neutralize_pipeline_commands(&body_text) - ))) + let error = response + .require_success("Failed to file GitHub issue") + .expect_err("non-success response must produce an API error"); + Ok(ExecutionResult::failure(error.to_string())) } } } @@ -495,7 +374,7 @@ impl Executor for CreateGithubIssueResult { #[cfg(test)] mod tests { use super::*; - use crate::safe_outputs::ToolResult; + use crate::safe_outputs::{ToolResult, resolve_target_repo, validate_target_repo}; use std::collections::HashMap; use std::path::PathBuf; @@ -520,6 +399,7 @@ mod tests { body: "The agent step failed during stage 1 with a network timeout.".to_string(), labels: vec![], assignees: vec![], + repository: None, temporary_id: None, } } @@ -565,6 +445,15 @@ mod tests { assert!(::validate(¶ms).is_err()); } + #[test] + fn test_validate_rejects_malformed_repository() { + let params = CreateGithubIssueParams { + repository: Some("octo/$(TOKEN)".to_string()), + ..valid_params() + }; + assert!(params.validate().is_err()); + } + #[test] fn test_sanitize_strips_control_chars() { let mut result = CreateGithubIssueResult { @@ -573,6 +462,7 @@ mod tests { body: "body\u{0008}with\u{0001}ctl chars (more than 30 characters total)".to_string(), labels: vec!["la\u{0007}bel".to_string()], assignees: vec!["jo\u{0008}hn".to_string()], + repository: Some("octo/repo".to_string()), temporary_id: None, }; result.sanitize_content_fields(); @@ -591,6 +481,7 @@ mod tests { body: "anything".to_string(), labels: vec![], assignees: vec![], + repository: None, temporary_id: None, }; assert_eq!( @@ -697,16 +588,11 @@ mod tests { ); } - /// `ExecutionContext::get_tool_config` deserializes with - /// `.ok().unwrap_or_default()`, so a config that fails to deserialize - /// silently becomes `Default` — i.e. `target_repo: None`, which resolves to - /// the *current* repository. That is why `target-repo` stays a plain - /// `String` validated by `validate_target_repo()` at each call site rather - /// than a `secure.rs` newtype validated at deserialization time: a newtype - /// would turn a malformed value into a silent redirect instead of a loud - /// failure. `test_execute_fails_when_target_repo_invalid` covers the plain - /// rejection; this pins the *no silent redirect* half, with a usable - /// current repository deliberately present in the context. + /// Config shape errors fail during strict deserialization, while the + /// repository slug itself is validated before any request is sent. + /// `test_execute_fails_when_target_repo_invalid` covers the plain + /// rejection; this pins the *no redirect* half, with a usable current + /// repository deliberately present in the context. #[tokio::test] async fn malformed_target_repo_does_not_redirect_to_current_repository() { let mut ctx = ctx_with_config( @@ -733,7 +619,7 @@ mod tests { #[test] fn test_merge_dedup_strings_dedupes_case_insensitively() { - let merged = merge_dedup_strings( + let merged = merge_github_values( &["bug".into(), "Triage".into()], &["BUG".into(), "fresh".into()], ); @@ -953,6 +839,65 @@ mod tests { ); } + #[tokio::test] + async fn agent_repository_must_be_an_exact_allowed_repo() { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/repos/octo/allowed/issues")) + .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({ + "number": 12, + "html_url": "https://github.example/octo/allowed/issues/12" + }))) + .expect(1) + .mount(&server) + .await; + let mut ctx = ctx_with_config( + serde_json::json!({ + "target-repo": "octo/default", + "allowed-repos": ["octo/allowed"] + }), + Some("token".to_string()), + ); + ctx.github_api_url = server.uri(); + let mut params = valid_params(); + params.repository = Some("OCTO/ALLOWED".to_string()); + let mut result: CreateGithubIssueResult = params.try_into().unwrap(); + let execution = result.execute_sanitized(&ctx).await.unwrap(); + assert!(execution.success, "create failed: {}", execution.message); + assert_eq!( + execution + .data + .as_ref() + .and_then(|data| data["target_repo"].as_str()), + Some("octo/allowed") + ); + } + + #[tokio::test] + async fn denied_agent_repository_fails_before_http() { + use wiremock::MockServer; + + let server = MockServer::start().await; + let mut ctx = ctx_with_config( + serde_json::json!({ + "target-repo": "octo/default", + "allowed-repos": ["octo/allowed"] + }), + Some("token".to_string()), + ); + ctx.github_api_url = server.uri(); + let mut params = valid_params(); + params.repository = Some("octo/denied".to_string()); + let mut result: CreateGithubIssueResult = params.try_into().unwrap(); + let execution = result.execute_sanitized(&ctx).await.unwrap(); + assert!(!execution.success); + assert!(execution.message.contains("not an exact")); + assert!(server.received_requests().await.unwrap().is_empty()); + } + #[tokio::test] async fn test_execute_neutralizes_pipeline_command_in_label_error() { // Even though Validate would reject this label up front, Stage 3 @@ -966,6 +911,7 @@ mod tests { body: "This is a sufficiently long body for the issue parameters.".to_string(), labels: vec!["##vso[task.complete]".to_string()], assignees: vec![], + repository: None, temporary_id: None, }; let ctx = ctx_with_config( @@ -1003,6 +949,7 @@ mod tests { fn test_config_round_trips_kebab_case() { let yaml = r#" target-repo: githubnext/ado-aw +allowed-repos: [githubnext/other] title-prefix: "[bug] " labels: [a] allowed-labels: ["agent-*"] @@ -1010,6 +957,7 @@ assignees: [u1] "#; let cfg: CreateGithubIssueConfig = serde_yaml::from_str(yaml).unwrap(); assert_eq!(cfg.target_repo.as_deref(), Some("githubnext/ado-aw")); + assert_eq!(cfg.allowed_repos, vec!["githubnext/other".to_string()]); assert_eq!(cfg.title_prefix.as_deref(), Some("[bug] ")); assert_eq!(cfg.labels, vec!["a".to_string()]); assert_eq!(cfg.allowed_labels, vec!["agent-*".to_string()]); @@ -1039,7 +987,7 @@ unexpected: oops build_reason: Some("Manual".to_string()), ..Default::default() }; - let footer = build_footer(&ctx); + let footer = build_github_trace_footer(&ctx); assert!(footer.contains("")); assert!(footer.contains("buildId=42")); assert!(footer.contains("dogfood")); diff --git a/src/safe_outputs/create_pull_request.rs b/src/safe_outputs/create_pull_request.rs index 7e11674cf..ad622c6a4 100644 --- a/src/safe_outputs/create_pull_request.rs +++ b/src/safe_outputs/create_pull_request.rs @@ -137,10 +137,7 @@ fn find_identity_in_response(data: &serde_json::Value, reviewer: &str) -> Option } // Fall back to first result if no exact match - let local_id = identities - .first()? - .get("localId")? - .as_str()?; + let local_id = identities.first()?.get("localId")?.as_str()?; debug!( "Resolved reviewer '{}' to first match ID '{}'", reviewer, local_id @@ -610,7 +607,7 @@ impl Executor for CreatePrResult { debug!("Source branch: {}", self.source_branch); debug!("Patch file: {}", self.patch_file); - let config: CreatePrConfig = ctx.get_tool_config("create-pull-request"); + let config: CreatePrConfig = ctx.get_tool_config("create-pull-request")?; debug!("Target branch from config: {}", config.target_branch); debug!("Draft: {}", config.draft); debug!("Auto-complete: {}", config.auto_complete); @@ -644,12 +641,11 @@ impl Executor for CreatePrResult { self.repository ); let repository_alias = - crate::safe_outputs::canonical_repository_alias(&self.repository, ctx) - .or_else(|| { - ctx.allowed_repositories - .is_empty() - .then(|| "self".to_string()) - }); + crate::safe_outputs::canonical_repository_alias(&self.repository, ctx).or_else(|| { + ctx.allowed_repositories + .is_empty() + .then(|| "self".to_string()) + }); let Some(repository_alias) = repository_alias else { warn!( "Repository '{}' not in allowed list: {:?}", @@ -1577,7 +1573,10 @@ async fn push_new_branch( if !retry_response.status().is_success() { let retry_status = retry_response.status(); let retry_body_text = retry_response.text().await.unwrap_or_default(); - warn!("Retry push also failed: {} - {}", retry_status, retry_body_text); + warn!( + "Retry push also failed: {} - {}", + retry_status, retry_body_text + ); return Ok(Err(ExecutionResult::failure(format!( "Failed to push changes after retry: {} - {}", retry_status, retry_body_text @@ -1631,7 +1630,10 @@ fn handle_no_changes(config: &CreatePrConfig, skipped_symlinks: &[String]) -> Ex "No changes detected after applying patch (if-no-changes: ignore){}", symlink_suffix ); - ExecutionResult::success(format!("No changes detected — nothing to do{}", symlink_suffix)) + ExecutionResult::success(format!( + "No changes detected — nothing to do{}", + symlink_suffix + )) } IfNoChanges::Warn => { warn!( @@ -2576,8 +2578,7 @@ mod tests { )]), ..Default::default() }; - let alias = - crate::safe_outputs::canonical_repository_alias("Project/tools", &ctx).unwrap(); + let alias = crate::safe_outputs::canonical_repository_alias("Project/tools", &ctx).unwrap(); let cfg = CreatePrConfig { infer_target_from_checkout_ref: true, ..Default::default() @@ -3197,6 +3198,7 @@ index 0000000..abcdefg build_number: None, build_reason: None, definition_name: None, + definition_id: None, source_branch: None, source_branch_name: None, source_version: None, diff --git a/src/safe_outputs/create_wiki_page.rs b/src/safe_outputs/create_wiki_page.rs index cf7ab943f..42cb4a4d6 100644 --- a/src/safe_outputs/create_wiki_page.rs +++ b/src/safe_outputs/create_wiki_page.rs @@ -216,7 +216,7 @@ impl Executor for CreateWikiPageResult { .as_ref() .context("No access token available (SYSTEM_ACCESSTOKEN or AZURE_DEVOPS_EXT_PAT)")?; - let config: CreateWikiPageConfig = ctx.get_tool_config("create-wiki-page"); + let config: CreateWikiPageConfig = ctx.get_tool_config("create-wiki-page")?; let wiki_name = config .wiki_name @@ -464,7 +464,10 @@ mod tests { let result: Result = params.try_into(); assert!(result.is_err()); assert!( - result.unwrap_err().to_string().contains("path must not be empty"), + result + .unwrap_err() + .to_string() + .contains("path must not be empty"), "expected 'path must not be empty' in error" ); } @@ -505,7 +508,10 @@ mod tests { let result: Result = params.try_into(); assert!(result.is_err()); assert!( - result.unwrap_err().to_string().contains("at least 10 characters"), + result + .unwrap_err() + .to_string() + .contains("at least 10 characters"), "expected 'at least 10 characters' in error" ); } diff --git a/src/safe_outputs/create_work_item.rs b/src/safe_outputs/create_work_item.rs index aa7476d7b..dae17cb93 100644 --- a/src/safe_outputs/create_work_item.rs +++ b/src/safe_outputs/create_work_item.rs @@ -259,17 +259,15 @@ async fn maybe_build_artifact_link_op( let repo_id = match repo_id { Some(id) => id, - None => { - match resolve_repository_id(client, org_url, project, token, repo_name).await { - Ok(id) => id, - Err(e) => { - return Err(ExecutionResult::failure(format!( - "Failed to resolve repository '{}': {}", - repo_name, e - ))); - } + None => match resolve_repository_id(client, org_url, project, token, repo_name).await { + Ok(id) => id, + Err(e) => { + return Err(ExecutionResult::failure(format!( + "Failed to resolve repository '{}': {}", + repo_name, e + ))); } - } + }, }; let op = artifact_link_op(project, &repo_id, &config.branch); @@ -356,7 +354,7 @@ impl Executor for CreateWorkItemResult { debug!("ADO org: {}, project: {}", org_url, project); // Get tool-specific configuration - let config: CreateWorkItemConfig = ctx.get_tool_config("create-work-item"); + let config: CreateWorkItemConfig = ctx.get_tool_config("create-work-item")?; debug!("Work item type: {}", config.work_item_type); debug!("Area path: {:?}", config.area_path); debug!("Iteration path: {:?}", config.iteration_path); diff --git a/src/safe_outputs/github_api.rs b/src/safe_outputs/github_api.rs new file mode 100644 index 000000000..3d3c2ce1b --- /dev/null +++ b/src/safe_outputs/github_api.rs @@ -0,0 +1,898 @@ +//! Shared GitHub REST and GraphQL client for Stage 3 safe outputs. +#![allow(dead_code)] // The remaining GitHub issue tools consume this shared surface in later slices. + +use anyhow::{Context, ensure}; +use reqwest::header::{ACCEPT, AUTHORIZATION, HeaderMap, LINK, USER_AGENT}; +use reqwest::{Method, StatusCode}; +use serde::Deserialize; +use serde::de::DeserializeOwned; +use serde_json::Value; +use url::Url; + +use super::{GithubTargetKind, GithubTargetMetadata, validate_github_repository}; + +const GITHUB_ACCEPT: &str = "application/vnd.github+json"; +const GITHUB_API_VERSION: &str = "2022-11-28"; +const MAX_ERROR_CHARS: usize = 4096; +const MAX_PAGES: usize = 1000; + +/// Captured GitHub response with helpers for sanitized API failures. +#[derive(Debug)] +pub struct GithubResponse { + pub status: StatusCode, + pub headers: HeaderMap, + body: String, +} + +impl GithubResponse { + pub fn is_success(&self) -> bool { + self.status.is_success() + } + + pub fn body(&self) -> &str { + &self.body + } + + pub fn json(&self, operation: &str) -> Result { + serde_json::from_str(&self.body).map_err(|error| GithubApiError { + operation: operation.to_string(), + status: Some(self.status), + message: format!("GitHub returned malformed JSON: {error}"), + }) + } + + pub fn require_success(self, operation: &str) -> Result { + if self.is_success() { + Ok(self) + } else { + Err(GithubApiError::from_response(operation, self)) + } + } +} + +/// Sanitized HTTP, GraphQL, or response-shape failure. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GithubApiError { + pub operation: String, + pub status: Option, + pub message: String, +} + +impl GithubApiError { + fn from_response(operation: &str, response: GithubResponse) -> Self { + Self { + operation: operation.to_string(), + status: Some(response.status), + message: sanitize_github_error_body(&response.body), + } + } + + fn graphql(operation: &str, status: StatusCode, errors: &[Value]) -> Self { + let messages: Vec = errors + .iter() + .map(|error| { + let message = error + .get("message") + .and_then(Value::as_str) + .unwrap_or("unknown GraphQL error"); + let kind = error.get("type").and_then(Value::as_str).or_else(|| { + error + .get("extensions") + .and_then(|extensions| extensions.get("type")) + .and_then(Value::as_str) + }); + match kind { + Some(kind) => format!("{kind}: {message}"), + None => message.to_string(), + } + }) + .collect(); + Self { + operation: operation.to_string(), + status: Some(status), + message: sanitize_github_error_body(&messages.join("; ")), + } + } +} + +impl std::fmt::Display for GithubApiError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self.status { + Some(status) => write!( + formatter, + "{} (HTTP {}): {}", + self.operation, status, self.message + ), + None => write!(formatter, "{}: {}", self.operation, self.message), + } + } +} + +impl std::error::Error for GithubApiError {} + +/// Minimal issue comment metadata needed by comment policy. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct GithubIssueComment { + pub id: u64, + pub node_id: Option, + #[serde(default)] + pub body: String, + pub html_url: Option, + pub user: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct GithubUser { + pub login: String, + pub id: Option, + pub node_id: Option, +} + +#[derive(Debug, Deserialize)] +struct GithubInstallation { + app_slug: String, +} + +/// Minimal milestone metadata needed by milestone assignment. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct GithubMilestone { + pub number: u64, + pub title: String, + pub state: String, + pub node_id: Option, +} + +/// GitHub client with fixed authentication, headers, REST base, and GraphQL URL. +#[derive(Clone)] +pub struct GithubClient { + http: reqwest::Client, + rest_api_url: Url, + graphql_url: Url, + token: String, +} + +impl GithubClient { + pub fn new(rest_api_url: &str, token: &str) -> anyhow::Result { + ensure!(!token.is_empty(), "GitHub token must not be empty"); + let rest_api_url = validate_rest_api_url(rest_api_url)?; + let graphql_url = graphql_url_from_rest_api_url(rest_api_url.as_str())?; + let http = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .context("Failed to build GitHub API client")?; + Ok(Self { + http, + rest_api_url, + graphql_url, + token: token.to_string(), + }) + } + + pub fn rest_api_url(&self) -> &Url { + &self.rest_api_url + } + + pub fn graphql_url(&self) -> &Url { + &self.graphql_url + } + + pub fn issues_url(&self, repository: &str) -> anyhow::Result { + self.repository_route(repository, &["issues"]) + } + + pub fn issue_url(&self, repository: &str, number: u64) -> anyhow::Result { + ensure!(number > 0, "GitHub issue number must be positive"); + self.repository_route(repository, &["issues", &number.to_string()]) + } + + pub fn issue_comments_url(&self, repository: &str, number: u64) -> anyhow::Result { + ensure!(number > 0, "GitHub issue number must be positive"); + self.repository_route(repository, &["issues", &number.to_string(), "comments"]) + } + + pub fn issue_comment_url(&self, repository: &str, comment_id: u64) -> anyhow::Result { + ensure!(comment_id > 0, "GitHub comment ID must be positive"); + self.repository_route(repository, &["issues", "comments", &comment_id.to_string()]) + } + + pub fn milestones_url(&self, repository: &str) -> anyhow::Result { + self.repository_route(repository, &["milestones"]) + } + + pub async fn send( + &self, + method: Method, + url: Url, + body: Option<&Value>, + ) -> anyhow::Result { + self.ensure_same_origin(&url)?; + let mut request = self + .http + .request(method, url) + .header(ACCEPT, GITHUB_ACCEPT) + .header("X-GitHub-Api-Version", GITHUB_API_VERSION) + .header(USER_AGENT, format!("ado-aw/{}", env!("CARGO_PKG_VERSION"))) + .header(AUTHORIZATION, format!("Bearer {}", self.token)); + if let Some(body) = body { + request = request.json(body); + } + let response = request + .send() + .await + .context("Failed to send request to GitHub API")?; + let status = response.status(); + let headers = response.headers().clone(); + let body = response + .text() + .await + .context("Failed to read response from GitHub API")?; + Ok(GithubResponse { + status, + headers, + body, + }) + } + + pub async fn get_issue( + &self, + repository: &str, + number: u64, + ) -> anyhow::Result> { + let response = self + .send(Method::GET, self.issue_url(repository, number)?, None) + .await?; + let response = match response.require_success("Failed to fetch GitHub issue") { + Ok(response) => response, + Err(error) => return Ok(Err(error)), + }; + let issue: RawGithubIssue = match response.json("Failed to parse GitHub issue") { + Ok(issue) => issue, + Err(error) => return Ok(Err(error)), + }; + Ok(Ok(issue.into_metadata())) + } + + pub async fn list_issue_comments( + &self, + repository: &str, + number: u64, + ) -> anyhow::Result, GithubApiError>> { + let url = self.issue_comments_url(repository, number)?; + self.get_paginated(url, "Failed to list GitHub issue comments") + .await + } + + pub async fn list_milestones( + &self, + repository: &str, + ) -> anyhow::Result, GithubApiError>> { + let mut url = self.milestones_url(repository)?; + url.query_pairs_mut().append_pair("state", "all"); + self.get_paginated(url, "Failed to list GitHub milestones") + .await + } + + pub async fn authenticated_user(&self) -> anyhow::Result> { + let response = self.send(Method::GET, self.route(&["user"])?, None).await?; + let response = match response.require_success("Failed to fetch authenticated GitHub user") { + Ok(response) => response, + Err(error) => return Ok(Err(error)), + }; + Ok(response.json("Failed to parse authenticated GitHub user")) + } + + /// Resolve the actor identity used for issue comments. + /// + /// User/PAT tokens expose `GET /user`. Installation tokens do not, so on + /// the installation-token 403 path derive the bot login from + /// `GET /installation` instead. The caller can then compare the exact actor + /// login and avoid minimizing comments written by a different actor. + pub async fn authenticated_comment_actor( + &self, + ) -> anyhow::Result> { + let response = self.send(Method::GET, self.route(&["user"])?, None).await?; + if response.is_success() { + return Ok(response.json("Failed to parse authenticated GitHub user")); + } + if response.status != StatusCode::FORBIDDEN { + return Ok(Err(GithubApiError::from_response( + "Failed to fetch authenticated GitHub user", + response, + ))); + } + + let response = self + .send(Method::GET, self.route(&["installation"])?, None) + .await?; + let response = match response.require_success("Failed to fetch GitHub App installation") { + Ok(response) => response, + Err(error) => return Ok(Err(error)), + }; + let installation: GithubInstallation = + match response.json("Failed to parse GitHub App installation") { + Ok(installation) => installation, + Err(error) => return Ok(Err(error)), + }; + if installation.app_slug.trim().is_empty() { + return Ok(Err(GithubApiError { + operation: "Failed to parse GitHub App installation".to_string(), + status: Some(response.status), + message: "GitHub App installation contained no app_slug".to_string(), + })); + } + Ok(Ok(GithubUser { + login: format!("{}[bot]", installation.app_slug), + id: None, + node_id: None, + })) + } + + pub async fn graphql( + &self, + operation: &str, + query: &str, + variables: Value, + ) -> anyhow::Result> { + let response = self + .send( + Method::POST, + self.graphql_url.clone(), + Some(&serde_json::json!({ + "query": query, + "variables": variables, + })), + ) + .await?; + let response = match response.require_success(operation) { + Ok(response) => response, + Err(error) => return Ok(Err(error)), + }; + let payload: Value = match response.json(operation) { + Ok(payload) => payload, + Err(error) => return Ok(Err(error)), + }; + let errors = payload + .get("errors") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + if !errors.is_empty() { + return Ok(Err(GithubApiError::graphql( + operation, + response.status, + &errors, + ))); + } + match payload.get("data") { + Some(data) => Ok(Ok(data.clone())), + None => Ok(Err(GithubApiError { + operation: operation.to_string(), + status: Some(response.status), + message: "GitHub GraphQL response contained no data".to_string(), + })), + } + } + + async fn get_paginated( + &self, + mut url: Url, + operation: &str, + ) -> anyhow::Result, GithubApiError>> { + if !url.query_pairs().any(|(key, _)| key == "per_page") { + url.query_pairs_mut().append_pair("per_page", "100"); + } + let mut values = Vec::new(); + for _ in 0..MAX_PAGES { + let response = self.send(Method::GET, url, None).await?; + let response = match response.require_success(operation) { + Ok(response) => response, + Err(error) => return Ok(Err(error)), + }; + let mut page: Vec = match response.json(operation) { + Ok(page) => page, + Err(error) => return Ok(Err(error)), + }; + values.append(&mut page); + let Some(next) = next_link(&response.headers) else { + return Ok(Ok(values)); + }; + let next = match Url::parse(&next) { + Ok(next) => next, + Err(error) => { + return Ok(Err(GithubApiError { + operation: operation.to_string(), + status: Some(response.status), + message: format!("GitHub returned an invalid pagination URL: {error}"), + })); + } + }; + if let Err(error) = self.ensure_same_origin(&next) { + return Ok(Err(GithubApiError { + operation: operation.to_string(), + status: Some(response.status), + message: error.to_string(), + })); + } + url = next; + } + Ok(Err(GithubApiError { + operation: operation.to_string(), + status: None, + message: format!("GitHub pagination exceeded {MAX_PAGES} pages"), + })) + } + + fn repository_route(&self, repository: &str, tail: &[&str]) -> anyhow::Result { + validate_github_repository(repository)?; + let (owner, name) = repository + .split_once('/') + .expect("validated GitHub repository contains slash"); + let mut segments = vec!["repos", owner, name]; + segments.extend_from_slice(tail); + self.route(&segments) + } + + fn route(&self, segments: &[&str]) -> anyhow::Result { + let mut url = self.rest_api_url.clone(); + { + let mut path = url + .path_segments_mut() + .map_err(|_| anyhow::anyhow!("GitHub API URL cannot be a base URL"))?; + path.pop_if_empty(); + for segment in segments { + path.push(segment); + } + } + Ok(url) + } + + fn ensure_same_origin(&self, url: &Url) -> anyhow::Result<()> { + ensure!( + url.scheme() == self.rest_api_url.scheme() + && url.host_str() == self.rest_api_url.host_str() + && url.port_or_known_default() == self.rest_api_url.port_or_known_default(), + "GitHub API pagination or route attempted to leave the configured API origin" + ); + Ok(()) + } +} + +#[derive(Debug, Deserialize)] +struct RawGithubIssue { + number: u64, + node_id: Option, + title: String, + state: String, + #[serde(default)] + labels: Vec, + pull_request: Option, + html_url: Option, +} + +impl RawGithubIssue { + fn into_metadata(self) -> GithubTargetMetadata { + GithubTargetMetadata { + number: self.number, + node_id: self.node_id, + title: self.title, + state: self.state, + labels: self.labels.into_iter().map(|label| label.name).collect(), + kind: if self.pull_request.is_some() { + GithubTargetKind::PullRequest + } else { + GithubTargetKind::Issue + }, + html_url: self.html_url, + } + } +} + +#[derive(Debug, Deserialize)] +struct RawGithubLabel { + name: String, +} + +fn validate_rest_api_url(raw: &str) -> anyhow::Result { + let mut url = Url::parse(raw).with_context(|| format!("GitHub API URL '{raw}' is invalid"))?; + ensure!( + url.host_str().is_some(), + "GitHub API URL must include a host" + ); + let loopback = matches!(url.host_str(), Some("localhost" | "127.0.0.1" | "::1")); + ensure!( + url.scheme() == "https" || (url.scheme() == "http" && loopback), + "GitHub API URL must use https" + ); + ensure!( + url.query().is_none() && url.fragment().is_none(), + "GitHub API URL must not contain a query string or fragment" + ); + url.set_query(None); + url.set_fragment(None); + let trimmed = url.path().trim_end_matches('/').to_string(); + url.set_path(if trimmed.is_empty() { "/" } else { &trimmed }); + Ok(url) +} + +/// Derive the GraphQL endpoint for GitHub.com or GitHub Enterprise Server. +pub fn graphql_url_from_rest_api_url(rest_api_url: &str) -> anyhow::Result { + let mut url = validate_rest_api_url(rest_api_url)?; + if url + .host_str() + .is_some_and(|host| host.eq_ignore_ascii_case("api.github.com")) + { + url.set_path("/graphql"); + return Ok(url); + } + + let path = url.path().trim_end_matches('/'); + let graphql_path = match path.strip_suffix("/api/v3") { + Some(prefix) => format!("{prefix}/api/graphql"), + None => format!("{path}/graphql"), + }; + url.set_path(&graphql_path); + Ok(url) +} + +fn next_link(headers: &HeaderMap) -> Option { + let value = headers.get(LINK)?.to_str().ok()?; + value.split(',').find_map(|part| { + let mut pieces = part.trim().split(';'); + let url = pieces.next()?.trim().strip_prefix('<')?.strip_suffix('>')?; + let is_next = pieces.any(|piece| piece.trim() == r#"rel="next""#); + is_next.then(|| url.to_string()) + }) +} + +fn sanitize_github_error_body(body: &str) -> String { + let structured = serde_json::from_str::(body) + .ok() + .and_then(|value| { + let message = value.get("message").and_then(Value::as_str)?; + let mut rendered = message.to_string(); + if let Some(errors) = value.get("errors").and_then(Value::as_array) + && !errors.is_empty() + { + let details: Vec = errors + .iter() + .filter_map(|error| { + error + .as_str() + .map(str::to_string) + .or_else(|| error.get("message")?.as_str().map(str::to_string)) + }) + .collect(); + if !details.is_empty() { + rendered.push_str(": "); + rendered.push_str(&details.join("; ")); + } + } + Some(rendered) + }) + .unwrap_or_else(|| body.to_string()); + let neutralized = crate::sanitize::neutralize_pipeline_commands(&structured); + let mut sanitized: String = neutralized + .chars() + .filter(|character| !character.is_control() || *character == '\n') + .take(MAX_ERROR_CHARS) + .collect(); + if neutralized.chars().count() > MAX_ERROR_CHARS { + sanitized.push('…'); + } + if sanitized.trim().is_empty() { + "".to_string() + } else { + sanitized + } +} + +#[cfg(test)] +mod tests { + use super::*; + use wiremock::matchers::{body_json, header, method, path, query_param}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + fn issue_json(number: u64, pull_request: bool) -> Value { + let mut value = serde_json::json!({ + "number": number, + "node_id": format!("I_{number}"), + "title": "Issue title", + "state": "open", + "labels": [{"name": "bug"}, {"name": "triage"}], + "html_url": format!("https://github.example/octo/repo/issues/{number}") + }); + if pull_request { + value["pull_request"] = serde_json::json!({"url": "https://api.example/pulls/1"}); + } + value + } + + #[test] + fn derives_dotcom_and_ghes_graphql_urls() { + assert_eq!( + graphql_url_from_rest_api_url("https://api.github.com") + .unwrap() + .as_str(), + "https://api.github.com/graphql" + ); + assert_eq!( + graphql_url_from_rest_api_url("https://ghe.example.com/api/v3/") + .unwrap() + .as_str(), + "https://ghe.example.com/api/graphql" + ); + assert_eq!( + graphql_url_from_rest_api_url("https://ghe.example.com/custom/api/v3") + .unwrap() + .as_str(), + "https://ghe.example.com/custom/api/graphql" + ); + } + + #[test] + fn rejects_insecure_non_loopback_and_url_suffixes() { + assert!(GithubClient::new("http://github.example", "token").is_err()); + assert!(GithubClient::new("https://api.github.com?token=x", "token").is_err()); + assert!(GithubClient::new("https://api.github.com", "").is_err()); + } + + #[tokio::test] + async fn sends_standard_headers_and_repository_routes() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/v3/repos/octo/repo/issues")) + .and(header("accept", GITHUB_ACCEPT)) + .and(header("x-github-api-version", GITHUB_API_VERSION)) + .and(header( + "user-agent", + format!("ado-aw/{}", env!("CARGO_PKG_VERSION")), + )) + .and(header("authorization", "Bearer secret")) + .and(body_json(serde_json::json!({"title": "hello"}))) + .respond_with(ResponseTemplate::new(201).set_body_json(issue_json(1, false))) + .expect(1) + .mount(&server) + .await; + + let client = GithubClient::new(&format!("{}/api/v3", server.uri()), "secret").unwrap(); + let url = client.issues_url("octo/repo").unwrap(); + let response = client + .send( + Method::POST, + url, + Some(&serde_json::json!({"title": "hello"})), + ) + .await + .unwrap(); + assert_eq!(response.status, StatusCode::CREATED); + } + + #[tokio::test] + async fn fetches_issue_and_distinguishes_pull_requests() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/repos/octo/repo/issues/7")) + .respond_with(ResponseTemplate::new(200).set_body_json(issue_json(7, true))) + .mount(&server) + .await; + let client = GithubClient::new(&server.uri(), "token").unwrap(); + let metadata = client.get_issue("octo/repo", 7).await.unwrap().unwrap(); + assert_eq!(metadata.kind, GithubTargetKind::PullRequest); + assert_eq!(metadata.labels, vec!["bug", "triage"]); + } + + #[tokio::test] + async fn paginates_comments_using_link_header() { + let server = MockServer::start().await; + let next = format!( + "<{}/page2/comments?per_page=100>; rel=\"next\"", + server.uri() + ); + Mock::given(method("GET")) + .and(path("/repos/octo/repo/issues/7/comments")) + .and(query_param("per_page", "100")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("Link", next) + .set_body_json(serde_json::json!([{ + "id": 1, + "node_id": "IC_1", + "body": "first", + "user": {"login": "octocat"} + }])), + ) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/page2/comments")) + .and(query_param("per_page", "100")) + .respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!([{ + "id": 2, + "node_id": "IC_2", + "body": "second", + "user": {"login": "octocat"} + }])), + ) + .expect(1) + .mount(&server) + .await; + let client = GithubClient::new(&server.uri(), "token").unwrap(); + let comments = client + .list_issue_comments("octo/repo", 7) + .await + .unwrap() + .unwrap(); + assert_eq!(comments.len(), 2); + assert_eq!(comments[1].id, 2); + } + + #[tokio::test] + async fn paginates_milestones_with_all_states() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/repos/octo/repo/milestones")) + .and(query_param("state", "all")) + .and(query_param("per_page", "100")) + .respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!([{ + "number": 3, + "title": "v1", + "state": "open", + "node_id": "MI_3" + }])), + ) + .mount(&server) + .await; + let client = GithubClient::new(&server.uri(), "token").unwrap(); + let milestones = client.list_milestones("octo/repo").await.unwrap().unwrap(); + assert_eq!(milestones[0].title, "v1"); + } + + #[tokio::test] + async fn derives_comment_actor_from_app_installation() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/user")) + .respond_with(ResponseTemplate::new(403).set_body_json(serde_json::json!({ + "message": "Resource not accessible by integration" + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/installation")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "app_slug": "ado-aw-app" + }))) + .expect(1) + .mount(&server) + .await; + let client = GithubClient::new(&server.uri(), "installation-token").unwrap(); + let actor = client.authenticated_comment_actor().await.unwrap().unwrap(); + assert_eq!(actor.login, "ado-aw-app[bot]"); + assert_eq!(actor.id, None); + assert_eq!(actor.node_id, None); + } + + #[tokio::test] + async fn sanitizes_structured_rest_errors() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/repos/octo/repo/issues/7")) + .respond_with(ResponseTemplate::new(422).set_body_json(serde_json::json!({ + "message": "Validation failed\n##vso[task.complete]", + "errors": [{"message": "bad label"}] + }))) + .mount(&server) + .await; + let client = GithubClient::new(&server.uri(), "token").unwrap(); + let error = client.get_issue("octo/repo", 7).await.unwrap().unwrap_err(); + assert!(error.to_string().contains("Validation failed")); + assert!(error.to_string().contains("bad label")); + assert!( + !error + .to_string() + .lines() + .any(|line| line.starts_with("##vso[")) + ); + } + + #[tokio::test] + async fn reports_malformed_rest_json_without_panicking() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/repos/octo/repo/issues/7")) + .respond_with(ResponseTemplate::new(200).set_body_string("{not-json")) + .mount(&server) + .await; + let client = GithubClient::new(&server.uri(), "token").unwrap(); + let error = client.get_issue("octo/repo", 7).await.unwrap().unwrap_err(); + assert!(error.message.contains("malformed JSON")); + } + + #[tokio::test] + async fn graphql_returns_data_on_success() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/graphql")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": {"viewer": {"login": "octocat"}} + }))) + .mount(&server) + .await; + let client = GithubClient::new(&server.uri(), "token").unwrap(); + let data = client + .graphql( + "Fetch viewer", + "query { viewer { login } }", + serde_json::json!({}), + ) + .await + .unwrap() + .unwrap(); + assert_eq!(data["viewer"]["login"], "octocat"); + } + + #[tokio::test] + async fn graphql_uses_ghes_route_and_surfaces_errors() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/graphql")) + .and(body_json(serde_json::json!({ + "query": "query Test { viewer { login } }", + "variables": {} + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": null, + "errors": [{ + "type": "FORBIDDEN", + "message": "denied\n##vso[task.complete]" + }] + }))) + .mount(&server) + .await; + let client = GithubClient::new(&format!("{}/api/v3", server.uri()), "token").unwrap(); + let error = client + .graphql( + "Test GraphQL operation", + "query Test { viewer { login } }", + serde_json::json!({}), + ) + .await + .unwrap() + .unwrap_err(); + assert!(error.to_string().contains("FORBIDDEN: denied")); + assert!( + !error + .to_string() + .lines() + .any(|line| line.starts_with("##vso[")) + ); + } + + #[tokio::test] + async fn rejects_cross_origin_pagination() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/repos/octo/repo/issues/7/comments")) + .respond_with( + ResponseTemplate::new(200) + .insert_header( + "Link", + "; rel=\"next\"", + ) + .set_body_json(serde_json::json!([])), + ) + .mount(&server) + .await; + let client = GithubClient::new(&server.uri(), "token").unwrap(); + let error = client + .list_issue_comments("octo/repo", 7) + .await + .unwrap() + .unwrap_err(); + assert!(error.message.contains("leave the configured API origin")); + } +} diff --git a/src/safe_outputs/github_issue_common.rs b/src/safe_outputs/github_issue_common.rs new file mode 100644 index 000000000..70c9e7456 --- /dev/null +++ b/src/safe_outputs/github_issue_common.rs @@ -0,0 +1,1020 @@ +//! Shared policy and target resolution for GitHub issue safe outputs. +#![allow(dead_code)] // The remaining GitHub issue tools consume this shared surface in later slices. + +use anyhow::ensure; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use std::fmt; +use std::sync::OnceLock; + +use crate::safe_outputs::{ExecutionContext, ExecutionResult}; +use crate::secure::GithubTemporaryId; +use crate::validate::reject_pipeline_injection; + +/// Positive GitHub issue number or a same-run temporary issue ID. +#[derive(Debug, Clone, Serialize, JsonSchema)] +#[serde(untagged)] +pub enum GithubIssueNumber { + Number(u64), + Temporary(GithubTemporaryId), +} + +impl GithubIssueNumber { + pub fn validate(&self, field: &str) -> anyhow::Result<()> { + if let Self::Number(number) = self { + ensure!(*number > 0, "{field} must be positive"); + } + Ok(()) + } +} + +impl fmt::Display for GithubIssueNumber { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Number(number) => write!(formatter, "{number}"), + Self::Temporary(temporary_id) => formatter.write_str(&temporary_id.canonical()), + } + } +} + +impl<'de> Deserialize<'de> for GithubIssueNumber { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + struct IssueNumberVisitor; + + impl serde::de::Visitor<'_> for IssueNumberVisitor { + type Value = GithubIssueNumber; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a positive issue number or #aw_ temporary issue ID") + } + + fn visit_u64(self, value: u64) -> Result { + Ok(GithubIssueNumber::Number(value)) + } + + fn visit_i64(self, value: i64) -> Result + where + E: serde::de::Error, + { + u64::try_from(value) + .map(GithubIssueNumber::Number) + .map_err(|_| E::custom("issue number must be positive")) + } + + fn visit_str(self, value: &str) -> Result + where + E: serde::de::Error, + { + if value.chars().all(|character| character.is_ascii_digit()) { + return value + .parse::() + .map(GithubIssueNumber::Number) + .map_err(|_| E::custom("quoted issue number is outside the u64 range")); + } + GithubTemporaryId::parse(value) + .map(GithubIssueNumber::Temporary) + .map_err(E::custom) + } + } + + deserializer.deserialize_any(IssueNumberVisitor) + } +} + +/// Borrowed repository policy shared by creation and mutation tools. +#[derive(Debug, Clone, Copy)] +pub struct GithubRepositoryPolicy<'a> { + pub target_repo: Option<&'a str>, + pub allowed_repos: &'a [String], +} + +impl<'a> GithubRepositoryPolicy<'a> { + pub const fn new(target_repo: Option<&'a str>, allowed_repos: &'a [String]) -> Self { + Self { + target_repo, + allowed_repos, + } + } +} + +/// Borrowed filters that must pass against the live issue or pull request. +#[derive(Debug, Clone, Copy, Default)] +pub struct GithubMutationFilters<'a> { + pub required_labels: &'a [String], + pub required_title_prefix: Option<&'a str>, +} + +impl GithubMutationFilters<'_> { + pub fn is_empty(&self) -> bool { + self.required_labels.is_empty() && self.required_title_prefix.is_none() + } +} + +/// Validate shared mutation-filter configuration before fetching a target. +pub fn validate_github_mutation_filter_config( + filters: GithubMutationFilters<'_>, +) -> anyhow::Result<()> { + for label in filters.required_labels { + ensure!( + !label.is_empty(), + "required-labels entries must not be empty" + ); + reject_pipeline_injection(label, "required-labels")?; + } + if let Some(prefix) = filters.required_title_prefix { + ensure!( + !prefix.is_empty(), + "required-title-prefix must not be empty" + ); + reject_pipeline_injection(prefix, "required-title-prefix")?; + } + Ok(()) +} + +/// Whether a mutation may target issues, pull requests, or both. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct GithubTargetCapabilities { + pub issues: bool, + pub pull_requests: bool, +} + +impl GithubTargetCapabilities { + pub const ISSUES_ONLY: Self = Self { + issues: true, + pull_requests: false, + }; + pub const ISSUES_AND_PULL_REQUESTS: Self = Self { + issues: true, + pull_requests: true, + }; +} + +/// Live GitHub target type returned by the shared API client. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GithubTargetKind { + Issue, + PullRequest, +} + +/// Issue/PR metadata used for policy checks before the first write. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GithubTargetMetadata { + pub number: u64, + pub node_id: Option, + pub title: String, + pub state: String, + pub labels: Vec, + pub kind: GithubTargetKind, + pub html_url: Option, +} + +/// Fully resolved mutation target. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedGithubIssueTarget { + pub repository: String, + pub number: u64, + pub url: Option, +} + +fn target_repo_regex() -> &'static regex_lite::Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + regex_lite::Regex::new(r"^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?/[A-Za-z0-9._-]+$") + .expect("GitHub repository regex is well-formed") + }) +} + +/// Validate an exact GitHub repository slug. +pub fn validate_github_repository(repository: &str) -> anyhow::Result<()> { + ensure!( + !repository.is_empty(), + "target-repo is required (expected 'owner/repo')" + ); + reject_pipeline_injection(repository, "GitHub repository")?; + ensure!( + target_repo_regex().is_match(repository), + "target-repo '{}' is not in 'owner/repo' format \ + (owner: alphanumerics/hyphens; repo: alphanumerics/dots/hyphens/underscores)", + repository + ); + let (_, name) = repository + .split_once('/') + .expect("validated GitHub repository contains a slash"); + ensure!( + name != "." && name != "..", + "target-repo repo segment must not be '.' or '..'" + ); + Ok(()) +} + +/// Backward-compatible name used by the existing GitHub issue tools. +pub fn validate_target_repo(target_repo: &str) -> anyhow::Result<()> { + validate_github_repository(target_repo) +} + +/// Return validated configured repositories, deduplicated case-insensitively. +pub fn configured_github_repositories( + policy: GithubRepositoryPolicy<'_>, +) -> anyhow::Result> { + let mut repositories = Vec::new(); + if let Some(target_repo) = policy.target_repo { + validate_github_repository(target_repo)?; + repositories.push(target_repo.to_string()); + } + for repository in policy.allowed_repos { + validate_github_repository(repository)?; + if !repositories + .iter() + .any(|existing| existing.eq_ignore_ascii_case(repository)) + { + repositories.push(repository.clone()); + } + } + Ok(repositories) +} + +fn current_github_repository(ctx: &ExecutionContext) -> Result { + let provider = ctx.repository_provider.as_deref().unwrap_or_default(); + if !provider.eq_ignore_ascii_case("github") + && !provider.eq_ignore_ascii_case("githubenterprise") + { + return Err(ExecutionResult::failure( + "target-repo is required when the Azure DevOps pipeline source is not GitHub", + )); + } + if provider.eq_ignore_ascii_case("githubenterprise") + && ctx + .github_api_url + .eq_ignore_ascii_case("https://api.github.com") + { + return Err(ExecutionResult::failure( + "safe-outputs.github-api-url or GitHub App api-url is required for a \ + GitHub Enterprise source", + )); + } + let repository = ctx.repository_name.clone().ok_or_else(|| { + ExecutionResult::failure( + "BUILD_REPOSITORY_NAME is not set; configure target-repo explicitly", + ) + })?; + validate_github_repository(&repository) + .map_err(|error| ExecutionResult::failure(error.to_string()))?; + Ok(repository) +} + +/// Select an effective repository using agent selection, fixed target, then source fallback. +pub fn resolve_github_repository( + requested_repository: Option<&str>, + policy: GithubRepositoryPolicy<'_>, + ctx: &ExecutionContext, +) -> Result { + let configured = configured_github_repositories(policy) + .map_err(|error| ExecutionResult::failure(error.to_string()))?; + let default_repository = match policy.target_repo { + Some(repository) => repository.to_string(), + None => current_github_repository(ctx)?, + }; + + let Some(requested) = requested_repository else { + return Ok(default_repository); + }; + validate_github_repository(requested) + .map_err(|error| ExecutionResult::failure(error.to_string()))?; + + if requested.eq_ignore_ascii_case(&default_repository) { + return Ok(default_repository); + } + if let Some(allowed) = configured + .iter() + .find(|repository| repository.eq_ignore_ascii_case(requested)) + { + return Ok(allowed.clone()); + } + + let mut allowed = vec![default_repository]; + for repository in configured { + if !allowed + .iter() + .any(|existing| existing.eq_ignore_ascii_case(&repository)) + { + allowed.push(repository); + } + } + Err(ExecutionResult::failure(format!( + "repository '{}' is not an exact target-repo or allowed-repos entry: {}", + crate::sanitize::neutralize_pipeline_commands(requested), + allowed.join(", ") + ))) +} + +/// Backward-compatible fixed/default target resolver. +pub fn resolve_target_repo( + configured: Option<&str>, + ctx: &ExecutionContext, +) -> Result { + resolve_github_repository(None, GithubRepositoryPolicy::new(configured, &[]), ctx) +} + +/// Resolve a numeric or temporary issue reference under the consumer's repository policy. +pub fn resolve_github_issue_target( + issue_number: &GithubIssueNumber, + requested_repository: Option<&str>, + policy: GithubRepositoryPolicy<'_>, + ctx: &ExecutionContext, +) -> anyhow::Result> { + if let Err(error) = configured_github_repositories(policy) { + return Ok(Err(ExecutionResult::failure(error.to_string()))); + } + + match issue_number { + GithubIssueNumber::Number(number) => { + if *number == 0 { + return Ok(Err(ExecutionResult::failure( + "issue_number must be positive", + ))); + } + let repository = match resolve_github_repository(requested_repository, policy, ctx) { + Ok(repository) => repository, + Err(error) => return Ok(Err(error)), + }; + Ok(Ok(ResolvedGithubIssueTarget { + repository, + number: *number, + url: None, + })) + } + GithubIssueNumber::Temporary(temporary_id) => { + let Some(issue) = ctx.resolve_github_issue(temporary_id)? else { + return Ok(Err(ExecutionResult::failure(format!( + "temporary issue ID '{}' has not been resolved; create-github-issue must \ + succeed earlier in the same SafeOutputs job", + temporary_id.canonical() + )))); + }; + if let Some(requested) = requested_repository + && !requested.eq_ignore_ascii_case(&issue.repository) + { + return Ok(Err(ExecutionResult::failure(format!( + "temporary issue ID '{}' resolved to repository '{}', which does not match \ + requested repository '{}'", + temporary_id.canonical(), + issue.repository, + crate::sanitize::neutralize_pipeline_commands(requested) + )))); + } + let repository = match resolve_github_repository(Some(&issue.repository), policy, ctx) { + Ok(repository) => repository, + Err(error) => return Ok(Err(error)), + }; + Ok(Ok(ResolvedGithubIssueTarget { + repository, + number: issue.number, + url: Some(issue.url), + })) + } + } +} + +/// Check all required labels and the required title prefix against live metadata. +pub fn validate_github_mutation_filters( + metadata: &GithubTargetMetadata, + filters: GithubMutationFilters<'_>, +) -> Result<(), ExecutionResult> { + let missing: Vec<&str> = filters + .required_labels + .iter() + .map(String::as_str) + .filter(|required| { + !metadata + .labels + .iter() + .any(|label| label.eq_ignore_ascii_case(required)) + }) + .collect(); + if !missing.is_empty() { + return Err(ExecutionResult::failure(format!( + "GitHub target #{} is missing required labels: {}", + metadata.number, + missing.join(", ") + ))); + } + if let Some(prefix) = filters.required_title_prefix + && !metadata.title.starts_with(prefix) + { + return Err(ExecutionResult::failure(format!( + "GitHub target #{} title does not start with required-title-prefix '{}'", + metadata.number, + crate::sanitize::neutralize_pipeline_commands(prefix) + ))); + } + Ok(()) +} + +/// Check whether the live target kind is enabled for a tool. +pub fn validate_github_target_capability( + metadata: &GithubTargetMetadata, + capabilities: GithubTargetCapabilities, +) -> Result<(), ExecutionResult> { + let allowed = match metadata.kind { + GithubTargetKind::Issue => capabilities.issues, + GithubTargetKind::PullRequest => capabilities.pull_requests, + }; + if allowed { + return Ok(()); + } + let kind = match metadata.kind { + GithubTargetKind::Issue => "issues", + GithubTargetKind::PullRequest => "pull requests", + }; + Err(ExecutionResult::failure(format!( + "GitHub target #{} is a {kind} target, but this tool is not configured to mutate {kind}", + metadata.number + ))) +} + +/// Apply gh-aw-compatible case-insensitive `*` glob policy with blocked-first semantics. +pub fn validate_blocked_first_globs( + values: &[String], + allowed: &[String], + blocked: &[String], + field: &str, +) -> Result<(), ExecutionResult> { + for value in values { + if blocked + .iter() + .any(|pattern| simple_github_glob_matches(value, pattern)) + { + return Err(ExecutionResult::failure(format!( + "{field} '{}' is blocked by policy", + crate::sanitize::neutralize_pipeline_commands(value) + ))); + } + } + if allowed.is_empty() { + return Ok(()); + } + for value in values { + if !allowed + .iter() + .any(|pattern| simple_github_glob_matches(value, pattern)) + { + return Err(ExecutionResult::failure(format!( + "{field} '{}' is not allowed by policy", + crate::sanitize::neutralize_pipeline_commands(value) + ))); + } + } + Ok(()) +} + +/// Match gh-aw's simple glob contract: case-insensitive, with only `*` special. +pub fn simple_github_glob_matches(value: &str, pattern: &str) -> bool { + !value.is_empty() + && !pattern.is_empty() + && super::wildcard_match(&pattern.to_ascii_lowercase(), &value.to_ascii_lowercase()) +} + +/// Extract the owner from a validated repository slug. +pub fn github_repository_owner(repository: &str) -> anyhow::Result<&str> { + validate_github_repository(repository)?; + Ok(repository + .split_once('/') + .expect("validated GitHub repository contains slash") + .0) +} + +/// Return the single owner suitable for GitHub App repository scoping. +pub fn github_app_owner_for_repositories<'a>( + repositories: impl IntoIterator, +) -> anyhow::Result> { + let mut owner: Option = None; + for repository in repositories { + let candidate = github_repository_owner(repository)?; + if let Some(existing) = owner.as_deref() { + ensure!( + existing.eq_ignore_ascii_case(candidate), + "GitHub App authentication requires all target-repo and allowed-repos values \ + to have the same owner; found '{}' and '{}'", + existing, + candidate + ); + } else { + owner = Some(candidate.to_string()); + } + } + Ok(owner) +} + +/// Repository names for a GitHub App installation owner, deduplicated case-insensitively. +pub fn github_app_repository_names<'a>( + owner: &str, + repositories: impl IntoIterator, +) -> anyhow::Result> { + let mut names = Vec::new(); + for repository in repositories { + validate_github_repository(repository)?; + let (candidate_owner, name) = repository + .split_once('/') + .expect("validated GitHub repository contains slash"); + ensure!( + candidate_owner.eq_ignore_ascii_case(owner), + "GitHub repository '{}' does not belong to App owner '{}'", + repository, + owner + ); + if !names + .iter() + .any(|existing: &String| existing.eq_ignore_ascii_case(name)) + { + names.push(name.to_string()); + } + } + Ok(names) +} + +/// Enforce the same-job approval invariant for temporary issue IDs. +pub fn validate_temporary_id_approval_compatibility( + consumer_name: &str, + create_requires_approval: bool, + consumer_requires_approval: bool, +) -> anyhow::Result<()> { + ensure!( + create_requires_approval == consumer_requires_approval, + "safe-outputs.create-github-issue and safe-outputs.{consumer_name} must use the same \ + effective require-approval value when {consumer_name} accepts temporary issue IDs" + ); + Ok(()) +} + +/// Stable hidden marker used to identify comments from one ADO pipeline definition. +pub fn github_pipeline_comment_marker(ctx: &ExecutionContext) -> anyhow::Result { + let definition_id = ctx.definition_id.ok_or_else(|| { + anyhow::anyhow!("SYSTEM_DEFINITIONID is required when hide-older-comments is enabled") + })?; + Ok(format!( + "" + )) +} + +/// Generic hidden marker for comments that do not use definition-scoped +/// hide-older-comments behavior. +pub const GITHUB_COMMENT_MARKER: &str = ""; + +/// Build the traceability footer used by GitHub issue content. +pub fn build_github_trace_footer(ctx: &ExecutionContext) -> String { + let mut lines = vec!["".to_string(), "---".to_string()]; + if let Some(name) = ctx.definition_name.as_ref() { + lines.push(format!("Pipeline: `{name}`")); + } + if let Some(build_id) = ctx.build_id { + if let (Some(org_url), Some(project)) = (ctx.ado_org_url.as_ref(), ctx.ado_project.as_ref()) + { + let url = format!( + "{}/{}/_build/results?buildId={}", + org_url.trim_end_matches('/'), + project, + build_id + ); + lines.push(format!("Run: <{url}>")); + } else { + lines.push(format!("Build: {build_id}")); + } + } + if let Some(reason) = ctx.build_reason.as_ref() { + lines.push(format!("Trigger: `{reason}`")); + } + lines.join("\n") +} + +/// Merge operator and agent strings with case-insensitive deduplication. +pub fn merge_github_values(operator: &[String], agent: &[String]) -> Vec { + let mut merged = operator.to_vec(); + for value in agent { + if !merged + .iter() + .any(|existing| existing.eq_ignore_ascii_case(value)) + { + merged.push(value.clone()); + } + } + merged +} + +/// Validate and deduplicate values intended to become exact repository policy. +pub fn dedupe_github_repositories(repositories: &[String]) -> anyhow::Result> { + let mut seen = HashSet::new(); + let mut deduped = Vec::new(); + for repository in repositories { + validate_github_repository(repository)?; + if seen.insert(repository.to_ascii_lowercase()) { + deduped.push(repository.clone()); + } + } + Ok(deduped) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::safe_outputs::ResolvedGithubIssue; + use std::collections::HashMap; + + fn github_ctx() -> ExecutionContext { + ExecutionContext { + repository_provider: Some("GitHub".to_string()), + repository_name: Some("octo/current".to_string()), + ..Default::default() + } + } + + #[test] + fn issue_number_accepts_numbers_quoted_numbers_and_temporary_ids() { + let numeric: GithubIssueNumber = serde_json::from_str("42").unwrap(); + let quoted: GithubIssueNumber = serde_json::from_str("\"42\"").unwrap(); + let temporary: GithubIssueNumber = serde_json::from_str("\"#aw_bug1\"").unwrap(); + assert!(matches!(numeric, GithubIssueNumber::Number(42))); + assert!(matches!(quoted, GithubIssueNumber::Number(42))); + assert!(matches!(temporary, GithubIssueNumber::Temporary(_))); + } + + #[test] + fn issue_number_rejects_negative_and_malformed_temporary_values() { + assert!(serde_json::from_str::("-1").is_err()); + assert!(serde_json::from_str::("\"not-an-id\"").is_err()); + assert!( + GithubIssueNumber::Number(0) + .validate("issue_number") + .is_err() + ); + } + + #[test] + fn validates_repository_shapes_and_injection() { + assert!(validate_github_repository("githubnext/ado-aw").is_ok()); + assert!(validate_github_repository("user/.github").is_ok()); + assert!(validate_github_repository("owner/repo_with.dot-dash").is_ok()); + for invalid in [ + "", + "owner", + "owner/repo/extra", + "-owner/repo", + "owner-/repo", + "under_score/repo", + "owner/..", + "owner/$(TOKEN)", + ] { + assert!( + validate_github_repository(invalid).is_err(), + "{invalid} should fail" + ); + } + } + + #[test] + fn repository_policy_uses_target_then_current_fallback() { + let ctx = github_ctx(); + let allowed = vec![]; + assert_eq!( + resolve_github_repository( + None, + GithubRepositoryPolicy::new(Some("octo/fixed"), &allowed), + &ctx + ) + .unwrap(), + "octo/fixed" + ); + assert_eq!( + resolve_github_repository(None, GithubRepositoryPolicy::new(None, &allowed), &ctx) + .unwrap(), + "octo/current" + ); + } + + #[test] + fn repository_selection_is_exact_and_case_insensitive() { + let ctx = github_ctx(); + let allowed = vec!["Octo/Other".to_string()]; + let selected = resolve_github_repository( + Some("octo/other"), + GithubRepositoryPolicy::new(Some("octo/fixed"), &allowed), + &ctx, + ) + .unwrap(); + assert_eq!(selected, "Octo/Other"); + + let denied = resolve_github_repository( + Some("octo/not-allowed"), + GithubRepositoryPolicy::new(Some("octo/fixed"), &allowed), + &ctx, + ) + .unwrap_err(); + assert!(denied.message.contains("not an exact")); + } + + #[test] + fn configured_repositories_dedupe_case_insensitively() { + let allowed = vec![ + "OCTO/REPO".to_string(), + "octo/other".to_string(), + "Octo/Other".to_string(), + ]; + let repositories = configured_github_repositories(GithubRepositoryPolicy::new( + Some("octo/repo"), + &allowed, + )) + .unwrap(); + assert_eq!(repositories, vec!["octo/repo", "octo/other"]); + } + + #[test] + fn implicit_repository_rejects_non_github_and_unconfigured_ghes() { + let ado = ExecutionContext { + repository_provider: Some("TfsGit".to_string()), + repository_name: Some("repo".to_string()), + ..Default::default() + }; + assert!( + resolve_github_repository(None, GithubRepositoryPolicy::new(None, &[]), &ado) + .unwrap_err() + .message + .contains("target-repo is required") + ); + + let ghes = ExecutionContext { + repository_provider: Some("GitHubEnterprise".to_string()), + repository_name: Some("octo/repo".to_string()), + ..Default::default() + }; + assert!( + resolve_github_repository(None, GithubRepositoryPolicy::new(None, &[]), &ghes) + .unwrap_err() + .message + .contains("GitHub Enterprise source") + ); + } + + #[test] + fn temporary_target_enforces_repository_policy_and_explicit_match() { + let temporary_id = GithubTemporaryId::parse("#aw_bug1").unwrap(); + let ctx = github_ctx(); + ctx.register_resolved_github_issue( + &temporary_id, + ResolvedGithubIssue { + repository: "octo/created".to_string(), + number: 17, + url: "https://github.com/octo/created/issues/17".to_string(), + }, + ) + .unwrap(); + let allowed = vec!["octo/created".to_string()]; + let target = resolve_github_issue_target( + &GithubIssueNumber::Temporary(temporary_id.clone()), + None, + GithubRepositoryPolicy::new(Some("octo/default"), &allowed), + &ctx, + ) + .unwrap() + .unwrap(); + assert_eq!(target.repository, "octo/created"); + assert_eq!(target.number, 17); + + let mismatch = resolve_github_issue_target( + &GithubIssueNumber::Temporary(temporary_id), + Some("octo/default"), + GithubRepositoryPolicy::new(Some("octo/default"), &allowed), + &ctx, + ) + .unwrap() + .unwrap_err(); + assert!(mismatch.message.contains("does not match")); + } + + fn issue_metadata() -> GithubTargetMetadata { + GithubTargetMetadata { + number: 7, + node_id: Some("I_1".to_string()), + title: "[agent] Fix it".to_string(), + state: "open".to_string(), + labels: vec!["Bug".to_string(), "triage".to_string()], + kind: GithubTargetKind::Issue, + html_url: None, + } + } + + #[test] + fn required_labels_and_title_prefix_are_all_required() { + let metadata = issue_metadata(); + assert!( + validate_github_mutation_filters( + &metadata, + GithubMutationFilters { + required_labels: &["bug".to_string(), "TRIAGE".to_string()], + required_title_prefix: Some("[agent]"), + } + ) + .is_ok() + ); + assert!( + validate_github_mutation_filters( + &metadata, + GithubMutationFilters { + required_labels: &["missing".to_string()], + required_title_prefix: None, + } + ) + .unwrap_err() + .message + .contains("missing required labels") + ); + assert!( + validate_github_mutation_filters( + &metadata, + GithubMutationFilters { + required_labels: &[], + required_title_prefix: Some("[other]"), + } + ) + .unwrap_err() + .message + .contains("required-title-prefix") + ); + } + + #[test] + fn mutation_filter_config_rejects_empty_values_and_injection() { + assert!( + validate_github_mutation_filter_config(GithubMutationFilters { + required_labels: &["".to_string()], + required_title_prefix: None, + }) + .is_err() + ); + assert!( + validate_github_mutation_filter_config(GithubMutationFilters { + required_labels: &[], + required_title_prefix: Some("$(TOKEN)"), + }) + .is_err() + ); + } + + #[test] + fn target_capability_distinguishes_issues_and_pull_requests() { + let issue = issue_metadata(); + assert!( + validate_github_target_capability(&issue, GithubTargetCapabilities::ISSUES_ONLY) + .is_ok() + ); + let pull_request = GithubTargetMetadata { + kind: GithubTargetKind::PullRequest, + ..issue + }; + assert!( + validate_github_target_capability(&pull_request, GithubTargetCapabilities::ISSUES_ONLY) + .is_err() + ); + } + + #[test] + fn blocked_globs_win_and_empty_allowlist_is_unrestricted() { + let values = vec!["dependabot[bot]".to_string()]; + assert!(validate_blocked_first_globs(&values, &[], &[], "assignee").is_ok()); + assert!( + validate_blocked_first_globs( + &values, + &["*".to_string()], + &["*[bot]".to_string()], + "assignee" + ) + .unwrap_err() + .message + .contains("blocked") + ); + assert!( + validate_blocked_first_globs( + &["alice".to_string()], + &["octo-*".to_string()], + &[], + "assignee" + ) + .is_err() + ); + } + + #[test] + fn pat_targets_may_span_owners_but_app_helper_rejects_them() { + let repositories = vec!["octo/one".to_string(), "hubot/two".to_string()]; + assert_eq!( + dedupe_github_repositories(&repositories).unwrap(), + repositories + ); + assert!( + github_app_owner_for_repositories(repositories.iter().map(String::as_str)).is_err() + ); + } + + #[test] + fn app_owner_and_repository_names_are_case_insensitive() { + let repositories = ["Octo/One", "octo/two", "OCTO/ONE"]; + assert_eq!( + github_app_owner_for_repositories(repositories).unwrap(), + Some("Octo".to_string()) + ); + assert_eq!( + github_app_repository_names("octo", repositories).unwrap(), + vec!["One".to_string(), "two".to_string()] + ); + } + + #[test] + fn approval_compatibility_requires_equal_variants() { + assert!(validate_temporary_id_approval_compatibility("consumer", true, true).is_ok()); + assert!(validate_temporary_id_approval_compatibility("consumer", true, false).is_err()); + } + + #[test] + fn stable_comment_marker_requires_definition_id() { + let ctx = ExecutionContext { + definition_id: Some(123), + ..Default::default() + }; + assert_eq!( + github_pipeline_comment_marker(&ctx).unwrap(), + "" + ); + assert!(github_pipeline_comment_marker(&ExecutionContext::default()).is_err()); + } + + #[test] + fn trace_footer_and_merge_preserve_existing_contract() { + let ctx = ExecutionContext { + ado_org_url: Some("https://dev.azure.com/octo".to_string()), + ado_project: Some("project".to_string()), + build_id: Some(42), + definition_name: Some("pipeline".to_string()), + build_reason: Some("Manual".to_string()), + ..Default::default() + }; + let footer = build_github_trace_footer(&ctx); + assert!(footer.contains("")); + assert!(footer.contains("buildId=42")); + assert_eq!( + merge_github_values( + &["bug".to_string(), "Triage".to_string()], + &["BUG".to_string(), "fresh".to_string()] + ), + vec!["bug".to_string(), "Triage".to_string(), "fresh".to_string()] + ); + } + + #[test] + fn unresolved_temporary_target_fails_cleanly() { + let result = resolve_github_issue_target( + &GithubIssueNumber::Temporary(GithubTemporaryId::parse("#aw_none").unwrap()), + None, + GithubRepositoryPolicy::new(Some("octo/repo"), &[]), + &github_ctx(), + ) + .unwrap() + .unwrap_err(); + assert!(result.message.contains("has not been resolved")); + } + + #[test] + fn repository_error_does_not_expose_pipeline_commands() { + let result = resolve_github_repository( + Some("octo/##vso[task.complete]"), + GithubRepositoryPolicy::new(Some("octo/repo"), &[]), + &github_ctx(), + ) + .unwrap_err(); + assert!( + !result + .message + .lines() + .any(|line| line.starts_with("##vso[")) + ); + } + + #[test] + fn registered_issue_map_is_shared_across_context_clones() { + let ctx = github_ctx(); + let cloned = ctx.clone(); + let temporary_id = GithubTemporaryId::parse("#aw_map1").unwrap(); + ctx.register_resolved_github_issue( + &temporary_id, + ResolvedGithubIssue { + repository: "octo/repo".to_string(), + number: 1, + url: String::new(), + }, + ) + .unwrap(); + let issues: HashMap<_, _> = cloned.resolved_github_issues.lock().unwrap().clone(); + assert!(issues.contains_key("#aw_map1")); + } +} diff --git a/src/safe_outputs/hide_github_issue_comment.rs b/src/safe_outputs/hide_github_issue_comment.rs new file mode 100644 index 000000000..d26efdc9d --- /dev/null +++ b/src/safe_outputs/hide_github_issue_comment.rs @@ -0,0 +1,1189 @@ +//! `hide-github-issue-comment` safe output. + +use anyhow::ensure; +use log::{debug, info}; +use reqwest::Method; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use url::Url; + +use crate::safe_outputs::{ + ExecutionContext, ExecutionResult, Executor, GithubClient, GithubMutationFilters, + GithubRepositoryPolicy, GithubTargetCapabilities, GithubTargetKind, GithubTargetMetadata, + Validate, resolve_github_repository, validate_github_mutation_filter_config, + validate_github_mutation_filters, validate_github_repository, + validate_github_target_capability, +}; +use crate::sanitize::{SanitizeContent, sanitize_config}; +use crate::tool_result; +use crate::validate::reject_pipeline_injection; +use ado_aw_derive::SanitizeConfig; + +const DEFAULT_REASON: &str = "OUTDATED"; +const VALID_REASONS: &[&str] = &[ + "SPAM", + "ABUSE", + "OFF_TOPIC", + "OUTDATED", + "RESOLVED", + "LOW_QUALITY", +]; +const RESOLVE_COMMENT_QUERY: &str = r#"query ResolveGithubComment($id: ID!) { + node(id: $id) { + __typename + ... on IssueComment { + id + url + repository { nameWithOwner } + } + ... on PullRequestReviewComment { + id + url + repository { nameWithOwner } + } + ... on DiscussionComment { + id + discussion { + number + title + repository { nameWithOwner } + } + } + } +}"#; +pub(crate) const MINIMIZE_COMMENT_MUTATION: &str = r#"mutation MinimizeGithubComment($input: MinimizeCommentInput!) { + minimizeComment(input: $input) { + minimizedComment { + isMinimized + minimizedReason + } + } +}"#; + +/// Numeric REST issue-comment ID or GraphQL node ID. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] +#[serde(untagged)] +pub enum GithubCommentId { + Numeric(u64), + Node(String), +} + +impl GithubCommentId { + fn validate(&self) -> anyhow::Result<()> { + match self { + Self::Numeric(id) => ensure!(*id > 0, "comment_id must be positive"), + Self::Node(id) => { + ensure!(!id.trim().is_empty(), "comment_id must not be empty"); + ensure!( + id.len() <= 256, + "GraphQL comment_id must be 256 characters or fewer" + ); + reject_pipeline_injection(id, "hide-github-issue-comment.comment_id")?; + } + } + Ok(()) + } +} + +impl std::fmt::Display for GithubCommentId { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Numeric(id) => write!(formatter, "{id}"), + Self::Node(id) => formatter.write_str(id), + } + } +} + +impl<'de> Deserialize<'de> for GithubCommentId { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + struct CommentIdVisitor; + + impl serde::de::Visitor<'_> for CommentIdVisitor { + type Value = GithubCommentId; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("a positive numeric REST comment ID or GraphQL node ID") + } + + fn visit_u64(self, value: u64) -> Result { + Ok(GithubCommentId::Numeric(value)) + } + + fn visit_i64(self, value: i64) -> Result + where + E: serde::de::Error, + { + u64::try_from(value) + .map(GithubCommentId::Numeric) + .map_err(|_| E::custom("comment_id must be positive")) + } + + fn visit_str(self, value: &str) -> Result + where + E: serde::de::Error, + { + if value.chars().all(|character| character.is_ascii_digit()) { + return value + .parse::() + .map(GithubCommentId::Numeric) + .map_err(|_| E::custom("numeric comment_id is outside the u64 range")); + } + Ok(GithubCommentId::Node(value.to_string())) + } + } + + deserializer.deserialize_any(CommentIdVisitor) + } +} + +#[derive(Deserialize, JsonSchema)] +pub struct HideGithubIssueCommentParams { + /// Numeric REST issue-comment ID or GraphQL node ID. + pub comment_id: GithubCommentId, + /// GitHub minimization classifier. Defaults to `OUTDATED`. + #[serde(default)] + pub reason: Option, + /// Optional target repository. Required to resolve numeric IDs unless a + /// default target repository is available. + #[serde(default)] + pub repository: Option, +} + +impl Validate for HideGithubIssueCommentParams { + fn validate(&self) -> anyhow::Result<()> { + self.comment_id.validate()?; + if let Some(reason) = self.reason.as_deref() { + canonical_github_comment_reason(Some(reason))?; + } + if let Some(repository) = self.repository.as_deref() { + validate_github_repository(repository)?; + } + Ok(()) + } +} + +tool_result! { + name = "hide-github-issue-comment", + write = true, + params = HideGithubIssueCommentParams, + default_max = 5, + /// Result of minimizing a GitHub issue, pull-request, or discussion comment. + pub struct HideGithubIssueCommentResult { + comment_id: GithubCommentId, + #[serde(default)] + reason: Option, + #[serde(default)] + repository: Option, + } +} + +impl SanitizeContent for HideGithubIssueCommentResult { + fn sanitize_content_fields(&mut self) { + self.reason = self.reason.as_deref().map(sanitize_config); + self.repository = self.repository.as_deref().map(sanitize_config); + if let GithubCommentId::Node(id) = &mut self.comment_id { + *id = sanitize_config(id); + } + } +} + +#[derive(Debug, Clone, Default, SanitizeConfig, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct HideGithubIssueCommentConfig { + #[serde(default, rename = "target-repo")] + pub target_repo: Option, + #[serde(default, rename = "allowed-repos")] + pub allowed_repos: Vec, + #[serde(default, rename = "required-labels")] + pub required_labels: Vec, + #[serde(default, rename = "required-title-prefix")] + pub required_title_prefix: Option, + /// Restrict the GraphQL minimization classifiers the agent may select. + /// Empty permits every supported classifier. + #[serde(default, rename = "allowed-reasons")] + pub allowed_reasons: Vec, + /// Permit GraphQL discussion comments in addition to issue/PR comments. + #[serde(default)] + #[sanitize_config(skip)] + pub discussions: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[sanitize_config(skip)] + pub max: Option, +} + +pub(crate) fn canonical_github_comment_reason(reason: Option<&str>) -> anyhow::Result { + let reason = reason.unwrap_or(DEFAULT_REASON).to_ascii_uppercase(); + ensure!( + VALID_REASONS.contains(&reason.as_str()), + "reason must be one of: {}", + VALID_REASONS.join(", ") + ); + Ok(reason) +} + +pub(crate) fn validate_github_comment_reason_policy( + reason: &str, + allowed_reasons: &[String], +) -> anyhow::Result<()> { + for allowed in allowed_reasons { + canonical_github_comment_reason(Some(allowed))?; + } + if !allowed_reasons.is_empty() + && !allowed_reasons + .iter() + .any(|allowed| allowed.eq_ignore_ascii_case(reason)) + { + anyhow::bail!( + "reason '{}' is not in allowed-reasons: {}", + crate::sanitize::neutralize_pipeline_commands(reason), + allowed_reasons.join(", ") + ); + } + Ok(()) +} + +pub(crate) async fn minimize_github_comment( + client: &GithubClient, + node_id: &str, + reason: &str, +) -> anyhow::Result> { + let data = match client + .graphql( + "Failed to minimize GitHub comment", + MINIMIZE_COMMENT_MUTATION, + serde_json::json!({ + "input": { + "subjectId": node_id, + "classifier": reason, + } + }), + ) + .await? + { + Ok(data) => data, + Err(error) => return Ok(Err(ExecutionResult::failure(error.to_string()))), + }; + if data + .pointer("/minimizeComment/minimizedComment/isMinimized") + .and_then(Value::as_bool) + != Some(true) + { + return Ok(Err(ExecutionResult::failure( + "GitHub minimizeComment response did not confirm that the comment was minimized", + ))); + } + Ok(Ok(())) +} + +#[derive(Debug, Deserialize)] +struct RestIssueComment { + id: u64, + node_id: Option, + issue_url: String, + html_url: Option, +} + +#[derive(Debug)] +enum CommentParent { + Issue { + repository: String, + number: u64, + kind: GithubTargetKind, + url: Option, + }, + Discussion { + repository: String, + number: u64, + url: Option, + }, +} + +#[derive(Debug)] +struct ResolvedComment { + node_id: String, + parent: CommentParent, +} + +pub(crate) fn validate_hide_github_issue_comment_config( + config: &HideGithubIssueCommentConfig, +) -> anyhow::Result<()> { + validate_github_mutation_filter_config(GithubMutationFilters { + required_labels: &config.required_labels, + required_title_prefix: config.required_title_prefix.as_deref(), + })?; + for reason in &config.allowed_reasons { + canonical_github_comment_reason(Some(reason))?; + } + Ok(()) +} + +fn validate_actual_repository( + selected_repository: &str, + actual_repository: &str, +) -> Result<(), ExecutionResult> { + if selected_repository.eq_ignore_ascii_case(actual_repository) { + Ok(()) + } else { + Err(ExecutionResult::failure(format!( + "comment belongs to repository '{}', not selected repository '{}'", + crate::sanitize::neutralize_pipeline_commands(actual_repository), + crate::sanitize::neutralize_pipeline_commands(selected_repository) + ))) + } +} + +fn parse_rest_issue_url(issue_url: &str, repository: &str) -> anyhow::Result { + let url = + Url::parse(issue_url).map_err(|error| anyhow::anyhow!("invalid issue_url: {error}"))?; + let segments: Vec<&str> = url + .path_segments() + .ok_or_else(|| anyhow::anyhow!("GitHub issue_url cannot be a base URL"))? + .collect(); + let Some(repos_index) = segments.iter().rposition(|segment| *segment == "repos") else { + anyhow::bail!("GitHub issue comment response contained an unrecognized issue_url"); + }; + let tail = &segments[repos_index..]; + ensure!( + tail.len() == 5 && tail[3] == "issues", + "GitHub issue comment response contained an unrecognized issue_url" + ); + let actual_repository = format!("{}/{}", tail[1], tail[2]); + ensure!( + actual_repository.eq_ignore_ascii_case(repository), + "GitHub issue comment belongs to repository '{}', not selected repository '{}'", + actual_repository, + repository + ); + let number = tail[4] + .parse::() + .map_err(|_| anyhow::anyhow!("GitHub issue comment issue_url has no numeric target"))?; + ensure!(number > 0, "GitHub issue comment target must be positive"); + Ok(number) +} + +fn parse_html_target_url(target_url: &str, repository: &str) -> anyhow::Result { + let url = + Url::parse(target_url).map_err(|error| anyhow::anyhow!("invalid comment URL: {error}"))?; + let segments: Vec<&str> = url + .path_segments() + .ok_or_else(|| anyhow::anyhow!("GitHub comment URL cannot be a base URL"))? + .collect(); + ensure!( + segments.len() >= 4, + "GitHub comment URL did not identify an issue or pull request" + ); + let actual_repository = format!("{}/{}", segments[0], segments[1]); + ensure!( + actual_repository.eq_ignore_ascii_case(repository), + "GitHub comment URL repository '{}' does not match '{}'", + actual_repository, + repository + ); + ensure!( + matches!(segments[2], "issues" | "pull"), + "GitHub comment URL did not identify an issue or pull request" + ); + let number = segments[3] + .parse::() + .map_err(|_| anyhow::anyhow!("GitHub comment URL has no numeric target"))?; + ensure!(number > 0, "GitHub comment target must be positive"); + Ok(number) +} + +fn repository_from_node(node: &Value, pointer: &str) -> Option { + node.pointer(pointer) + .and_then(Value::as_str) + .map(str::to_string) +} + +async fn validate_issue_parent( + client: &GithubClient, + repository: String, + number: u64, + filters: GithubMutationFilters<'_>, + url: Option, +) -> anyhow::Result> { + let metadata = match client.get_issue(&repository, number).await? { + Ok(metadata) => metadata, + Err(error) => return Ok(Err(ExecutionResult::failure(error.to_string()))), + }; + if let Err(result) = validate_github_target_capability( + &metadata, + GithubTargetCapabilities::ISSUES_AND_PULL_REQUESTS, + ) { + return Ok(Err(result)); + } + if let Err(result) = validate_github_mutation_filters(&metadata, filters) { + return Ok(Err(result)); + } + Ok(Ok(CommentParent::Issue { + repository, + number, + kind: metadata.kind, + url: url.or(metadata.html_url), + })) +} + +async fn resolve_numeric_comment( + client: &GithubClient, + id: u64, + selected_repository: &str, + filters: GithubMutationFilters<'_>, +) -> anyhow::Result> { + let response = client + .send( + Method::GET, + client.issue_comment_url(selected_repository, id)?, + None, + ) + .await?; + let response = match response.require_success("Failed to fetch GitHub issue comment") { + Ok(response) => response, + Err(error) => return Ok(Err(ExecutionResult::failure(error.to_string()))), + }; + let comment: RestIssueComment = match response.json("Failed to parse GitHub issue comment") { + Ok(comment) => comment, + Err(error) => return Ok(Err(ExecutionResult::failure(error.to_string()))), + }; + if comment.id != id { + return Ok(Err(ExecutionResult::failure( + "GitHub issue comment response ID did not match the requested comment_id", + ))); + } + let Some(node_id) = comment.node_id.filter(|node_id| !node_id.is_empty()) else { + return Ok(Err(ExecutionResult::failure( + "GitHub issue comment response contained no GraphQL node_id", + ))); + }; + let number = match parse_rest_issue_url(&comment.issue_url, selected_repository) { + Ok(number) => number, + Err(error) => return Ok(Err(ExecutionResult::failure(error.to_string()))), + }; + let parent = match validate_issue_parent( + client, + selected_repository.to_string(), + number, + filters, + comment.html_url, + ) + .await? + { + Ok(parent) => parent, + Err(result) => return Ok(Err(result)), + }; + Ok(Ok(ResolvedComment { node_id, parent })) +} + +async fn resolve_node_comment( + client: &GithubClient, + node_id: &str, + selected_repository: &str, + filters: GithubMutationFilters<'_>, + discussions: bool, +) -> anyhow::Result> { + let data = match client + .graphql( + "Failed to resolve GitHub comment node", + RESOLVE_COMMENT_QUERY, + serde_json::json!({ "id": node_id }), + ) + .await? + { + Ok(data) => data, + Err(error) => return Ok(Err(ExecutionResult::failure(error.to_string()))), + }; + let Some(node) = data.get("node").filter(|node| !node.is_null()) else { + return Ok(Err(ExecutionResult::failure( + "GitHub comment node was not found", + ))); + }; + let returned_id = node.get("id").and_then(Value::as_str).unwrap_or_default(); + if returned_id != node_id { + return Ok(Err(ExecutionResult::failure( + "GitHub comment node response ID did not match comment_id", + ))); + } + let typename = node + .get("__typename") + .and_then(Value::as_str) + .unwrap_or_default(); + match typename { + "IssueComment" | "PullRequestReviewComment" => { + let Some(repository) = repository_from_node(node, "/repository/nameWithOwner") else { + return Ok(Err(ExecutionResult::failure( + "GitHub comment node contained no repository", + ))); + }; + if let Err(result) = validate_actual_repository(selected_repository, &repository) { + return Ok(Err(result)); + } + let Some(url) = node.get("url").and_then(Value::as_str) else { + return Ok(Err(ExecutionResult::failure( + "GitHub comment node contained no target URL", + ))); + }; + let number = match parse_html_target_url(url, &repository) { + Ok(number) => number, + Err(error) => return Ok(Err(ExecutionResult::failure(error.to_string()))), + }; + let parent = match validate_issue_parent( + client, + repository, + number, + filters, + Some(url.to_string()), + ) + .await? + { + Ok(parent) => parent, + Err(result) => return Ok(Err(result)), + }; + Ok(Ok(ResolvedComment { + node_id: node_id.to_string(), + parent, + })) + } + "DiscussionComment" => { + if !discussions { + return Ok(Err(ExecutionResult::failure( + "GitHub discussion comments are disabled; set discussions: true", + ))); + } + let Some(repository) = + repository_from_node(node, "/discussion/repository/nameWithOwner") + else { + return Ok(Err(ExecutionResult::failure( + "GitHub discussion comment contained no repository", + ))); + }; + if let Err(result) = validate_actual_repository(selected_repository, &repository) { + return Ok(Err(result)); + } + let number = node + .pointer("/discussion/number") + .and_then(Value::as_u64) + .filter(|number| *number > 0); + let Some(number) = number else { + return Ok(Err(ExecutionResult::failure( + "GitHub discussion comment contained no positive discussion number", + ))); + }; + let title = node + .pointer("/discussion/title") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let metadata = GithubTargetMetadata { + number, + node_id: None, + title, + state: String::new(), + labels: Vec::new(), + kind: GithubTargetKind::Issue, + html_url: None, + }; + if let Err(result) = validate_github_mutation_filters(&metadata, filters) { + return Ok(Err(result)); + } + Ok(Ok(ResolvedComment { + node_id: node_id.to_string(), + parent: CommentParent::Discussion { + repository, + number, + url: None, + }, + })) + } + _ => Ok(Err(ExecutionResult::failure(format!( + "GraphQL node '{}' is not a minimizable GitHub issue, pull-request, or discussion comment", + crate::sanitize::neutralize_pipeline_commands(node_id) + )))), + } +} + +#[async_trait::async_trait] +impl Executor for HideGithubIssueCommentResult { + fn dry_run_summary(&self) -> String { + format!( + "hide GitHub comment {} as {}", + self.comment_id, + self.reason.as_deref().unwrap_or(DEFAULT_REASON) + ) + } + + async fn execute_impl(&self, ctx: &ExecutionContext) -> anyhow::Result { + const TOOL: &str = "hide-github-issue-comment"; + if !ctx.tool_configs.contains_key(TOOL) { + return Ok(ExecutionResult::failure(format!( + "{TOOL} is not configured for this workflow" + ))); + } + let token = match ctx.github_token.as_ref() { + Some(token) => token, + None => { + return Ok(ExecutionResult::failure( + "ADO_AW_GITHUB_TOKEN is not set; configure safe-outputs.github-token \ + or safe-outputs.github-app", + )); + } + }; + let config: HideGithubIssueCommentConfig = ctx.get_tool_config(TOOL)?; + validate_hide_github_issue_comment_config(&config)?; + let reason = canonical_github_comment_reason(self.reason.as_deref())?; + if let Err(error) = validate_github_comment_reason_policy(&reason, &config.allowed_reasons) + { + return Ok(ExecutionResult::failure(error.to_string())); + } + + let selected_repository = match resolve_github_repository( + self.repository.as_deref(), + GithubRepositoryPolicy::new(config.target_repo.as_deref(), &config.allowed_repos), + ctx, + ) { + Ok(repository) => repository, + Err(result) => return Ok(result), + }; + let filters = GithubMutationFilters { + required_labels: &config.required_labels, + required_title_prefix: config.required_title_prefix.as_deref(), + }; + let client = GithubClient::new(&ctx.github_api_url, token)?; + + // Resolve the owning target and apply repository/live-target policy + // before the first mutation. + let resolved = match &self.comment_id { + GithubCommentId::Numeric(id) => { + resolve_numeric_comment(&client, *id, &selected_repository, filters).await? + } + GithubCommentId::Node(node_id) => { + resolve_node_comment( + &client, + node_id, + &selected_repository, + filters, + config.discussions, + ) + .await? + } + }; + let resolved = match resolved { + Ok(resolved) => resolved, + Err(result) => return Ok(result), + }; + + debug!( + "Minimizing GitHub comment {} with reason {}", + self.comment_id, reason + ); + if let Err(result) = minimize_github_comment(&client, &resolved.node_id, &reason).await? { + return Ok(result); + } + + let (repository, number, target_kind, url) = match resolved.parent { + CommentParent::Issue { + repository, + number, + kind, + url, + } => { + let kind = match kind { + GithubTargetKind::Issue => "issue", + GithubTargetKind::PullRequest => "pull_request", + }; + (repository, number, kind, url) + } + CommentParent::Discussion { + repository, + number, + url, + } => (repository, number, "discussion", url), + }; + info!( + "Minimized GitHub comment {} in {}#{}", + self.comment_id, repository, number + ); + Ok(ExecutionResult::success_with_data( + format!( + "Minimized GitHub comment {} in {}#{} as {}", + self.comment_id, repository, number, reason + ), + serde_json::json!({ + "comment_id": self.comment_id.to_string(), + "comment_node_id": resolved.node_id, + "reason": reason, + "target_repo": repository, + "number": number, + "target_kind": target_kind, + "url": url, + }), + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::safe_outputs::ToolResult; + use std::collections::HashMap; + use wiremock::matchers::{body_json, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + fn issue_json(number: u64, pull_request: bool) -> Value { + let mut issue = serde_json::json!({ + "number": number, + "node_id": format!("I_{number}"), + "title": "[agent] Managed target", + "state": "open", + "labels": [{"name": "managed"}], + "html_url": format!("https://github.example/octo/repo/issues/{number}") + }); + if pull_request { + issue["pull_request"] = serde_json::json!({}); + } + issue + } + + fn context(server: &MockServer, config: Value) -> ExecutionContext { + let mut tool_configs = HashMap::new(); + tool_configs.insert("hide-github-issue-comment".to_string(), config); + ExecutionContext { + github_token: Some("token".to_string()), + github_api_url: server.uri(), + tool_configs, + ..Default::default() + } + } + + fn numeric_result(id: u64, reason: Option<&str>) -> HideGithubIssueCommentResult { + HideGithubIssueCommentParams { + comment_id: GithubCommentId::Numeric(id), + reason: reason.map(str::to_string), + repository: None, + } + .try_into() + .unwrap() + } + + fn minimize_response() -> Value { + serde_json::json!({ + "data": { + "minimizeComment": { + "minimizedComment": { + "isMinimized": true, + "minimizedReason": "OUTDATED" + } + } + } + }) + } + + #[test] + fn result_contract_and_comment_id_deserialization() { + assert_eq!( + HideGithubIssueCommentResult::NAME, + "hide-github-issue-comment" + ); + assert_eq!(HideGithubIssueCommentResult::DEFAULT_MAX, 5); + let numeric: HideGithubIssueCommentParams = + serde_json::from_value(serde_json::json!({"comment_id": "42"})).unwrap(); + assert_eq!(numeric.comment_id, GithubCommentId::Numeric(42)); + let node: HideGithubIssueCommentParams = + serde_json::from_value(serde_json::json!({"comment_id": "IC_kwDOAA"})).unwrap(); + assert_eq!( + node.comment_id, + GithubCommentId::Node("IC_kwDOAA".to_string()) + ); + } + + #[test] + fn validates_ids_reasons_and_repository() { + assert!( + HideGithubIssueCommentParams { + comment_id: GithubCommentId::Numeric(0), + reason: None, + repository: None, + } + .validate() + .is_err() + ); + assert!( + HideGithubIssueCommentParams { + comment_id: GithubCommentId::Node("".to_string()), + reason: None, + repository: None, + } + .validate() + .is_err() + ); + assert!( + HideGithubIssueCommentParams { + comment_id: GithubCommentId::Numeric(1), + reason: Some("not-a-reason".to_string()), + repository: None, + } + .validate() + .is_err() + ); + assert!( + HideGithubIssueCommentParams { + comment_id: GithubCommentId::Numeric(1), + reason: Some("spam".to_string()), + repository: Some("octo/$(TOKEN)".to_string()), + } + .validate() + .is_err() + ); + } + + #[test] + fn strict_config_defaults_and_rejects_unknown_fields() { + let config: HideGithubIssueCommentConfig = serde_yaml::from_str( + r#" +target-repo: octo/repo +allowed-repos: [octo/other] +required-labels: [managed] +required-title-prefix: "[agent]" +allowed-reasons: [spam, OUTDATED] +discussions: true +max: 3 +"#, + ) + .unwrap(); + assert!(config.discussions); + assert_eq!(config.max, Some(3)); + assert!(serde_yaml::from_str::("unknown: true").is_err()); + assert!(!HideGithubIssueCommentConfig::default().discussions); + } + + #[test] + fn sanitizes_structural_text_and_formats_dry_run() { + let mut result = HideGithubIssueCommentResult { + name: "hide-github-issue-comment".to_string(), + comment_id: GithubCommentId::Node("IC_\u{0007}1".to_string()), + reason: Some("out\u{0008}dated".to_string()), + repository: Some("octo/re\u{0007}po".to_string()), + }; + result.sanitize_content_fields(); + assert_eq!(result.comment_id, GithubCommentId::Node("IC_1".to_string())); + assert_eq!(result.reason.as_deref(), Some("outdated")); + assert_eq!( + result.dry_run_summary(), + "hide GitHub comment IC_1 as outdated" + ); + } + + #[tokio::test] + async fn dry_run_performs_no_http() { + let server = MockServer::start().await; + let mut ctx = context(&server, serde_json::json!({"target-repo": "octo/repo"})); + ctx.dry_run = true; + let mut result = numeric_result(7, None); + let execution = result.execute_sanitized(&ctx).await.unwrap(); + assert!(execution.success); + assert!(execution.message.contains("[DRY-RUN]")); + assert!(server.received_requests().await.unwrap().is_empty()); + } + + #[tokio::test] + async fn policy_rejections_happen_before_http() { + let server = MockServer::start().await; + let mut result = numeric_result(7, Some("spam")); + let ctx = context( + &server, + serde_json::json!({ + "target-repo": "octo/repo", + "allowed-reasons": ["OUTDATED"] + }), + ); + let execution = result.execute_sanitized(&ctx).await.unwrap(); + assert!(!execution.success); + assert!(execution.message.contains("allowed-reasons")); + assert!(server.received_requests().await.unwrap().is_empty()); + } + + #[tokio::test] + async fn numeric_id_resolves_owner_and_minimizes_after_filters() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/repos/octo/repo/issues/comments/99")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": 99, + "node_id": "IC_99", + "issue_url": format!("{}/repos/octo/repo/issues/7", server.uri()), + "html_url": "https://github.example/octo/repo/issues/7#issuecomment-99" + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/repos/octo/repo/issues/7")) + .respond_with(ResponseTemplate::new(200).set_body_json(issue_json(7, false))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/graphql")) + .and(body_json(serde_json::json!({ + "query": MINIMIZE_COMMENT_MUTATION, + "variables": { + "input": {"subjectId": "IC_99", "classifier": "SPAM"} + } + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(minimize_response())) + .expect(1) + .mount(&server) + .await; + + let ctx = context( + &server, + serde_json::json!({ + "target-repo": "octo/repo", + "required-labels": ["MANAGED"], + "required-title-prefix": "[agent]", + "allowed-reasons": ["SPAM"] + }), + ); + let mut result = numeric_result(99, Some("spam")); + let execution = result.execute_sanitized(&ctx).await.unwrap(); + assert!(execution.success, "{}", execution.message); + assert_eq!( + execution.data.as_ref().unwrap()["comment_node_id"], + serde_json::json!("IC_99") + ); + } + + #[tokio::test] + async fn failed_live_filter_performs_no_graphql_mutation() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/repos/octo/repo/issues/comments/99")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": 99, + "node_id": "IC_99", + "issue_url": format!("{}/repos/octo/repo/issues/7", server.uri()) + }))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/repos/octo/repo/issues/7")) + .respond_with(ResponseTemplate::new(200).set_body_json(issue_json(7, false))) + .mount(&server) + .await; + let ctx = context( + &server, + serde_json::json!({ + "target-repo": "octo/repo", + "required-labels": ["missing"] + }), + ); + let mut result = numeric_result(99, None); + let execution = result.execute_sanitized(&ctx).await.unwrap(); + assert!(!execution.success); + assert!(execution.message.contains("missing required labels")); + let requests = server.received_requests().await.unwrap(); + assert_eq!(requests.len(), 2); + assert!( + requests + .iter() + .all(|request| request.method.as_str() == "GET") + ); + } + + #[tokio::test] + async fn node_id_resolves_repository_and_pull_request_before_minimizing() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/graphql")) + .and(body_json(serde_json::json!({ + "query": RESOLVE_COMMENT_QUERY, + "variables": {"id": "PRRC_1"} + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { + "node": { + "__typename": "PullRequestReviewComment", + "id": "PRRC_1", + "url": "https://github.example/octo/repo/pull/7#discussion_r1", + "repository": {"nameWithOwner": "octo/repo"} + } + } + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/repos/octo/repo/issues/7")) + .respond_with(ResponseTemplate::new(200).set_body_json(issue_json(7, true))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/graphql")) + .and(body_json(serde_json::json!({ + "query": MINIMIZE_COMMENT_MUTATION, + "variables": { + "input": {"subjectId": "PRRC_1", "classifier": "OUTDATED"} + } + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(minimize_response())) + .expect(1) + .mount(&server) + .await; + + let ctx = context(&server, serde_json::json!({"target-repo": "octo/repo"})); + let mut result: HideGithubIssueCommentResult = HideGithubIssueCommentParams { + comment_id: GithubCommentId::Node("PRRC_1".to_string()), + reason: None, + repository: None, + } + .try_into() + .unwrap(); + let execution = result.execute_sanitized(&ctx).await.unwrap(); + assert!(execution.success, "{}", execution.message); + assert_eq!( + execution.data.as_ref().unwrap()["target_kind"], + serde_json::json!("pull_request") + ); + } + + #[tokio::test] + async fn discussion_requires_opt_in_and_applies_title_filter() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/graphql")) + .and(body_json(serde_json::json!({ + "query": RESOLVE_COMMENT_QUERY, + "variables": {"id": "DC_1"} + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { + "node": { + "__typename": "DiscussionComment", + "id": "DC_1", + "discussion": { + "number": 4, + "title": "[agent] Discussion", + "repository": {"nameWithOwner": "octo/repo"} + } + } + } + }))) + .mount(&server) + .await; + + let mut disabled: HideGithubIssueCommentResult = HideGithubIssueCommentParams { + comment_id: GithubCommentId::Node("DC_1".to_string()), + reason: None, + repository: None, + } + .try_into() + .unwrap(); + let disabled_ctx = context(&server, serde_json::json!({"target-repo": "octo/repo"})); + let execution = disabled.execute_sanitized(&disabled_ctx).await.unwrap(); + assert!(!execution.success); + assert!(execution.message.contains("discussions: true")); + + Mock::given(method("POST")) + .and(path("/graphql")) + .and(body_json(serde_json::json!({ + "query": MINIMIZE_COMMENT_MUTATION, + "variables": { + "input": {"subjectId": "DC_1", "classifier": "OUTDATED"} + } + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(minimize_response())) + .expect(1) + .mount(&server) + .await; + let enabled_ctx = context( + &server, + serde_json::json!({ + "target-repo": "octo/repo", + "discussions": true, + "required-title-prefix": "[agent]" + }), + ); + let mut enabled: HideGithubIssueCommentResult = HideGithubIssueCommentParams { + comment_id: GithubCommentId::Node("DC_1".to_string()), + reason: None, + repository: None, + } + .try_into() + .unwrap(); + let execution = enabled.execute_sanitized(&enabled_ctx).await.unwrap(); + assert!(execution.success, "{}", execution.message); + } + + #[tokio::test] + async fn rest_and_graphql_failures_are_explicit() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/repos/octo/repo/issues/comments/99")) + .respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({ + "message": "comment not found" + }))) + .mount(&server) + .await; + let ctx = context(&server, serde_json::json!({"target-repo": "octo/repo"})); + let mut missing = numeric_result(99, None); + let execution = missing.execute_sanitized(&ctx).await.unwrap(); + assert!(!execution.success); + assert!(execution.message.contains("HTTP 404")); + + let graphql_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/repos/octo/repo/issues/comments/99")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": 99, + "node_id": "IC_99", + "issue_url": format!("{}/repos/octo/repo/issues/7", graphql_server.uri()) + }))) + .mount(&graphql_server) + .await; + Mock::given(method("GET")) + .and(path("/repos/octo/repo/issues/7")) + .respond_with(ResponseTemplate::new(200).set_body_json(issue_json(7, false))) + .mount(&graphql_server) + .await; + Mock::given(method("POST")) + .and(path("/graphql")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "errors": [{"type": "FORBIDDEN", "message": "cannot minimize"}] + }))) + .mount(&graphql_server) + .await; + let graphql_ctx = context( + &graphql_server, + serde_json::json!({"target-repo": "octo/repo"}), + ); + let mut denied = numeric_result(99, None); + let execution = denied.execute_sanitized(&graphql_ctx).await.unwrap(); + assert!(!execution.success); + assert!(execution.message.contains("FORBIDDEN")); + assert!(execution.message.contains("cannot minimize")); + } + + #[tokio::test] + async fn missing_configuration_and_token_fail_cleanly() { + let mut result = numeric_result(1, None); + let execution = result + .execute_sanitized(&ExecutionContext::default()) + .await + .unwrap(); + assert!(!execution.success); + assert!(execution.message.contains("not configured")); + + let server = MockServer::start().await; + let mut ctx = context(&server, serde_json::json!({"target-repo": "octo/repo"})); + ctx.github_token = None; + let mut result = numeric_result(1, None); + let execution = result.execute_sanitized(&ctx).await.unwrap(); + assert!(!execution.success); + assert!(execution.message.contains("ADO_AW_GITHUB_TOKEN")); + assert!(server.received_requests().await.unwrap().is_empty()); + } +} diff --git a/src/safe_outputs/link_github_sub_issue.rs b/src/safe_outputs/link_github_sub_issue.rs new file mode 100644 index 000000000..4a5e48ea3 --- /dev/null +++ b/src/safe_outputs/link_github_sub_issue.rs @@ -0,0 +1,726 @@ +//! `link-github-sub-issue` safe output. + +use anyhow::ensure; +use log::{debug, info}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::safe_outputs::{ + ExecutionContext, ExecutionResult, Executor, GithubClient, GithubIssueNumber, + GithubMutationFilters, GithubRepositoryPolicy, GithubTargetCapabilities, Validate, + resolve_github_issue_target, validate_github_mutation_filter_config, + validate_github_mutation_filters, validate_github_repository, + validate_github_target_capability, +}; +use crate::sanitize::{SanitizeContent, sanitize_config}; +use crate::tool_result; +use ado_aw_derive::SanitizeConfig; + +const GET_SUB_ISSUE_PARENT: &str = r#"query($id: ID!) { + node(id: $id) { + ... on Issue { + id + parent { + id + number + repository { nameWithOwner } + } + } + } +}"#; + +const ADD_SUB_ISSUE: &str = r#"mutation($parentId: ID!, $subIssueId: ID!) { + addSubIssue(input: { + issueId: $parentId + subIssueId: $subIssueId + replaceParent: false + }) { + issue { id number } + subIssue { id number } + } +}"#; + +#[derive(Deserialize, JsonSchema)] +pub struct LinkGithubSubIssueParams { + /// Positive parent issue number or a temporary ID from create-github-issue. + pub parent_issue_number: GithubIssueNumber, + /// Positive child issue number or a temporary ID from create-github-issue. + pub sub_issue_number: GithubIssueNumber, + /// Optional repository shared by the parent and child. + #[serde(default)] + pub repository: Option, +} + +impl Validate for LinkGithubSubIssueParams { + fn validate(&self) -> anyhow::Result<()> { + self.parent_issue_number.validate("parent_issue_number")?; + self.sub_issue_number.validate("sub_issue_number")?; + ensure!( + !same_issue_reference(&self.parent_issue_number, &self.sub_issue_number), + "parent_issue_number and sub_issue_number must be different" + ); + if let Some(repository) = self.repository.as_deref() { + validate_github_repository(repository)?; + } + Ok(()) + } +} + +tool_result! { + name = "link-github-sub-issue", + write = true, + params = LinkGithubSubIssueParams, + default_max = 5, + /// Result of linking two GitHub issues as parent and child. + pub struct LinkGithubSubIssueResult { + parent_issue_number: GithubIssueNumber, + sub_issue_number: GithubIssueNumber, + #[serde(default)] + repository: Option, + } +} + +impl SanitizeContent for LinkGithubSubIssueResult { + fn sanitize_content_fields(&mut self) { + self.repository = self.repository.as_deref().map(sanitize_config); + } +} + +#[derive(Debug, Clone, Default, SanitizeConfig, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LinkGithubSubIssueConfig { + #[serde(default, rename = "target-repo")] + pub target_repo: Option, + #[serde(default, rename = "allowed-repos")] + pub allowed_repos: Vec, + #[serde(default, rename = "parent-required-labels")] + pub parent_required_labels: Vec, + #[serde(default, rename = "parent-title-prefix")] + pub parent_title_prefix: Option, + #[serde(default, rename = "sub-required-labels")] + pub sub_required_labels: Vec, + #[serde(default, rename = "sub-title-prefix")] + pub sub_title_prefix: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[sanitize_config(skip)] + pub max: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ExistingParent { + id: String, + number: u64, + repository: String, +} + +#[async_trait::async_trait] +impl Executor for LinkGithubSubIssueResult { + fn dry_run_summary(&self) -> String { + format!( + "link GitHub issue {} as a sub-issue of {}", + display_issue_number(&self.sub_issue_number), + display_issue_number(&self.parent_issue_number) + ) + } + + async fn execute_impl(&self, ctx: &ExecutionContext) -> anyhow::Result { + if !ctx.tool_configs.contains_key("link-github-sub-issue") { + return Ok(ExecutionResult::failure( + "link-github-sub-issue is not configured for this workflow", + )); + } + let token = match ctx.github_token.as_ref() { + Some(token) => token, + None => { + return Ok(ExecutionResult::failure( + "ADO_AW_GITHUB_TOKEN is not set; configure safe-outputs.github-token \ + or safe-outputs.github-app", + )); + } + }; + let config: LinkGithubSubIssueConfig = ctx.get_tool_config("link-github-sub-issue")?; + if let Err(error) = validate_link_github_sub_issue_config(&config) { + return Ok(ExecutionResult::failure(error.to_string())); + } + let policy = + GithubRepositoryPolicy::new(config.target_repo.as_deref(), &config.allowed_repos); + let parent = match resolve_github_issue_target( + &self.parent_issue_number, + self.repository.as_deref(), + policy, + ctx, + )? { + Ok(target) => target, + Err(result) => return Ok(result), + }; + let sub_issue = match resolve_github_issue_target( + &self.sub_issue_number, + self.repository.as_deref(), + policy, + ctx, + )? { + Ok(target) => target, + Err(result) => return Ok(result), + }; + if !parent + .repository + .eq_ignore_ascii_case(&sub_issue.repository) + { + return Ok(ExecutionResult::failure(format!( + "parent issue repository '{}' and sub-issue repository '{}' must be the same", + parent.repository, sub_issue.repository + ))); + } + if parent.number == sub_issue.number { + return Ok(ExecutionResult::failure( + "parent_issue_number and sub_issue_number resolved to the same GitHub issue", + )); + } + + let client = GithubClient::new(&ctx.github_api_url, token)?; + let parent_metadata = match client.get_issue(&parent.repository, parent.number).await? { + Ok(metadata) => metadata, + Err(error) => return Ok(ExecutionResult::failure(error.to_string())), + }; + let sub_metadata = match client + .get_issue(&sub_issue.repository, sub_issue.number) + .await? + { + Ok(metadata) => metadata, + Err(error) => return Ok(ExecutionResult::failure(error.to_string())), + }; + for metadata in [&parent_metadata, &sub_metadata] { + if let Err(result) = + validate_github_target_capability(metadata, GithubTargetCapabilities::ISSUES_ONLY) + { + return Ok(result); + } + } + let parent_filters = GithubMutationFilters { + required_labels: &config.parent_required_labels, + required_title_prefix: config.parent_title_prefix.as_deref(), + }; + let sub_filters = GithubMutationFilters { + required_labels: &config.sub_required_labels, + required_title_prefix: config.sub_title_prefix.as_deref(), + }; + if let Err(result) = validate_github_mutation_filters(&parent_metadata, parent_filters) { + return Ok(result); + } + if let Err(result) = validate_github_mutation_filters(&sub_metadata, sub_filters) { + return Ok(result); + } + let Some(parent_node_id) = parent_metadata.node_id.as_deref() else { + return Ok(ExecutionResult::failure(format!( + "GitHub parent issue {}#{} has no GraphQL node ID; sub-issues are unsupported or unavailable", + parent.repository, parent.number + ))); + }; + let Some(sub_node_id) = sub_metadata.node_id.as_deref() else { + return Ok(ExecutionResult::failure(format!( + "GitHub sub-issue {}#{} has no GraphQL node ID; sub-issues are unsupported or unavailable", + sub_issue.repository, sub_issue.number + ))); + }; + + let preflight = match client + .graphql( + "Check GitHub sub-issue parent", + GET_SUB_ISSUE_PARENT, + serde_json::json!({ "id": sub_node_id }), + ) + .await? + { + Ok(data) => data, + Err(error) => { + return Ok(ExecutionResult::failure(format!( + "GitHub sub-issues are unsupported or unavailable: {error}" + ))); + } + }; + if let Some(existing) = match parse_existing_parent(&preflight) { + Ok(parent) => parent, + Err(message) => return Ok(ExecutionResult::failure(message)), + } { + let same_parent = existing.id == parent_node_id; + if same_parent { + info!( + "GitHub issue {}#{} is already a sub-issue of #{}", + parent.repository, sub_issue.number, parent.number + ); + return Ok(ExecutionResult::success_with_data( + format!( + "GitHub issue {}#{} is already a sub-issue of #{}", + parent.repository, sub_issue.number, parent.number + ), + serde_json::json!({ + "parent_issue_number": parent.number, + "sub_issue_number": sub_issue.number, + "target_repo": parent.repository, + "already_linked": true, + }), + )); + } + let existing_target = format!("{}#{}", existing.repository, existing.number); + return Ok(ExecutionResult::failure(format!( + "GitHub issue {}#{} is already linked to a different parent ({existing_target}); refusing to replace it", + sub_issue.repository, sub_issue.number + ))); + } + + debug!( + "Linking GitHub issue {}#{} as a sub-issue of #{}", + parent.repository, sub_issue.number, parent.number + ); + let mutation = match client + .graphql( + "Link GitHub sub-issue", + ADD_SUB_ISSUE, + serde_json::json!({ + "parentId": parent_node_id, + "subIssueId": sub_node_id, + }), + ) + .await? + { + Ok(data) => data, + Err(error) => { + return Ok(ExecutionResult::failure(format!( + "GitHub addSubIssue mutation is unsupported or failed: {error}" + ))); + } + }; + let mutated_parent = mutation + .pointer("/addSubIssue/issue/number") + .and_then(Value::as_u64); + let mutated_sub = mutation + .pointer("/addSubIssue/subIssue/number") + .and_then(Value::as_u64); + if mutated_parent != Some(parent.number) || mutated_sub != Some(sub_issue.number) { + return Ok(ExecutionResult::failure( + "GitHub addSubIssue response did not identify the requested parent and sub-issue", + )); + } + + info!( + "Linked GitHub issue {}#{} as a sub-issue of #{}", + parent.repository, sub_issue.number, parent.number + ); + Ok(ExecutionResult::success_with_data( + format!( + "Linked GitHub issue {}#{} as a sub-issue of #{}", + parent.repository, sub_issue.number, parent.number + ), + serde_json::json!({ + "parent_issue_number": parent.number, + "sub_issue_number": sub_issue.number, + "target_repo": parent.repository, + "already_linked": false, + }), + )) + } +} + +pub(crate) fn validate_link_github_sub_issue_config( + config: &LinkGithubSubIssueConfig, +) -> anyhow::Result<()> { + validate_github_mutation_filter_config(GithubMutationFilters { + required_labels: &config.parent_required_labels, + required_title_prefix: config.parent_title_prefix.as_deref(), + })?; + validate_github_mutation_filter_config(GithubMutationFilters { + required_labels: &config.sub_required_labels, + required_title_prefix: config.sub_title_prefix.as_deref(), + }) +} + +fn same_issue_reference(parent: &GithubIssueNumber, sub_issue: &GithubIssueNumber) -> bool { + match (parent, sub_issue) { + (GithubIssueNumber::Number(parent), GithubIssueNumber::Number(sub_issue)) => { + parent == sub_issue + } + (GithubIssueNumber::Temporary(parent), GithubIssueNumber::Temporary(sub_issue)) => { + parent.canonical() == sub_issue.canonical() + } + _ => false, + } +} + +fn display_issue_number(issue_number: &GithubIssueNumber) -> String { + match issue_number { + GithubIssueNumber::Number(number) => format!("#{number}"), + GithubIssueNumber::Temporary(temporary_id) => temporary_id.canonical(), + } +} + +fn parse_existing_parent(data: &Value) -> Result, String> { + let node = data.get("node").and_then(Value::as_object).ok_or_else(|| { + "GitHub sub-issue preflight did not return the requested issue; sub-issues may be unsupported" + .to_string() + })?; + let Some(parent) = node.get("parent") else { + return Err( + "GitHub API response did not expose Issue.parent; sub-issues are unsupported" + .to_string(), + ); + }; + if parent.is_null() { + return Ok(None); + } + let parent = parent.as_object().ok_or_else(|| { + "GitHub sub-issue preflight returned malformed parent metadata".to_string() + })?; + let id = parent + .get("id") + .and_then(Value::as_str) + .filter(|id| !id.is_empty()) + .ok_or_else(|| "GitHub sub-issue parent had no GraphQL node ID".to_string())?; + let number = parent + .get("number") + .and_then(Value::as_u64) + .filter(|number| *number > 0) + .ok_or_else(|| "GitHub sub-issue parent had no positive issue number".to_string())?; + let repository = parent + .get("repository") + .and_then(Value::as_object) + .and_then(|repository| repository.get("nameWithOwner")) + .and_then(Value::as_str) + .filter(|repository| !repository.is_empty()) + .ok_or_else(|| "GitHub sub-issue parent had no repository identity".to_string())?; + Ok(Some(ExistingParent { + id: id.to_string(), + number, + repository: repository.to_string(), + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::safe_outputs::ToolResult; + use crate::secure::GithubTemporaryId; + use std::collections::HashMap; + use wiremock::matchers::{body_json, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + #[test] + fn result_contract_and_dry_run() { + assert_eq!(LinkGithubSubIssueResult::NAME, "link-github-sub-issue"); + assert_eq!(LinkGithubSubIssueResult::DEFAULT_MAX, 5); + let result: LinkGithubSubIssueResult = LinkGithubSubIssueParams { + parent_issue_number: GithubIssueNumber::Number(10), + sub_issue_number: GithubIssueNumber::Number(11), + repository: None, + } + .try_into() + .unwrap(); + assert_eq!( + result.dry_run_summary(), + "link GitHub issue #11 as a sub-issue of #10" + ); + } + + #[test] + fn validates_numeric_and_temporary_references() { + let params = LinkGithubSubIssueParams { + parent_issue_number: GithubIssueNumber::Temporary( + GithubTemporaryId::parse("#aw_parent").unwrap(), + ), + sub_issue_number: GithubIssueNumber::Temporary( + GithubTemporaryId::parse("#aw_sub").unwrap(), + ), + repository: Some("octo/repo".to_string()), + }; + assert!(params.validate().is_ok()); + + let same = LinkGithubSubIssueParams { + parent_issue_number: GithubIssueNumber::Number(7), + sub_issue_number: GithubIssueNumber::Number(7), + repository: None, + }; + assert!( + same.validate() + .unwrap_err() + .to_string() + .contains("must be different") + ); + } + + #[test] + fn rejects_same_temporary_id_and_invalid_repository() { + let temporary_id = GithubTemporaryId::parse("#aw_same").unwrap(); + let same = LinkGithubSubIssueParams { + parent_issue_number: GithubIssueNumber::Temporary(temporary_id.clone()), + sub_issue_number: GithubIssueNumber::Temporary(temporary_id), + repository: None, + }; + assert!(same.validate().is_err()); + + let invalid_repo = LinkGithubSubIssueParams { + parent_issue_number: GithubIssueNumber::Number(1), + sub_issue_number: GithubIssueNumber::Number(2), + repository: Some("octo/$(TOKEN)".to_string()), + }; + assert!(invalid_repo.validate().is_err()); + } + + #[test] + fn config_is_strict_and_has_separate_filters() { + assert!( + serde_yaml::from_str::( + "parent-required-labels: [parent]\nunknown: true" + ) + .is_err() + ); + let config: LinkGithubSubIssueConfig = serde_yaml::from_str( + "parent-required-labels: [parent]\n\ + parent-title-prefix: 'Parent: '\n\ + sub-required-labels: [child]\n\ + sub-title-prefix: 'Child: '\n\ + max: 4", + ) + .unwrap(); + assert_eq!(config.parent_required_labels, vec!["parent"]); + assert_eq!(config.parent_title_prefix.as_deref(), Some("Parent: ")); + assert_eq!(config.sub_required_labels, vec!["child"]); + assert_eq!(config.sub_title_prefix.as_deref(), Some("Child: ")); + assert_eq!(config.max, Some(4)); + assert!(validate_link_github_sub_issue_config(&config).is_ok()); + } + + #[test] + fn parses_none_same_and_different_parent_metadata() { + assert_eq!( + parse_existing_parent(&serde_json::json!({ + "node": {"id": "SUB", "parent": null} + })) + .unwrap(), + None + ); + assert_eq!( + parse_existing_parent(&serde_json::json!({ + "node": { + "id": "SUB", + "parent": { + "id": "PARENT", + "number": 10, + "repository": {"nameWithOwner": "octo/repo"} + } + } + })) + .unwrap(), + Some(ExistingParent { + id: "PARENT".to_string(), + number: 10, + repository: "octo/repo".to_string(), + }) + ); + assert!( + parse_existing_parent(&serde_json::json!({"node": {"id": "SUB"}})) + .unwrap_err() + .contains("unsupported") + ); + } + + async fn mount_issue( + server: &MockServer, + number: u64, + node_id: &str, + title: &str, + label: &str, + ) { + Mock::given(method("GET")) + .and(path(format!("/repos/octo/repo/issues/{number}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "number": number, + "node_id": node_id, + "title": title, + "state": "open", + "labels": [{"name": label}], + "html_url": format!("https://github.example/octo/repo/issues/{number}") + }))) + .expect(1) + .mount(server) + .await; + } + + fn context(server: &MockServer) -> ExecutionContext { + let mut tool_configs = HashMap::new(); + tool_configs.insert( + "link-github-sub-issue".to_string(), + serde_json::json!({ + "target-repo": "octo/repo", + "parent-required-labels": ["parent"], + "parent-title-prefix": "Parent:", + "sub-required-labels": ["child"], + "sub-title-prefix": "Child:" + }), + ); + ExecutionContext { + github_token: Some("token".to_string()), + github_api_url: server.uri(), + tool_configs, + ..Default::default() + } + } + + fn result() -> LinkGithubSubIssueResult { + LinkGithubSubIssueParams { + parent_issue_number: GithubIssueNumber::Number(10), + sub_issue_number: GithubIssueNumber::Number(11), + repository: None, + } + .try_into() + .unwrap() + } + + #[tokio::test] + async fn preflights_filters_and_links_sub_issue() { + let server = MockServer::start().await; + mount_issue(&server, 10, "PARENT", "Parent: plan", "parent").await; + mount_issue(&server, 11, "SUB", "Child: task", "child").await; + Mock::given(method("POST")) + .and(path("/graphql")) + .and(body_json(serde_json::json!({ + "query": GET_SUB_ISSUE_PARENT, + "variables": {"id": "SUB"} + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": {"node": {"id": "SUB", "parent": null}} + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/graphql")) + .and(body_json(serde_json::json!({ + "query": ADD_SUB_ISSUE, + "variables": {"parentId": "PARENT", "subIssueId": "SUB"} + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { + "addSubIssue": { + "issue": {"id": "PARENT", "number": 10}, + "subIssue": {"id": "SUB", "number": 11} + } + } + }))) + .expect(1) + .mount(&server) + .await; + + let ctx = context(&server); + let mut result = result(); + let execution = result.execute_sanitized(&ctx).await.unwrap(); + assert!( + execution.success, + "unexpected failure: {}", + execution.message + ); + assert_eq!( + execution + .data + .as_ref() + .and_then(|data| data["already_linked"].as_bool()), + Some(false) + ); + } + + #[tokio::test] + async fn existing_same_parent_is_idempotent_without_mutation() { + let server = MockServer::start().await; + mount_issue(&server, 10, "PARENT", "Parent: plan", "parent").await; + mount_issue(&server, 11, "SUB", "Child: task", "child").await; + Mock::given(method("POST")) + .and(path("/graphql")) + .and(body_json(serde_json::json!({ + "query": GET_SUB_ISSUE_PARENT, + "variables": {"id": "SUB"} + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { + "node": { + "id": "SUB", + "parent": { + "id": "PARENT", + "number": 10, + "repository": {"nameWithOwner": "octo/repo"} + } + } + } + }))) + .expect(1) + .mount(&server) + .await; + + let ctx = context(&server); + let mut result = result(); + let execution = result.execute_sanitized(&ctx).await.unwrap(); + assert!(execution.success); + assert_eq!( + execution + .data + .as_ref() + .and_then(|data| data["already_linked"].as_bool()), + Some(true) + ); + assert_eq!(server.received_requests().await.unwrap().len(), 3); + } + + #[tokio::test] + async fn different_existing_parent_fails_without_mutation() { + let server = MockServer::start().await; + mount_issue(&server, 10, "PARENT", "Parent: plan", "parent").await; + mount_issue(&server, 11, "SUB", "Child: task", "child").await; + Mock::given(method("POST")) + .and(path("/graphql")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { + "node": { + "id": "SUB", + "parent": { + "id": "OTHER", + "number": 9, + "repository": {"nameWithOwner": "octo/repo"} + } + } + } + }))) + .expect(1) + .mount(&server) + .await; + + let ctx = context(&server); + let mut result = result(); + let execution = result.execute_sanitized(&ctx).await.unwrap(); + assert!(!execution.success); + assert!(execution.message.contains("different parent")); + assert_eq!(server.received_requests().await.unwrap().len(), 3); + } + + #[tokio::test] + async fn unsupported_parent_query_fails_explicitly() { + let server = MockServer::start().await; + mount_issue(&server, 10, "PARENT", "Parent: plan", "parent").await; + mount_issue(&server, 11, "SUB", "Child: task", "child").await; + Mock::given(method("POST")) + .and(path("/graphql")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": null, + "errors": [{ + "type": "undefinedField", + "message": "Field 'parent' doesn't exist on type 'Issue'" + }] + }))) + .expect(1) + .mount(&server) + .await; + + let ctx = context(&server); + let mut result = result(); + let execution = result.execute_sanitized(&ctx).await.unwrap(); + assert!(!execution.success); + assert!(execution.message.contains("unsupported or unavailable")); + } +} diff --git a/src/safe_outputs/link_work_items.rs b/src/safe_outputs/link_work_items.rs index 83acbf7f5..954eeef38 100644 --- a/src/safe_outputs/link_work_items.rs +++ b/src/safe_outputs/link_work_items.rs @@ -155,7 +155,7 @@ impl Executor for LinkWorkItemsResult { .context("No access token available (SYSTEM_ACCESSTOKEN or AZURE_DEVOPS_EXT_PAT)")?; debug!("ADO org: {}, project: {}", org_url, project); - let config: LinkWorkItemsConfig = ctx.get_tool_config("link-work-items"); + let config: LinkWorkItemsConfig = ctx.get_tool_config("link-work-items")?; debug!("Allowed link types: {:?}", config.allowed_link_types); // Validate work item IDs against target scope @@ -372,7 +372,8 @@ mod tests { }; let err = LinkWorkItemsResult::try_from(params).unwrap_err(); assert!( - err.to_string().contains("source_id and target_id must be different"), + err.to_string() + .contains("source_id and target_id must be different"), "expected error about same ids, got: {err}" ); } diff --git a/src/safe_outputs/mod.rs b/src/safe_outputs/mod.rs index 3fe10ce1a..89f513eac 100644 --- a/src/safe_outputs/mod.rs +++ b/src/safe_outputs/mod.rs @@ -44,8 +44,21 @@ pub const SAFE_OUTPUT_CONFIG_KEYS: &[&str] = &[ pub const DEBUG_ONLY_TOOLS: &[&str] = &[]; /// Public tools exposed only when explicitly configured in `safe-outputs:`. -pub const CONFIGURED_ONLY_TOOLS: &[&str] = - tool_names![CreateGithubIssueResult, SetGithubIssueTypeResult]; +pub const CONFIGURED_ONLY_TOOLS: &[&str] = tool_names![ + CreateGithubIssueResult, + SetGithubIssueTypeResult, + CommentOnGithubIssueResult, + HideGithubIssueCommentResult, + AddGithubIssueLabelsResult, + RemoveGithubIssueLabelsResult, + CloseGithubIssueResult, + UpdateGithubIssueResult, + SetGithubIssueFieldResult, + AssignGithubIssueMilestoneResult, + AssignGithubIssueToUserResult, + UnassignGithubIssueFromUserResult, + LinkGithubSubIssueResult, +]; /// All recognised safe-output keys accepted in front matter `safe-outputs:`. /// This is the union of write-requiring tool types and diagnostic tool types. @@ -77,6 +90,17 @@ pub const ALL_KNOWN_SAFE_OUTPUTS: &[&str] = all_safe_output_names![ ResolvePrThreadResult, CreateGithubIssueResult, SetGithubIssueTypeResult, + CommentOnGithubIssueResult, + HideGithubIssueCommentResult, + AddGithubIssueLabelsResult, + RemoveGithubIssueLabelsResult, + CloseGithubIssueResult, + UpdateGithubIssueResult, + SetGithubIssueFieldResult, + AssignGithubIssueMilestoneResult, + AssignGithubIssueToUserResult, + UnassignGithubIssueFromUserResult, + LinkGithubSubIssueResult, // Always-on diagnostics NoopResult, MissingDataResult, @@ -191,9 +215,7 @@ pub(crate) fn lookup_allowed_repository_alias<'a>( input: &str, allowed_repositories: &'a std::collections::HashMap, ) -> Option<&'a String> { - fn unique_alias<'a>( - mut matches: impl Iterator, - ) -> Option<&'a String> { + fn unique_alias<'a>(mut matches: impl Iterator) -> Option<&'a String> { let first = matches.next()?; if matches.next().is_some() { return None; @@ -428,7 +450,12 @@ pub(crate) fn name_matches_pattern(name: &str, pattern: &str) -> bool { pub(crate) use crate::validate::validate_git_ref_name; mod add_build_tag; +mod add_github_issue_labels; mod add_pr_comment; +mod assign_github_issue_milestone; +mod assign_github_issue_to_user; +mod close_github_issue; +mod comment_on_github_issue; mod comment_on_work_item; mod create_branch; mod create_git_tag; @@ -436,17 +463,25 @@ mod create_github_issue; mod create_pull_request; mod create_wiki_page; mod create_work_item; +mod github_api; +mod github_issue_common; +mod hide_github_issue_comment; +mod link_github_sub_issue; mod link_work_items; mod missing_data; mod missing_tool; mod noop; mod queue_build; +mod remove_github_issue_labels; mod reply_to_pr_comment; mod report_incomplete; mod resolve_pr_thread; mod result; +mod set_github_issue_field; mod set_github_issue_type; mod submit_pr_review; +mod unassign_github_issue_from_user; +mod update_github_issue; mod update_pr; mod update_wiki_page; mod update_work_item; @@ -455,20 +490,29 @@ mod upload_pipeline_artifact; mod upload_workitem_attachment; pub use add_build_tag::*; +pub use add_github_issue_labels::*; pub use add_pr_comment::*; +pub use assign_github_issue_milestone::*; +pub use assign_github_issue_to_user::*; +pub use close_github_issue::*; +pub use comment_on_github_issue::*; pub use comment_on_work_item::*; pub use create_branch::*; pub use create_git_tag::*; -pub(crate) use create_github_issue::validate_target_repo; pub use create_github_issue::*; pub use create_pull_request::*; pub use create_wiki_page::*; pub use create_work_item::*; +pub use github_api::*; +pub use github_issue_common::*; +pub use hide_github_issue_comment::*; +pub use link_github_sub_issue::*; pub use link_work_items::*; pub use missing_data::*; pub use missing_tool::*; pub use noop::*; pub use queue_build::*; +pub use remove_github_issue_labels::*; pub use reply_to_pr_comment::*; pub use report_incomplete::*; pub use resolve_pr_thread::*; @@ -476,8 +520,11 @@ pub use result::{ ExecutionContext, ExecutionResult, Executor, ResolvedGithubIssue, ToolResult, Validate, anyhow_to_mcp_error, org_from_url, }; +pub use set_github_issue_field::*; pub use set_github_issue_type::*; pub use submit_pr_review::*; +pub use unassign_github_issue_from_user::*; +pub use update_github_issue::*; pub use update_pr::*; pub use update_wiki_page::*; pub use update_work_item::*; diff --git a/src/safe_outputs/queue_build.rs b/src/safe_outputs/queue_build.rs index 9c58873e1..e03dc6740 100644 --- a/src/safe_outputs/queue_build.rs +++ b/src/safe_outputs/queue_build.rs @@ -139,7 +139,7 @@ impl Executor for QueueBuildResult { debug!("ADO org: {}, project: {}", org_url, project); // Get tool-specific configuration - let config: QueueBuildConfig = ctx.get_tool_config("queue-build"); + let config: QueueBuildConfig = ctx.get_tool_config("queue-build")?; debug!("Allowed pipelines: {:?}", config.allowed_pipelines); debug!("Allowed branches: {:?}", config.allowed_branches); debug!("Allowed parameters: {:?}", config.allowed_parameters); @@ -357,7 +357,8 @@ mod tests { let result: Result = params.try_into(); let err = result.unwrap_err(); assert!( - err.to_string().contains("reason must be at least 5 characters"), + err.to_string() + .contains("reason must be at least 5 characters"), "unexpected error: {err}" ); } @@ -373,7 +374,8 @@ mod tests { let result: Result = params.try_into(); let err = result.unwrap_err(); assert!( - err.to_string().contains("branch name must not contain '..'"), + err.to_string() + .contains("branch name must not contain '..'"), "unexpected error: {err}" ); } diff --git a/src/safe_outputs/remove_github_issue_labels.rs b/src/safe_outputs/remove_github_issue_labels.rs new file mode 100644 index 000000000..35ec37c62 --- /dev/null +++ b/src/safe_outputs/remove_github_issue_labels.rs @@ -0,0 +1,613 @@ +//! `remove-github-issue-labels` safe output. + +use anyhow::ensure; +use log::{debug, info}; +use reqwest::{Method, StatusCode}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::safe_outputs::{ + ExecutionContext, ExecutionResult, Executor, GithubClient, GithubIssueNumber, + GithubMutationFilters, GithubRepositoryPolicy, GithubTargetCapabilities, Validate, + merge_github_values, resolve_github_issue_target, validate_blocked_first_globs, + validate_github_mutation_filter_config, validate_github_mutation_filters, + validate_github_repository, validate_github_target_capability, +}; +use crate::sanitize::{SanitizeContent, sanitize_config}; +use crate::tool_result; +use crate::validate::reject_pipeline_injection; +use ado_aw_derive::SanitizeConfig; + +#[derive(Deserialize, JsonSchema)] +pub struct RemoveGithubIssueLabelsParams { + /// Positive GitHub issue number or a temporary ID from create-github-issue. + pub issue_number: GithubIssueNumber, + /// Labels to remove. At least one label is required. + pub labels: Vec, + /// Optional target repository. Must exactly match `target-repo` or an + /// `allowed-repos` entry. + #[serde(default)] + pub repository: Option, +} + +impl Validate for RemoveGithubIssueLabelsParams { + fn validate(&self) -> anyhow::Result<()> { + self.issue_number.validate("issue_number")?; + ensure!(!self.labels.is_empty(), "labels must not be empty"); + for label in &self.labels { + ensure!(!label.is_empty(), "labels entries must not be empty"); + reject_pipeline_injection(label, "remove-github-issue-labels.labels")?; + } + if let Some(repository) = self.repository.as_deref() { + validate_github_repository(repository)?; + } + Ok(()) + } +} + +tool_result! { + name = "remove-github-issue-labels", + write = true, + params = RemoveGithubIssueLabelsParams, + default_max = 5, + /// Result of removing labels from a GitHub issue. + pub struct RemoveGithubIssueLabelsResult { + issue_number: GithubIssueNumber, + labels: Vec, + #[serde(default)] + repository: Option, + } +} + +impl SanitizeContent for RemoveGithubIssueLabelsResult { + fn sanitize_content_fields(&mut self) { + for label in &mut self.labels { + *label = sanitize_config(label); + } + self.repository = self.repository.as_deref().map(sanitize_config); + } +} + +#[derive(Debug, Clone, Default, SanitizeConfig, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RemoveGithubIssueLabelsConfig { + #[serde(default, rename = "target-repo")] + pub target_repo: Option, + #[serde(default, rename = "allowed-repos")] + pub allowed_repos: Vec, + #[serde(default, rename = "required-labels")] + pub required_labels: Vec, + #[serde(default, rename = "required-title-prefix")] + pub required_title_prefix: Option, + /// Case-insensitive gh-aw-compatible glob allowlist. Empty permits any + /// label not matched by `blocked`. + #[serde(default)] + pub allowed: Vec, + /// Case-insensitive gh-aw-compatible glob denylist. Evaluated first. + #[serde(default)] + pub blocked: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[sanitize_config(skip)] + pub max: Option, +} + +pub(crate) fn validate_remove_github_issue_labels_config( + config: &RemoveGithubIssueLabelsConfig, +) -> anyhow::Result<()> { + validate_github_mutation_filter_config(GithubMutationFilters { + required_labels: &config.required_labels, + required_title_prefix: config.required_title_prefix.as_deref(), + })?; + validate_label_policy_config(&config.allowed, &config.blocked) +} + +fn validate_label_policy_config(allowed: &[String], blocked: &[String]) -> anyhow::Result<()> { + for (field, patterns) in [("allowed", allowed), ("blocked", blocked)] { + for pattern in patterns { + ensure!(!pattern.is_empty(), "{field} entries must not be empty"); + reject_pipeline_injection(pattern, field)?; + } + } + Ok(()) +} + +fn target_display(issue_number: &GithubIssueNumber, repository: Option<&str>) -> String { + match repository { + Some(repository) => format!("{repository}#{issue_number}"), + None => format!("#{issue_number}"), + } +} + +#[async_trait::async_trait] +impl Executor for RemoveGithubIssueLabelsResult { + fn dry_run_summary(&self) -> String { + format!( + "remove GitHub labels [{}] from {}", + self.labels.join(", "), + target_display(&self.issue_number, self.repository.as_deref()) + ) + } + + async fn execute_impl(&self, ctx: &ExecutionContext) -> anyhow::Result { + const TOOL: &str = "remove-github-issue-labels"; + if !ctx.tool_configs.contains_key(TOOL) { + return Ok(ExecutionResult::failure(format!( + "{TOOL} is not configured for this workflow" + ))); + } + let token = match ctx.github_token.as_ref() { + Some(token) => token, + None => { + return Ok(ExecutionResult::failure( + "ADO_AW_GITHUB_TOKEN is not set; configure safe-outputs.github-token \ + or safe-outputs.github-app", + )); + } + }; + let config: RemoveGithubIssueLabelsConfig = ctx.get_tool_config(TOOL)?; + validate_remove_github_issue_labels_config(&config)?; + + let labels = merge_github_values(&[], &self.labels); + if let Err(result) = + validate_blocked_first_globs(&labels, &config.allowed, &config.blocked, "label") + { + return Ok(result); + } + + let target = match resolve_github_issue_target( + &self.issue_number, + self.repository.as_deref(), + GithubRepositoryPolicy::new(config.target_repo.as_deref(), &config.allowed_repos), + ctx, + )? { + Ok(target) => target, + Err(result) => return Ok(result), + }; + let filters = GithubMutationFilters { + required_labels: &config.required_labels, + required_title_prefix: config.required_title_prefix.as_deref(), + }; + let client = GithubClient::new(&ctx.github_api_url, token)?; + let metadata = match client.get_issue(&target.repository, target.number).await? { + Ok(metadata) => metadata, + Err(error) => return Ok(ExecutionResult::failure(error.to_string())), + }; + if let Err(result) = validate_github_target_capability( + &metadata, + GithubTargetCapabilities::ISSUES_AND_PULL_REQUESTS, + ) { + return Ok(result); + } + if let Err(result) = validate_github_mutation_filters(&metadata, filters) { + return Ok(result); + } + + // Resolve all requested labels against the live target before the first + // DELETE. This makes absent labels idempotent and prevents a denied + // later value from leaving a partial mutation. + let mut present = Vec::new(); + let mut absent = Vec::new(); + for requested in &labels { + match metadata + .labels + .iter() + .find(|existing| existing.eq_ignore_ascii_case(requested)) + { + Some(existing) => present.push(existing.clone()), + None => absent.push(requested.clone()), + } + } + + let mut removed = Vec::new(); + for label in present { + let mut url = client.issue_url(&target.repository, target.number)?; + url.path_segments_mut() + .map_err(|_| anyhow::anyhow!("GitHub issue URL cannot be a base URL"))? + .push("labels") + .push(&label); + debug!( + "DELETEing label '{}' from GitHub issue {}#{}", + label, target.repository, target.number + ); + let response = client.send(Method::DELETE, url, None).await?; + if response.status == StatusCode::NOT_FOUND { + // A concurrent actor may remove the label after our preflight. + // The requested end state is already satisfied. + absent.push(label); + continue; + } + if !response.is_success() { + let error = response + .require_success("Failed to remove GitHub issue label") + .expect_err("non-success response must produce an API error"); + return Ok(ExecutionResult::failure(error.to_string())); + } + removed.push(label); + } + + info!( + "Removed {} label(s) from GitHub issue {}#{}; {} already absent", + removed.len(), + target.repository, + target.number, + absent.len() + ); + Ok(ExecutionResult::success_with_data( + format!( + "Removed labels [{}] from {}#{} ({} already absent)", + removed.join(", "), + target.repository, + target.number, + absent.len() + ), + serde_json::json!({ + "number": target.number, + "target_repo": target.repository, + "removed_labels": removed, + "absent_labels": absent, + }), + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::safe_outputs::{ResolvedGithubIssue, ToolResult}; + use crate::secure::GithubTemporaryId; + use std::collections::HashMap; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + fn issue_json(number: u64, pull_request: bool) -> serde_json::Value { + let mut value = serde_json::json!({ + "number": number, + "node_id": format!("I_{number}"), + "title": "[agent] Fix the build", + "state": "open", + "labels": [{"name": "Managed"}, {"name": "needs triage"}], + "html_url": format!("https://github.example/octo/repo/issues/{number}") + }); + if pull_request { + value["pull_request"] = serde_json::json!({}); + } + value + } + + fn context(server: &MockServer, config: serde_json::Value) -> ExecutionContext { + let mut tool_configs = HashMap::new(); + tool_configs.insert("remove-github-issue-labels".to_string(), config); + ExecutionContext { + github_token: Some("token".to_string()), + github_api_url: server.uri(), + tool_configs, + ..Default::default() + } + } + + fn result(labels: &[&str]) -> RemoveGithubIssueLabelsResult { + RemoveGithubIssueLabelsParams { + issue_number: GithubIssueNumber::Number(7), + labels: labels.iter().map(|label| (*label).to_string()).collect(), + repository: None, + } + .try_into() + .unwrap() + } + + #[test] + fn params_require_labels_and_support_temporary_ids() { + assert!( + RemoveGithubIssueLabelsParams { + issue_number: GithubIssueNumber::Number(1), + labels: vec![], + repository: None, + } + .validate() + .is_err() + ); + assert!( + RemoveGithubIssueLabelsParams { + issue_number: GithubIssueNumber::Temporary( + GithubTemporaryId::parse("#aw_labels").unwrap() + ), + labels: vec!["triage".to_string()], + repository: Some("octo/repo".to_string()), + } + .validate() + .is_ok() + ); + } + + #[test] + fn params_reject_empty_or_injecting_labels() { + for label in ["", "##vso[task.complete]", "${{ variables.secret }}"] { + assert!( + RemoveGithubIssueLabelsParams { + issue_number: GithubIssueNumber::Number(1), + labels: vec![label.to_string()], + repository: None, + } + .validate() + .is_err(), + "label should be rejected: {label}" + ); + } + } + + #[test] + fn result_contract_and_dry_run_summary() { + assert_eq!( + RemoveGithubIssueLabelsResult::NAME, + "remove-github-issue-labels" + ); + assert_eq!(RemoveGithubIssueLabelsResult::DEFAULT_MAX, 5); + assert_eq!( + result(&["bug"]).dry_run_summary(), + "remove GitHub labels [bug] from #7" + ); + } + + #[test] + fn config_is_strict() { + let config: RemoveGithubIssueLabelsConfig = serde_yaml::from_str( + "target-repo: octo/repo\nallowed: ['agent-*']\nblocked: [security]\n", + ) + .unwrap(); + assert_eq!(config.allowed, vec!["agent-*"]); + assert!( + serde_yaml::from_str::( + "target-repo: octo/repo\npull-requests: true\n" + ) + .is_err() + ); + } + + #[tokio::test] + async fn blocked_wins_case_insensitively_before_http() { + let server = MockServer::start().await; + let execution = result(&["security-review"]) + .execute_impl(&context( + &server, + serde_json::json!({ + "target-repo": "octo/repo", + "allowed": ["*"], + "blocked": ["SECURITY-*"] + }), + )) + .await + .unwrap(); + assert!(!execution.success); + assert!(execution.message.contains("blocked")); + assert!(server.received_requests().await.unwrap().is_empty()); + } + + #[tokio::test] + async fn omitted_allowed_permits_removal_with_filters() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/repos/octo/repo/issues/7")) + .respond_with(ResponseTemplate::new(200).set_body_json(issue_json(7, false))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("DELETE")) + .and(path("/repos/octo/repo/issues/7/labels/needs%20triage")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([]))) + .expect(1) + .mount(&server) + .await; + let execution = result(&["NEEDS TRIAGE"]) + .execute_impl(&context( + &server, + serde_json::json!({ + "target-repo": "octo/repo", + "required-labels": ["managed"], + "required-title-prefix": "[agent]" + }), + )) + .await + .unwrap(); + assert!( + execution.success, + "unexpected failure: {}", + execution.message + ); + } + + #[tokio::test] + async fn absent_label_is_idempotent_without_delete() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/repos/octo/repo/issues/7")) + .respond_with(ResponseTemplate::new(200).set_body_json(issue_json(7, false))) + .expect(1) + .mount(&server) + .await; + let execution = result(&["already-absent"]) + .execute_impl(&context( + &server, + serde_json::json!({"target-repo": "octo/repo"}), + )) + .await + .unwrap(); + assert!( + execution.success, + "unexpected failure: {}", + execution.message + ); + assert_eq!( + execution + .data + .as_ref() + .and_then(|data| data["absent_labels"].as_array()) + .map(Vec::len), + Some(1) + ); + let requests = server.received_requests().await.unwrap(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].method.as_str(), "GET"); + } + + #[tokio::test] + async fn pull_request_target_allows_label_removal() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/repos/octo/repo/issues/7")) + .respond_with(ResponseTemplate::new(200).set_body_json(issue_json(7, true))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("DELETE")) + .and(path("/repos/octo/repo/issues/7/labels/Managed")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([]))) + .expect(1) + .mount(&server) + .await; + let execution = result(&["managed"]) + .execute_impl(&context( + &server, + serde_json::json!({"target-repo": "octo/repo"}), + )) + .await + .unwrap(); + assert!(execution.success, "{}", execution.message); + assert_eq!(server.received_requests().await.unwrap().len(), 2); + } + + #[tokio::test] + async fn filter_failure_happens_before_any_delete() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/repos/octo/repo/issues/7")) + .respond_with(ResponseTemplate::new(200).set_body_json(issue_json(7, false))) + .expect(1) + .mount(&server) + .await; + let execution = result(&["managed"]) + .execute_impl(&context( + &server, + serde_json::json!({ + "target-repo": "octo/repo", + "required-title-prefix": "[other]" + }), + )) + .await + .unwrap(); + assert!(!execution.success); + let requests = server.received_requests().await.unwrap(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].method.as_str(), "GET"); + } + + #[tokio::test] + async fn delete_not_found_is_idempotent_success() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/repos/octo/repo/issues/7")) + .respond_with(ResponseTemplate::new(200).set_body_json(issue_json(7, false))) + .mount(&server) + .await; + Mock::given(method("DELETE")) + .and(path("/repos/octo/repo/issues/7/labels/Managed")) + .respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({ + "message": "Label does not exist" + }))) + .mount(&server) + .await; + let execution = result(&["managed"]) + .execute_impl(&context( + &server, + serde_json::json!({"target-repo": "octo/repo"}), + )) + .await + .unwrap(); + assert!( + execution.success, + "unexpected failure: {}", + execution.message + ); + assert_eq!( + execution + .data + .as_ref() + .and_then(|data| data["absent_labels"].as_array()) + .map(Vec::len), + Some(1) + ); + } + + #[tokio::test] + async fn temporary_id_resolves_before_removal() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/repos/octo/repo/issues/42")) + .respond_with(ResponseTemplate::new(200).set_body_json(issue_json(42, false))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("DELETE")) + .and(path("/repos/octo/repo/issues/42/labels/Managed")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([]))) + .expect(1) + .mount(&server) + .await; + let ctx = context(&server, serde_json::json!({"target-repo": "octo/repo"})); + let id = GithubTemporaryId::parse("#aw_labels").unwrap(); + ctx.register_resolved_github_issue( + &id, + ResolvedGithubIssue { + repository: "octo/repo".to_string(), + number: 42, + url: "https://github.example/octo/repo/issues/42".to_string(), + }, + ) + .unwrap(); + let execution = RemoveGithubIssueLabelsResult { + name: "remove-github-issue-labels".to_string(), + issue_number: GithubIssueNumber::Temporary(id), + labels: vec!["managed".to_string()], + repository: None, + } + .execute_impl(&ctx) + .await + .unwrap(); + assert!( + execution.success, + "unexpected failure: {}", + execution.message + ); + } + + #[tokio::test] + async fn github_failure_is_neutralized() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/repos/octo/repo/issues/7")) + .respond_with(ResponseTemplate::new(200).set_body_json(issue_json(7, false))) + .mount(&server) + .await; + Mock::given(method("DELETE")) + .and(path("/repos/octo/repo/issues/7/labels/Managed")) + .respond_with(ResponseTemplate::new(500).set_body_json(serde_json::json!({ + "message": "##vso[task.complete] rejected" + }))) + .mount(&server) + .await; + let execution = result(&["managed"]) + .execute_impl(&context( + &server, + serde_json::json!({"target-repo": "octo/repo"}), + )) + .await + .unwrap(); + assert!(!execution.success); + assert!( + !execution + .message + .lines() + .any(|line| line.starts_with("##vso[")) + ); + assert!(execution.message.contains("`##vso[`")); + } +} diff --git a/src/safe_outputs/reply_to_pr_comment.rs b/src/safe_outputs/reply_to_pr_comment.rs index 4f3bc289f..bf54af9cd 100644 --- a/src/safe_outputs/reply_to_pr_comment.rs +++ b/src/safe_outputs/reply_to_pr_comment.rs @@ -128,7 +128,7 @@ impl Executor for ReplyToPrCommentResult { .context("No access token available (SYSTEM_ACCESSTOKEN or AZURE_DEVOPS_EXT_PAT)")?; debug!("ADO org: {}, project: {}", org_url, project); - let config: ReplyToPrCommentConfig = ctx.get_tool_config("reply-to-pr-comment"); + let config: ReplyToPrCommentConfig = ctx.get_tool_config("reply-to-pr-comment")?; debug!("Config: {:?}", config); let repository = self.repository.as_deref().unwrap_or("self"); @@ -322,7 +322,8 @@ mod tests { let result: Result = params.try_into(); let err = result.unwrap_err(); assert!( - err.to_string().contains("content must be at least 10 characters"), + err.to_string() + .contains("content must be at least 10 characters"), "unexpected error: {err}" ); } diff --git a/src/safe_outputs/resolve_pr_thread.rs b/src/safe_outputs/resolve_pr_thread.rs index 9f6adfdc5..671d730f8 100644 --- a/src/safe_outputs/resolve_pr_thread.rs +++ b/src/safe_outputs/resolve_pr_thread.rs @@ -156,7 +156,7 @@ impl Executor for ResolvePrThreadResult { .context("No access token available (SYSTEM_ACCESSTOKEN or AZURE_DEVOPS_EXT_PAT)")?; debug!("ADO org: {}, project: {}", org_url, project); - let config: ResolvePrThreadConfig = ctx.get_tool_config("resolve-pr-thread"); + let config: ResolvePrThreadConfig = ctx.get_tool_config("resolve-pr-thread")?; debug!("Config: {:?}", config); // Validate status against allowed-statuses — REQUIRED. diff --git a/src/safe_outputs/result.rs b/src/safe_outputs/result.rs index e96d1f369..d45b8f10f 100644 --- a/src/safe_outputs/result.rs +++ b/src/safe_outputs/result.rs @@ -143,6 +143,13 @@ pub struct ExecutionContext { /// Pipeline definition name (`BUILD_DEFINITIONNAME`) #[allow(dead_code)] pub definition_name: Option, + /// Stable numeric pipeline definition identity (`SYSTEM_DEFINITIONID`). + /// + /// GitHub comment tools use this value in hidden markers so older comments + /// can be matched to the originating pipeline without relying on mutable + /// definition names or agent-authored text. + #[allow(dead_code)] + pub definition_id: Option, /// Full source ref, e.g. `refs/heads/main` (`BUILD_SOURCEBRANCH`) #[allow(dead_code)] pub source_branch: Option, @@ -209,53 +216,37 @@ impl ExecutionContext { /// Get typed configuration for a specific tool. /// /// Deserializes the tool's JSON config from front matter and applies - /// [`SanitizeConfig`] to all textual fields before returning. The - /// `SanitizeConfig` bound acts as a compile-time forcing function: - /// adding a new config struct without implementing the trait won't compile. + /// [`SanitizeConfig`] to all textual fields before returning. Missing or + /// explicit null configs use the tool default; malformed configured JSON + /// returns an error. The `SanitizeConfig` bound acts as a compile-time + /// forcing function: adding a new config struct without implementing the + /// trait won't compile. pub fn get_tool_config( &self, tool_name: &str, - ) -> T { - let value = self - .tool_configs - .get(tool_name) - .cloned() - .map(|mut value| { + ) -> anyhow::Result { + let mut config = match self.tool_configs.get(tool_name) { + None | Some(serde_json::Value::Null) => T::default(), + Some(value) => { + let mut value = value.clone(); // Compiler orchestration metadata, not executor configuration. // Both keys are injected into EVERY tool config by Stage 3 // (`main.rs` for `--source`, `compile/custom_tools.rs` for the // `--resolved-config` production path), so a config struct // declared `deny_unknown_fields` fails to deserialize unless - // they are stripped first. Because the error is swallowed - // below, that manifests as the operator's config being - // silently replaced by `Default::default()` rather than as a - // visible failure — keep this list in sync with every key the - // compiler injects. + // they are stripped first. Keep this list in sync with every + // key the compiler injects. if let Some(object) = value.as_object_mut() { object.remove("require-approval"); object.remove("staged"); } - value - }); - let mut config: T = value - .map(|v| match serde_json::from_value(v) { - Ok(config) => config, - Err(error) => { - // Never fail silently: a config-shape mismatch here wipes - // every operator-supplied setting for the tool (target - // repos, allowlists, budgets), which is easy to mistake for - // a product bug at runtime. - log::warn!( - "Failed to deserialize config for tool '{tool_name}': {error}. \ - Falling back to defaults; operator-supplied settings for this \ - tool will NOT be applied." - ); - T::default() - } - }) - .unwrap_or_default(); + serde_json::from_value(value).map_err(|error| { + anyhow::anyhow!("failed to deserialize config for tool '{tool_name}': {error}") + })? + } + }; config.sanitize_config_fields(); - config + Ok(config) } pub fn has_resolved_github_issue( @@ -385,6 +376,7 @@ impl ExecutionContext { build_number: env("BUILD_BUILDNUMBER"), build_reason: env("BUILD_REASON"), definition_name: env("BUILD_DEFINITIONNAME"), + definition_id: env("SYSTEM_DEFINITIONID").and_then(|s| s.parse().ok()), source_branch: env("BUILD_SOURCEBRANCH"), source_branch_name: env("BUILD_SOURCEBRANCHNAME"), source_version: env("BUILD_SOURCEVERSION"), @@ -975,7 +967,8 @@ mod tests { "my-tool".to_string(), serde_json::json!({ "value": "##vso[task.setvariable variable=secret]injected" }), ); - let config: TestConfigForSanitization = ctx.get_tool_config("my-tool"); + let config: TestConfigForSanitization = + ctx.get_tool_config("my-tool").expect("config should parse"); assert!( !config.value.contains("##vso[task."), "Injected ##vso[ command should be neutralized; got: {}", @@ -988,6 +981,60 @@ mod tests { ); } + #[test] + fn test_get_tool_config_missing_and_null_use_defaults() { + let missing: TestConfigForSanitization = ExecutionContext::default() + .get_tool_config("missing-tool") + .expect("missing config should use defaults"); + assert!(missing.value.is_empty()); + + let mut ctx = ExecutionContext::default(); + ctx.tool_configs + .insert("null-tool".to_string(), serde_json::Value::Null); + let null: TestConfigForSanitization = ctx + .get_tool_config("null-tool") + .expect("null config should use defaults"); + assert!(null.value.is_empty()); + } + + #[test] + fn test_get_tool_config_rejects_malformed_github_config() { + let mut ctx = ExecutionContext::default(); + ctx.tool_configs.insert( + "create-github-issue".to_string(), + serde_json::json!({ "allowed-labels": "not-an-array" }), + ); + + let error = ctx + .get_tool_config::("create-github-issue") + .expect_err("malformed GitHub config must fail closed"); + assert!( + error + .to_string() + .contains("failed to deserialize config for tool 'create-github-issue'"), + "unexpected error: {error}" + ); + } + + #[test] + fn test_get_tool_config_rejects_malformed_non_github_config() { + let mut ctx = ExecutionContext::default(); + ctx.tool_configs.insert( + "add-build-tag".to_string(), + serde_json::json!({ "allow-any-build": "not-a-boolean" }), + ); + + let error = ctx + .get_tool_config::("add-build-tag") + .expect_err("malformed non-GitHub config must fail closed"); + assert!( + error + .to_string() + .contains("failed to deserialize config for tool 'add-build-tag'"), + "unexpected error: {error}" + ); + } + // ── ADO build variable capture tests (use from_env_lookup so they // don't mutate the process-global environment) ───────────────────── @@ -1006,6 +1053,7 @@ mod tests { ("BUILD_BUILDNUMBER", "20240101.1"), ("BUILD_REASON", "Manual"), ("BUILD_DEFINITIONNAME", "My Pipeline"), + ("SYSTEM_DEFINITIONID", "987"), ("BUILD_SOURCEBRANCH", "refs/heads/main"), ("BUILD_SOURCEBRANCHNAME", "main"), ("BUILD_SOURCEVERSION", "abc1234"), @@ -1014,6 +1062,7 @@ mod tests { assert_eq!(ctx.build_number.as_deref(), Some("20240101.1")); assert_eq!(ctx.build_reason.as_deref(), Some("Manual")); assert_eq!(ctx.definition_name.as_deref(), Some("My Pipeline")); + assert_eq!(ctx.definition_id, Some(987)); assert_eq!(ctx.source_branch.as_deref(), Some("refs/heads/main")); assert_eq!(ctx.source_branch_name.as_deref(), Some("main")); assert_eq!(ctx.source_version.as_deref(), Some("abc1234")); @@ -1023,10 +1072,7 @@ mod tests { fn test_from_env_lookup_populates_checkout_directories() { let ctx = ExecutionContext::from_env_lookup(env_from(&[ ("BUILD_SOURCESDIRECTORY", "C:\\agent\\s"), - ( - "ADO_AW_SELF_REPOSITORY_DIRECTORY", - "C:\\agent\\s\\ado-aw", - ), + ("ADO_AW_SELF_REPOSITORY_DIRECTORY", "C:\\agent\\s\\ado-aw"), ])); assert_eq!( @@ -1060,10 +1106,7 @@ mod tests { ])); assert_eq!(ctx.repository_id.as_deref(), Some("self-id")); - assert_eq!( - ctx.repository_name.as_deref(), - Some("project/self-repo") - ); + assert_eq!(ctx.repository_name.as_deref(), Some("project/self-repo")); } #[test] @@ -1089,10 +1132,7 @@ mod tests { ])); assert_eq!(ctx.repository_id.as_deref(), Some("build-id")); - assert_eq!( - ctx.repository_name.as_deref(), - Some("project/build-repo") - ); + assert_eq!(ctx.repository_name.as_deref(), Some("project/build-repo")); } #[test] @@ -1107,6 +1147,18 @@ mod tests { assert!(ctx.build_id.is_none()); } + #[test] + fn test_from_env_lookup_definition_id_none_for_invalid_or_unset() { + let invalid = + ExecutionContext::from_env_lookup(env_from(&[("SYSTEM_DEFINITIONID", "invalid")])); + assert!(invalid.definition_id.is_none()); + assert!( + ExecutionContext::from_env_lookup(env_from(&[])) + .definition_id + .is_none() + ); + } + #[test] fn test_from_env_lookup_build_container_id_parses_numeric() { let ctx = ExecutionContext::from_env_lookup(env_from(&[("BUILD_CONTAINERID", "112233")])); @@ -1231,14 +1283,11 @@ mod tests { } } - /// Regression guard for a silent config wipe. + /// Regression guard for compiler-only orchestration keys. /// /// `CreateGithubIssueConfig` and `SetGithubIssueTypeConfig` are declared /// `#[serde(deny_unknown_fields)]`, so the compiler-injected `staged` / - /// `require-approval` keys made deserialization fail. `get_tool_config` - /// swallowed the error and returned `Default::default()`, silently - /// discarding every operator setting (`target-repo`, `allowed-labels`, - /// budgets, …) instead of failing visibly. + /// `require-approval` keys must be stripped before strict deserialization. #[test] fn test_get_tool_config_survives_compiler_injected_orchestration_keys() { let ctx = ctx_with_injected_keys( @@ -1252,8 +1301,9 @@ mod tests { "max": 3, }), ); - let config: crate::safe_outputs::CreateGithubIssueConfig = - ctx.get_tool_config("create-github-issue"); + let config: crate::safe_outputs::CreateGithubIssueConfig = ctx + .get_tool_config("create-github-issue") + .expect("compiler-only keys should be stripped"); assert_eq!( config.target_repo.as_deref(), Some("octo/scratch"), @@ -1272,8 +1322,9 @@ mod tests { "set-github-issue-type", serde_json::json!({ "target-repo": "octo/scratch", "allowed": ["Bug"] }), ); - let config: crate::safe_outputs::SetGithubIssueTypeConfig = - ctx.get_tool_config("set-github-issue-type"); + let config: crate::safe_outputs::SetGithubIssueTypeConfig = ctx + .get_tool_config("set-github-issue-type") + .expect("compiler-only keys should be stripped"); assert_eq!(config.target_repo.as_deref(), Some("octo/scratch")); // An empty `allowed` list is default-ALLOW, so a silent wipe here fails // open — any issue type would be accepted. diff --git a/src/safe_outputs/set_github_issue_field.rs b/src/safe_outputs/set_github_issue_field.rs new file mode 100644 index 000000000..0595774f2 --- /dev/null +++ b/src/safe_outputs/set_github_issue_field.rs @@ -0,0 +1,937 @@ +//! `set-github-issue-field` safe output. + +use anyhow::ensure; +use chrono::NaiveDate; +use log::{debug, info}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::safe_outputs::{ + ExecutionContext, ExecutionResult, Executor, GithubClient, GithubIssueNumber, + GithubMutationFilters, GithubRepositoryPolicy, GithubTargetCapabilities, Validate, + resolve_github_issue_target, validate_github_mutation_filter_config, + validate_github_mutation_filters, validate_github_repository, + validate_github_target_capability, +}; +use crate::sanitize::{SanitizeContent, sanitize_config}; +use crate::tool_result; +use crate::validate::reject_pipeline_injection; +use ado_aw_derive::SanitizeConfig; + +const DISCOVER_ISSUE_FIELDS: &str = r#"query($owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { + issueFields(first: 100) { + nodes { + __typename + ... on IssueFieldText { id name } + ... on IssueFieldNumber { id name } + ... on IssueFieldDate { id name } + ... on IssueFieldSingleSelect { id name options { id name } } + ... on IssueFieldMultiSelect { id name options { id name } } + } + } + } +}"#; + +const SET_ISSUE_FIELD_VALUE: &str = r#"mutation($issueId: ID!, $issueFields: [IssueFieldCreateOrUpdateInput!]!) { + setIssueFieldValue(input: { issueId: $issueId, issueFields: $issueFields }) { + issue { id number } + } +}"#; + +#[derive(Deserialize, JsonSchema)] +pub struct SetGithubIssueFieldParams { + /// Positive GitHub issue number or a temporary ID from create-github-issue. + pub issue_number: GithubIssueNumber, + /// Exact repository-defined field name. + #[serde(default)] + pub field_name: Option, + /// GraphQL node ID of a repository-defined field. + #[serde(default)] + pub field_node_id: Option, + /// String representation of the desired field value. + pub value: String, + /// Optional target repository. + #[serde(default)] + pub repository: Option, +} + +impl Validate for SetGithubIssueFieldParams { + fn validate(&self) -> anyhow::Result<()> { + self.issue_number.validate("issue_number")?; + ensure!( + self.field_name.is_some() ^ self.field_node_id.is_some(), + "exactly one of field_name or field_node_id must be provided" + ); + if let Some(field_name) = self.field_name.as_deref() { + validate_field_selector(field_name, "field_name")?; + ensure!( + !is_builtin_issue_field(field_name), + "field_name '{}' is a built-in GitHub issue field; use its dedicated safe-output tool", + field_name + ); + } + if let Some(field_node_id) = self.field_node_id.as_deref() { + validate_field_selector(field_node_id, "field_node_id")?; + } + ensure!( + self.value.len() <= 65_536, + "value must be 65536 characters or fewer" + ); + reject_pipeline_injection(&self.value, "set-github-issue-field.value")?; + if let Some(repository) = self.repository.as_deref() { + validate_github_repository(repository)?; + } + Ok(()) + } +} + +tool_result! { + name = "set-github-issue-field", + write = true, + params = SetGithubIssueFieldParams, + default_max = 5, + /// Result of setting a repository-defined GitHub issue field. + pub struct SetGithubIssueFieldResult { + issue_number: GithubIssueNumber, + #[serde(default)] + field_name: Option, + #[serde(default)] + field_node_id: Option, + value: String, + #[serde(default)] + repository: Option, + } +} + +impl SanitizeContent for SetGithubIssueFieldResult { + fn sanitize_content_fields(&mut self) { + self.field_name = self.field_name.as_deref().map(sanitize_config); + self.field_node_id = self.field_node_id.as_deref().map(sanitize_config); + // Field values are external API payloads. Preserve their exact semantic + // value while removing transport controls and ADO logging commands. + self.value = sanitize_config(&self.value); + self.repository = self.repository.as_deref().map(sanitize_config); + } +} + +#[derive(Debug, Clone, Default, SanitizeConfig, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SetGithubIssueFieldConfig { + #[serde(default, rename = "target-repo")] + pub target_repo: Option, + #[serde(default, rename = "allowed-repos")] + pub allowed_repos: Vec, + #[serde(default, rename = "required-labels")] + pub required_labels: Vec, + #[serde(default, rename = "required-title-prefix")] + pub required_title_prefix: Option, + #[serde(default, rename = "allowed-fields")] + pub allowed_fields: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[sanitize_config(skip)] + pub max: Option, +} + +#[derive(Debug, Clone, PartialEq)] +struct IssueField { + id: String, + name: String, + kind: String, + options: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct IssueFieldOption { + id: String, + name: String, +} + +#[async_trait::async_trait] +impl Executor for SetGithubIssueFieldResult { + fn dry_run_summary(&self) -> String { + let target = display_issue_number(&self.issue_number); + let field = self + .field_name + .as_deref() + .or(self.field_node_id.as_deref()) + .unwrap_or(""); + format!( + "set GitHub issue field '{field}' on {target} to '{}'", + self.value + ) + } + + async fn execute_impl(&self, ctx: &ExecutionContext) -> anyhow::Result { + if !ctx.tool_configs.contains_key("set-github-issue-field") { + return Ok(ExecutionResult::failure( + "set-github-issue-field is not configured for this workflow", + )); + } + let token = match ctx.github_token.as_ref() { + Some(token) => token, + None => { + return Ok(ExecutionResult::failure( + "ADO_AW_GITHUB_TOKEN is not set; configure safe-outputs.github-token \ + or safe-outputs.github-app", + )); + } + }; + let config: SetGithubIssueFieldConfig = ctx.get_tool_config("set-github-issue-field")?; + if let Err(error) = validate_set_github_issue_field_config(&config) { + return Ok(ExecutionResult::failure(error.to_string())); + } + + let target = match resolve_github_issue_target( + &self.issue_number, + self.repository.as_deref(), + GithubRepositoryPolicy::new(config.target_repo.as_deref(), &config.allowed_repos), + ctx, + )? { + Ok(target) => target, + Err(result) => return Ok(result), + }; + + let client = GithubClient::new(&ctx.github_api_url, token)?; + let metadata = match client.get_issue(&target.repository, target.number).await? { + Ok(metadata) => metadata, + Err(error) => return Ok(ExecutionResult::failure(error.to_string())), + }; + if let Err(result) = + validate_github_target_capability(&metadata, GithubTargetCapabilities::ISSUES_ONLY) + { + return Ok(result); + } + let filters = GithubMutationFilters { + required_labels: &config.required_labels, + required_title_prefix: config.required_title_prefix.as_deref(), + }; + if let Err(error) = validate_github_mutation_filter_config(filters) { + return Ok(ExecutionResult::failure(error.to_string())); + } + if let Err(result) = validate_github_mutation_filters(&metadata, filters) { + return Ok(result); + } + let Some(issue_node_id) = metadata.node_id.as_deref() else { + return Ok(ExecutionResult::failure(format!( + "GitHub issue {}#{} has no GraphQL node ID; issue fields are unsupported or unavailable", + target.repository, target.number + ))); + }; + + let (owner, repo) = target + .repository + .split_once('/') + .expect("resolved GitHub repository is validated"); + let discovery = match client + .graphql( + "Discover GitHub issue fields", + DISCOVER_ISSUE_FIELDS, + serde_json::json!({ "owner": owner, "repo": repo }), + ) + .await? + { + Ok(data) => data, + Err(error) => { + return Ok(ExecutionResult::failure(format!( + "GitHub issue fields are unsupported or unavailable: {error}" + ))); + } + }; + let fields = match parse_issue_fields(&discovery) { + Ok(fields) => fields, + Err(message) => return Ok(ExecutionResult::failure(message)), + }; + let field = match select_issue_field( + &fields, + self.field_name.as_deref(), + self.field_node_id.as_deref(), + ) { + Ok(field) => field, + Err(message) => return Ok(ExecutionResult::failure(message)), + }; + if is_builtin_issue_field(&field.name) { + return Ok(ExecutionResult::failure(format!( + "GitHub field '{}' is a built-in issue field; use its dedicated safe-output tool", + crate::sanitize::neutralize_pipeline_commands(&field.name) + ))); + } + if !github_issue_field_is_allowed(&config.allowed_fields, &field.name) { + return Ok(ExecutionResult::failure(format!( + "GitHub issue field '{}' is not in allowed-fields: {}", + crate::sanitize::neutralize_pipeline_commands(&field.name), + config.allowed_fields.join(", ") + ))); + } + + let field_input = match coerce_field_value(field, &self.value) { + Ok(input) => input, + Err(message) => return Ok(ExecutionResult::failure(message)), + }; + debug!( + "Setting GitHub issue field {} ({}) on {}#{}", + field.name, field.kind, target.repository, target.number + ); + let mutation = match client + .graphql( + "Set GitHub issue field value", + SET_ISSUE_FIELD_VALUE, + serde_json::json!({ + "issueId": issue_node_id, + "issueFields": [field_input], + }), + ) + .await? + { + Ok(data) => data, + Err(error) => { + return Ok(ExecutionResult::failure(format!( + "GitHub issue field mutation is unsupported or failed: {error}" + ))); + } + }; + let updated_number = mutation + .pointer("/setIssueFieldValue/issue/number") + .and_then(Value::as_u64); + if updated_number != Some(target.number) { + return Ok(ExecutionResult::failure( + "GitHub setIssueFieldValue response did not identify the updated issue", + )); + } + + info!( + "Set GitHub issue field '{}' on {}#{}", + field.name, target.repository, target.number + ); + Ok(ExecutionResult::success_with_data( + format!( + "Set GitHub issue field '{}' on {}#{}", + field.name, target.repository, target.number + ), + serde_json::json!({ + "number": target.number, + "target_repo": target.repository, + "field_name": field.name, + "field_node_id": field.id, + "field_type": field.kind, + "value": self.value, + }), + )) + } +} + +fn github_issue_field_is_allowed(allowed_fields: &[String], field_name: &str) -> bool { + allowed_fields + .iter() + .any(|allowed| allowed == "*" || allowed.eq_ignore_ascii_case(field_name)) +} + +fn validate_field_selector(value: &str, field: &str) -> anyhow::Result<()> { + ensure!(!value.is_empty(), "{field} must not be empty"); + ensure!( + value.len() <= 256, + "{field} must be 256 characters or fewer" + ); + reject_pipeline_injection(value, &format!("set-github-issue-field.{field}")) +} + +pub(crate) fn validate_set_github_issue_field_config( + config: &SetGithubIssueFieldConfig, +) -> anyhow::Result<()> { + ensure!( + !config.allowed_fields.is_empty(), + "set-github-issue-field requires at least one allowed-fields entry" + ); + for field in &config.allowed_fields { + validate_field_selector(field, "allowed-fields")?; + ensure!( + !is_builtin_issue_field(field), + "allowed-fields entry '{}' is a built-in GitHub issue field", + field + ); + } + Ok(()) +} + +fn display_issue_number(issue_number: &GithubIssueNumber) -> String { + match issue_number { + GithubIssueNumber::Number(number) => format!("#{number}"), + GithubIssueNumber::Temporary(temporary_id) => temporary_id.canonical(), + } +} + +fn normalized_field_name(value: &str) -> String { + value + .chars() + .map(|character| match character { + '-' | '_' => ' ', + other => other.to_ascii_lowercase(), + }) + .collect::() + .split_whitespace() + .collect::>() + .join(" ") +} + +fn is_builtin_issue_field(value: &str) -> bool { + matches!( + normalized_field_name(value).as_str(), + "title" + | "body" + | "description" + | "status" + | "state" + | "assignee" + | "assignees" + | "label" + | "labels" + | "type" + | "issue type" + | "milestone" + | "project" + | "projects" + | "repository" + | "relationship" + | "relationships" + | "development" + | "parent issue" + | "sub issue" + | "sub issues" + ) +} + +fn parse_issue_fields(data: &Value) -> Result, String> { + let repository = data + .get("repository") + .and_then(Value::as_object) + .ok_or_else(|| { + "GitHub repository was not found or repository issue fields are unsupported".to_string() + })?; + let connection = repository + .get("issueFields") + .and_then(Value::as_object) + .ok_or_else(|| { + "GitHub repository/API does not expose the issueFields GraphQL feature".to_string() + })?; + let nodes = connection + .get("nodes") + .and_then(Value::as_array) + .ok_or_else(|| "GitHub issueFields response contained no nodes array".to_string())?; + let mut fields = Vec::with_capacity(nodes.len()); + for node in nodes { + let object = node + .as_object() + .ok_or_else(|| "GitHub issueFields response contained a malformed field".to_string())?; + let id = object.get("id").and_then(Value::as_str).ok_or_else(|| { + "GitHub issueFields response contained a field without an ID".to_string() + })?; + let name = object.get("name").and_then(Value::as_str).ok_or_else(|| { + "GitHub issueFields response contained a field without a name".to_string() + })?; + let kind = object + .get("__typename") + .and_then(Value::as_str) + .ok_or_else(|| { + "GitHub issueFields response contained a field without a type".to_string() + })?; + let options = object + .get("options") + .and_then(Value::as_array) + .map(|options| { + options + .iter() + .filter_map(|option| { + Some(IssueFieldOption { + id: option.get("id")?.as_str()?.to_string(), + name: option.get("name")?.as_str()?.to_string(), + }) + }) + .collect() + }) + .unwrap_or_default(); + fields.push(IssueField { + id: id.to_string(), + name: name.to_string(), + kind: kind.to_string(), + options, + }); + } + Ok(fields) +} + +fn select_issue_field<'a>( + fields: &'a [IssueField], + field_name: Option<&str>, + field_node_id: Option<&str>, +) -> Result<&'a IssueField, String> { + let matches: Vec<&IssueField> = if let Some(name) = field_name { + fields + .iter() + .filter(|field| field.name.eq_ignore_ascii_case(name)) + .collect() + } else if let Some(id) = field_node_id { + fields.iter().filter(|field| field.id == id).collect() + } else { + Vec::new() + }; + match matches.as_slice() { + [field] => Ok(*field), + [] => { + let selector = field_name.or(field_node_id).unwrap_or(""); + Err(format!( + "No repository-defined GitHub issue field matched '{}'", + crate::sanitize::neutralize_pipeline_commands(selector) + )) + } + _ => Err(format!( + "Multiple repository-defined GitHub issue fields matched '{}'; use field_node_id", + crate::sanitize::neutralize_pipeline_commands(field_name.unwrap_or("")) + )), + } +} + +fn coerce_field_value(field: &IssueField, value: &str) -> Result { + let mut input = serde_json::Map::new(); + input.insert("fieldId".to_string(), Value::String(field.id.clone())); + match field.kind.as_str() { + "IssueFieldText" => { + input.insert("textValue".to_string(), Value::String(value.to_string())); + } + "IssueFieldNumber" => { + let number = value.parse::().map_err(|_| { + format!( + "Value '{}' is not a valid number for GitHub issue field '{}'", + crate::sanitize::neutralize_pipeline_commands(value), + field.name + ) + })?; + if !number.is_finite() { + return Err(format!( + "Value '{}' is not a finite number for GitHub issue field '{}'", + crate::sanitize::neutralize_pipeline_commands(value), + field.name + )); + } + input.insert( + "numberValue".to_string(), + serde_json::Number::from_f64(number) + .map(Value::Number) + .expect("finite f64 converts to a JSON number"), + ); + } + "IssueFieldDate" => { + NaiveDate::parse_from_str(value, "%Y-%m-%d").map_err(|_| { + format!( + "Value '{}' is not a valid YYYY-MM-DD date for GitHub issue field '{}'", + crate::sanitize::neutralize_pipeline_commands(value), + field.name + ) + })?; + input.insert("dateValue".to_string(), Value::String(value.to_string())); + } + "IssueFieldSingleSelect" => { + let matching: Vec<&IssueFieldOption> = field + .options + .iter() + .filter(|option| option.name.eq_ignore_ascii_case(value)) + .collect(); + let option = match matching.as_slice() { + [option] => *option, + [] => { + return Err(format!( + "Value '{}' is not an option for GitHub issue field '{}'; allowed options: {}", + crate::sanitize::neutralize_pipeline_commands(value), + field.name, + field + .options + .iter() + .map(|option| option.name.as_str()) + .collect::>() + .join(", ") + )); + } + _ => { + return Err(format!( + "Multiple options named '{}' exist for GitHub issue field '{}'", + crate::sanitize::neutralize_pipeline_commands(value), + field.name + )); + } + }; + input.insert( + "singleSelectOptionId".to_string(), + Value::String(option.id.clone()), + ); + } + unsupported => { + return Err(format!( + "GitHub issue field '{}' uses unsupported type '{}'; supported types are single-select, number, date, and text", + field.name, + crate::sanitize::neutralize_pipeline_commands(unsupported) + )); + } + } + Ok(Value::Object(input)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::safe_outputs::ToolResult; + use crate::secure::GithubTemporaryId; + use std::collections::HashMap; + use wiremock::matchers::{body_json, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + fn field(kind: &str) -> IssueField { + IssueField { + id: "IF_1".to_string(), + name: "Priority".to_string(), + kind: kind.to_string(), + options: vec![ + IssueFieldOption { + id: "OPT_HIGH".to_string(), + name: "High".to_string(), + }, + IssueFieldOption { + id: "OPT_LOW".to_string(), + name: "Low".to_string(), + }, + ], + } + } + + #[test] + fn result_contract_and_dry_run() { + assert_eq!(SetGithubIssueFieldResult::NAME, "set-github-issue-field"); + assert_eq!(SetGithubIssueFieldResult::DEFAULT_MAX, 5); + let result: SetGithubIssueFieldResult = SetGithubIssueFieldParams { + issue_number: GithubIssueNumber::Number(7), + field_name: Some("Priority".to_string()), + field_node_id: None, + value: "High".to_string(), + repository: None, + } + .try_into() + .unwrap(); + assert_eq!( + result.dry_run_summary(), + "set GitHub issue field 'Priority' on #7 to 'High'" + ); + } + + #[test] + fn validates_temporary_id_and_exactly_one_selector() { + let valid = SetGithubIssueFieldParams { + issue_number: GithubIssueNumber::Temporary( + GithubTemporaryId::parse("#aw_field").unwrap(), + ), + field_name: None, + field_node_id: Some("IF_1".to_string()), + value: "42".to_string(), + repository: Some("octo/repo".to_string()), + }; + assert!(valid.validate().is_ok()); + + for (name, id) in [(None, None), (Some("Priority"), Some("IF_1"))] { + let invalid = SetGithubIssueFieldParams { + issue_number: GithubIssueNumber::Number(1), + field_name: name.map(str::to_string), + field_node_id: id.map(str::to_string), + value: "High".to_string(), + repository: None, + }; + assert!( + invalid + .validate() + .unwrap_err() + .to_string() + .contains("exactly one") + ); + } + } + + #[test] + fn rejects_built_in_field_and_pipeline_injection() { + let built_in = SetGithubIssueFieldParams { + issue_number: GithubIssueNumber::Number(1), + field_name: Some("Issue_Type".to_string()), + field_node_id: None, + value: "Bug".to_string(), + repository: None, + }; + assert!( + built_in + .validate() + .unwrap_err() + .to_string() + .contains("built-in") + ); + + let injected = SetGithubIssueFieldParams { + issue_number: GithubIssueNumber::Number(1), + field_name: Some("Priority".to_string()), + field_node_id: None, + value: "##vso[task.complete]".to_string(), + repository: None, + }; + assert!(injected.validate().is_err()); + } + + #[test] + fn config_is_strict_and_requires_allowed_fields() { + assert!( + serde_yaml::from_str::( + "allowed-fields: [Priority]\nunexpected: true" + ) + .is_err() + ); + assert!( + validate_set_github_issue_field_config(&SetGithubIssueFieldConfig::default()) + .unwrap_err() + .to_string() + .contains("allowed-fields") + ); + let config: SetGithubIssueFieldConfig = + serde_yaml::from_str("allowed-fields: [Priority]\nmax: 3").unwrap(); + assert_eq!(config.allowed_fields, vec!["Priority"]); + assert_eq!(config.max, Some(3)); + let wildcard: SetGithubIssueFieldConfig = + serde_yaml::from_str("allowed-fields: ['*']").unwrap(); + assert!(validate_set_github_issue_field_config(&wildcard).is_ok()); + assert!(github_issue_field_is_allowed( + &wildcard.allowed_fields, + "Custom Priority" + )); + assert!(!github_issue_field_is_allowed( + &config.allowed_fields, + "Custom Priority" + )); + } + + #[test] + fn parses_and_selects_discovered_fields() { + let data = serde_json::json!({ + "repository": { + "issueFields": { + "nodes": [{ + "__typename": "IssueFieldSingleSelect", + "id": "IF_1", + "name": "Priority", + "options": [{"id": "OPT_HIGH", "name": "High"}] + }] + } + } + }); + let fields = parse_issue_fields(&data).unwrap(); + assert_eq!(fields.len(), 1); + assert_eq!( + select_issue_field(&fields, Some("priority"), None) + .unwrap() + .id, + "IF_1" + ); + assert_eq!( + select_issue_field(&fields, None, Some("IF_1")) + .unwrap() + .name, + "Priority" + ); + } + + #[test] + fn coerces_all_supported_field_types() { + assert_eq!( + coerce_field_value(&field("IssueFieldText"), "hello").unwrap(), + serde_json::json!({"fieldId": "IF_1", "textValue": "hello"}) + ); + assert_eq!( + coerce_field_value(&field("IssueFieldNumber"), "42.5").unwrap(), + serde_json::json!({"fieldId": "IF_1", "numberValue": 42.5}) + ); + assert_eq!( + coerce_field_value(&field("IssueFieldDate"), "2030-01-02").unwrap(), + serde_json::json!({"fieldId": "IF_1", "dateValue": "2030-01-02"}) + ); + assert_eq!( + coerce_field_value(&field("IssueFieldSingleSelect"), "high").unwrap(), + serde_json::json!({ + "fieldId": "IF_1", + "singleSelectOptionId": "OPT_HIGH" + }) + ); + } + + #[test] + fn coercion_reports_invalid_and_unsupported_values() { + assert!( + coerce_field_value(&field("IssueFieldNumber"), "many") + .unwrap_err() + .contains("valid number") + ); + assert!( + coerce_field_value(&field("IssueFieldDate"), "2030-02-30") + .unwrap_err() + .contains("YYYY-MM-DD") + ); + assert!( + coerce_field_value(&field("IssueFieldSingleSelect"), "Urgent") + .unwrap_err() + .contains("allowed options") + ); + assert!( + coerce_field_value(&field("IssueFieldMultiSelect"), "High") + .unwrap_err() + .contains("unsupported type") + ); + } + + #[tokio::test] + async fn executes_discovery_coercion_and_mutation() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/repos/octo/repo/issues/7")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "number": 7, + "node_id": "ISSUE_7", + "title": "Allowed issue", + "state": "open", + "labels": [{"name": "automation"}], + "html_url": "https://github.example/octo/repo/issues/7" + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/graphql")) + .and(body_json(serde_json::json!({ + "query": DISCOVER_ISSUE_FIELDS, + "variables": {"owner": "octo", "repo": "repo"} + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { + "repository": { + "issueFields": { + "nodes": [{ + "__typename": "IssueFieldSingleSelect", + "id": "IF_1", + "name": "Priority", + "options": [{"id": "OPT_HIGH", "name": "High"}] + }] + } + } + } + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/graphql")) + .and(body_json(serde_json::json!({ + "query": SET_ISSUE_FIELD_VALUE, + "variables": { + "issueId": "ISSUE_7", + "issueFields": [{ + "fieldId": "IF_1", + "singleSelectOptionId": "OPT_HIGH" + }] + } + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": {"setIssueFieldValue": {"issue": {"id": "ISSUE_7", "number": 7}}} + }))) + .expect(1) + .mount(&server) + .await; + + let mut tool_configs = HashMap::new(); + tool_configs.insert( + "set-github-issue-field".to_string(), + serde_json::json!({ + "target-repo": "octo/repo", + "required-labels": ["automation"], + "required-title-prefix": "Allowed", + "allowed-fields": ["*"] + }), + ); + let ctx = ExecutionContext { + github_token: Some("token".to_string()), + github_api_url: server.uri(), + tool_configs, + ..Default::default() + }; + let mut result: SetGithubIssueFieldResult = SetGithubIssueFieldParams { + issue_number: GithubIssueNumber::Number(7), + field_name: Some("priority".to_string()), + field_node_id: None, + value: "high".to_string(), + repository: None, + } + .try_into() + .unwrap(); + let execution = result.execute_sanitized(&ctx).await.unwrap(); + assert!( + execution.success, + "unexpected failure: {}", + execution.message + ); + assert_eq!( + execution + .data + .as_ref() + .and_then(|data| data["field_name"].as_str()), + Some("Priority") + ); + } + + #[tokio::test] + async fn unsupported_discovery_is_an_explicit_failure() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/repos/octo/repo/issues/7")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "number": 7, + "node_id": "ISSUE_7", + "title": "Issue", + "state": "open", + "labels": [] + }))) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/graphql")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": null, + "errors": [{"type": "undefinedField", "message": "Field 'issueFields' doesn't exist"}] + }))) + .mount(&server) + .await; + let mut tool_configs = HashMap::new(); + tool_configs.insert( + "set-github-issue-field".to_string(), + serde_json::json!({ + "target-repo": "octo/repo", + "allowed-fields": ["Priority"] + }), + ); + let ctx = ExecutionContext { + github_token: Some("token".to_string()), + github_api_url: server.uri(), + tool_configs, + ..Default::default() + }; + let mut result: SetGithubIssueFieldResult = SetGithubIssueFieldParams { + issue_number: GithubIssueNumber::Number(7), + field_name: Some("Priority".to_string()), + field_node_id: None, + value: "High".to_string(), + repository: None, + } + .try_into() + .unwrap(); + let execution = result.execute_sanitized(&ctx).await.unwrap(); + assert!(!execution.success); + assert!(execution.message.contains("unsupported or unavailable")); + } +} diff --git a/src/safe_outputs/set_github_issue_type.rs b/src/safe_outputs/set_github_issue_type.rs index 20922f59c..680eb6f93 100644 --- a/src/safe_outputs/set_github_issue_type.rs +++ b/src/safe_outputs/set_github_issue_type.rs @@ -1,92 +1,45 @@ //! `set-github-issue-type` safe output. -use anyhow::Context; use log::{debug, info}; -use percent_encoding::utf8_percent_encode; +use reqwest::Method; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use super::PATH_SEGMENT; -use super::create_github_issue::{resolve_target_repo, validate_target_repo}; -use crate::safe_outputs::{ExecutionContext, ExecutionResult, Executor, Validate}; +use crate::safe_outputs::{ + ExecutionContext, ExecutionResult, Executor, GithubClient, GithubIssueNumber, + GithubMutationFilters, GithubRepositoryPolicy, GithubTargetCapabilities, Validate, + resolve_github_issue_target, validate_github_mutation_filter_config, + validate_github_mutation_filters, validate_github_repository, + validate_github_target_capability, +}; use crate::sanitize::{SanitizeContent, sanitize_config}; -use crate::secure::GithubTemporaryId; use crate::tool_result; use crate::validate::reject_pipeline_injection; use ado_aw_derive::SanitizeConfig; -#[derive(Debug, Clone, Serialize, JsonSchema)] -#[serde(untagged)] -pub enum GithubIssueNumber { - Number(u64), - Temporary(GithubTemporaryId), -} - -impl<'de> Deserialize<'de> for GithubIssueNumber { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - struct IssueNumberVisitor; - - impl serde::de::Visitor<'_> for IssueNumberVisitor { - type Value = GithubIssueNumber; - - fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter.write_str("a positive issue number or #aw_ temporary issue ID") - } - - fn visit_u64(self, value: u64) -> Result { - Ok(GithubIssueNumber::Number(value)) - } - - fn visit_i64(self, value: i64) -> Result - where - E: serde::de::Error, - { - u64::try_from(value) - .map(GithubIssueNumber::Number) - .map_err(|_| E::custom("issue_number must be positive")) - } - - fn visit_str(self, value: &str) -> Result - where - E: serde::de::Error, - { - if value.chars().all(|c| c.is_ascii_digit()) { - return value - .parse::() - .map(GithubIssueNumber::Number) - .map_err(|_| E::custom("quoted issue_number is outside the u64 range")); - } - GithubTemporaryId::parse(value) - .map(GithubIssueNumber::Temporary) - .map_err(E::custom) - } - } - - deserializer.deserialize_any(IssueNumberVisitor) - } -} - #[derive(Deserialize, JsonSchema)] pub struct SetGithubIssueTypeParams { /// Positive GitHub issue number or a temporary ID from create-github-issue. pub issue_number: GithubIssueNumber, /// Native issue type name. An empty string clears the type. pub issue_type: String, + /// Optional target repository. Must exactly match `target-repo` or an + /// `allowed-repos` entry. + #[serde(default)] + pub repository: Option, } impl Validate for SetGithubIssueTypeParams { fn validate(&self) -> anyhow::Result<()> { - if let GithubIssueNumber::Number(number) = self.issue_number { - anyhow::ensure!(number > 0, "issue_number must be positive"); - } + self.issue_number.validate("issue_number")?; anyhow::ensure!( self.issue_type.len() <= 128, "issue_type must be 128 characters or fewer" ); reject_pipeline_injection(&self.issue_type, "set-github-issue-type.issue_type")?; + if let Some(repository) = self.repository.as_deref() { + validate_github_repository(repository)?; + } Ok(()) } } @@ -100,12 +53,18 @@ tool_result! { pub struct SetGithubIssueTypeResult { issue_number: GithubIssueNumber, issue_type: String, + #[serde(default)] + repository: Option, } } impl SanitizeContent for SetGithubIssueTypeResult { fn sanitize_content_fields(&mut self) { self.issue_type = sanitize_config(&self.issue_type); + self.repository = self + .repository + .as_deref() + .map(crate::sanitize::sanitize_config); } } @@ -114,6 +73,12 @@ impl SanitizeContent for SetGithubIssueTypeResult { pub struct SetGithubIssueTypeConfig { #[serde(default, rename = "target-repo")] pub target_repo: Option, + #[serde(default, rename = "allowed-repos")] + pub allowed_repos: Vec, + #[serde(default, rename = "required-labels")] + pub required_labels: Vec, + #[serde(default, rename = "required-title-prefix")] + pub required_title_prefix: Option, #[serde(default)] pub allowed: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -151,7 +116,7 @@ impl Executor for SetGithubIssueTypeResult { )); } }; - let config: SetGithubIssueTypeConfig = ctx.get_tool_config("set-github-issue-type"); + let config: SetGithubIssueTypeConfig = ctx.get_tool_config("set-github-issue-type")?; let resolved_type = if self.issue_type.is_empty() { String::new() @@ -171,79 +136,53 @@ impl Executor for SetGithubIssueTypeResult { ))); }; - let (target_repo, issue_number) = match &self.issue_number { - GithubIssueNumber::Number(number) => { - let target = match resolve_target_repo(config.target_repo.as_deref(), ctx) { - Ok(target) => target, - Err(result) => return Ok(result), - }; - (target, *number) - } - GithubIssueNumber::Temporary(temporary_id) => { - let Some(issue) = ctx.resolve_github_issue(temporary_id)? else { - return Ok(ExecutionResult::failure(format!( - "temporary issue ID '{}' has not been resolved; create-github-issue must \ - succeed earlier in the same SafeOutputs job", - temporary_id.canonical() - ))); - }; - if let Some(configured) = config.target_repo.as_deref() { - if let Err(error) = validate_target_repo(configured) { - return Ok(ExecutionResult::failure(error.to_string())); - } - if !configured.eq_ignore_ascii_case(&issue.repository) { - return Ok(ExecutionResult::failure(format!( - "temporary issue ID '{}' resolved to repository '{}', which does \ - not match set-github-issue-type.target-repo '{}'", - temporary_id.canonical(), - issue.repository, - configured - ))); - } - } - (issue.repository, issue.number) - } + let target = match resolve_github_issue_target( + &self.issue_number, + self.repository.as_deref(), + GithubRepositoryPolicy::new(config.target_repo.as_deref(), &config.allowed_repos), + ctx, + )? { + Ok(target) => target, + Err(result) => return Ok(result), }; - let (owner, repo) = target_repo - .split_once('/') - .context("target-repo must be 'owner/repo'")?; - let url = format!( - "{}/repos/{}/{}/issues/{}", - ctx.github_api_url.trim_end_matches('/'), - utf8_percent_encode(owner, PATH_SEGMENT), - utf8_percent_encode(repo, PATH_SEGMENT), - issue_number - ); + let client = GithubClient::new(&ctx.github_api_url, token)?; + let filters = GithubMutationFilters { + required_labels: &config.required_labels, + required_title_prefix: config.required_title_prefix.as_deref(), + }; + validate_github_mutation_filter_config(filters)?; + let metadata = match client.get_issue(&target.repository, target.number).await? { + Ok(metadata) => metadata, + Err(error) => return Ok(ExecutionResult::failure(error.to_string())), + }; + if let Err(result) = + validate_github_target_capability(&metadata, GithubTargetCapabilities::ISSUES_ONLY) + { + return Ok(result); + } + if let Err(result) = validate_github_mutation_filters(&metadata, filters) { + return Ok(result); + } + + let url = client.issue_url(&target.repository, target.number)?; debug!("PATCHing GitHub issue type at {url}"); // gh-aw's set-github-issue-type contract uses an empty string to clear the // native type; preserve that wire behavior for front-matter parity. - let response = reqwest::Client::new() - .patch(&url) - .header("Accept", "application/vnd.github+json") - .header("X-GitHub-Api-Version", "2022-11-28") - .header( - "User-Agent", - format!("ado-aw/{}", env!("CARGO_PKG_VERSION")), + let response = client + .send( + Method::PATCH, + url, + Some(&serde_json::json!({ "type": resolved_type })), ) - .bearer_auth(token) - .json(&serde_json::json!({ "type": resolved_type })) - .send() - .await - .context("Failed to send request to GitHub API")?; - - let status = response.status(); - if !status.is_success() { - let body = response - .text() - .await - .unwrap_or_else(|_| "".to_string()); - return Ok(ExecutionResult::failure(format!( - "Failed to set GitHub issue type (HTTP {}): {}", - status, - crate::sanitize::neutralize_pipeline_commands(&body) - ))); + .await?; + + if !response.is_success() { + let error = response + .require_success("Failed to set GitHub issue type") + .expect_err("non-success response must produce an API error"); + return Ok(ExecutionResult::failure(error.to_string())); } let action = if resolved_type.is_empty() { @@ -253,13 +192,16 @@ impl Executor for SetGithubIssueTypeResult { }; info!( "{} native type for GitHub issue {}#{}", - action, target_repo, issue_number + action, target.repository, target.number ); Ok(ExecutionResult::success_with_data( - format!("{} issue type for {}#{}", action, target_repo, issue_number), + format!( + "{} issue type for {}#{}", + action, target.repository, target.number + ), serde_json::json!({ - "number": issue_number, - "target_repo": target_repo, + "number": target.number, + "target_repo": target.repository, "issue_type": resolved_type, }), )) @@ -270,8 +212,32 @@ impl Executor for SetGithubIssueTypeResult { mod tests { use super::*; use crate::safe_outputs::{CreateGithubIssueParams, ToolResult}; + use crate::secure::GithubTemporaryId; use std::collections::HashMap; + async fn mount_issue_get(server: &wiremock::MockServer, number: u64, pull_request: bool) { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, ResponseTemplate}; + + let mut body = serde_json::json!({ + "number": number, + "node_id": format!("I_{number}"), + "title": "Issue title", + "state": "open", + "labels": [], + "html_url": format!("https://github.example/octo/repo/issues/{number}") + }); + if pull_request { + body["pull_request"] = serde_json::json!({}); + } + Mock::given(method("GET")) + .and(path(format!("/repos/octo/repo/issues/{number}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(body)) + .expect(1) + .mount(server) + .await; + } + #[test] fn result_name_and_default_budget_match_contract() { assert_eq!(SetGithubIssueTypeResult::NAME, "set-github-issue-type"); @@ -284,6 +250,7 @@ mod tests { SetGithubIssueTypeParams { issue_number: GithubIssueNumber::Number(1), issue_type: "Bug".to_string(), + repository: None, } .validate() .is_ok() @@ -294,6 +261,7 @@ mod tests { GithubTemporaryId::parse("#aw_bug1").unwrap() ), issue_type: String::new(), + repository: None, } .validate() .is_ok() @@ -305,6 +273,18 @@ mod tests { let result = SetGithubIssueTypeParams { issue_number: GithubIssueNumber::Number(0), issue_type: "Bug".to_string(), + repository: None, + } + .validate(); + assert!(result.is_err()); + } + + #[test] + fn rejects_malformed_repository() { + let result = SetGithubIssueTypeParams { + issue_number: GithubIssueNumber::Number(1), + issue_type: "Bug".to_string(), + repository: Some("octo/$(TOKEN)".to_string()), } .validate(); assert!(result.is_err()); @@ -317,10 +297,7 @@ mod tests { "issue_type": "Bug" })) .unwrap(); - assert!(matches!( - params.issue_number, - GithubIssueNumber::Number(42) - )); + assert!(matches!(params.issue_number, GithubIssueNumber::Number(42))); } #[tokio::test] @@ -338,6 +315,7 @@ mod tests { .expect(1) .mount(&server) .await; + mount_issue_get(&server, 42, false).await; Mock::given(method("PATCH")) .and(path("/repos/octo/repo/issues/42")) .and(body_json(serde_json::json!({ "type": "Bug" }))) @@ -373,6 +351,7 @@ mod tests { body: "A detailed issue body that is long enough for validation.".to_string(), labels: vec![], assignees: vec![], + repository: None, temporary_id: Some(GithubTemporaryId::parse("#aw_bug1").unwrap()), } .try_into() @@ -385,6 +364,7 @@ mod tests { body: "Another detailed issue body that is long enough for validation.".to_string(), labels: vec![], assignees: vec![], + repository: None, temporary_id: Some(GithubTemporaryId::parse("#aw_bug1").unwrap()), } .try_into() @@ -398,6 +378,7 @@ mod tests { GithubTemporaryId::parse("aw_bug1").unwrap(), ), issue_type: "bug".to_string(), + repository: None, } .try_into() .unwrap(); @@ -429,6 +410,7 @@ mod tests { GithubTemporaryId::parse("#aw_missing").unwrap(), ), issue_type: "Bug".to_string(), + repository: None, } .try_into() .unwrap(); @@ -446,6 +428,7 @@ mod tests { let mut result: SetGithubIssueTypeResult = SetGithubIssueTypeParams { issue_number: GithubIssueNumber::Number(1), issue_type: "Bug".to_string(), + repository: None, } .try_into() .unwrap(); @@ -460,6 +443,7 @@ mod tests { use wiremock::{Mock, MockServer, ResponseTemplate}; let server = MockServer::start().await; + mount_issue_get(&server, 7, false).await; Mock::given(method("PATCH")) .and(path("/repos/octo/repo/issues/7")) .and(body_json(serde_json::json!({ "type": "" }))) @@ -484,6 +468,7 @@ mod tests { let mut result: SetGithubIssueTypeResult = SetGithubIssueTypeParams { issue_number: GithubIssueNumber::Number(7), issue_type: String::new(), + repository: None, } .try_into() .unwrap(); @@ -503,6 +488,7 @@ mod tests { use wiremock::{Mock, MockServer, ResponseTemplate}; let server = MockServer::start().await; + mount_issue_get(&server, 7, false).await; Mock::given(method("PATCH")) .and(path("/repos/octo/repo/issues/7")) .and(body_json(serde_json::json!({ "type": "Epic" }))) @@ -524,6 +510,7 @@ mod tests { let mut result: SetGithubIssueTypeResult = SetGithubIssueTypeParams { issue_number: GithubIssueNumber::Number(7), issue_type: "Epic".to_string(), + repository: None, } .try_into() .unwrap(); @@ -559,6 +546,7 @@ mod tests { let mut result: SetGithubIssueTypeResult = SetGithubIssueTypeParams { issue_number: GithubIssueNumber::Number(7), issue_type: "Epic".to_string(), + repository: None, } .try_into() .unwrap(); @@ -582,6 +570,7 @@ mod tests { use wiremock::{Mock, MockServer, ResponseTemplate}; let server = MockServer::start().await; + mount_issue_get(&server, 7, false).await; Mock::given(method("PATCH")) .and(path("/repos/octo/repo/issues/7")) .and(body_json(serde_json::json!({ "type": "Bug" }))) @@ -606,6 +595,7 @@ mod tests { let mut result: SetGithubIssueTypeResult = SetGithubIssueTypeParams { issue_number: GithubIssueNumber::Number(7), issue_type: "bUg".to_string(), + repository: None, } .try_into() .unwrap(); @@ -616,4 +606,158 @@ mod tests { execution.message ); } + + #[tokio::test] + async fn required_filters_preflight_before_type_patch() { + use wiremock::matchers::{body_json, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/repos/octo/repo/issues/7")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "number": 7, + "node_id": "I_7", + "title": "[agent] Fix the build", + "state": "open", + "labels": [{"name": "bug"}], + "html_url": "https://github.example/octo/repo/issues/7" + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("PATCH")) + .and(path("/repos/octo/repo/issues/7")) + .and(body_json(serde_json::json!({ "type": "Bug" }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({}))) + .expect(1) + .mount(&server) + .await; + let mut tool_configs = HashMap::new(); + tool_configs.insert( + "set-github-issue-type".to_string(), + serde_json::json!({ + "target-repo": "octo/default", + "allowed-repos": ["octo/repo"], + "required-labels": ["BUG"], + "required-title-prefix": "[agent]", + "allowed": ["Bug"] + }), + ); + let ctx = ExecutionContext { + github_token: Some("token".to_string()), + github_api_url: server.uri(), + tool_configs, + ..Default::default() + }; + let mut result: SetGithubIssueTypeResult = SetGithubIssueTypeParams { + issue_number: GithubIssueNumber::Number(7), + issue_type: "bug".to_string(), + repository: Some("OCTO/REPO".to_string()), + } + .try_into() + .unwrap(); + let execution = result.execute_sanitized(&ctx).await.unwrap(); + assert!( + execution.success, + "filtered patch failed: {}", + execution.message + ); + } + + #[tokio::test] + async fn pull_request_target_is_rejected_before_type_patch() { + use wiremock::MockServer; + + let server = MockServer::start().await; + mount_issue_get(&server, 7, true).await; + let mut tool_configs = HashMap::new(); + tool_configs.insert( + "set-github-issue-type".to_string(), + serde_json::json!({"target-repo": "octo/repo"}), + ); + let ctx = ExecutionContext { + github_token: Some("token".to_string()), + github_api_url: server.uri(), + tool_configs, + ..Default::default() + }; + let mut result: SetGithubIssueTypeResult = SetGithubIssueTypeParams { + issue_number: GithubIssueNumber::Number(7), + issue_type: "Bug".to_string(), + repository: None, + } + .try_into() + .unwrap(); + let execution = result.execute_sanitized(&ctx).await.unwrap(); + assert!(!execution.success); + assert!(execution.message.contains("pull requests")); + let requests = server.received_requests().await.unwrap(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].method.as_str(), "GET"); + } + + #[tokio::test] + async fn failed_required_filter_performs_no_patch() { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/repos/octo/repo/issues/7")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "number": 7, + "title": "Unexpected title", + "state": "open", + "labels": [{"name": "bug"}] + }))) + .expect(1) + .mount(&server) + .await; + let mut tool_configs = HashMap::new(); + tool_configs.insert( + "set-github-issue-type".to_string(), + serde_json::json!({ + "target-repo": "octo/repo", + "required-title-prefix": "[agent]" + }), + ); + let ctx = ExecutionContext { + github_token: Some("token".to_string()), + github_api_url: server.uri(), + tool_configs, + ..Default::default() + }; + let mut result: SetGithubIssueTypeResult = SetGithubIssueTypeParams { + issue_number: GithubIssueNumber::Number(7), + issue_type: "Bug".to_string(), + repository: None, + } + .try_into() + .unwrap(); + let execution = result.execute_sanitized(&ctx).await.unwrap(); + assert!(!execution.success); + assert!(execution.message.contains("required-title-prefix")); + let requests = server.received_requests().await.unwrap(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].method.as_str(), "GET"); + } + + #[test] + fn config_round_trips_shared_repository_and_filter_fields() { + let config: SetGithubIssueTypeConfig = serde_yaml::from_str( + r#" +target-repo: octo/default +allowed-repos: [octo/other] +required-labels: [bug, triage] +required-title-prefix: "[agent]" +allowed: [Bug] +"#, + ) + .unwrap(); + assert_eq!(config.target_repo.as_deref(), Some("octo/default")); + assert_eq!(config.allowed_repos, vec!["octo/other".to_string()]); + assert_eq!(config.required_labels, vec!["bug", "triage"]); + assert_eq!(config.required_title_prefix.as_deref(), Some("[agent]")); + } } diff --git a/src/safe_outputs/submit_pr_review.rs b/src/safe_outputs/submit_pr_review.rs index 8fcde01d4..86b6a2a56 100644 --- a/src/safe_outputs/submit_pr_review.rs +++ b/src/safe_outputs/submit_pr_review.rs @@ -364,7 +364,7 @@ impl Executor for SubmitPrReviewResult { .context("No access token available (SYSTEM_ACCESSTOKEN or AZURE_DEVOPS_EXT_PAT)")?; debug!("ADO org: {}, project: {}", org_url, project); - let config: SubmitPrReviewConfig = ctx.get_tool_config("submit-pr-review"); + let config: SubmitPrReviewConfig = ctx.get_tool_config("submit-pr-review")?; debug!("Config: {:?}", config); // Validate event against allowed-events — REQUIRED. @@ -438,13 +438,17 @@ impl Executor for SubmitPrReviewResult { pull_request_id: self.pull_request_id, token, }; - if let Some(failure) = check_self_approval(&vote_ctx, &user_id, &self.event, vote_value).await? { + if let Some(failure) = + check_self_approval(&vote_ctx, &user_id, &self.event, vote_value).await? + { return Ok(failure); } // PUT vote to reviewers endpoint let encoded_user_id = utf8_percent_encode(&user_id, PATH_SEGMENT).to_string(); - if let Some(failure) = submit_vote(&vote_ctx, &encoded_user_id, &self.event, vote_value).await? { + if let Some(failure) = + submit_vote(&vote_ctx, &encoded_user_id, &self.event, vote_value).await? + { return Ok(failure); } @@ -538,7 +542,8 @@ mod tests { }; let err = >::try_from(params).unwrap_err(); assert!( - err.to_string().contains("pull_request_id must be a positive integer"), + err.to_string() + .contains("pull_request_id must be a positive integer"), "unexpected error: {err}" ); } @@ -568,7 +573,8 @@ mod tests { }; let err = >::try_from(params).unwrap_err(); assert!( - err.to_string().contains("body is required when event is 'request-changes'"), + err.to_string() + .contains("body is required when event is 'request-changes'"), "unexpected error: {err}" ); } diff --git a/src/safe_outputs/unassign_github_issue_from_user.rs b/src/safe_outputs/unassign_github_issue_from_user.rs new file mode 100644 index 000000000..512aa710b --- /dev/null +++ b/src/safe_outputs/unassign_github_issue_from_user.rs @@ -0,0 +1,486 @@ +//! `unassign-github-issue-from-user` safe output. + +use anyhow::ensure; +use log::{debug, info}; +use reqwest::Method; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use url::Url; + +use crate::safe_outputs::{ + ExecutionContext, ExecutionResult, Executor, GithubClient, GithubIssueNumber, + GithubMutationFilters, GithubRepositoryPolicy, GithubTargetCapabilities, Validate, + resolve_github_issue_target, validate_blocked_first_globs, + validate_github_mutation_filter_config, validate_github_mutation_filters, + validate_github_repository, validate_github_target_capability, +}; +use crate::sanitize::{SanitizeContent, sanitize_config}; +use crate::tool_result; +use crate::validate::reject_pipeline_injection; +use ado_aw_derive::SanitizeConfig; + +const MAX_ASSIGNEE_LEN: usize = 100; +const MAX_ASSIGNEES: usize = 100; + +#[derive(Deserialize, JsonSchema)] +pub struct UnassignGithubIssueFromUserParams { + /// Positive GitHub issue number or a temporary ID from create-github-issue. + pub issue_number: GithubIssueNumber, + /// One GitHub username to remove. + #[serde(default)] + pub assignee: Option, + /// Multiple GitHub usernames to remove. + #[serde(default)] + pub assignees: Vec, + /// Optional target repository. Must exactly match `target-repo` or an + /// `allowed-repos` entry. + #[serde(default)] + pub repository: Option, +} + +impl Validate for UnassignGithubIssueFromUserParams { + fn validate(&self) -> anyhow::Result<()> { + self.issue_number.validate("issue_number")?; + normalized_assignees(self.assignee.as_deref(), &self.assignees)?; + if let Some(repository) = self.repository.as_deref() { + validate_github_repository(repository)?; + } + Ok(()) + } +} + +tool_result! { + name = "unassign-github-issue-from-user", + write = true, + params = UnassignGithubIssueFromUserParams, + default_max = 1, + /// Result of removing one or more users from a GitHub issue. + pub struct UnassignGithubIssueFromUserResult { + issue_number: GithubIssueNumber, + #[serde(default)] + assignee: Option, + #[serde(default)] + assignees: Vec, + #[serde(default)] + repository: Option, + } +} + +impl SanitizeContent for UnassignGithubIssueFromUserResult { + fn sanitize_content_fields(&mut self) { + self.assignee = self.assignee.as_deref().map(sanitize_config); + self.assignees = self + .assignees + .iter() + .map(|assignee| sanitize_config(assignee)) + .collect(); + self.repository = self.repository.as_deref().map(sanitize_config); + } +} + +#[derive(Debug, Clone, Default, SanitizeConfig, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct UnassignGithubIssueFromUserConfig { + #[serde(default, rename = "target-repo")] + pub target_repo: Option, + #[serde(default, rename = "allowed-repos")] + pub allowed_repos: Vec, + #[serde(default, rename = "required-labels")] + pub required_labels: Vec, + #[serde(default, rename = "required-title-prefix")] + pub required_title_prefix: Option, + /// Case-insensitive `*` glob allowlist. Empty permits any non-blocked user. + #[serde(default)] + pub allowed: Vec, + /// Case-insensitive `*` glob blocklist, evaluated before `allowed`. + #[serde(default)] + pub blocked: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[sanitize_config(skip)] + pub max: Option, +} + +fn validate_assignee(assignee: &str) -> anyhow::Result<()> { + ensure!(!assignee.trim().is_empty(), "assignee must not be empty"); + ensure!( + assignee.len() <= MAX_ASSIGNEE_LEN, + "assignee must be {MAX_ASSIGNEE_LEN} characters or fewer" + ); + reject_pipeline_injection(assignee, "unassign-github-issue-from-user.assignee") +} + +fn normalized_assignees( + assignee: Option<&str>, + assignees: &[String], +) -> anyhow::Result> { + ensure!( + assignee.is_none() || assignees.is_empty(), + "provide assignee or assignees, not both" + ); + ensure!( + assignee.is_some() || !assignees.is_empty(), + "assignee or assignees must be provided" + ); + ensure!( + assignees.len() <= MAX_ASSIGNEES, + "assignees must contain at most {MAX_ASSIGNEES} entries" + ); + let candidates: Vec<&str> = match assignee { + Some(value) => vec![value], + None => assignees.iter().map(String::as_str).collect(), + }; + let mut normalized = Vec::new(); + for candidate in candidates { + validate_assignee(candidate)?; + if !normalized + .iter() + .any(|existing: &String| existing.eq_ignore_ascii_case(candidate)) + { + normalized.push(candidate.to_string()); + } + } + Ok(normalized) +} + +pub(crate) fn validate_unassign_github_issue_from_user_config( + config: &UnassignGithubIssueFromUserConfig, +) -> anyhow::Result<()> { + validate_github_mutation_filter_config(GithubMutationFilters { + required_labels: &config.required_labels, + required_title_prefix: config.required_title_prefix.as_deref(), + })?; + for (field, patterns) in [ + ("allowed", config.allowed.as_slice()), + ("blocked", config.blocked.as_slice()), + ] { + for pattern in patterns { + ensure!(!pattern.is_empty(), "{field} entries must not be empty"); + reject_pipeline_injection(pattern, field)?; + } + } + Ok(()) +} + +fn issue_assignees_url( + client: &GithubClient, + repository: &str, + number: u64, +) -> anyhow::Result { + let mut url = client.issue_url(repository, number)?; + url.path_segments_mut() + .map_err(|_| anyhow::anyhow!("GitHub issue URL cannot be a base URL"))? + .push("assignees"); + Ok(url) +} + +#[async_trait::async_trait] +impl Executor for UnassignGithubIssueFromUserResult { + fn dry_run_summary(&self) -> String { + let assignees = + normalized_assignees(self.assignee.as_deref(), &self.assignees).unwrap_or_default(); + format!( + "remove {} from GitHub issue {}", + assignees.join(", "), + self.issue_number + ) + } + + async fn execute_impl(&self, ctx: &ExecutionContext) -> anyhow::Result { + const TOOL: &str = "unassign-github-issue-from-user"; + if !ctx.tool_configs.contains_key(TOOL) { + return Ok(ExecutionResult::failure(format!( + "{TOOL} is not configured for this workflow" + ))); + } + let token = match ctx.github_token.as_ref() { + Some(token) => token, + None => { + return Ok(ExecutionResult::failure( + "ADO_AW_GITHUB_TOKEN is not set; configure safe-outputs.github-token \ + or safe-outputs.github-app", + )); + } + }; + let config: UnassignGithubIssueFromUserConfig = ctx.get_tool_config(TOOL)?; + validate_unassign_github_issue_from_user_config(&config)?; + let assignees = normalized_assignees(self.assignee.as_deref(), &self.assignees)?; + if let Err(result) = + validate_blocked_first_globs(&assignees, &config.allowed, &config.blocked, "assignee") + { + return Ok(result); + } + + let target = match resolve_github_issue_target( + &self.issue_number, + self.repository.as_deref(), + GithubRepositoryPolicy::new(config.target_repo.as_deref(), &config.allowed_repos), + ctx, + )? { + Ok(target) => target, + Err(result) => return Ok(result), + }; + let client = GithubClient::new(&ctx.github_api_url, token)?; + let filters = GithubMutationFilters { + required_labels: &config.required_labels, + required_title_prefix: config.required_title_prefix.as_deref(), + }; + + // Resolve the live target and all policy filters before the write. + let metadata = match client.get_issue(&target.repository, target.number).await? { + Ok(metadata) => metadata, + Err(error) => return Ok(ExecutionResult::failure(error.to_string())), + }; + if let Err(result) = + validate_github_target_capability(&metadata, GithubTargetCapabilities::ISSUES_ONLY) + { + return Ok(result); + } + if let Err(result) = validate_github_mutation_filters(&metadata, filters) { + return Ok(result); + } + + debug!( + "Removing users [{}] from {}#{}", + assignees.join(", "), + target.repository, + target.number + ); + let response = client + .send( + Method::DELETE, + issue_assignees_url(&client, &target.repository, target.number)?, + Some(&serde_json::json!({ "assignees": assignees })), + ) + .await?; + if let Err(error) = response.require_success("Failed to remove GitHub issue assignees") { + return Ok(ExecutionResult::failure(error.to_string())); + } + + // GitHub ignores requested users who were already absent, making the + // operation safely idempotent. + info!( + "Removed users [{}] from {}#{}", + assignees.join(", "), + target.repository, + target.number + ); + Ok(ExecutionResult::success_with_data( + format!( + "Removed {} from {}#{}", + assignees.join(", "), + target.repository, + target.number + ), + serde_json::json!({ + "number": target.number, + "target_repo": target.repository, + "assignees": assignees, + }), + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::safe_outputs::ToolResult; + use crate::secure::GithubTemporaryId; + use std::collections::HashMap; + use wiremock::matchers::{body_json, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + #[test] + fn contract_validates_numeric_temporary_singular_and_plural() { + assert_eq!( + UnassignGithubIssueFromUserResult::NAME, + "unassign-github-issue-from-user" + ); + assert_eq!(UnassignGithubIssueFromUserResult::DEFAULT_MAX, 1); + for issue_number in [ + GithubIssueNumber::Number(7), + GithubIssueNumber::Temporary(GithubTemporaryId::parse("#aw_created").unwrap()), + ] { + assert!( + UnassignGithubIssueFromUserParams { + issue_number, + assignee: Some("octocat".to_string()), + assignees: vec![], + repository: Some("octo/repo".to_string()), + } + .validate() + .is_ok() + ); + } + assert_eq!( + normalized_assignees(None, &["Octocat".to_string(), "octocat".to_string()]).unwrap(), + vec!["Octocat".to_string()] + ); + assert!( + UnassignGithubIssueFromUserParams { + issue_number: GithubIssueNumber::Number(7), + assignee: None, + assignees: vec![], + repository: None, + } + .validate() + .is_err() + ); + } + + #[test] + fn strict_config_round_trips_policy() { + let config: UnassignGithubIssueFromUserConfig = serde_yaml::from_str( + r#" +target-repo: octo/repo +allowed-repos: [octo/other] +required-labels: [managed] +required-title-prefix: "[agent]" +allowed: ["team-*"] +blocked: ["team-admin"] +max: 1 +"#, + ) + .unwrap(); + assert_eq!(config.allowed, vec!["team-*".to_string()]); + assert_eq!(config.blocked, vec!["team-admin".to_string()]); + assert!( + serde_yaml::from_str::( + "target-repo: octo/repo\nunexpected: true\n" + ) + .is_err() + ); + } + + fn context(server: &MockServer, config: serde_json::Value) -> ExecutionContext { + let mut tool_configs = HashMap::new(); + tool_configs.insert("unassign-github-issue-from-user".to_string(), config); + ExecutionContext { + github_token: Some("token".to_string()), + github_api_url: server.uri(), + tool_configs, + ..Default::default() + } + } + + fn issue() -> serde_json::Value { + serde_json::json!({ + "number": 7, + "node_id": "I_7", + "title": "[agent] tracked", + "state": "open", + "labels": [{"name": "managed"}], + "assignees": [] + }) + } + + #[tokio::test] + async fn already_absent_assignee_is_idempotent_success() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/repos/octo/repo/issues/7")) + .respond_with(ResponseTemplate::new(200).set_body_json(issue())) + .expect(1) + .mount(&server) + .await; + Mock::given(method("DELETE")) + .and(path("/repos/octo/repo/issues/7/assignees")) + .and(body_json(serde_json::json!({"assignees": ["octocat"]}))) + .respond_with(ResponseTemplate::new(200).set_body_json(issue())) + .expect(1) + .mount(&server) + .await; + let ctx = context( + &server, + serde_json::json!({ + "target-repo": "octo/repo", + "allowed": ["octo*"] + }), + ); + let mut result: UnassignGithubIssueFromUserResult = UnassignGithubIssueFromUserParams { + issue_number: GithubIssueNumber::Number(7), + assignee: Some("octocat".to_string()), + assignees: vec![], + repository: None, + } + .try_into() + .unwrap(); + let execution = result.execute_sanitized(&ctx).await.unwrap(); + assert!(execution.success, "{}", execution.message); + } + + #[tokio::test] + async fn blocked_policy_wins_before_any_http_request() { + let server = MockServer::start().await; + let ctx = context( + &server, + serde_json::json!({ + "target-repo": "octo/repo", + "allowed": ["team-*"], + "blocked": ["TEAM-ADMIN"] + }), + ); + let mut result: UnassignGithubIssueFromUserResult = UnassignGithubIssueFromUserParams { + issue_number: GithubIssueNumber::Number(7), + assignee: Some("team-admin".to_string()), + assignees: vec![], + repository: None, + } + .try_into() + .unwrap(); + let execution = result.execute_sanitized(&ctx).await.unwrap(); + assert!(!execution.success); + assert!(execution.message.contains("blocked")); + assert!(server.received_requests().await.unwrap().is_empty()); + } + + #[tokio::test] + async fn filter_failure_prevents_delete() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/repos/octo/repo/issues/7")) + .respond_with(ResponseTemplate::new(200).set_body_json(issue())) + .expect(1) + .mount(&server) + .await; + let ctx = context( + &server, + serde_json::json!({ + "target-repo": "octo/repo", + "required-labels": ["missing"] + }), + ); + let mut result: UnassignGithubIssueFromUserResult = UnassignGithubIssueFromUserParams { + issue_number: GithubIssueNumber::Number(7), + assignee: None, + assignees: vec!["octocat".to_string(), "hubot".to_string()], + repository: None, + } + .try_into() + .unwrap(); + let execution = result.execute_sanitized(&ctx).await.unwrap(); + assert!(!execution.success); + let requests = server.received_requests().await.unwrap(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].method.as_str(), "GET"); + } + + #[tokio::test] + async fn dry_run_skips_configuration_and_network() { + let ctx = ExecutionContext { + dry_run: true, + ..Default::default() + }; + let mut result: UnassignGithubIssueFromUserResult = UnassignGithubIssueFromUserParams { + issue_number: GithubIssueNumber::Number(7), + assignee: Some("octocat".to_string()), + assignees: vec![], + repository: None, + } + .try_into() + .unwrap(); + let execution = result.execute_sanitized(&ctx).await.unwrap(); + assert!(execution.success); + assert!(execution.message.contains("[DRY-RUN]")); + assert!(execution.message.contains("octocat")); + } +} diff --git a/src/safe_outputs/update_github_issue.rs b/src/safe_outputs/update_github_issue.rs new file mode 100644 index 000000000..0bdb54d83 --- /dev/null +++ b/src/safe_outputs/update_github_issue.rs @@ -0,0 +1,1137 @@ +//! `update-github-issue` safe output. + +use anyhow::ensure; +use log::{debug, info}; +use reqwest::Method; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use url::Url; + +use crate::safe_outputs::{ + ExecutionContext, ExecutionResult, Executor, GithubClient, GithubIssueNumber, + GithubMutationFilters, GithubRepositoryPolicy, GithubTargetCapabilities, GithubTargetKind, + GithubTargetMetadata, Validate, build_github_trace_footer, resolve_github_issue_target, + validate_blocked_first_globs, validate_github_mutation_filter_config, + validate_github_mutation_filters, validate_github_repository, + validate_github_target_capability, +}; +use crate::sanitize::{SanitizeContent, sanitize as sanitize_text, sanitize_config}; +use crate::tool_result; +use crate::validate::reject_pipeline_injection; +use ado_aw_derive::SanitizeConfig; + +const MAX_TITLE_LEN: usize = 256; +const MAX_BODY_LEN: usize = 65_536; +const MAX_LABELS: usize = 100; +const MAX_ASSIGNEES: usize = 100; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum GithubIssueStatus { + Open, + Closed, +} + +impl GithubIssueStatus { + fn as_str(self) -> &'static str { + match self { + Self::Open => "open", + Self::Closed => "closed", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub enum GithubBodyOperation { + Append, + Prepend, + Replace, + ReplaceIsland, +} + +#[derive(Deserialize, JsonSchema)] +pub struct UpdateGithubIssueParams { + /// Positive GitHub issue number or a temporary ID from create-github-issue. + pub issue_number: GithubIssueNumber, + /// Set the issue or pull request state. + #[serde(default)] + pub status: Option, + /// Replace the title. + #[serde(default)] + pub title: Option, + /// Body content used by `operation`. + #[serde(default)] + pub body: Option, + /// Body update operation. Defaults to `append`. + #[serde(default)] + pub operation: Option, + /// Replace all labels with this list. + #[serde(default)] + pub labels: Option>, + /// Replace all assignees with this list. + #[serde(default)] + pub assignees: Option>, + /// Assign an existing milestone by number. + #[serde(default)] + pub milestone: Option, + /// Optional target repository. + #[serde(default)] + pub repository: Option, +} + +impl Validate for UpdateGithubIssueParams { + fn validate(&self) -> anyhow::Result<()> { + self.issue_number.validate("issue_number")?; + ensure!( + self.status.is_some() + || self.title.is_some() + || self.body.is_some() + || self.labels.is_some() + || self.assignees.is_some() + || self.milestone.is_some(), + "at least one of status, title, body, labels, assignees, or milestone is required" + ); + if let Some(title) = self.title.as_deref() { + ensure!(!title.trim().is_empty(), "title must not be empty"); + ensure!( + title.len() <= MAX_TITLE_LEN, + "title must be {MAX_TITLE_LEN} characters or fewer" + ); + } + if let Some(body) = self.body.as_deref() { + ensure!( + body.len() <= MAX_BODY_LEN, + "body must be {MAX_BODY_LEN} characters or fewer" + ); + } else { + ensure!( + self.operation.is_none(), + "operation may only be provided when body is provided" + ); + } + if let Some(labels) = &self.labels { + ensure!( + labels.len() <= MAX_LABELS, + "labels must contain at most {MAX_LABELS} entries" + ); + for label in labels { + ensure!(!label.is_empty(), "labels entries must not be empty"); + reject_pipeline_injection(label, "update-github-issue.labels")?; + } + } + if let Some(assignees) = &self.assignees { + ensure!( + assignees.len() <= MAX_ASSIGNEES, + "assignees must contain at most {MAX_ASSIGNEES} entries" + ); + for assignee in assignees { + ensure!(!assignee.is_empty(), "assignees entries must not be empty"); + reject_pipeline_injection(assignee, "update-github-issue.assignees")?; + } + } + if let Some(milestone) = self.milestone { + ensure!(milestone > 0, "milestone must be positive"); + } + if let Some(repository) = self.repository.as_deref() { + validate_github_repository(repository)?; + } + Ok(()) + } +} + +tool_result! { + name = "update-github-issue", + write = true, + params = UpdateGithubIssueParams, + default_max = 1, + /// Result of updating a GitHub issue or pull request. + pub struct UpdateGithubIssueResult { + issue_number: GithubIssueNumber, + #[serde(default)] + status: Option, + #[serde(default)] + title: Option, + #[serde(default)] + body: Option, + #[serde(default)] + operation: Option, + #[serde(default)] + labels: Option>, + #[serde(default)] + assignees: Option>, + #[serde(default)] + milestone: Option, + #[serde(default)] + repository: Option, + } +} + +impl SanitizeContent for UpdateGithubIssueResult { + fn sanitize_content_fields(&mut self) { + self.title = self.title.as_deref().map(sanitize_text); + self.body = self.body.as_deref().map(sanitize_text); + self.labels = self + .labels + .as_ref() + .map(|labels| labels.iter().map(|label| sanitize_config(label)).collect()); + self.assignees = self.assignees.as_ref().map(|assignees| { + assignees + .iter() + .map(|assignee| sanitize_config(assignee)) + .collect() + }); + self.repository = self.repository.as_deref().map(sanitize_config); + } +} + +fn default_true() -> bool { + true +} + +#[derive(Debug, Clone, SanitizeConfig, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct UpdateGithubIssueConfig { + #[serde(default, rename = "target-repo")] + pub target_repo: Option, + #[serde(default, rename = "allowed-repos")] + pub allowed_repos: Vec, + #[serde(default, rename = "required-labels")] + pub required_labels: Vec, + #[serde(default, rename = "required-title-prefix")] + pub required_title_prefix: Option, + /// Allow state changes. + #[serde(default)] + #[sanitize_config(skip)] + pub status: bool, + /// Allow title replacement. + #[serde(default)] + #[sanitize_config(skip)] + pub title: bool, + /// Allow body changes. + #[serde(default)] + #[sanitize_config(skip)] + pub body: bool, + /// Allow replacing labels. + #[serde(default)] + #[sanitize_config(skip)] + pub labels: bool, + /// Allow replacing assignees. + #[serde(default)] + #[sanitize_config(skip)] + pub assignees: bool, + /// Allow milestone assignment. + #[serde(default)] + #[sanitize_config(skip)] + pub milestone: bool, + /// Case-insensitive `*` glob allowlist for requested labels. + #[serde(default, rename = "allowed-labels")] + pub allowed_labels: Vec, + /// Include the standard ado-aw trace footer in body updates. + #[serde(default = "default_true")] + #[sanitize_config(skip)] + pub footer: bool, + /// Permit issue targets. + #[serde(default = "default_true")] + #[sanitize_config(skip)] + pub issues: bool, + /// Permit pull request targets through the shared issues endpoint. + #[serde(default = "default_true", rename = "pull-requests")] + #[sanitize_config(skip)] + pub pull_requests: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[sanitize_config(skip)] + pub max: Option, +} + +impl Default for UpdateGithubIssueConfig { + fn default() -> Self { + Self { + target_repo: None, + allowed_repos: Vec::new(), + required_labels: Vec::new(), + required_title_prefix: None, + status: false, + title: false, + body: false, + labels: false, + assignees: false, + milestone: false, + allowed_labels: Vec::new(), + footer: true, + issues: true, + pull_requests: true, + max: None, + } + } +} + +pub(crate) fn validate_update_github_issue_config( + config: &UpdateGithubIssueConfig, +) -> anyhow::Result<()> { + validate_github_mutation_filter_config(GithubMutationFilters { + required_labels: &config.required_labels, + required_title_prefix: config.required_title_prefix.as_deref(), + })?; + ensure!( + config.issues || config.pull_requests, + "at least one of issues or pull-requests must be true" + ); + for label in &config.allowed_labels { + ensure!( + !label.is_empty(), + "allowed-labels entries must not be empty" + ); + reject_pipeline_injection(label, "update-github-issue.allowed-labels")?; + } + Ok(()) +} + +#[derive(Debug, Deserialize)] +struct RawUpdateTarget { + number: u64, + node_id: Option, + title: String, + #[serde(default)] + body: Option, + state: String, + #[serde(default)] + labels: Vec, + #[serde(default)] + assignees: Vec, + milestone: Option, + pull_request: Option, + html_url: Option, +} + +#[derive(Debug, Deserialize)] +struct RawLabel { + name: String, +} + +#[derive(Debug, Deserialize)] +struct RawAssignee { + login: String, +} + +#[derive(Debug, Deserialize)] +struct RawMilestone { + number: u64, + title: String, +} + +impl RawUpdateTarget { + fn metadata(&self) -> GithubTargetMetadata { + GithubTargetMetadata { + number: self.number, + node_id: self.node_id.clone(), + title: self.title.clone(), + state: self.state.clone(), + labels: self.labels.iter().map(|label| label.name.clone()).collect(), + kind: if self.pull_request.is_some() { + GithubTargetKind::PullRequest + } else { + GithubTargetKind::Issue + }, + html_url: self.html_url.clone(), + } + } +} + +fn repository_route(client: &GithubClient, repository: &str, tail: &[&str]) -> anyhow::Result { + validate_github_repository(repository)?; + let (owner, name) = repository + .split_once('/') + .expect("validated GitHub repository contains slash"); + let mut url = client.rest_api_url().clone(); + { + let mut path = url + .path_segments_mut() + .map_err(|_| anyhow::anyhow!("GitHub API URL cannot be a base URL"))?; + path.pop_if_empty(); + path.push("repos"); + path.push(owner); + path.push(name); + for segment in tail { + path.push(segment); + } + } + Ok(url) +} + +fn body_with_footer(body: &str, include_footer: bool, ctx: &ExecutionContext) -> String { + if include_footer { + format!("{body}\n\n{}", build_github_trace_footer(ctx)) + } else { + body.to_string() + } +} + +fn status_island_markers(ctx: &ExecutionContext) -> Result<(String, String), ExecutionResult> { + let Some(definition_id) = ctx.definition_id else { + return Err(ExecutionResult::failure( + "SYSTEM_DEFINITIONID is required for replace-island", + )); + }; + Ok(( + format!(""), + format!(""), + )) +} + +fn replace_status_island( + current: &str, + replacement: &str, + ctx: &ExecutionContext, +) -> Result { + let (start_marker, end_marker) = status_island_markers(ctx)?; + let starts: Vec = current + .match_indices(&start_marker) + .map(|(index, _)| index) + .collect(); + let ends: Vec = current + .match_indices(&end_marker) + .map(|(index, _)| index) + .collect(); + if starts.len() != 1 || ends.len() != 1 { + return Err(ExecutionResult::failure(format!( + "replace-island requires exactly one matching status island for pipeline \ + definition {}; found {} start marker(s) and {} end marker(s)", + ctx.definition_id.unwrap_or_default(), + starts.len(), + ends.len() + ))); + } + let start = starts[0]; + let end = ends[0]; + if end <= start { + return Err(ExecutionResult::failure( + "replace-island status island markers are out of order", + )); + } + let end_after_marker = end + end_marker.len(); + Ok(format!( + "{}{}\n{}\n{}{}", + ¤t[..start], + start_marker, + replacement, + end_marker, + ¤t[end_after_marker..] + )) +} + +fn build_updated_body( + current: &str, + new_content: &str, + operation: GithubBodyOperation, + include_footer: bool, + ctx: &ExecutionContext, +) -> Result { + let section = body_with_footer(new_content, include_footer, ctx); + let updated = match operation { + GithubBodyOperation::Append => { + if current.is_empty() { + section + } else { + format!("{current}\n\n---\n\n{section}") + } + } + GithubBodyOperation::Prepend => { + if current.is_empty() { + section + } else { + format!("{section}\n\n---\n\n{current}") + } + } + GithubBodyOperation::Replace => section, + GithubBodyOperation::ReplaceIsland => replace_status_island(current, §ion, ctx)?, + }; + if updated.len() > MAX_BODY_LEN { + return Err(ExecutionResult::failure(format!( + "updated body exceeds GitHub's {MAX_BODY_LEN}-character limit" + ))); + } + Ok(updated) +} + +impl UpdateGithubIssueResult { + fn requested_fields(&self) -> Vec<&'static str> { + let mut fields = Vec::new(); + if self.status.is_some() { + fields.push("status"); + } + if self.title.is_some() { + fields.push("title"); + } + if self.body.is_some() { + fields.push("body"); + } + if self.labels.is_some() { + fields.push("labels"); + } + if self.assignees.is_some() { + fields.push("assignees"); + } + if self.milestone.is_some() { + fields.push("milestone"); + } + fields + } + + fn validate_opt_ins(&self, config: &UpdateGithubIssueConfig) -> Result<(), ExecutionResult> { + for (requested, enabled, field) in [ + (self.status.is_some(), config.status, "status"), + (self.title.is_some(), config.title, "title"), + (self.body.is_some(), config.body, "body"), + (self.labels.is_some(), config.labels, "labels"), + (self.assignees.is_some(), config.assignees, "assignees"), + (self.milestone.is_some(), config.milestone, "milestone"), + ] { + if requested && !enabled { + return Err(ExecutionResult::failure(format!( + "update-github-issue field '{field}' is not enabled by configuration" + ))); + } + } + if let Some(labels) = &self.labels + && let Err(result) = + validate_blocked_first_globs(labels, &config.allowed_labels, &[], "label") + { + return Err(result); + } + Ok(()) + } + + async fn fetch_target( + &self, + client: &GithubClient, + repository: &str, + number: u64, + ) -> anyhow::Result> { + let response = client + .send(Method::GET, client.issue_url(repository, number)?, None) + .await?; + let response = match response.require_success("Failed to fetch GitHub issue") { + Ok(response) => response, + Err(error) => return Ok(Err(ExecutionResult::failure(error.to_string()))), + }; + match response.json("Failed to parse GitHub issue") { + Ok(target) => Ok(Ok(target)), + Err(error) => Ok(Err(ExecutionResult::failure(error.to_string()))), + } + } + + async fn preflight_labels( + &self, + client: &GithubClient, + repository: &str, + ) -> anyhow::Result> { + let Some(labels) = &self.labels else { + return Ok(Ok(())); + }; + for label in labels { + let response = client + .send( + Method::GET, + repository_route(client, repository, &["labels", label])?, + None, + ) + .await?; + if !response.is_success() { + let error = response + .require_success("Failed to validate GitHub label") + .expect_err("non-success response must produce an API error"); + return Ok(Err(ExecutionResult::failure(format!( + "Label '{}' failed preflight: {}", + crate::sanitize::neutralize_pipeline_commands(label), + error + )))); + } + } + Ok(Ok(())) + } + + async fn preflight_assignees( + &self, + client: &GithubClient, + repository: &str, + ) -> anyhow::Result> { + let Some(assignees) = &self.assignees else { + return Ok(Ok(())); + }; + for assignee in assignees { + let response = client + .send( + Method::GET, + repository_route(client, repository, &["assignees", assignee])?, + None, + ) + .await?; + if !response.is_success() { + let error = response + .require_success("Failed to validate GitHub assignee") + .expect_err("non-success response must produce an API error"); + return Ok(Err(ExecutionResult::failure(format!( + "Assignee '{}' failed preflight: {}", + crate::sanitize::neutralize_pipeline_commands(assignee), + error + )))); + } + } + Ok(Ok(())) + } + + async fn preflight_milestone( + &self, + client: &GithubClient, + repository: &str, + ) -> anyhow::Result> { + let Some(milestone) = self.milestone else { + return Ok(Ok(())); + }; + let milestones = match client.list_milestones(repository).await? { + Ok(milestones) => milestones, + Err(error) => return Ok(Err(ExecutionResult::failure(error.to_string()))), + }; + if milestones + .iter() + .any(|candidate| candidate.number == milestone) + { + Ok(Ok(())) + } else { + Ok(Err(ExecutionResult::failure(format!( + "Milestone #{milestone} does not exist in repository {repository}" + )))) + } + } +} + +#[async_trait::async_trait] +impl Executor for UpdateGithubIssueResult { + fn dry_run_summary(&self) -> String { + let target = match &self.issue_number { + GithubIssueNumber::Number(number) => format!("#{number}"), + GithubIssueNumber::Temporary(id) => id.canonical(), + }; + format!( + "update GitHub issue {target}: {}", + self.requested_fields().join(", ") + ) + } + + async fn execute_impl(&self, ctx: &ExecutionContext) -> anyhow::Result { + if !ctx.tool_configs.contains_key("update-github-issue") { + return Ok(ExecutionResult::failure( + "update-github-issue is not configured for this workflow", + )); + } + let Some(token) = ctx.github_token.as_ref() else { + return Ok(ExecutionResult::failure( + "ADO_AW_GITHUB_TOKEN is not set; configure safe-outputs.github-token \ + or safe-outputs.github-app", + )); + }; + let config: UpdateGithubIssueConfig = ctx.get_tool_config("update-github-issue")?; + validate_update_github_issue_config(&config)?; + if let Err(result) = self.validate_opt_ins(&config) { + return Ok(result); + } + let filters = GithubMutationFilters { + required_labels: &config.required_labels, + required_title_prefix: config.required_title_prefix.as_deref(), + }; + if let Err(error) = validate_github_mutation_filter_config(filters) { + return Ok(ExecutionResult::failure(error.to_string())); + } + let target = match resolve_github_issue_target( + &self.issue_number, + self.repository.as_deref(), + GithubRepositoryPolicy::new(config.target_repo.as_deref(), &config.allowed_repos), + ctx, + )? { + Ok(target) => target, + Err(result) => return Ok(result), + }; + let client = GithubClient::new(&ctx.github_api_url, token)?; + + // Fetch and validate every dependency before the single PATCH write. + let current = match self + .fetch_target(&client, &target.repository, target.number) + .await? + { + Ok(target) => target, + Err(result) => return Ok(result), + }; + let metadata = current.metadata(); + if let Err(result) = validate_github_target_capability( + &metadata, + GithubTargetCapabilities { + issues: config.issues, + pull_requests: config.pull_requests, + }, + ) { + return Ok(result); + } + if let Err(result) = validate_github_mutation_filters(&metadata, filters) { + return Ok(result); + } + if let Err(result) = self.preflight_labels(&client, &target.repository).await? { + return Ok(result); + } + if let Err(result) = self + .preflight_assignees(&client, &target.repository) + .await? + { + return Ok(result); + } + if let Err(result) = self + .preflight_milestone(&client, &target.repository) + .await? + { + return Ok(result); + } + + let mut payload = Map::new(); + if let Some(status) = self.status { + payload.insert( + "state".to_string(), + Value::String(status.as_str().to_string()), + ); + } + if let Some(title) = self.title.as_ref() { + payload.insert("title".to_string(), Value::String(title.clone())); + } + if let Some(body) = self.body.as_deref() { + let updated = match build_updated_body( + current.body.as_deref().unwrap_or_default(), + body, + self.operation.unwrap_or(GithubBodyOperation::Append), + config.footer, + ctx, + ) { + Ok(body) => body, + Err(result) => return Ok(result), + }; + payload.insert("body".to_string(), Value::String(updated)); + } + if let Some(labels) = self.labels.as_ref() { + payload.insert("labels".to_string(), serde_json::json!(labels)); + } + if let Some(assignees) = self.assignees.as_ref() { + payload.insert("assignees".to_string(), serde_json::json!(assignees)); + } + if let Some(milestone) = self.milestone { + payload.insert("milestone".to_string(), serde_json::json!(milestone)); + } + + debug!( + "Updating GitHub target {}#{} fields: {}", + target.repository, + target.number, + self.requested_fields().join(", ") + ); + let response = client + .send( + Method::PATCH, + client.issue_url(&target.repository, target.number)?, + Some(&Value::Object(payload)), + ) + .await?; + if !response.is_success() { + let error = response + .require_success("Failed to update GitHub issue") + .expect_err("non-success response must produce an API error"); + return Ok(ExecutionResult::failure(error.to_string())); + } + + info!( + "Updated GitHub target {}#{}", + target.repository, target.number + ); + Ok(ExecutionResult::success_with_data( + format!( + "Updated GitHub target {}#{}: {}", + target.repository, + target.number, + self.requested_fields().join(", ") + ), + serde_json::json!({ + "number": target.number, + "target_repo": target.repository, + "target_kind": match metadata.kind { + GithubTargetKind::Issue => "issue", + GithubTargetKind::PullRequest => "pull_request", + }, + "fields": self.requested_fields(), + "previous": { + "title": current.title, + "state": current.state, + "labels": current.labels.into_iter().map(|label| label.name).collect::>(), + "assignees": current.assignees.into_iter().map(|user| user.login).collect::>(), + "milestone": current.milestone.map(|milestone| { + serde_json::json!({ + "number": milestone.number, + "title": milestone.title, + }) + }), + }, + }), + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::safe_outputs::ToolResult; + use std::collections::HashMap; + use wiremock::matchers::{body_json, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + fn context(server: &MockServer, config: serde_json::Value) -> ExecutionContext { + let mut tool_configs = HashMap::new(); + tool_configs.insert("update-github-issue".to_string(), config); + ExecutionContext { + github_token: Some("token".to_string()), + github_api_url: server.uri(), + tool_configs, + definition_id: Some(123), + ..Default::default() + } + } + + fn target(number: u64, pull_request: bool) -> serde_json::Value { + let mut target = serde_json::json!({ + "number": number, + "node_id": format!("I_{number}"), + "title": "[agent] Existing", + "body": "Existing body", + "state": "open", + "labels": [{"name": "bug"}], + "assignees": [{"login": "octocat"}], + "milestone": {"number": 1, "title": "v1"}, + "html_url": format!("https://github.example/octo/repo/issues/{number}") + }); + if pull_request { + target["pull_request"] = serde_json::json!({"url": "https://api.example/pr"}); + } + target + } + + fn params() -> UpdateGithubIssueParams { + UpdateGithubIssueParams { + issue_number: GithubIssueNumber::Number(7), + status: None, + title: Some("Updated title".to_string()), + body: None, + operation: None, + labels: None, + assignees: None, + milestone: None, + repository: None, + } + } + + #[test] + fn contract_name_and_budget() { + assert_eq!(UpdateGithubIssueResult::NAME, "update-github-issue"); + assert_eq!(UpdateGithubIssueResult::DEFAULT_MAX, 1); + } + + #[test] + fn requires_at_least_one_change() { + let mut params = params(); + params.title = None; + assert!(params.validate().is_err()); + } + + #[test] + fn validates_operations_and_collection_limits() { + let mut operation_without_body = params(); + operation_without_body.operation = Some(GithubBodyOperation::Replace); + assert!(operation_without_body.validate().is_err()); + + let mut too_many_labels = params(); + too_many_labels.labels = Some(vec!["label".to_string(); MAX_LABELS + 1]); + assert!(too_many_labels.validate().is_err()); + } + + #[test] + fn config_is_strict_and_fields_default_to_opt_out() { + assert!( + serde_json::from_value::(serde_json::json!({ + "allow-body": true + })) + .is_err() + ); + let config: UpdateGithubIssueConfig = + serde_json::from_value(serde_json::json!({})).unwrap(); + assert!(!config.status); + assert!(!config.title); + assert!(!config.body); + assert!(!config.labels); + assert!(!config.assignees); + assert!(!config.milestone); + assert!(config.issues); + assert!(config.pull_requests); + assert!(config.footer); + } + + #[test] + fn body_operations_are_deterministic() { + let ctx = ExecutionContext { + definition_id: Some(123), + ..Default::default() + }; + assert_eq!( + build_updated_body("old", "new", GithubBodyOperation::Append, false, &ctx).unwrap(), + "old\n\n---\n\nnew" + ); + assert_eq!( + build_updated_body("old", "new", GithubBodyOperation::Prepend, false, &ctx).unwrap(), + "new\n\n---\n\nold" + ); + assert_eq!( + build_updated_body("old", "new", GithubBodyOperation::Replace, false, &ctx).unwrap(), + "new" + ); + } + + #[test] + fn replace_island_is_strict_and_preserves_surrounding_body() { + let ctx = ExecutionContext { + definition_id: Some(123), + ..Default::default() + }; + let existing = "before\n\nold\n\nafter"; + let updated = build_updated_body( + existing, + "new", + GithubBodyOperation::ReplaceIsland, + false, + &ctx, + ) + .unwrap(); + assert!(updated.starts_with("before\n")); + assert!(updated.ends_with("\nafter")); + assert!(updated.contains("\nnew\n")); + assert!(!updated.contains("\nold\n")); + assert!( + build_updated_body( + "no markers", + "new", + GithubBodyOperation::ReplaceIsland, + false, + &ctx + ) + .is_err() + ); + } + + #[tokio::test] + async fn all_requested_values_are_preflighted_before_single_patch() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/repos/octo/repo/issues/7")) + .respond_with(ResponseTemplate::new(200).set_body_json(target(7, false))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/repos/octo/repo/labels/enhancement")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "name": "enhancement" + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/repos/octo/repo/assignees/hubot")) + .respond_with(ResponseTemplate::new(204)) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/repos/octo/repo/milestones")) + .respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!([{ + "number": 2, + "title": "v2", + "state": "open", + "node_id": "M_2" + }])), + ) + .expect(1) + .mount(&server) + .await; + Mock::given(method("PATCH")) + .and(path("/repos/octo/repo/issues/7")) + .and(body_json(serde_json::json!({ + "state": "closed", + "title": "Updated title", + "body": "Replacement", + "labels": ["enhancement"], + "assignees": ["hubot"], + "milestone": 2 + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({}))) + .expect(1) + .mount(&server) + .await; + let ctx = context( + &server, + serde_json::json!({ + "target-repo": "octo/repo", + "status": true, + "title": true, + "body": true, + "labels": true, + "assignees": true, + "milestone": true, + "allowed-labels": ["enhance*"], + "footer": false, + "required-labels": ["BUG"], + "required-title-prefix": "[agent]" + }), + ); + let mut result: UpdateGithubIssueResult = UpdateGithubIssueParams { + issue_number: GithubIssueNumber::Number(7), + status: Some(GithubIssueStatus::Closed), + title: Some("Updated title".to_string()), + body: Some("Replacement".to_string()), + operation: Some(GithubBodyOperation::Replace), + labels: Some(vec!["enhancement".to_string()]), + assignees: Some(vec!["hubot".to_string()]), + milestone: Some(2), + repository: None, + } + .try_into() + .unwrap(); + let execution = result.execute_sanitized(&ctx).await.unwrap(); + assert!(execution.success, "{}", execution.message); + } + + #[tokio::test] + async fn preflight_failure_prevents_patch() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/repos/octo/repo/issues/7")) + .respond_with(ResponseTemplate::new(200).set_body_json(target(7, false))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/repos/octo/repo/labels/missing")) + .respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({ + "message": "Not Found ##vso[task.setvariable variable=oops]x" + }))) + .expect(1) + .mount(&server) + .await; + let ctx = context( + &server, + serde_json::json!({ + "target-repo": "octo/repo", + "labels": true, + "allowed-labels": ["*"] + }), + ); + let mut result: UpdateGithubIssueResult = UpdateGithubIssueParams { + issue_number: GithubIssueNumber::Number(7), + status: None, + title: None, + body: None, + operation: None, + labels: Some(vec!["missing".to_string()]), + assignees: None, + milestone: None, + repository: None, + } + .try_into() + .unwrap(); + let execution = result.execute_sanitized(&ctx).await.unwrap(); + assert!(!execution.success); + assert!(execution.message.contains("`##vso[`")); + assert!(!execution.message.contains("##vso[task.")); + assert_eq!(server.received_requests().await.unwrap().len(), 2); + } + + #[tokio::test] + async fn pull_request_parity_is_configurable() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/repos/octo/repo/issues/7")) + .respond_with(ResponseTemplate::new(200).set_body_json(target(7, true))) + .expect(1) + .mount(&server) + .await; + let ctx = context( + &server, + serde_json::json!({ + "target-repo": "octo/repo", + "title": true, + "issues": true, + "pull-requests": false + }), + ); + let mut result: UpdateGithubIssueResult = params().try_into().unwrap(); + let execution = result.execute_sanitized(&ctx).await.unwrap(); + assert!(!execution.success); + assert!(execution.message.contains("pull requests")); + assert_eq!(server.received_requests().await.unwrap().len(), 1); + } + + #[tokio::test] + async fn disabled_field_and_filter_fail_before_patch() { + let server = MockServer::start().await; + let ctx = context( + &server, + serde_json::json!({ + "target-repo": "octo/repo", + "body": true + }), + ); + let mut disabled: UpdateGithubIssueResult = params().try_into().unwrap(); + assert!(!disabled.execute_sanitized(&ctx).await.unwrap().success); + assert!(server.received_requests().await.unwrap().is_empty()); + + Mock::given(method("GET")) + .and(path("/repos/octo/repo/issues/7")) + .respond_with(ResponseTemplate::new(200).set_body_json(target(7, false))) + .expect(1) + .mount(&server) + .await; + let ctx = context( + &server, + serde_json::json!({ + "target-repo": "octo/repo", + "title": true, + "required-title-prefix": "[other]" + }), + ); + let mut filtered: UpdateGithubIssueResult = params().try_into().unwrap(); + let execution = filtered.execute_sanitized(&ctx).await.unwrap(); + assert!(!execution.success); + assert!(execution.message.contains("required-title-prefix")); + assert_eq!(server.received_requests().await.unwrap().len(), 1); + } + + #[tokio::test] + async fn dry_run_makes_no_requests() { + let server = MockServer::start().await; + let mut ctx = context( + &server, + serde_json::json!({ + "target-repo": "octo/repo", + "title": true + }), + ); + ctx.dry_run = true; + let mut result: UpdateGithubIssueResult = params().try_into().unwrap(); + let execution = result.execute_sanitized(&ctx).await.unwrap(); + assert!(execution.success); + assert!(execution.message.contains("[DRY-RUN]")); + assert!(server.received_requests().await.unwrap().is_empty()); + } +} diff --git a/src/safe_outputs/update_pr.rs b/src/safe_outputs/update_pr.rs index 7d189b9d5..67e44bdec 100644 --- a/src/safe_outputs/update_pr.rs +++ b/src/safe_outputs/update_pr.rs @@ -262,7 +262,7 @@ impl Executor for UpdatePrResult { .context("No access token available (SYSTEM_ACCESSTOKEN or AZURE_DEVOPS_EXT_PAT)")?; debug!("ADO org: {}, project: {}", org_url, project); - let config: UpdatePrConfig = ctx.get_tool_config("update-pr"); + let config: UpdatePrConfig = ctx.get_tool_config("update-pr")?; debug!("Config: {:?}", config); // Validate operation against allowed-operations @@ -1179,5 +1179,4 @@ allowed-votes: let config: UpdatePrConfig = serde_yaml::from_str(yaml).unwrap(); assert_eq!(config.merge_strategy, "rebase"); } - } diff --git a/src/safe_outputs/update_wiki_page.rs b/src/safe_outputs/update_wiki_page.rs index 329d585fb..c7639af51 100644 --- a/src/safe_outputs/update_wiki_page.rs +++ b/src/safe_outputs/update_wiki_page.rs @@ -214,7 +214,7 @@ impl Executor for UpdateWikiPageResult { .as_ref() .context("No access token available (SYSTEM_ACCESSTOKEN or AZURE_DEVOPS_EXT_PAT)")?; - let config: UpdateWikiPageConfig = ctx.get_tool_config("update-wiki-page"); + let config: UpdateWikiPageConfig = ctx.get_tool_config("update-wiki-page")?; let wiki_name = config .wiki_name @@ -496,7 +496,8 @@ mod tests { let result: Result = params.try_into(); let err = result.unwrap_err(); assert!( - err.to_string().contains("content must be at least 10 characters"), + err.to_string() + .contains("content must be at least 10 characters"), "expected 'content must be at least 10 characters' in error; got: {err}" ); } @@ -802,5 +803,4 @@ wiki-name: "MyProject.wiki" assert!(!outcome.success); assert!(outcome.message.contains("path-prefix")); } - } diff --git a/src/safe_outputs/update_work_item.rs b/src/safe_outputs/update_work_item.rs index bd2aa1fff..a9bae2ef6 100644 --- a/src/safe_outputs/update_work_item.rs +++ b/src/safe_outputs/update_work_item.rs @@ -466,7 +466,7 @@ impl Executor for UpdateWorkItemResult { .as_ref() .context("No access token available (SYSTEM_ACCESSTOKEN or AZURE_DEVOPS_EXT_PAT)")?; - let config: UpdateWorkItemConfig = ctx.get_tool_config("update-work-item"); + let config: UpdateWorkItemConfig = ctx.get_tool_config("update-work-item")?; debug!( "Config: status={}, title={}, body={}, markdown_body={}, target={:?}, title_prefix={:?}, tag_prefix={:?}", config.status, @@ -1148,5 +1148,4 @@ allowed-tags: exec_result.message ); } - } diff --git a/src/safe_outputs/upload_build_attachment.rs b/src/safe_outputs/upload_build_attachment.rs index d08b2c0f8..f9af5836f 100644 --- a/src/safe_outputs/upload_build_attachment.rs +++ b/src/safe_outputs/upload_build_attachment.rs @@ -302,7 +302,7 @@ impl Executor for UploadBuildAttachmentResult { effective_build_id, self.artifact_name, self.file_path ); - let config: UploadBuildAttachmentConfig = ctx.get_tool_config("upload-build-attachment"); + let config: UploadBuildAttachmentConfig = ctx.get_tool_config("upload-build-attachment")?; debug!("Max file size: {} bytes", config.max_file_size); debug!("Allowed extensions: {:?}", config.allowed_extensions); debug!( @@ -512,7 +512,10 @@ impl Executor for UploadBuildAttachmentResult { "SYSTEM_JOBID is not set — required to attach to the current build (build attachments \ are written to the current job's timeline record)", )?; - debug!("ADO org: {}, project: {} ({})", org_url, project, project_id); + debug!( + "ADO org: {}, project: {} ({})", + org_url, project, project_id + ); // Build the DistributedTask timeline-attachment URL. This is the write // side of a build attachment — the object is read back via the Build ▸ @@ -928,6 +931,7 @@ attachment-type: "agent-artifact" build_number: None, build_reason: None, definition_name: None, + definition_id: None, source_branch: None, source_branch_name: None, source_version: None, @@ -1053,8 +1057,7 @@ attachment-type: "agent-artifact" async fn test_executor_fails_when_plan_id_missing() { // SHA-256 of b"hello" so the non-dry-run integrity check passes and we // reach the timeline-coordinate resolution. - const HELLO_SHA: &str = - "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"; + const HELLO_SHA: &str = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"; let dir = tempfile::tempdir().unwrap(); let staged = "upload-build-attachment-agent-report-0badf00d.txt"; std::fs::write(dir.path().join(staged), b"hello").unwrap(); @@ -1089,8 +1092,7 @@ attachment-type: "agent-artifact" async fn test_executor_fails_when_project_id_missing() { // SHA-256 of b"hello" so the integrity check passes and we reach the // scope-identifier resolution. - const HELLO_SHA: &str = - "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"; + const HELLO_SHA: &str = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"; let dir = tempfile::tempdir().unwrap(); let staged = "upload-build-attachment-agent-report-1badf00d.txt"; std::fs::write(dir.path().join(staged), b"hello").unwrap(); diff --git a/src/safe_outputs/upload_pipeline_artifact.rs b/src/safe_outputs/upload_pipeline_artifact.rs index 49a55da6b..c0f3eb24e 100644 --- a/src/safe_outputs/upload_pipeline_artifact.rs +++ b/src/safe_outputs/upload_pipeline_artifact.rs @@ -393,7 +393,10 @@ fn resolve_staged_file( file_size, max_file_size )))); } - Ok(Ok(StagedFileInfo { canonical, file_size })) + Ok(Ok(StagedFileInfo { + canonical, + file_size, + })) } /// Extract the required ADO context fields from `ctx`, failing with a clear @@ -424,7 +427,13 @@ fn resolve_ado_context(ctx: &ExecutionContext) -> anyhow::Result>::try_into(params) + let err = + >::try_into( + params, + ) .unwrap_err(); assert!( err.to_string().contains("work_item_id must be positive"), @@ -387,7 +390,10 @@ mod tests { file_path: "".to_string(), comment: None, }; - let err = >::try_into(params) + let err = + >::try_into( + params, + ) .unwrap_err(); assert!( err.to_string().contains("must not be empty"), @@ -402,7 +408,10 @@ mod tests { file_path: "../etc/passwd".to_string(), comment: None, }; - let err = >::try_into(params) + let err = + >::try_into( + params, + ) .unwrap_err(); assert!( err.to_string().contains("path-traversal"), @@ -531,10 +540,15 @@ mod tests { file_path: "##[error]value.txt".to_string(), comment: None, }; - let vso_err = >::try_into(vso) - .unwrap_err(); - let shorthand_err = >::try_into(shorthand) + let vso_err = + >::try_into( + vso, + ) .unwrap_err(); + let shorthand_err = >::try_into(shorthand) + .unwrap_err(); assert!( vso_err.to_string().contains("pipeline command"), "unexpected error for ##vso[: {vso_err}" diff --git a/tests/compiler_tests.rs b/tests/compiler_tests.rs index 3784da1a9..fe2a32b0d 100644 --- a/tests/compiler_tests.rs +++ b/tests/compiler_tests.rs @@ -1662,8 +1662,7 @@ Vote on pull requests. ]; for (dir_prefix, test_content, case_desc) in configs { - let temp_dir = - std::env::temp_dir().join(format!("{}-{}", dir_prefix, std::process::id())); + let temp_dir = std::env::temp_dir().join(format!("{}-{}", dir_prefix, std::process::id())); fs::create_dir_all(&temp_dir).expect("Failed to create temp directory"); let test_input = temp_dir.join("upr-agent.md"); @@ -2157,8 +2156,7 @@ fn test_fixture_azure_devops_mcp_compiled_output() { "MCPG config should contain the container image" ); assert!( - compiled.contains("\"@azure-devops/mcp@2.8.1\"") - && compiled.contains("expected 2.8.1"), + compiled.contains("\"@azure-devops/mcp@2.8.1\"") && compiled.contains("expected 2.8.1"), "the unversioned frontmatter form must use and verify the compiler default" ); assert!( @@ -6075,13 +6073,14 @@ fn test_compile_github_issue_app_fixture_scopes_tokens_by_stage() { let compiled = compile_fixture("github-issue-app-agent.md"); assert_valid_yaml(&compiled, "github-issue-app-agent.md"); assert!(compiled_has_enabled_tool(&compiled, "create-github-issue")); - assert!(compiled_has_enabled_tool(&compiled, "set-github-issue-type")); + assert!(compiled_has_enabled_tool( + &compiled, + "set-github-issue-type" + )); assert!(compiled.contains("Mint GitHub App token (SafeOutputs)")); assert!(compiled.contains("--output-var 'ADO_AW_SAFE_OUTPUTS_GITHUB_APP_TOKEN'")); assert!(compiled.contains("--permissions-json '{\"issues\":\"write\"}'")); - assert!(compiled.contains( - "ADO_AW_GITHUB_TOKEN: $(ADO_AW_SAFE_OUTPUTS_GITHUB_APP_TOKEN)" - )); + assert!(compiled.contains("ADO_AW_GITHUB_TOKEN: $(ADO_AW_SAFE_OUTPUTS_GITHUB_APP_TOKEN)")); let agent_start = compiled.find("- job: Agent").expect("Agent job"); let detection_start = compiled.find("- job: Detection").expect("Detection job"); @@ -6092,13 +6091,53 @@ fn test_compile_github_issue_app_fixture_scopes_tokens_by_stage() { &compiled[agent_start..detection_start], &compiled[detection_start..safe_outputs_start], ] { - assert!(block.contains( - "--permissions-json '{\"contents\":\"read\",\"issues\":\"read\"}'" - )); + assert!(block.contains("--permissions-json '{\"contents\":\"read\",\"issues\":\"read\"}'")); assert!(!block.contains("ADO_AW_GITHUB_TOKEN")); } } +#[test] +fn test_compile_github_app_auth_is_scoped_to_reviewed_variant() { + let compiled = compile_inline_agent( + "github-app-reviewed-variant", + r#"--- +name: "GitHub App Reviewed Variant" +description: "GitHub App auth is emitted only in the GitHub execution lane" +engine: + id: copilot + github-app-token: + app-id: 1234567 + owner: octo-org + permissions: + issues: read +safe-outputs: + noop: + require-approval: false + create-github-issue: + target-repo: octo-org/reviewed-repo + require-approval: true +--- + +Create a reviewed GitHub issue. +"#, + ); + let automatic_start = compiled + .find("- job: SafeOutputs\n") + .expect("automatic job"); + let reviewed_start = compiled + .find("- job: SafeOutputs_Reviewed") + .expect("reviewed job"); + let automatic = &compiled[automatic_start..reviewed_start]; + let reviewed = &compiled[reviewed_start..]; + + assert!(!automatic.contains("Mint GitHub App token (SafeOutputs)")); + assert!(!automatic.contains("ADO_AW_SAFE_OUTPUTS_GITHUB_APP_TOKEN")); + assert!(reviewed.contains("Mint GitHub App token (SafeOutputs)")); + assert!(reviewed.contains("--repositories 'reviewed-repo'")); + assert!(reviewed.contains("--permissions-json '{\"issues\":\"write\"}'")); + assert!(reviewed.contains("Revoke GitHub App token (SafeOutputs)")); +} + /// The example file in `examples/dogfood-failure-reporter.md` must compile /// cleanly. Mirror of the structural smoke test for `examples/sample-agent.md`. #[test] @@ -6442,8 +6481,8 @@ fn test_execution_context_pr_emits_prepare_step_and_prompt_supplement() { // IS expected at Agent-job-level `variables:` scope, the documented // safe location — that hoist is asserted separately.) let parsed = parse_compiled_yaml(&compiled); - let agent_job = find_job_mapping(&parsed, "Agent") - .expect("compiled YAML must contain the Agent job"); + let agent_job = + find_job_mapping(&parsed, "Agent").expect("compiled YAML must contain the Agent job"); let stage_step = agent_job .get(yaml_key("steps")) .and_then(|v| v.as_sequence()) @@ -7399,7 +7438,10 @@ safe-outputs: assert!(detection.contains("--model detection-model"), "{detection}"); assert!(detection.contains("--reasoning-effort=low"), "{detection}"); - assert!(!detection.contains("--reasoning-effort=high"), "{detection}"); + assert!( + !detection.contains("--reasoning-effort=high"), + "{detection}" + ); assert!(detection.contains("INHERITED_ENV: agent"), "{detection}"); assert!(detection.contains("DETECTION_ENV: enabled"), "{detection}"); assert!( @@ -7443,9 +7485,7 @@ safe-outputs: assert!(!detection.contains("Prepare threat analysis prompt")); assert!(detection.contains("name: threatAnalysis"), "{detection}"); assert!( - detection.contains( - "##vso[task.setvariable variable=SafeToProcess;isOutput=true]true" - ), + detection.contains("##vso[task.setvariable variable=SafeToProcess;isOutput=true]true"), "{detection}" ); assert!(detection.contains("name: reviewedProposals"), "{detection}"); @@ -7464,8 +7504,7 @@ safe-outputs: ); let safe_outputs = job_block(&compiled, "SafeOutputs"); assert!( - safe_outputs - .contains("dependencies.Detection.outputs['threatAnalysis.SafeToProcess']"), + safe_outputs.contains("dependencies.Detection.outputs['threatAnalysis.SafeToProcess']"), "{safe_outputs}" ); } @@ -7487,9 +7526,15 @@ safe-outputs: "#; let (ok, compiled, stderr) = compile_inline_source("threat-detection-disabled-invalid-version", source); - assert!(ok, "disabled Detection should not resolve install steps: {stderr}"); + assert!( + ok, + "disabled Detection should not resolve install steps: {stderr}" + ); let detection = job_block(&compiled, "Detection"); - assert!(detection.contains("Bypass AI threat analysis"), "{detection}"); + assert!( + detection.contains("Bypass AI threat analysis"), + "{detection}" + ); assert!(!detection.contains("bad version"), "{detection}"); } @@ -7574,7 +7619,10 @@ safe-outputs: let detection = job_block(&compiled, "Detection"); assert!(!agent.contains("detector.example.com"), "{agent}"); assert!(detection.contains("detector.example.com"), "{detection}"); - assert!(detection.contains("COPILOT_PROVIDER_BASE_URL"), "{detection}"); + assert!( + detection.contains("COPILOT_PROVIDER_BASE_URL"), + "{detection}" + ); assert!(detection.contains("DETECTION_API_KEY"), "{detection}"); assert!( detection.contains("--exclude-env COPILOT_PROVIDER_API_KEY"), @@ -8368,6 +8416,16 @@ safe-outputs: compiled.contains("AW_REVIEWED_TOOLS: create-pull-request"), "expected the reviewed tool list passed via env:\n{compiled}" ); + assert!( + compiled.contains("AW_GITHUB_REPOSITORY_POLICIES: '{}'") + || compiled.contains("AW_GITHUB_REPOSITORY_POLICIES: {}"), + "expected an empty trusted repository-policy map:\n{compiled}" + ); + assert!( + compiled.contains("AW_CURRENT_REPOSITORY: $(Build.Repository.Name)") + && compiled.contains("AW_CURRENT_REPOSITORY_PROVIDER: $(Build.Repository.Provider)"), + "expected trusted ADO repository metadata:\n{compiled}" + ); // The step lives in the Agent job, not the Detection job. let agent_block = job_block(&compiled, "Agent"); @@ -8382,6 +8440,66 @@ safe-outputs: ); } +#[test] +fn test_safe_outputs_summary_step_embeds_trusted_github_repository_policy() { + let source = r#"--- +name: "GitHub Summary Agent" +description: "Trusted repository summary" +safe-outputs: + create-github-issue: + target-repo: octo/default + allowed-repos: [octo/other] + require-approval: true +--- + +## Body +"#; + let (ok, compiled, stderr) = compile_inline_source("summary-github-repo", source); + assert!(ok, "pipeline should compile: {stderr}"); + let agent_block = job_block(&compiled, "Agent"); + assert!( + agent_block.contains("AW_GITHUB_REPOSITORY_POLICIES:") + && agent_block.contains("create-github-issue") + && agent_block.contains("targetRepo") + && agent_block.contains("octo/default") + && agent_block.contains("allowedRepos") + && agent_block.contains("octo/other"), + "expected compiler-owned GitHub repository policy:\n{agent_block}" + ); + assert!( + agent_block.contains("AW_GITHUB_API_URL: https://api.github.com"), + "expected resolved GitHub API URL:\n{agent_block}" + ); +} + +#[test] +fn test_safe_outputs_summary_step_preserves_current_repository_fallback() { + let source = r#"--- +name: "GitHub Current Repository Summary" +description: "Runtime current repository fallback" +safe-outputs: + create-github-issue: {} +--- + +## Body +"#; + let (ok, compiled, stderr) = compile_inline_source("summary-github-current", source); + assert!(ok, "pipeline should compile: {stderr}"); + let agent_block = job_block(&compiled, "Agent"); + assert!( + agent_block.contains("create-github-issue") + && agent_block.contains("targetRepo") + && agent_block.contains("null") + && agent_block.contains("allowedRepos"), + "expected a trusted policy with no fixed target:\n{agent_block}" + ); + assert!( + agent_block.contains("AW_CURRENT_REPOSITORY: $(Build.Repository.Name)") + && agent_block.contains("AW_CURRENT_REPOSITORY_PROVIDER: $(Build.Repository.Provider)"), + "expected runtime current-repository fallback inputs:\n{agent_block}" + ); +} + /// The summary step is also emitted for a plain safe-outputs pipeline with no /// approval configured (always-on transparency), with an empty reviewed list. #[test] @@ -9414,7 +9532,8 @@ fn test_create_pull_request_safeoutputs_prepare_step_covers_all_checkout_repos() ); let safeoutputs = job_block(&compiled, "SafeOutputs"); assert!( - safeoutputs.contains("--repo-dir \"$(Build.SourcesDirectory)/self\" --target-branch 'main'"), + safeoutputs + .contains("--repo-dir \"$(Build.SourcesDirectory)/self\" --target-branch 'main'"), "self must target the literal default 'main' in the SafeOutputs job:\n{safeoutputs}" ); assert!( @@ -9528,8 +9647,7 @@ fn test_issue_1731_safeoutputs_checks_out_additional_repos_for_create_pr() { "Agent must check out self at its fixed multi-checkout path:\n{agent}" ); assert!( - agent.contains("- checkout: build-tools") - && agent.contains("path: s/build-tools"), + agent.contains("- checkout: build-tools") && agent.contains("path: s/build-tools"), "Agent must check out tools at its explicit alias path:\n{agent}" ); assert!( @@ -9611,13 +9729,11 @@ fn test_issue_1731_safeoutputs_executor_source_path_uses_multi_checkout_layout() let safeoutputs = job_block(&compiled, "SafeOutputs"); // With additional repos, self is pinned to $(Build.SourcesDirectory)/self. assert!( - safeoutputs - .contains("ado-aw execute --source \"$(Build.SourcesDirectory)/self/"), + safeoutputs.contains("ado-aw execute --source \"$(Build.SourcesDirectory)/self/"), "SafeOutputs executor --source must use the multi-checkout layout path:\n{safeoutputs}" ); assert!( - safeoutputs - .contains("ADO_AW_SELF_REPOSITORY_DIRECTORY: $(Build.SourcesDirectory)/self"), + safeoutputs.contains("ADO_AW_SELF_REPOSITORY_DIRECTORY: $(Build.SourcesDirectory)/self"), "SafeOutputs must pass the exact self checkout to the executor:\n{safeoutputs}" ); assert!( @@ -9704,23 +9820,18 @@ fn test_issue_1731_split_approval_additional_checkouts_only_in_pr_variant() { ); assert!( auto.contains("ado-aw execute --source \"$(Build.SourcesDirectory)/") - && !auto.contains( - "ado-aw execute --source \"$(Build.SourcesDirectory)/self/" - ), + && !auto.contains("ado-aw execute --source \"$(Build.SourcesDirectory)/self/"), "self-only SafeOutputs must use its single-checkout source path:\n{auto}" ); assert!( auto.contains("ADO_AW_SELF_REPOSITORY_DIRECTORY: $(Build.SourcesDirectory)") - && !auto.contains( - "ADO_AW_SELF_REPOSITORY_DIRECTORY: $(Build.SourcesDirectory)/self" - ), + && !auto.contains("ADO_AW_SELF_REPOSITORY_DIRECTORY: $(Build.SourcesDirectory)/self"), "self-only SafeOutputs must pass its checkout root as the self repo:\n{auto}" ); assert!( reviewed.contains("ado-aw execute --source \"$(Build.SourcesDirectory)/self/") - && reviewed.contains( - "ADO_AW_SELF_REPOSITORY_DIRECTORY: $(Build.SourcesDirectory)/self" - ), + && reviewed + .contains("ADO_AW_SELF_REPOSITORY_DIRECTORY: $(Build.SourcesDirectory)/self"), "PR-capable reviewed job must use its multi-checkout self path:\n{reviewed}" ); } @@ -9751,23 +9862,18 @@ fn test_issue_1731_split_approval_additional_checkouts_in_auto_when_sibling_gate ); assert!( auto.contains("ado-aw execute --source \"$(Build.SourcesDirectory)/self/") - && auto.contains( - "ADO_AW_SELF_REPOSITORY_DIRECTORY: $(Build.SourcesDirectory)/self" - ), + && auto.contains("ADO_AW_SELF_REPOSITORY_DIRECTORY: $(Build.SourcesDirectory)/self"), "PR-capable automatic job must use its multi-checkout self path:\n{auto}" ); assert!( reviewed.contains("ado-aw execute --source \"$(Build.SourcesDirectory)/") - && !reviewed.contains( - "ado-aw execute --source \"$(Build.SourcesDirectory)/self/" - ), + && !reviewed.contains("ado-aw execute --source \"$(Build.SourcesDirectory)/self/"), "self-only reviewed job must use its single-checkout source path:\n{reviewed}" ); assert!( reviewed.contains("ADO_AW_SELF_REPOSITORY_DIRECTORY: $(Build.SourcesDirectory)") - && !reviewed.contains( - "ADO_AW_SELF_REPOSITORY_DIRECTORY: $(Build.SourcesDirectory)/self" - ), + && !reviewed + .contains("ADO_AW_SELF_REPOSITORY_DIRECTORY: $(Build.SourcesDirectory)/self"), "self-only reviewed job must pass its checkout root as the self repo:\n{reviewed}" ); } @@ -9799,9 +9905,7 @@ fn test_issue_1731_split_checkout_layout_compiles_for_every_target() { ); assert!( compiled.contains("ado-aw execute --source \"$(Build.SourcesDirectory)/") - && compiled.contains( - "ADO_AW_SELF_REPOSITORY_DIRECTORY: $(Build.SourcesDirectory)" - ), + && compiled.contains("ADO_AW_SELF_REPOSITORY_DIRECTORY: $(Build.SourcesDirectory)"), "{target}: self-only Stage 3 sibling must use single-checkout layout:\n{compiled}" ); }