Skip to content

Skip branch-PR validation in validate-pr-target-branch workflow #55

Skip branch-PR validation in validate-pr-target-branch workflow

Skip branch-PR validation in validate-pr-target-branch workflow #55

# This workflow automatically labels issues with the preview/RC version when their fixing PR
# is merged into main or a release branch, and sets their milestone to the current version.
# All version information is read from eng/Versions.props at the merge commit.
name: Label and milestone closed issues
on:
pull_request_target:
types: [closed]
branches:
- main
- release/**
permissions:
issues: write
contents: read
pull-requests: read
jobs:
label:
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
steps:
- name: Label issues and update milestones
uses: actions/github-script@v8
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const prNumber = context.payload.pull_request.number;
const mergeCommitSha = context.payload.pull_request.merge_commit_sha;
// Find issues closed by this PR using GraphQL (include current milestone)
const query = `
query($owner: String!, $repo: String!, $prNumber: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $prNumber) {
closingIssuesReferences(first: 50) {
nodes {
number
milestone {
title
}
}
}
}
}
}
`;
const result = await github.graphql(query, { owner, repo, prNumber });
const closingIssues = result.repository.pullRequest.closingIssuesReferences.nodes;
if (closingIssues.length === 0) {
console.log('No closing issues linked to this PR, skipping');
return;
}
// Read version info from eng/Versions.props at the actual merge commit
const { data: versionFileData } = await github.rest.repos.getContent({
owner,
repo,
path: 'eng/Versions.props',
ref: mergeCommitSha
});
const versionFileContent = Buffer.from(versionFileData.content, 'base64').toString('utf-8');
const versionPrefixMatch = versionFileContent.match(/<VersionPrefix>(\d+\.\d+\.\d+)<\/VersionPrefix>/);
if (!versionPrefixMatch) {
throw new Error('Could not parse VersionPrefix from eng/Versions.props');
}
const versionPrefix = versionPrefixMatch[1];
const preReleaseLabelMatch = versionFileContent.match(/<PreReleaseVersionLabel>(preview|rc)<\/PreReleaseVersionLabel>/);
const preReleaseIterationMatch = versionFileContent.match(/<PreReleaseVersionIteration>(\d+)<\/PreReleaseVersionIteration>/);
let label;
if (preReleaseLabelMatch && preReleaseIterationMatch) {
label = `${preReleaseLabelMatch[1]}-${preReleaseIterationMatch[1]}`;
}
console.log(`Version: ${versionPrefix}, label: ${label ?? 'none'}`);
// Label all closing issues
// (don't filter by state to avoid race conditions where GitHub
// hasn't closed the issue yet when this workflow runs)
const errors = [];
if (label) {
for (const issue of closingIssues) {
console.log(`Adding label '${label}' to issue #${issue.number}`);
try {
await github.rest.issues.addLabels({
owner,
repo,
issue_number: issue.number,
labels: [label]
});
} catch (error) {
errors.push(`Failed to add label to issue #${issue.number}: ${error.message}`);
}
}
}
// Look up the target milestone (e.g. "11.0.0", "10.0.5") via GraphQL,
// including closed milestones to avoid recreating one that already exists.
// The GraphQL query parameter does fuzzy/substring matching (no exact match
// option), so we fetch multiple results and filter client-side.
const targetMilestoneName = versionPrefix;
const milestoneResult = await github.graphql(`
query($owner: String!, $repo: String!, $title: String!) {
repository(owner: $owner, name: $repo) {
milestones(query: $title, states: [OPEN, CLOSED], first: 10) {
nodes {
number
title
}
}
}
}
`, { owner, repo, title: targetMilestoneName });
let milestoneNode = milestoneResult.repository.milestones.nodes
.find(m => m.title === targetMilestoneName);
if (!milestoneNode) {
console.log(`Milestone '${targetMilestoneName}' not found, creating it`);
try {
const { data: created } = await github.rest.issues.createMilestone({
owner,
repo,
title: targetMilestoneName
});
milestoneNode = { number: created.number, title: created.title };
} catch (error) {
throw new Error(`Failed to create milestone '${targetMilestoneName}': ${error.message}`);
}
}
// Set the milestone on closing issues, applying a "min" strategy:
// only update if the issue has no version milestone or the target is earlier
const targetVersion = parseVersion(versionPrefix);
for (const issue of closingIssues) {
const currentTitle = issue.milestone?.title;
const currentVersion = currentTitle ? parseVersion(currentTitle) : null;
if (currentVersion && compareVersions(currentVersion, targetVersion) <= 0) {
console.log(`Issue #${issue.number} already has milestone '${currentTitle}' <= '${targetMilestoneName}', skipping`);
continue;
}
const from = currentTitle ? `'${currentTitle}'` : 'none';
console.log(`Setting milestone on issue #${issue.number} from ${from} to '${targetMilestoneName}'`);
try {
await github.rest.issues.update({
owner,
repo,
issue_number: issue.number,
milestone: milestoneNode.number
});
} catch (error) {
errors.push(`Failed to set milestone on issue #${issue.number}: ${error.message}`);
}
}
if (errors.length > 0) {
throw new Error(`Errors processing issues:\n${errors.join('\n')}`);
}
console.log(`Done. Processed ${closingIssues.length} issue(s) with label '${label ?? 'none'}' and milestone '${targetMilestoneName}'`);
// Parses a milestone title as a semver version, or returns null for
// non-version milestones (e.g. "Backlog", "MQ", "Discussions")
function parseVersion(title) {
const m = title.match(/^(\d+)\.(\d+)\.(\d+)$/);
return m ? [parseInt(m[1]), parseInt(m[2]), parseInt(m[3])] : null;
}
function compareVersions(a, b) {
for (let i = 0; i < 3; i++) {
if (a[i] !== b[i]) return a[i] - b[i];
}
return 0;
}