|
| 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