diff --git a/.github/workflows/primer-api-review.lock.yml b/.github/workflows/primer-api-review.lock.yml index 6bd7a406874..309dae9b211 100644 --- a/.github/workflows/primer-api-review.lock.yml +++ b/.github/workflows/primer-api-review.lock.yml @@ -1,5 +1,5 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"52efa6af926e1af676d737798ec49d1d15da528f2613da1467779d0974b37fe4","body_hash":"cc63dce1a77a407e7e3568c748edc9570742f51b2f3070dfd76951a1d3b100b4","compiler_version":"v0.88.2","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.80"}} -# gh-aw-manifest: {"version":1,"secrets":["GH_AW_DEFAULT_OTLP_HEADERS","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"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/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"9271a1804551c0dc4fb0085a97979950aa2f8489","version":"v0.88.2"}],"skills":[".github/skills/style-guide"],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.28.12","digest":"sha256:390051be4ed1847f774fd8980b61d3a3523574c0175d00c3fc7cdf2002a88202","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.28.12@sha256:390051be4ed1847f774fd8980b61d3a3523574c0175d00c3fc7cdf2002a88202"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.12","digest":"sha256:d7d533d87c80d87ff91ac0e21e9299055c3beedff1536262b97ed700fb065a32","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.12@sha256:d7d533d87c80d87ff91ac0e21e9299055c3beedff1536262b97ed700fb065a32"},{"image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.28.12","digest":"sha256:5250629d48eaedfedf2e948785228e8da29eec2a83cbab58ea0751c14a7b021d","pinned_image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.28.12@sha256:5250629d48eaedfedf2e948785228e8da29eec2a83cbab58ea0751c14a7b021d"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.28.12","digest":"sha256:52c34aca98d2a6833c329f1505912a6949c4fda16618c010c979bd59ea99254f","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.28.12@sha256:52c34aca98d2a6833c329f1505912a6949c4fda16618c010c979bd59ea99254f"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.15","digest":"sha256:60cd97533e93d8e7be36b979c0f08a70846189bda6190f28bbd6d427bc0d9b6e","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.15@sha256:60cd97533e93d8e7be36b979c0f08a70846189bda6190f28bbd6d427bc0d9b6e"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:bac2192f6374d6262116399b34fc5e143d576f82719e90a18261cae7480f4d4e","pinned_image":"ghcr.io/github/gh-aw-node@sha256:bac2192f6374d6262116399b34fc5e143d576f82719e90a18261cae7480f4d4e"},{"image":"ghcr.io/github/github-mcp-server:v1.11.0","digest":"sha256:fbec75de11c255213fa08d80fb166abe73d851fff631c51c0079872967720699","pinned_image":"ghcr.io/github/github-mcp-server:v1.11.0@sha256:fbec75de11c255213fa08d80fb166abe73d851fff631c51c0079872967720699"}],"mcp_servers":[{"name":"safeoutputs","tools":["create_issue","missing_data","missing_tool","noop","update_issue"]}]} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"149c1b91ae1410c0e9cd931a0773a390482432f4a0b17bac7d1e4cf7b4353a5f","body_hash":"d851a45338e8ee3368c9dfec3e1d7b5d9c7ac3c41008817b4b7b040858c53907","compiler_version":"v0.88.2","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.80"}} +# gh-aw-manifest: {"version":1,"secrets":["GH_AW_DEFAULT_OTLP_HEADERS","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"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/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"9271a1804551c0dc4fb0085a97979950aa2f8489","version":"v0.88.2"}],"skills":[".github/skills/style-guide"],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.28.12","digest":"sha256:390051be4ed1847f774fd8980b61d3a3523574c0175d00c3fc7cdf2002a88202","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.28.12@sha256:390051be4ed1847f774fd8980b61d3a3523574c0175d00c3fc7cdf2002a88202"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.12","digest":"sha256:d7d533d87c80d87ff91ac0e21e9299055c3beedff1536262b97ed700fb065a32","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.12@sha256:d7d533d87c80d87ff91ac0e21e9299055c3beedff1536262b97ed700fb065a32"},{"image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.28.12","digest":"sha256:5250629d48eaedfedf2e948785228e8da29eec2a83cbab58ea0751c14a7b021d","pinned_image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.28.12@sha256:5250629d48eaedfedf2e948785228e8da29eec2a83cbab58ea0751c14a7b021d"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.28.12","digest":"sha256:52c34aca98d2a6833c329f1505912a6949c4fda16618c010c979bd59ea99254f","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.28.12@sha256:52c34aca98d2a6833c329f1505912a6949c4fda16618c010c979bd59ea99254f"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.15","digest":"sha256:60cd97533e93d8e7be36b979c0f08a70846189bda6190f28bbd6d427bc0d9b6e","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.15@sha256:60cd97533e93d8e7be36b979c0f08a70846189bda6190f28bbd6d427bc0d9b6e"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:bac2192f6374d6262116399b34fc5e143d576f82719e90a18261cae7480f4d4e","pinned_image":"ghcr.io/github/gh-aw-node@sha256:bac2192f6374d6262116399b34fc5e143d576f82719e90a18261cae7480f4d4e"},{"image":"ghcr.io/github/github-mcp-server:v1.11.0","digest":"sha256:fbec75de11c255213fa08d80fb166abe73d851fff631c51c0079872967720699","pinned_image":"ghcr.io/github/github-mcp-server:v1.11.0@sha256:fbec75de11c255213fa08d80fb166abe73d851fff631c51c0079872967720699"}],"mcp_servers":[{"name":"safeoutputs","tools":["create_issue","missing_data","missing_tool","noop","publish_component_findings","update_issue"]}]} # This file was automatically generated by gh-aw (v0.88.2). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # # ___ _ _ @@ -298,7 +298,7 @@ jobs: GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} GH_AW_PROMPT_CONTENT_0000: "\n" - GH_AW_PROMPT_CONTENT_0001: "\nTools: create_issue, update_issue, missing_tool, missing_data, noop\n" + GH_AW_PROMPT_CONTENT_0001: "\nTools: create_issue, update_issue, missing_tool, missing_data, noop, publish_component_findings\n" GH_AW_PROMPT_CONTENT_0002: "\n" GH_AW_PROMPT_CONTENT_0003: "\nThe following GitHub context information is available for this workflow:\n{{#if github.actor}}\n- **actor**: __GH_AW_GITHUB_ACTOR__\n{{/if}}\n{{#if github.repository}}\n- **repository**: __GH_AW_GITHUB_REPOSITORY__\n{{/if}}\n{{#if github.workspace}}\n- **workspace**: __GH_AW_GITHUB_WORKSPACE__\n{{/if}}\n{{#if github.event.issue.number || (github.aw.context.item_type == 'issue' && github.aw.context.item_number)}}\n- **issue-number**: #__GH_AW_EXPR_802A9F6A__\n{{/if}}\n{{#if github.event.discussion.number || (github.aw.context.item_type == 'discussion' && github.aw.context.item_number)}}\n- **discussion-number**: #__GH_AW_EXPR_1A3A194A__\n{{/if}}\n{{#if github.event.pull_request.number || (github.aw.context.item_type == 'pull_request' && github.aw.context.item_number)}}\n- **pull-request-number**: #__GH_AW_EXPR_463A214A__\n{{/if}}\n{{#if github.event.comment.id || github.aw.context.comment_id}}\n- **comment-id**: __GH_AW_EXPR_FF1D34CE__\n{{/if}}\n{{#if github.run_id}}\n- **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__\n{{/if}}\n\n\n" GH_AW_PROMPT_CONTENT_0004: "\n" @@ -564,7 +564,7 @@ jobs: env: GH_AW_FILE_ROOT: "${{ runner.temp }}/gh-aw" GH_AW_FILE_CONFIG: "{\"files\":[{\"path\":\"safeoutputs/config.json\",\"content_env\":\"GH_AW_SAFE_OUTPUTS_CONFIG\"}]}" - GH_AW_SAFE_OUTPUTS_CONFIG: "{\"create_issue\":{\"deduplicate_by_title\":true,\"max\":1},\"create_report_incomplete_issue\":{},\"mentions\":{\"enabled\":false},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"false\"},\"report_incomplete\":{},\"update_issue\":{\"allow_body\":true,\"max\":1,\"required_title_prefix\":\"Primer API Review\",\"target\":\"*\"}}" + GH_AW_SAFE_OUTPUTS_CONFIG: "{\"create_issue\":{\"deduplicate_by_title\":true,\"footer\":false,\"max\":1},\"create_report_incomplete_issue\":{},\"mentions\":{\"enabled\":false},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"false\"},\"publish_component_findings\":{\"description\":\"Publish one batch of up to 100 component finding comments and resolve their checklist links\",\"inputs\":{\"comments\":{\"default\":null,\"description\":\"Base64-encoded UTF-8 JSON array of objects with component (name) and body (complete finding comment), at most 100 unique components\",\"required\":true,\"type\":\"string\"},\"issue_number\":{\"default\":null,\"description\":\"Review issue number, or aw_review for the issue created in this run\",\"required\":true,\"type\":\"string\"}}},\"report_incomplete\":{},\"update_issue\":{\"allow_body\":true,\"footer\":false,\"max\":1,\"required_title_prefix\":\"Primer API Review\",\"target\":\"*\"}}" with: script: | const path = require('path'); @@ -582,7 +582,30 @@ jobs: "update_issue": " CONSTRAINTS: Maximum 1 issue(s) can be updated. Target: *. The target issue title must start with \"Primer API Review\"." }, "repo_params": {}, - "dynamic_tools": [] + "dynamic_tools": [ + { + "description": "Publish one batch of up to 100 component finding comments and resolve their checklist links", + "inputSchema": { + "additionalProperties": false, + "properties": { + "comments": { + "description": "Base64-encoded UTF-8 JSON array of objects with component (name) and body (complete finding comment), at most 100 unique components", + "type": "string" + }, + "issue_number": { + "description": "Review issue number, or aw_review for the issue created in this run", + "type": "string" + } + }, + "required": [ + "comments", + "issue_number" + ], + "type": "object" + }, + "name": "publish_component_findings" + } + ] } GH_AW_VALIDATION_JSON: | { @@ -1819,6 +1842,91 @@ jobs: GH_HOST="${GITHUB_SERVER_URL#https://}" GH_HOST="${GH_HOST#http://}" echo "GH_HOST=${GH_HOST}" >> "$GITHUB_ENV" + - name: Configure Safe Output Scripts + run: | + cat > "${RUNNER_TEMP}/gh-aw/actions/safe_output_script_publish_component_findings.cjs" << 'GH_AW_SAFE_OUTPUT_SCRIPT_PUBLISH_COMPONENT_FINDINGS_75faeab9d5765245_EOF' + // @ts-check + /// + // Auto-generated safe-output script handler: publish-component-findings + + const { sanitizeContent } = require("./sanitize_content.cjs"); + + /** @type {import('./types/safe-output-script').SafeOutputScriptMain} */ + async function main(config = {}) { + const { comments, issue_number } = config; + return async function handlePublishComponentFindings(item, resolvedTemporaryIds, temporaryIdMap) { + const {matchesWorkflowId, generateWorkflowIdMarker} = require('./generate_footer.cjs') + const repo = context.repo + const resolved = resolvedTemporaryIds[item.issue_number] + const issueNumber = Number(resolved ? resolved.number : item.issue_number) + const staged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === 'true' + const pendingStagedIssue = staged && !resolved && item.issue_number === 'aw_review' + if (resolved && resolved.repo !== `${repo.owner}/${repo.repo}`) { + throw new Error('Cross-repository review targets are not allowed') + } + if ((!pendingStagedIssue && (!Number.isSafeInteger(issueNumber) || issueNumber <= 0)) || + typeof item.comments !== 'string' || item.comments.length > 500000) { + throw new Error('Invalid component review payload') + } + const decoded = Buffer.from(item.comments, 'base64') + if (decoded.toString('base64') !== item.comments) { + throw new Error('Invalid base64 component review payload') + } + const entries = JSON.parse(decoded.toString('utf8')) + if (!Array.isArray(entries) || entries.length === 0 || entries.length > 100) { + throw new Error('Batch must contain between 1 and 100 component comments') + } + const components = new Set() + const batch = entries.map(entry => { + if (!entry || typeof entry.component !== 'string' || + !/^[A-Za-z][A-Za-z0-9.]*$/.test(entry.component) || components.has(entry.component) || + typeof entry.body !== 'string' || entry.body.length < 20 || entry.body.length > 60000) { + throw new Error('Invalid or duplicate component review entry') + } + components.add(entry.component) + return {component: entry.component, body: sanitizeContent(entry.body)} + }) + if (staged) { + core.info(`Would publish ${batch.length} component comments on issue ${item.issue_number}`) + return {success: true, staged: true} + } + const {data: issue} = await github.rest.issues.get({...repo, issue_number: issueNumber}) + if (issue.pull_request || issue.title !== 'Primer API Review') { + throw new Error('Target must be the Primer API Review issue') + } + const comments = await github.paginate(github.rest.issues.listComments, { + ...repo, issue_number: issueNumber, per_page: 100, + }) + let updatedBody = issue.body || '' + for (const entry of batch) { + const marker = `` + const previous = comments.find(comment => + comment.user?.login === 'github-actions[bot]' && + matchesWorkflowId(comment.body || '', 'primer-api-review') && + (comment.body || '').includes(marker), + ) + const body = `${entry.body}\n\n${marker}\n${generateWorkflowIdMarker('primer-api-review')}` + let comment = previous + if (!previous || previous.body !== body) { + const result = previous + ? await github.rest.issues.updateComment({...repo, comment_id: previous.id, body}) + : await github.rest.issues.createComment({...repo, issue_number: issueNumber, body}) + comment = result.data + } + updatedBody = updatedBody.replaceAll( + `(#api-review-comment-${entry.component})`, `(${comment.html_url})`, + ) + } + if (updatedBody !== issue.body) { + await github.rest.issues.update({...repo, issue_number: issueNumber, body: updatedBody}) + } + return {success: true, url: issue.html_url} + + }; + } + module.exports = { main }; + + GH_AW_SAFE_OUTPUT_SCRIPT_PUBLISH_COMPONENT_FINDINGS_75faeab9d5765245_EOF - name: Process Safe Outputs id: process_safe_outputs uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 @@ -1828,7 +1936,8 @@ jobs: GH_AW_ALLOWED_DOMAINS: "api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,s.symcb.com,s.symcd.com,security.ubuntu.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com" GITHUB_SERVER_URL: ${{ github.server_url }} GITHUB_API_URL: ${{ github.api_url }} - GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"create_issue\":{\"deduplicate_by_title\":true,\"max\":1},\"create_report_incomplete_issue\":{},\"mentions\":{\"enabled\":false},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"false\"},\"report_incomplete\":{},\"update_issue\":{\"allow_body\":true,\"max\":1,\"required_title_prefix\":\"Primer API Review\",\"target\":\"*\"}}" + GH_AW_SAFE_OUTPUT_SCRIPTS: "{\"publish_component_findings\":\"safe_output_script_publish_component_findings.cjs\"}" + GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"create_issue\":{\"deduplicate_by_title\":true,\"footer\":false,\"max\":1},\"create_report_incomplete_issue\":{},\"mentions\":{\"enabled\":false},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"false\"},\"report_incomplete\":{},\"update_issue\":{\"allow_body\":true,\"footer\":false,\"max\":1,\"required_title_prefix\":\"Primer API Review\",\"target\":\"*\"}}" with: github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | diff --git a/.github/workflows/primer-api-review.md b/.github/workflows/primer-api-review.md index 43b86fa2e84..ddc396425e0 100644 --- a/.github/workflows/primer-api-review.md +++ b/.github/workflows/primer-api-review.md @@ -49,12 +49,92 @@ skills: - .github/skills/style-guide safe-outputs: mentions: false + footer: false create-issue: deduplicate-by-title: true max: 1 update-issue: target: '*' required-title-prefix: 'Primer API Review' + scripts: + publish-component-findings: + description: Publish one batch of up to 100 component finding comments and resolve their checklist links + inputs: + issue_number: + type: string + required: true + description: Review issue number, or aw_review for the issue created in this run + comments: + type: string + required: true + description: Base64-encoded UTF-8 JSON array of objects with component (name) and body (complete finding comment), at most 100 unique components + script: | + const {matchesWorkflowId, generateWorkflowIdMarker} = require('./generate_footer.cjs') + const repo = context.repo + const resolved = resolvedTemporaryIds[item.issue_number] + const issueNumber = Number(resolved ? resolved.number : item.issue_number) + const staged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === 'true' + const pendingStagedIssue = staged && !resolved && item.issue_number === 'aw_review' + if (resolved && resolved.repo !== `${repo.owner}/${repo.repo}`) { + throw new Error('Cross-repository review targets are not allowed') + } + if ((!pendingStagedIssue && (!Number.isSafeInteger(issueNumber) || issueNumber <= 0)) || + typeof item.comments !== 'string' || item.comments.length > 500000) { + throw new Error('Invalid component review payload') + } + const decoded = Buffer.from(item.comments, 'base64') + if (decoded.toString('base64') !== item.comments) { + throw new Error('Invalid base64 component review payload') + } + const entries = JSON.parse(decoded.toString('utf8')) + if (!Array.isArray(entries) || entries.length === 0 || entries.length > 100) { + throw new Error('Batch must contain between 1 and 100 component comments') + } + const components = new Set() + const batch = entries.map(entry => { + if (!entry || typeof entry.component !== 'string' || + !/^[A-Za-z][A-Za-z0-9.]*$/.test(entry.component) || components.has(entry.component) || + typeof entry.body !== 'string' || entry.body.length < 20 || entry.body.length > 60000) { + throw new Error('Invalid or duplicate component review entry') + } + components.add(entry.component) + return {component: entry.component, body: sanitizeContent(entry.body)} + }) + if (staged) { + core.info(`Would publish ${batch.length} component comments on issue ${item.issue_number}`) + return {success: true, staged: true} + } + const {data: issue} = await github.rest.issues.get({...repo, issue_number: issueNumber}) + if (issue.pull_request || issue.title !== 'Primer API Review') { + throw new Error('Target must be the Primer API Review issue') + } + const comments = await github.paginate(github.rest.issues.listComments, { + ...repo, issue_number: issueNumber, per_page: 100, + }) + let updatedBody = issue.body || '' + for (const entry of batch) { + const marker = `` + const previous = comments.find(comment => + comment.user?.login === 'github-actions[bot]' && + matchesWorkflowId(comment.body || '', 'primer-api-review') && + (comment.body || '').includes(marker), + ) + const body = `${entry.body}\n\n${marker}\n${generateWorkflowIdMarker('primer-api-review')}` + let comment = previous + if (!previous || previous.body !== body) { + const result = previous + ? await github.rest.issues.updateComment({...repo, comment_id: previous.id, body}) + : await github.rest.issues.createComment({...repo, issue_number: issueNumber, body}) + comment = result.data + } + updatedBody = updatedBody.replaceAll( + `(#api-review-comment-${entry.component})`, `(${comment.html_url})`, + ) + } + if (updatedBody !== issue.body) { + await github.rest.issues.update({...repo, issue_number: issueNumber, body: updatedBody}) + } + return {success: true, url: issue.html_url} --- # Primer API Review @@ -65,70 +145,208 @@ unresolved component API deviations from the Primer React style guide. ## Review process 1. Read and apply the installed `style-guide` skill, including - `contributor-docs/style.md` and its component prop-naming guidance. + `contributor-docs/style.md` and its component prop-naming guidance. Build a + checklist of every principle from both documents, using their headings as + stable identifiers. Include rest-parameter placement, intentional shared-prop + merging (`mergeProps`), hooks accepting instead of returning refs, hide/show + naming and durable defaults, and mutually exclusive booleans, as well as + callback signatures, boolean state names, and variant/size semantics. These + examples are not the complete checklist. 2. Read `/tmp/gh-aw/data/components.json`. Review every listed component directory. Cross-check the package's public exports and add any exported - component that is missing from the inventory. Partition the complete list - into batches of no more than 10 directories and delegate each batch to the - `component-api-auditor` agent. Do not skip a component because it is + component that is missing from the inventory. Include compound subcomponents, + re-exported types, and relevant hooks, even when their implementation lives + outside the component directory. Do not skip a component because it is deprecated, experimental, or complex. -3. Require evidence for every finding: +3. Track a coverage matrix for every component and checklist principle. Each cell + starts as `not-reviewed`; change it to `pass`, `finding`, or `not-applicable` + only after inspection. + Record inspected file paths and line ranges for passes and findings, and a + source-backed reason for each not-applicable judgment. A component is fully + reviewed only when no cell remains `not-reviewed`. Read the existing issue's + remaining-coverage list and prioritize those pairs so successive runs do not + repeat only the same callback, boolean-name, and variant/size checks. Prior + coverage is historical context, not proof of a pass against current source. +4. Partition the inventory into batches of no more than five directories. + Delegate one pilot batch to `component-api-auditor` with the exact component + list, principle checklist, and coverage contract below. Validate its returned + coverage before dispatching the remaining batches: wait for the pilot result, + not just an agent ID or idle status. Keep delegation one level deep and at + most two batches in flight. + - Treat empty, malformed, errored, or findings-only responses as missing + coverage, not as evidence of no deviations. A bare `none` is insufficient. + - Accept only source-backed cells for assigned components and principles; + retain valid partial results and leave omitted or unsupported cells + `not-reviewed`. + - Retry missing coverage once per batch with a smaller assignment (one + component). Inspect the rest of that batch directly. After two consecutive + unusable responses, stop dispatching sub-agents for this run and switch to + direct review of all remaining cells. Do not repeat the same failed fan-out. + For a model/pricing or authentication error, skip the retry and switch to + direct inspection immediately; a smaller assignment cannot fix that error. + - On any unresolved delegation failure, inspect the missing coverage directly + using the same checklist and evidence requirements. Read the relevant + source, types, render paths, and hooks for every component/principle pair. + Grep-based sweeps are navigation aids, not proof of full coverage or of the + absence of a deviation. + - Continue direct inspection in bounded batches while time and context allow, + reserving time to publish the verified results and remaining coverage. Do + not stop solely because the entire inventory is too large for one run. +5. Require evidence for every finding: - identify the component and public API - cite the exact style-guide principle - cite repository file paths and line numbers - describe the smallest consumer-facing API change that would resolve it -4. Read `/tmp/gh-aw/data/existing-review.json`. When a prior finding appears in - the existing issue, inspect the issue comments for a clear, substantive +6. Read `/tmp/gh-aw/data/existing-review.json` and all comments on that issue, + paginating if needed. Findings may be in the legacy issue body or in managed + component comments. When a prior finding appears, inspect the other issue + comments for a clear, substantive explanation of why that API intentionally exists. If a comment is tied to that finding and provides a reason, omit the finding entirely. Do not treat - an acknowledgement, question, or unrelated comment as a reason. -5. Merge duplicate findings and discard anything speculative, stylistic but not + an acknowledgement, question, unrelated comment, or the workflow's own + finding/recommendation text as a reason. +7. Merge duplicate findings and discard anything speculative, stylistic but not covered by the guide, or unsupported by source evidence. +8. Reconcile the coverage matrix against the complete inventory and checklist + before writing the issue. Publish useful, evidence-backed progress even if + some cells remain `not-reviewed`; label the audit partial and list the + remaining component/principle pairs. Merge verified findings with the existing + issue, retaining prior findings not rechecked and labeling them as such. Remove + a prior finding only when current source disproves it or an issue comment + supplies the documented rationale described above. Never claim full coverage + based on the number of dispatched batches. ## Issue output -Build a complete replacement body using GitHub-flavored Markdown: +Build the issue body as an overview, not a dump of findings. Use GitHub-flavored +Markdown and start sections at `###`: -- Start sections at `###`. -- Include a short summary with the review date and number of components reviewed. -- Group findings by style-guide principle. -- For each finding, include the component/API, evidence, impact, and recommended - change. -- If there are no unresolved findings, state that the full review found no - unexplained deviations. -- Include the workflow run as +- Limit the summary to at most two sentences, including the review date, fully + reviewed component count out of the inventory, and whether the audit is partial + or complete. +- Under `### Proposed API changes`, include an unchecked task-list item for each + proposed API change, naming the component/API and linking to its finding + comment. Use the finding heading as the link text when a component has multiple + findings. Do not check a proposal merely because it was reviewed. +- Keep evidence, impact, and recommendation details in the component comments, + not in the overview checklist. +- Put run details and the coverage table inside + `
Run details and coverage`. Include counts of `pass`, `finding`, + `not-applicable`, and `not-reviewed` components for this run. Each row must + account for the entire inventory; retained historical findings do not count + as reviewed cells. + State whether sub-agents, direct inspection, or both supplied the evidence, + including any failed batches and recovery performed. +- For a partial audit, put the remaining-coverage list inside + `
Remaining coverage`, grouped by principle + with component names and the next bounded batch to inspect. Prioritize gaps + from the previous run that remain unreviewed before newly introduced gaps. +- Put retained findings not rechecked in this run behind + `
Past findings not rechecked`, preserving their + comment links and unresolved status rather than silently dropping them. +- State that the full review found no unexplained deviations only when coverage + is complete and there are no unresolved findings. When a partial audit has no + new findings, state that no new deviations were found in the inspected subset + without implying the unreviewed APIs passed. +- Inside the run-details disclosure, include the workflow run as `[§${{ github.run_id }}](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})`. - Do not include findings that have a documented rationale in issue comments. +- Close every disclosure with `
` and leave blank lines around its + Markdown content so GitHub renders tables and lists correctly. - Do not append an unbounded run history or copy comment discussions into the - issue body. + issue body or component comments. + +### Component finding comments + +Maintain one managed comment per component with findings, grouping its findings +under separate `####` headings below `### ComponentName`. Every finding must +belong to that component, identify its public API and style-guide principle, and +use a table with `Evidence`, `Impact`, and `Recommendation` columns. Evidence +must link to the source file and line range and the exact style-guide principle; +recommendations must describe the proposed consumer-facing API change. + +Keep active findings visible. Place retained findings not rechecked this run in +a `
Past findings not rechecked` block within the +component comment. When source inspection resolves a previously published +finding, move a brief status and its evidence into +`
Resolved findings` rather than presenting it as an +active recommendation. Omit findings with a documented rationale as described +above. Never modify human comments or publish comments for components with no +current or previously published findings. + +Use one `publish_component_findings` call with `comments` containing a +base64-encoded UTF-8 JSON array of objects, each containing `component` and its +complete comment `body`. Use +`Buffer.from(JSON.stringify(comments), 'utf8').toString('base64')` or an equivalent +encoder; do not hand-escape Markdown. This keeps ingestion's Markdown sanitizer +from corrupting the JSON transport; each decoded comment is still sanitized +before publication. Keep the encoded batch under 500,000 characters. +The safe-output +handler maintains the component marker, reuses the managed +comment, skips unchanged content, and inserts its real URL into the checklist. +Do not supply comment IDs or invent comment URLs. For a new or updated component +comment, use `(#api-review-comment-ComponentName)` as the checklist link target. +Use actual existing comment URLs for unchanged comments. Multiple findings for +one component may link to the same comment, with distinct finding labels. If `/tmp/gh-aw/data/existing-review.json` contains an issue: -- update that issue's body with `update_issue` +- first queue that issue's complete replacement overview body with `update_issue` + and `operation: replace` (never append a second checklist) - keep the title exactly `Primer API Review` - reopen it if it is closed -Otherwise, create one issue with `create_issue`, the exact title -`Primer API Review`, and the generated body. +Otherwise, first queue one issue with `create_issue`, the exact title +`Primer API Review`, `temporary_id: aw_review`, and the overview body. -Perform exactly one visible issue action per run. Never create a second review -issue when an exact-title issue exists. Use `noop` with a short reason only when -the review cannot be completed well enough to produce a trustworthy issue body. +Then queue exactly one `publish_component_findings` call containing all new or +changed component comments (at most one entry per component and 100 per batch), +using the existing issue number as a string or `aw_review` for the newly created +issue. Include any component whose checklist links use placeholders, even if +its comment is unchanged. Skip the call when there are no comments to publish. +The ingestion layer allows only one publishing output per run: never emit one +call per component or split the array across calls. Safe outputs execute after +the agent finishes, so do not try to read back newly queued comments during this +run. Queue the overview before the batch publisher; do not queue another overview update afterward that would +overwrite resolved links. If the publication cap is reached, retain existing +links and list unpublished components in the collapsed remaining-coverage block +without emitting dangling placeholders. + +Never create a second review issue when an exact-title issue exists. Use `noop` +with a short reason only when +no trustworthy progress can be published (for example, source or prior issue +data is unavailable and no safe update is possible). Incomplete coverage or +failed delegation alone is not a reason to use `noop`: verified existing +findings, new findings, or newly completed coverage can support a partial update. ## agent: `component-api-auditor` --- description: Audits a bounded batch of Primer React component APIs against the style guide -model: small +model: claude-sonnet-5 --- -Review only the assigned component directories. Read the installed `style-guide` -skill, `contributor-docs/style.md`, and relevant public types, exports, tests, -stories, and documentation for each component. +Review only the assigned components and principles, following their relevant +hooks, compound subcomponents, and re-exported types outside the assigned +directories as needed. Read the installed `style-guide` skill, +`contributor-docs/style.md`, its component prop-naming guidance, and relevant +source, public types, exports, tests, stories, and documentation for each +component. Do not delegate further. + +Return a compact structured report with `coverage` and `findings` sections, even +when no deviations are found. For every assigned component/principle pair, +include a coverage entry with: + +- component and principle heading +- status: `pass`, `finding`, `not-applicable`, or `not-reviewed` +- inspected file paths and line ranges, plus a brief explanation supporting the + status (including why a principle is not applicable) +- a blocker when the status is `not-reviewed` -Return compact structured findings. Each finding must contain: +Mark a pair `not-reviewed` when source inspection is incomplete; never infer a +pass from a search with no matches. Each finding must contain: - component and public API - violated style-guide principle @@ -136,6 +354,6 @@ Return compact structured findings. Each finding must contain: - consumer impact - smallest recommended API change -Report `none` for a component when no evidence-backed deviation exists. Do not -infer requirements that are absent from the style guide, and do not propose code -changes. +Return an empty `findings` list when no evidence-backed deviation exists, but +still return the coverage entries. Do not infer requirements that are absent +from the style guide, and do not propose code changes. diff --git a/script/primer-api-review.test.mjs b/script/primer-api-review.test.mjs new file mode 100644 index 00000000000..f056b74c4ff --- /dev/null +++ b/script/primer-api-review.test.mjs @@ -0,0 +1,277 @@ +/* eslint camelcase: ["error", {allow: ["pull_request", "html_url", "issue_number", "aw_review"]}] */ +import assert from 'node:assert/strict' +import {mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs' +import {execFileSync} from 'node:child_process' +import {tmpdir} from 'node:os' +import {join} from 'node:path' +import {test} from 'node:test' + +const workflow = readFileSync(new URL('../.github/workflows/primer-api-review.md', import.meta.url), 'utf8') +const script = workflow + .match(/ {6}script: \|\n([\s\S]*?)\n---/)[1] + .split('\n') + .map(line => line.slice(8)) + .join('\n') +const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor +const publish = new AsyncFunction( + 'item', + 'resolvedTemporaryIds', + 'config', + 'context', + 'github', + 'core', + 'process', + 'require', + 'sanitizeContent', + script, +) +const workflowMarker = '' +const componentMarker = '' +const commentUrl = 'https://github.com/primer/react/issues/8384#issuecomment-123' +const commentBody = '### Button\n\nEvidence-backed API findings' +const encodeComments = entries => Buffer.from(JSON.stringify(entries), 'utf8').toString('base64') + +function setup({comments = [], title = 'Primer API Review', pullRequest, staged = false} = {}) { + const calls = [] + const issue = { + title, + pull_request: pullRequest, + body: '- [ ] [Button API change](#api-review-comment-Button)', + } + const config = {} + const issues = { + get: async () => ({data: issue}), + listComments: () => {}, + createComment: async args => { + calls.push(['createComment', args]) + const comment = { + id: 123 + comments.length, + html_url: commentUrl.replace('123', String(123 + comments.length)), + user: {login: 'github-actions[bot]'}, + body: args.body, + } + comments.push(comment) + return {data: comment} + }, + updateComment: async args => { + calls.push(['updateComment', args]) + const comment = comments.find(value => value.id === args.comment_id) + comment.body = args.body + return {data: comment} + }, + update: async args => { + calls.push(['update', args]) + issue.body = args.body + }, + } + return { + calls, + issue, + config, + run: (item = {}, resolved = {}) => + publish( + {issue_number: '8384', comments: encodeComments([{component: 'Button', body: commentBody}]), ...item}, + resolved, + config, + {repo: {owner: 'primer', repo: 'react'}}, + {rest: {issues}, paginate: async () => comments}, + {info: () => {}}, + {env: {GH_AW_SAFE_OUTPUTS_STAGED: String(staged)}}, + () => ({ + matchesWorkflowId: body => body.includes(workflowMarker), + generateWorkflowIdMarker: () => workflowMarker, + }), + body => `sanitized:${body}`, + ), + } +} + +test('creates a sanitized component comment and replaces every matching checklist link', async () => { + const fixture = setup() + fixture.issue.body += '\n- [ ] [Another finding](#api-review-comment-Button)' + await fixture.run() + assert.deepEqual( + fixture.calls.map(([name]) => name), + ['createComment', 'update'], + ) + assert.equal(fixture.calls[0][1].body, `sanitized:${commentBody}\n\n${componentMarker}\n${workflowMarker}`) + assert.equal(fixture.issue.body.split(commentUrl).length - 1, 2) + await fixture.run() + assert.equal(fixture.calls.length, 2, 'unchanged reruns must not create comments or rewrite the issue') +}) + +test('updates only its own managed component comment', async () => { + const comments = [ + {id: 1, user: {login: 'maintainer'}, body: `${componentMarker}\n${workflowMarker}`}, + {id: 2, user: {login: 'github-actions[bot]'}, body: componentMarker}, + {id: 3, user: {login: 'github-actions[bot]'}, body: `${componentMarker}\n${workflowMarker}`, html_url: commentUrl}, + ] + const fixture = setup({comments}) + await fixture.run() + assert.equal(fixture.calls[0][0], 'updateComment') + assert.equal(fixture.calls[0][1].comment_id, 3) + assert.equal(comments[0].body, `${componentMarker}\n${workflowMarker}`) + assert.equal(comments[1].body, componentMarker) +}) + +test('resolves a newly created issue temporary ID in this repository', async () => { + const fixture = setup() + await fixture.run({issue_number: 'aw_review'}, {aw_review: {repo: 'primer/react', number: 8384}}) + assert.equal(fixture.calls[0][1].issue_number, 8384) +}) + +test('rejects invalid targets and payloads without writing', async () => { + for (const options of [{title: 'Another issue'}, {pullRequest: {url: 'pr'}}]) { + const fixture = setup(options) + await assert.rejects(fixture.run(), /Target must be/) + assert.equal(fixture.calls.length, 0) + } + for (const item of [ + {issue_number: '-1'}, + {comments: undefined}, + {comments: 'x'.repeat(500001)}, + ...[{component: '../Button'}, {component: undefined}, {body: ''}, null].map(entry => ({ + comments: encodeComments([entry]), + })), + ]) { + const fixture = setup() + await assert.rejects(fixture.run(item), /Invalid.*component review/) + assert.equal(fixture.calls.length, 0) + } + const fixture = setup() + await assert.rejects( + fixture.run({issue_number: 'aw_review'}, {aw_review: {repo: 'primer/other', number: 8384}}), + /Cross-repository/, + ) + assert.equal(fixture.calls.length, 0) +}) + +test('respects staged mode and validates the whole bounded batch before writing', async () => { + const fixture = setup({staged: true}) + await fixture.run() + await fixture.run({issue_number: 'aw_review'}) + assert.equal(fixture.calls.length, 0) + assert.equal(fixture.calls.length, 0) + for (const entries of [ + [], + {}, + Array.from({length: 101}, (_, index) => ({component: `Component${index}`, body: commentBody})), + [ + {component: 'Button', body: commentBody}, + {component: 'Button', body: commentBody}, + ], + [ + {component: 'Button', body: commentBody}, + {component: 'Dialog', body: ''}, + ], + ]) { + const invalid = setup() + await assert.rejects(invalid.run({comments: encodeComments(entries)})) + assert.equal(invalid.calls.length, 0) + } + await assert.rejects(fixture.run({comments: '[invalid base64'}), /Invalid base64/) + await assert.rejects(fixture.run({comments: Buffer.from('[invalid JSON').toString('base64')}), SyntaxError) +}) + +test('one batch publishes all component comments and preserves their checklist links', async () => { + const fixture = setup() + fixture.issue.body += '\n- [ ] [Dialog change](#api-review-comment-Dialog)' + const item = { + comments: encodeComments([ + {component: 'Button', body: commentBody}, + {component: 'Dialog', body: '### Dialog\n\nEvidence-backed API findings'}, + ]), + } + await fixture.run(item) + assert.equal(fixture.issue.body.includes('#api-review-comment-'), false) + assert.equal( + fixture.issue.body, + `- [ ] [Button API change](${commentUrl})\n- [ ] [Dialog change](${commentUrl.replace('123', '124')})`, + ) + assert.equal(fixture.calls.filter(([name]) => name === 'createComment').length, 2) + assert.equal(fixture.calls.filter(([name]) => name === 'update').length, 1) + await fixture.run(item) + assert.equal(fixture.calls.length, 3, 'reruns reuse every comment in the batch') +}) + +test('overview instructions replace instead of append and precede comment publication', () => { + assert.match(workflow, /`update_issue`\n\s+and `operation: replace`/) + assert.match(workflow, /Queue the overview before the batch publisher/) + assert.match(workflow, /exactly one `publish_component_findings` call/) + assert.match(workflow, /at most two sentences/) +}) + +// Set GH_AW_ACTIONS_DIR to actions/setup/js from the lock file's pinned gh-aw release. +test( + 'pinned gh-aw ingestion accepts all 19 comments in one output, not 19 outputs', + { + skip: !process.env.GH_AW_ACTIONS_DIR, + }, + async () => { + const directory = mkdtempSync(join(tmpdir(), 'api-review-ingestion-')) + try { + const lock = readFileSync(new URL('../.github/workflows/primer-api-review.lock.yml', import.meta.url), 'utf8') + const config = JSON.parse(JSON.parse(lock.match(/GH_AW_SAFE_OUTPUTS_CONFIG: (.+)/)[1])) + writeFileSync(join(directory, 'config.json'), JSON.stringify(config)) + const entries = Array.from({length: 19}, (_, index) => ({ + component: `Component${index}`, + body: `### Component${index}\n\n| Evidence | Impact | Recommendation |\n| --- | --- | --- |\n| [Source](https://github.com/primer/react/blob/main/file.ts#L1) | "quoted" and "fullwidth" text | Use \`size\` |\n\n
Past findings\n\nSee https://example.com/documentation for more context. No past findings.\n\n
`, + })) + const message = {type: 'publish_component_findings', issue_number: '8384', comments: encodeComments(entries)} + const ingest = messages => { + writeFileSync(join(directory, 'outputs.jsonl'), messages.map(value => JSON.stringify(value)).join('\n')) + return JSON.parse( + execFileSync( + process.execPath, + [ + '-e', + ` + const path = require('node:path'); + const runtime = process.env.GH_AW_ACTIONS_DIR; + require(path.join(runtime, 'constants.cjs')).TMP_GH_AW_PATH = process.env.TEST_DIRECTORY; + global.core = { + info() {}, warning() {}, error() {}, debug() {}, exportVariable() {}, + setFailed(message) { throw new Error(message); }, + setOutput(name, value) { if (name === 'output') process.stdout.write(value); }, + }; + global.context = {repo: {owner: 'primer', repo: 'react'}, payload: {}}; + global.github = {}; + require(path.join(runtime, 'collect_ndjson_output.cjs')).main().catch(error => { + console.error(error); + process.exitCode = 1; + }); + `, + ], + { + encoding: 'utf8', + env: { + ...process.env, + TEST_DIRECTORY: directory, + RUNNER_TEMP: directory, + GH_AW_SAFE_OUTPUTS: join(directory, 'outputs.jsonl'), + GH_AW_SAFE_OUTPUTS_CONFIG_PATH: join(directory, 'config.json'), + GH_AW_VALIDATION_CONFIG_PATH: join(directory, 'missing-validation.json'), + GH_AW_VALIDATION_CONFIG: '', + }, + }, + ), + ) + } + const rejected = ingest(entries.map(entry => ({...message, comments: encodeComments([entry])}))) + assert.equal(rejected.items.length, 1) + assert.equal(rejected.errors.length, 18) + assert.match(rejected.errors[0], /Maximum allowed: 1/) + const accepted = ingest([message]) + assert.deepEqual(accepted.errors, []) + assert.equal(accepted.items.length, 1) + assert.deepEqual(JSON.parse(Buffer.from(accepted.items[0].comments, 'base64').toString('utf8')), entries) + const fixture = setup() + fixture.issue.body = entries.map(entry => `- [ ] [Finding](#api-review-comment-${entry.component})`).join('\n') + await fixture.run(accepted.items[0]) + assert.equal(fixture.calls.filter(([name]) => name === 'createComment').length, 19) + assert.equal(fixture.issue.body.includes('#api-review-comment-'), false) + } finally { + rmSync(directory, {recursive: true, force: true}) + } + }, +)