atris 3.71.2 → 3.72.0

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 (52) hide show
  1. package/atris/skills/campaigns/SKILL.md +1 -1
  2. package/bin/atris.js +8 -2
  3. package/commands/autoland.js +102 -2
  4. package/commands/campaign.js +263 -11
  5. package/commands/clean.js +60 -4
  6. package/commands/gm.js +1273 -7
  7. package/commands/improve.js +3 -2
  8. package/commands/member.js +28 -3
  9. package/commands/mission.js +82 -27
  10. package/commands/pulse.js +89 -5
  11. package/commands/sync.js +2 -2
  12. package/commands/task.js +49 -13
  13. package/commands/worktree.js +24 -2
  14. package/commands/zen.js +183 -0
  15. package/lib/auto-accept-certified.js +9 -4
  16. package/lib/campaign-cadence.js +53 -14
  17. package/lib/campaign-core.js +98 -17
  18. package/lib/campaign-keep.js +91 -0
  19. package/lib/campaign-lanes.js +11 -6
  20. package/lib/campaign-loop.js +173 -32
  21. package/lib/campaign-reading.js +14 -1
  22. package/lib/campaign-rsi.js +697 -0
  23. package/lib/campaign-run-lock.js +9 -1
  24. package/lib/campaign-schedule.js +124 -0
  25. package/lib/campaign-schema.js +3 -1
  26. package/lib/campaign-store.js +34 -2
  27. package/lib/campaign-trace.js +77 -29
  28. package/lib/engine-resource.js +5 -1
  29. package/lib/gm-bet.js +2219 -0
  30. package/lib/gm-phone-page.js +1102 -0
  31. package/lib/gm-phone.js +682 -0
  32. package/lib/gm-season.js +1162 -0
  33. package/lib/gm-target.js +292 -0
  34. package/lib/job-code-verify.js +169 -0
  35. package/lib/known-commands.js +1 -1
  36. package/lib/member-autoland.js +105 -14
  37. package/lib/member-progress.js +2 -2
  38. package/lib/member-report.js +11 -1
  39. package/lib/member-self-next-job.js +6 -1
  40. package/lib/member-up.js +17 -8
  41. package/lib/merged-review-close.js +206 -0
  42. package/lib/mission-code-provenance.js +118 -0
  43. package/lib/mission-protected-lane.js +52 -4
  44. package/lib/pulse.js +2 -1
  45. package/lib/qr.js +338 -0
  46. package/lib/rsi-record.js +88 -0
  47. package/lib/task-receipt.js +24 -6
  48. package/lib/yes-lock.js +530 -0
  49. package/lib/zen-apply.js +511 -0
  50. package/lib/zen-workspace.js +121 -0
  51. package/lib/zen.js +395 -0
  52. package/package.json +1 -1
