looply-cli 0.5.10 → 0.5.11

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.
Files changed (105) hide show
  1. package/dist/.src-hash +1 -1
  2. package/dist/autonomy-controller.d.ts +23 -1
  3. package/dist/autonomy-controller.d.ts.map +1 -1
  4. package/dist/autonomy-controller.js +50 -9
  5. package/dist/autonomy-controller.js.map +1 -1
  6. package/dist/autonomy-store.d.ts +33 -1
  7. package/dist/autonomy-store.d.ts.map +1 -1
  8. package/dist/autonomy-store.js +58 -8
  9. package/dist/autonomy-store.js.map +1 -1
  10. package/dist/autonomy-tick.d.ts +23 -1
  11. package/dist/autonomy-tick.d.ts.map +1 -1
  12. package/dist/autonomy-tick.js +80 -14
  13. package/dist/autonomy-tick.js.map +1 -1
  14. package/dist/bin.d.ts.map +1 -1
  15. package/dist/bin.js +25 -5
  16. package/dist/bin.js.map +1 -1
  17. package/dist/branch-delivery-wiring.d.ts.map +1 -1
  18. package/dist/branch-delivery-wiring.js +6 -1
  19. package/dist/branch-delivery-wiring.js.map +1 -1
  20. package/dist/branch-delivery.d.ts +135 -6
  21. package/dist/branch-delivery.d.ts.map +1 -1
  22. package/dist/branch-delivery.js +540 -80
  23. package/dist/branch-delivery.js.map +1 -1
  24. package/dist/branch-replay.d.ts +45 -0
  25. package/dist/branch-replay.d.ts.map +1 -0
  26. package/dist/branch-replay.js +123 -0
  27. package/dist/branch-replay.js.map +1 -0
  28. package/dist/commit-ownership.d.ts +102 -0
  29. package/dist/commit-ownership.d.ts.map +1 -0
  30. package/dist/commit-ownership.js +97 -0
  31. package/dist/commit-ownership.js.map +1 -0
  32. package/dist/coordinator-state.d.ts.map +1 -1
  33. package/dist/coordinator-state.js +21 -1
  34. package/dist/coordinator-state.js.map +1 -1
  35. package/dist/daemon-cmd.d.ts.map +1 -1
  36. package/dist/daemon-cmd.js +24 -3
  37. package/dist/daemon-cmd.js.map +1 -1
  38. package/dist/deliver-cmd.d.ts +18 -0
  39. package/dist/deliver-cmd.d.ts.map +1 -1
  40. package/dist/deliver-cmd.js +71 -5
  41. package/dist/deliver-cmd.js.map +1 -1
  42. package/dist/delivery-settings-cache.d.ts +15 -1
  43. package/dist/delivery-settings-cache.d.ts.map +1 -1
  44. package/dist/delivery-settings-cache.js +31 -3
  45. package/dist/delivery-settings-cache.js.map +1 -1
  46. package/dist/environment-hold.d.ts +69 -0
  47. package/dist/environment-hold.d.ts.map +1 -0
  48. package/dist/environment-hold.js +120 -0
  49. package/dist/environment-hold.js.map +1 -0
  50. package/dist/gate.d.ts.map +1 -1
  51. package/dist/gate.js +15 -1
  52. package/dist/gate.js.map +1 -1
  53. package/dist/github-api.d.ts +19 -0
  54. package/dist/github-api.d.ts.map +1 -1
  55. package/dist/github-api.js +26 -0
  56. package/dist/github-api.js.map +1 -1
  57. package/dist/goal-adaptive.d.ts +1 -0
  58. package/dist/goal-adaptive.d.ts.map +1 -1
  59. package/dist/goal-adaptive.js +26 -13
  60. package/dist/goal-adaptive.js.map +1 -1
  61. package/dist/goal-compile.d.ts +37 -0
  62. package/dist/goal-compile.d.ts.map +1 -1
  63. package/dist/goal-compile.js +25 -1
  64. package/dist/goal-compile.js.map +1 -1
  65. package/dist/goal-replay.d.ts +83 -0
  66. package/dist/goal-replay.d.ts.map +1 -0
  67. package/dist/goal-replay.js +121 -0
  68. package/dist/goal-replay.js.map +1 -0
  69. package/dist/goal-reproof.d.ts +5 -0
  70. package/dist/goal-reproof.d.ts.map +1 -1
  71. package/dist/goal-reproof.js +11 -4
  72. package/dist/goal-reproof.js.map +1 -1
  73. package/dist/grant-pause.d.ts +48 -0
  74. package/dist/grant-pause.d.ts.map +1 -0
  75. package/dist/grant-pause.js +103 -0
  76. package/dist/grant-pause.js.map +1 -0
  77. package/dist/grant-reconcile.d.ts +8 -2
  78. package/dist/grant-reconcile.d.ts.map +1 -1
  79. package/dist/grant-reconcile.js +9 -2
  80. package/dist/grant-reconcile.js.map +1 -1
  81. package/dist/grant-waiting-wiring.d.ts +49 -0
  82. package/dist/grant-waiting-wiring.d.ts.map +1 -0
  83. package/dist/grant-waiting-wiring.js +77 -0
  84. package/dist/grant-waiting-wiring.js.map +1 -0
  85. package/dist/planner-timeouts.d.ts +14 -0
  86. package/dist/planner-timeouts.d.ts.map +1 -0
  87. package/dist/planner-timeouts.js +51 -0
  88. package/dist/planner-timeouts.js.map +1 -0
  89. package/dist/run-cmd.d.ts +12 -1
  90. package/dist/run-cmd.d.ts.map +1 -1
  91. package/dist/run-cmd.js +146 -10
  92. package/dist/run-cmd.js.map +1 -1
  93. package/dist/run-refusal-marks.d.ts +7 -1
  94. package/dist/run-refusal-marks.d.ts.map +1 -1
  95. package/dist/run-refusal-marks.js +4 -0
  96. package/dist/run-refusal-marks.js.map +1 -1
  97. package/dist/server-client.d.ts +15 -1
  98. package/dist/server-client.d.ts.map +1 -1
  99. package/dist/server-client.js +17 -0
  100. package/dist/server-client.js.map +1 -1
  101. package/dist/start-attempts.d.ts +4 -2
  102. package/dist/start-attempts.d.ts.map +1 -1
  103. package/dist/start-attempts.js +6 -1
  104. package/dist/start-attempts.js.map +1 -1
  105. package/package.json +2 -2
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.NO_CHECKS_GRACE_MS = exports.CHECKS_PATIENCE_MS = exports.BRANCH_DELIVERIES_FILE = void 0;
3
+ exports.FOLLOW_DEPLOYS_MS = exports.NO_CHECKS_GRACE_MS = exports.CHECKS_PATIENCE_MS = exports.BRANCH_DELIVERIES_FILE = void 0;
4
4
  exports.nodeDeliveryGit = nodeDeliveryGit;
5
5
  exports.makeDeployPush = makeDeployPush;
