scripts/changeset-assign-milestone.mjs
scripts/changeset-assign-milestone.mjsBrowse 7 files
1,873 tokens
7,079 bytes
Token encoding: o200k_base
Snapshot 506f736
← Back to SKILL.md
1#!/usr/bin/env node2 3import fs from 'fs';4import { execFileSync } from 'child_process';5 6const { fetch, console, process } = globalThis;7 8function printUsage() {9 console.log('Usage: pnpm changeset:assign-milestone -- <path-to-json>');10}11 12function readJson(filePath) {13 const contents = fs.readFileSync(filePath, 'utf8');14 return JSON.parse(contents);15}16 17function parseMilestoneTitle(title) {18 const match = title.match(/^(\d+)\.(\d+)\.x$/);19 if (!match) return null;20 return { major: Number(match[1]), minor: Number(match[2]), title };21}22 23const bumpRanks = {24 none: 0,25 patch: 1,26 minor: 2,27 major: 3,28};29 30function maxBump(left, right) {31 return bumpRanks[right] > bumpRanks[left] ? right : left;32}33 34function bumpFromChangesetLine(line) {35 const match = line.match(36 /^\s*['"]?[^'":]+['"]?\s*:\s*(patch|minor|major)\s*$/,37 );38 return match?.[1];39}40 41function changesetBumpFromContent(content) {42 const parts = content.split('---');43 if (parts.length < 3) {44 return 'none';45 }46 let bump = 'none';47 for (const line of parts[1].split(/\r?\n/)) {48 const lineBump = bumpFromChangesetLine(line);49 if (lineBump) {50 bump = maxBump(bump, lineBump);51 }52 }53 return bump;54}55 56function changedChangesetFiles(baseSha, headSha) {57 const diff = execFileSync(58 'git',59 ['diff', '--name-only', `${baseSha}...${headSha}`, '--', '.changeset'],60 { encoding: 'utf8' },61 ).trim();62 return diff63 ? diff64 .split(/\r?\n/)65 .filter((file) => file.endsWith('.md') && !file.endsWith('README.md'))66 : [];67}68 69function changesetBumpForEvent(event) {70 const baseSha = event?.pull_request?.base?.sha;71 const headSha = event?.pull_request?.head?.sha;72 if (!baseSha || !headSha) {73 return 'none';74 }75 76 let bump = 'none';77 for (const file of changedChangesetFiles(baseSha, headSha)) {78 let content;79 try {80 content = execFileSync('git', ['show', `${headSha}:${file}`], {81 encoding: 'utf8',82 });83 } catch (_error) {84 continue;85 }86 bump = maxBump(bump, changesetBumpFromContent(content));87 }88 return bump;89}90 91async function listOpenMilestones(owner, repo, token) {92 if (process.env.CHANGESET_ASSIGN_MILESTONE_MILESTONES_JSON) {93 return JSON.parse(process.env.CHANGESET_ASSIGN_MILESTONE_MILESTONES_JSON);94 }95 96 const milestonesResponse = await fetch(97 `https://api.github.com/repos/${owner}/${repo}/milestones?state=open&per_page=100`,98 {99 headers: {100 Accept: 'application/vnd.github+json',101 Authorization: `Bearer ${token}`,102 },103 },104 );105 106 if (!milestonesResponse.ok) {107 console.warn(108 `Milestone assignment skipped (failed to list milestones: ${milestonesResponse.status}).`,109 );110 return null;111 }112 113 return milestonesResponse.json();114}115 116async function assignMilestone(releaseBump) {117 if (releaseBump === 'none') {118 console.log('Milestone assignment skipped (no package changes).');119 return;120 }121 122 const token = process.env.GITHUB_TOKEN;123 if (!token) {124 console.warn('Milestone assignment skipped (missing GITHUB_TOKEN).');125 return;126 }127 128 const eventPath = process.env.GITHUB_EVENT_PATH;129 if (!eventPath) {130 console.warn('Milestone assignment skipped (missing GITHUB_EVENT_PATH).');131 return;132 }133 134 let event;135 try {136 event = JSON.parse(fs.readFileSync(eventPath, 'utf8'));137 } catch (_error) {138 console.warn(139 'Milestone assignment skipped (failed to read event payload).',140 );141 return;142 }143 144 const owner = event?.repository?.owner?.login;145 const repo = event?.repository?.name;146 const prNumber = event?.pull_request?.number;147 if (!owner || !repo || !prNumber) {148 console.warn(149 'Milestone assignment skipped (missing repository or PR info).',150 );151 return;152 }153 154 const milestones = await listOpenMilestones(owner, repo, token);155 if (!milestones) {156 return;157 }158 159 const parsed = milestones160 .map((milestone) => ({161 milestone,162 parsed: parseMilestoneTitle(milestone.title),163 }))164 .filter((entry) => entry.parsed)165 .sort((a, b) => {166 if (a.parsed.major !== b.parsed.major)167 return a.parsed.major - b.parsed.major;168 return a.parsed.minor - b.parsed.minor;169 });170 171 if (parsed.length === 0) {172 console.warn(173 'Milestone assignment skipped (no open milestones matching X.Y.x).',174 );175 return;176 }177 178 const majors = Array.from(179 new Set(parsed.map((entry) => entry.parsed.major)),180 ).sort((a, b) => a - b);181 const currentMajor = majors[0];182 const nextMajor = majors[1];183 184 const currentMajorEntries = parsed.filter(185 (entry) => entry.parsed.major === currentMajor,186 );187 const patchTarget = currentMajorEntries[0];188 const minorTarget = currentMajorEntries[1] ?? patchTarget;189 190 let majorTarget;191 if (nextMajor !== undefined) {192 const nextMajorEntries = parsed.filter(193 (entry) => entry.parsed.major === nextMajor,194 );195 majorTarget = nextMajorEntries[0];196 }197 198 let targetEntry;199 if (releaseBump === 'major') {200 targetEntry = majorTarget;201 } else if (releaseBump === 'minor') {202 targetEntry = minorTarget;203 } else {204 targetEntry = patchTarget;205 }206 if (!targetEntry) {207 console.warn(208 'Milestone assignment skipped (not enough open milestones for selection).',209 );210 return;211 }212 213 if (process.env.CHANGESET_ASSIGN_MILESTONE_DRY_RUN === '1') {214 console.log(`Milestone would be set to ${targetEntry.milestone.title}.`);215 return;216 }217 218 const updateResponse = await fetch(219 `https://api.github.com/repos/${owner}/${repo}/issues/${prNumber}`,220 {221 method: 'PATCH',222 headers: {223 Accept: 'application/vnd.github+json',224 Authorization: `Bearer ${token}`,225 },226 body: JSON.stringify({ milestone: targetEntry.milestone.number }),227 },228 );229 230 if (!updateResponse.ok) {231 console.warn(232 `Milestone assignment skipped (failed to update PR milestone: ${updateResponse.status}).`,233 );234 return;235 }236 237 console.log(`Milestone set to ${targetEntry.milestone.title}.`);238}239 240function main() {241 const inputPath = process.argv242 .slice(2)243 .filter((arg) => arg !== '--')244 .find(Boolean);245 if (!inputPath) {246 printUsage();247 console.warn('Milestone assignment skipped (missing input path).');248 return;249 }250 251 let data;252 try {253 data = readJson(inputPath);254 } catch (_error) {255 console.warn(256 `Milestone assignment skipped (failed to read JSON from ${inputPath}).`,257 );258 return;259 }260 261 const requiredBump = data?.required_bump;262 if (!requiredBump) {263 console.warn('Milestone assignment skipped (missing required_bump).');264 return;265 }266 267 let releaseBump = requiredBump;268 if (process.env.GITHUB_EVENT_PATH) {269 try {270 const event = JSON.parse(271 fs.readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8'),272 );273 releaseBump = maxBump(requiredBump, changesetBumpForEvent(event));274 } catch (_error) {275 releaseBump = requiredBump;276 }277 }278 279 assignMilestone(releaseBump).catch((error) => {280 console.warn(`Milestone assignment skipped: ${error.message}`);281 });282}283 284main();285