@@ -561,6 +561,9 @@ function autolandMemberRun({ mission, cwd, summary = '', workerEngine = '', load
561
561
  }
562
562
 
563
563
  const MAX_REQUEUES = 3;
564
+ // A merge nobody closed within a day may belong to an earlier week. Closing it
565
+ // later would count old work as a new landing, so a person decides.
566
+ const STALE_MERGE_MS = 24 * 60 * 60 * 1000;
564
567
 
565
568
  // One queued branch: leave it, forget it, or catch it up and queue it again.
566
569
  // Only the exact commit autoland queued (or its own catch-up) is re-queued; a
@@ -592,13 +595,13 @@ function inMergeQueue(number, cwd) {
592
595
  return out.ok && out.stdout === 'true';
593
596
  }
594
597
 
595
- function requeueOne(entry, cwd, maxRequeues) {
598
+ function requeueOne(entry, cwd, maxRequeues, now = Date.now(), dryRun = false) {
596
599
  const { branch, base, number } = entry;
597
600
  const label = `pull request #${number} (${branch})`;
598
601
  const keep = (state, line) => ({ branch, number, state, keep: true, line: `${label}: ${line}` });
599
602
  const forget = (state, line) => ({ branch, number, state, keep: false, line: `${label}: ${line}` });
600
603
  const viewed = run('gh', ['pr', 'view', String(number), '--json',
601
- 'number,state,headRefName,baseRefName,headRefOid,isCrossRepository,autoMergeRequest,mergeable'], cwd);
604
+ 'number,state,headRefName,baseRefName,headRefOid,isCrossRepository,autoMergeRequest,mergeable,mergedAt'], cwd);
602
605
  let pr = null;
603
606
  try {
604
607
  pr = viewed.ok ? JSON.parse(viewed.stdout) : null;
@@ -610,6 +613,12 @@ function requeueOne(entry, cwd, maxRequeues) {
610
613
  // that carried someone else's push leaves the job for a person.
611
614
  if (pr.state === 'MERGED') {
612
615
  if (pr.headRefOid !== entry.pushed) return forget('merged', 'merged with commits someone else pushed; its job is left for you');
616
+ // An old merge still closes its job, recorded as merged earlier so the
617
+ // weekly board does not count last week's work as this week's landing.
618
+ const mergedMs = Date.parse(pr.mergedAt || '');
619
+ if (Number.isFinite(mergedMs) && now - mergedMs > STALE_MERGE_MS) {
620
+ return { ...forget('merged', 'merged over a day ago; closed without counting it as a new landing'), own: true, stale: true };
621
+ }
613
622
  return { ...forget('merged', 'merged'), own: true };
614
623
  }
615
624
  if (pr.state !== 'OPEN') return forget('closed', 'closed by someone; left alone');
@@ -625,6 +634,7 @@ function requeueOne(entry, cwd, maxRequeues) {
625
634
  // failure fails twice and waits for a fix.
626
635
  const failed = pr.headRefOid === entry.pushed ? failedCheckRuns(number, cwd) : [];
627
636
  if (failed.length && entry.reran !== entry.pushed) {
637
+ if (dryRun) return keep('would-rerun', 'a check failed; would run the failed checks again once');
628
638
  const reran = failed.filter((id) => run('gh', ['run', 'rerun', id, '--failed'], cwd).ok);
629
639
  if (reran.length) return { ...keep('rerun', 'a check failed; ran the failed checks again once'), reran: entry.pushed };
630
640
  return keep('queued', 'a check failed and could not be run again; will look again');
@@ -636,6 +646,12 @@ function requeueOne(entry, cwd, maxRequeues) {
636
646
  if ((Number(entry.requeues) || 0) >= maxRequeues) {
637
647
  return forget('gave-up', `dropped again after ${maxRequeues} catch-ups; left for you`);
638
648
  }
649
+ // A dry run never fetches, pushes, or switches auto-merge back on. GitHub's
650
+ // own clash flag stands in for the catch-up it would try.
651
+ if (dryRun) {
652
+ if (pr.mergeable === 'CONFLICTING') return { ...forget('clash', 'clashes with the main branch; left for you'), why: 'it clashes with the main branch' };
653
+ return keep('would-requeue', 'would catch it up with the main branch and queue it again');
654
+ }
639
655
  const fetched = run('git', ['fetch', '-q', 'origin', `+refs/heads/${branch}:refs/remotes/origin/${branch}`], cwd);
640
656
  const remote = run('git', ['rev-parse', `refs/remotes/origin/${branch}`], cwd);
641
657
  if (!fetched.ok || !remote.ok || remote.stdout !== entry.pushed) {
@@ -664,7 +680,7 @@ function heldAtClose(row) {
664
680
  return Boolean(require('./member-task-pick').heldForPerson(row));
665
681
  }
666
682
 
667
- function defaultCloseTask(id, { number }) {
683
+ function defaultCloseTask(id, { number, stale = false }) {
668
684
  const taskDb = require('./task-db');
669
685
  const db = taskDb.open();
670
686
  // A hold put on the job while its change waited to merge still wins: that
@@ -673,7 +689,9 @@ function defaultCloseTask(id, { number }) {
673
689
  if (heldAtClose(row)) return { updated: false };
674
690
  const out = taskDb.doneTask(db, {
675
691
  id, actor: 'autoland', allowReview: true, autoAccepted: true,
676
- action: 'merged', proof: `its own pull request #${number} merged`,
692
+ action: stale ? 'merged_earlier' : 'merged',
693
+ proof: stale ? `its own pull request #${number} merged over a day ago; closed without counting it as a new landing`
694
+ : `its own pull request #${number} merged`,
677
695
  });
678
696
  return { ...out, replaced: row.metadata && row.metadata.replaced_pull_requests };
679
697
  }
@@ -681,14 +699,14 @@ function defaultCloseTask(id, { number }) {
681
699
  // The member's own change merged: close every job it was queued for. A job
682
700
  // already closed, or one that cannot be read, is left as it is. Each closed
683
701
  // job hands back the clashing pull requests its redo replaced.
684
- function closeMergedJobs(entry, closeTask) {
702
+ function closeMergedJobs(entry, closeTask, { stale = false } = {}) {
685
703
  const ids = Array.isArray(entry.task_ids) ? entry.task_ids.filter((id) => typeof id === 'string' && id.trim()) : [];
686
704
  const closed = [];
687
705
  const replaced = [];
688
706
  for (const id of ids) {
689
707
  let out = null;
690
708
  try {
691
- out = closeTask(id, { number: entry.number, branch: entry.branch });
709
+ out = closeTask(id, { number: entry.number, branch: entry.branch, stale });
692
710
  } catch {
693
711
  out = null;
694
712
  }
@@ -746,6 +764,21 @@ function defaultSendBack(id, { number, pushed, why }) {
746
764
  });
747
765
  }
748
766
 
767
+ // The dry run's stand-ins: the same checks the real close and send-back make,
768
+ // read only.
769
+ function wouldClose(id) {
770
+ const taskDb = require('./task-db');
771
+ const row = taskDb.getTask(taskDb.open(), id);
772
+ return { updated: Boolean(row) && ['open', 'claimed', 'review'].includes(row.status) && !heldAtClose(row) };
773
+ }
774
+
775
+ function wouldSendBack(id) {
776
+ const taskDb = require('./task-db');
777
+ const row = taskDb.getTask(taskDb.open(), id);
778
+ const restarts = Number(row && row.metadata && row.metadata.clash_restarts) || 0;
779
+ return { reopened: Boolean(row) && row.status === 'review' && !heldAtClose(row) && restarts < MAX_CLASH_RESTARTS };
780
+ }
781
+
749
782
  function sendBackClashedJobs(entry, why, sendBack) {
750
783
  const ids = Array.isArray(entry.task_ids) ? entry.task_ids.filter((id) => typeof id === 'string' && id.trim()) : [];
751
784
  const sent = [];
@@ -761,26 +794,45 @@ function sendBackClashedJobs(entry, why, sendBack) {
761
794
  return sent;
762
795
  }
763
796
 
797
+ // A mission can queue its change while a sweep is reading GitHub. Apply only
798
+ // the sweep's own changes to the list as it stands now: an entry someone
799
+ // added or rewrote meanwhile is kept as they left it.
800
+ function mergeQueueChanges(latest, snapshot, swept) {
801
+ const out = { ...latest };
802
+ for (const [key, before] of Object.entries(snapshot)) {
803
+ if (JSON.stringify(latest[key]) !== JSON.stringify(before)) continue;
804
+ if (key in swept) out[key] = swept[key];
805
+ else delete out[key];
806
+ }
807
+ return out;
808
+ }
809
+
764
810
  // Look at every branch autoland queued and re-queue the ones GitHub dropped.
765
811
  // A branch whose own change merged closes the jobs it was queued for; one that
766
- // clashes sends its jobs back on the list.
767
- function requeueDroppedAutolands({ cwd, maxRequeues = MAX_REQUEUES, closeTask = defaultCloseTask, sendBack = defaultSendBack } = {}) {
812
+ // clashes sends its jobs back on the list. dryRun reads GitHub and the job
813
+ // list, says what it would do, and changes nothing anywhere.
814
+ function requeueDroppedAutolands({ cwd, maxRequeues = MAX_REQUEUES, closeTask = defaultCloseTask, sendBack = defaultSendBack, now = Date.now(), dryRun = false } = {}) {
768
815
  const file = queueFile(cwd);
769
816
  if (!file) return { checked: 0, results: [], line: 'not inside a git checkout' };
770
817
  if (!policyOn(cwd)) return { checked: 0, results: [], line: 'self-landing is not switched on in the main checkout' };
818
+ if (dryRun) {
819
+ closeTask = wouldClose;
820
+ sendBack = wouldSendBack;
821
+ }
771
822
  const entries = readQueue(file);
823
+ const snapshot = JSON.parse(JSON.stringify(entries));
772
824
  const results = [];
773
825
  for (const [key, entry] of Object.entries(entries)) {
774
826
  const valid = entry && typeof entry.branch === 'string' && entry.branch === key && typeof entry.base === 'string'
775
827
  && Number.isInteger(entry.number) && entry.number > 0 && /^[0-9a-f]{40,64}$/.test(String(entry.pushed || ''));
776
- const result = valid ? requeueOne(entry, cwd, maxRequeues)
828
+ const result = valid ? requeueOne(entry, cwd, maxRequeues, now, dryRun)
777
829
  : { branch: key, state: 'unreadable', keep: false, line: `${key}: queue entry could not be read; dropped from the list` };
778
830
  if (valid && result.own) {
779
- const merged = closeMergedJobs(entry, closeTask);
831
+ const merged = closeMergedJobs(entry, closeTask, { stale: result.stale === true });
780
832
  result.closed = merged.closed;
781
833
  const n = result.closed.length;
782
- if (n) result.line += `; closed ${n === 1 ? 'its job' : `its ${n} jobs`}`;
783
- result.replaced = closeReplacedPullRequests(merged.replaced, entry.number, cwd);
834
+ if (n) result.line += `; ${dryRun ? 'would close' : 'closed'} ${n === 1 ? 'its job' : `its ${n} jobs`}`;
835
+ result.replaced = dryRun ? [] : closeReplacedPullRequests(merged.replaced, entry.number, cwd);
784
836
  if (result.replaced.length) {
785
837
  result.line += `; closed the clashing ${result.replaced.length === 1 ? 'change' : 'changes'} it replaced (${result.replaced.map((r) => `#${r}`).join(', ')})`;
786
838
  }
@@ -788,7 +840,8 @@ function requeueDroppedAutolands({ cwd, maxRequeues = MAX_REQUEUES, closeTask =
788
840
  if (valid && result.state === 'clash') {
789
841
  result.sent_back = sendBackClashedJobs(entry, result.why, sendBack);
790
842
  const n = result.sent_back.length;
791
- if (n) result.line = result.line.replace(/; left for you$/, `; ${n === 1 ? 'its job went' : `its ${n} jobs went`} back on the list`);
843
+ const went = dryRun ? 'would go' : 'went';
844
+ if (n) result.line = result.line.replace(/; left for you$/, `; ${n === 1 ? `its job ${went}` : `its ${n} jobs ${went}`} back on the list`);
792
845
  }
793
846
  results.push(result);
794
847
  if (!result.keep) delete entries[key];
@@ -798,7 +851,7 @@ function requeueDroppedAutolands({ cwd, maxRequeues = MAX_REQUEUES, closeTask =
798
851
  entries[key] = { ...entry, reran: result.reran, reran_at: new Date().toISOString() };
799
852
  }
800
853
  }
801
- if (results.length) writeQueue(file, entries);
854
+ if (results.length && !dryRun) writeQueue(file, mergeQueueChanges(readQueue(file), snapshot, entries));
802
855
  const requeued = results.filter((r) => r.state === 'requeued').length;
803
856
  const rerun = results.filter((r) => r.state === 'rerun').length;
804
857
  const closed = results.reduce((sum, r) => sum + (r.closed ? r.closed.length : 0), 0);
@@ -810,8 +863,46 @@ function requeueDroppedAutolands({ cwd, maxRequeues = MAX_REQUEUES, closeTask =
810
863
  return { checked: results.length, requeued, rerun, closed, results, line };
811
864
  }
812
865
 
866
+ // The team loop is not the only thing running. On 2026-10-15 it swept for the
867
+ // last time at 07:03, two member changes merged at 07:05, and their jobs
868
+ // waited a week while mission runs kept going. A finished mission run looks at
869
+ // the merge line too, but only while the loop is down: a live loop does it
870
+ // along with its code refresh and two-hour warning, which a second caller
871
+ // would throw off. It takes the loop's lock and its own five-minute timer, so
872
+ // a loop starting mid-pass never overlaps it. Nothing queued, or self-landing
873
+ // off: GitHub is not called.
874
+ function teamLoopAlive(cwd) {
875
+ const root = mainCheckoutRoot(cwd);
876
+ if (!root) return false;
877
+ let pid = null;
878
+ try {
879
+ pid = JSON.parse(fs.readFileSync(path.join(root, '.atris', 'state', 'member-up.json'), 'utf8')).pid;
880
+ } catch {
881
+ return false;
882
+ }
883
+ if (!Number.isInteger(pid) || pid <= 0) return false;
884
+ try {
885
+ process.kill(pid, 0);
886
+ return true;
887
+ } catch (error) {
888
+ return error.code === 'EPERM';
889
+ }
890
+ }
891
+
892
+ function sweepAfterMissionRun({ cwd, now = Date.now(), requeue = requeueDroppedAutolands } = {}) {
893
+ if (!policyOn(cwd)) return null;
894
+ const file = queueFile(cwd);
895
+ if (!file || !Object.keys(readQueue(file)).length) return null;
896
+ if (teamLoopAlive(cwd)) return null;
897
+ return require('./member-up').withMaintenanceLock(
898
+ { cwd, now, stateFile: 'mission-merge-pass.json' },
899
+ () => requeue({ cwd, now }),
900
+ );
901
+ }
902
+
813
903
  module.exports = {
814
904
  autolandMemberRun,
905
+ sweepAfterMissionRun,
815
906
  landingDestination,
816
907
  requeueDroppedAutolands,
817
908
  codeStatus,
@@ -4,7 +4,7 @@
4
4
  // stderr and appended to <dir>/<member>.log so another shell can tail it.
5
5
 
6
6
  const fs = require('fs');
7
- const os = require('os');
7
+ const { atrisHome } = require('./state-home');
8
8
  const path = require('path');
9
9
 
10
10
  const MAX_PROGRESS_BYTES = 256 * 1024;
@@ -135,7 +135,7 @@ function formatCodexEvent(event, cwd) {
135
135
  }
136
136
 
137
137
  function progressDir() {
138
- return process.env.ATRIS_PROGRESS_DIR || path.join(os.homedir(), '.atris', 'progress');
138
+ return process.env.ATRIS_PROGRESS_DIR || path.join(atrisHome(), 'progress');
139
139
  }
140
140
 
141
141
  function safeMember(member) {
@@ -251,14 +251,24 @@ function sameTitle(a, b) {
251
251
  function summarize(items) {
252
252
  const groups = { merged: [], finished: [], turned_down: [], waiting: [] };
253
253
  const sorted = [...items].sort((a, b) => b.at - a.at);
254
+ const indexed = sorted.every((item) => !item.title || typeof item.title === 'string');
255
+ const titles = new Set();
256
+ const links = new Set();
254
257
  for (const item of sorted) {
255
258
  if (!item.title) continue;
256
259
  // Newest word on a piece of work wins, and merged beats finished. A pull
257
260
  // request a diary already logged shares its link, so it counts once.
261
+ const title = indexed ? item.title.trim().toLowerCase() : null;
258
262
  const seenBefore = (seen) => sameTitle(seen.title, item.title) || (item.link && seen.link === item.link);
259
- if (Object.values(groups).some((list) => list.some(seenBefore))) continue;
263
+ if (indexed
264
+ ? titles.has(title) || (item.link && links.has(item.link))
265
+ : Object.values(groups).some((list) => list.some(seenBefore))) continue;
260
266
  const { member, at, group, ...rest } = item;
261
267
  groups[group].push(rest);
268
+ if (indexed) {
269
+ titles.add(title);
270
+ if (item.link) links.add(item.link);
271
+ }
262
272
  }
263
273
  return groups;
264
274
  }
@@ -5,7 +5,7 @@
5
5
  // job is a normal open task: the North Star is the goal, and the mission's
6
6
  // `## Check` section names the command that proves a run. A mission with no
7
7
  // runnable check writes nothing, so the member stays idle as before. At most
8
- // one self-written job is open per member, and at most MAX_PER_DAY are
8
+ // one self-written job is open, claimed, or in review per member, and at most MAX_PER_DAY are
9
9
  // written per member per local day, so an idle team cannot spin forever.
10
10
 
11
11
  const fs = require('fs');
@@ -75,6 +75,11 @@ function writeSelfNextJob(taskDb, db, { root, workspaceRoot, member, job, now =
75
75
  if (mine.some((row) => row.status === 'open' || row.status === 'claimed')) {
76
76
  return { skip: 'its last self-written job is still open' };
77
77
  }
78
+ // A job waiting for review still holds the seat. Freeing it at review let
79
+ // atris-cli pile up 15 "next step toward" jobs in review at once.
80
+ if (mine.some((row) => row.status === 'review')) {
81
+ return { skip: 'its last self-written job is still waiting for review' };
82
+ }
78
83
  const day = localDay(now);
79
84
  const today = mine.filter((row) => String(row.source_key).startsWith(`${prefix}${day}:`)).length;
80
85
  if (today >= MAX_PER_DAY) return { skip: `already wrote ${MAX_PER_DAY} jobs for itself today` };
package/lib/member-up.js CHANGED
@@ -69,15 +69,17 @@ function refreshMemberCode(root) {
69
69
  : { state: 'refresh-failed', line: 'could not refresh the members\' code copy; will try again' };
70
70
  }
71
71
 
72
- // Shared by all windows of a project, including linked work folders. The
73
- // completed-pass timestamp survives release, so a second window waits too.
74
- function memberMaintenance({ cwd, codeRoot, now = Date.now(), requeueDropped, waitingTasks = () => [] }) {
72
+ // One lock for every pass that reads GitHub's merge line, shared by all windows
73
+ // of a project, including linked work folders. Each caller keeps its own
74
+ // completed-pass file, so its timer survives release and a second window
75
+ // waits too. Busy lock or no git: nothing runs.
76
+ function withMaintenanceLock({ cwd, now = Date.now(), stateFile }, fn) {
75
77
  const common = maintenanceGit(cwd, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
76
78
  if (!common.ok) return { results: [] };
77
79
  const dir = path.join(path.dirname(common.text), '.atris', 'state');
78
80
  fs.mkdirSync(dir, { recursive: true });
79
81
  const lock = path.join(dir, 'member-up-maintenance.lock');
80
- const file = path.join(dir, 'member-up-maintenance.json');
82
+ const file = path.join(dir, stateFile);
81
83
  let fd;
82
84
  try {
83
85
  fd = fs.openSync(lock, 'wx');
@@ -110,6 +112,15 @@ function memberMaintenance({ cwd, codeRoot, now = Date.now(), requeueDropped, wa
110
112
  };
111
113
  state.checked_at = now;
112
114
  save();
115
+ return fn(state, save);
116
+ } finally {
117
+ fs.closeSync(fd);
118
+ fs.unlinkSync(lock);
119
+ }
120
+ }
121
+
122
+ function memberMaintenance({ cwd, codeRoot, now = Date.now(), requeueDropped, waitingTasks = () => [] }) {
123
+ return withMaintenanceLock({ cwd, now, stateFile: 'member-up-maintenance.json' }, (state, save) => {
113
124
  const out = requeueDropped();
114
125
  const results = [...(out?.results || [])];
115
126
  const merged = results.some((r) => r.state === 'merged');
@@ -128,10 +139,7 @@ function memberMaintenance({ cwd, codeRoot, now = Date.now(), requeueDropped, wa
128
139
  state.refresh_failed = refresh?.state === 'refresh-failed';
129
140
  save();
130
141
  return { ...out, results };
131
- } finally {
132
- fs.closeSync(fd);
133
- fs.unlinkSync(lock);
134
- }
142
+ });
135
143
  }
136
144
 
137
145
  function dayOf(ms) {
@@ -514,6 +522,7 @@ module.exports = {
514
522
  idleWaitMs,
515
523
  memberUpResult,
516
524
  memberMaintenance,
525
+ withMaintenanceLock,
517
526
  parseMemberUpArgs,
518
527
  runMemberUp,
519
528
  };
@@ -0,0 +1,206 @@
1
+ 'use strict';
2
+
3
+ // Jobs whose change is already on the main branch close themselves, every
4
+ // hour, with no person and no team loop.
5
+ //
6
+ // On 2026-10-22 four jobs in this repo sat in review for a week after their
7
+ // change merged. Two were still on the autoland list (#1318, #1319); two had
8
+ // left it, so only the code itself could say they landed. This pass reads
9
+ // git, never GitHub, and closes a review job only on one of two exact facts:
10
+ //
11
+ // One. The commit the builder recorded for the job (work_commit, different
12
+ // from the commit it started on) is on the main branch, and it came in from
13
+ // a side branch. A commit on the main line itself proves nothing: a builder
14
+ // working in the main folder records whatever the main branch was at.
15
+ //
16
+ // Two. The job has no commit of its own, and every test file its check names
17
+ // was first added to the main branch after the job was written, did not exist
18
+ // where the job started, is still there, and no other review job names it.
19
+ //
20
+ // Either way, work a later commit reverted stays open, and so does a job
21
+ // whose row changed after the scan read it.
22
+ //
23
+ // A merge more than a day old still closes the job, but records it as
24
+ // merged earlier, so last week's work never counts as this week's landing.
25
+
26
+ const { spawnSync } = require('child_process');
27
+
28
+ const DAY_MS = 24 * 60 * 60 * 1000;
29
+ const SHA = /^[0-9a-f]{40,64}$/;
30
+ const TEST_FILE = /^(?!\/)(?!.*\.\.)[\w.@+-]+(?:\/[\w.@+-]+)+\.(?:c|m)?[jt]s$|^(?!\/)(?!.*\.\.)[\w.@+-]+(?:\/[\w.@+-]+)+\.py$/;
31
+
32
+ function git(args, cwd) {
33
+ const res = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 60000, maxBuffer: 64 * 1024 * 1024 });
34
+ return { ok: res.status === 0, status: res.status, out: String(res.stdout || '').trim() };
35
+ }
36
+
37
+ // The branch merged work lands on: origin's default first, then the usual
38
+ // names, then a local branch for a project with no remote.
39
+ function mainBranchRef(root) {
40
+ const head = git(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], root);
41
+ const candidates = [head.ok ? head.out.replace(/^refs\/remotes\//, '') : '', 'origin/master', 'origin/main', 'master', 'main'];
42
+ for (const ref of candidates.filter(Boolean)) {
43
+ if (git(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`], root).ok) return ref;
44
+ }
45
+ return null;
46
+ }
47
+
48
+ function createdMs(value) {
49
+ if (typeof value === 'number') return value;
50
+ const asNumber = Number(value);
51
+ if (Number.isFinite(asNumber) && asNumber > 0) return asNumber;
52
+ const parsed = Date.parse(String(value || ''));
53
+ return Number.isFinite(parsed) ? parsed : null;
54
+ }
55
+
56
+ function verifyTestFiles(verify) {
57
+ return [...new Set(String(verify || '').replace(/[`'"]/g, ' ').split(/\s+/).filter((t) => TEST_FILE.test(t)))];
58
+ }
59
+
60
+ // One reader per pass: the main line is read once, and each answer is kept.
61
+ function createReader(root, base) {
62
+ const line = git(['log', '--first-parent', '--format=%H %ct', base], root).out
63
+ .split('\n').filter(Boolean).map((row) => {
64
+ const [sha, ct] = row.split(' ');
65
+ return { sha, at: Number(ct) * 1000 };
66
+ }).reverse();
67
+ const onLine = new Map(line.map((c, i) => [c.sha, i]));
68
+ const ancestor = (sha, of) => git(['merge-base', '--is-ancestor', sha, of], root).status === 0;
69
+ const commitExists = (sha) => SHA.test(sha) && git(['cat-file', '-e', `${sha}^{commit}`], root).ok;
70
+
71
+ // The main-line commit that brought `sha` in, and whether `sha` is itself
72
+ // on the main line. The main line is ordered, so a binary search holds.
73
+ function landing(sha) {
74
+ if (!commitExists(sha) || !line.length || !ancestor(sha, line[line.length - 1].sha)) return null;
75
+ if (onLine.has(sha)) return { sha, at: line[onLine.get(sha)].at, mainLine: true };
76
+ let lo = 0;
77
+ let hi = line.length - 1;
78
+ while (lo < hi) {
79
+ const mid = (lo + hi) >> 1;
80
+ if (ancestor(sha, line[mid].sha)) hi = mid;
81
+ else lo = mid + 1;
82
+ }
83
+ return { sha: line[lo].sha, at: line[lo].at, mainLine: false };
84
+ }
85
+
86
+ function firstAdded(file) {
87
+ const rows = git(['log', base, '--diff-filter=A', '--format=%H', '--', file], root).out.split('\n').filter(Boolean);
88
+ return rows.length ? rows[rows.length - 1] : null;
89
+ }
90
+
91
+ const existsAt = (sha, file) => git(['cat-file', '-e', `${sha}:${file}`], root).ok;
92
+ const commitTime = (sha) => Number(git(['log', '-1', '--format=%ct', sha], root).out) * 1000;
93
+ // Taken back out: git's own revert message names the commit it reverts.
94
+ const reverted = (...shas) => shas.filter(Boolean).some((sha) => git(['log', base, '--format=%H', '-F',
95
+ `--grep=This reverts commit ${sha}`], root).out.length > 0);
96
+ const onBase = (file) => existsAt(base, file);
97
+ return { landing, firstAdded, existsAt, commitExists, commitTime, reverted, onBase };
98
+ }
99
+
100
+ // Commit times are whole seconds, so compare at that grain.
101
+ const notBefore = (ms, created) => ms >= Math.floor(created / 1000) * 1000;
102
+
103
+ // Why this review job's change is on the main branch, or null. `shared`
104
+ // holds test files named by more than one review job: a new file two jobs
105
+ // both point at says nothing about which one landed it.
106
+ function mergedProof(task, reader, shared = new Set()) {
107
+ const metadata = (task && task.metadata) || {};
108
+ const work = String(metadata.work_commit || '').trim();
109
+ const start = String(metadata.start_commit || '').trim();
110
+ const created = createdMs(task.created_at);
111
+ if (SHA.test(work) && work !== start) {
112
+ // The job has code of its own: only that code can say it landed. A test
113
+ // file someone else merged does not close a redo that never merged.
114
+ const landed = reader.landing(work);
115
+ if (!landed || landed.mainLine) return null;
116
+ // A commit older than the job is not the job's work.
117
+ if (created && !notBefore(reader.commitTime(work), created)) return null;
118
+ if (reader.reverted(work, landed.sha)) return null;
119
+ return { how: 'work_commit', commit: work, landed_sha: landed.sha, landed_at: landed.at };
120
+ }
121
+ // A check run from another folder names paths relative to that folder.
122
+ if (/(^|[\s;&|(])cd\s/.test(String(metadata.verify || ''))) return null;
123
+ const files = verifyTestFiles(metadata.verify);
124
+ if (!files.length || !created || files.some((file) => shared.has(file))) return null;
125
+ let latest = null;
126
+ for (const file of files) {
127
+ const added = reader.firstAdded(file);
128
+ if (!added || !reader.onBase(file)) return null;
129
+ if (SHA.test(start) && reader.commitExists(start) && reader.existsAt(start, file)) return null;
130
+ const landed = reader.landing(added);
131
+ if (!landed) return null;
132
+ // Added before the job existed: the test belongs to earlier work.
133
+ if (!notBefore(reader.commitTime(added), created)) return null;
134
+ if (reader.reverted(added, landed.sha)) return null;
135
+ if (!latest || landed.at > latest.landed_at) latest = { commit: added, landed_sha: landed.sha, landed_at: landed.at, file };
136
+ }
137
+ return latest ? { how: 'verify_test', files, ...latest } : null;
138
+ }
139
+
140
+ function heldForPerson(row) {
141
+ if (!row || typeof row !== 'object') return true;
142
+ return Boolean(require('./member-task-pick').heldForPerson(row));
143
+ }
144
+
145
+ // The row must still be the one the scan read: new proof since then (a newer
146
+ // updated_at) means newer work the old read knows nothing about.
147
+ function defaultClose(id, { action, proof, updatedAt }) {
148
+ const taskDb = require('./task-db');
149
+ const db = taskDb.open();
150
+ const row = taskDb.getTask(db, id);
151
+ if (!row || row.status !== 'review' || heldForPerson(row)) return { updated: false };
152
+ if (updatedAt != null && Number(row.updated_at) !== Number(updatedAt)) return { updated: false };
153
+ return taskDb.doneTask(db, { id, actor: 'autoland', allowReview: true, autoAccepted: true, action, proof });
154
+ }
155
+
156
+ function proofLine(found) {
157
+ const short = (sha) => String(sha || '').slice(0, 10);
158
+ const when = new Date(found.landed_at).toISOString().slice(0, 16).replace('T', ' ');
159
+ return found.how === 'work_commit'
160
+ ? `its own commit ${short(found.commit)} is on the main branch, merged ${when} UTC (${short(found.landed_sha)})`
161
+ : `its check ${found.files.join(', ')} was added to the main branch after the job was written, merged ${when} UTC (${short(found.landed_sha)})`;
162
+ }
163
+
164
+ // Close every review job in `tasks` whose change is on the main branch.
165
+ // dryRun reports what would close and writes nothing.
166
+ function closeMergedReviewJobs({ root, tasks = [], now = Date.now(), dryRun = false, close = defaultClose } = {}) {
167
+ const base = mainBranchRef(root);
168
+ if (!base) return { base: null, checked: 0, matched: [], closed: [] };
169
+ const reader = createReader(root, base);
170
+ const candidates = tasks.filter((t) => t && t.status === 'review' && !heldForPerson(t)
171
+ && String((t.review && t.review.approval_status) || (t.metadata && t.metadata.approval_status) || 'pending') === 'pending');
172
+ const named = new Map();
173
+ for (const task of tasks.filter((t) => t && t.status === 'review')) {
174
+ for (const file of verifyTestFiles(task.metadata && task.metadata.verify)) named.set(file, (named.get(file) || 0) + 1);
175
+ }
176
+ const shared = new Set([...named].filter(([, n]) => n > 1).map(([file]) => file));
177
+ const matched = [];
178
+ const closed = [];
179
+ for (const task of candidates) {
180
+ let found = null;
181
+ try {
182
+ found = mergedProof(task, reader, shared);
183
+ } catch {
184
+ found = null;
185
+ }
186
+ if (!found) continue;
187
+ const fresh = now - found.landed_at <= DAY_MS;
188
+ const action = fresh ? 'merged' : 'merged_earlier';
189
+ const proof = fresh ? `merged: ${proofLine(found)}` : `merged earlier, closed without counting it as a new landing: ${proofLine(found)}`;
190
+ const row = { id: task.id, ref: task.display_id || task.id, title: task.title, how: found.how, action, landed_at: new Date(found.landed_at).toISOString(), proof };
191
+ matched.push(row);
192
+ if (dryRun) continue;
193
+ let out = null;
194
+ try {
195
+ out = close(task.id, { action, proof, updatedAt: task.updated_at });
196
+ } catch {
197
+ out = null;
198
+ }
199
+ if (out && out.updated) closed.push(row);
200
+ }
201
+ return { base, checked: candidates.length, matched, closed };
202
+ }
203
+
204
+ module.exports = {
205
+ closeMergedReviewJobs,
206
+ };