diff --git a/.github/scripts/jira_sites.py b/.github/scripts/jira_sites.py new file mode 100644 index 0000000..a5bd3cf --- /dev/null +++ b/.github/scripts/jira_sites.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Helpers for diagnosing JIRA API access in the analytics-review workflow. + +Parses the JSON that `GET https://api.atlassian.com/oauth/token/accessible-resources` +returns (the list of Atlassian sites a credential can reach) and answers two +questions for the workflow's JIRA pre-step: + + jira_sites.py cloud-id + Print the cloudId to use with the scoped-token endpoint + (https://api.atlassian.com/ex/jira/), chosen as: the site + whose `url` equals the configured base URL, else the cloudId already + embedded in an .../ex/jira/ base, else the only site when exactly + one is accessible. Prints nothing when no confident answer exists. + + jira_sites.py summary + Print a one-line "url (cloudId ...)" list for diagnostics. + +Site URLs and cloudIds are not credentials (cloudIds appear in every browser +request to a JIRA site); nothing secret is ever read or printed here. Exit +code is always 0 — this feeds a diagnostics path that must never fail the job. +""" + +import json +import sys + + +def load_sites(path): + try: + with open(path) as fh: + data = json.load(fh) + except Exception: + return [] + return [s for s in data if isinstance(s, dict)] if isinstance(data, list) else [] + + +def main(): + if len(sys.argv) < 3: + return + mode, path = sys.argv[1], sys.argv[2] + sites = load_sites(path) + if mode == "summary": + line = "; ".join( + "%s (cloudId %s)" % (s.get("url", "?"), s.get("id", "?")) for s in sites + ) + print(line or "no sites accessible to this token") + elif mode == "cloud-id": + base = (sys.argv[3] if len(sys.argv) > 3 else "").rstrip("/") + match = [s for s in sites if str(s.get("url", "")).rstrip("/") == base] + if not match and "/ex/jira/" in base: + cid = base.split("/ex/jira/", 1)[1].split("/")[0] + match = [s for s in sites if s.get("id") == cid] + if not match and len(sites) == 1: + match = sites + if match: + print(match[0].get("id", "")) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/analytics-review.lock.yml b/.github/workflows/analytics-review.lock.yml index 0340f66..1185246 100644 --- a/.github/workflows/analytics-review.lock.yml +++ b/.github/workflows/analytics-review.lock.yml @@ -1,4 +1,4 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"897ac6af2994e60159d3e38c29d67d7d8dca906da7078b3fa2fe171df6978050","body_hash":"6a8bb45a9b4607ef7a9a8b41821ad90afa771402a3561cf884944098c98dea59","compiler_version":"v0.86.2","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.79"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"a5aae95c31e04d094eb82b541158b7713878811afe0acba09faffd0513ea4798","body_hash":"0b8477f533a289d2aeef66568c4ced69e0535dc02c4547c8d4a2c48f85037557","compiler_version":"v0.86.2","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.79"}} # gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN","JIRA_API_TOKEN","JIRA_BASE_URL","JIRA_EMAIL"],"actions":[{"repo":"actions/cache/restore","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/save","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/setup-python","sha":"5fda3b95a4ea91299a34e894583c3862153e4b97","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"6aab9e5b5c91c615506061f09bedd81a23babe3c","version":"v0.86.2"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44","digest":"sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44","digest":"sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44","digest":"sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.9","digest":"sha256:e5a1569aeaf41820fa7bdee3e94468cae448133cdbf00119ad24f5b74db1ab9f","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.9@sha256:e5a1569aeaf41820fa7bdee3e94468cae448133cdbf00119ad24f5b74db1ab9f"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196","pinned_image":"ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196"},{"image":"ghcr.io/github/github-mcp-server:v1.9.0","digest":"sha256:881b53d6f75f69bdbc1b5b10fc2f1361717c19054143b3a8529fb5c32061a50e","pinned_image":"ghcr.io/github/github-mcp-server:v1.9.0@sha256:881b53d6f75f69bdbc1b5b10fc2f1361717c19054143b3a8529fb5c32061a50e"}],"has_pull_request_target":true} # This file was automatically generated by gh-aw (v0.86.2). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # @@ -486,18 +486,20 @@ jobs: path: /tmp/gh-aw - env: EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} + EVENT_NAME: ${{ github.event_name }} EVENT_PR_NUMBER: ${{ github.event.pull_request.number }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} INPUT_PR_NUMBER: ${{ github.event.inputs.pr_number }} REPO: ${{ github.repository }} + id: resolve_pr name: Resolve PR context (number, head ref, head SHA) - run: "set -euo pipefail\nmkdir -p /tmp/gh-aw/context\nPR_NUMBER=\"\"\nfor candidate in \"${EVENT_PR_NUMBER:-}\" \"${EVENT_ISSUE_NUMBER:-}\" \"${INPUT_PR_NUMBER:-}\"; do\n # issue_comment events reach us only for comments on PRs (trigger filter), so the\n # issue number IS the PR number there. workflow_dispatch supplies its own input.\n if printf '%s' \"$candidate\" | grep -qE '^[0-9]+$'; then PR_NUMBER=\"$candidate\"; break; fi\ndone\nHEAD_REF=\"\"; HEAD_SHA=\"\"\nif [ -n \"$PR_NUMBER\" ]; then\n HEAD_REF=$(gh api \"repos/$REPO/pulls/$PR_NUMBER\" --jq .head.ref || true)\n HEAD_SHA=$(gh api \"repos/$REPO/pulls/$PR_NUMBER\" --jq .head.sha || true)\nfi\n# git ref names cannot contain whitespace or control characters, so single-line\n# GITHUB_ENV writes are safe here.\n{\n echo \"AW_PR_NUMBER=$PR_NUMBER\"\n echo \"AW_HEAD_REF=$HEAD_REF\"\n echo \"AW_HEAD_SHA=$HEAD_SHA\"\n} >> \"$GITHUB_ENV\"\necho \"PR=#${PR_NUMBER:-none} head=${HEAD_REF:-?}@${HEAD_SHA:-?}\"\n" + run: "set -euo pipefail\nmkdir -p /tmp/gh-aw/context\nPR_NUMBER=\"\"\nfor candidate in \"${EVENT_PR_NUMBER:-}\" \"${EVENT_ISSUE_NUMBER:-}\" \"${INPUT_PR_NUMBER:-}\"; do\n # issue_comment events reach us only for comments on PRs (trigger filter), so the\n # issue number IS the PR number there. workflow_dispatch supplies its own input.\n if printf '%s' \"$candidate\" | grep -qE '^[0-9]+$'; then PR_NUMBER=\"$candidate\"; break; fi\ndone\nHEAD_REF=\"\"; HEAD_SHA=\"\"\nif [ -n \"$PR_NUMBER\" ]; then\n HEAD_REF=$(gh api \"repos/$REPO/pulls/$PR_NUMBER\" --jq .head.ref || true)\n HEAD_SHA=$(gh api \"repos/$REPO/pulls/$PR_NUMBER\" --jq .head.sha || true)\nfi\n# git ref names cannot contain whitespace or control characters, so single-line\n# GITHUB_ENV writes are safe here.\n{\n echo \"AW_PR_NUMBER=$PR_NUMBER\"\n echo \"AW_HEAD_REF=$HEAD_REF\"\n echo \"AW_HEAD_SHA=$HEAD_SHA\"\n} >> \"$GITHUB_ENV\"\n{\n echo \"pr_number=$PR_NUMBER\"\n echo \"head_ref=$HEAD_REF\"\n echo \"head_sha=$HEAD_SHA\"\n} >> \"$GITHUB_OUTPUT\"\n# The agent's source of truth for WHICH PR it is reviewing. Head ref is\n# author-controlled text, but git forbids whitespace/control characters in ref\n# names, so these single-line writes cannot be broken out of.\n{\n echo \"# Run context (resolved by a deterministic pre-step — trust this over the event payload)\"\n echo\n echo \"- Triggering event: $EVENT_NAME\"\n if [ -n \"$PR_NUMBER\" ]; then\n echo \"- PR under review: #$PR_NUMBER\"\n echo \"- Head ref (PR branch name): $HEAD_REF\"\n echo \"- Head SHA: $HEAD_SHA\"\n else\n echo \"- PR under review: NONE RESOLVED — the event payload contained no PR or issue number and no pr_number dispatch input was given. There is nothing to review.\"\n fi\n} > /tmp/gh-aw/context/run-context.md\necho \"PR=#${PR_NUMBER:-none} head=${HEAD_REF:-?}@${HEAD_SHA:-?}\"\n" - env: JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }} JIRA_EMAIL: ${{ secrets.JIRA_EMAIL }} name: Fetch JIRA ticket context - run: "# Deliberately no `set -e`: this step must NEVER fail the job. Every failure mode\n# degrades into a marker file that tells the agent (and the humans reading the review)\n# exactly what was missing.\nset -uo pipefail\nOUT=/tmp/gh-aw/context/jira-ticket.md\nmkdir -p /tmp/gh-aw/context\nno_ticket() {\n printf 'NO TICKET FOUND: %s\\n' \"$1\" > \"$OUT\"\n echo \"jira-ticket.md marker written: $1\"\n exit 0\n}\n[ -n \"${AW_HEAD_REF:-}\" ] || no_ticket \"no pull request context, so no branch name to extract a ticket ID from\"\nKEY=$(printf '%s' \"$AW_HEAD_REF\" | grep -oiE 'ENG-[0-9]+' | head -1 | tr '[:lower:]' '[:upper:]')\n[ -n \"$KEY\" ] || no_ticket \"branch '$AW_HEAD_REF' does not contain a JIRA ticket ID (repo convention: branch name = ticket, e.g. ENG-909)\"\nif [ -z \"${JIRA_BASE_URL:-}\" ] || [ -z \"${JIRA_EMAIL:-}\" ] || [ -z \"${JIRA_API_TOKEN:-}\" ]; then\n no_ticket \"ticket $KEY detected in branch name, but the JIRA_BASE_URL / JIRA_EMAIL / JIRA_API_TOKEN repo secrets are not configured\"\nfi\nJIRA_BASE_URL=\"${JIRA_BASE_URL%/}\"\nISSUE_JSON=$(mktemp); COMMENTS_JSON=$(mktemp)\nCODE=$(curl -sS -o \"$ISSUE_JSON\" -w '%{http_code}' --max-time 30 \\\n -u \"$JIRA_EMAIL:$JIRA_API_TOKEN\" -H 'Accept: application/json' \\\n \"$JIRA_BASE_URL/rest/api/3/issue/$KEY?fields=summary,description,labels,status\") || CODE=000\n[ \"$CODE\" = \"200\" ] || no_ticket \"JIRA returned HTTP $CODE for $KEY (ticket may not exist, or credentials may be wrong)\"\nCCODE=$(curl -sS -o \"$COMMENTS_JSON\" -w '%{http_code}' --max-time 30 \\\n -u \"$JIRA_EMAIL:$JIRA_API_TOKEN\" -H 'Accept: application/json' \\\n \"$JIRA_BASE_URL/rest/api/3/issue/$KEY/comment\") || CCODE=000\n[ \"$CCODE\" = \"200\" ] || printf '{\"comments\":[]}' > \"$COMMENTS_JSON\"\n# Render the ADF (Atlassian Document Format) JSON into readable markdown, using the\n# renderer from the TRUSTED BASE checkout. Imperfect rendering is fine; a failed render\n# is not — fall back to the marker.\npython3 \"$GITHUB_WORKSPACE/.github/scripts/render_jira_ticket.py\" \"$KEY\" \"$ISSUE_JSON\" \"$COMMENTS_JSON\" > \"$OUT\" \\\n || no_ticket \"failed to render the JIRA response for $KEY\"\necho \"jira-ticket.md written for $KEY\"\n" + run: "# Deliberately no `set -e`: this step must NEVER fail the job. Every failure mode\n# degrades into a marker file that tells the agent (and the humans reading the review)\n# exactly what was missing.\nset -uo pipefail\nOUT=/tmp/gh-aw/context/jira-ticket.md\nmkdir -p /tmp/gh-aw/context\nno_ticket() {\n printf 'NO TICKET FOUND: %s\\n' \"$1\" > \"$OUT\"\n echo \"jira-ticket.md marker written: $1\"\n exit 0\n}\n[ -n \"${AW_HEAD_REF:-}\" ] || no_ticket \"no pull request context, so no branch name to extract a ticket ID from\"\nKEY=$(printf '%s' \"$AW_HEAD_REF\" | grep -oiE 'ENG-[0-9]+' | head -1 | tr '[:lower:]' '[:upper:]')\n[ -n \"$KEY\" ] || no_ticket \"branch '$AW_HEAD_REF' does not contain a JIRA ticket ID (repo convention: branch name = ticket, e.g. ENG-909)\"\nif [ -z \"${JIRA_BASE_URL:-}\" ] || [ -z \"${JIRA_EMAIL:-}\" ] || [ -z \"${JIRA_API_TOKEN:-}\" ]; then\n no_ticket \"ticket $KEY detected in branch name, but the JIRA_BASE_URL / JIRA_EMAIL / JIRA_API_TOKEN repo secrets are not configured\"\nfi\nJIRA_BASE_URL=\"${JIRA_BASE_URL%/}\"\n# Classify the configured base URL's shape for diagnostics. Hosts only — never values.\ncase \"$JIRA_BASE_URL\" in\n https://api.atlassian.com/ex/jira/*) BASE_FORM=\"host api.atlassian.com — the scoped-token form\" ;;\n https://*.atlassian.net*) BASE_FORM=\"a *.atlassian.net host — works only with UNSCOPED tokens\" ;;\n *) BASE_FORM=\"a host that is neither api.atlassian.com nor *.atlassian.net\" ;;\nesac\nISSUE_JSON=$(mktemp); COMMENTS_JSON=$(mktemp)\nfetch_issue() {\n CODE=$(curl -sS -o \"$ISSUE_JSON\" -w '%{http_code}' --max-time 30 \\\n -u \"$JIRA_EMAIL:$JIRA_API_TOKEN\" -H 'Accept: application/json' \\\n \"$1/rest/api/3/issue/$KEY?fields=summary,description,labels,status\") || CODE=000\n}\nBASE=\"$JIRA_BASE_URL\"\nfetch_issue \"$BASE\"\nif [ \"$CODE\" != \"200\" ]; then\n ORIG_CODE=$CODE\n # Ask Atlassian which sites this credential can reach. Works for scoped AND\n # unscoped tokens, and each returned entry's `id` is the cloudId needed for the\n # scoped-token endpoint — one call both diagnoses and enables auto-recovery.\n RES_JSON=$(mktemp)\n PROBE=$(curl -sS -o \"$RES_JSON\" -w '%{http_code}' --max-time 15 \\\n -u \"$JIRA_EMAIL:$JIRA_API_TOKEN\" -H 'Accept: application/json' \\\n \"https://api.atlassian.com/oauth/token/accessible-resources\") || PROBE=000\n CLOUD_ID=\"\"; RETRIED=\"\"\n if [ \"$PROBE\" = \"200\" ]; then\n CLOUD_ID=$(python3 \"$GITHUB_WORKSPACE/.github/scripts/jira_sites.py\" cloud-id \"$RES_JSON\" \"$BASE\" || true)\n if [ -n \"$CLOUD_ID\" ] && [ \"$BASE\" != \"https://api.atlassian.com/ex/jira/$CLOUD_ID\" ]; then\n BASE=\"https://api.atlassian.com/ex/jira/$CLOUD_ID\"\n RETRIED=1\n echo \"issue fetch got HTTP $ORIG_CODE at the configured base URL ($BASE_FORM); retrying via the scoped-token endpoint\"\n fetch_issue \"$BASE\"\n fi\n fi\n if [ \"$CODE\" != \"200\" ]; then\n # Only HTTP codes, site URLs, and cloudIds appear below — never credential values.\n case \"$PROBE\" in\n 401|403)\n DIAG=\"JIRA credentials rejected (accessible-resources probe returned HTTP $PROBE) — check that JIRA_EMAIL matches the account that owns JIRA_API_TOKEN, and that the token has not expired (Atlassian scoped tokens expire within 365 days)\" ;;\n 200)\n SITES=$(python3 \"$GITHUB_WORKSPACE/.github/scripts/jira_sites.py\" summary \"$RES_JSON\" || echo \"unavailable\")\n if [ -n \"$RETRIED\" ]; then\n DIAG=\"authentication works, and the scoped-token endpoint https://api.atlassian.com/ex/jira/$CLOUD_ID was tried too (HTTP $CODE there) — $KEY is likely not visible to this account or does not exist (JIRA returns 404 rather than 403 for unviewable issues). Token can reach: $SITES\"\n elif [ -n \"$CLOUD_ID\" ]; then\n DIAG=\"authentication works and JIRA_BASE_URL is already well-formed ($BASE_FORM) — $KEY is likely not visible to this account or does not exist (JIRA returns 404 rather than 403 for unviewable issues). Token can reach: $SITES\"\n else\n DIAG=\"authentication works (accessible-resources returned 200) but the configured JIRA_BASE_URL ($BASE_FORM) matches none of the token's sites. Scoped API tokens must target https://api.atlassian.com/ex/jira/ — set JIRA_BASE_URL to exactly that (no trailing slash, no /rest suffix). Token can reach: $SITES\"\n fi ;;\n 000) DIAG=\"could not reach api.atlassian.com to validate the credentials (connection failed) — runner egress may be blocked\" ;;\n *) DIAG=\"accessible-resources probe returned unexpected HTTP $PROBE — the token may be of a type this workflow does not recognise\" ;;\n esac\n no_ticket \"JIRA returned HTTP $ORIG_CODE for $KEY at the configured base URL. Diagnosis: $DIAG.\"\n fi\n echo \"recovered: issue fetched via the scoped-token endpoint — consider setting JIRA_BASE_URL to https://api.atlassian.com/ex/jira/$CLOUD_ID\"\nfi\nCCODE=$(curl -sS -o \"$COMMENTS_JSON\" -w '%{http_code}' --max-time 30 \\\n -u \"$JIRA_EMAIL:$JIRA_API_TOKEN\" -H 'Accept: application/json' \\\n \"$BASE/rest/api/3/issue/$KEY/comment\") || CCODE=000\n[ \"$CCODE\" = \"200\" ] || printf '{\"comments\":[]}' > \"$COMMENTS_JSON\"\n# Render the ADF (Atlassian Document Format) JSON into readable markdown, using the\n# renderer from the TRUSTED BASE checkout. Imperfect rendering is fine; a failed render\n# is not — fall back to the marker.\npython3 \"$GITHUB_WORKSPACE/.github/scripts/render_jira_ticket.py\" \"$KEY\" \"$ISSUE_JSON\" \"$COMMENTS_JSON\" > \"$OUT\" \\\n || no_ticket \"failed to render the JIRA response for $KEY\"\necho \"jira-ticket.md written for $KEY\"\n" - name: Check out the care-sql-code-review skill (pinned) uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 (source v7) with: diff --git a/.github/workflows/analytics-review.md b/.github/workflows/analytics-review.md index ba1bf9a..9b36968 100644 --- a/.github/workflows/analytics-review.md +++ b/.github/workflows/analytics-review.md @@ -88,7 +88,16 @@ checkout: # Every one of them is best-effort: a missing secret, an unreachable JIRA, or a broken file must # degrade into an explanatory marker the agent can read — never into a failed job. steps: + # The resolved identity is written THREE ways because each has a different consumer: + # - $GITHUB_ENV → the later pre-steps (JIRA fetch reads AW_HEAD_REF, lint reads all). + # - $GITHUB_OUTPUT → anything that later wants `steps.resolve_pr.outputs.*` in THIS job. + # - run-context.md → THE AGENT. Step env does not reach the agent's sandbox, and the + # prompt is rendered in a separate activation job, so `${{ steps.* }}` interpolation into + # the prompt body cannot work either. On workflow_dispatch the event payload carries no PR + # object at all — dry-run 32015295640 noop'd ("no PR/issue number in context") for exactly + # that reason. The context file is the one channel proven to reach the agent. - name: Resolve PR context (number, head ref, head SHA) + id: resolve_pr env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Event-derived values enter the shell via env — never interpolated with ${{ }} inside @@ -98,6 +107,7 @@ steps: EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} INPUT_PR_NUMBER: ${{ github.event.inputs.pr_number }} REPO: ${{ github.repository }} + EVENT_NAME: ${{ github.event_name }} run: | set -euo pipefail mkdir -p /tmp/gh-aw/context @@ -119,6 +129,26 @@ steps: echo "AW_HEAD_REF=$HEAD_REF" echo "AW_HEAD_SHA=$HEAD_SHA" } >> "$GITHUB_ENV" + { + echo "pr_number=$PR_NUMBER" + echo "head_ref=$HEAD_REF" + echo "head_sha=$HEAD_SHA" + } >> "$GITHUB_OUTPUT" + # The agent's source of truth for WHICH PR it is reviewing. Head ref is + # author-controlled text, but git forbids whitespace/control characters in ref + # names, so these single-line writes cannot be broken out of. + { + echo "# Run context (resolved by a deterministic pre-step — trust this over the event payload)" + echo + echo "- Triggering event: $EVENT_NAME" + if [ -n "$PR_NUMBER" ]; then + echo "- PR under review: #$PR_NUMBER" + echo "- Head ref (PR branch name): $HEAD_REF" + echo "- Head SHA: $HEAD_SHA" + else + echo "- PR under review: NONE RESOLVED — the event payload contained no PR or issue number and no pr_number dispatch input was given. There is nothing to review." + fi + } > /tmp/gh-aw/context/run-context.md echo "PR=#${PR_NUMBER:-none} head=${HEAD_REF:-?}@${HEAD_SHA:-?}" # ------------------------------------------------------------------------------------------ @@ -129,6 +159,19 @@ steps: # The three JIRA secrets are referenced ONLY in this step's env. They are never exposed to # the agent: the agent reads the *rendered markdown file*, not the credentials. Keep it that # way — an LLM with a live credential in env is one prompt-injection away from leaking it. + # + # Atlassian has TWO kinds of API tokens with DIFFERENT base URLs (see "Manage API tokens for + # your Atlassian account" in Atlassian's docs): + # - unscoped tokens → https://.atlassian.net + # - scoped tokens → https://api.atlassian.com/ex/jira/ + # A scoped token sent to the *.atlassian.net form gets HTTP 404 — same status JIRA uses for + # a missing/unviewable issue, so it is silently misleading. This step therefore (a) retries + # via the scoped-token endpoint when it can resolve the cloudId, making either token type + # work with either JIRA_BASE_URL form, and (b) on hard failure writes a self-diagnosing + # marker. The diagnostic probe is /oauth/token/accessible-resources, which works for BOTH + # token types. Do NOT probe /rest/api/3/myself (granular read:issue scopes are not allowed + # to call it — it false-reports valid credentials as broken) and do NOT use + # /_edgeAuth/tenantInfo for cloudId discovery (retired endpoint). # ------------------------------------------------------------------------------------------ - name: Fetch JIRA ticket context env: @@ -154,14 +197,63 @@ steps: no_ticket "ticket $KEY detected in branch name, but the JIRA_BASE_URL / JIRA_EMAIL / JIRA_API_TOKEN repo secrets are not configured" fi JIRA_BASE_URL="${JIRA_BASE_URL%/}" + # Classify the configured base URL's shape for diagnostics. Hosts only — never values. + case "$JIRA_BASE_URL" in + https://api.atlassian.com/ex/jira/*) BASE_FORM="host api.atlassian.com — the scoped-token form" ;; + https://*.atlassian.net*) BASE_FORM="a *.atlassian.net host — works only with UNSCOPED tokens" ;; + *) BASE_FORM="a host that is neither api.atlassian.com nor *.atlassian.net" ;; + esac ISSUE_JSON=$(mktemp); COMMENTS_JSON=$(mktemp) - CODE=$(curl -sS -o "$ISSUE_JSON" -w '%{http_code}' --max-time 30 \ - -u "$JIRA_EMAIL:$JIRA_API_TOKEN" -H 'Accept: application/json' \ - "$JIRA_BASE_URL/rest/api/3/issue/$KEY?fields=summary,description,labels,status") || CODE=000 - [ "$CODE" = "200" ] || no_ticket "JIRA returned HTTP $CODE for $KEY (ticket may not exist, or credentials may be wrong)" + fetch_issue() { + CODE=$(curl -sS -o "$ISSUE_JSON" -w '%{http_code}' --max-time 30 \ + -u "$JIRA_EMAIL:$JIRA_API_TOKEN" -H 'Accept: application/json' \ + "$1/rest/api/3/issue/$KEY?fields=summary,description,labels,status") || CODE=000 + } + BASE="$JIRA_BASE_URL" + fetch_issue "$BASE" + if [ "$CODE" != "200" ]; then + ORIG_CODE=$CODE + # Ask Atlassian which sites this credential can reach. Works for scoped AND + # unscoped tokens, and each returned entry's `id` is the cloudId needed for the + # scoped-token endpoint — one call both diagnoses and enables auto-recovery. + RES_JSON=$(mktemp) + PROBE=$(curl -sS -o "$RES_JSON" -w '%{http_code}' --max-time 15 \ + -u "$JIRA_EMAIL:$JIRA_API_TOKEN" -H 'Accept: application/json' \ + "https://api.atlassian.com/oauth/token/accessible-resources") || PROBE=000 + CLOUD_ID=""; RETRIED="" + if [ "$PROBE" = "200" ]; then + CLOUD_ID=$(python3 "$GITHUB_WORKSPACE/.github/scripts/jira_sites.py" cloud-id "$RES_JSON" "$BASE" || true) + if [ -n "$CLOUD_ID" ] && [ "$BASE" != "https://api.atlassian.com/ex/jira/$CLOUD_ID" ]; then + BASE="https://api.atlassian.com/ex/jira/$CLOUD_ID" + RETRIED=1 + echo "issue fetch got HTTP $ORIG_CODE at the configured base URL ($BASE_FORM); retrying via the scoped-token endpoint" + fetch_issue "$BASE" + fi + fi + if [ "$CODE" != "200" ]; then + # Only HTTP codes, site URLs, and cloudIds appear below — never credential values. + case "$PROBE" in + 401|403) + DIAG="JIRA credentials rejected (accessible-resources probe returned HTTP $PROBE) — check that JIRA_EMAIL matches the account that owns JIRA_API_TOKEN, and that the token has not expired (Atlassian scoped tokens expire within 365 days)" ;; + 200) + SITES=$(python3 "$GITHUB_WORKSPACE/.github/scripts/jira_sites.py" summary "$RES_JSON" || echo "unavailable") + if [ -n "$RETRIED" ]; then + DIAG="authentication works, and the scoped-token endpoint https://api.atlassian.com/ex/jira/$CLOUD_ID was tried too (HTTP $CODE there) — $KEY is likely not visible to this account or does not exist (JIRA returns 404 rather than 403 for unviewable issues). Token can reach: $SITES" + elif [ -n "$CLOUD_ID" ]; then + DIAG="authentication works and JIRA_BASE_URL is already well-formed ($BASE_FORM) — $KEY is likely not visible to this account or does not exist (JIRA returns 404 rather than 403 for unviewable issues). Token can reach: $SITES" + else + DIAG="authentication works (accessible-resources returned 200) but the configured JIRA_BASE_URL ($BASE_FORM) matches none of the token's sites. Scoped API tokens must target https://api.atlassian.com/ex/jira/ — set JIRA_BASE_URL to exactly that (no trailing slash, no /rest suffix). Token can reach: $SITES" + fi ;; + 000) DIAG="could not reach api.atlassian.com to validate the credentials (connection failed) — runner egress may be blocked" ;; + *) DIAG="accessible-resources probe returned unexpected HTTP $PROBE — the token may be of a type this workflow does not recognise" ;; + esac + no_ticket "JIRA returned HTTP $ORIG_CODE for $KEY at the configured base URL. Diagnosis: $DIAG." + fi + echo "recovered: issue fetched via the scoped-token endpoint — consider setting JIRA_BASE_URL to https://api.atlassian.com/ex/jira/$CLOUD_ID" + fi CCODE=$(curl -sS -o "$COMMENTS_JSON" -w '%{http_code}' --max-time 30 \ -u "$JIRA_EMAIL:$JIRA_API_TOKEN" -H 'Accept: application/json' \ - "$JIRA_BASE_URL/rest/api/3/issue/$KEY/comment") || CCODE=000 + "$BASE/rest/api/3/issue/$KEY/comment") || CCODE=000 [ "$CCODE" = "200" ] || printf '{"comments":[]}' > "$COMMENTS_JSON" # Render the ADF (Atlassian Document Format) JSON into readable markdown, using the # renderer from the TRUSTED BASE checkout. Imperfect rendering is fine; a failed render @@ -285,9 +377,14 @@ layout. To see this PR's own content — including whether a past finding was fi **at the head SHA via the GitHub API**. Confusing the two is what produces a false "this was fixed": you read the old file and saw the old code. -**Your prepared context.** Deterministic pre-steps already ran and left three inputs for you. -Read the first two **before** reviewing anything: +**Your prepared context.** Deterministic pre-steps already ran and left four inputs for you. +Read the first three **before** reviewing anything: +- `/tmp/gh-aw/context/run-context.md` — **which PR you are reviewing**: the PR number, head ref, + and head SHA a pre-step resolved from the trigger. This is your source of truth for PR + identity. Do **not** infer the PR from the event payload: on `workflow_dispatch` the payload + carries no PR object at all (the PR arrives via the `pr_number` dispatch input, and only this + file reflects it). - `/tmp/gh-aw/context/jira-ticket.md` — the JIRA requirement behind this PR (branch name = ticket ID in this repo), or a `NO TICKET FOUND: ` marker. This is what Lens 1 reviews against. - `/tmp/gh-aw/context/lint-report.md` — deterministic sqlglot parse results and TEMPLATE.md @@ -313,6 +410,11 @@ Use that exact prefix every time; it is how a human finds your review among othe ## First: decide what kind of run this is +Start from `/tmp/gh-aw/context/run-context.md` — it names the PR under review. If it resolves +**no** PR number, there is genuinely nothing to review: call `noop` with that reason. If it names +a PR, review that PR by the rules below regardless of the triggering event — a manual +`workflow_dispatch` with a resolved PR number is a normal review, not a special case. + - **No prior comments from you** → *first review*. Review the full PR diff. - **Prior comments exist, triggered by a push (`synchronize`) or any other PR event** (`reopened`, `ready_for_review`, a manual dispatch) → *re-review*. Review **only what changed since your last @@ -329,15 +431,17 @@ what you already said, and the commit history tells you what has landed since. repo. - **Do read the repository** to check conventions and precedents — how sibling queries document the same table, what `TEMPLATE.md` requires, where a domain's files live. -- **Skip entirely** (call `noop` with the reason) when: the delta since your last review is empty, - or the diff touches no query docs and no SQL (e.g. README-only) and there is nothing your lenses - apply to. (Draft PRs never reach you — they are filtered at the trigger.) +- **Skip entirely** (call `noop` with the reason) when: the run context resolves no PR number, the + delta since your last review is empty, or the diff touches no query docs and no SQL (e.g. + README-only) and there is nothing your lenses apply to. (Draft PRs never reach you — they are + filtered at the trigger.) - If you have already posted **6 or more** review rounds on this PR, post nothing further unless a human @-mentions you. A reviewer that will not stop is noise, and every round costs credits. ## Reviewing -1. Read `/tmp/gh-aw/context/jira-ticket.md` and `/tmp/gh-aw/context/lint-report.md`, then the skill +1. Read `/tmp/gh-aw/context/run-context.md` (the PR under review), then + `/tmp/gh-aw/context/jira-ticket.md` and `/tmp/gh-aw/context/lint-report.md`, then the skill files under `/tmp/gh-aw/skills/care-sql-code-review/`. 2. Fetch the PR's changed files and diff via the GitHub API. For a re-review, diff against the head SHA you last commented on rather than the base — you are looking for what is *new*. diff --git a/README.md b/README.md index 02c8d8e..eabc762 100644 --- a/README.md +++ b/README.md @@ -50,5 +50,5 @@ Every pull request is reviewed automatically by an agentic workflow **Talking to it:** reply to any of its inline comments, or @-mention it anywhere on the PR, and it will answer. It stops re-reviewing after 6 rounds per PR unless summoned with an @-mention. To silence it for a PR, mark the PR as draft. **Prerequisites (admin setup):** -- Repo secrets `JIRA_BASE_URL`, `JIRA_EMAIL`, `JIRA_API_TOKEN` — for fetching ticket context. Without them the bot still runs, minus the requirement-fidelity lens. +- Repo secrets `JIRA_BASE_URL`, `JIRA_EMAIL`, `JIRA_API_TOKEN` — for fetching ticket context. For Atlassian API tokens **with scopes**, `JIRA_BASE_URL` must be `https://api.atlassian.com/ex/jira/`; only unscoped tokens work against `https://.atlassian.net`. (The workflow auto-recovers from a mismatched base URL when it can resolve the cloudId, and otherwise writes a self-diagnosing note into the review context.) Without these secrets the bot still runs, minus the requirement-fidelity lens. - Org-level `COPILOT_GITHUB_TOKEN` — the Copilot engine credential already used by [care_fe's reviewer](https://github.com/ohcnetwork/care_fe/blob/develop/.github/workflows/care-review.md).