Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions .github/scripts/pr-governance-issue-check.js
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,
});
}
} 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));
};
229 changes: 229 additions & 0 deletions .github/scripts/pr-governance-priority-check.js
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
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}`);
}
};
43 changes: 43 additions & 0 deletions .github/workflows/pr-governance.yml
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
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 });
Loading
Loading