@yemi33/minions 0.1.2370 → 0.1.2372
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/bin/minions.js +38 -11
- package/dashboard/js/render-other.js +2 -30
- package/dashboard/js/render-work-items.js +0 -34
- package/dashboard/js/settings.js +12 -27
- package/dashboard/pages/tools.html +1 -1
- package/dashboard.js +80 -257
- package/docs/README.md +2 -3
- package/docs/architecture.excalidraw +2 -2
- package/docs/auto-discovery.md +3 -3
- package/docs/completion-reports.md +0 -121
- package/docs/copilot-cli-schema.md +9 -17
- package/docs/deprecated.json +0 -51
- package/docs/design-state-storage.md +4 -4
- package/docs/harness-propagation.md +33 -263
- package/docs/human-vs-automated.md +1 -1
- package/docs/named-agents.md +0 -2
- package/docs/plan-lifecycle.md +5 -6
- package/docs/qa-runbook-lifecycle.md +10 -25
- package/docs/shared-lifecycle-module-map.md +2 -10
- package/engine/ado-comment.js +5 -11
- package/engine/ado.js +10 -5
- package/engine/cleanup.js +16 -36
- package/engine/cli.js +13 -175
- package/engine/comment-format.js +8 -182
- package/engine/db/index.js +60 -10
- package/engine/db/migrations/018-sql-only-cutover.js +56 -0
- package/engine/dispatch-store.js +0 -114
- package/engine/dispatch.js +1 -6
- package/engine/gh-comment.js +2 -10
- package/engine/github.js +8 -6
- package/engine/lifecycle.js +58 -173
- package/engine/llm.js +9 -11
- package/engine/managed-spawn.js +1 -6
- package/engine/memory-store.js +8 -6
- package/engine/metrics-store.js +4 -128
- package/engine/pipeline.js +4 -7
- package/engine/playbook.js +8 -196
- package/engine/prd-store.js +115 -141
- package/engine/preflight.js +8 -55
- package/engine/projects.js +34 -41
- package/engine/pull-requests-store.js +9 -234
- package/engine/qa-runs.js +4 -15
- package/engine/qa-sessions.js +5 -17
- package/engine/queries.js +23 -103
- package/engine/routing.js +1 -1
- package/engine/runtimes/claude.js +1 -2
- package/engine/runtimes/codex.js +0 -2
- package/engine/runtimes/copilot.js +1 -4
- package/engine/shared-branch-pr-reconcile.js +2 -5
- package/engine/shared.js +174 -533
- package/engine/small-state-store.js +12 -647
- package/engine/spawn-agent.js +5 -96
- package/engine/state-operations.js +166 -0
- package/engine/supervisor.js +0 -1
- package/engine/watch-actions.js +2 -3
- package/engine/watches-store.js +2 -128
- package/engine/work-items-store.js +3 -254
- package/engine.js +102 -334
- package/package.json +2 -2
- package/playbooks/build-fix-complex.md +0 -4
- package/playbooks/fix.md +0 -4
- package/playbooks/implement-shared.md +0 -4
- package/playbooks/implement.md +0 -4
- package/playbooks/plan-to-prd.md +16 -11
- package/playbooks/plan.md +0 -4
- package/playbooks/review.md +0 -6
- package/playbooks/shared-rules.md +0 -65
- package/docs/harness-transparency.md +0 -199
- package/docs/project-skills.md +0 -193
- package/engine/discover-project-skills.js +0 -673
- package/engine/playbook-intents.js +0 -76
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Retire live JSON mirror metadata after all runtime stores become SQL-only.
|
|
2
|
+
module.exports = {
|
|
3
|
+
version: 18,
|
|
4
|
+
description: 'SQL-only runtime-state cutover; drop obsolete PR mirror hashes',
|
|
5
|
+
up(db, { fs, path }) {
|
|
6
|
+
let legacyPrdCount = 0;
|
|
7
|
+
let importedPrdCount = 0;
|
|
8
|
+
const minionsDir = process.env.MINIONS_TEST_DIR || process.env.MINIONS_HOME
|
|
9
|
+
|| (() => { try { return require('../../shared').MINIONS_DIR; } catch { return null; } })();
|
|
10
|
+
if (minionsDir) {
|
|
11
|
+
const importDir = (dir, archived) => {
|
|
12
|
+
let files = [];
|
|
13
|
+
try { files = fs.readdirSync(dir).filter(f => f.endsWith('.json')); } catch { return; }
|
|
14
|
+
const { _upsertPrd } = require('../../prd-store');
|
|
15
|
+
for (const filename of files) {
|
|
16
|
+
legacyPrdCount++;
|
|
17
|
+
const filePath = path.join(dir, filename);
|
|
18
|
+
let prd;
|
|
19
|
+
try { prd = JSON.parse(fs.readFileSync(filePath, 'utf8')); }
|
|
20
|
+
catch (e) { throw new Error(`cannot import legacy PRD ${filePath}: ${e.message}`); }
|
|
21
|
+
if (!prd || typeof prd !== 'object' || Array.isArray(prd)) {
|
|
22
|
+
throw new Error(`cannot import legacy PRD ${filePath}: expected an object`);
|
|
23
|
+
}
|
|
24
|
+
_upsertPrd(db, filename, archived, prd, Date.now());
|
|
25
|
+
importedPrdCount++;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
importDir(path.join(minionsDir, 'prd'), 0);
|
|
29
|
+
importDir(path.join(minionsDir, 'prd', 'archive'), 1);
|
|
30
|
+
}
|
|
31
|
+
if (importedPrdCount !== legacyPrdCount) {
|
|
32
|
+
throw new Error(`legacy PRD import count mismatch: found ${legacyPrdCount}, imported ${importedPrdCount}`);
|
|
33
|
+
}
|
|
34
|
+
db.exec(`
|
|
35
|
+
DROP TABLE IF EXISTS pr_mirror_hashes;
|
|
36
|
+
CREATE TABLE IF NOT EXISTS state_markers (
|
|
37
|
+
key TEXT PRIMARY KEY,
|
|
38
|
+
value TEXT NOT NULL,
|
|
39
|
+
updated_at INTEGER NOT NULL
|
|
40
|
+
);
|
|
41
|
+
`);
|
|
42
|
+
const quickCheck = db.prepare('PRAGMA quick_check').all();
|
|
43
|
+
if (quickCheck.length !== 1 || quickCheck[0].quick_check !== 'ok') {
|
|
44
|
+
throw new Error(`SQLite quick_check failed: ${JSON.stringify(quickCheck)}`);
|
|
45
|
+
}
|
|
46
|
+
const foreignKeyErrors = db.prepare('PRAGMA foreign_key_check').all();
|
|
47
|
+
if (foreignKeyErrors.length) {
|
|
48
|
+
throw new Error(`SQLite foreign_key_check failed: ${JSON.stringify(foreignKeyErrors)}`);
|
|
49
|
+
}
|
|
50
|
+
db.prepare(`
|
|
51
|
+
INSERT INTO state_markers (key, value, updated_at)
|
|
52
|
+
VALUES ('sql_only_cutover', '1', ?)
|
|
53
|
+
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at
|
|
54
|
+
`).run(Date.now());
|
|
55
|
+
},
|
|
56
|
+
};
|
package/engine/dispatch-store.js
CHANGED
|
@@ -17,18 +17,6 @@
|
|
|
17
17
|
|
|
18
18
|
const SECTIONS = ['pending', 'active', 'completed', 'review'];
|
|
19
19
|
|
|
20
|
-
// First-touch guard (P-5f0e8c27). Tracks whether a dispatch mutation has
|
|
21
|
-
// written SQL in THIS process. Once SQL has been written, an empty read
|
|
22
|
-
// reflects a genuine prune-to-zero — NOT a fresh install — so we must not
|
|
23
|
-
// resurrect a stale dispatch.json (e.g. a crash between the SQL COMMIT and
|
|
24
|
-
// the post-commit mirror write leaves dispatch.json holding the pre-prune
|
|
25
|
-
// rows). This mirrors the `_resyncIfJsonDiverged` first-touch discipline the
|
|
26
|
-
// work-items / pull-requests / metrics / watches stores already use: once SQL
|
|
27
|
-
// is the established source of truth this process, JSON never overrides it.
|
|
28
|
-
// Reset only by process restart (module reload); test isolation re-requires
|
|
29
|
-
// the module via withFreshDb's cache-bust, so each test starts fresh.
|
|
30
|
-
let _sqlWrittenThisProcess = false;
|
|
31
|
-
|
|
32
20
|
function _emptySectioned() {
|
|
33
21
|
return { pending: [], active: [], completed: [], review: [] };
|
|
34
22
|
}
|
|
@@ -60,33 +48,6 @@ function readDispatchSectioned() {
|
|
|
60
48
|
ORDER BY status, created_at
|
|
61
49
|
`).all();
|
|
62
50
|
|
|
63
|
-
if (rows.length === 0 && !_sqlWrittenThisProcess) {
|
|
64
|
-
// SQL has no live dispatches AND no mutation has written SQL this process.
|
|
65
|
-
// Only then is the JSON file a trustworthy fallback. Two scenarios:
|
|
66
|
-
// (a) Production fresh install — JSON file is empty/missing, fallback
|
|
67
|
-
// returns the same empty sectioned object. No harm.
|
|
68
|
-
// (b) Test that wrote dispatch.json directly (legacy test helpers in
|
|
69
|
-
// timeout-behavioral / orphan-* / etc. — they bypass the proper
|
|
70
|
-
// mutateDispatch API and seed via fs.writeFileSync). Picking up
|
|
71
|
-
// the JSON keeps those tests working through the transition without
|
|
72
|
-
// touching every helper. Phase 1.5 migrates them to the proper API.
|
|
73
|
-
//
|
|
74
|
-
// The `!_sqlWrittenThisProcess` gate is the first-touch guard: once a
|
|
75
|
-
// mutation has written SQL this process (e.g. the engine pruned the last
|
|
76
|
-
// dispatch to zero), an empty SQL read is the real state. Adopting a stale
|
|
77
|
-
// non-empty dispatch.json here would resurrect pruned records and could
|
|
78
|
-
// trigger a duplicate dispatch before the next mutation self-heals the
|
|
79
|
-
// mirror — the exact crash-window bug this guard closes.
|
|
80
|
-
const fallback = _readDispatchJsonFallback();
|
|
81
|
-
const hasContent = (
|
|
82
|
-
(fallback.pending && fallback.pending.length > 0) ||
|
|
83
|
-
(fallback.active && fallback.active.length > 0) ||
|
|
84
|
-
(fallback.completed && fallback.completed.length > 0) ||
|
|
85
|
-
(fallback.review && fallback.review.length > 0)
|
|
86
|
-
);
|
|
87
|
-
if (hasContent) return fallback;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
51
|
const out = _emptySectioned();
|
|
91
52
|
for (const row of rows) {
|
|
92
53
|
const rec = _parseRow(row);
|
|
@@ -96,37 +57,6 @@ function readDispatchSectioned() {
|
|
|
96
57
|
return out;
|
|
97
58
|
}
|
|
98
59
|
|
|
99
|
-
function _readDispatchJsonFallback() {
|
|
100
|
-
const path = require('path');
|
|
101
|
-
const fs = require('fs');
|
|
102
|
-
const shared = require('./shared');
|
|
103
|
-
const dispatchPath = path.join(shared.MINIONS_DIR, 'engine', 'dispatch.json');
|
|
104
|
-
let raw;
|
|
105
|
-
try {
|
|
106
|
-
raw = fs.readFileSync(dispatchPath, 'utf8');
|
|
107
|
-
} catch {
|
|
108
|
-
// ENOENT / permission / etc. — file genuinely absent. Silently
|
|
109
|
-
// return an empty sectioned object; that's the legacy semantics.
|
|
110
|
-
return _emptySectioned();
|
|
111
|
-
}
|
|
112
|
-
try {
|
|
113
|
-
const parsed = JSON.parse(raw);
|
|
114
|
-
const out = _emptySectioned();
|
|
115
|
-
for (const s of SECTIONS) {
|
|
116
|
-
if (Array.isArray(parsed[s])) out[s] = parsed[s];
|
|
117
|
-
}
|
|
118
|
-
return out;
|
|
119
|
-
} catch (e) {
|
|
120
|
-
// File exists but parse failed — that's user-visible corruption.
|
|
121
|
-
// Surface to logs the same way the legacy queries.getDispatch path
|
|
122
|
-
// did via shared.readJsonNoRestore. (Phase 1 contract test
|
|
123
|
-
// "getDispatch warns on corrupt dispatch.json".)
|
|
124
|
-
// eslint-disable-next-line no-console
|
|
125
|
-
console.warn(`[dispatch-store] corrupt JSON in ${dispatchPath}: ${e.message}`);
|
|
126
|
-
return _emptySectioned();
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
60
|
// Internal: read the full (unfiltered) dispatch state from SQL.
|
|
131
61
|
// Used by applyDispatchMutation so the diff sees ALL completed rows,
|
|
132
62
|
// not just the recent window. Mutators that prune old completed
|
|
@@ -272,57 +202,13 @@ function applyDispatchMutation(mutator) {
|
|
|
272
202
|
const wrote = _applyDispatchDiff(db, diff);
|
|
273
203
|
// First-touch guard: record that SQL was written this process so a later
|
|
274
204
|
// empty read won't resurrect a stale dispatch.json (see readDispatchSectioned).
|
|
275
|
-
if (wrote) _sqlWrittenThisProcess = true;
|
|
276
205
|
return { wrote, result: after };
|
|
277
206
|
});
|
|
278
207
|
}
|
|
279
208
|
|
|
280
|
-
// Mirror the canonical SQL state back to dispatch.json after a successful
|
|
281
|
-
// mutation. Phase 1 keeps the JSON file as a *read-only derivative* of SQL
|
|
282
|
-
// — never an independent source of truth — so legacy callers that
|
|
283
|
-
// readFileSync the file directly (a couple of internal paths + the broader
|
|
284
|
-
// test suite) keep seeing consistent data. There's no drift opportunity
|
|
285
|
-
// because:
|
|
286
|
-
//
|
|
287
|
-
// - The mirror is always regenerated from the SQL table, never from
|
|
288
|
-
// in-memory state, so a write that lands transactionally in SQL but
|
|
289
|
-
// then crashes before the mirror is also written produces a stale
|
|
290
|
-
// mirror — but the SQL state is correct, and the next successful
|
|
291
|
-
// mutation overwrites the mirror.
|
|
292
|
-
// - No code path writes to dispatch.json independently anymore;
|
|
293
|
-
// mutateDispatch is the only writer.
|
|
294
|
-
//
|
|
295
|
-
// Phase 1.5 will remove the mirror once we've migrated the remaining
|
|
296
|
-
// direct-readers (engine/queries.js fallback path, engine/routing.js,
|
|
297
|
-
// the test infrastructure) to use readDispatchSectioned() / getDispatch().
|
|
298
|
-
function _mirrorJsonFromSql() {
|
|
299
|
-
try {
|
|
300
|
-
const path = require('path');
|
|
301
|
-
const fs = require('fs');
|
|
302
|
-
const shared = require('./shared');
|
|
303
|
-
// The full sectioned view (not the 24h-windowed one) so the JSON
|
|
304
|
-
// mirror matches what a legacy reader would have seen before Phase 1.
|
|
305
|
-
const full = _readDispatchSectionedFull();
|
|
306
|
-
const dispatchPath = path.join(shared.MINIONS_DIR, 'engine', 'dispatch.json');
|
|
307
|
-
// safeWrite handles the atomic rename + .backup sidecar. Best-effort —
|
|
308
|
-
// failure here doesn't propagate (SQL is source of truth, mirror is
|
|
309
|
-
// optional refresh material).
|
|
310
|
-
shared.safeWrite(dispatchPath, full);
|
|
311
|
-
// Re-derive what the parent shared.safeWrite would have done so an
|
|
312
|
-
// operator copying the file out gets matched fs.statSync mtimes
|
|
313
|
-
// (the dashboard's slow-state tracker watches this file as part of
|
|
314
|
-
// the dispatch path list). No-op if safeWrite already advanced mtime.
|
|
315
|
-
try { fs.utimesSync(dispatchPath, new Date(), new Date()); } catch { /* best-effort */ }
|
|
316
|
-
} catch {
|
|
317
|
-
// Mirror failures are non-fatal: SQL has already committed. Tests +
|
|
318
|
-
// legacy readers will see the previous mirror until the next write.
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
|
|
322
209
|
module.exports = {
|
|
323
210
|
readDispatchSectioned,
|
|
324
211
|
applyDispatchMutation,
|
|
325
212
|
_readDispatchSectionedFull,
|
|
326
213
|
_computeDispatchDiff,
|
|
327
|
-
_mirrorJsonFromSql,
|
|
328
214
|
};
|
package/engine/dispatch.js
CHANGED
|
@@ -122,11 +122,6 @@ function mutateDispatch(mutator) {
|
|
|
122
122
|
if (wrote) {
|
|
123
123
|
try { require('./queries').invalidateDispatchCache(); } catch {}
|
|
124
124
|
try { require('./db-events').emitStateEvent('dispatch'); } catch { /* optional */ }
|
|
125
|
-
// Mirror back to dispatch.json for tests + tools that fs.readFileSync
|
|
126
|
-
// the file directly. SQL is the source of truth; the JSON file is
|
|
127
|
-
// regenerated from SQL on every successful mutation, never independently
|
|
128
|
-
// mutated. Cheap to delete once those direct-JSON readers are gone.
|
|
129
|
-
try { store._mirrorJsonFromSql(); } catch { /* best-effort */ }
|
|
130
125
|
}
|
|
131
126
|
return result;
|
|
132
127
|
}
|
|
@@ -784,6 +779,7 @@ function isRetryableFailureReason(reason = '', failureClass = '') {
|
|
|
784
779
|
FAILURE_CLASS.LIVE_CHECKOUT_STALE_BASE, // W-mr98op8w000ma4ad — live-checkout STALE-BASE GUARD refused to fork a new branch off a local mainRef that has diverged from origin/<mainRef> with unpushed commits; contaminated local base does not change on retry (operator must reconcile the local base branch — the engine never resets it — then re-dispatch)
|
|
785
780
|
FAILURE_CLASS.LIVE_CHECKOUT_WRONG_BASE, // W-mr3lunnq000o9f41 — live-checkout refused to fork a new branch because the operator HEAD is on an unrelated branch (not the project base) and no local base ref was usable; forking off it would bake that branch's commits into the new PR (scope contamination). Deterministic — mechanical retry reproduces it (operator must `git checkout <mainRef>`, then re-dispatch)
|
|
786
781
|
FAILURE_CLASS.OUTPUT_TRUNCATED, // P-8e4c2a17 — agent stdout exceeded the hard capture cap before the terminal result event; mechanical retry just reproduces the overflow (agent must reduce output volume or the task must be split)
|
|
782
|
+
FAILURE_CLASS.PRE_DISPATCH_EVAL_STUCK, // W-mrcrh5hj0017d22f — pre-dispatch-eval rejected the SAME unchanged description ENGINE_DEFAULTS.preDispatchEvalMaxRejections times in a row; non-retryable until the description changes (engine/shared.js:4986)
|
|
787
783
|
]);
|
|
788
784
|
if (neverRetry.has(failureClass)) return false;
|
|
789
785
|
}
|
|
@@ -1454,7 +1450,6 @@ function cleanDispatchEntries(matchFn) {
|
|
|
1454
1450
|
* Sets status=CANCELLED + _cancelledBy=reason. Returns count cancelled.
|
|
1455
1451
|
*/
|
|
1456
1452
|
function cancelPendingWorkItems(wiPath, matchFn, reason) {
|
|
1457
|
-
if (!fs.existsSync(wiPath)) return 0;
|
|
1458
1453
|
let cancelled = 0;
|
|
1459
1454
|
try {
|
|
1460
1455
|
mutateWorkItems(wiPath, items => {
|
package/engine/gh-comment.js
CHANGED
|
@@ -42,8 +42,6 @@ const os = require('os');
|
|
|
42
42
|
const { execFileSync: _execFileSync } = require('child_process');
|
|
43
43
|
const { resolveTokenForSlug: _defaultResolveTokenForSlug } = require('./gh-token');
|
|
44
44
|
const {
|
|
45
|
-
buildHarnessUsedSection,
|
|
46
|
-
buildSkippedSkillSection,
|
|
47
45
|
linkifyBrandTrailer,
|
|
48
46
|
MINIONS_BRAND_URL,
|
|
49
47
|
// Neutral marker + body builder now lives in comment-format.js so the GitHub
|
|
@@ -155,15 +153,13 @@ function postPrComment({
|
|
|
155
153
|
agentId,
|
|
156
154
|
kind,
|
|
157
155
|
workItemId,
|
|
158
|
-
harnessUsed,
|
|
159
|
-
skillSkipped,
|
|
160
156
|
timeoutMs = 30000,
|
|
161
157
|
execFileSync = _execFileSync,
|
|
162
158
|
resolveTokenForSlug,
|
|
163
159
|
} = {}) {
|
|
164
160
|
_validateRepo(repo);
|
|
165
161
|
_validatePrNumber(prNumber);
|
|
166
|
-
const finalBody = buildMinionsCommentBody({ agentId, kind, workItemId, body
|
|
162
|
+
const finalBody = buildMinionsCommentBody({ agentId, kind, workItemId, body });
|
|
167
163
|
const file = _writeTempBodyFile(finalBody);
|
|
168
164
|
const env = _resolveTokenEnvForRepo(repo, resolveTokenForSlug);
|
|
169
165
|
try {
|
|
@@ -186,15 +182,13 @@ function postPrReviewComment({
|
|
|
186
182
|
agentId,
|
|
187
183
|
kind,
|
|
188
184
|
workItemId,
|
|
189
|
-
harnessUsed,
|
|
190
|
-
skillSkipped,
|
|
191
185
|
timeoutMs = 30000,
|
|
192
186
|
execFileSync = _execFileSync,
|
|
193
187
|
resolveTokenForSlug,
|
|
194
188
|
} = {}) {
|
|
195
189
|
_validateRepo(repo);
|
|
196
190
|
_validatePrNumber(prNumber);
|
|
197
|
-
const finalBody = buildMinionsCommentBody({ agentId, kind, workItemId, body
|
|
191
|
+
const finalBody = buildMinionsCommentBody({ agentId, kind, workItemId, body });
|
|
198
192
|
const file = _writeTempBodyFile(finalBody);
|
|
199
193
|
const env = _resolveTokenEnvForRepo(repo, resolveTokenForSlug);
|
|
200
194
|
try {
|
|
@@ -213,8 +207,6 @@ function postPrReviewComment({
|
|
|
213
207
|
module.exports = {
|
|
214
208
|
// Builders / parsers (pure functions — usable from anywhere)
|
|
215
209
|
buildMinionsCommentBody,
|
|
216
|
-
buildHarnessUsedSection, // re-export of engine/comment-format.js for comment callers
|
|
217
|
-
buildSkippedSkillSection, // re-export of engine/comment-format.js for comment callers
|
|
218
210
|
linkifyBrandTrailer, // re-export of engine/comment-format.js for comment callers
|
|
219
211
|
MINIONS_BRAND_URL, // re-export of engine/comment-format.js for comment callers
|
|
220
212
|
parseMinionsMarker,
|
package/engine/github.js
CHANGED
|
@@ -840,12 +840,14 @@ async function pollPrStatus(config) {
|
|
|
840
840
|
else if (prData.state === 'closed') newStatus = PR_STATUS.ABANDONED;
|
|
841
841
|
else if (prData.state === 'open') newStatus = PR_STATUS.ACTIVE;
|
|
842
842
|
|
|
843
|
-
// W-mpej044m00076d63:
|
|
844
|
-
//
|
|
845
|
-
//
|
|
846
|
-
//
|
|
847
|
-
//
|
|
848
|
-
|
|
843
|
+
// W-mpej044m00076d63 / W-mrezh0yb0007f733: keep `pr.created` aligned with
|
|
844
|
+
// GitHub's real `created_at`. The platform is authoritative for when a PR
|
|
845
|
+
// was opened, so adopt its value whenever the stored one is missing, a
|
|
846
|
+
// legacy date-only stamp, or a link/attach-time `now()` default (the bug
|
|
847
|
+
// where manually-linked PRs showed a "created today" date).
|
|
848
|
+
// `shouldAdoptPlatformCreated` compares instants so equivalent
|
|
849
|
+
// representations don't churn. Idempotent.
|
|
850
|
+
if (shared.shouldAdoptPlatformCreated(pr.created, prData.created_at)) {
|
|
849
851
|
pr.created = prData.created_at;
|
|
850
852
|
updated = true;
|
|
851
853
|
}
|