Skip to content

Commit 9ca8e7c

Browse files
committed
fix: add update-milestones script
Assisted-by: ClaudeCode:claude-sonnet-5 Signed-off-by: Maksim Sukharev <antreesy.web@gmail.com>
1 parent 3228ebf commit 9ca8e7c

2 files changed

Lines changed: 330 additions & 0 deletions

File tree

Makefile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ prepare-changelog:
2929
bump-version:
3030
node ./scripts/bump-version.mjs $(PR)
3131

32+
update-milestones:
33+
node ./scripts/update-milestones.mjs $(BRANCH)
34+
3235
release: appstore create-tag
3336

3437
build-dev: composer-install-dev build-js

scripts/update-milestones.mjs

Lines changed: 327 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,327 @@
1+
/*
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
/**
7+
* Roll the milestones over after a release, per the "Rename milestone..."
8+
* block of the release checklist (https://github.com/nextcloud/spreed/issues/5879):
9+
* 1. Rename the open '<emoji> Next Patch/RC/Major (X)' milestone to 'vX.Y.Z'
10+
* 2. Create its follow-up milestone, same emoji (skipped with --last)
11+
* 3. Move open issues to the follow-up milestone (with --last: to the
12+
* open '(X+1)' milestone instead; warns if none exists)
13+
* 4. Move open PRs to the follow-up milestone (with --last: never moved,
14+
* just warns for manual triage)
15+
* 5. Close the 'vX.Y.Z' milestone
16+
*
17+
* Follow-up milestone's flavour/due date derive from the released version:
18+
* same flavour unless no prerelease tag remains (then "Next Patch"); due in
19+
* 4 weeks, or 1 week for a prerelease tag (e.g. -rc.2).
20+
*
21+
* Version is read from the branch's appinfo/info.xml, same as
22+
* validate-release.mjs and prepare-changelog.mjs.
23+
*
24+
* Read-only against GitHub — every write is a `gh` command printed for a
25+
* human to run.
26+
*
27+
* Requires: git.
28+
*
29+
* Usage:
30+
* node scripts/update-milestones.mjs <stable-branch> [options]
31+
*
32+
* Arguments:
33+
* <stable-branch> The stable branch that was just released, e.g. stable33
34+
*
35+
* Options:
36+
* --last Last release of this branch — skip the follow-up milestone;
37+
* plan moving open issues to the next major's '(X+1)' milestone,
38+
* warn about open PRs instead
39+
* -h, --help Show this help
40+
*/
41+
42+
import process from 'node:process'
43+
import semver from 'semver'
44+
import { branchExists, ghFetchAll, parseArgs, preflight, print, tryRead } from './cli-utils.mjs'
45+
import { fetchOrigin, parseInfoVersion, readBranchInfoVersion } from './release-utils.mjs'
46+
47+
const REPO = 'nextcloud/spreed'
48+
49+
/** Print usage information and exit. */
50+
function usage() {
51+
print.log(`Usage: node scripts/update-milestones.mjs <STABLE_BRANCH> [OPTIONS]
52+
53+
Roll the milestones over after releasing <STABLE_BRANCH>: rename the open
54+
"Next Patch/RC/Major (X)" milestone matching the Nextcloud stable branch
55+
number to "vX.Y.Z" (version read from the branch's appinfo/info.xml), plan
56+
its follow-up milestone (same emoji, flavour and due date derived from the
57+
version) and moving open issues/PRs over, then close "vX.Y.Z".
58+
59+
This never writes to GitHub itself — it prints the exact 'gh' commands for
60+
each step, for you to copy, run and verify.
61+
62+
ARGUMENTS:
63+
STABLE_BRANCH The stable branch that was just released, e.g. stable33
64+
65+
OPTIONS:
66+
--last Last release of this branch — skip the follow-up milestone;
67+
plan moving open issues to the next major's '(X+1)' milestone,
68+
warn about open PRs instead
69+
-h, --help Show this help message
70+
71+
EXAMPLES:
72+
node scripts/update-milestones.mjs stable33
73+
node scripts/update-milestones.mjs stable34 --last
74+
`)
75+
process.exit(0)
76+
}
77+
78+
/**
79+
* Parse CLI arguments.
80+
*
81+
* @return {{branch: string, last: boolean}} parsed options
82+
*/
83+
function parseArguments() {
84+
let branch = null
85+
let last = false
86+
87+
parseArgs(process.argv.slice(2), {
88+
usage,
89+
flags: {
90+
'--last': () => {
91+
last = true
92+
},
93+
},
94+
onPositional: (arg) => {
95+
if (!branch) {
96+
branch = arg
97+
}
98+
},
99+
})
100+
101+
if (!branch) {
102+
print.err('A stable branch is required, e.g. stable33')
103+
usage()
104+
}
105+
106+
return { branch, last }
107+
}
108+
109+
/** Check required tools are available; exit if not. */
110+
function checkPreflight() {
111+
if (!preflight(['git'])) {
112+
process.exit(1)
113+
}
114+
}
115+
116+
/**
117+
* Resolve the branch's version from appinfo/info.xml: local branch first (no
118+
* network needed), else fetch and read origin/<branch>.
119+
*
120+
* @param {string} branch the stable branch to read
121+
* @return {string} the version, or '' when it could not be determined
122+
*/
123+
function resolveBranchVersion(branch) {
124+
if (branchExists(branch)) {
125+
print.note(`Using local branch '${branch}'`)
126+
return parseInfoVersion(tryRead('git', ['show', `${branch}:appinfo/info.xml`]))
127+
}
128+
129+
fetchOrigin()
130+
if (!branchExists(`origin/${branch}`)) {
131+
print.err(`Branch '${branch}' not found locally or on origin`)
132+
process.exit(1)
133+
}
134+
return readBranchInfoVersion(branch)
135+
}
136+
137+
/**
138+
* Compute the follow-up milestone's due date: 4 weeks out normally, 1 week
139+
* for a prerelease tag (e.g. 24.0.0-rc.2 — beta/RC cadence).
140+
*
141+
* @param {string} version the released version
142+
* @return {string} an ISO date-time, e.g. '2026-09-28T00:00:00Z'
143+
*/
144+
function computeDueDate(version) {
145+
const days = semver.prerelease(version) ? 7 : 28
146+
const date = new Date()
147+
date.setUTCDate(date.getUTCDate() + days)
148+
return `${date.toISOString().slice(0, 10)}T00:00:00Z`
149+
}
150+
151+
/**
152+
* Find a milestone by exact title.
153+
*
154+
* @param {string} title the milestone title to look for
155+
* @param {Array<object>} milestones all milestones
156+
* @return {object|undefined} the milestone, or undefined when not found
157+
*/
158+
function findMilestoneByTitle(title, milestones) {
159+
return milestones.find((m) => m.title === title)
160+
}
161+
162+
/**
163+
* Find the open "next" milestone for a Nextcloud stable branch number,
164+
* whatever flavour — Next Patch/RC/Major (X). Mirrors prepare-changelog.mjs.
165+
*
166+
* @param {string} ncMajor the Nextcloud stable branch number, e.g. '33' for 'stable33'
167+
* @param {Array<object>} milestones all milestones
168+
* @return {object|undefined} the milestone, or undefined when not found
169+
*/
170+
function findNextMilestone(ncMajor, milestones) {
171+
const pattern = new RegExp(`\\(${ncMajor}\\)$`)
172+
return milestones.find((m) => m.state === 'open' && pattern.test(m.title))
173+
}
174+
175+
/**
176+
* Derive the follow-up milestone's title: same emoji and flavour, unless the
177+
* released version has no prerelease tag anymore (branch gone stable), in
178+
* which case it's always "Next Patch".
179+
*
180+
* @param {string} patchTitle the current milestone's title
181+
* @param {string} version the released version
182+
* @param {string} ncMajor the Nextcloud stable branch number
183+
* @return {string} the follow-up milestone's title
184+
*/
185+
function deriveNextPatchTitle(patchTitle, version, ncMajor) {
186+
const titleMatch = patchTitle.match(/^(\S+)\s+(.+?)\s+\(\d[\d.]*\)$/)
187+
const emoji = titleMatch?.[1] ?? '💚'
188+
const flavour = semver.prerelease(version) ? (titleMatch?.[2] ?? 'Next Patch') : 'Next Patch'
189+
return `${emoji} ${flavour} (${ncMajor})`
190+
}
191+
192+
/**
193+
* Fetch everything open on a milestone, split into issues and PRs (a PR is
194+
* any item carrying a `pull_request` field).
195+
*
196+
* @param {number} milestoneNumber the milestone's number
197+
* @return {Promise<{issues: Array<{number: number, title: string}>, prs: Array<{number: number, title: string}>}>} the open issues and PRs
198+
*/
199+
async function listOpenOnMilestone(milestoneNumber) {
200+
const items = await ghFetchAll(`/repos/${REPO}/issues?milestone=${milestoneNumber}&state=open&per_page=100`)
201+
return {
202+
issues: items.filter((i) => !i.pull_request),
203+
prs: items.filter((i) => i.pull_request),
204+
}
205+
}
206+
207+
/**
208+
* Print a single shell loop that moves every listed issue/PR to a milestone.
209+
*
210+
* @param {'issue'|'pr'} kind which `gh` subcommand to loop
211+
* @param {Array<{number: number}>} items the issues or PRs to move
212+
* @param {string} milestoneTitle the destination milestone's title
213+
*/
214+
function printMoveCommand(kind, items, milestoneTitle) {
215+
const numbers = items.map((i) => i.number).join(' ')
216+
print.command(`for n in ${numbers}; do gh ${kind} edit "$n" --repo ${REPO} --milestone "${milestoneTitle}"; done`)
217+
}
218+
219+
/**
220+
* Print the full plan: each checklist step and the `gh` command for it.
221+
*
222+
* @param {object} plan the computed plan
223+
*/
224+
function printPlan(plan) {
225+
const { patchTitle, patchMilestone, nextPatchTitle, nextMajorMilestone, nextNcMajor, releaseTitle, dueDate, last, openIssues, openPrs } = plan
226+
227+
print.section(`1. Rename '${patchTitle}' (#${patchMilestone.number}) → '${releaseTitle}'`)
228+
print.command(`gh api --method PATCH repos/${REPO}/milestones/${patchMilestone.number} -f title="${releaseTitle}"`)
229+
230+
if (last) {
231+
print.note("--last given: no follow-up milestone — this branch's line is done")
232+
233+
print.section(`3. Move open issues from '${releaseTitle}' to the '(${nextNcMajor})' milestone`)
234+
if (openIssues.length === 0) {
235+
print.ok('No open issues to move')
236+
} else if (!nextMajorMilestone) {
237+
print.warn(`No open milestone matching '(${nextNcMajor})' found — ${openIssues.length} issue(s) need manual triage`)
238+
} else {
239+
print.note(`${openIssues.length} issue(s) → '${nextMajorMilestone.title}'`)
240+
printMoveCommand('issue', openIssues, nextMajorMilestone.title)
241+
}
242+
243+
print.section('4. Open PRs')
244+
if (openPrs.length === 0) {
245+
print.ok('No open PRs left on this milestone')
246+
} else {
247+
print.warn(`${openPrs.length} open PR(s) not moved — triage manually`)
248+
}
249+
} else {
250+
print.section(`2. Create milestone '${nextPatchTitle}', due ${dueDate.slice(0, 10)}`)
251+
print.command(`gh api --method POST repos/${REPO}/milestones -f title="${nextPatchTitle}" -f due_on="${dueDate}"`)
252+
253+
print.section(`3. Move open issues from '${releaseTitle}' to '${nextPatchTitle}'`)
254+
if (openIssues.length === 0) {
255+
print.ok('No open issues to move')
256+
} else {
257+
print.note(`${openIssues.length} issue(s)`)
258+
printMoveCommand('issue', openIssues, nextPatchTitle)
259+
}
260+
261+
print.section(`4. Move open PRs from '${releaseTitle}' to '${nextPatchTitle}'`)
262+
if (openPrs.length === 0) {
263+
print.ok('No open PRs to move')
264+
} else {
265+
print.note(`${openPrs.length} PR(s)`)
266+
printMoveCommand('pr', openPrs, nextPatchTitle)
267+
}
268+
}
269+
270+
print.section(`5. Close milestone '${releaseTitle}'`)
271+
print.command(`gh api --method PATCH repos/${REPO}/milestones/${patchMilestone.number} -f state=closed`)
272+
}
273+
274+
/** Run the milestone rollover plan. */
275+
async function main() {
276+
const { branch, last } = parseArguments()
277+
278+
print.header(`Nextcloud Spreed Milestone Rollover — ${branch}`)
279+
280+
checkPreflight()
281+
282+
const version = resolveBranchVersion(branch)
283+
if (!version) {
284+
print.err(`Could not read version from ${branch}:appinfo/info.xml`)
285+
process.exit(1)
286+
}
287+
print.note(`${branch} is at v${version}`)
288+
289+
// '(X)' is the Nextcloud stable branch number, not Talk's own version —
290+
// same convention as prepare-changelog.mjs's ncMajor.
291+
const ncMajor = (branch.match(/[0-9.]+/) || [''])[0]
292+
const releaseTitle = `v${version}`
293+
const dueDate = computeDueDate(version)
294+
295+
const milestones = await ghFetchAll(`/repos/${REPO}/milestones?state=all&per_page=100`)
296+
297+
const patchMilestone = findNextMilestone(ncMajor, milestones)
298+
if (!patchMilestone) {
299+
print.err(`No open milestone matching '(${ncMajor})' found — expected e.g. 'Next Patch (${ncMajor})', 'Next RC (${ncMajor})' or 'Next Major (${ncMajor})'`)
300+
process.exit(1)
301+
}
302+
const patchTitle = patchMilestone.title
303+
print.note(`Rolling over: '${patchTitle}' (#${patchMilestone.number})`)
304+
305+
const nextPatchTitle = deriveNextPatchTitle(patchTitle, version, ncMajor)
306+
// With --last, open issues go to the next Nextcloud major's milestone —
307+
// e.g. rolling over stable33's last release looks for open '(34)'.
308+
const nextNcMajor = String(Number(ncMajor) + 1)
309+
const nextMajorMilestone = last ? findNextMilestone(nextNcMajor, milestones) : undefined
310+
311+
const existingRelease = findMilestoneByTitle(releaseTitle, milestones)
312+
if (existingRelease) {
313+
print.err(`Milestone '${releaseTitle}' already exists (#${existingRelease.number}) — nothing to rename into`)
314+
process.exit(1)
315+
}
316+
317+
const { issues: openIssues, prs: openPrs } = await listOpenOnMilestone(patchMilestone.number)
318+
319+
const plan = { patchTitle, patchMilestone, nextPatchTitle, nextMajorMilestone, nextNcMajor, releaseTitle, dueDate, last, openIssues, openPrs }
320+
321+
printPlan(plan)
322+
}
323+
324+
main().catch((err) => {
325+
print.err(err.message)
326+
process.exit(1)
327+
})

0 commit comments

Comments
 (0)