From 0dfdbc2a3d9baea48cb61768e45907ed8b7c1ff9 Mon Sep 17 00:00:00 2001 From: Rajesh Kamal Date: Tue, 31 Mar 2026 22:34:00 -0700 Subject: [PATCH 1/5] Add PR governance workflow and update contribution guidelines Adds a GitHub Actions workflow that runs on PRs targeting main: - Checks for a linked GitHub issue (hard fail if missing) - Posts a prioritization comment based on the issue's milestone - Skips dependabot PRs and PRs with 'skip-governance' label - Updates comment in place on subsequent runs (no duplicates) Also updates CONTRIBUTING.md with linked issue guidance. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/pr-governance.yml | 338 +++++++++++++++++++++++++ .vscode/cspell-github-user-aliases.txt | 10 +- cli/azd/CONTRIBUTING.md | 3 +- 3 files changed, 346 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/pr-governance.yml diff --git a/.github/workflows/pr-governance.yml b/.github/workflows/pr-governance.yml new file mode 100644 index 00000000000..dfe05a2f8f4 --- /dev/null +++ b/.github/workflows/pr-governance.yml @@ -0,0 +1,338 @@ +name: pr-governance + +on: + pull_request: + branches: [main] + types: [opened, edited, synchronize, labeled, unlabeled] + +permissions: + pull-requests: write + issues: write + +jobs: + governance-checks: + name: PR Governance + runs-on: ubuntu-latest + steps: + - name: Check linked issue + id: issue-check + uses: actions/github-script@v7 + with: + script: | + const pr = context.payload.pull_request; + const body = pr.body || ''; + + // Skip for dependabot and automated PRs + const skipAuthors = ['dependabot[bot]', 'dependabot', 'app/dependabot']; + if (skipAuthors.includes(pr.user.login)) { + console.log(`Skipping: automated PR by ${pr.user.login}`); + core.setOutput('skipped', 'true'); + return; + } + + // Skip for PRs with specific labels + const labels = pr.labels.map(l => l.name); + const skipLabels = ['skip-governance']; + if (labels.some(l => skipLabels.includes(l))) { + console.log(`Skipping: PR has exempt label`); + core.setOutput('skipped', 'true'); + return; + } + + // Check for issue references in body + const issuePatterns = [ + /\b(?:fixes|closes|resolves|fix|close|resolve)\s+#(\d+)/gi, + /\b(?:fixes|closes|resolves|fix|close|resolve)\s+https:\/\/github\.com\/[^/]+\/[^/]+\/issues\/(\d+)/gi, + /https:\/\/github\.com\/[^/]+\/[^/]+\/issues\/(\d+)/gi, + ]; + + let linkedIssueNumbers = []; + for (const pattern of issuePatterns) { + let match; + while ((match = pattern.exec(body)) !== null) { + const num = parseInt(match[1]); + if (!linkedIssueNumbers.includes(num)) { + linkedIssueNumbers.push(num); + } + } + } + + // Also check GitHub's closing issue references (sidebar links) + const query = `query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + closingIssuesReferences(first: 10) { + nodes { number } + } + } + } + }`; + + const result = await github.graphql(query, { + owner: context.repo.owner, + repo: context.repo.repo, + number: pr.number, + }); + + const sidebarLinked = result.repository.pullRequest.closingIssuesReferences.nodes; + for (const issue of sidebarLinked) { + if (!linkedIssueNumbers.includes(issue.number)) { + linkedIssueNumbers.push(issue.number); + } + } + + if (linkedIssueNumbers.length === 0) { + // Post or update a comment so the contributor sees the message on the PR + const BOT_MARKER = ''; + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + per_page: 100, + }); + const existingComment = comments.find(c => c.body && c.body.includes(BOT_MARKER)); + + const commentBody = [ + BOT_MARKER, + `### 🔗 Linked Issue Required`, + '', + 'Thanks for the contribution! Please link a GitHub issue to this PR by adding `Fixes #123` to the description or using the sidebar.', + 'No issue yet? Feel free to [create one](https://github.com/Azure/azure-dev/issues/new)!', + ].join('\n'); + + try { + if (existingComment) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existingComment.id, + body: commentBody, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + body: commentBody, + }); + } + } catch (e) { + console.log(`Could not post comment (expected for fork PRs): ${e.message}`); + } + + core.setFailed('PR must be linked to a GitHub issue.'); + return; + } + + console.log(`✅ PR has linked issue(s): ${linkedIssueNumbers.join(', ')}`); + core.setOutput('issue_numbers', JSON.stringify(linkedIssueNumbers)); + + - name: Check issue priority + if: steps.issue-check.outputs.skipped != 'true' && steps.issue-check.outcome == 'success' + uses: actions/github-script@v7 + env: + PROJECT_TOKEN: ${{ secrets.PROJECT_READ_TOKEN }} + with: + script: | + const issueNumbers = JSON.parse('${{ steps.issue-check.outputs.issue_numbers }}'); + const pr = context.payload.pull_request; + const projectToken = process.env.PROJECT_TOKEN; + + // Determine current month milestone name (e.g., "April 2026") + const now = new Date(); + const monthNames = [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December' + ]; + const currentMilestoneName = `${monthNames[now.getMonth()]} ${now.getFullYear()}`; + let issueDetails = []; + + // Check sprint assignment via Project #182 (if token available) + let sprintInfo = {}; + if (projectToken) { + try { + async function graphqlWithToken(query, token) { + const response = await fetch('https://api.github.com/graphql', { + method: 'POST', + headers: { + 'Authorization': `bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ query }), + }); + const json = await response.json(); + if (json.errors) throw new Error(json.errors[0].message); + return json.data; + } + + // Get current sprint iteration + const sprintData = await graphqlWithToken(`{ + organization(login: "Azure") { + projectV2(number: 182) { + field(name: "Sprint") { + ... on ProjectV2IterationField { + id + configuration { + iterations { id title startDate duration } + } + } + } + } + } + }`, projectToken); + + const iterations = sprintData.organization.projectV2.field.configuration.iterations; + + // Find the current sprint (today falls within start + duration) + const today = new Date(); + const currentSprint = iterations.find(iter => { + const start = new Date(iter.startDate); + const end = new Date(start); + end.setDate(end.getDate() + iter.duration); + return today >= start && today < end; + }); + + if (currentSprint) { + console.log(`Current sprint: ${currentSprint.title}`); + + // Query sprint assignment per issue (efficient — no pagination needed) + for (const issueNum of issueNumbers) { + try { + const issueData = await graphqlWithToken(`{ + repository(owner: "Azure", name: "azure-dev") { + issue(number: ${issueNum}) { + projectItems(first: 10) { + nodes { + project { number } + fieldValueByName(name: "Sprint") { + ... on ProjectV2ItemFieldIterationValue { + title + } + } + } + } + } + } + }`, projectToken); + + const projectItems = issueData.repository.issue.projectItems.nodes; + const match = projectItems.find(item => + item.project.number === 182 && item.fieldValueByName?.title + ); + if (match) { + sprintInfo[issueNum] = match.fieldValueByName.title; + console.log(`Issue #${issueNum} sprint: ${match.fieldValueByName.title}`); + } + } catch (err) { + console.log(`Could not check sprint for issue #${issueNum}: ${err.message}`); + } + } + } + } catch (err) { + console.log(`Sprint check skipped: ${err.message}`); + } + } else { + console.log('Sprint check skipped: no PROJECT_READ_TOKEN'); + } + + // If sprint found, skip milestone check entirely + const hasCurrentSprint = issueNumbers.some(n => sprintInfo[n]); + + if (!hasCurrentSprint) { + // Fetch milestones for each issue + for (const issueNum of issueNumbers) { + try { + const issue = await github.rest.issues.get({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNum, + }); + + const milestone = issue.data.milestone; + const milestoneTitle = milestone ? milestone.title : 'None'; + + issueDetails.push({ + number: issueNum, + milestone: milestoneTitle, + sprint: null, + isCurrentMonth: milestoneTitle === currentMilestoneName, + }); + } catch (err) { + console.log(`Could not fetch issue #${issueNum}: ${err.message}`); + } + } + } + + const hasCurrentMilestone = issueDetails.some(i => i.isCurrentMonth); + + // Find existing bot comment to update + const BOT_MARKER = ''; + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + per_page: 100, + }); + const existingComment = comments.find(c => c.body && c.body.includes(BOT_MARKER)); + + let commentBody = ''; + + if (hasCurrentSprint) { + const sprintName = Object.values(sprintInfo)[0] || 'current sprint'; + console.log(`✅ Issue is in current sprint: ${sprintName}. All good!`); + + // Delete existing comment if one was posted earlier + if (existingComment) { + try { + await github.rest.issues.deleteComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existingComment.id, + }); + console.log('Removed prior governance comment — issue is now in sprint'); + } catch (e) { + console.log(`Could not remove comment (expected for fork PRs): ${e.message}`); + } + } + return; + } else if (hasCurrentMilestone) { + console.log('✅ Issue is in the current milestone'); + commentBody = [ + BOT_MARKER, + `### 📋 Milestone: ${currentMilestoneName}`, + '', + `This work is tracked for **${currentMilestoneName}**. The team will review it soon!`, + ].join('\n'); + } else { + console.log('â„šī¸ Issue is not in current sprint or milestone'); + commentBody = [ + BOT_MARKER, + `### 📋 Prioritization Note`, + '', + `Thanks for the contribution! The linked issue isn't in the current milestone yet.`, + 'Review may take a bit longer — reach out to **@rajeshkamal5050** or **@kristenwomack** if you\'d like to discuss prioritization.', + ].join('\n'); + } + + // Post or update comment + try { + if (existingComment) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existingComment.id, + body: commentBody, + }); + console.log('Updated existing governance comment'); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + body: commentBody, + }); + console.log('Posted governance comment'); + } + } catch (e) { + console.log(`Could not post comment (expected for fork PRs): ${e.message}`); + } diff --git a/.vscode/cspell-github-user-aliases.txt b/.vscode/cspell-github-user-aliases.txt index 804adb51023..f1e16d03611 100644 --- a/.vscode/cspell-github-user-aliases.txt +++ b/.vscode/cspell-github-user-aliases.txt @@ -23,9 +23,11 @@ isatty joho karolz kitsiosk +kristenwomack LianwMS Lunatico9 mattn +Menghua1 mikekistler Mstiekema multierr @@ -34,21 +36,21 @@ otiai10 pamelafox pauldotyu pbnj +rajeshkamal richardpark-msft rujche +Saipriya saragluna scottaddie sebastianmattar sergi sethvargo +spboyer stretchr -tidwall theckman TheEskhaton +tidwall tonybaloney vivazqu weilim Yionse -Saipriya -Menghua1 -spboyer diff --git a/cli/azd/CONTRIBUTING.md b/cli/azd/CONTRIBUTING.md index da3feeb05d6..6e244ee7cc1 100644 --- a/cli/azd/CONTRIBUTING.md +++ b/cli/azd/CONTRIBUTING.md @@ -14,7 +14,8 @@ In general, to make contributions a smooth and easy experience, we encourage the - Check existing issues for [bugs][bug issues] or [enhancements][enhancement issues]. - Open an issue if things aren't working as expected, or if an enhancement is being proposed. - Start a conversation on the issue if you are thinking of submitting a pull request. -- Submit a pull request. The `azd` team will work with you to review the changes and provide feedback. Once the pull request is accepted, a member will merge the changes. Thank you for taking time out of your day to help improve our community! +- Submit a pull request **linked to the issue** (e.g., add `Fixes #123` to the PR description). PRs without a linked issue will be flagged by our automated checks. Issues in the current milestone get priority review — if yours isn't prioritized yet, tag **@rajeshkamal5050** or **@kristenwomack** and we'll help get it sorted. +- The `azd` team will work with you to review the changes and provide feedback. Once the pull request is accepted, a member will merge the changes. Thank you for taking time out of your day to help improve our community! ## Building `azd` From ae9ea606a3c27d9a420e78997e36483a682b2dbf Mon Sep 17 00:00:00 2001 From: Rajesh Kamal Date: Wed, 1 Apr 2026 14:29:54 -0700 Subject: [PATCH 2/5] Extract governance scripts into separate files Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/scripts/pr-governance-issue-check.js | 110 ++++++ .../scripts/pr-governance-priority-check.js | 206 ++++++++++++ .github/workflows/pr-governance.yml | 318 +----------------- 3 files changed, 325 insertions(+), 309 deletions(-) create mode 100644 .github/scripts/pr-governance-issue-check.js create mode 100644 .github/scripts/pr-governance-priority-check.js diff --git a/.github/scripts/pr-governance-issue-check.js b/.github/scripts/pr-governance-issue-check.js new file mode 100644 index 00000000000..95b8097449f --- /dev/null +++ b/.github/scripts/pr-governance-issue-check.js @@ -0,0 +1,110 @@ +// PR Governance: Check that a PR has at least one linked GitHub issue. +// Posts a comment if no issue is found and fails the check. +module.exports = async ({ github, context, core }) => { + const pr = context.payload.pull_request; + const body = pr.body || ''; + + // Skip for dependabot and automated PRs + const skipAuthors = ['dependabot[bot]', 'dependabot', 'app/dependabot']; + if (skipAuthors.includes(pr.user.login)) { + console.log(`Skipping: automated PR by ${pr.user.login}`); + core.setOutput('skipped', 'true'); + return; + } + + // Skip for PRs with specific labels + const labels = pr.labels.map(l => l.name); + const skipLabels = ['skip-governance']; + if (labels.some(l => skipLabels.includes(l))) { + console.log(`Skipping: PR has exempt label`); + core.setOutput('skipped', 'true'); + return; + } + + // Check for issue references in body + const issuePatterns = [ + /\b(?:fixes|closes|resolves|fix|close|resolve)\s+#(\d+)/gi, + /\b(?:fixes|closes|resolves|fix|close|resolve)\s+https:\/\/github\.com\/[^/]+\/[^/]+\/issues\/(\d+)/gi, + /https:\/\/github\.com\/[^/]+\/[^/]+\/issues\/(\d+)/gi, + ]; + + let linkedIssueNumbers = []; + for (const pattern of issuePatterns) { + let match; + while ((match = pattern.exec(body)) !== null) { + const num = parseInt(match[1]); + if (!linkedIssueNumbers.includes(num)) { + linkedIssueNumbers.push(num); + } + } + } + + // Also check GitHub's closing issue references (sidebar links) + const query = `query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + closingIssuesReferences(first: 10) { + nodes { number } + } + } + } + }`; + + const result = await github.graphql(query, { + owner: context.repo.owner, + repo: context.repo.repo, + number: pr.number, + }); + + const sidebarLinked = result.repository.pullRequest.closingIssuesReferences.nodes; + for (const issue of sidebarLinked) { + if (!linkedIssueNumbers.includes(issue.number)) { + linkedIssueNumbers.push(issue.number); + } + } + + if (linkedIssueNumbers.length === 0) { + const BOT_MARKER = ''; + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + per_page: 100, + }); + const existingComment = comments.find(c => c.body && c.body.includes(BOT_MARKER)); + + const commentBody = [ + BOT_MARKER, + `### 🔗 Linked Issue Required`, + '', + 'Thanks for the contribution! Please link a GitHub issue to this PR by adding `Fixes #123` to the description or using the sidebar.', + 'No issue yet? Feel free to [create one](https://github.com/Azure/azure-dev/issues/new)!', + ].join('\n'); + + try { + if (existingComment) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existingComment.id, + body: commentBody, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + body: commentBody, + }); + } + } catch (e) { + console.log(`Could not post comment (expected for fork PRs): ${e.message}`); + } + + core.setFailed('PR must be linked to a GitHub issue.'); + return; + } + + console.log(`✅ PR has linked issue(s): ${linkedIssueNumbers.join(', ')}`); + core.setOutput('issue_numbers', JSON.stringify(linkedIssueNumbers)); +}; diff --git a/.github/scripts/pr-governance-priority-check.js b/.github/scripts/pr-governance-priority-check.js new file mode 100644 index 00000000000..66015095106 --- /dev/null +++ b/.github/scripts/pr-governance-priority-check.js @@ -0,0 +1,206 @@ +// PR Governance: Check sprint/milestone status of linked issues +// and post informational comments to help contributors understand prioritization. +module.exports = async ({ github, context, core }) => { + const issueNumbers = JSON.parse(process.env.ISSUE_NUMBERS); + const pr = context.payload.pull_request; + const projectToken = process.env.PROJECT_TOKEN; + + // Determine current month milestone name (e.g., "April 2026") + const now = new Date(); + const monthNames = [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December' + ]; + const currentMilestoneName = `${monthNames[now.getMonth()]} ${now.getFullYear()}`; + let issueDetails = []; + + // Check sprint assignment via Project #182 (if token available) + let sprintInfo = {}; + if (projectToken) { + try { + async function graphqlWithToken(query, token) { + const response = await fetch('https://api.github.com/graphql', { + method: 'POST', + headers: { + 'Authorization': `bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ query }), + }); + const json = await response.json(); + if (json.errors) throw new Error(json.errors[0].message); + return json.data; + } + + // Get current sprint iteration + const sprintData = await graphqlWithToken(`{ + organization(login: "Azure") { + projectV2(number: 182) { + field(name: "Sprint") { + ... on ProjectV2IterationField { + id + configuration { + iterations { id title startDate duration } + } + } + } + } + } + }`, projectToken); + + const iterations = sprintData.organization.projectV2.field.configuration.iterations; + + // Find the current sprint (today falls within start + duration) + const today = new Date(); + const currentSprint = iterations.find(iter => { + const start = new Date(iter.startDate); + const end = new Date(start); + end.setDate(end.getDate() + iter.duration); + return today >= start && today < end; + }); + + if (currentSprint) { + console.log(`Current sprint: ${currentSprint.title}`); + + // Query sprint assignment per issue + for (const issueNum of issueNumbers) { + try { + const issueData = await graphqlWithToken(`{ + repository(owner: "Azure", name: "azure-dev") { + issue(number: ${issueNum}) { + projectItems(first: 10) { + nodes { + project { number } + fieldValueByName(name: "Sprint") { + ... on ProjectV2ItemFieldIterationValue { + title + } + } + } + } + } + } + }`, projectToken); + + const projectItems = issueData.repository.issue.projectItems.nodes; + const match = projectItems.find(item => + item.project.number === 182 && item.fieldValueByName?.title + ); + if (match) { + sprintInfo[issueNum] = match.fieldValueByName.title; + console.log(`Issue #${issueNum} sprint: ${match.fieldValueByName.title}`); + } + } catch (err) { + console.log(`Could not check sprint for issue #${issueNum}: ${err.message}`); + } + } + } + } catch (err) { + console.log(`Sprint check skipped: ${err.message}`); + } + } else { + console.log('Sprint check skipped: no PROJECT_READ_TOKEN'); + } + + // If sprint found, skip milestone check entirely + const hasCurrentSprint = issueNumbers.some(n => sprintInfo[n]); + + if (!hasCurrentSprint) { + // Fetch milestones for each issue + for (const issueNum of issueNumbers) { + try { + const issue = await github.rest.issues.get({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNum, + }); + + const milestone = issue.data.milestone; + const milestoneTitle = milestone ? milestone.title : 'None'; + + issueDetails.push({ + number: issueNum, + milestone: milestoneTitle, + sprint: null, + isCurrentMonth: milestoneTitle === currentMilestoneName, + }); + } catch (err) { + console.log(`Could not fetch issue #${issueNum}: ${err.message}`); + } + } + } + + const hasCurrentMilestone = issueDetails.some(i => i.isCurrentMonth); + + // Find existing bot comment to update + const BOT_MARKER = ''; + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + per_page: 100, + }); + const existingComment = comments.find(c => c.body && c.body.includes(BOT_MARKER)); + + let commentBody = ''; + + if (hasCurrentSprint) { + const sprintName = Object.values(sprintInfo)[0] || 'current sprint'; + console.log(`✅ Issue is in current sprint: ${sprintName}. All good!`); + + // Delete existing comment if one was posted earlier + if (existingComment) { + try { + await github.rest.issues.deleteComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existingComment.id, + }); + console.log('Removed prior governance comment — issue is now in sprint'); + } catch (e) { + console.log(`Could not remove comment (expected for fork PRs): ${e.message}`); + } + } + return; + } else if (hasCurrentMilestone) { + console.log('✅ Issue is in the current milestone'); + commentBody = [ + BOT_MARKER, + `### 📋 Milestone: ${currentMilestoneName}`, + '', + `This work is tracked for **${currentMilestoneName}**. The team will review it soon!`, + ].join('\n'); + } else { + console.log('â„šī¸ Issue is not in current sprint or milestone'); + commentBody = [ + BOT_MARKER, + `### 📋 Prioritization Note`, + '', + `Thanks for the contribution! The linked issue isn't in the current milestone yet.`, + 'Review may take a bit longer — reach out to **@rajeshkamal5050** or **@kristenwomack** if you\'d like to discuss prioritization.', + ].join('\n'); + } + + // Post or update comment + try { + if (existingComment) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existingComment.id, + body: commentBody, + }); + console.log('Updated existing governance comment'); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + body: commentBody, + }); + console.log('Posted governance comment'); + } + } catch (e) { + console.log(`Could not post comment (expected for fork PRs): ${e.message}`); + } +}; diff --git a/.github/workflows/pr-governance.yml b/.github/workflows/pr-governance.yml index dfe05a2f8f4..0d81f184454 100644 --- a/.github/workflows/pr-governance.yml +++ b/.github/workflows/pr-governance.yml @@ -8,331 +8,31 @@ on: permissions: pull-requests: write issues: write + contents: read jobs: governance-checks: name: PR Governance runs-on: ubuntu-latest steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Check linked issue id: issue-check uses: actions/github-script@v7 with: script: | - const pr = context.payload.pull_request; - const body = pr.body || ''; - - // Skip for dependabot and automated PRs - const skipAuthors = ['dependabot[bot]', 'dependabot', 'app/dependabot']; - if (skipAuthors.includes(pr.user.login)) { - console.log(`Skipping: automated PR by ${pr.user.login}`); - core.setOutput('skipped', 'true'); - return; - } - - // Skip for PRs with specific labels - const labels = pr.labels.map(l => l.name); - const skipLabels = ['skip-governance']; - if (labels.some(l => skipLabels.includes(l))) { - console.log(`Skipping: PR has exempt label`); - core.setOutput('skipped', 'true'); - return; - } - - // Check for issue references in body - const issuePatterns = [ - /\b(?:fixes|closes|resolves|fix|close|resolve)\s+#(\d+)/gi, - /\b(?:fixes|closes|resolves|fix|close|resolve)\s+https:\/\/github\.com\/[^/]+\/[^/]+\/issues\/(\d+)/gi, - /https:\/\/github\.com\/[^/]+\/[^/]+\/issues\/(\d+)/gi, - ]; - - let linkedIssueNumbers = []; - for (const pattern of issuePatterns) { - let match; - while ((match = pattern.exec(body)) !== null) { - const num = parseInt(match[1]); - if (!linkedIssueNumbers.includes(num)) { - linkedIssueNumbers.push(num); - } - } - } - - // Also check GitHub's closing issue references (sidebar links) - const query = `query($owner: String!, $repo: String!, $number: Int!) { - repository(owner: $owner, name: $repo) { - pullRequest(number: $number) { - closingIssuesReferences(first: 10) { - nodes { number } - } - } - } - }`; - - const result = await github.graphql(query, { - owner: context.repo.owner, - repo: context.repo.repo, - number: pr.number, - }); - - const sidebarLinked = result.repository.pullRequest.closingIssuesReferences.nodes; - for (const issue of sidebarLinked) { - if (!linkedIssueNumbers.includes(issue.number)) { - linkedIssueNumbers.push(issue.number); - } - } - - if (linkedIssueNumbers.length === 0) { - // Post or update a comment so the contributor sees the message on the PR - const BOT_MARKER = ''; - const comments = await github.paginate(github.rest.issues.listComments, { - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number, - per_page: 100, - }); - const existingComment = comments.find(c => c.body && c.body.includes(BOT_MARKER)); - - const commentBody = [ - BOT_MARKER, - `### 🔗 Linked Issue Required`, - '', - 'Thanks for the contribution! Please link a GitHub issue to this PR by adding `Fixes #123` to the description or using the sidebar.', - 'No issue yet? Feel free to [create one](https://github.com/Azure/azure-dev/issues/new)!', - ].join('\n'); - - try { - if (existingComment) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existingComment.id, - body: commentBody, - }); - } else { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number, - body: commentBody, - }); - } - } catch (e) { - console.log(`Could not post comment (expected for fork PRs): ${e.message}`); - } - - core.setFailed('PR must be linked to a GitHub issue.'); - return; - } - - console.log(`✅ PR has linked issue(s): ${linkedIssueNumbers.join(', ')}`); - core.setOutput('issue_numbers', JSON.stringify(linkedIssueNumbers)); + const script = require('./.github/scripts/pr-governance-issue-check.js'); + await script({ github, context, core }); - name: Check issue priority if: steps.issue-check.outputs.skipped != 'true' && steps.issue-check.outcome == 'success' uses: actions/github-script@v7 env: PROJECT_TOKEN: ${{ secrets.PROJECT_READ_TOKEN }} + ISSUE_NUMBERS: ${{ steps.issue-check.outputs.issue_numbers }} with: script: | - const issueNumbers = JSON.parse('${{ steps.issue-check.outputs.issue_numbers }}'); - const pr = context.payload.pull_request; - const projectToken = process.env.PROJECT_TOKEN; - - // Determine current month milestone name (e.g., "April 2026") - const now = new Date(); - const monthNames = [ - 'January', 'February', 'March', 'April', 'May', 'June', - 'July', 'August', 'September', 'October', 'November', 'December' - ]; - const currentMilestoneName = `${monthNames[now.getMonth()]} ${now.getFullYear()}`; - let issueDetails = []; - - // Check sprint assignment via Project #182 (if token available) - let sprintInfo = {}; - if (projectToken) { - try { - async function graphqlWithToken(query, token) { - const response = await fetch('https://api.github.com/graphql', { - method: 'POST', - headers: { - 'Authorization': `bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ query }), - }); - const json = await response.json(); - if (json.errors) throw new Error(json.errors[0].message); - return json.data; - } - - // Get current sprint iteration - const sprintData = await graphqlWithToken(`{ - organization(login: "Azure") { - projectV2(number: 182) { - field(name: "Sprint") { - ... on ProjectV2IterationField { - id - configuration { - iterations { id title startDate duration } - } - } - } - } - } - }`, projectToken); - - const iterations = sprintData.organization.projectV2.field.configuration.iterations; - - // Find the current sprint (today falls within start + duration) - const today = new Date(); - const currentSprint = iterations.find(iter => { - const start = new Date(iter.startDate); - const end = new Date(start); - end.setDate(end.getDate() + iter.duration); - return today >= start && today < end; - }); - - if (currentSprint) { - console.log(`Current sprint: ${currentSprint.title}`); - - // Query sprint assignment per issue (efficient — no pagination needed) - for (const issueNum of issueNumbers) { - try { - const issueData = await graphqlWithToken(`{ - repository(owner: "Azure", name: "azure-dev") { - issue(number: ${issueNum}) { - projectItems(first: 10) { - nodes { - project { number } - fieldValueByName(name: "Sprint") { - ... on ProjectV2ItemFieldIterationValue { - title - } - } - } - } - } - } - }`, projectToken); - - const projectItems = issueData.repository.issue.projectItems.nodes; - const match = projectItems.find(item => - item.project.number === 182 && item.fieldValueByName?.title - ); - if (match) { - sprintInfo[issueNum] = match.fieldValueByName.title; - console.log(`Issue #${issueNum} sprint: ${match.fieldValueByName.title}`); - } - } catch (err) { - console.log(`Could not check sprint for issue #${issueNum}: ${err.message}`); - } - } - } - } catch (err) { - console.log(`Sprint check skipped: ${err.message}`); - } - } else { - console.log('Sprint check skipped: no PROJECT_READ_TOKEN'); - } - - // If sprint found, skip milestone check entirely - const hasCurrentSprint = issueNumbers.some(n => sprintInfo[n]); - - if (!hasCurrentSprint) { - // Fetch milestones for each issue - for (const issueNum of issueNumbers) { - try { - const issue = await github.rest.issues.get({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNum, - }); - - const milestone = issue.data.milestone; - const milestoneTitle = milestone ? milestone.title : 'None'; - - issueDetails.push({ - number: issueNum, - milestone: milestoneTitle, - sprint: null, - isCurrentMonth: milestoneTitle === currentMilestoneName, - }); - } catch (err) { - console.log(`Could not fetch issue #${issueNum}: ${err.message}`); - } - } - } - - const hasCurrentMilestone = issueDetails.some(i => i.isCurrentMonth); - - // Find existing bot comment to update - const BOT_MARKER = ''; - const comments = await github.paginate(github.rest.issues.listComments, { - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number, - per_page: 100, - }); - const existingComment = comments.find(c => c.body && c.body.includes(BOT_MARKER)); - - let commentBody = ''; - - if (hasCurrentSprint) { - const sprintName = Object.values(sprintInfo)[0] || 'current sprint'; - console.log(`✅ Issue is in current sprint: ${sprintName}. All good!`); - - // Delete existing comment if one was posted earlier - if (existingComment) { - try { - await github.rest.issues.deleteComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existingComment.id, - }); - console.log('Removed prior governance comment — issue is now in sprint'); - } catch (e) { - console.log(`Could not remove comment (expected for fork PRs): ${e.message}`); - } - } - return; - } else if (hasCurrentMilestone) { - console.log('✅ Issue is in the current milestone'); - commentBody = [ - BOT_MARKER, - `### 📋 Milestone: ${currentMilestoneName}`, - '', - `This work is tracked for **${currentMilestoneName}**. The team will review it soon!`, - ].join('\n'); - } else { - console.log('â„šī¸ Issue is not in current sprint or milestone'); - commentBody = [ - BOT_MARKER, - `### 📋 Prioritization Note`, - '', - `Thanks for the contribution! The linked issue isn't in the current milestone yet.`, - 'Review may take a bit longer — reach out to **@rajeshkamal5050** or **@kristenwomack** if you\'d like to discuss prioritization.', - ].join('\n'); - } - - // Post or update comment - try { - if (existingComment) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existingComment.id, - body: commentBody, - }); - console.log('Updated existing governance comment'); - } else { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number, - body: commentBody, - }); - console.log('Posted governance comment'); - } - } catch (e) { - console.log(`Could not post comment (expected for fork PRs): ${e.message}`); - } + const script = require('./.github/scripts/pr-governance-priority-check.js'); + await script({ github, context, core }); From 3cb2660ce4b70f2ced80c98906d00e3a852146ea Mon Sep 17 00:00:00 2001 From: Rajesh Kamal Date: Wed, 1 Apr 2026 14:37:26 -0700 Subject: [PATCH 3/5] Simplify issue check to use only closingIssuesReferences API Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/scripts/pr-governance-issue-check.js | 30 +++----------------- 1 file changed, 4 insertions(+), 26 deletions(-) diff --git a/.github/scripts/pr-governance-issue-check.js b/.github/scripts/pr-governance-issue-check.js index 95b8097449f..ab4998511b8 100644 --- a/.github/scripts/pr-governance-issue-check.js +++ b/.github/scripts/pr-governance-issue-check.js @@ -2,7 +2,6 @@ // Posts a comment if no issue is found and fails the check. module.exports = async ({ github, context, core }) => { const pr = context.payload.pull_request; - const body = pr.body || ''; // Skip for dependabot and automated PRs const skipAuthors = ['dependabot[bot]', 'dependabot', 'app/dependabot']; @@ -21,25 +20,8 @@ module.exports = async ({ github, context, core }) => { return; } - // Check for issue references in body - const issuePatterns = [ - /\b(?:fixes|closes|resolves|fix|close|resolve)\s+#(\d+)/gi, - /\b(?:fixes|closes|resolves|fix|close|resolve)\s+https:\/\/github\.com\/[^/]+\/[^/]+\/issues\/(\d+)/gi, - /https:\/\/github\.com\/[^/]+\/[^/]+\/issues\/(\d+)/gi, - ]; - - let linkedIssueNumbers = []; - for (const pattern of issuePatterns) { - let match; - while ((match = pattern.exec(body)) !== null) { - const num = parseInt(match[1]); - if (!linkedIssueNumbers.includes(num)) { - linkedIssueNumbers.push(num); - } - } - } - - // Also check GitHub's closing issue references (sidebar links) + // Check linked issues via GitHub's closingIssuesReferences API + // Covers closing keywords (Fixes/Closes/Resolves #123) and sidebar links const query = `query($owner: String!, $repo: String!, $number: Int!) { repository(owner: $owner, name: $repo) { pullRequest(number: $number) { @@ -56,12 +38,8 @@ module.exports = async ({ github, context, core }) => { number: pr.number, }); - const sidebarLinked = result.repository.pullRequest.closingIssuesReferences.nodes; - for (const issue of sidebarLinked) { - if (!linkedIssueNumbers.includes(issue.number)) { - linkedIssueNumbers.push(issue.number); - } - } + const linkedIssues = result.repository.pullRequest.closingIssuesReferences.nodes; + const linkedIssueNumbers = linkedIssues.map(i => i.number); if (linkedIssueNumbers.length === 0) { const BOT_MARKER = ''; From 0de6132ca4cae244e679fe196c87aaf4d759a0cd Mon Sep 17 00:00:00 2001 From: Rajesh Kamal Date: Wed, 1 Apr 2026 18:21:01 -0700 Subject: [PATCH 4/5] Address review feedback: hardening and edge cases - Fix sprint match to check current sprint only (not past/future) - Add concurrency group to prevent duplicate comments - Add response.ok check before parsing JSON - Defensive parsing of ISSUE_NUMBERS env var - Use parseInt for GraphQL issue number interpolation - Skip comment when all lookups fail (avoid misleading message) - Wrap GraphQL and paginate calls in try/catch - Use separate BOT_MARKER per script (issue vs priority) - Skip draft PRs, add ready_for_review trigger Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/scripts/pr-governance-issue-check.js | 63 ++++++++++++++----- .../scripts/pr-governance-priority-check.js | 43 +++++++++---- .github/workflows/pr-governance.yml | 6 +- 3 files changed, 85 insertions(+), 27 deletions(-) diff --git a/.github/scripts/pr-governance-issue-check.js b/.github/scripts/pr-governance-issue-check.js index ab4998511b8..c00910ccd40 100644 --- a/.github/scripts/pr-governance-issue-check.js +++ b/.github/scripts/pr-governance-issue-check.js @@ -3,6 +3,13 @@ module.exports = async ({ github, context, core }) => { const pr = context.payload.pull_request; + // Skip for draft PRs + if (pr.draft) { + console.log('Skipping: draft PR'); + core.setOutput('skipped', 'true'); + return; + } + // Skip for dependabot and automated PRs const skipAuthors = ['dependabot[bot]', 'dependabot', 'app/dependabot']; if (skipAuthors.includes(pr.user.login)) { @@ -32,25 +39,22 @@ module.exports = async ({ github, context, core }) => { } }`; - const result = await github.graphql(query, { - owner: context.repo.owner, - repo: context.repo.repo, - number: pr.number, - }); - - const linkedIssues = result.repository.pullRequest.closingIssuesReferences.nodes; - const linkedIssueNumbers = linkedIssues.map(i => i.number); - - if (linkedIssueNumbers.length === 0) { - const BOT_MARKER = ''; - const comments = await github.paginate(github.rest.issues.listComments, { + let linkedIssueNumbers = []; + try { + const result = await github.graphql(query, { owner: context.repo.owner, repo: context.repo.repo, - issue_number: pr.number, - per_page: 100, + number: pr.number, }); - const existingComment = comments.find(c => c.body && c.body.includes(BOT_MARKER)); + linkedIssueNumbers = result?.repository?.pullRequest?.closingIssuesReferences?.nodes?.map(i => i.number) || []; + } catch (err) { + core.warning(`Could not check linked issues: ${err.message}`); + return; + } + const BOT_MARKER = ''; + + if (linkedIssueNumbers.length === 0) { const commentBody = [ BOT_MARKER, `### 🔗 Linked Issue Required`, @@ -60,6 +64,14 @@ module.exports = async ({ github, context, core }) => { ].join('\n'); try { + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + per_page: 100, + }); + const existingComment = comments.find(c => c.body?.includes(BOT_MARKER)); + if (existingComment) { await github.rest.issues.updateComment({ owner: context.repo.owner, @@ -83,6 +95,27 @@ module.exports = async ({ github, context, core }) => { return; } + // Issues found — clean up any prior "link issue" comment + try { + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + per_page: 100, + }); + const existingComment = comments.find(c => c.body?.includes(BOT_MARKER)); + if (existingComment) { + await github.rest.issues.deleteComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existingComment.id, + }); + console.log('Removed prior "link issue" comment'); + } + } catch (e) { + console.log(`Could not clean up comment: ${e.message}`); + } + console.log(`✅ PR has linked issue(s): ${linkedIssueNumbers.join(', ')}`); core.setOutput('issue_numbers', JSON.stringify(linkedIssueNumbers)); }; diff --git a/.github/scripts/pr-governance-priority-check.js b/.github/scripts/pr-governance-priority-check.js index 66015095106..3a07744236b 100644 --- a/.github/scripts/pr-governance-priority-check.js +++ b/.github/scripts/pr-governance-priority-check.js @@ -1,7 +1,14 @@ // PR Governance: Check sprint/milestone status of linked issues // and post informational comments to help contributors understand prioritization. module.exports = async ({ github, context, core }) => { - const issueNumbers = JSON.parse(process.env.ISSUE_NUMBERS); + let issueNumbers; + try { + issueNumbers = JSON.parse(process.env.ISSUE_NUMBERS || '[]'); + } catch { + console.log('No valid issue numbers provided, skipping priority check'); + return; + } + if (!Array.isArray(issueNumbers) || issueNumbers.length === 0) return; const pr = context.payload.pull_request; const projectToken = process.env.PROJECT_TOKEN; @@ -27,7 +34,8 @@ module.exports = async ({ github, context, core }) => { }, body: JSON.stringify({ query }), }); - const json = await response.json(); + const json = response.ok ? await response.json() : null; + if (!response.ok) throw new Error(`GitHub API returned ${response.status}: ${response.statusText}`); if (json.errors) throw new Error(json.errors[0].message); return json.data; } @@ -64,10 +72,12 @@ module.exports = async ({ github, context, core }) => { // Query sprint assignment per issue for (const issueNum of issueNumbers) { + const num = parseInt(issueNum, 10); + if (isNaN(num)) continue; try { const issueData = await graphqlWithToken(`{ repository(owner: "Azure", name: "azure-dev") { - issue(number: ${issueNum}) { + issue(number: ${num}) { projectItems(first: 10) { nodes { project { number } @@ -84,7 +94,7 @@ module.exports = async ({ github, context, core }) => { const projectItems = issueData.repository.issue.projectItems.nodes; const match = projectItems.find(item => - item.project.number === 182 && item.fieldValueByName?.title + item.project.number === 182 && item.fieldValueByName?.title === currentSprint.title ); if (match) { sprintInfo[issueNum] = match.fieldValueByName.title; @@ -131,16 +141,27 @@ module.exports = async ({ github, context, core }) => { } const hasCurrentMilestone = issueDetails.some(i => i.isCurrentMonth); + const allLookupsFailed = !hasCurrentSprint && issueDetails.length === 0; + + if (allLookupsFailed) { + console.log('âš ī¸ Could not determine sprint or milestone status — skipping comment'); + return; + } // Find existing bot comment to update const BOT_MARKER = ''; - const comments = await github.paginate(github.rest.issues.listComments, { - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number, - per_page: 100, - }); - const existingComment = comments.find(c => c.body && c.body.includes(BOT_MARKER)); + let existingComment; + try { + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + per_page: 100, + }); + existingComment = comments.find(c => c.body?.includes(BOT_MARKER)); + } catch (e) { + console.log(`Could not list comments (expected for fork PRs): ${e.message}`); + } let commentBody = ''; diff --git a/.github/workflows/pr-governance.yml b/.github/workflows/pr-governance.yml index 0d81f184454..ec24bd2eceb 100644 --- a/.github/workflows/pr-governance.yml +++ b/.github/workflows/pr-governance.yml @@ -3,7 +3,11 @@ name: pr-governance on: pull_request: branches: [main] - types: [opened, edited, synchronize, labeled, unlabeled] + types: [opened, edited, synchronize, labeled, unlabeled, ready_for_review] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true permissions: pull-requests: write From 0fd661f857afd80c1969383e3a3388e3928f4197 Mon Sep 17 00:00:00 2001 From: Rajesh Kamal Date: Fri, 3 Apr 2026 08:29:22 -0700 Subject: [PATCH 5/5] Extract project number constant, add timeout and reopened trigger Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/scripts/pr-governance-priority-check.js | 6 ++++-- .github/workflows/pr-governance.yml | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/scripts/pr-governance-priority-check.js b/.github/scripts/pr-governance-priority-check.js index 3a07744236b..15d0111bd1a 100644 --- a/.github/scripts/pr-governance-priority-check.js +++ b/.github/scripts/pr-governance-priority-check.js @@ -1,5 +1,7 @@ // PR Governance: Check sprint/milestone status of linked issues // and post informational comments to help contributors understand prioritization. +const PROJECT_NUMBER = 182; + module.exports = async ({ github, context, core }) => { let issueNumbers; try { @@ -43,7 +45,7 @@ module.exports = async ({ github, context, core }) => { // Get current sprint iteration const sprintData = await graphqlWithToken(`{ organization(login: "Azure") { - projectV2(number: 182) { + projectV2(number: ${PROJECT_NUMBER}) { field(name: "Sprint") { ... on ProjectV2IterationField { id @@ -94,7 +96,7 @@ module.exports = async ({ github, context, core }) => { const projectItems = issueData.repository.issue.projectItems.nodes; const match = projectItems.find(item => - item.project.number === 182 && item.fieldValueByName?.title === currentSprint.title + item.project.number === PROJECT_NUMBER && item.fieldValueByName?.title === currentSprint.title ); if (match) { sprintInfo[issueNum] = match.fieldValueByName.title; diff --git a/.github/workflows/pr-governance.yml b/.github/workflows/pr-governance.yml index ec24bd2eceb..502358b5879 100644 --- a/.github/workflows/pr-governance.yml +++ b/.github/workflows/pr-governance.yml @@ -3,7 +3,7 @@ name: pr-governance on: pull_request: branches: [main] - types: [opened, edited, synchronize, labeled, unlabeled, ready_for_review] + types: [opened, edited, synchronize, labeled, unlabeled, reopened, ready_for_review] concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number }} @@ -18,6 +18,7 @@ jobs: governance-checks: name: PR Governance runs-on: ubuntu-latest + timeout-minutes: 5 steps: - name: Checkout uses: actions/checkout@v4