@idea1/cli 1.10.4 → 1.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +74 -32
- package/dist/commands/help.d.ts.map +1 -1
- package/dist/commands/help.js +30 -12
- package/dist/commands/help.js.map +1 -1
- package/dist/commands/index.d.ts +3 -1
- package/dist/commands/index.d.ts.map +1 -1
- package/dist/commands/index.js +5 -1
- package/dist/commands/index.js.map +1 -1
- package/dist/commands/login.d.ts +3 -0
- package/dist/commands/login.d.ts.map +1 -0
- package/dist/commands/login.js +15 -0
- package/dist/commands/login.js.map +1 -0
- package/dist/commands/logout.d.ts +3 -0
- package/dist/commands/logout.d.ts.map +1 -0
- package/dist/commands/logout.js +17 -0
- package/dist/commands/logout.js.map +1 -0
- package/dist/commands/release.d.ts +9 -4
- package/dist/commands/release.d.ts.map +1 -1
- package/dist/commands/release.js +53 -27
- package/dist/commands/release.js.map +1 -1
- package/dist/lib/project-item-url.d.ts +6 -0
- package/dist/lib/project-item-url.d.ts.map +1 -1
- package/dist/lib/project-item-url.js +11 -0
- package/dist/lib/project-item-url.js.map +1 -1
- package/dist/lib/settings.d.ts +25 -3
- package/dist/lib/settings.d.ts.map +1 -1
- package/dist/lib/settings.js +26 -1
- package/dist/lib/settings.js.map +1 -1
- package/dist/prompts/platform-prompts.d.ts +5 -30
- package/dist/prompts/platform-prompts.d.ts.map +1 -1
- package/dist/prompts/platform-prompts.js +5 -44
- package/dist/prompts/platform-prompts.js.map +1 -1
- package/dist/services/github.d.ts +21 -19
- package/dist/services/github.d.ts.map +1 -1
- package/dist/services/github.js +56 -64
- package/dist/services/github.js.map +1 -1
- package/dist/services/platform-auth.d.ts +44 -0
- package/dist/services/platform-auth.d.ts.map +1 -0
- package/dist/services/platform-auth.js +476 -0
- package/dist/services/platform-auth.js.map +1 -0
- package/dist/services/platform.d.ts +75 -18
- package/dist/services/platform.d.ts.map +1 -1
- package/dist/services/platform.js +254 -191
- package/dist/services/platform.js.map +1 -1
- package/dist/services/setup.d.ts.map +1 -1
- package/dist/services/setup.js +2 -1
- package/dist/services/setup.js.map +1 -1
- package/dist/types/index.d.ts +32 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +1 -1
- package/dist/types/index.js.map +1 -1
- package/dist/workflow/data/team-member.d.ts +9 -2
- package/dist/workflow/data/team-member.d.ts.map +1 -1
- package/dist/workflow/data/team-member.js +8 -1
- package/dist/workflow/data/team-member.js.map +1 -1
- package/dist/workflow/engine/approval-flow.d.ts +28 -11
- package/dist/workflow/engine/approval-flow.d.ts.map +1 -1
- package/dist/workflow/engine/approval-flow.js +52 -17
- package/dist/workflow/engine/approval-flow.js.map +1 -1
- package/dist/workflow/engine/branch-setup.d.ts +1 -1
- package/dist/workflow/engine/branch-setup.js +1 -1
- package/dist/workflow/engine/handlers/approve-pr.d.ts +10 -9
- package/dist/workflow/engine/handlers/approve-pr.d.ts.map +1 -1
- package/dist/workflow/engine/handlers/approve-pr.js +24 -17
- package/dist/workflow/engine/handlers/approve-pr.js.map +1 -1
- package/dist/workflow/engine/release-engine.d.ts +90 -58
- package/dist/workflow/engine/release-engine.d.ts.map +1 -1
- package/dist/workflow/engine/release-engine.js +296 -161
- package/dist/workflow/engine/release-engine.js.map +1 -1
- package/dist/workflow/engine/workflow-engine.d.ts +9 -2
- package/dist/workflow/engine/workflow-engine.d.ts.map +1 -1
- package/dist/workflow/engine/workflow-engine.js +20 -2
- package/dist/workflow/engine/workflow-engine.js.map +1 -1
- package/package.json +2 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readFileSync
|
|
1
|
+
import { existsSync, readFileSync } from 'fs';
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
import { logger } from '../../lib/logger.js';
|
|
4
4
|
import { readReleaseSettings } from '../../lib/settings.js';
|
|
@@ -6,6 +6,7 @@ import { exec, execOrThrow } from '../../lib/shell.js';
|
|
|
6
6
|
import { promptConfirm, promptInput } from '../../lib/prompt.js';
|
|
7
7
|
import * as git from '../../services/git.js';
|
|
8
8
|
import * as github from '../../services/github.js';
|
|
9
|
+
import * as platform from '../../services/platform.js';
|
|
9
10
|
export const RELEASE_KIND = {
|
|
10
11
|
commandName: 'idea1 release',
|
|
11
12
|
label: 'Release',
|
|
@@ -20,6 +21,13 @@ export const HOTFIX_RELEASE_KIND = {
|
|
|
20
21
|
gate: 'This PR is the review, approval and release of the hotfix it carries: its assignee reviews it, ' +
|
|
21
22
|
'it must pass the required CI/CD checks, and the merge of this PR is its approval.'
|
|
22
23
|
};
|
|
24
|
+
export const CONTENT_RELEASE_KIND = {
|
|
25
|
+
commandName: 'idea1 release',
|
|
26
|
+
label: 'Content release',
|
|
27
|
+
assigneeReviewsCommits: true,
|
|
28
|
+
gate: 'This PR is the release of the content it carries: it changes only the content release\'s paths, ' +
|
|
29
|
+
'it must pass the required checks, and the merge of this PR is its approval - performed by its assignee.'
|
|
30
|
+
};
|
|
23
31
|
export const CHANGE_KIND = {
|
|
24
32
|
commandName: 'idea1 ready-for-approval',
|
|
25
33
|
label: 'Change',
|
|
@@ -30,8 +38,9 @@ export const CHANGE_KIND = {
|
|
|
30
38
|
/**
|
|
31
39
|
* The `--pr-mode` option of `idea1 release`. `create` is the default: the PR is opened and the
|
|
32
40
|
* approver merges it in GitHub once the required checks are green - that merge is the approval of
|
|
33
|
-
* record
|
|
34
|
-
*
|
|
41
|
+
* record. In `merge` mode the CLI waits for the checks and has the Idea1 platform merge the PR as
|
|
42
|
+
* the approver's recorded approval, exactly as `idea1 release --approve` does. A content release is
|
|
43
|
+
* always merged the second way.
|
|
35
44
|
*/
|
|
36
45
|
export const PR_MODE_OPTION = {
|
|
37
46
|
longName: 'pr-mode',
|
|
@@ -41,7 +50,7 @@ export const PR_MODE_OPTION = {
|
|
|
41
50
|
promptWhenDefaultExists: true,
|
|
42
51
|
choices: [
|
|
43
52
|
{ label: 'create - Create the PR and open it in the browser; merge it there once the required checks pass', value: 'create' },
|
|
44
|
-
{ label: 'merge -
|
|
53
|
+
{ label: 'merge - Wait for the required checks, then have Idea1 merge the PR as your recorded approval', value: 'merge' }
|
|
45
54
|
]
|
|
46
55
|
};
|
|
47
56
|
/** The first line of the comment that records an admin override; the evidence script keys on it. */
|
|
@@ -70,6 +79,17 @@ export const CHANGE_APPROVAL_COMMENT_MARKER = '**Change-management approval: cha
|
|
|
70
79
|
const UNRECORDED_APPROVER = '_unrecorded (merged by a GitHub App)_';
|
|
71
80
|
/** The heading of the approval chain in a gated PR's body, which both the CLI and the platform write. */
|
|
72
81
|
const APPROVAL_CHAIN_HEADING = '## Approval chain';
|
|
82
|
+
/**
|
|
83
|
+
* The markers around the approval chain section of a gated PR's body, which both the CLI and the
|
|
84
|
+
* platform write: `<!-- approval-chain:start head=<sha> -->` before the heading and
|
|
85
|
+
* `<!-- approval-chain:end -->` after the last line. The section is rendered from the branch and
|
|
86
|
+
* re-rendered while the PR is open, replacing only what lies between the markers, so the rest of
|
|
87
|
+
* the body - its opening line and anything a person adds - stays as it is. `head` is the source
|
|
88
|
+
* branch's tip the section was rendered at.
|
|
89
|
+
*/
|
|
90
|
+
const APPROVAL_CHAIN_START_MARKER = '<!-- approval-chain:start';
|
|
91
|
+
const APPROVAL_CHAIN_END_MARKER = '<!-- approval-chain:end -->';
|
|
92
|
+
const RENDERED_HEAD_PATTERN = /<!-- approval-chain:start head=([0-9a-f]{40}) -->/;
|
|
73
93
|
/**
|
|
74
94
|
* The local checkout must be the source branch, clean, fully pushed and level with origin, so the
|
|
75
95
|
* PR carries exactly what the person running the command is looking at.
|
|
@@ -113,8 +133,6 @@ export async function validateSourceBranch(sourceBranch) {
|
|
|
113
133
|
logger.newline();
|
|
114
134
|
}
|
|
115
135
|
export const VERSION_BUMP_TYPES = ['patch', 'minor', 'major'];
|
|
116
|
-
/** The subject prefix of the commit a version bump makes, which both the CLI and the platform write. */
|
|
117
|
-
const VERSION_BUMP_SUBJECT_PREFIX = 'chore: bump ';
|
|
118
136
|
/** The tree of `path` at `origin/<branch>`, or null when the branch has no such path. */
|
|
119
137
|
async function readRemoteTreeSha(branch, path) {
|
|
120
138
|
const result = await exec('git', ['rev-parse', '--verify', '--quiet', `origin/${branch}:${path}`]);
|
|
@@ -223,22 +241,18 @@ function setVersion(json, path, entryKey, version) {
|
|
|
223
241
|
return edited;
|
|
224
242
|
}
|
|
225
243
|
/**
|
|
226
|
-
* Move one package to `version` in the
|
|
227
|
-
*
|
|
244
|
+
* Move one package to `version` in `files`, the version files' content as edited so far (read from
|
|
245
|
+
* the checkout when not yet edited): its `package.json`, and each lockfile at or above its folder
|
|
246
|
+
* that records it.
|
|
228
247
|
*
|
|
229
248
|
* A lockfile in the package's own folder records the package as its root; a workspace root's
|
|
230
249
|
* lockfile records it as the entry for its path relative to the lockfile. These are the files
|
|
231
250
|
* `npm version` would touch, edited in place so nothing else in them changes.
|
|
232
251
|
*/
|
|
233
|
-
function
|
|
234
|
-
const changed = [];
|
|
252
|
+
function editVersionFiles(repoRoot, files, pkg, version) {
|
|
235
253
|
const edit = (path, entryKeys) => {
|
|
236
|
-
const original = readFileSync(join(repoRoot, path), 'utf-8');
|
|
237
|
-
|
|
238
|
-
if (content !== original) {
|
|
239
|
-
writeFileSync(join(repoRoot, path), content);
|
|
240
|
-
changed.push(path);
|
|
241
|
-
}
|
|
254
|
+
const original = files.get(path) ?? readFileSync(join(repoRoot, path), 'utf-8');
|
|
255
|
+
files.set(path, entryKeys.reduce((current, entryKey) => setVersion(current, path, entryKey, version) ?? current, original));
|
|
242
256
|
};
|
|
243
257
|
edit(`${pkg.path}/package.json`, [null]);
|
|
244
258
|
for (const directory of ancestorDirectories(pkg.path)) {
|
|
@@ -248,7 +262,6 @@ function writeVersionFiles(repoRoot, pkg, version) {
|
|
|
248
262
|
edit(lockfile, relativePath === '' ? [null, ''] : [relativePath]);
|
|
249
263
|
}
|
|
250
264
|
}
|
|
251
|
-
return changed;
|
|
252
265
|
}
|
|
253
266
|
/** A folder and each folder above it, ending with the repository root (the empty path). */
|
|
254
267
|
function ancestorDirectories(directory) {
|
|
@@ -267,9 +280,11 @@ function versionFilePaths(packages) {
|
|
|
267
280
|
return new Set(packages.flatMap(pkg => [`${pkg.path}/package.json`, ...ancestorDirectories(pkg.path).map(lockfilePath)]));
|
|
268
281
|
}
|
|
269
282
|
/**
|
|
270
|
-
* Bump the versions of the pending packages in one commit on the release branch
|
|
271
|
-
*
|
|
272
|
-
*
|
|
283
|
+
* Bump the versions of the pending packages in one commit on the release branch, so the release PR
|
|
284
|
+
* opens against a branch that already carries them. The bump is computed from the checkout, which is
|
|
285
|
+
* level with origin, and the Idea1 platform commits it onto the branch as the Idea1 GitHub App -
|
|
286
|
+
* `chore: bump <package> <version>, ...`, naming the releaser - only while the branch is still at the
|
|
287
|
+
* checkout's tip. The checkout is then fast-forwarded to the bump.
|
|
273
288
|
*/
|
|
274
289
|
export async function commitVersionBumps(options) {
|
|
275
290
|
const { releaseBranch, pending, bumps } = options;
|
|
@@ -278,17 +293,19 @@ export async function commitVersionBumps(options) {
|
|
|
278
293
|
if (missing.length > 0) {
|
|
279
294
|
throw new Error(`Choose a version bump for: ${missing.join(', ')}.`);
|
|
280
295
|
}
|
|
281
|
-
const
|
|
282
|
-
const summary = [];
|
|
296
|
+
const files = new Map();
|
|
283
297
|
for (const pkg of pending) {
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
await execOrThrow('git', ['
|
|
290
|
-
await
|
|
291
|
-
await git.
|
|
298
|
+
editVersionFiles(repoRoot, files, pkg, nextVersion(pkg.currentVersion, bumps[pkg.name]));
|
|
299
|
+
}
|
|
300
|
+
const changed = [...files.entries()]
|
|
301
|
+
.filter(([path, content]) => content !== readFileSync(join(repoRoot, path), 'utf-8'))
|
|
302
|
+
.map(([path, content]) => ({ path, content }));
|
|
303
|
+
const head = (await execOrThrow('git', ['rev-parse', 'HEAD'])).trim();
|
|
304
|
+
await platform.commitVersionBump(await git.getRepositoryFullName(), releaseBranch, head, changed);
|
|
305
|
+
await git.fetchRemoteBranch(releaseBranch);
|
|
306
|
+
if (await git.fastForwardCheckoutToOrigin(releaseBranch, repoRoot) !== 'fast-forwarded') {
|
|
307
|
+
throw new Error(`The version bump is on origin/${releaseBranch}, but this checkout could not be fast-forwarded to it. Pull ${releaseBranch} before going on.`);
|
|
308
|
+
}
|
|
292
309
|
logger.success('Versions bumped');
|
|
293
310
|
}
|
|
294
311
|
/**
|
|
@@ -335,18 +352,26 @@ async function changesOnlyVersionFiles(commit, versionFiles) {
|
|
|
335
352
|
const changedFiles = result.stdout.split('\n').filter(file => file.trim().length > 0);
|
|
336
353
|
return result.exitCode === 0 && changedFiles.length > 0 && changedFiles.every(file => versionFiles.has(file));
|
|
337
354
|
}
|
|
355
|
+
/** A commit abbreviated as the approval chain and the direct-push record list it. */
|
|
356
|
+
const shortSha = (sha) => sha.slice(0, 9);
|
|
338
357
|
/**
|
|
339
358
|
* The refusal of a planned release that carries direct pushes, and the one way past it. A change
|
|
340
359
|
* reaches a planned release through its own approved PR; a commit pushed straight to the branch
|
|
341
360
|
* instead is released only when a repository admin - the production root tier, the only accounts
|
|
342
361
|
* able to push past the branch's pull request requirement - confirms it and records why, and the
|
|
343
362
|
* reason goes on the release PR (change-management-policy.md, "Pull requests and direct pushes").
|
|
363
|
+
* `prIsOpen` says whether the release PR already exists: a direct push that lands while it is open
|
|
364
|
+
* needs its reason recorded on it before it can merge.
|
|
344
365
|
*/
|
|
345
|
-
async function resolveDirectPushes(commits, sourceBranch, targetBranch, kind) {
|
|
346
|
-
const
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
366
|
+
async function resolveDirectPushes(commits, sourceBranch, targetBranch, kind, prIsOpen) {
|
|
367
|
+
const label = kind.label.toLowerCase();
|
|
368
|
+
const commitsWithNoPr = `${commits.length} commit(s) on origin/${sourceBranch} beyond origin/${targetBranch} ` +
|
|
369
|
+
`reached ${sourceBranch} without a pull request`;
|
|
370
|
+
const lead = prIsOpen
|
|
371
|
+
? `The ${label} PR records no reason for ${commitsWithNoPr}.`
|
|
372
|
+
: `Refusing to open the ${label} PR: ${commitsWithNoPr}.`;
|
|
373
|
+
const refusal = lead + ' A change reaches a release through its own approved PR; a direct push is released only ' +
|
|
374
|
+
'with its reason recorded on the release PR by a repository admin (change-management-policy.md, "Pull requests and direct pushes").';
|
|
350
375
|
if (!(await github.getRepoAccess()).admin) {
|
|
351
376
|
throw new Error(refusal + '\n\nYou are not a repository admin, so you cannot record the reason.');
|
|
352
377
|
}
|
|
@@ -355,7 +380,7 @@ async function resolveDirectPushes(commits, sourceBranch, targetBranch, kind) {
|
|
|
355
380
|
logger.newline();
|
|
356
381
|
logger.warning('The commits with no pull request are:');
|
|
357
382
|
for (const commit of commits) {
|
|
358
|
-
logger.dim(` ${commit.sha
|
|
383
|
+
logger.dim(` ${shortSha(commit.sha)} ${commit.author} ${commit.subject}`);
|
|
359
384
|
}
|
|
360
385
|
logger.newline();
|
|
361
386
|
const proceed = await promptConfirm('Release them anyway, recording on the PR why they were pushed directly?', { default: false });
|
|
@@ -382,12 +407,36 @@ function buildDirectPushesComment(kind, record) {
|
|
|
382
407
|
'Commits with no pull request:',
|
|
383
408
|
'',
|
|
384
409
|
'```',
|
|
385
|
-
...record.commits.map(commit => `${commit.sha
|
|
410
|
+
...record.commits.map(commit => `${shortSha(commit.sha)} ${commit.author} ${commit.subject}`),
|
|
386
411
|
'```',
|
|
387
412
|
'',
|
|
388
413
|
`Recorded automatically by \`${kind.commandName}\` for the quarterly review.`
|
|
389
414
|
].join('\n');
|
|
390
415
|
}
|
|
416
|
+
/**
|
|
417
|
+
* The direct-push records on a PR, read from its comments. Each names the person who posted it -
|
|
418
|
+
* the GitHub login on the comment, not any name written in it - with the reason and the commits it
|
|
419
|
+
* covers. Only a repository admin, the production root tier, records why commits were pushed
|
|
420
|
+
* directly (change-management-policy.md, "Pull requests and direct pushes"), so a record posted by
|
|
421
|
+
* anyone else is left out; each author's permission is read once. The platform's `ReleaseEngine`
|
|
422
|
+
* reads the same records the same way.
|
|
423
|
+
*/
|
|
424
|
+
async function readDirectPushRecords(comments) {
|
|
425
|
+
const records = comments.filter(comment => comment.body.startsWith(DIRECT_PUSHES_COMMENT_MARKER));
|
|
426
|
+
const admins = new Set();
|
|
427
|
+
for (const author of new Set(records.map(comment => comment.author))) {
|
|
428
|
+
if (await github.isRepoAdmin(author)) {
|
|
429
|
+
admins.add(author);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return records
|
|
433
|
+
.filter(comment => admins.has(comment.author))
|
|
434
|
+
.map(comment => ({
|
|
435
|
+
recordedBy: comment.author,
|
|
436
|
+
reason: (/^Reason given: (.*)$/m.exec(comment.body)?.[1] ?? '').trim(),
|
|
437
|
+
shortShas: [...comment.body.matchAll(/^([0-9a-f]{9}) {2}/gm)].map(match => match[1])
|
|
438
|
+
}));
|
|
439
|
+
}
|
|
391
440
|
/**
|
|
392
441
|
* The GitHub App a login belongs to, or null for a person. `gh`'s GraphQL fields name an app
|
|
393
442
|
* `app/<slug>`; the REST API names it `<slug>[bot]`.
|
|
@@ -422,20 +471,30 @@ async function resolveApprover(record) {
|
|
|
422
471
|
return approvals.at(-1) ?? null;
|
|
423
472
|
}
|
|
424
473
|
/**
|
|
425
|
-
* The
|
|
474
|
+
* The approval chain of everything the PR carries, rendered from the source branch as origin last
|
|
475
|
+
* showed it.
|
|
426
476
|
*
|
|
427
477
|
* SOC2 change management asks who approved a change and when. Each change on the source branch
|
|
428
478
|
* was already approved by its assignee, who merged its PR into that branch. Listing those merges
|
|
429
|
-
* here makes
|
|
479
|
+
* here makes the PR self-contained evidence: the whole chain is readable from the one PR an
|
|
430
480
|
* auditor lands on. Commits are attributed to PRs through GitHub's own association, so squash and
|
|
431
481
|
* rebase merges are covered, and a commit with no PR is shown rather than silently dropped.
|
|
482
|
+
*
|
|
483
|
+
* `recorded` holds the direct-push records already on the PR, or is null before the PR exists. A
|
|
484
|
+
* direct push no record covers is refused, or released with its reason, as `resolveDirectPushes`
|
|
485
|
+
* decides; the record that returns is posted on the PR once the body is written. The section's
|
|
486
|
+
* start marker records the source branch's tip it was rendered at.
|
|
432
487
|
*/
|
|
433
|
-
|
|
488
|
+
async function renderApprovalChain(sourceBranch, targetBranch, kind, recorded) {
|
|
434
489
|
const commits = await listFirstParentCommits(sourceBranch, targetBranch);
|
|
435
490
|
if (commits.length === 0) {
|
|
436
491
|
throw new Error(`Nothing to ${kind.label.toLowerCase()}: origin/${targetBranch} already contains everything on origin/${sourceBranch}.`);
|
|
437
492
|
}
|
|
438
|
-
const
|
|
493
|
+
const releaseSettings = await readReleaseSettings(await git.getRepoRoot());
|
|
494
|
+
const versionFiles = versionFilePaths(releaseSettings.versionedPackages);
|
|
495
|
+
// Whose review a commit with no PR gets: the release PR's assignee, when the kind of PR or the
|
|
496
|
+
// repository's release branch policy lets such a commit in, else a repository admin, who records why.
|
|
497
|
+
const assigneeReviewsDirectCommits = kind.assigneeReviewsCommits || releaseSettings.releaseBranchChanges === 'direct-allowed';
|
|
439
498
|
logger.step(`Attributing ${commits.length} commit(s) on ${sourceBranch} to their pull requests...`);
|
|
440
499
|
const prNumbers = new Set();
|
|
441
500
|
const versionBumps = [];
|
|
@@ -456,15 +515,22 @@ export async function buildApprovalChain(sourceBranch, targetBranch, kind) {
|
|
|
456
515
|
unapprovedCommits.push(commit);
|
|
457
516
|
}
|
|
458
517
|
// A version bump is release automation, so it is never refused. A change PR or a hotfix release
|
|
459
|
-
// is never refused over direct commits either: they are what its assignee reviews.
|
|
460
|
-
|
|
461
|
-
|
|
518
|
+
// is never refused over direct commits either: they are what its assignee reviews. Nor is a
|
|
519
|
+
// release of a repository that accepts direct changes on its release branch (`direct-allowed`).
|
|
520
|
+
const unapprovedShas = new Set(unapprovedCommits.map(commit => shortSha(commit.sha)));
|
|
521
|
+
const recordedReasons = (recorded ?? []).filter(record => record.shortShas.some(sha => unapprovedShas.has(sha)));
|
|
522
|
+
const recordedShas = new Set(recordedReasons.flatMap(record => record.shortShas));
|
|
523
|
+
const unrecordedCommits = unapprovedCommits.filter(commit => !recordedShas.has(shortSha(commit.sha)));
|
|
524
|
+
const directPushRecord = !assigneeReviewsDirectCommits && unrecordedCommits.length > 0
|
|
525
|
+
? await resolveDirectPushes(unrecordedCommits, sourceBranch, targetBranch, kind, recorded !== null)
|
|
462
526
|
: null;
|
|
527
|
+
const reasons = directPushRecord
|
|
528
|
+
? [...recordedReasons, { recordedBy: directPushRecord.recordedBy, reason: directPushRecord.reason, shortShas: [] }]
|
|
529
|
+
: recordedReasons;
|
|
463
530
|
const records = new Map((await github.listMergedPrsInto(sourceBranch)).map(record => [record.number, record]));
|
|
464
531
|
const sortedNumbers = [...prNumbers].sort((a, b) => a - b);
|
|
465
532
|
const lines = [
|
|
466
|
-
`${
|
|
467
|
-
'',
|
|
533
|
+
`${APPROVAL_CHAIN_START_MARKER} head=${commits[0].sha} -->`,
|
|
468
534
|
APPROVAL_CHAIN_HEADING,
|
|
469
535
|
''
|
|
470
536
|
];
|
|
@@ -515,37 +581,132 @@ export async function buildApprovalChain(sourceBranch, targetBranch, kind) {
|
|
|
515
581
|
lines.push('| Commit | Author | Subject |');
|
|
516
582
|
lines.push('|---|---|---|');
|
|
517
583
|
for (const commit of versionBumps) {
|
|
518
|
-
lines.push(`| ${commit.sha
|
|
584
|
+
lines.push(`| ${shortSha(commit.sha)} | ${escapeTableCell(commit.author)} | ${escapeTableCell(commit.subject)} |`);
|
|
519
585
|
}
|
|
520
586
|
}
|
|
521
587
|
if (unapprovedCommits.length > 0) {
|
|
522
588
|
lines.push('');
|
|
523
|
-
if (
|
|
524
|
-
// The
|
|
525
|
-
// PR if
|
|
589
|
+
if (!assigneeReviewsDirectCommits) {
|
|
590
|
+
// The reasons go in the body as well as the comments, so they can never end up missing from
|
|
591
|
+
// the PR if a comment fails to post.
|
|
526
592
|
lines.push('## Direct commits (no pull request)');
|
|
527
593
|
lines.push('');
|
|
528
594
|
lines.push(`The commits below were pushed straight to \`${sourceBranch}\`, with no pull request of their own; this PR is`);
|
|
529
595
|
lines.push('their approval. change-management-policy.md, "Pull requests and direct pushes", permits this for the');
|
|
530
596
|
lines.push('production root access tier with the reason recorded on the release PR.');
|
|
531
|
-
|
|
532
|
-
|
|
597
|
+
for (const record of reasons) {
|
|
598
|
+
lines.push('');
|
|
599
|
+
lines.push(`> **Reason recorded by @${record.recordedBy}:** ${escapeTableCell(record.reason)}`);
|
|
600
|
+
}
|
|
533
601
|
}
|
|
534
602
|
else {
|
|
535
603
|
lines.push(`## Commits in this ${kind.label.toLowerCase()}`);
|
|
536
604
|
lines.push('');
|
|
537
605
|
lines.push(`The commits below were pushed to \`${sourceBranch}\`. They are the change this PR asks its assignee to`);
|
|
538
|
-
|
|
539
|
-
|
|
606
|
+
if (kind.assigneeReviewsCommits) {
|
|
607
|
+
lines.push('review and approve by merging it, so they are not an exception under change-management-policy.md,');
|
|
608
|
+
lines.push('"Pull requests and direct pushes" - this PR is their approval.');
|
|
609
|
+
}
|
|
610
|
+
else {
|
|
611
|
+
lines.push(`review and approve by merging it: this repository accepts changes made directly on \`${sourceBranch}\``);
|
|
612
|
+
lines.push('(`release.releaseBranchChanges` is `direct-allowed`), so this PR is their approval.');
|
|
613
|
+
}
|
|
540
614
|
}
|
|
541
615
|
lines.push('');
|
|
542
616
|
lines.push('| Commit | Author | Subject |');
|
|
543
617
|
lines.push('|---|---|---|');
|
|
544
618
|
for (const commit of unapprovedCommits) {
|
|
545
|
-
lines.push(`| ${commit.sha
|
|
619
|
+
lines.push(`| ${shortSha(commit.sha)} | ${escapeTableCell(commit.author)} | ${escapeTableCell(commit.subject)} |`);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
lines.push('', APPROVAL_CHAIN_END_MARKER);
|
|
623
|
+
return {
|
|
624
|
+
intro: `${kind.label} from \`${sourceBranch}\` into \`${targetBranch}\`, created by \`${kind.commandName}\`.`,
|
|
625
|
+
section: lines.join('\n'),
|
|
626
|
+
directPushRecord
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* The body of a new gated PR, written as the approval record for everything the PR carries: its
|
|
631
|
+
* opening line, then the approval chain section. A direct push it carries with no recorded reason
|
|
632
|
+
* is refused unless a repository admin records why; that record is returned for posting on the PR.
|
|
633
|
+
*/
|
|
634
|
+
export async function buildApprovalChain(sourceBranch, targetBranch, kind) {
|
|
635
|
+
const chain = await renderApprovalChain(sourceBranch, targetBranch, kind, null);
|
|
636
|
+
return { body: `${chain.intro}\n\n${chain.section}`, directPushRecord: chain.directPushRecord };
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* The body with its approval chain section replaced by `section`, and everything outside it as it
|
|
640
|
+
* was. The section lies between its markers; in a body written before the markers existed, it runs
|
|
641
|
+
* from the approval chain heading to the end of the body, all of which the release wrote.
|
|
642
|
+
*/
|
|
643
|
+
function replaceApprovalChainSection(body, section) {
|
|
644
|
+
const start = body.indexOf(APPROVAL_CHAIN_START_MARKER);
|
|
645
|
+
const end = start < 0 ? -1 : body.indexOf(APPROVAL_CHAIN_END_MARKER, start);
|
|
646
|
+
if (end >= 0) {
|
|
647
|
+
return body.slice(0, start) + section + body.slice(end + APPROVAL_CHAIN_END_MARKER.length);
|
|
648
|
+
}
|
|
649
|
+
return body.slice(0, Math.max(body.indexOf(APPROVAL_CHAIN_HEADING), 0)) + section;
|
|
650
|
+
}
|
|
651
|
+
/** The source branch's tip the body's approval chain section was rendered at, or null when it does not say. */
|
|
652
|
+
function readRenderedHead(body) {
|
|
653
|
+
return RENDERED_HEAD_PATTERN.exec(body)?.[1] ?? null;
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* Re-render the approval chain section of an open gated PR from the source branch as it stands on
|
|
657
|
+
* origin now, and write it to the PR when it differs; the rest of the body stays as it is. Returns
|
|
658
|
+
* whether the body changed. The direct pushes already recorded on the PR stay accounted for; one
|
|
659
|
+
* that landed with no recorded reason is resolved as when the PR was opened - a repository admin
|
|
660
|
+
* records why, on the PR - or refused.
|
|
661
|
+
*/
|
|
662
|
+
export async function refreshApprovalChain(pr, sourceBranch, targetBranch, kind) {
|
|
663
|
+
await git.fetchLatest();
|
|
664
|
+
const recorded = await readDirectPushRecords(await github.listPrComments(pr.number));
|
|
665
|
+
const chain = await renderApprovalChain(sourceBranch, targetBranch, kind, recorded);
|
|
666
|
+
const body = replaceApprovalChainSection(pr.body, chain.section);
|
|
667
|
+
if (body !== pr.body) {
|
|
668
|
+
await github.updatePrBody(pr.url, body);
|
|
669
|
+
}
|
|
670
|
+
if (chain.directPushRecord) {
|
|
671
|
+
logger.step('Recording why the release carries direct pushes on the PR...');
|
|
672
|
+
await github.addPrComment(sourceBranch, buildDirectPushesComment(kind, chain.directPushRecord));
|
|
673
|
+
logger.success('Reason recorded on the PR');
|
|
674
|
+
}
|
|
675
|
+
return body !== pr.body;
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* Bring the open PR's change list up to date before anything merges it: the release PR lists every
|
|
679
|
+
* change it contains (change-management-policy.md, "Approval and release gates"), and a change
|
|
680
|
+
* merged into the source branch after the PR was opened ships inside it. When the re-render fails,
|
|
681
|
+
* the merge goes ahead only if the branch is still at the tip the body's list was rendered at, so
|
|
682
|
+
* the list it shows is complete; otherwise nothing is merged.
|
|
683
|
+
*/
|
|
684
|
+
async function ensureChangeListCurrent(sourceBranch, targetBranch, kind) {
|
|
685
|
+
const pr = await github.getOpenPr(sourceBranch, targetBranch);
|
|
686
|
+
if (!pr) {
|
|
687
|
+
throw new Error(`There is no open ${kind.label.toLowerCase()} PR from ${sourceBranch} into ${targetBranch}.`);
|
|
688
|
+
}
|
|
689
|
+
logger.step(`Bringing the change list of PR #${pr.number} up to date...`);
|
|
690
|
+
try {
|
|
691
|
+
if (await refreshApprovalChain(pr, sourceBranch, targetBranch, kind)) {
|
|
692
|
+
logger.success('Change list updated with what the branch carries now');
|
|
546
693
|
}
|
|
694
|
+
else {
|
|
695
|
+
logger.success('The change list already lists everything the branch carries');
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
catch (error) {
|
|
699
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
700
|
+
const renderedAt = readRenderedHead(pr.body);
|
|
701
|
+
const tip = await github.getRemoteBranchTip(sourceBranch);
|
|
702
|
+
if (renderedAt === null || renderedAt !== tip) {
|
|
703
|
+
throw new Error(`Refusing to merge PR #${pr.number}: ${sourceBranch} moved since its change list was written, and the list could ` +
|
|
704
|
+
'not be brought up to date, so the PR would merge changes it does not list (change-management-policy.md, ' +
|
|
705
|
+
`"Approval and release gates").\n\n${reason}`);
|
|
706
|
+
}
|
|
707
|
+
logger.warning(`Could not bring the change list of PR #${pr.number} up to date (${reason}). ${sourceBranch} has not moved ` +
|
|
708
|
+
'since the list was written, so it still lists every change the PR carries.');
|
|
547
709
|
}
|
|
548
|
-
return { body: lines.join('\n'), directPushRecord };
|
|
549
710
|
}
|
|
550
711
|
/**
|
|
551
712
|
* Open the PR with its approval-chain body and its approver as assignee. Returns the PR URL.
|
|
@@ -570,33 +731,48 @@ export async function createGatedPr(options) {
|
|
|
570
731
|
return prUrl;
|
|
571
732
|
}
|
|
572
733
|
/**
|
|
573
|
-
* The
|
|
574
|
-
*
|
|
575
|
-
* no approver, and a different merger is an exception the evidence report calls out - so it
|
|
576
|
-
* takes an explicit, eyes-open confirmation.
|
|
734
|
+
* The signed-in Idea1 user's linked GitHub login: the identity the platform records an approval
|
|
735
|
+
* under, checks against a PR's assignees and checks a release role for.
|
|
577
736
|
*/
|
|
578
|
-
export async function
|
|
579
|
-
const
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
'idea1 ready-for-approval --assignee <teammate email>; otherwise set the assignee on the PR in GitHub.');
|
|
737
|
+
export async function signedInGitHubLogin() {
|
|
738
|
+
const signedIn = await platform.getSignedInUser();
|
|
739
|
+
const login = (await platform.resolveTeammate(signedIn.emailAddress)).gitHubUsername;
|
|
740
|
+
if (!login) {
|
|
741
|
+
throw new Error(`Link your GitHub account in Idea1 first: an approval is recorded under it, so ${signedIn.emailAddress} cannot approve without one.`);
|
|
584
742
|
}
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
743
|
+
return login;
|
|
744
|
+
}
|
|
745
|
+
/**
|
|
746
|
+
* Refuse `login` a release role they lack: only the members of `team`, a GitHub team of the
|
|
747
|
+
* repository's organization named by `setting` in `.idea1/settings.json`, may `action`. A repository
|
|
748
|
+
* that names no team for the role leaves it to anyone the release workflow admits. The platform
|
|
749
|
+
* checks the same role again before it merges or commits anything.
|
|
750
|
+
*/
|
|
751
|
+
export async function requireReleaseRole(team, setting, login, action) {
|
|
752
|
+
if (team === null) {
|
|
589
753
|
return;
|
|
590
754
|
}
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
logger.
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
755
|
+
const organization = (await git.getRepositoryFullName()).split('/')[0];
|
|
756
|
+
if (!await github.isTeamMember(team, login)) {
|
|
757
|
+
throw new Error(`Only members of the GitHub team @${organization}/${team} (${setting} in .idea1/settings.json) may ${action}, and @${login} is not one.`);
|
|
758
|
+
}
|
|
759
|
+
logger.info(`@${login} is a member of @${organization}/${team}`);
|
|
760
|
+
}
|
|
761
|
+
/**
|
|
762
|
+
* Refuse a release by anyone outside the release role for its kind: `release.releaserTeam` for a
|
|
763
|
+
* planned or hotfix release, `release.contentRelease.releaserTeam` for a content release. Returns
|
|
764
|
+
* the releaser's linked GitHub login.
|
|
765
|
+
*/
|
|
766
|
+
export async function requireReleaser(kind, sourceBranch) {
|
|
767
|
+
const login = await signedInGitHubLogin();
|
|
768
|
+
const settings = await readReleaseSettings(await git.getRepoRoot());
|
|
769
|
+
if (kind === CONTENT_RELEASE_KIND) {
|
|
770
|
+
await requireReleaseRole(settings.contentRelease?.releaserTeam ?? null, 'release.contentRelease.releaserTeam', login, `release '${sourceBranch}'`);
|
|
771
|
+
}
|
|
772
|
+
else {
|
|
773
|
+
await requireReleaseRole(settings.releaserTeam, 'release.releaserTeam', login, `make a ${kind.label.toLowerCase()}`);
|
|
599
774
|
}
|
|
775
|
+
return login;
|
|
600
776
|
}
|
|
601
777
|
function buildOverrideComment(kind, reason, checkSummary) {
|
|
602
778
|
return [
|
|
@@ -618,19 +794,19 @@ function buildOverrideComment(kind, reason, checkSummary) {
|
|
|
618
794
|
].join('\n');
|
|
619
795
|
}
|
|
620
796
|
/**
|
|
621
|
-
* Merge the PR once the required checks pass
|
|
622
|
-
* the
|
|
623
|
-
*
|
|
624
|
-
*
|
|
625
|
-
*
|
|
797
|
+
* Merge the release PR once the required checks pass: the Idea1 platform records the releaser's
|
|
798
|
+
* approval on the PR and merges it as the Idea1 GitHub App, after the PR's change list is brought up
|
|
799
|
+
* to date with what landed on the branch while the checks ran. Checks that fail, never start or
|
|
800
|
+
* never finish stop the merge; a repository with no CI/CD configured has nothing to wait for. The one
|
|
801
|
+
* sanctioned admin override remains, merged here as the admin: it needs an explicit confirmation, a
|
|
802
|
+
* stated reason, and the exception must be recorded as a comment on the PR *before* the merge - if
|
|
803
|
+
* the comment cannot be posted, nothing is merged.
|
|
626
804
|
*/
|
|
627
|
-
export async function mergeWithCheckGate(branchName, kind) {
|
|
805
|
+
export async function mergeWithCheckGate(branchName, targetBranch, kind) {
|
|
628
806
|
const checks = await github.waitForPrChecks(branchName);
|
|
629
807
|
if (checks.passed) {
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
throw new Error(`Failed to merge PR: ${result.error}`);
|
|
633
|
-
}
|
|
808
|
+
await ensureChangeListCurrent(branchName, targetBranch, kind);
|
|
809
|
+
await platform.approveRelease(await git.getRepositoryFullName(), branchName);
|
|
634
810
|
return;
|
|
635
811
|
}
|
|
636
812
|
logger.newline();
|
|
@@ -644,6 +820,7 @@ export async function mergeWithCheckGate(branchName, kind) {
|
|
|
644
820
|
const reason = (await promptInput('Reason for the override (required - recorded on the PR):', {
|
|
645
821
|
validate: input => input.trim() ? true : 'A reason is required for an admin override'
|
|
646
822
|
})).trim();
|
|
823
|
+
await ensureChangeListCurrent(branchName, targetBranch, kind);
|
|
647
824
|
logger.step('Recording the exception on the PR...');
|
|
648
825
|
await github.addPrComment(branchName, buildOverrideComment(kind, reason, checks.summary));
|
|
649
826
|
const result = await github.mergePrWithAdminOverride(branchName);
|
|
@@ -652,33 +829,6 @@ export async function mergeWithCheckGate(branchName, kind) {
|
|
|
652
829
|
}
|
|
653
830
|
logger.success('Exception recorded on the PR');
|
|
654
831
|
}
|
|
655
|
-
/**
|
|
656
|
-
* GitHub's answers to an auto-merge request that mean auto-merge is not the way to merge this PR:
|
|
657
|
-
* it is already mergeable (nothing left to gate on), the repository doesn't allow auto-merge, or
|
|
658
|
-
* the base branch has no protection for auto-merge to wait on.
|
|
659
|
-
*/
|
|
660
|
-
const AUTO_MERGE_UNAVAILABLE = /\b(clean|unstable) status\b|auto.?merge is not allowed|protected branch rules not configured/i;
|
|
661
|
-
/**
|
|
662
|
-
* Merge the PR the way that records the person running this as `merged_by`, without them having to
|
|
663
|
-
* wait: GitHub's auto-merge, which merges once the required checks pass. Where GitHub can't
|
|
664
|
-
* auto-merge (the PR is already mergeable, or the repository doesn't allow auto-merge), it falls
|
|
665
|
-
* back to waiting for the checks here and merging (`mergeWithCheckGate`, with its admin-override
|
|
666
|
-
* path). Any other refusal throws.
|
|
667
|
-
*/
|
|
668
|
-
export async function enableAutoMergeOrPoll(branchName, kind) {
|
|
669
|
-
const result = await github.enablePrAutoMerge(branchName);
|
|
670
|
-
if (result.success) {
|
|
671
|
-
logger.success('Auto-merge enabled: GitHub merges the PR once the required checks pass, recording you as the merger.');
|
|
672
|
-
return 'auto-merge-enabled';
|
|
673
|
-
}
|
|
674
|
-
const error = result.error ?? '';
|
|
675
|
-
if (!AUTO_MERGE_UNAVAILABLE.test(error)) {
|
|
676
|
-
throw new Error(`Could not enable auto-merge on the PR for ${branchName}: ${error}`);
|
|
677
|
-
}
|
|
678
|
-
logger.dim(`GitHub can't auto-merge this PR (${error.split('\n')[0]}) - waiting for the checks and merging here instead.`);
|
|
679
|
-
await mergeWithCheckGate(branchName, kind);
|
|
680
|
-
return 'merged';
|
|
681
|
-
}
|
|
682
832
|
/**
|
|
683
833
|
* Open URL in browser (cross-platform)
|
|
684
834
|
*/
|
|
@@ -706,13 +856,20 @@ async function openGitHubActions() {
|
|
|
706
856
|
/**
|
|
707
857
|
* Finish the sequence in the chosen mode. Either way the merge is the approval of record: in
|
|
708
858
|
* `create` mode the approver performs it in GitHub once the checks are green; in `merge` mode the
|
|
709
|
-
* CLI
|
|
710
|
-
*
|
|
711
|
-
*
|
|
859
|
+
* CLI waits for the checks and the Idea1 platform merges it as the approver's recorded approval. A
|
|
860
|
+
* content release is merged only the second way, since its releaser cannot merge into the release
|
|
861
|
+
* target in GitHub, so `create` mode leaves it for `idea1 release --approve`.
|
|
712
862
|
*/
|
|
713
863
|
export async function runPrMode(options) {
|
|
714
864
|
const { mode, sourceBranch, targetBranch, prUrl, kind } = options;
|
|
715
865
|
const noun = `${kind.label.toLowerCase()} PR`;
|
|
866
|
+
if (mode === 'create' && kind === CONTENT_RELEASE_KIND) {
|
|
867
|
+
logger.success('================================================');
|
|
868
|
+
logger.success(`${kind.label} PR created`);
|
|
869
|
+
logger.success('================================================');
|
|
870
|
+
logger.hint(`Once its required checks pass, run idea1 release --branch ${sourceBranch} --approve: Idea1 records your approval and merges it.`);
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
716
873
|
if (mode === 'create') {
|
|
717
874
|
logger.step('Opening PR in browser...');
|
|
718
875
|
await exec('gh', ['pr', 'view', sourceBranch, '--web']);
|
|
@@ -723,25 +880,18 @@ export async function runPrMode(options) {
|
|
|
723
880
|
logger.hint(`Merge it in GitHub once the required checks pass - that merge is the ${kind.label.toLowerCase()} approval of record.`);
|
|
724
881
|
return;
|
|
725
882
|
}
|
|
726
|
-
await confirmApproverIdentity(sourceBranch);
|
|
727
883
|
const confirmMerge = options.confirmed || await promptConfirm(`Merge ${noun} (${sourceBranch} -> ${targetBranch}) once the required checks pass?`, { default: true });
|
|
728
884
|
if (!confirmMerge) {
|
|
729
|
-
logger.info(`Merge cancelled. The ${noun} is created
|
|
885
|
+
logger.info(`Merge cancelled. The ${noun} is created; approve it with idea1 release --branch ${sourceBranch} --approve.`);
|
|
730
886
|
logger.hint(`PR: ${prUrl}`);
|
|
731
887
|
return;
|
|
732
888
|
}
|
|
733
|
-
|
|
889
|
+
await ensureChangeListCurrent(sourceBranch, targetBranch, kind);
|
|
890
|
+
await mergeWithCheckGate(sourceBranch, targetBranch, kind);
|
|
734
891
|
logger.newline();
|
|
735
|
-
await
|
|
892
|
+
await reportMerged(kind, targetBranch, options.openActionsAfterMerge);
|
|
736
893
|
}
|
|
737
|
-
async function
|
|
738
|
-
if (outcome === 'auto-merge-enabled') {
|
|
739
|
-
logger.success('================================================');
|
|
740
|
-
logger.success(`${kind.label} PR will merge to ${targetBranch} once the required checks pass`);
|
|
741
|
-
logger.success('================================================');
|
|
742
|
-
logger.hint(`Follow it on the PR: ${prUrl}`);
|
|
743
|
-
return;
|
|
744
|
-
}
|
|
894
|
+
async function reportMerged(kind, targetBranch, openActionsAfterMerge) {
|
|
745
895
|
if (openActionsAfterMerge) {
|
|
746
896
|
await openGitHubActions();
|
|
747
897
|
logger.newline();
|
|
@@ -751,10 +901,9 @@ async function reportMergeOutcome(outcome, kind, targetBranch, prUrl, openAction
|
|
|
751
901
|
logger.success('========================================');
|
|
752
902
|
}
|
|
753
903
|
/**
|
|
754
|
-
* Why the person running the command cannot release, or null when they can. A release
|
|
755
|
-
*
|
|
756
|
-
*
|
|
757
|
-
* permissions the same way.
|
|
904
|
+
* Why the person running the command cannot release, or null when they can. A release opens its PR
|
|
905
|
+
* and records direct-push and override reasons on it as that person, so they need push access to the
|
|
906
|
+
* repository. The platform's `ReleaseEngine` checks its GitHub App's permissions the same way.
|
|
758
907
|
*/
|
|
759
908
|
async function getMissingPermissionsMessage() {
|
|
760
909
|
const access = await github.getRepoAccess();
|
|
@@ -806,12 +955,14 @@ export async function ensureReleasable(sourceBranch, targetBranch) {
|
|
|
806
955
|
throw new Error(`A release PR is already open: ${pr.url}. Run idea1 release --approve to approve it.`);
|
|
807
956
|
}
|
|
808
957
|
/**
|
|
809
|
-
* `idea1 release --approve`: approve
|
|
810
|
-
*
|
|
811
|
-
* of its assignees, its approvers of record,
|
|
812
|
-
*
|
|
813
|
-
*
|
|
814
|
-
* which refuses unless the override is confirmed,
|
|
958
|
+
* `idea1 release --approve`: approve the open release PR from the source branch into the target, as
|
|
959
|
+
* its approver, and have the Idea1 platform merge it. Only a PR that follows the release workflow is
|
|
960
|
+
* approved, and only by one of its assignees, its approvers of record, who holds the release role for
|
|
961
|
+
* its kind of release - the platform checks both again. The CLI waits for the required checks, and
|
|
962
|
+
* the platform records the approval on the PR and merges it as the Idea1 GitHub App; with a failed
|
|
963
|
+
* check it goes to the gate's admin-override path, which refuses unless the override is confirmed,
|
|
964
|
+
* reasoned and recorded. Before any of that, the PR's change list is brought up to date with what
|
|
965
|
+
* landed on the branch since it was opened.
|
|
815
966
|
*/
|
|
816
967
|
export async function approveRelease(sourceBranch, targetBranch, kind) {
|
|
817
968
|
const missingPermissions = await getMissingPermissionsMessage();
|
|
@@ -827,33 +978,17 @@ export async function approveRelease(sourceBranch, targetBranch, kind) {
|
|
|
827
978
|
if (violation) {
|
|
828
979
|
throw new Error(violation);
|
|
829
980
|
}
|
|
830
|
-
const login = await
|
|
981
|
+
const login = await signedInGitHubLogin();
|
|
831
982
|
const assignees = pr.assignees.map(assignee => `@${assignee}`).join(', ');
|
|
832
983
|
if (!pr.assignees.some(assignee => assignee.toLowerCase() === login.toLowerCase())) {
|
|
833
984
|
throw new Error(`The release PR is assigned to ${assignees}, its approver(s) of record, not @${login}. ` +
|
|
834
985
|
'Reassign it in GitHub, or have the assignee approve it.');
|
|
835
986
|
}
|
|
836
987
|
logger.info(`Assigned to ${assignees} - you are the approver of record`);
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
if (checkState === 'passed') {
|
|
841
|
-
const result = await github.mergePr(sourceBranch);
|
|
842
|
-
if (!result.success) {
|
|
843
|
-
throw new Error(`Failed to merge PR: ${result.error}`);
|
|
844
|
-
}
|
|
845
|
-
outcome = 'merged';
|
|
846
|
-
}
|
|
847
|
-
else if (checkState === 'pending') {
|
|
848
|
-
outcome = await enableAutoMergeOrPoll(sourceBranch, kind);
|
|
849
|
-
}
|
|
850
|
-
else {
|
|
851
|
-
// Failed checks reach the gate's admin-override path; checks not reported yet are waited for
|
|
852
|
-
// there, and a repository with no CI/CD at all merges straight through it.
|
|
853
|
-
await mergeWithCheckGate(sourceBranch, kind);
|
|
854
|
-
outcome = 'merged';
|
|
855
|
-
}
|
|
988
|
+
await requireReleaser(kind, sourceBranch);
|
|
989
|
+
await ensureChangeListCurrent(sourceBranch, targetBranch, kind);
|
|
990
|
+
await mergeWithCheckGate(sourceBranch, targetBranch, kind);
|
|
856
991
|
logger.newline();
|
|
857
|
-
await
|
|
992
|
+
await reportMerged(kind, targetBranch, true);
|
|
858
993
|
}
|
|
859
994
|
//# sourceMappingURL=release-engine.js.map
|