-
Notifications
You must be signed in to change notification settings - Fork 286
Add PR governance workflow #7428
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
rajeshkamal5050
wants to merge
5
commits into
Azure:main
Choose a base branch
from
rajeshkamal5050:pr-governance
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
0dfdbc2
Add PR governance workflow and update contribution guidelines
rajeshkamal5050 ae9ea60
Extract governance scripts into separate files
rajeshkamal5050 3cb2660
Simplify issue check to use only closingIssuesReferences API
rajeshkamal5050 0de6132
Address review feedback: hardening and edge cases
rajeshkamal5050 0fd661f
Extract project number constant, add timeout and reopened trigger
rajeshkamal5050 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,121 @@ | ||
| // 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; | ||
|
|
||
| // 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)) { | ||
| 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 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) { | ||
| closingIssuesReferences(first: 10) { | ||
| nodes { number } | ||
| } | ||
| } | ||
| } | ||
| }`; | ||
|
|
||
| let linkedIssueNumbers = []; | ||
| try { | ||
| const result = await github.graphql(query, { | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| number: pr.number, | ||
| }); | ||
| 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 = '<!-- pr-governance-issue -->'; | ||
|
|
||
| if (linkedIssueNumbers.length === 0) { | ||
| 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 { | ||
| 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, | ||
| 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, | ||
| }); | ||
| } | ||
rajeshkamal5050 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } 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; | ||
| } | ||
|
|
||
| // 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)); | ||
| }; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,229 @@ | ||
| // 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 { | ||
| 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; | ||
|
|
||
| // 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 = 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; | ||
| } | ||
|
|
||
| // Get current sprint iteration | ||
| const sprintData = await graphqlWithToken(`{ | ||
| organization(login: "Azure") { | ||
| projectV2(number: ${PROJECT_NUMBER}) { | ||
| 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) { | ||
| const num = parseInt(issueNum, 10); | ||
| if (isNaN(num)) continue; | ||
| try { | ||
| const issueData = await graphqlWithToken(`{ | ||
| repository(owner: "Azure", name: "azure-dev") { | ||
| issue(number: ${num}) { | ||
| 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 === PROJECT_NUMBER && item.fieldValueByName?.title === currentSprint.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); | ||
| 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 | ||
rajeshkamal5050 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| const BOT_MARKER = '<!-- pr-governance-priority -->'; | ||
| 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 = ''; | ||
|
|
||
| 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}`); | ||
| } | ||
| }; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| name: pr-governance | ||
|
|
||
| on: | ||
| pull_request: | ||
| branches: [main] | ||
| types: [opened, edited, synchronize, labeled, unlabeled, reopened, ready_for_review] | ||
|
|
||
| concurrency: | ||
| group: ${{ github.workflow }}-${{ github.event.pull_request.number }} | ||
| cancel-in-progress: true | ||
|
|
||
| permissions: | ||
| pull-requests: write | ||
| issues: write | ||
| contents: read | ||
|
|
||
| jobs: | ||
| governance-checks: | ||
| name: PR Governance | ||
| runs-on: ubuntu-latest | ||
rajeshkamal5050 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| timeout-minutes: 5 | ||
| steps: | ||
| - name: Checkout | ||
| uses: actions/checkout@v4 | ||
|
|
||
| - name: Check linked issue | ||
| id: issue-check | ||
| uses: actions/github-script@v7 | ||
| with: | ||
| script: | | ||
| 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 script = require('./.github/scripts/pr-governance-priority-check.js'); | ||
| await script({ github, context, core }); | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.