Skip to content
Open
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
88 changes: 88 additions & 0 deletions .github/workflows/milestone-automation.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
name: Milestone Automation

on:
pull_request_target:
types:
- closed

# Disable permissions for all available scopes by default.
# Any needed permissions should be configured at the job level.
permissions: {}

jobs:
# Assigns merged PRs to the appropriate milestone
assign-milestone:
name: Assign milestone to merged PR
runs-on: ubuntu-24.04
permissions:
pull-requests: write
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This job only calls Issues APIs (issues.listMilestones, issues.update, issues.createComment). Per GitHub’s fine-grained GITHUB_TOKEN permissions, issues: write is sufficient for updating a PR milestone and creating an issue comment on a PR, so pull-requests: write appears unnecessary. Dropping it would reduce the workflow’s privilege surface.

Suggested change
pull-requests: write

Copilot uses AI. Check for mistakes.
issues: write
# Only run on merged PRs
if: github.event.pull_request.merged == true

steps:
- name: Assign or update milestone
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
const pr = context.payload.pull_request;
const prNumber = pr.number;
const currentMilestone = pr.milestone;

// Fetch all open milestones
const { data: milestones } = await github.rest.issues.listMilestones({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
sort: 'due_on',
direction: 'asc'
});
Comment on lines +32 to +39
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issues.listMilestones is paginated (default page size is limited), but this script only reads the first page. If there are more open milestones than the first page, the “earliest open milestone” selection can be wrong (especially for milestones without due dates that rely on title sorting). Consider fetching all pages (e.g., via Octokit pagination) or at least setting per_page: 100 and paging until exhausted before sorting.

Suggested change
// Fetch all open milestones
const { data: milestones } = await github.rest.issues.listMilestones({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
sort: 'due_on',
direction: 'asc'
});
// Fetch all open milestones (handle pagination)
const milestones = await github.paginate(
github.rest.issues.listMilestones,
{
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
sort: 'due_on',
direction: 'asc',
per_page: 100
}
);

Copilot uses AI. Check for mistakes.

if (milestones.length === 0) {
console.log('No open milestones found. Skipping milestone assignment.');
return;
}

// Sort milestones: first by due_on (earliest first), then by title (version number)
const sortedMilestones = milestones.sort((a, b) => {
// If both have due dates, sort by due date
if (a.due_on && b.due_on) {
return new Date(a.due_on) - new Date(b.due_on);
}
// If only one has a due date, prioritize it
if (a.due_on) return -1;
if (b.due_on) return 1;
// Otherwise sort by title (version number) - lower version first
return a.title.localeCompare(b.title, undefined, { numeric: true });
});

const targetMilestone = sortedMilestones[0];
console.log(`Target milestone: ${targetMilestone.title} (#${targetMilestone.number})`);

// Case 1: PR has no milestone - assign to target milestone
if (!currentMilestone) {
console.log(`PR #${prNumber} has no milestone. Assigning to ${targetMilestone.title}.`);
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
milestone: targetMilestone.number
});
return;
}

// Case 2: PR has an open milestone - keep it
if (currentMilestone.state === 'open') {
console.log(`PR #${prNumber} already has open milestone: ${currentMilestone.title}. Keeping it.`);
return;
}

// Case 3: PR has a closed milestone - notify but don't change
console.log(`PR #${prNumber} has closed milestone: ${currentMilestone.title}. Adding notification comment.`);

await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: `This PR was merged with a closed milestone (\`${currentMilestone.title}\`). Please verify this is intentional.\n\nIf not, the next open milestone is \`${targetMilestone.title}\`.`
});
Comment on lines +80 to +88
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The workflow’s “closed milestone” branch only adds a comment and does not move the PR to the earliest open milestone. This conflicts with the PR description/expected behavior (“Updates to earliest open milestone + adds comment”) and the sample comment text (which says it was moved). Update this branch to call the Issues update API to set milestone: targetMilestone.number before/alongside creating the notification comment, and align the comment text accordingly.

Copilot uses AI. Check for mistakes.
Loading