From c7d3f661b09cd7a8e5442e9cfc917f986d00e95a Mon Sep 17 00:00:00 2001 From: Jeff Carpenter Date: Thu, 24 Sep 2026 18:25:44 +0000 Subject: [PATCH 1/6] Add reusable PR approved issue check workflow and unit tests --- .github/workflows/pr-approved-issue.yml | 41 +++ scripts/pr-approved-issue.js | 208 ++++++++++++++ scripts/pr-approved-issue.test.js | 364 ++++++++++++++++++++++++ 3 files changed, 613 insertions(+) create mode 100644 .github/workflows/pr-approved-issue.yml create mode 100644 scripts/pr-approved-issue.js create mode 100644 scripts/pr-approved-issue.test.js diff --git a/.github/workflows/pr-approved-issue.yml b/.github/workflows/pr-approved-issue.yml new file mode 100644 index 0000000..e4fffb5 --- /dev/null +++ b/.github/workflows/pr-approved-issue.yml @@ -0,0 +1,41 @@ +name: 'PR Approved Issue Check' +# Converts PRs from external contributors to draft if they're not the assignee +# of a linked issue. + +on: + workflow_call: + inputs: + ref: + description: 'Git ref (branch, tag, or commit SHA) of shared-workflows to check out' + required: false + type: string + default: 'main' + +permissions: + contents: write # Needed, with pull-requests: write, to toggle draft state. + issues: read + pull-requests: write + +jobs: + approved-issue: + # Bypass maintainers, collaborators and bots (e.g. dependabot). + if: >- + github.event.pull_request.user.type != 'Bot' && + !contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.pull_request.author_association) + runs-on: ubuntu-latest + steps: + - name: 'Check out shared workflows repo' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: 'keras-team/shared-workflows' + ref: '${{ inputs.ref }}' + persist-credentials: false + path: '.shared-workflows' + + - name: 'Check PR approved issue' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + github-token: '${{ secrets.GITHUB_TOKEN }}' + script: | + const prApprovedIssue = require('./.shared-workflows/scripts/pr-approved-issue.js'); + await prApprovedIssue({ github, context, core }); diff --git a/scripts/pr-approved-issue.js b/scripts/pr-approved-issue.js new file mode 100644 index 0000000..1affd10 --- /dev/null +++ b/scripts/pr-approved-issue.js @@ -0,0 +1,208 @@ +/** + * @license + * Copyright 2026 The Keras Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ============================================================================= + */ + +/** + * Enforce that external PRs link an approved issue assigned to the author. + * + * - On `opened`, the PR is converted to a draft and an "Approved issue link" + * section is added to the description if it is missing. + * - On every run, the description is scanned for issue references + * (`#123`, `owner/repo#123` or a full issue URL). The PR passes when at + * least one referenced issue exists in this repo and has the PR author as + * an assignee. + * - When the check passes, a draft PR is marked "Ready for review". When it + * fails and the PR is not a draft, it is converted to a draft. A single + * sticky comment is kept up to date with the result. + * + * Maintainers, collaborators and bots are skipped by the workflow `if:`. + */ + +const SECTION_HEADING = "## Approved issue link"; +const SECTION_TEMPLATE = `${SECTION_HEADING} + + +`; +const COMMENT_MARKER = ""; +const BYPASS_ASSOCIATIONS = ["OWNER", "MEMBER", "COLLABORATOR"]; + +module.exports = async function prApprovedIssue({ github, context, core }) { + const pr = context.payload.pull_request; + if (!pr) { + core.info("Not a pull request payload."); + return; + } + + const { owner, repo } = context.repo; + const action = context.payload.action; + const author = pr.user.login; + + // Defence in depth: the workflow `if:` already filters these out. + if (pr.user.type === "Bot" || BYPASS_ASSOCIATIONS.includes(pr.author_association)) { + core.info(`Skipping #${pr.number}: ${author} is ${pr.author_association}.`); + return; + } + + let body = pr.body || ""; + let isDraft = pr.draft; + + // 1. On open: make sure the description has the section and start as draft. + if (action === "opened") { + if (!body.includes(SECTION_HEADING)) { + body = SECTION_TEMPLATE + body; + await github.rest.pulls.update({ owner, repo, pull_number: pr.number, body }); + core.info(`Added "${SECTION_HEADING}" section to #${pr.number}.`); + } + if (!isDraft) { + isDraft = await convertToDraft(github, core, pr); + } + } + + // 2. Find issues referenced in the description that are assigned to the author. + const referenced = findIssueNumbers(body, owner, repo); + const assigned = []; + const notAssigned = []; + for (const number of referenced) { + try { + const { data: issue } = await github.rest.issues.get({ owner, repo, issue_number: number }); + if (issue.pull_request) continue; // A PR reference, not an issue. + const assignees = (issue.assignees || []).map((a) => a.login.toLowerCase()); + (assignees.includes(author.toLowerCase()) ? assigned : notAssigned).push(number); + } catch (err) { + core.info(`Could not fetch issue #${number}: ${err.message}`); + } + } + const passed = assigned.length > 0; + + // 3. Flip the draft state to match the result. + if (passed && isDraft) { + isDraft = await markReadyForReview(github, core, pr); + } else if (!passed && !isDraft) { + isDraft = await convertToDraft(github, core, pr); + } + + // 4. Report via a sticky comment and the job status. + const message = passed + ? [ + `✅ Approved issue check passed: ${assigned.map((n) => `#${n}`).join(", ")} ` + + `is assigned to @${author}.`, + isDraft + ? "Could not mark this PR as ready automatically; please mark it **Ready for review**." + : "This PR is **Ready for review**.", + ] + : [ + `❌ Approved issue check failed. This PR ${isDraft ? "stays" : "must stay"} in **draft** until ` + + "it links an approved issue that is assigned to you.", + "", + "To fix this:", + "1. Find or open an issue for this change and ask a maintainer to approve it and assign it to you.", + `2. Link it under the "${SECTION_HEADING.replace("## ", "")}" section of this PR's description, e.g. \`Fixes #123\`.`, + "", + "The PR will be marked **Ready for review** automatically once the check passes.", + "", + notAssigned.length + ? `Referenced issue(s) not assigned to @${author}: ${notAssigned.map((n) => `#${n}`).join(", ")}.` + : "No issue reference was found in the description.", + ]; + await upsertComment(github, owner, repo, pr.number, message.join("\n")); + + if (!passed) { + core.setFailed(`No approved issue assigned to ${author} is linked in #${pr.number}.`); + } +}; + +/** Collect issue numbers referenced in `body` that belong to this repo. */ +function findIssueNumbers(body, owner, repo) { + const escaped = `${owner}/${repo}`.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const patterns = [ + new RegExp(`https?://github\\.com/${escaped}/issues/(\\d+)`, "gi"), + new RegExp(`(?:^|[^\\w/])${escaped}#(\\d+)`, "gi"), + // Bare `#123`, but not `owner/repo#123` of some other repo. + /(?:^|[^\w/])#(\d+)\b/g, + ]; + const numbers = new Set(); + for (const pattern of patterns) { + for (const match of body.matchAll(pattern)) { + numbers.add(Number(match[1])); + } + } + return [...numbers]; +} + +async function convertToDraft(github, core, pr) { + try { + await github.graphql( + `mutation($id: ID!) { + convertPullRequestToDraft(input: {pullRequestId: $id}) { + pullRequest { isDraft } + } + }`, + { id: pr.node_id } + ); + core.info(`Converted #${pr.number} to draft.`); + return true; + } catch (err) { + warnDraftToggleFailed(core, `convert #${pr.number} to draft`, err); + return pr.draft; + } +} + +async function markReadyForReview(github, core, pr) { + try { + await github.graphql( + `mutation($id: ID!) { + markPullRequestReadyForReview(input: {pullRequestId: $id}) { + pullRequest { isDraft } + } + }`, + { id: pr.node_id } + ); + core.info(`Marked #${pr.number} as ready for review.`); + return false; + } catch (err) { + warnDraftToggleFailed(core, `mark #${pr.number} as ready for review`, err); + return true; + } +} + +function warnDraftToggleFailed(core, what, err) { + const lines = [`Could not ${what}: ${err.message}`]; + if (/not accessible by integration/i.test(err.message)) { + lines.push("Toggling draft state needs `contents: write` and `pull-requests: write`."); + } + core.warning(lines.join("\n")); +} + +async function upsertComment(github, owner, repo, issue_number, text) { + const body = `${COMMENT_MARKER}\n${text}`; + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number, + per_page: 100, + }); + const existing = comments.find((c) => c.body && c.body.startsWith(COMMENT_MARKER)); + if (existing) { + if (existing.body !== body) { + await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }); + } + } else { + await github.rest.issues.createComment({ owner, repo, issue_number, body }); + } +} diff --git a/scripts/pr-approved-issue.test.js b/scripts/pr-approved-issue.test.js new file mode 100644 index 0000000..5eaebb8 --- /dev/null +++ b/scripts/pr-approved-issue.test.js @@ -0,0 +1,364 @@ +/** + * @license + * Copyright 2026 The Keras Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ============================================================================= + */ + +const { describe, it } = require('node:test'); +const assert = require('node:assert'); +const prApprovedIssue = require('./pr-approved-issue.js'); + +describe('prApprovedIssue', () => { + function createHarness({ + action = 'opened', + pr = null, + issuesByNumber = {}, + existingComments = [], + graphqlError = null, + }) { + const infoMessages = []; + const warningMessages = []; + let failedMessage = null; + const updatedPrs = []; + const graphqlCalls = []; + const createdComments = []; + const updatedComments = []; + + const core = { + info: (msg) => infoMessages.push(msg), + warning: (msg) => warningMessages.push(msg), + setFailed: (msg) => { + failedMessage = msg; + }, + }; + + const github = { + rest: { + pulls: { + update: async (params) => { + updatedPrs.push(params); + }, + }, + issues: { + get: async ({ issue_number }) => { + if (!(issue_number in issuesByNumber)) { + throw new Error('Not Found'); + } + return { data: issuesByNumber[issue_number] }; + }, + listComments: async () => ({ data: existingComments }), + createComment: async (params) => { + createdComments.push(params); + }, + updateComment: async (params) => { + updatedComments.push(params); + }, + }, + }, + paginate: async (fn, params) => { + const res = await fn(params); + return res.data; + }, + graphql: async (query, variables) => { + graphqlCalls.push({ query, variables }); + if (graphqlError) { + throw graphqlError; + } + return {}; + }, + }; + + const context = { + repo: { owner: 'keras-team', repo: 'keras-hub' }, + payload: { + action, + pull_request: pr, + }, + }; + + return { + run: () => prApprovedIssue({ github, context, core }), + getInfoMessages: () => infoMessages, + getWarningMessages: () => warningMessages, + getFailedMessage: () => failedMessage, + getUpdatedPrs: () => updatedPrs, + getGraphqlCalls: () => graphqlCalls, + getCreatedComments: () => createdComments, + getUpdatedComments: () => updatedComments, + }; + } + + it('ignores non-pull-request payloads safely', async () => { + const harness = createHarness({ pr: null }); + await harness.run(); + assert.strictEqual(harness.getFailedMessage(), null); + assert.ok(harness.getInfoMessages().includes('Not a pull request payload.')); + }); + + it('skips bots and bypassed author associations (OWNER, MEMBER, COLLABORATOR)', async () => { + for (const [type, association] of [ + ['Bot', 'NONE'], + ['User', 'OWNER'], + ['User', 'MEMBER'], + ['User', 'COLLABORATOR'], + ]) { + const harness = createHarness({ + pr: { + number: 10, + node_id: 'PR_10', + draft: false, + author_association: association, + user: { login: 'maintainer-or-bot', type }, + body: 'No issue link needed', + }, + }); + await harness.run(); + assert.strictEqual(harness.getFailedMessage(), null); + assert.strictEqual(harness.getGraphqlCalls().length, 0); + assert.strictEqual(harness.getCreatedComments().length, 0); + } + }); + + it('on opened: prepends Approved issue link section, converts non-draft PR to draft, posts comment, and fails when no issue is linked', async () => { + const harness = createHarness({ + action: 'opened', + pr: { + number: 42, + node_id: 'PR_42', + draft: false, + author_association: 'NONE', + user: { login: 'external-dev', type: 'User' }, + body: 'Here is my new feature.', + }, + }); + + await harness.run(); + + // 1. Section added to PR description. + assert.strictEqual(harness.getUpdatedPrs().length, 1); + assert.ok(harness.getUpdatedPrs()[0].body.startsWith('## Approved issue link\n')); + assert.ok(harness.getUpdatedPrs()[0].body.endsWith('Here is my new feature.')); + + // 2. Converted to draft once on opened (and not called a second time). + assert.strictEqual(harness.getGraphqlCalls().length, 1); + assert.ok(harness.getGraphqlCalls()[0].query.includes('convertPullRequestToDraft')); + assert.deepStrictEqual(harness.getGraphqlCalls()[0].variables, { id: 'PR_42' }); + + // 3. Sticky comment created. + assert.strictEqual(harness.getCreatedComments().length, 1); + assert.ok(harness.getCreatedComments()[0].body.startsWith('')); + assert.ok(harness.getCreatedComments()[0].body.includes('No issue reference was found in the description.')); + + // 4. Workflow marked failed. + assert.strictEqual( + harness.getFailedMessage(), + 'No approved issue assigned to external-dev is linked in #42.' + ); + }); + + it('on opened when section already exists and PR is already draft: skips body update and draft conversion', async () => { + const harness = createHarness({ + action: 'opened', + pr: { + number: 43, + node_id: 'PR_43', + draft: true, + author_association: 'NONE', + user: { login: 'external-dev', type: 'User' }, + body: '## Approved issue link\n\nStill working on it.', + }, + }); + + await harness.run(); + + assert.strictEqual(harness.getUpdatedPrs().length, 0); + assert.strictEqual(harness.getGraphqlCalls().length, 0); + assert.notStrictEqual(harness.getFailedMessage(), null); + }); + + it('passes check and marks draft PR ready for review when an issue assigned to the author is referenced', async () => { + const harness = createHarness({ + action: 'edited', + pr: { + number: 42, + node_id: 'PR_42', + draft: true, + author_association: 'FIRST_TIME_CONTRIBUTOR', + user: { login: 'External-Dev', type: 'User' }, + body: '## Approved issue link\nFixes #123\nAlso see keras-team/keras-hub#124 and https://github.com/keras-team/keras-hub/issues/125', + }, + issuesByNumber: { + 123: { number: 123, assignees: [{ login: 'external-dev' }] }, + 124: { number: 124, assignees: [{ login: 'someone-else' }] }, + 125: { number: 125, assignees: [{ login: 'EXTERNAL-DEV' }] }, + }, + }); + + await harness.run(); + + // Marked ready for review via GraphQL. + assert.strictEqual(harness.getGraphqlCalls().length, 1); + assert.ok(harness.getGraphqlCalls()[0].query.includes('markPullRequestReadyForReview')); + + // Sticky comment reports passed issues #125, #123 (matched in pattern order: URL, owner/repo#, bare #). + assert.strictEqual(harness.getCreatedComments().length, 1); + const commentBody = harness.getCreatedComments()[0].body; + assert.ok(commentBody.includes('✅ Approved issue check passed: #125, #123 is assigned to @External-Dev.')); + assert.ok(commentBody.includes('This PR is **Ready for review**.')); + + // Check succeeded. + assert.strictEqual(harness.getFailedMessage(), null); + }); + + it('ignores references to other repositories and pull request numbers', async () => { + const harness = createHarness({ + action: 'edited', + pr: { + number: 50, + node_id: 'PR_50', + draft: true, + author_association: 'NONE', + user: { login: 'contributor', type: 'User' }, + body: 'Fixes other-org/other-repo#100 and https://github.com/other-org/other-repo/issues/101 and #200', + }, + issuesByNumber: { + 100: { number: 100, assignees: [{ login: 'contributor' }] }, + 101: { number: 101, assignees: [{ login: 'contributor' }] }, + // #200 is a PR, not an issue. + 200: { number: 200, pull_request: {}, assignees: [{ login: 'contributor' }] }, + }, + }); + + await harness.run(); + + assert.strictEqual(harness.getGraphqlCalls().length, 0); + assert.notStrictEqual(harness.getFailedMessage(), null); + assert.ok( + harness.getCreatedComments()[0].body.includes('No issue reference was found in the description.') + ); + }); + + it('converts non-draft PR to draft when edited to reference only unassigned issues', async () => { + const harness = createHarness({ + action: 'edited', + pr: { + number: 55, + node_id: 'PR_55', + draft: false, + author_association: 'CONTRIBUTOR', + user: { login: 'contributor', type: 'User' }, + body: '## Approved issue link\nFixes #300 and #999', + }, + issuesByNumber: { + 300: { number: 300, assignees: [{ login: 'other-user' }] }, + // #999 does not exist (throws Not Found). + }, + }); + + await harness.run(); + + assert.strictEqual(harness.getGraphqlCalls().length, 1); + assert.ok(harness.getGraphqlCalls()[0].query.includes('convertPullRequestToDraft')); + assert.ok( + harness + .getCreatedComments()[0] + .body.includes('Referenced issue(s) not assigned to @contributor: #300.') + ); + assert.notStrictEqual(harness.getFailedMessage(), null); + }); + + it('updates existing sticky comment when content changes and avoids API call when unchanged', async () => { + const existingBody = + '\n' + + '✅ Approved issue check passed: #123 is assigned to @contributor.\n' + + 'This PR is **Ready for review**.'; + + // 1. Unchanged comment body -> no create or update call. + const unchangedHarness = createHarness({ + action: 'edited', + pr: { + number: 60, + node_id: 'PR_60', + draft: false, + author_association: 'NONE', + user: { login: 'contributor', type: 'User' }, + body: 'Fixes #123', + }, + issuesByNumber: { + 123: { number: 123, assignees: [{ login: 'contributor' }] }, + }, + existingComments: [{ id: 777, body: existingBody }], + }); + + await unchangedHarness.run(); + assert.strictEqual(unchangedHarness.getCreatedComments().length, 0); + assert.strictEqual(unchangedHarness.getUpdatedComments().length, 0); + + // 2. Changed comment body -> updates existing comment. + const changedHarness = createHarness({ + action: 'edited', + pr: { + number: 60, + node_id: 'PR_60', + draft: false, + author_association: 'NONE', + user: { login: 'contributor', type: 'User' }, + body: 'Fixes #456', + }, + issuesByNumber: { + 456: { number: 456, assignees: [{ login: 'contributor' }] }, + }, + existingComments: [{ id: 777, body: existingBody }], + }); + + await changedHarness.run(); + assert.strictEqual(changedHarness.getCreatedComments().length, 0); + assert.strictEqual(changedHarness.getUpdatedComments().length, 1); + assert.strictEqual(changedHarness.getUpdatedComments()[0].comment_id, 777); + assert.ok(changedHarness.getUpdatedComments()[0].body.includes('#456')); + }); + + it('logs warning with permissions hint when GraphQL draft state toggle fails', async () => { + const harness = createHarness({ + action: 'edited', + pr: { + number: 70, + node_id: 'PR_70', + draft: true, + author_association: 'NONE', + user: { login: 'contributor', type: 'User' }, + body: 'Fixes #123', + }, + issuesByNumber: { + 123: { number: 123, assignees: [{ login: 'contributor' }] }, + }, + graphqlError: new Error('Resource not accessible by integration'), + }); + + await harness.run(); + + assert.strictEqual(harness.getWarningMessages().length, 1); + assert.ok( + harness + .getWarningMessages()[0] + .includes('Toggling draft state needs `contents: write` and `pull-requests: write`.') + ); + assert.ok( + harness + .getCreatedComments()[0] + .body.includes('Could not mark this PR as ready automatically; please mark it **Ready for review**.') + ); + }); +}); From a101136e6efcc48ffe0152218822a305d52448b7 Mon Sep 17 00:00:00 2001 From: Jeff Carpenter Date: Thu, 24 Sep 2026 20:50:18 +0000 Subject: [PATCH 2/6] Remove auto ready-for-review, strip HTML comments/RFC issue, and add docs/pr_policy.md --- .github/workflows/pr-approved-issue.yml | 7 +- docs/pr_policy.md | 49 +++++++++++++ scripts/pr-approved-issue.js | 98 ++++++++++++------------- scripts/pr-approved-issue.test.js | 91 ++++++++++++++--------- 4 files changed, 156 insertions(+), 89 deletions(-) create mode 100644 docs/pr_policy.md diff --git a/.github/workflows/pr-approved-issue.yml b/.github/workflows/pr-approved-issue.yml index e4fffb5..d06a7f2 100644 --- a/.github/workflows/pr-approved-issue.yml +++ b/.github/workflows/pr-approved-issue.yml @@ -1,6 +1,7 @@ -name: 'PR Approved Issue Check' -# Converts PRs from external contributors to draft if they're not the assignee -# of a linked issue. +name: 'PR Approved Issue Check (Reusable)' +# Every PR from an external contributor is converted to a draft if its +# description does not link an approved issue assigned to the PR author. +# Repository owners, org members, collaborators and bots bypass this check. on: workflow_call: diff --git a/docs/pr_policy.md b/docs/pr_policy.md new file mode 100644 index 0000000..236954a --- /dev/null +++ b/docs/pr_policy.md @@ -0,0 +1,49 @@ +# Keras Pull Request Policy + +To keep review bandwidth focused on high-leverage improvements and prevent duplicate work, external pull requests across `keras-team` repositories require a **linked, approved issue assigned to the PR author**. + +## Overview of Requirements + +1. **Open or find an issue first**: + - Before opening a pull request, find an existing issue or open a new issue describing the bug or feature. + - If an issue is open for contributions, comment on the issue to request assignment. +2. **Wait for issue assignment**: + - Once a maintainer approves the direction and **assigns the issue to you**, you can proceed with opening a PR. +3. **Link the assigned issue in your PR description**: + - Include a reference to the issue in your PR description (for example, `Fixes #xxx` under the `## Approved issue link` section). +4. **Complete the Contributor Agreement checklist**: + - Check all required boxes in the PR description's Contributor Agreement section. +5. **Stay responsive during review**: + - You may have up to **3 PRs** ready for review at a time (additional PRs should remain in draft). + - Feel free to run `/gemini review` on your PR to catch minor issues while waiting for a maintainer review. + - PRs with no activity for 7 days will be marked as stale and closed after 7 additional days. + +--- + +## Automated Enforcement (`pr-approved-issue`) + +Our automated workflow checks every pull request from external contributors when a PR is `opened`, `edited`, `reopened`, or marked `ready_for_review`: + +- **Who is checked**: All external contributors (`FIRST_TIME_CONTRIBUTOR`, `CONTRIBUTOR`, `NONE`). Repository owners (`OWNER`), organization members (`MEMBER`), collaborators (`COLLABORATOR`), and bots (`Bot` / `[bot]`) are exempt. +- **How issues are matched**: + - The workflow strips HTML comments (``) from the PR description and scans for issue references belonging to the same repository (`#xxx`, `owner/repo#xxx`, or `https://github.com/owner/repo/issues/xxx`). + - Pull request numbers are ignored — the linked number must be an **issue** in the repository, and your GitHub username must be in the issue's **Assignees** list. +- **Draft state behavior**: + - **When the check fails**: If your PR does not link an issue assigned to you and is not currently a draft, the workflow automatically converts the PR to **Draft** and posts a status comment explaining how to resolve it. + - **When the check passes**: Once you link an issue assigned to you in the PR description, the status check passes (`✅`). If your PR was in **Draft**, it stays in draft until you click **Ready for review** yourself (the workflow will never prematurely mark a draft PR as ready for review). + +--- + +## FAQ + +### My PR was converted to a draft — how do I get it reviewed? + +1. Ensure an issue for your change exists in the repository and has been **assigned to you** by a maintainer. +2. Edit your PR description to link that issue (e.g., `Fixes #xxx` under `## Approved issue link`). +3. Wait for the `PR approved issue check` status to pass (`✅`), then click **Ready for review** on your PR. + +### Multiple people offered to send a PR for an issue — how is assignment decided? + +1. In general, assignment is first-come, first-served, with the reporter of the issue given the first opportunity to submit a PR. +2. Maintainers may author PRs directly based on urgency and priority. +3. If an issue is assigned to a contributor and there is no PR or activity within 7 days, it may be reassigned to the next volunteer. diff --git a/scripts/pr-approved-issue.js b/scripts/pr-approved-issue.js index 1affd10..4d4f9c5 100644 --- a/scripts/pr-approved-issue.js +++ b/scripts/pr-approved-issue.js @@ -19,28 +19,35 @@ /** * Enforce that external PRs link an approved issue assigned to the author. * - * - On `opened`, the PR is converted to a draft and an "Approved issue link" - * section is added to the description if it is missing. - * - On every run, the description is scanned for issue references - * (`#123`, `owner/repo#123` or a full issue URL). The PR passes when at - * least one referenced issue exists in this repo and has the PR author as - * an assignee. - * - When the check passes, a draft PR is marked "Ready for review". When it - * fails and the PR is not a draft, it is converted to a draft. A single - * sticky comment is kept up to date with the result. + * - On `opened`, an "Approved issue link" section is added to the description + * if it is missing. + * - On every run, HTML comments are stripped and the description is scanned for + * issue references (`#xxx`, `owner/repo#xxx` or a full issue URL). The PR + * passes when at least one referenced issue exists in this repo and has the + * PR author as an assignee. + * - When the check fails and the PR is not a draft, the PR is converted to a + * draft. Draft PRs are never automatically converted out of draft; the author + * can mark the PR "Ready for review" themselves once ready. + * - A single sticky comment is kept up to date with the result. * * Maintainers, collaborators and bots are skipped by the workflow `if:`. */ +const POLICY_DOC_URL = + "https://github.com/keras-team/shared-workflows/blob/main/docs/pr_policy.md"; const SECTION_HEADING = "## Approved issue link"; const SECTION_TEMPLATE = `${SECTION_HEADING} - + marked "Ready for review". See ${POLICY_DOC_URL} --> `; const COMMENT_MARKER = ""; const BYPASS_ASSOCIATIONS = ["OWNER", "MEMBER", "COLLABORATOR"]; +// Historical RFC announcement issue in keras-team/keras that was linked in PR templates. +const IGNORED_REPO_ISSUES = { + "keras-team/keras": new Set([23601]), +}; module.exports = async function prApprovedIssue({ github, context, core }) { const pr = context.payload.pull_request; @@ -54,7 +61,11 @@ module.exports = async function prApprovedIssue({ github, context, core }) { const author = pr.user.login; // Defence in depth: the workflow `if:` already filters these out. - if (pr.user.type === "Bot" || BYPASS_ASSOCIATIONS.includes(pr.author_association)) { + if ( + pr.user.type === "Bot" || + author.toLowerCase().endsWith("[bot]") || + BYPASS_ASSOCIATIONS.includes(pr.author_association) + ) { core.info(`Skipping #${pr.number}: ${author} is ${pr.author_association}.`); return; } @@ -62,16 +73,11 @@ module.exports = async function prApprovedIssue({ github, context, core }) { let body = pr.body || ""; let isDraft = pr.draft; - // 1. On open: make sure the description has the section and start as draft. - if (action === "opened") { - if (!body.includes(SECTION_HEADING)) { - body = SECTION_TEMPLATE + body; - await github.rest.pulls.update({ owner, repo, pull_number: pr.number, body }); - core.info(`Added "${SECTION_HEADING}" section to #${pr.number}.`); - } - if (!isDraft) { - isDraft = await convertToDraft(github, core, pr); - } + // 1. On open: ensure the description has the "Approved issue link" section. + if (action === "opened" && !body.includes(SECTION_HEADING)) { + body = SECTION_TEMPLATE + body; + await github.rest.pulls.update({ owner, repo, pull_number: pr.number, body }); + core.info(`Added "${SECTION_HEADING}" section to #${pr.number}.`); } // 2. Find issues referenced in the description that are assigned to the author. @@ -90,10 +96,9 @@ module.exports = async function prApprovedIssue({ github, context, core }) { } const passed = assigned.length > 0; - // 3. Flip the draft state to match the result. - if (passed && isDraft) { - isDraft = await markReadyForReview(github, core, pr); - } else if (!passed && !isDraft) { + // 3. Convert to draft if the check failed and the PR is not already a draft. + // Never automatically mark a draft PR as ready for review. + if (!passed && !isDraft) { isDraft = await convertToDraft(github, core, pr); } @@ -103,18 +108,17 @@ module.exports = async function prApprovedIssue({ github, context, core }) { `✅ Approved issue check passed: ${assigned.map((n) => `#${n}`).join(", ")} ` + `is assigned to @${author}.`, isDraft - ? "Could not mark this PR as ready automatically; please mark it **Ready for review**." + ? "You can mark this PR **Ready for review** when it is ready." : "This PR is **Ready for review**.", ] : [ `❌ Approved issue check failed. This PR ${isDraft ? "stays" : "must stay"} in **draft** until ` + "it links an approved issue that is assigned to you.", "", - "To fix this:", + `See the [Keras Pull Request Policy](${POLICY_DOC_URL}). To fix this:`, "1. Find or open an issue for this change and ask a maintainer to approve it and assign it to you.", - `2. Link it under the "${SECTION_HEADING.replace("## ", "")}" section of this PR's description, e.g. \`Fixes #123\`.`, - "", - "The PR will be marked **Ready for review** automatically once the check passes.", + `2. Link it under the "${SECTION_HEADING.replace("## ", "")}" section of this PR's description, e.g. \`Fixes #xxx\`.`, + "3. Once this check passes, mark the PR **Ready for review** when it is ready.", "", notAssigned.length ? `Referenced issue(s) not assigned to @${author}: ${notAssigned.map((n) => `#${n}`).join(", ")}.` @@ -127,8 +131,13 @@ module.exports = async function prApprovedIssue({ github, context, core }) { } }; -/** Collect issue numbers referenced in `body` that belong to this repo. */ +/** Collect issue numbers referenced in `body` (excluding HTML comments) that belong to this repo. */ function findIssueNumbers(body, owner, repo) { + // Strip HTML comments so placeholders like are never matched. + const uncommented = body.replace(//g, ""); + const repoKey = `${owner}/${repo}`.toLowerCase(); + const ignored = IGNORED_REPO_ISSUES[repoKey] || new Set(); + const escaped = `${owner}/${repo}`.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const patterns = [ new RegExp(`https?://github\\.com/${escaped}/issues/(\\d+)`, "gi"), @@ -138,8 +147,11 @@ function findIssueNumbers(body, owner, repo) { ]; const numbers = new Set(); for (const pattern of patterns) { - for (const match of body.matchAll(pattern)) { - numbers.add(Number(match[1])); + for (const match of uncommented.matchAll(pattern)) { + const num = Number(match[1]); + if (!ignored.has(num)) { + numbers.add(num); + } } } return [...numbers]; @@ -163,24 +175,6 @@ async function convertToDraft(github, core, pr) { } } -async function markReadyForReview(github, core, pr) { - try { - await github.graphql( - `mutation($id: ID!) { - markPullRequestReadyForReview(input: {pullRequestId: $id}) { - pullRequest { isDraft } - } - }`, - { id: pr.node_id } - ); - core.info(`Marked #${pr.number} as ready for review.`); - return false; - } catch (err) { - warnDraftToggleFailed(core, `mark #${pr.number} as ready for review`, err); - return true; - } -} - function warnDraftToggleFailed(core, what, err) { const lines = [`Could not ${what}: ${err.message}`]; if (/not accessible by integration/i.test(err.message)) { diff --git a/scripts/pr-approved-issue.test.js b/scripts/pr-approved-issue.test.js index 5eaebb8..5016b97 100644 --- a/scripts/pr-approved-issue.test.js +++ b/scripts/pr-approved-issue.test.js @@ -22,6 +22,8 @@ const prApprovedIssue = require('./pr-approved-issue.js'); describe('prApprovedIssue', () => { function createHarness({ + owner = 'keras-team', + repo = 'keras-hub', action = 'opened', pr = null, issuesByNumber = {}, @@ -32,6 +34,7 @@ describe('prApprovedIssue', () => { const warningMessages = []; let failedMessage = null; const updatedPrs = []; + const fetchedIssues = []; const graphqlCalls = []; const createdComments = []; const updatedComments = []; @@ -53,6 +56,7 @@ describe('prApprovedIssue', () => { }, issues: { get: async ({ issue_number }) => { + fetchedIssues.push(issue_number); if (!(issue_number in issuesByNumber)) { throw new Error('Not Found'); } @@ -81,7 +85,7 @@ describe('prApprovedIssue', () => { }; const context = { - repo: { owner: 'keras-team', repo: 'keras-hub' }, + repo: { owner, repo }, payload: { action, pull_request: pr, @@ -94,6 +98,7 @@ describe('prApprovedIssue', () => { getWarningMessages: () => warningMessages, getFailedMessage: () => failedMessage, getUpdatedPrs: () => updatedPrs, + getFetchedIssues: () => fetchedIssues, getGraphqlCalls: () => graphqlCalls, getCreatedComments: () => createdComments, getUpdatedComments: () => updatedComments, @@ -108,11 +113,12 @@ describe('prApprovedIssue', () => { }); it('skips bots and bypassed author associations (OWNER, MEMBER, COLLABORATOR)', async () => { - for (const [type, association] of [ - ['Bot', 'NONE'], - ['User', 'OWNER'], - ['User', 'MEMBER'], - ['User', 'COLLABORATOR'], + for (const [login, type, association] of [ + ['bot-account', 'Bot', 'NONE'], + ['dependabot[bot]', 'User', 'NONE'], + ['owner-user', 'User', 'OWNER'], + ['member-user', 'User', 'MEMBER'], + ['collab-user', 'User', 'COLLABORATOR'], ]) { const harness = createHarness({ pr: { @@ -120,7 +126,7 @@ describe('prApprovedIssue', () => { node_id: 'PR_10', draft: false, author_association: association, - user: { login: 'maintainer-or-bot', type }, + user: { login, type }, body: 'No issue link needed', }, }); @@ -131,7 +137,7 @@ describe('prApprovedIssue', () => { } }); - it('on opened: prepends Approved issue link section, converts non-draft PR to draft, posts comment, and fails when no issue is linked', async () => { + it('on opened without linked issue: prepends section, converts non-draft PR to draft, posts comment, and fails', async () => { const harness = createHarness({ action: 'opened', pr: { @@ -142,6 +148,10 @@ describe('prApprovedIssue', () => { user: { login: 'external-dev', type: 'User' }, body: 'Here is my new feature.', }, + issuesByNumber: { + // Even if #123 exists in the repo, the template comment must not trigger a fetch for #123. + 123: { number: 123, assignees: [{ login: 'someone-else' }] }, + }, }); await harness.run(); @@ -151,44 +161,51 @@ describe('prApprovedIssue', () => { assert.ok(harness.getUpdatedPrs()[0].body.startsWith('## Approved issue link\n')); assert.ok(harness.getUpdatedPrs()[0].body.endsWith('Here is my new feature.')); - // 2. Converted to draft once on opened (and not called a second time). + // 2. No issue numbers fetched from HTML comments. + assert.deepStrictEqual(harness.getFetchedIssues(), []); + + // 3. Converted to draft via GraphQL. assert.strictEqual(harness.getGraphqlCalls().length, 1); assert.ok(harness.getGraphqlCalls()[0].query.includes('convertPullRequestToDraft')); assert.deepStrictEqual(harness.getGraphqlCalls()[0].variables, { id: 'PR_42' }); - // 3. Sticky comment created. + // 4. Sticky comment created. assert.strictEqual(harness.getCreatedComments().length, 1); assert.ok(harness.getCreatedComments()[0].body.startsWith('')); assert.ok(harness.getCreatedComments()[0].body.includes('No issue reference was found in the description.')); - // 4. Workflow marked failed. + // 5. Workflow marked failed. assert.strictEqual( harness.getFailedMessage(), 'No approved issue assigned to external-dev is linked in #42.' ); }); - it('on opened when section already exists and PR is already draft: skips body update and draft conversion', async () => { + it('on opened with an approved issue already linked: keeps non-draft PR ready for review without converting to draft', async () => { const harness = createHarness({ action: 'opened', pr: { - number: 43, - node_id: 'PR_43', - draft: true, + number: 44, + node_id: 'PR_44', + draft: false, author_association: 'NONE', user: { login: 'external-dev', type: 'User' }, - body: '## Approved issue link\n\nStill working on it.', + body: '## Approved issue link\nFixes #500', + }, + issuesByNumber: { + 500: { number: 500, assignees: [{ login: 'external-dev' }] }, }, }); await harness.run(); - assert.strictEqual(harness.getUpdatedPrs().length, 0); + // Never converts to draft or mutates draft state when already passing! assert.strictEqual(harness.getGraphqlCalls().length, 0); - assert.notStrictEqual(harness.getFailedMessage(), null); + assert.strictEqual(harness.getFailedMessage(), null); + assert.ok(harness.getCreatedComments()[0].body.includes('This PR is **Ready for review**.')); }); - it('passes check and marks draft PR ready for review when an issue assigned to the author is referenced', async () => { + it('passes check on draft PR without automatically converting it out of draft', async () => { const harness = createHarness({ action: 'edited', pr: { @@ -208,22 +225,23 @@ describe('prApprovedIssue', () => { await harness.run(); - // Marked ready for review via GraphQL. - assert.strictEqual(harness.getGraphqlCalls().length, 1); - assert.ok(harness.getGraphqlCalls()[0].query.includes('markPullRequestReadyForReview')); + // Must NOT call markPullRequestReadyForReview — draft PRs stay draft until the author marks them ready. + assert.strictEqual(harness.getGraphqlCalls().length, 0); - // Sticky comment reports passed issues #125, #123 (matched in pattern order: URL, owner/repo#, bare #). + // Sticky comment reports passed issues #125, #123 and tells author they can mark it ready when ready. assert.strictEqual(harness.getCreatedComments().length, 1); const commentBody = harness.getCreatedComments()[0].body; assert.ok(commentBody.includes('✅ Approved issue check passed: #125, #123 is assigned to @External-Dev.')); - assert.ok(commentBody.includes('This PR is **Ready for review**.')); + assert.ok(commentBody.includes('You can mark this PR **Ready for review** when it is ready.')); // Check succeeded. assert.strictEqual(harness.getFailedMessage(), null); }); - it('ignores references to other repositories and pull request numbers', async () => { + it('ignores issue references inside HTML comments and the RFC policy issue #23601', async () => { const harness = createHarness({ + owner: 'keras-team', + repo: 'keras', action: 'edited', pr: { number: 50, @@ -231,9 +249,15 @@ describe('prApprovedIssue', () => { draft: true, author_association: 'NONE', user: { login: 'contributor', type: 'User' }, - body: 'Fixes other-org/other-repo#100 and https://github.com/other-org/other-repo/issues/101 and #200', + body: [ + '', + 'Please review our [PR Contribution Policy](https://github.com/keras-team/keras/issues/23601).', + 'Fixes other-org/other-repo#100 and https://github.com/other-org/other-repo/issues/101 and #200', + ].join('\n'), }, issuesByNumber: { + 123: { number: 123, assignees: [{ login: 'contributor' }] }, + 23601: { number: 23601, assignees: [{ login: 'contributor' }] }, 100: { number: 100, assignees: [{ login: 'contributor' }] }, 101: { number: 101, assignees: [{ login: 'contributor' }] }, // #200 is a PR, not an issue. @@ -243,6 +267,8 @@ describe('prApprovedIssue', () => { await harness.run(); + // Only #200 was fetched (neither #123 in HTML comment nor #23601 RFC policy issue was fetched). + assert.deepStrictEqual(harness.getFetchedIssues(), [200]); assert.strictEqual(harness.getGraphqlCalls().length, 0); assert.notStrictEqual(harness.getFailedMessage(), null); assert.ok( @@ -330,19 +356,16 @@ describe('prApprovedIssue', () => { assert.ok(changedHarness.getUpdatedComments()[0].body.includes('#456')); }); - it('logs warning with permissions hint when GraphQL draft state toggle fails', async () => { + it('logs warning with permissions hint when GraphQL convertToDraft fails', async () => { const harness = createHarness({ - action: 'edited', + action: 'ready_for_review', pr: { number: 70, node_id: 'PR_70', - draft: true, + draft: false, author_association: 'NONE', user: { login: 'contributor', type: 'User' }, - body: 'Fixes #123', - }, - issuesByNumber: { - 123: { number: 123, assignees: [{ login: 'contributor' }] }, + body: 'No issue linked yet', }, graphqlError: new Error('Resource not accessible by integration'), }); @@ -358,7 +381,7 @@ describe('prApprovedIssue', () => { assert.ok( harness .getCreatedComments()[0] - .body.includes('Could not mark this PR as ready automatically; please mark it **Ready for review**.') + .body.includes('This PR must stay in **draft**') ); }); }); From 57fb270d3920960c4f50e848eb55da41ec738d81 Mon Sep 17 00:00:00 2001 From: Jeff Carpenter Date: Thu, 24 Sep 2026 21:00:10 +0000 Subject: [PATCH 3/6] Add note linking to keras#23601 for policy feedback --- docs/pr_policy.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/pr_policy.md b/docs/pr_policy.md index 236954a..947f29d 100644 --- a/docs/pr_policy.md +++ b/docs/pr_policy.md @@ -2,6 +2,8 @@ To keep review bandwidth focused on high-leverage improvements and prevent duplicate work, external pull requests across `keras-team` repositories require a **linked, approved issue assigned to the PR author**. +> **NOTE:** We're constantly evolving this policy for optimal project velocity. If you have any feedback, please chime in on [keras#23601](https://github.com/keras-team/keras/issues/23601). + ## Overview of Requirements 1. **Open or find an issue first**: From 178240cd8f91ab1b71c38de4e4c474abf7e92b9d Mon Sep 17 00:00:00 2001 From: Jeff Carpenter Date: Thu, 24 Sep 2026 21:04:52 +0000 Subject: [PATCH 4/6] Avoid mutating PR body on opened when approved issue is already linked --- scripts/pr-approved-issue.js | 16 ++++++++-------- scripts/pr-approved-issue.test.js | 7 ++++--- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/scripts/pr-approved-issue.js b/scripts/pr-approved-issue.js index 4d4f9c5..48008e1 100644 --- a/scripts/pr-approved-issue.js +++ b/scripts/pr-approved-issue.js @@ -73,14 +73,7 @@ module.exports = async function prApprovedIssue({ github, context, core }) { let body = pr.body || ""; let isDraft = pr.draft; - // 1. On open: ensure the description has the "Approved issue link" section. - if (action === "opened" && !body.includes(SECTION_HEADING)) { - body = SECTION_TEMPLATE + body; - await github.rest.pulls.update({ owner, repo, pull_number: pr.number, body }); - core.info(`Added "${SECTION_HEADING}" section to #${pr.number}.`); - } - - // 2. Find issues referenced in the description that are assigned to the author. + // 1. Find issues referenced in the description that are assigned to the author. const referenced = findIssueNumbers(body, owner, repo); const assigned = []; const notAssigned = []; @@ -96,6 +89,13 @@ module.exports = async function prApprovedIssue({ github, context, core }) { } const passed = assigned.length > 0; + // 2. On open: if the check failed, ensure the description has the "Approved issue link" section. + if (!passed && action === "opened" && !body.includes(SECTION_HEADING)) { + body = SECTION_TEMPLATE + body; + await github.rest.pulls.update({ owner, repo, pull_number: pr.number, body }); + core.info(`Added "${SECTION_HEADING}" section to #${pr.number}.`); + } + // 3. Convert to draft if the check failed and the PR is not already a draft. // Never automatically mark a draft PR as ready for review. if (!passed && !isDraft) { diff --git a/scripts/pr-approved-issue.test.js b/scripts/pr-approved-issue.test.js index 5016b97..3a9e35a 100644 --- a/scripts/pr-approved-issue.test.js +++ b/scripts/pr-approved-issue.test.js @@ -181,7 +181,7 @@ describe('prApprovedIssue', () => { ); }); - it('on opened with an approved issue already linked: keeps non-draft PR ready for review without converting to draft', async () => { + it('on opened with an approved issue already linked: keeps non-draft PR ready for review without mutating body or draft state', async () => { const harness = createHarness({ action: 'opened', pr: { @@ -190,7 +190,7 @@ describe('prApprovedIssue', () => { draft: false, author_association: 'NONE', user: { login: 'external-dev', type: 'User' }, - body: '## Approved issue link\nFixes #500', + body: '## Description\nFixes #500', }, issuesByNumber: { 500: { number: 500, assignees: [{ login: 'external-dev' }] }, @@ -199,7 +199,8 @@ describe('prApprovedIssue', () => { await harness.run(); - // Never converts to draft or mutates draft state when already passing! + // Never mutates body or converts to draft when already passing on open. + assert.strictEqual(harness.getUpdatedPrs().length, 0); assert.strictEqual(harness.getGraphqlCalls().length, 0); assert.strictEqual(harness.getFailedMessage(), null); assert.ok(harness.getCreatedComments()[0].body.includes('This PR is **Ready for review**.')); From 05818e99d2f535838473f96c5b04dab25ad2dcc8 Mon Sep 17 00:00:00 2001 From: Jeff Carpenter Date: Thu, 24 Sep 2026 21:19:15 +0000 Subject: [PATCH 5/6] Remove PR body mutation and upsertComment; only scan, validate, convert to draft, and post comment on failure --- docs/pr_policy.md | 4 +- scripts/pr-approved-issue.js | 125 +++++++++----------------- scripts/pr-approved-issue.test.js | 140 +++++------------------------- 3 files changed, 69 insertions(+), 200 deletions(-) diff --git a/docs/pr_policy.md b/docs/pr_policy.md index 947f29d..aaafff3 100644 --- a/docs/pr_policy.md +++ b/docs/pr_policy.md @@ -31,8 +31,8 @@ Our automated workflow checks every pull request from external contributors when - The workflow strips HTML comments (``) from the PR description and scans for issue references belonging to the same repository (`#xxx`, `owner/repo#xxx`, or `https://github.com/owner/repo/issues/xxx`). - Pull request numbers are ignored — the linked number must be an **issue** in the repository, and your GitHub username must be in the issue's **Assignees** list. - **Draft state behavior**: - - **When the check fails**: If your PR does not link an issue assigned to you and is not currently a draft, the workflow automatically converts the PR to **Draft** and posts a status comment explaining how to resolve it. - - **When the check passes**: Once you link an issue assigned to you in the PR description, the status check passes (`✅`). If your PR was in **Draft**, it stays in draft until you click **Ready for review** yourself (the workflow will never prematurely mark a draft PR as ready for review). + - **When the check fails**: If your PR does not link an issue assigned to you and is not currently a draft, the workflow automatically converts the PR to **Draft**, posts a comment explaining how to resolve it, and fails the check. + - **When the check passes**: Once you link an issue assigned to you in the PR description, the status check passes (`✅`). If your PR was in **Draft**, it stays in draft until you click **Ready for review** yourself. --- diff --git a/scripts/pr-approved-issue.js b/scripts/pr-approved-issue.js index 48008e1..1c4bf1c 100644 --- a/scripts/pr-approved-issue.js +++ b/scripts/pr-approved-issue.js @@ -19,30 +19,18 @@ /** * Enforce that external PRs link an approved issue assigned to the author. * - * - On `opened`, an "Approved issue link" section is added to the description - * if it is missing. - * - On every run, HTML comments are stripped and the description is scanned for - * issue references (`#xxx`, `owner/repo#xxx` or a full issue URL). The PR - * passes when at least one referenced issue exists in this repo and has the + * - Strips HTML comments from the PR description and scans for issue references + * (`#xxx`, `owner/repo#xxx` or a full issue URL). + * - Passes when at least one referenced issue exists in this repo and has the * PR author as an assignee. - * - When the check fails and the PR is not a draft, the PR is converted to a - * draft. Draft PRs are never automatically converted out of draft; the author - * can mark the PR "Ready for review" themselves once ready. - * - A single sticky comment is kept up to date with the result. + * - When the check fails: converts non-draft PRs to draft, posts a comment (on + * `opened` or when converting to draft), and marks the check as failed. * * Maintainers, collaborators and bots are skipped by the workflow `if:`. */ const POLICY_DOC_URL = "https://github.com/keras-team/shared-workflows/blob/main/docs/pr_policy.md"; -const SECTION_HEADING = "## Approved issue link"; -const SECTION_TEMPLATE = `${SECTION_HEADING} - - -`; -const COMMENT_MARKER = ""; const BYPASS_ASSOCIATIONS = ["OWNER", "MEMBER", "COLLABORATOR"]; // Historical RFC announcement issue in keras-team/keras that was linked in PR templates. const IGNORED_REPO_ISSUES = { @@ -70,8 +58,7 @@ module.exports = async function prApprovedIssue({ github, context, core }) { return; } - let body = pr.body || ""; - let isDraft = pr.draft; + const body = pr.body || ""; // 1. Find issues referenced in the description that are assigned to the author. const referenced = findIssueNumbers(body, owner, repo); @@ -87,48 +74,44 @@ module.exports = async function prApprovedIssue({ github, context, core }) { core.info(`Could not fetch issue #${number}: ${err.message}`); } } - const passed = assigned.length > 0; - // 2. On open: if the check failed, ensure the description has the "Approved issue link" section. - if (!passed && action === "opened" && !body.includes(SECTION_HEADING)) { - body = SECTION_TEMPLATE + body; - await github.rest.pulls.update({ owner, repo, pull_number: pr.number, body }); - core.info(`Added "${SECTION_HEADING}" section to #${pr.number}.`); + if (assigned.length > 0) { + core.info( + `Approved issue check passed for #${pr.number}: ${assigned.map((n) => `#${n}`).join(", ")} assigned to @${author}.` + ); + return; } - // 3. Convert to draft if the check failed and the PR is not already a draft. - // Never automatically mark a draft PR as ready for review. - if (!passed && !isDraft) { - isDraft = await convertToDraft(github, core, pr); + // 2. Check failed: convert non-draft PRs to draft. + const wasNotDraft = !pr.draft; + if (wasNotDraft) { + await convertToDraft(github, core, pr); } - // 4. Report via a sticky comment and the job status. - const message = passed - ? [ - `✅ Approved issue check passed: ${assigned.map((n) => `#${n}`).join(", ")} ` + - `is assigned to @${author}.`, - isDraft - ? "You can mark this PR **Ready for review** when it is ready." - : "This PR is **Ready for review**.", - ] - : [ - `❌ Approved issue check failed. This PR ${isDraft ? "stays" : "must stay"} in **draft** until ` + - "it links an approved issue that is assigned to you.", - "", - `See the [Keras Pull Request Policy](${POLICY_DOC_URL}). To fix this:`, - "1. Find or open an issue for this change and ask a maintainer to approve it and assign it to you.", - `2. Link it under the "${SECTION_HEADING.replace("## ", "")}" section of this PR's description, e.g. \`Fixes #xxx\`.`, - "3. Once this check passes, mark the PR **Ready for review** when it is ready.", - "", - notAssigned.length - ? `Referenced issue(s) not assigned to @${author}: ${notAssigned.map((n) => `#${n}`).join(", ")}.` - : "No issue reference was found in the description.", - ]; - await upsertComment(github, owner, repo, pr.number, message.join("\n")); - - if (!passed) { - core.setFailed(`No approved issue assigned to ${author} is linked in #${pr.number}.`); + // 3. Post a comment when the PR is opened or when converting a non-draft PR to draft. + if (action === "opened" || wasNotDraft) { + const message = [ + `❌ **Approved issue check failed.** This PR has been placed in **draft** until it links an approved issue assigned to @${author}.`, + "", + `Please review the [Keras Pull Request Policy](${POLICY_DOC_URL}). To resolve this:`, + "1. Find or open an issue for this change and ask a maintainer to approve it and assign it to you.", + "2. Link the issue in this PR's description (e.g. `Fixes #xxx`).", + "3. Once this check passes, mark the PR **Ready for review** when it is ready.", + "", + notAssigned.length + ? `Referenced issue(s) not assigned to @${author}: ${notAssigned.map((n) => `#${n}`).join(", ")}.` + : "No issue reference was found in the description.", + ].join("\n"); + + await github.rest.issues.createComment({ + owner, + repo, + issue_number: pr.number, + body: message, + }); } + + core.setFailed(`No approved issue assigned to ${author} is linked in #${pr.number}.`); }; /** Collect issue numbers referenced in `body` (excluding HTML comments) that belong to this repo. */ @@ -170,33 +153,11 @@ async function convertToDraft(github, core, pr) { core.info(`Converted #${pr.number} to draft.`); return true; } catch (err) { - warnDraftToggleFailed(core, `convert #${pr.number} to draft`, err); - return pr.draft; - } -} - -function warnDraftToggleFailed(core, what, err) { - const lines = [`Could not ${what}: ${err.message}`]; - if (/not accessible by integration/i.test(err.message)) { - lines.push("Toggling draft state needs `contents: write` and `pull-requests: write`."); - } - core.warning(lines.join("\n")); -} - -async function upsertComment(github, owner, repo, issue_number, text) { - const body = `${COMMENT_MARKER}\n${text}`; - const comments = await github.paginate(github.rest.issues.listComments, { - owner, - repo, - issue_number, - per_page: 100, - }); - const existing = comments.find((c) => c.body && c.body.startsWith(COMMENT_MARKER)); - if (existing) { - if (existing.body !== body) { - await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }); + const lines = [`Could not convert #${pr.number} to draft: ${err.message}`]; + if (/not accessible by integration/i.test(err.message)) { + lines.push("Toggling draft state needs `contents: write` and `pull-requests: write`."); } - } else { - await github.rest.issues.createComment({ owner, repo, issue_number, body }); + core.warning(lines.join("\n")); + return pr.draft; } } diff --git a/scripts/pr-approved-issue.test.js b/scripts/pr-approved-issue.test.js index 3a9e35a..0cc7a70 100644 --- a/scripts/pr-approved-issue.test.js +++ b/scripts/pr-approved-issue.test.js @@ -27,17 +27,14 @@ describe('prApprovedIssue', () => { action = 'opened', pr = null, issuesByNumber = {}, - existingComments = [], graphqlError = null, }) { const infoMessages = []; const warningMessages = []; let failedMessage = null; - const updatedPrs = []; const fetchedIssues = []; const graphqlCalls = []; const createdComments = []; - const updatedComments = []; const core = { info: (msg) => infoMessages.push(msg), @@ -49,11 +46,6 @@ describe('prApprovedIssue', () => { const github = { rest: { - pulls: { - update: async (params) => { - updatedPrs.push(params); - }, - }, issues: { get: async ({ issue_number }) => { fetchedIssues.push(issue_number); @@ -62,19 +54,11 @@ describe('prApprovedIssue', () => { } return { data: issuesByNumber[issue_number] }; }, - listComments: async () => ({ data: existingComments }), createComment: async (params) => { createdComments.push(params); }, - updateComment: async (params) => { - updatedComments.push(params); - }, }, }, - paginate: async (fn, params) => { - const res = await fn(params); - return res.data; - }, graphql: async (query, variables) => { graphqlCalls.push({ query, variables }); if (graphqlError) { @@ -97,11 +81,9 @@ describe('prApprovedIssue', () => { getInfoMessages: () => infoMessages, getWarningMessages: () => warningMessages, getFailedMessage: () => failedMessage, - getUpdatedPrs: () => updatedPrs, getFetchedIssues: () => fetchedIssues, getGraphqlCalls: () => graphqlCalls, getCreatedComments: () => createdComments, - getUpdatedComments: () => updatedComments, }; } @@ -137,7 +119,7 @@ describe('prApprovedIssue', () => { } }); - it('on opened without linked issue: prepends section, converts non-draft PR to draft, posts comment, and fails', async () => { + it('on opened without linked issue: converts non-draft PR to draft, posts comment, and fails', async () => { const harness = createHarness({ action: 'opened', pr: { @@ -148,65 +130,27 @@ describe('prApprovedIssue', () => { user: { login: 'external-dev', type: 'User' }, body: 'Here is my new feature.', }, - issuesByNumber: { - // Even if #123 exists in the repo, the template comment must not trigger a fetch for #123. - 123: { number: 123, assignees: [{ login: 'someone-else' }] }, - }, }); await harness.run(); - // 1. Section added to PR description. - assert.strictEqual(harness.getUpdatedPrs().length, 1); - assert.ok(harness.getUpdatedPrs()[0].body.startsWith('## Approved issue link\n')); - assert.ok(harness.getUpdatedPrs()[0].body.endsWith('Here is my new feature.')); - - // 2. No issue numbers fetched from HTML comments. - assert.deepStrictEqual(harness.getFetchedIssues(), []); - - // 3. Converted to draft via GraphQL. + // 1. Converted to draft via GraphQL. assert.strictEqual(harness.getGraphqlCalls().length, 1); assert.ok(harness.getGraphqlCalls()[0].query.includes('convertPullRequestToDraft')); assert.deepStrictEqual(harness.getGraphqlCalls()[0].variables, { id: 'PR_42' }); - // 4. Sticky comment created. + // 2. Comment posted. assert.strictEqual(harness.getCreatedComments().length, 1); - assert.ok(harness.getCreatedComments()[0].body.startsWith('')); assert.ok(harness.getCreatedComments()[0].body.includes('No issue reference was found in the description.')); - // 5. Workflow marked failed. + // 3. Workflow marked failed. assert.strictEqual( harness.getFailedMessage(), 'No approved issue assigned to external-dev is linked in #42.' ); }); - it('on opened with an approved issue already linked: keeps non-draft PR ready for review without mutating body or draft state', async () => { - const harness = createHarness({ - action: 'opened', - pr: { - number: 44, - node_id: 'PR_44', - draft: false, - author_association: 'NONE', - user: { login: 'external-dev', type: 'User' }, - body: '## Description\nFixes #500', - }, - issuesByNumber: { - 500: { number: 500, assignees: [{ login: 'external-dev' }] }, - }, - }); - - await harness.run(); - - // Never mutates body or converts to draft when already passing on open. - assert.strictEqual(harness.getUpdatedPrs().length, 0); - assert.strictEqual(harness.getGraphqlCalls().length, 0); - assert.strictEqual(harness.getFailedMessage(), null); - assert.ok(harness.getCreatedComments()[0].body.includes('This PR is **Ready for review**.')); - }); - - it('passes check on draft PR without automatically converting it out of draft', async () => { + it('passes check without posting a comment or mutating draft state when an assigned issue is linked', async () => { const harness = createHarness({ action: 'edited', pr: { @@ -226,24 +170,22 @@ describe('prApprovedIssue', () => { await harness.run(); - // Must NOT call markPullRequestReadyForReview — draft PRs stay draft until the author marks them ready. + // No GraphQL draft toggle and no comment when passing. assert.strictEqual(harness.getGraphqlCalls().length, 0); - - // Sticky comment reports passed issues #125, #123 and tells author they can mark it ready when ready. - assert.strictEqual(harness.getCreatedComments().length, 1); - const commentBody = harness.getCreatedComments()[0].body; - assert.ok(commentBody.includes('✅ Approved issue check passed: #125, #123 is assigned to @External-Dev.')); - assert.ok(commentBody.includes('You can mark this PR **Ready for review** when it is ready.')); - - // Check succeeded. + assert.strictEqual(harness.getCreatedComments().length, 0); assert.strictEqual(harness.getFailedMessage(), null); + assert.ok( + harness + .getInfoMessages() + .includes('Approved issue check passed for #42: #125, #123 assigned to @External-Dev.') + ); }); it('ignores issue references inside HTML comments and the RFC policy issue #23601', async () => { const harness = createHarness({ owner: 'keras-team', repo: 'keras', - action: 'edited', + action: 'opened', pr: { number: 50, node_id: 'PR_50', @@ -277,7 +219,7 @@ describe('prApprovedIssue', () => { ); }); - it('converts non-draft PR to draft when edited to reference only unassigned issues', async () => { + it('converts non-draft PR to draft and posts comment when edited to reference only unassigned issues', async () => { const harness = createHarness({ action: 'edited', pr: { @@ -298,6 +240,7 @@ describe('prApprovedIssue', () => { assert.strictEqual(harness.getGraphqlCalls().length, 1); assert.ok(harness.getGraphqlCalls()[0].query.includes('convertPullRequestToDraft')); + assert.strictEqual(harness.getCreatedComments().length, 1); assert.ok( harness .getCreatedComments()[0] @@ -306,55 +249,24 @@ describe('prApprovedIssue', () => { assert.notStrictEqual(harness.getFailedMessage(), null); }); - it('updates existing sticky comment when content changes and avoids API call when unchanged', async () => { - const existingBody = - '\n' + - '✅ Approved issue check passed: #123 is assigned to @contributor.\n' + - 'This PR is **Ready for review**.'; - - // 1. Unchanged comment body -> no create or update call. - const unchangedHarness = createHarness({ + it('does not post duplicate comments when an already-draft PR is edited and still failing', async () => { + const harness = createHarness({ action: 'edited', pr: { number: 60, node_id: 'PR_60', - draft: false, + draft: true, author_association: 'NONE', user: { login: 'contributor', type: 'User' }, - body: 'Fixes #123', + body: 'Still working on description', }, - issuesByNumber: { - 123: { number: 123, assignees: [{ login: 'contributor' }] }, - }, - existingComments: [{ id: 777, body: existingBody }], }); - await unchangedHarness.run(); - assert.strictEqual(unchangedHarness.getCreatedComments().length, 0); - assert.strictEqual(unchangedHarness.getUpdatedComments().length, 0); - - // 2. Changed comment body -> updates existing comment. - const changedHarness = createHarness({ - action: 'edited', - pr: { - number: 60, - node_id: 'PR_60', - draft: false, - author_association: 'NONE', - user: { login: 'contributor', type: 'User' }, - body: 'Fixes #456', - }, - issuesByNumber: { - 456: { number: 456, assignees: [{ login: 'contributor' }] }, - }, - existingComments: [{ id: 777, body: existingBody }], - }); + await harness.run(); - await changedHarness.run(); - assert.strictEqual(changedHarness.getCreatedComments().length, 0); - assert.strictEqual(changedHarness.getUpdatedComments().length, 1); - assert.strictEqual(changedHarness.getUpdatedComments()[0].comment_id, 777); - assert.ok(changedHarness.getUpdatedComments()[0].body.includes('#456')); + assert.strictEqual(harness.getGraphqlCalls().length, 0); + assert.strictEqual(harness.getCreatedComments().length, 0); + assert.notStrictEqual(harness.getFailedMessage(), null); }); it('logs warning with permissions hint when GraphQL convertToDraft fails', async () => { @@ -379,10 +291,6 @@ describe('prApprovedIssue', () => { .getWarningMessages()[0] .includes('Toggling draft state needs `contents: write` and `pull-requests: write`.') ); - assert.ok( - harness - .getCreatedComments()[0] - .body.includes('This PR must stay in **draft**') - ); + assert.strictEqual(harness.getCreatedComments().length, 1); }); }); From 3536ca3e6cc47c65d6ec0f590e4de890f1f29489 Mon Sep 17 00:00:00 2001 From: Jeff Carpenter Date: Thu, 24 Sep 2026 23:49:33 +0000 Subject: [PATCH 6/6] Fetch live PR state via pulls.get instead of frozen event payload --- scripts/pr-approved-issue.js | 12 ++++++++-- scripts/pr-approved-issue.test.js | 39 ++++++++++++++++++++++++++++++- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/scripts/pr-approved-issue.js b/scripts/pr-approved-issue.js index 1c4bf1c..92cd625 100644 --- a/scripts/pr-approved-issue.js +++ b/scripts/pr-approved-issue.js @@ -38,14 +38,22 @@ const IGNORED_REPO_ISSUES = { }; module.exports = async function prApprovedIssue({ github, context, core }) { - const pr = context.payload.pull_request; - if (!pr) { + const payloadPr = context.payload.pull_request; + if (!payloadPr) { core.info("Not a pull request payload."); return; } const { owner, repo } = context.repo; const action = context.payload.action; + + // Fetch live PR state so re-running a failed workflow job reads the latest + // PR description and draft status rather than the frozen event payload. + const { data: pr } = await github.rest.pulls.get({ + owner, + repo, + pull_number: payloadPr.number, + }); const author = pr.user.login; // Defence in depth: the workflow `if:` already filters these out. diff --git a/scripts/pr-approved-issue.test.js b/scripts/pr-approved-issue.test.js index 0cc7a70..7a5c7f2 100644 --- a/scripts/pr-approved-issue.test.js +++ b/scripts/pr-approved-issue.test.js @@ -26,6 +26,7 @@ describe('prApprovedIssue', () => { repo = 'keras-hub', action = 'opened', pr = null, + payloadPr = undefined, issuesByNumber = {}, graphqlError = null, }) { @@ -46,6 +47,9 @@ describe('prApprovedIssue', () => { const github = { rest: { + pulls: { + get: async () => ({ data: pr }), + }, issues: { get: async ({ issue_number }) => { fetchedIssues.push(issue_number); @@ -72,7 +76,7 @@ describe('prApprovedIssue', () => { repo: { owner, repo }, payload: { action, - pull_request: pr, + pull_request: payloadPr !== undefined ? payloadPr : (pr ? { number: pr.number } : null), }, }; @@ -119,6 +123,39 @@ describe('prApprovedIssue', () => { } }); + it('fetches live PR state via pulls.get instead of relying on frozen event payload when re-run', async () => { + const harness = createHarness({ + action: 'opened', + // Stale frozen webhook payload from when the PR was first opened without an issue link: + payloadPr: { + number: 42, + node_id: 'PR_42', + draft: false, + author_association: 'NONE', + user: { login: 'external-dev', type: 'User' }, + body: 'Initial body without issue link', + }, + // Live PR state on GitHub after the author updated the description: + pr: { + number: 42, + node_id: 'PR_42', + draft: true, + author_association: 'NONE', + user: { login: 'external-dev', type: 'User' }, + body: 'Fixes #123', + }, + issuesByNumber: { + 123: { number: 123, assignees: [{ login: 'external-dev' }] }, + }, + }); + + await harness.run(); + + assert.strictEqual(harness.getFailedMessage(), null); + assert.strictEqual(harness.getGraphqlCalls().length, 0); + assert.strictEqual(harness.getCreatedComments().length, 0); + }); + it('on opened without linked issue: converts non-draft PR to draft, posts comment, and fails', async () => { const harness = createHarness({ action: 'opened',