6
6
  exports.fileDeliveryStore = fileDeliveryStore;
@@ -10,6 +10,9 @@ exports.stackedOn = stackedOn;
10
10
  exports.pullTitle = pullTitle;
11
11
  exports.pullBody = pullBody;
12
12
  exports.deliverBranch = deliverBranch;
13
+ exports.describeForeign = describeForeign;
14
+ exports.unmergedForeign = unmergedForeign;
15
+ exports.mergeGuard = mergeGuard;
13
16
  exports.decideChecks = decideChecks;
14
17
  exports.advanceDeliveries = advanceDeliveries;
15
18
  exports.makeReleaseRunner = makeReleaseRunner;
@@ -19,8 +22,12 @@ const node_path_1 = require("node:path");
19
22
  const delivery_rules_1 = require("./delivery-rules");
20
23
  const git_driver_1 = require("./git-driver");
21
24
  const integrator_1 = require("./integrator");
25
+ const branch_replay_1 = require("./branch-replay");
26
+ const commit_ownership_1 = require("./commit-ownership");
22
27
  const run_base_1 = require("./run-base");
23
- function nodeDeliveryGit(cwd, configuredBase, rawExec = (0, git_driver_1.nodeGitExec)(cwd)) {
28
+ function nodeDeliveryGit(cwd, configuredBase, rawExec = (0, git_driver_1.nodeGitExec)(cwd),
29
+ /** Where a rebuild's temporary worktree goes (os tmpdir by default), and how git is run in it. */
30
+ rebuild = {}) {
24
31
  /**
25
32
  * NO SIGN-IN WINDOW, EVER. The network verbs here (push, ls-remote) reach the
26
33
  * person's credential helper — Git Credential Manager on Windows — which opens
@@ -77,6 +84,47 @@ function nodeDeliveryGit(cwd, configuredBase, rawExec = (0, git_driver_1.nodeGit
77
84
  return { ok: true };
78
85
  return { ok: false, because: (0, delivery_rules_1.redactUrl)(`${r.stderr}\n${r.stdout}`.trim()).slice(0, 400) || `git push exited ${r.code}` };
79
86
  },
87
+ async freshBase(base) {
88
+ const name = (0, delivery_rules_1.shortBranch)(base);
89
+ if (/^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(name) && !name.includes('..')) {
90
+ // Read-only for the person: it moves origin's remote-tracking copy and nothing else.
91
+ await out(['fetch', '--quiet', '--no-tags', delivery_rules_1.DELIVERY_REMOTE, `refs/heads/${name}:refs/remotes/${delivery_rules_1.DELIVERY_REMOTE}/${name}`]).catch(() => undefined);
92
+ }
93
+ const tracking = `refs/remotes/${delivery_rules_1.DELIVERY_REMOTE}/${name}`;
94
+ return (await driver.refExists(tracking).catch(() => false)) ? tracking : base;
95
+ },
96
+ async commitsBetween(base, tip) {
97
+ const r = await out(['rev-list', '--reverse', '--topo-order', '--parents', `${base}..${tip}`, '--']).catch(() => null);
98
+ if (r === null || r.code !== 0)
99
+ return null;
100
+ return r.stdout
101
+ .split(/\r?\n/)
102
+ .map((l) => l.trim().split(/\s+/))
103
+ .filter((ids) => /^[0-9a-f]{40}$/.test(ids[0] ?? ''))
104
+ .map(([sha, ...parents]) => ({ sha: sha, parents }));
105
+ },
106
+ async replay(base, commits) {
107
+ try {
108
+ return await (0, branch_replay_1.replayOnto)(exec, (dir) => {
109
+ const inner = (rebuild.execAt ?? git_driver_1.nodeGitExec)(dir);
110
+ return (args) => inner(['-c', 'credential.interactive=never', ...args]);
111
+ }, base, commits, rebuild.scratchRoot);
112
+ }
113
+ catch (e) {
114
+ return { ok: false, because: e.message.slice(0, 300) };
115
+ }
116
+ },
117
+ async treeOf(ref) {
118
+ const r = await out(['rev-parse', '--verify', '--quiet', `${ref}^{tree}`]).catch(() => null);
119
+ return r !== null && r.code === 0 && r.stdout.trim() !== '' ? r.stdout.trim() : null;
120
+ },
121
+ async createBranch(name, sha) {
122
+ // `branch` without `-f` refuses an existing name, so this can only ever ADD a branch.
123
+ if (!name.startsWith(integrator_1.RUN_BRANCH_PREFIX) || !/^[0-9a-f]{40}$/.test(sha))
124
+ return false;
125
+ const r = await out(['branch', '--no-track', name, sha]).catch(() => null);
126
+ return r !== null && r.code === 0;
127
+ },
80
128
  };
81
129
  }
82
130
  /**
@@ -174,12 +222,7 @@ async function findGoalBranch(git, input, branches) {
174
222
  return null;
175
223
  }
176
224
  /** Which goal a run branch belongs to, from the lane claims; undefined when nothing says. */
177
- function goalOfBranch(claims, branch) {
178
- for (let i = claims.length - 1; i >= 0; i--)
179
- if (claims[i].branch === branch)
180
- return claims[i].goalId;
181
- return undefined;
182
- }
225
+ const goalOfBranch = commit_ownership_1.ownerOfBranch;
183
226
  /**
184
227
  * THE OTHER, UNMERGED GOAL'S BRANCH THIS ONE IS BUILT ON — or null.
185
228
  *
@@ -235,6 +278,7 @@ function reportOf(e) {
235
278
  dependsOn: e.dependsOn,
236
279
  mergeSha: e.mergeSha ?? null,
237
280
  deploy: e.deploy ?? null,
281
+ ...(e.environments === undefined ? {} : { environments: e.environments }),
238
282
  };
239
283
  }
240
284
  async function save(deps, e) {
@@ -280,6 +324,14 @@ function pullBody(input) {
280
324
  lines.push(`⚠️ This builds on \`${input.stacked.branch}\`${input.stacked.goalId ? ` (goal ${input.stacked.goalId})` : ''}, which is not merged and not on GitHub, so this pull request also contains that branch's commits. Review or merge that work first.`);
281
325
  }
282
326
  }
327
+ if (input.rebuilt) {
328
+ lines.push('');
329
+ lines.push(`This branch was rebuilt from \`${input.rebuilt.from}\` onto the base with only this goal's own commits: ${input.rebuilt.without}.`);
330
+ }
331
+ if (input.foreign) {
332
+ lines.push('');
333
+ lines.push(`⚠️ ${input.foreign.charAt(0).toUpperCase()}${input.foreign.slice(1)}. Looply will not merge this while that is so.`);
334
+ }
283
335
  return lines.join('\n');
284
336
  }
285
337
  /**
@@ -332,10 +384,14 @@ async function deliverOnce(deps, input) {
332
384
  ? `there is no local branch ${input.branch} (only ${integrator_1.RUN_BRANCH_PREFIX}* branches are delivered)`
333
385
  : `no ${integrator_1.RUN_BRANCH_PREFIX}* branch on this machine holds commit ${input.sha.slice(0, 12)}`);
334
386
  }
335
- const branch = found.name;
336
- const tip = found.sha;
337
- // Already done at this tip: say it again (the server may have missed it), do nothing else.
338
- if (prior !== null && prior.branch === branch && prior.sha === tip && prior.pullRequestUrl !== null) {
387
+ const original = { name: found.name, sha: found.sha };
388
+ let branch = found.name;
389
+ let tip = found.sha;
390
+ // Already done at this tip — as it is, or as its rebuild: say it again (the server may have missed it), do nothing else.
391
+ if (input.replace !== true &&
392
+ prior !== null &&
393
+ prior.pullRequestUrl !== null &&
394
+ ((prior.branch === branch && prior.sha === tip) || (prior.rebuiltFrom?.branch === branch && prior.rebuiltFrom.sha === tip))) {
339
395
  return save(deps, { ...prior, updatedAt: at });
340
396
  }
341
397
  const remoteUrl = await deps.git.remoteUrl();
@@ -350,6 +406,30 @@ async function deliverOnce(deps, input) {
350
406
  const refused = (0, delivery_rules_1.pushRefusal)({ remote: delivery_rules_1.DELIVERY_REMOTE, remoteUrl, branch, base, protectedBranches });
351
407
  if (refused !== null)
352
408
  return notPushed(branch, refused);
409
+ /**
410
+ * OTHER GOALS' UNMERGED COMMITS UNDER THIS ONE: REBUILD WITHOUT THEM, WHEN IT CAN.
411
+ * A pull request that carries another ticket's unreviewed work is one a green check — or an
412
+ * "Approve and ship" — would merge into the base with it. So unless that other goal is one
413
+ * this goal DECLARES it depends on (then the stacking is the point, and the merge guard holds
414
+ * it until the dependency merges), the goal's own commits are replayed onto the base as
415
+ * `<branch>-solo`, and that is what is delivered. When the replay refuses, the branch goes as
416
+ * it is, and says so.
417
+ */
418
+ const onOrigin = await deps.git.remoteRunBranches().catch(() => new Set());
419
+ const plan = await planRebuild(deps, input, { branch, tip, base, branches, onOrigin, prior, at });
420
+ const rebuilt = plan.kind === 'rebuilt' ? plan : null;
421
+ const foreignLeft = plan.kind === 'stacked' ? plan.foreign : [];
422
+ const knownBranches = rebuilt === null ? branches : [...branches, { name: rebuilt.branch, sha: rebuilt.sha }];
423
+ if (rebuilt !== null) {
424
+ branch = rebuilt.branch;
425
+ tip = rebuilt.sha;
426
+ deps.log(`deliver : rebuilt ${original.name} as ${branch} (${tip.slice(0, 12)}) without ${rebuilt.withoutText}`);
427
+ if (api !== null && (await api.branchProtected(branch)) === true)
428
+ protectedBranches.push(branch);
429
+ const refusedSolo = (0, delivery_rules_1.pushRefusal)({ remote: delivery_rules_1.DELIVERY_REMOTE, remoteUrl, branch, base, protectedBranches });
430
+ if (refusedSolo !== null)
431
+ return notPushed(branch, refusedSolo);
432
+ }
353
433
  // Never forced: origin's copy must be absent, this tip, or something this tip contains.
354
434
  const theirs = await deps.git.remoteSha(branch);
355
435
  if (theirs !== null && theirs !== tip && !(await deps.git.isAncestor(theirs, tip))) {
@@ -361,9 +441,8 @@ async function deliverOnce(deps, input) {
361
441
  return notPushed(branch, `git push to origin failed: ${pushed.because}`);
362
442
  deps.log(`deliver : pushed ${branch} (${tip.slice(0, 12)}) to origin`);
363
443
  }
364
- const onOrigin = await deps.git.remoteRunBranches().catch(() => new Set());
365
444
  onOrigin.add(branch);
366
- const stack = await stackedOn(deps.git, { branch, tip, goalId: input.goalId, base, claims: input.claims, branches, onOrigin });
445
+ const stack = await stackedOn(deps.git, { branch, tip, goalId: input.goalId, base, claims: input.claims, branches: knownBranches, onOrigin });
367
446
  const projectBase = base === null ? null : (0, delivery_rules_1.shortBranch)(base);
368
447
  const entry = {
369
448
  ...blank(branch),
@@ -373,6 +452,7 @@ async function deliverOnce(deps, input) {
373
452
  projectBase,
374
453
  dependsOn: stack?.branch ?? null,
375
454
  state: 'pushed',
455
+ ...(rebuilt === null ? {} : { rebuiltFrom: { branch: original.name, sha: original.sha, without: rebuilt.without } }),
376
456
  };
377
457
  if (gh === null) {
378
458
  return save(deps, { ...entry, note: `pushed to origin (${remoteUrl === null ? 'no URL' : (0, delivery_rules_1.redactUrl)(remoteUrl)}); origin is not on github.com, so no pull request was opened` });
@@ -394,7 +474,12 @@ async function deliverOnce(deps, input) {
394
474
  if (prBase === null) {
395
475
  return save(deps, { ...entry, note: `pushed ${branch} to origin, but no pull request was opened: the project's base branch could not be found (set run.base_branch)` });
396
476
  }
477
+ const absorbs = replacing !== null ? [replacing.goalId] : [];
478
+ const foreignStill = foreignLeft.filter((f) => f.goalId === undefined || !absorbs.includes(f.goalId));
479
+ const foreignText = foreignStill.length > 0 ? describeForeign(deps.store, foreignStill, projectBase ?? prBase) : null;
480
+ const couldNot = plan.kind === 'stacked' && plan.because !== null ? `Looply could not rebuild it without that work (${plan.because})` : null;
397
481
  let pull = await api.findPull(branch);
482
+ let replacedOwn = null;
398
483
  if (pull === null || (pull.state === 'closed' && !pull.merged)) {
399
484
  const stackedPull = stack !== null && stack.onOrigin ? await api.findPull(stack.branch).catch(() => null) : null;
400
485
  try {
@@ -402,26 +487,63 @@ async function deliverOnce(deps, input) {
402
487
  title: pullTitle(input.goalText),
403
488
  head: branch,
404
489
  base: prBase,
405
- body: pullBody({ goalId: input.goalId, goalText: input.goalText, branch, sha: tip, ...(input.checks ? { checks: input.checks } : {}), mode: input.mode, stacked: stack, stackedPull }),
490
+ body: pullBody({
491
+ goalId: input.goalId,
492
+ goalText: input.goalText,
493
+ branch,
494
+ sha: tip,
495
+ ...(input.checks ? { checks: input.checks } : {}),
496
+ mode: input.mode,
497
+ stacked: stack,
498
+ stackedPull,
499
+ rebuilt: rebuilt === null ? null : { from: original.name, without: rebuilt.withoutText },
500
+ foreign: foreignText,
501
+ }),
406
502
  });
407
503
  deps.log(`deliver : opened ${pull.url} (${branch} → ${prBase})`);
408
504
  if (replacing !== null) {
409
505
  await api.closePull(replacing.pullNumber, `Replaced by ${pull.url}, which contains this work and its fix.`).catch(() => undefined);
410
506
  await save(deps, { ...replacing, done: true, note: `replaced by ${pull.url}`, updatedAt: at });
411
507
  }
508
+ // `--rebuild`: the old pull request of this goal's stacked branch is closed, pointing here.
509
+ if (input.replace === true && rebuilt !== null && prior !== null && prior.pullNumber !== null && prior.branch === original.name && prior.pullRequestUrl !== pull.url) {
510
+ const old = await api.getPull(prior.pullNumber).catch(() => null);
511
+ if (old !== null && old.state === 'open' && !old.merged) {
512
+ await api.closePull(prior.pullNumber, `Replaced by ${pull.url}, which holds only this ticket's work.`);
513
+ deps.log(`deliver : closed ${prior.pullRequestUrl} (replaced by ${pull.url})`);
514
+ replacedOwn = prior;
515
+ }
516
+ }
412
517
  }
413
518
  catch (e) {
414
519
  return save(deps, { ...entry, note: `pushed ${branch} to origin, but GitHub refused the pull request: ${e.message}` });
415
520
  }
416
521
  }
417
- const state = pull.merged ? 'merged' : input.mode === 'pr' ? 'pr-open' : 'waiting-checks';
418
- const note = stack === null
419
- ? null
420
- : replacing !== null
421
- ? `replaces ${replacing.pullRequestUrl} (its checks failed); contains that work and the fix`
422
- : stack.onOrigin
423
- ? `builds on ${stack.branch}, which is not merged yet; the pull request targets it`
424
- : `builds on ${stack.branch}${stack.goalId ? ` (goal ${stack.goalId})` : ''}, which is not merged or pushed; the pull request also contains its commits`;
522
+ /**
523
+ * 🔴 UNDER `merge`/`deploy`, A PULL REQUEST INTO THE BASE THAT STILL CARRIES ANOTHER GOAL'S
524
+ * UNMERGED WORK IS `blocked` FROM THE START — the merge guard in `advanceOne` would say so on
525
+ * its first pass anyway, and the ticket should not read "waiting for checks" in between. One
526
+ * that targets its dependency's branch waits for that branch as before, and meets the guard
527
+ * once it is retargeted.
528
+ */
529
+ const blocked = !pull.merged && input.mode !== 'pr' && foreignStill.length > 0 && (pull.base || prBase) === projectBase;
530
+ const state = pull.merged ? 'merged' : input.mode === 'pr' ? 'pr-open' : blocked ? 'blocked' : 'waiting-checks';
531
+ const tail = (s) => (couldNot === null ? s : `${s}; ${couldNot}`);
532
+ const note = replacing !== null
533
+ ? `replaces ${replacing.pullRequestUrl} (its checks failed); contains that work and the fix`
534
+ : blocked
535
+ ? couldNot === null
536
+ ? guardNote(foreignText, original.name)
537
+ : `not merged: ${foreignText}; merge or close that first (${couldNot})`
538
+ : rebuilt !== null
539
+ ? `rebuilt onto ${projectBase ?? prBase} without ${rebuilt.withoutText}${replacedOwn !== null ? `; replaces ${replacedOwn.pullRequestUrl}` : ''}`
540
+ : stack !== null
541
+ ? tail(stack.onOrigin
542
+ ? `builds on ${stack.branch}, which is not merged yet; the pull request targets it`
543
+ : `builds on ${stack.branch}${stack.goalId ? ` (goal ${stack.goalId})` : ''}, which is not merged or pushed; the pull request also contains its commits`)
544
+ : foreignText !== null
545
+ ? tail(foreignText)
546
+ : null;
425
547
  return save(deps, {
426
548
  ...entry,
427
549
  pullNumber: pull.number,
@@ -429,8 +551,167 @@ async function deliverOnce(deps, input) {
429
551
  base: pull.base || prBase,
430
552
  state,
431
553
  note,
554
+ ...(absorbs.length > 0 ? { absorbs } : {}),
432
555
  });
433
556
  }
557
+ /**
558
+ * Decide — and, when it is the answer, do — the rebuild of a branch that carries other goals'
559
+ * unmerged commits. Never throws for git's sake: anything git will not do is `stacked` with the
560
+ * reason, and the branch is delivered as it is.
561
+ */
562
+ async function planRebuild(deps, input, at) {
563
+ if (at.base === null)
564
+ return { kind: 'unknown' };
565
+ // The same goal at the same tip was rebuilt before and that branch is still here: use it.
566
+ const before = at.prior?.rebuiltFrom;
567
+ if (before && before.branch === at.branch && before.sha === at.tip) {
568
+ const kept = at.branches.find((b) => b.name === at.prior.branch);
569
+ if (kept !== undefined) {
570
+ return { kind: 'rebuilt', branch: kept.name, sha: kept.sha, without: before.without, withoutText: withoutSentence(before.without) };
571
+ }
572
+ }
573
+ const ref = await deps.git.freshBase(at.base);
574
+ const owned = await (0, commit_ownership_1.commitOwnership)(deps.git, { tip: at.tip, base: ref, goalId: input.goalId, claims: input.claims, branches: at.branches, onOrigin: at.onOrigin });
575
+ if (owned === null)
576
+ return { kind: 'unknown' };
577
+ const foreign = await unmergedForeign(deps.git, deps.store, owned.foreign, []);
578
+ if (foreign.length === 0)
579
+ return { kind: 'clean' };
580
+ if (foreign.some((f) => f.goalId !== undefined && (input.dependsOn ?? []).includes(f.goalId))) {
581
+ return { kind: 'stacked', foreign, because: null };
582
+ }
583
+ if (owned.merges.some((m) => owned.own.includes(m))) {
584
+ return { kind: 'stacked', foreign, because: "this goal's own commits include a merge, which Looply never replays" };
585
+ }
586
+ const replayed = await deps.git.replay(ref, owned.own);
587
+ if (!replayed.ok)
588
+ return { kind: 'stacked', foreign, because: replayed.because };
589
+ // `<branch>-solo`, or the next free `-solo-N`; one already here with exactly this content is reused.
590
+ const stem = at.branch.replace(/-solo(-\d+)?$/, '');
591
+ let name = null;
592
+ let sha = replayed.sha;
593
+ for (let i = 1; i <= 20 && name === null; i++) {
594
+ const candidate = i === 1 ? `${stem}${commit_ownership_1.SOLO_SUFFIX}` : `${stem}${commit_ownership_1.SOLO_SUFFIX}-${i}`;
595
+ const existing = at.branches.find((b) => b.name === candidate);
596
+ if (existing === undefined && !at.onOrigin.has(candidate)) {
597
+ if (!(await deps.git.createBranch(candidate, replayed.sha))) {
598
+ return { kind: 'stacked', foreign, because: `the branch ${candidate} could not be created` };
599
+ }
600
+ name = candidate;
601
+ }
602
+ else if (existing !== undefined && (await deps.git.treeOf(existing.sha)) === replayed.tree && (await deps.git.isAncestor(ref, existing.sha))) {
603
+ name = candidate;
604
+ sha = existing.sha;
605
+ }
606
+ }
607
+ if (name === null)
608
+ return { kind: 'stacked', foreign, because: `every ${stem}${commit_ownership_1.SOLO_SUFFIX} name up to -20 is taken` };
609
+ // Name the rebuilt branch as this goal's in the lane claims, from the original branch's own record.
610
+ const source = [...input.claims].reverse().find((r) => r.branch === at.branch && r.goalId === input.goalId);
611
+ if (source !== undefined && deps.recordClaim !== undefined) {
612
+ try {
613
+ deps.recordClaim({
614
+ schema: source.schema,
615
+ kind: 'claims',
616
+ at: at.at,
617
+ branch: name,
618
+ runId: source.runId,
619
+ goalId: input.goalId,
620
+ goalVersion: source.goalVersion,
621
+ planRevision: source.planRevision,
622
+ servings: [],
623
+ ...(source.kind === 'claims' && source.workspace !== undefined ? { workspace: source.workspace } : {}),
624
+ });
625
+ }
626
+ catch {
627
+ /* bookkeeping; `ownerOfBranch` still reads a -solo branch through its original */
628
+ }
629
+ }
630
+ const without = foreign.map((f) => ({ goalId: f.goalId ?? null, branch: f.branch, commits: f.commits.length }));
631
+ return { kind: 'rebuilt', branch: name, sha, without, withoutText: withoutSentence(without) };
632
+ }
633
+ function plural(n, word) {
634
+ return `${n} ${word}${n === 1 ? '' : 's'}`;
635
+ }
636
+ function withoutSentence(without) {
637
+ const n = without.reduce((s, w) => s + w.commits, 0);
638
+ const byGoal = new Map();
639
+ for (const w of without) {
640
+ const key = w.goalId ?? `branch:${w.branch}`;
641
+ const g = byGoal.get(key) ?? { goalId: w.goalId, branches: [], commits: 0 };
642
+ g.branches.push(w.branch);
643
+ g.commits += w.commits;
644
+ byGoal.set(key, g);
645
+ }
646
+ const parts = [...byGoal.values()].map((g) => `${g.commits} of ${g.goalId ? `goal ${g.goalId}` : 'another goal'} (${g.branches.join(', ')})`);
647
+ return `${plural(n, 'commit')} from other goals: ${parts.join('; ')}`;
648
+ }
649
+ /** The plain sentence a ticket shows for other goals' work still in a pull request. */
650
+ function describeForeign(store, foreign, base) {
651
+ const groups = (0, commit_ownership_1.foreignByGoal)(foreign);
652
+ const prOf = (goalId, branches) => {
653
+ const e = goalId !== undefined ? store?.get(goalId) : store?.all().find((x) => branches.includes(x.branch));
654
+ if (!e || e.pullNumber === null || e.done === true)
655
+ return '';
656
+ return branches.includes(e.branch) || (e.rebuiltFrom && branches.includes(e.rebuiltFrom.branch)) ? `, PR #${e.pullNumber}` : '';
657
+ };
658
+ const parts = groups.map((g) => `${plural(g.commits, 'commit')} of ${g.goalId ? `goal ${g.goalId}` : 'another goal'} (${g.branches.join(', ')}${prOf(g.goalId, g.branches)})`);
659
+ if (parts.length === 1)
660
+ return `it also contains ${parts[0]}, which is not merged into ${base}`;
661
+ const n = groups.reduce((s, g) => s + g.commits, 0);
662
+ return `it also contains ${n} commits of other goals that are not merged into ${base}: ${parts.join('; ')}`;
663
+ }
664
+ function guardNote(foreignText, branch) {
665
+ return `not merged: ${foreignText}; merge or close that first, or rebuild this branch without it (looply deliver --rebuild ${branch})`;
666
+ }
667
+ /**
668
+ * Drop the foreign work that is merged after all, or carried on purpose:
669
+ * - a goal this pull request ABSORBS (a fix-up that replaced that goal's red pull request);
670
+ * - a goal whose own delivery is merged, from this branch or a rebuild of it — a person may
671
+ * have squashed it, so its commits are not in the base by sha, but its work is.
672
+ */
673
+ async function unmergedForeign(git, store, foreign, absorbs = []) {
674
+ const out = [];
675
+ for (const f of foreign) {
676
+ if (f.goalId !== undefined && absorbs.includes(f.goalId))
677
+ continue;
678
+ const e = f.goalId !== undefined ? store.get(f.goalId) : store.all().find((x) => x.branch === f.branch);
679
+ if (e && (e.state === 'merged' || e.state === 'deployed' || e.state === 'deploy-failed')) {
680
+ const top = f.commits[f.commits.length - 1];
681
+ const same = e.branch === f.branch || e.rebuiltFrom?.branch === f.branch || (top !== undefined && (await git.isAncestor(top, e.sha).catch(() => false)));
682
+ if (same)
683
+ continue;
684
+ }
685
+ out.push(f);
686
+ }
687
+ return out;
688
+ }
689
+ /**
690
+ * 🔴 THE MERGE GUARD: may this pull request's head be merged into the base without bringing
691
+ * another goal's unmerged work with it? Asked right before every merge — automatic or approved —
692
+ * and on every pass while the answer is no. Origin's base is fetched first; a head this machine
693
+ * cannot read is a NO (it is checked again next pass), never a guess.
694
+ */
695
+ async function mergeGuard(deps, e, head) {
696
+ const base = e.projectBase ?? e.base;
697
+ if (base === null)
698
+ return { ok: false, note: 'not merged: the base branch it would merge into is not known', foreign: [] };
699
+ const ref = await deps.git.freshBase(base);
700
+ const branches = await deps.git.runBranches().catch(() => []);
701
+ const onOrigin = await deps.git.remoteRunBranches().catch(() => new Set());
702
+ const owned = await (0, commit_ownership_1.commitOwnership)(deps.git, { tip: head, base: ref, goalId: e.goalId, claims: deps.claims?.() ?? [], branches, onOrigin });
703
+ if (owned === null) {
704
+ return {
705
+ ok: false,
706
+ note: `not merged: Looply could not read this pull request's commits (${head.slice(0, 12)}) on this machine to check whose work they are; it checks again on the next pass`,
707
+ foreign: [],
708
+ };
709
+ }
710
+ const left = await unmergedForeign(deps.git, deps.store, owned.foreign, e.absorbs ?? []);
711
+ if (left.length === 0)
712
+ return { ok: true };
713
+ return { ok: false, note: guardNote(describeForeign(deps.store, left, base), e.rebuiltFrom?.branch ?? e.branch), foreign: left };
714
+ }
434
715
  /* ─────────────────────────────────────────────── merge, then deploy ── */
435
716
  /** How long `merge` waits for checks before handing the pull request to a person. */
436
717
  exports.CHECKS_PATIENCE_MS = 24 * 60 * 60_000;
@@ -461,9 +742,13 @@ function decideChecks(required, checks) {
461
742
  const waiting = checks.filter((c) => c.state === 'pending').map((c) => c.name);
462
743
  return waiting.length > 0 ? { kind: 'pending', waiting } : { kind: 'green' };
463
744
  }
464
- /** States a pass may move on. `pr-open` and `checks-failed` move only on a person's approval. */
465
- const ADVANCING = ['waiting-checks', 'merged', 'pr-open', 'checks-failed'];
466
- const APPROVABLE = ['pr-open', 'waiting-checks', 'checks-failed'];
745
+ /**
746
+ * States a pass may move on. `pr-open` and `checks-failed` move only on a person's approval.
747
+ * `blocked` is re-examined on every pass: it unblocks by itself once the other goals' work it
748
+ * carries is merged into the base, and an approval given meanwhile is remembered, not acted on.
749
+ */
750
+ const ADVANCING = ['waiting-checks', 'merged', 'pr-open', 'checks-failed', 'blocked', 'deployed', 'deploy-failed'];
751
+ const APPROVABLE = ['pr-open', 'waiting-checks', 'checks-failed', 'blocked'];
467
752
  /**
468
753
  * One pass over every delivered pull request still waiting on Looply:
469
754
  *
@@ -495,31 +780,80 @@ async function advanceOne(deps, e) {
495
780
  if (!e.approvedAt && APPROVABLE.includes(e.state) && e.pullNumber !== null) {
496
781
  const approved = deps.shipRequested(e.goalId);
497
782
  if (approved !== null) {
498
- e = { ...e, approvedAt: approved, state: 'waiting-checks', note: 'approved to ship by a person; merging once the checks allow', updatedAt: at };
783
+ // 🔴 A BLOCKED ONE STAYS BLOCKED: the approval is kept, and acted on once it unblocks.
784
+ e =
785
+ e.state === 'blocked'
786
+ ? { ...e, approvedAt: approved, updatedAt: at }
787
+ : { ...e, approvedAt: approved, state: 'waiting-checks', note: 'approved to ship by a person; merging once the checks allow', updatedAt: at };
499
788
  await save(deps, e);
500
789
  }
501
790
  }
502
791
  const approved = !!e.approvedAt;
503
792
  const wantsMerge = approved || mode === 'merge' || mode === 'deploy';
504
793
  const wantsDeploy = approved || mode === 'deploy';
505
- if (e.state === 'pr-open' || e.state === 'checks-failed')
506
- return;
507
794
  if (e.state === 'waiting-checks' && !wantsMerge) {
508
795
  await save(deps, { ...e, mode, state: 'pr-open', note: `delivery is now "${mode}", so Looply will not merge it; a person merges`, updatedAt: at });
509
796
  return;
510
797
  }
511
- if (e.state === 'merged' && (!wantsDeploy || e.deploy))
512
- return;
513
- if (e.owner === null || e.repo === null || e.pullNumber === null)
798
+ const client = e.owner !== null && e.repo !== null ? await deps.github(e.owner, e.repo) : null;
799
+ const gh = client?.api ?? null;
800
+ // Merged: where did it get to? (Looply's own deploy steps once, then GitHub's word for 14 days.)
801
+ if (e.state === 'merged' || e.state === 'deployed' || e.state === 'deploy-failed') {
802
+ await followDeploys(deps, gh, e, wantsDeploy, mode);
514
803
  return;
515
- const client = await deps.github(e.owner, e.repo);
516
- if (client.api === null)
804
+ }
805
+ if (gh === null || e.pullNumber === null)
517
806
  return; // no credentials right now: try again next pass
518
- const api = client.api;
807
+ const api = gh;
808
+ const pullNumber = e.pullNumber;
809
+ /**
810
+ * A PERSON'S OWN MERGE IS SEEN TOO. A pull request Looply is not merging (`pr-open`,
811
+ * `checks-failed`) is still read every pass: merged on GitHub by a person, it becomes `merged`
812
+ * with its merge commit, and its deploys are followed like any other.
813
+ */
814
+ if (e.state === 'pr-open' || e.state === 'checks-failed') {
815
+ const pull = await api.getPull(pullNumber);
816
+ if (pull.merged) {
817
+ e = { ...e, state: 'merged', mergeSha: pull.mergeSha ?? e.mergeSha ?? null, note: 'merged by a person', updatedAt: at };
818
+ await save(deps, e);
819
+ await followDeploys(deps, api, e, wantsDeploy, mode);
820
+ }
821
+ else if (pull.state === 'closed') {
822
+ await save(deps, { ...e, done: true, note: 'the pull request was closed without merging; Looply leaves it', updatedAt: at });
823
+ }
824
+ return;
825
+ }
826
+ if (e.state === 'blocked') {
827
+ const pull = await api.getPull(pullNumber);
828
+ if (pull.merged) {
829
+ // A person merged it themselves: theirs to decide, and it is what happened.
830
+ e = { ...e, state: 'merged', mergeSha: pull.mergeSha ?? e.mergeSha ?? null, note: 'merged by a person', updatedAt: at };
831
+ await save(deps, e);
832
+ }
833
+ else if (pull.state === 'closed') {
834
+ await save(deps, { ...e, state: 'pr-open', done: true, note: 'the pull request was closed without merging; Looply leaves it', updatedAt: at });
835
+ return;
836
+ }
837
+ else {
838
+ const guard = await mergeGuard(deps, e, pull.headSha || e.sha);
839
+ if (!guard.ok) {
840
+ if (guard.note !== e.note)
841
+ await save(deps, { ...e, note: guard.note, updatedAt: at });
842
+ return;
843
+ }
844
+ const base = e.projectBase ?? pull.base;
845
+ if (!wantsMerge) {
846
+ await save(deps, { ...e, mode, state: 'pr-open', note: `the other goals' work it carried is merged into ${base} now; a person merges this one`, updatedAt: at });
847
+ return;
848
+ }
849
+ e = { ...e, state: 'waiting-checks', note: `the other goals' work it carried is merged into ${base} now; merging once the checks allow`, updatedAt: at };
850
+ await save(deps, e);
851
+ }
852
+ }
519
853
  if (e.state === 'waiting-checks') {
520
- const pull = await api.getPull(e.pullNumber);
854
+ const pull = await api.getPull(pullNumber);
521
855
  if (pull.merged) {
522
- e = { ...e, state: 'merged', note: 'merged by a person', updatedAt: at };
856
+ e = { ...e, state: 'merged', mergeSha: pull.mergeSha ?? e.mergeSha ?? null, note: 'merged by a person', updatedAt: at };
523
857
  await save(deps, e);
524
858
  }
525
859
  else if (pull.state === 'closed') {
@@ -540,6 +874,17 @@ async function advanceOne(deps, e) {
540
874
  }
541
875
  return;
542
876
  }
877
+ /**
878
+ * 🔴 NEVER ANOTHER GOAL'S UNMERGED WORK. Checked here — after any retarget, before the
879
+ * checks are even read — so neither green CI nor "Approve and ship" can carry another
880
+ * ticket's unreviewed commits into the base. `merge` below is pinned to the same head.
881
+ */
882
+ const guard = await mergeGuard(deps, e, pull.headSha || e.sha);
883
+ if (!guard.ok) {
884
+ deps.log(`deliver : ${e.goalId}: ${guard.note}`);
885
+ await save(deps, { ...e, state: 'blocked', note: guard.note, updatedAt: at });
886
+ return;
887
+ }
543
888
  const verdict = decideChecks(await api.requiredChecks(pull.base), await api.checksFor(pull.headSha));
544
889
  const age = now - Date.parse(e.approvedAt ?? e.openedAt);
545
890
  if (verdict.kind === 'red') {
@@ -569,51 +914,166 @@ async function advanceOne(deps, e) {
569
914
  await save(deps, e);
570
915
  }
571
916
  }
572
- if (e.state !== 'merged' || !wantsDeploy || e.deploy)
573
- return;
574
- await save(deps, await deployMerged(deps, e, mode));
917
+ if (e.state === 'merged')
918
+ await followDeploys(deps, api, e, wantsDeploy, mode);
575
919
  }
576
- /** Run the configured deploy step for a merged pull request, and say what happened. */
577
- async function deployMerged(deps, e, mode) {
578
- const at = new Date(deps.now()).toISOString();
579
- const base = e.projectBase ?? '';
920
+ /* ────────────────────────────────────────── where the merge got to ── */
921
+ /** How long a merged commit's deploys are followed for, so one done outside Looply is still seen. */
922
+ exports.FOLLOW_DEPLOYS_MS = 14 * 24 * 60 * 60_000;
923
+ /** The environments to deploy, in order; the single `deploy` (or, under `deploy`, a declared release step) when none are named. */
924
+ function plansFor(deps, mode) {
925
+ const named = deps.environments?.() ?? [];
926
+ if (named.length > 0)
927
+ return [...named];
580
928
  // Absent: under `deploy` with a declared release step, run it (the setting before deploy kinds existed).
581
- const cfg = deps.deploy() ?? (mode === 'deploy' && deps.release() !== null ? { kind: 'run' } : { kind: 'none' });
582
- const done = (ok, detail, state) => ({
583
- ...e,
584
- state,
585
- done: true,
586
- deploy: { kind: cfg.kind, ok, detail: detail.slice(0, 600) },
587
- note: ok ? null : detail.slice(0, 600),
588
- updatedAt: at,
589
- });
590
- if (cfg.kind === 'none')
591
- return done(true, 'merged; nothing is configured to deploy', 'merged');
592
- if (cfg.kind === 'merge-deploys')
593
- return done(true, `merged into ${base}, which deploys it${e.mergeSha ? ` (${e.mergeSha.slice(0, 12)})` : ''}`, 'deployed');
594
- if (cfg.kind === 'push') {
595
- if (!e.mergeSha)
596
- return done(false, 'merged, but GitHub did not say which commit the merge made, so nothing was pushed to deploy', 'deploy-failed');
597
- deps.log(`deliver : deploying ${e.mergeSha.slice(0, 12)} to ${cfg.remote} ${cfg.branch}`);
598
- const r = await deps.deployPush(cfg.remote, cfg.branch, e.mergeSha, base);
599
- return r.ok
600
- ? done(true, `pushed ${e.mergeSha.slice(0, 12)} to ${cfg.branch} on ${cfg.remote}`, 'deployed')
601
- : done(false, `pushing to ${cfg.remote} ${cfg.branch} failed: ${r.because}`, 'deploy-failed');
602
- }
603
- const command = deps.release();
604
- if (command === null)
605
- return done(false, 'merged; not deployed — no release step is declared (deliver.release in .looply/config.yaml)', 'deploy-failed');
606
- deps.log(`deliver : running the declared release step for ${e.goalId}`);
607
- const r = await deps.runRelease(command, {
608
- LOOPLY_GOAL_ID: e.goalId,
609
- LOOPLY_BRANCH: e.branch,
610
- LOOPLY_BASE_BRANCH: base,
611
- LOOPLY_MERGED_SHA: e.mergeSha ?? '',
612
- LOOPLY_PULL_REQUEST_URL: e.pullRequestUrl ?? '',
613
- });
614
- return r.code === 0
615
- ? done(true, 'the declared release step finished', 'deployed')
616
- : done(false, `the release step exited ${r.code}: ${r.tail.slice(-300)}`, 'deploy-failed');
929
+ const d = deps.deploy() ?? (mode === 'deploy' && deps.release() !== null ? { kind: 'run' } : null);
930
+ if (d === null || d.kind === 'none')
931
+ return [];
932
+ return [{ name: d.kind === 'push' ? d.remote : 'default', deploy: d, unnamed: true }];
933
+ }
934
+ /**
935
+ * 🔴 WHERE A MERGED COMMIT IS, TOLD TRUTHFULLY, PER ENVIRONMENT.
936
+ *
937
+ * The first time it is seen merged, every environment gets a row and Looply runs its OWN deploy
938
+ * steps, in order (only under `deploy` or "Approve and ship"; a failure stops the ones after it):
939
+ * - `push` → deployed when the git push exited 0 (a push to Heroku returns only after the release);
940
+ * - `run` → deployed when the project's declared release step exited 0;
941
+ * - `merge-deploys` → UNCONFIRMED: the merge should deploy it, but a merge is not a deploy;
942
+ * - `none` → waiting: nothing here deploys it.
943
+ * Then, every pass, GitHub's Deployments for the merge commit are read, and an environment of the
944
+ * same name (any, for the one unnamed environment) that GitHub says is `success` is `deployed`,
945
+ * `failure`/`error` is `failed`, still running is `deploying` — which is how a Heroku or Netlify
946
+ * deploy, or one a person ran by hand, is seen. Nothing else ever makes a row `deployed`.
947
+ *
948
+ * The delivery is `done` once every environment is deployed or failed, or 14 days after the merge;
949
+ * a pass that changes nothing records nothing. No environment configured: done at the merge, and
950
+ * the report says nothing is configured to deploy.
951
+ */
952
+ async function followDeploys(deps, api, e, wantsDeploy, mode) {
953
+ const nowMs = deps.now();
954
+ const at = new Date(nowMs).toISOString();
955
+ let x = { ...e, mergedAt: e.mergedAt ?? at };
956
+ const plans = plansFor(deps, mode);
957
+ if (x.environments === undefined) {
958
+ if (plans.length === 0) {
959
+ await save(deps, { ...x, done: true, deploy: { kind: 'none', ok: true, detail: 'merged; nothing is configured to deploy' }, updatedAt: at });
960
+ return;
961
+ }
962
+ x = await deployAll(deps, x, plans, wantsDeploy, at);
963
+ }
964
+ const sha = x.mergeSha ?? null;
965
+ if (api?.deploymentsFor && sha !== null) {
966
+ const found = await api.deploymentsFor(sha).catch(() => null);
967
+ if (found !== null)
968
+ x = { ...x, environments: (x.environments ?? []).map((row) => fromGitHub(row, plans, found)) };
969
+ }
970
+ const rows = x.environments ?? [];
971
+ const settled = rows.every((r) => r.state === 'deployed' || r.state === 'failed');
972
+ const expired = nowMs - Date.parse(x.mergedAt) > exports.FOLLOW_DEPLOYS_MS;
973
+ const failed = rows.find((r) => r.state === 'failed');
974
+ const state = failed ? 'deploy-failed' : rows.length > 0 && settled ? 'deployed' : 'merged';
975
+ const open = rows.filter((r) => r.state !== 'deployed');
976
+ const note = open.length === 0
977
+ ? null
978
+ : `${expired && !settled ? 'after 14 days, ' : ''}${open.map((r) => `${r.name}: ${r.detail ?? r.state}`).join('; ')}`.slice(0, 600);
979
+ x = { ...x, state, note, ...(settled || expired ? { done: true } : {}) };
980
+ const same = JSON.stringify({ ...x, updatedAt: '' }) === JSON.stringify({ ...e, updatedAt: '' });
981
+ if (!same)
982
+ await save(deps, { ...x, updatedAt: at });
983
+ }
984
+ /** Run Looply's own deploy step for each environment, in order, and make every row. */
985
+ async function deployAll(deps, e, plans, wantsDeploy, at) {
986
+ const base = e.projectBase ?? '';
987
+ const sha = e.mergeSha ?? null;
988
+ const rows = [];
989
+ let stoppedBy = null;
990
+ for (const p of plans) {
991
+ const row = (state, detail) => ({
992
+ name: p.name,
993
+ test: p.test === true,
994
+ state,
995
+ sha,
996
+ at: state === 'waiting' ? null : at,
997
+ url: p.url ?? null,
998
+ detail: detail.slice(0, 600),
999
+ });
1000
+ const d = p.deploy;
1001
+ if (stoppedBy !== null) {
1002
+ rows.push(row('waiting', `not deployed: the deploy to ${stoppedBy} failed first`));
1003
+ continue;
1004
+ }
1005
+ if (d.kind === 'none') {
1006
+ rows.push(row('waiting', `no deploy step is configured for ${p.name}`));
1007
+ continue;
1008
+ }
1009
+ if (d.kind === 'merge-deploys') {
1010
+ rows.push(row('unconfirmed', `merged into ${base}, which should deploy it; not confirmed yet`));
1011
+ continue;
1012
+ }
1013
+ if (!wantsDeploy) {
1014
+ rows.push(row('waiting', `Looply deploys only under "deploy" or after "Approve and ship"`));
1015
+ continue;
1016
+ }
1017
+ if (d.kind === 'push') {
1018
+ if (sha === null) {
1019
+ rows.push(row('failed', 'merged, but GitHub did not say which commit the merge made, so nothing was pushed to deploy'));
1020
+ }
1021
+ else {
1022
+ deps.log(`deliver : deploying ${sha.slice(0, 12)} to ${d.remote} ${d.branch} (${p.name})`);
1023
+ const r = await deps.deployPush(d.remote, d.branch, sha, base);
1024
+ rows.push(r.ok ? row('deployed', `pushed ${sha.slice(0, 12)} to ${d.branch} on ${d.remote}`) : row('failed', `pushing to ${d.remote} ${d.branch} failed: ${r.because}`));
1025
+ }
1026
+ }
1027
+ else {
1028
+ const command = deps.release();
1029
+ if (command === null) {
1030
+ rows.push(row('failed', 'merged; not deployed — no release step is declared (deliver.release in .looply/config.yaml)'));
1031
+ }
1032
+ else {
1033
+ deps.log(`deliver : running the declared release step for ${e.goalId} (${p.name})`);
1034
+ const r = await deps.runRelease(command, {
1035
+ LOOPLY_GOAL_ID: e.goalId,
1036
+ LOOPLY_BRANCH: e.branch,
1037
+ LOOPLY_BASE_BRANCH: base,
1038
+ LOOPLY_MERGED_SHA: sha ?? '',
1039
+ LOOPLY_PULL_REQUEST_URL: e.pullRequestUrl ?? '',
1040
+ LOOPLY_ENVIRONMENT: p.name,
1041
+ });
1042
+ rows.push(r.code === 0 ? row('deployed', 'the declared release step finished') : row('failed', `the release step exited ${r.code}: ${r.tail.slice(-300)}`));
1043
+ }
1044
+ }
1045
+ if (rows[rows.length - 1].state === 'failed')
1046
+ stoppedBy = p.name;
1047
+ }
1048
+ // The one-line summary older readers know: the failed step, else the first environment's.
1049
+ const lead = rows.find((r) => r.state === 'failed') ?? rows[0];
1050
+ const leadPlan = plans[rows.indexOf(lead)];
1051
+ return { ...e, environments: rows, deploy: { kind: leadPlan.deploy.kind, ok: lead.state !== 'failed', detail: lead.detail ?? '' } };
1052
+ }
1053
+ /** GitHub's word on one environment, when it has one and the row is not already settled. */
1054
+ function fromGitHub(row, plans, found) {
1055
+ if (row.state === 'deployed' || row.state === 'failed')
1056
+ return row;
1057
+ const plan = plans.find((p) => p.name === row.name);
1058
+ const match = found.find((d) => plan?.unnamed === true || d.environment.toLowerCase() === row.name.toLowerCase());
1059
+ if (match === undefined)
1060
+ return row;
1061
+ const state = match.state === 'success' || match.state === 'inactive'
1062
+ ? 'deployed'
1063
+ : match.state === 'failure' || match.state === 'error'
1064
+ ? 'failed'
1065
+ : ['in_progress', 'queued', 'pending'].includes(match.state)
1066
+ ? 'deploying'
1067
+ : null;
1068
+ if (state === null || (state === row.state && (match.url ?? row.url) === row.url))
1069
+ return row;
1070
+ const said = match.description ? `: ${match.description}` : '';
1071
+ const detail = state === 'deployed'
1072
+ ? `GitHub reports it deployed to ${match.environment}${match.state === 'inactive' ? ' (since replaced by a newer deploy)' : ''}${said}`
1073
+ : state === 'failed'
1074
+ ? `GitHub reports the deploy to ${match.environment} failed${said}`
1075
+ : `GitHub reports a deploy to ${match.environment} in progress${said}`;
1076
+ return { ...row, state, at: Number.isNaN(Date.parse(match.at)) ? row.at : new Date(match.at).toISOString(), url: match.url ?? row.url, detail: detail.slice(0, 600) };
617
1077
  }
618
1078
  /**
619
1079
  * Run the project's declared release step in the project folder, hidden, bounded