diff --git a/.github/scripts/pr-governance-issue-check.js b/.github/scripts/pr-governance-issue-check.js new file mode 100644 index 00000000000..c00910ccd40 --- /dev/null +++ b/.github/scripts/pr-governance-issue-check.js @@ -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 = ''; + + 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)); +}; diff --git a/.github/scripts/pr-governance-priority-check.js b/.github/scripts/pr-governance-priority-check.js new file mode 100644 index 00000000000..15d0111bd1a --- /dev/null +++ b/.github/scripts/pr-governance-priority-check.js @@ -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 = ''; + 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}`); + } +}; diff --git a/.github/workflows/pr-governance.yml b/.github/workflows/pr-governance.yml new file mode 100644 index 00000000000..502358b5879 --- /dev/null +++ b/.github/workflows/pr-governance.yml @@ -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 }); 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`