diff --git a/.github/scripts/issue-version-triage.cjs b/.github/scripts/issue-version-triage.cjs index 3e0655ef0..71f2a50cc 100644 --- a/.github/scripts/issue-version-triage.cjs +++ b/.github/scripts/issue-version-triage.cjs @@ -9,6 +9,8 @@ const BUG_LABEL = "bug"; const DOCS_LABEL = "documentation"; const ENHANCEMENT_LABEL = "enhancement"; const MAINTAINER_AUTHOR_ASSOCIATIONS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]); +const RETEST_COMMENT_GRACE_MS = 5 * 60 * 1000; +const RETEST_COMMENT_LOOKBACK_MS = 24 * 60 * 60 * 1000; function escapeRegExp(value) { return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); @@ -297,6 +299,45 @@ async function syncLabels({ github, context, core }) { }); } +async function postRetestCommentForIssue({ + github, + context, + core, + issue, + latestVersion, +}) { + const { reportedVersion, isBugLike, comparison } = buildTriageState( + issue, + core, + latestVersion + ); + + if (!isBugLike) { + core.info("Issue is not bug-like after classification. Skipping public retest guidance."); + return false; + } + if (!reportedVersion) { + core.info("Issue is missing Pulse version metadata. Skipping public retest guidance."); + return false; + } + if (comparison === null || comparison >= 0) { + core.info("Issue is already on the latest stable core or newer. Skipping public retest guidance."); + return false; + } + if (await hasRetestComment(github, context, issue.number)) { + core.info("Retest guidance comment already exists."); + return false; + } + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + body: buildRetestCommentBody(reportedVersion, latestVersion), + }); + return true; +} + async function postRetestComment({ github, context, core }) { const issue = context.payload.issue; const action = context.payload.action || ""; @@ -306,38 +347,67 @@ async function postRetestComment({ github, context, core }) { } const latestVersion = await getLatestStableVersion(github, context, core); - const { reportedVersion, isBugLike, comparison } = buildTriageState( - issue, + await postRetestCommentForIssue({ + github, + context, core, - latestVersion - ); - - if (!isBugLike) { - core.info("Issue is not bug-like after classification. Skipping public retest guidance."); - return; - } - if (!reportedVersion) { - core.info("Issue is missing Pulse version metadata. Skipping public retest guidance."); - return; - } - if (comparison === null || comparison >= 0) { - core.info("Issue is already on the latest stable core or newer. Skipping public retest guidance."); - return; - } - if (await hasRetestComment(github, context, issue.number)) { - core.info("Retest guidance comment already exists."); - return; - } - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - body: buildRetestCommentBody(reportedVersion, latestVersion), + issue, + latestVersion, }); } +async function postEligibleRetestComments({ + github, + context, + core, + nowMs = Date.now(), + graceMs = RETEST_COMMENT_GRACE_MS, + lookbackMs = RETEST_COMMENT_LOOKBACK_MS, +}) { + const issues = await github.paginate(github.rest.issues.listForRepo, { + owner: context.repo.owner, + repo: context.repo.repo, + state: "open", + sort: "created", + direction: "desc", + since: new Date(nowMs - lookbackMs).toISOString(), + per_page: 100, + }); + const latestVersion = await getLatestStableVersion(github, context, core); + let eligibleCount = 0; + let postedCount = 0; + + for (const issue of issues) { + if (issue.pull_request || !canPostRetestComment(issue, "opened")) continue; + + const createdMs = new Date(issue.created_at || "").getTime(); + if (!Number.isFinite(createdMs)) { + core.warning(`Issue #${issue.number} has an invalid created_at value.`); + continue; + } + + const ageMs = nowMs - createdMs; + if (ageMs < graceMs || ageMs > lookbackMs) continue; + eligibleCount += 1; + + const posted = await postRetestCommentForIssue({ + github, + context, + core, + issue, + latestVersion, + }); + if (posted) postedCount += 1; + } + + core.info( + `Retest guidance sweep complete: eligible=${eligibleCount}, posted=${postedCount}.` + ); + return { eligibleCount, postedCount }; +} + module.exports = { + postEligibleRetestComments, syncLabels, postRetestComment, internals: { @@ -347,6 +417,8 @@ module.exports = { ENHANCEMENT_LABEL, NEEDS_VERSION_LABEL, RETEST_COMMENT_MARKER, + RETEST_COMMENT_GRACE_MS, + RETEST_COMMENT_LOOKBACK_MS, RETEST_LABEL, TRIAGE_FOOTER, VERSION_LABEL_PREFIX, diff --git a/.github/scripts/issue-version-triage.test.cjs b/.github/scripts/issue-version-triage.test.cjs index 32a384d4e..4cebda620 100644 --- a/.github/scripts/issue-version-triage.test.cjs +++ b/.github/scripts/issue-version-triage.test.cjs @@ -7,6 +7,7 @@ function createGithub({ latestVersion = "6.0.1", existingLabels = new Set(), existingComments = [], + issues = [], } = {}) { const calls = { createComment: [], @@ -42,6 +43,7 @@ function createGithub({ calls.createComment.push(payload); return { data: payload }; }, + listForRepo: Symbol("listForRepo"), listComments: Symbol("listComments"), }, repos: { @@ -51,9 +53,9 @@ function createGithub({ }, }, }, - async paginate() { - calls.paginate.push(true); - return existingComments; + async paginate(endpoint) { + calls.paginate.push(endpoint); + return endpoint === github.rest.issues.listForRepo ? issues : existingComments; }, }; @@ -152,6 +154,69 @@ test("postRetestComment comments once for older non-maintainer bug reports", asy ); }); +test("scheduled retest guidance waits five minutes and reads the current issue", async () => { + const nowMs = Date.parse("2026-08-26T09:06:00Z"); + const issues = [ + { + number: 1780, + title: "Agent token scope", + body: "## Feedback type\nBug / regression\n\n## Pulse version\n6.3.2\n", + labels: [{ name: "bug" }], + author_association: "NONE", + created_at: "2026-08-26T09:01:51Z", + }, + ]; + const { github, calls } = createGithub({ latestVersion: "6.3.2", issues }); + + const result = await triage.postEligibleRetestComments({ + github, + context: createContext({ issue: null }), + core: createCore(), + nowMs, + }); + + assert.deepEqual(result, { eligibleCount: 0, postedCount: 0 }); + assert.equal(calls.createComment.length, 0); + + const laterResult = await triage.postEligibleRetestComments({ + github, + context: createContext({ issue: null }), + core: createCore(), + nowMs: Date.parse("2026-08-26T09:07:00Z"), + }); + + assert.deepEqual(laterResult, { eligibleCount: 1, postedCount: 0 }); + assert.equal(calls.createComment.length, 0); +}); + +test("scheduled retest guidance posts once after the grace window", async () => { + const issues = [ + { + number: 1200, + title: "Upgrade regression", + body: "## Feedback type\nRegression\n\n## Pulse version\n5.1.9\n", + labels: [{ name: "bug" }], + author_association: "NONE", + created_at: "2026-08-26T08:55:00Z", + }, + ]; + const { github, calls } = createGithub({ latestVersion: "6.3.2", issues }); + + const result = await triage.postEligibleRetestComments({ + github, + context: createContext({ issue: null }), + core: createCore(), + nowMs: Date.parse("2026-08-26T09:01:00Z"), + }); + + assert.deepEqual(result, { eligibleCount: 1, postedCount: 1 }); + assert.equal(calls.createComment.length, 1); + assert.equal(calls.createComment[0].issue_number, 1200); + assert.ok( + calls.createComment[0].body.endsWith(`\n\n${triage.internals.TRIAGE_FOOTER}`) + ); +}); + test("timeout close comments use the canonical triage footer", () => { const { buildTimeoutCloseCommentBody, CLOSE_COMMENT_MARKER, TRIAGE_FOOTER } = triage.internals; diff --git a/.github/scripts/triage-comment-policy.test.cjs b/.github/scripts/triage-comment-policy.test.cjs index 251a7a462..79e6b9fe8 100644 --- a/.github/scripts/triage-comment-policy.test.cjs +++ b/.github/scripts/triage-comment-policy.test.cjs @@ -4,20 +4,21 @@ const fs = require("node:fs"); const path = require("node:path"); const workflowDir = path.resolve(__dirname, "../workflows"); -const commentPublisherPatterns = [ +const issueMutationPatterns = [ /github\.rest\.issues\.createComment/, /github\.rest\.pulls\.createReviewComment/, /addDiscussionComment/, - /postRetestComment/, + /post(?:Eligible)?RetestComments?/, + /syncLabels/, ]; -test("automated workflow comments use the dedicated triage identity", () => { +test("automated workflow issue mutations use the dedicated triage identity", () => { const publishers = []; for (const name of fs.readdirSync(workflowDir)) { if (!name.endsWith(".yml") && !name.endsWith(".yaml")) continue; const workflow = fs.readFileSync(path.join(workflowDir, name), "utf8"); - if (!commentPublisherPatterns.some((pattern) => pattern.test(workflow))) continue; + if (!issueMutationPatterns.some((pattern) => pattern.test(workflow))) continue; publishers.push(name); assert.match( @@ -39,6 +40,7 @@ test("automated workflow comments use the dedicated triage identity", () => { assert.deepEqual(publishers.sort(), [ "close-needs-retest-timeout.yml", + "issue-version-label-sync.yml", "issue-version-retest-comment.yml", ]); }); diff --git a/.github/workflows/issue-version-label-sync.yml b/.github/workflows/issue-version-label-sync.yml index 06c53a726..889a057db 100644 --- a/.github/workflows/issue-version-label-sync.yml +++ b/.github/workflows/issue-version-label-sync.yml @@ -9,13 +9,19 @@ on: permissions: contents: read - issues: write jobs: sync: if: ${{ github.event.issue.pull_request == null }} runs-on: ubuntu-24.04 steps: + - name: Mint triage bot token + id: triage-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: 2484142 + private-key: ${{ secrets.PULSE_TRIAGE_APP_PRIVATE_KEY }} + - name: Check out triage helper uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: @@ -26,6 +32,7 @@ jobs: - name: Sync issue version metadata uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 with: + github-token: ${{ steps.triage-token.outputs.token }} script: | const triage = require(`${process.env.GITHUB_WORKSPACE}/.github/scripts/issue-version-triage.cjs`); await triage.syncLabels({ github, context, core }); diff --git a/.github/workflows/issue-version-retest-comment.yml b/.github/workflows/issue-version-retest-comment.yml index 76b1d384c..54048907d 100644 --- a/.github/workflows/issue-version-retest-comment.yml +++ b/.github/workflows/issue-version-retest-comment.yml @@ -1,16 +1,19 @@ name: Issue Version Retest Comment on: - issues: - types: - - opened + schedule: + - cron: "*/5 * * * *" + workflow_dispatch: permissions: contents: read +concurrency: + group: issue-version-retest-comment + cancel-in-progress: false + jobs: comment: - if: ${{ github.event.issue.pull_request == null }} runs-on: ubuntu-24.04 steps: - name: Mint triage bot token @@ -33,4 +36,4 @@ jobs: github-token: ${{ steps.triage-token.outputs.token }} script: | const triage = require(`${process.env.GITHUB_WORKSPACE}/.github/scripts/issue-version-triage.cjs`); - await triage.postRetestComment({ github, context, core }); + await triage.postEligibleRetestComments({ github, context, core }); diff --git a/docs/AI_TRANSPARENCY.md b/docs/AI_TRANSPARENCY.md index a8c6f07ad..019a7269f 100644 --- a/docs/AI_TRANSPARENCY.md +++ b/docs/AI_TRANSPARENCY.md @@ -8,9 +8,10 @@ or issue thread. ## The short version Automation contributes to code, tests, documentation, release notes, issue -triage, and routine repository maintenance. A continuously running automated -maintainer may investigate reports, implement changes, run verification, and -land routine work within boundaries I define. +triage, and routine repository maintenance. Routine changes may be +investigated, implemented, tested, and merged to `main` without line-by-line +human review. The continuously running maintainer does this within boundaries +I define. I set the product direction, control releases, and remain responsible for everything that ships. I do not claim to have personally written every line. @@ -28,7 +29,11 @@ released builds still go through Pulse's release qualification process. and the evidence used to qualify a release. - **Issue triage and support.** Automated issue and discussion replies post under the dedicated `pulse-triage` bot identity and link back to this page. - Automated support replies are sent as Pulse Triage and link here as well. + Automated issue state changes use that identity as well. Automated support + replies are sent as Pulse Triage and link here as well. +- **Change provenance.** Commits made by the continuously running maintainer + carry a dedicated bot author and committer identity. Issue-driven changes + link back to the originating report where applicable. The link on an automated reply is intentionally understated. It makes the process discoverable without turning every technical exchange into a banner @@ -42,9 +47,9 @@ does not have unrestricted authority. Product direction, architecture, acceptable risk, capability boundaries, and release decisions remain mine. Release publication and other high-impact actions require explicit approval. -My role is to direct the project, design and maintain those boundaries, review -outcomes, and answer for the result. If an automated change is wrong, it is -still my bug and my responsibility to correct it. +My role is to direct the project, design and maintain those boundaries, +monitor outcomes, and answer for the result. If an automated change is wrong, +it is still my bug and my responsibility to correct it. ## How the work should be judged