forge-workflow 0.1.0-beta.2 → 0.1.0-beta.4
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/.forge/hooks/check-tdd.js +79 -5
- package/.forge/hooks/forge-native-hook.js +194 -8
- package/AGENTS.md +2 -1
- package/CHANGELOG.md +28 -0
- package/QUICKSTART.md +6 -2
- package/README.md +3 -1
- package/bin/forge-cmd.js +1 -1
- package/bin/forge.js +95 -19
- package/docs/guides/SETUP.md +4 -1
- package/docs/guides/SUPPORT.md +5 -0
- package/docs/reference/COMMANDS.md +12 -1
- package/docs/reference/shepherd.md +114 -3
- package/lib/activation/ensure-forge-home.js +135 -0
- package/lib/adapters/beads-kernel-compat.js +67 -0
- package/lib/adapters/greptile-review-adapter.js +1 -1
- package/lib/adapters/pr-state-adapter.js +103 -8
- package/lib/adoption-profiles.js +17 -4
- package/lib/agents-config.js +5 -0
- package/lib/beads-detect.js +60 -0
- package/lib/commands/_aliases.js +248 -0
- package/lib/commands/_issue.js +65 -41
- package/lib/commands/_manifest.js +3 -1
- package/lib/commands/_registry.js +14 -0
- package/lib/commands/_resolve-command-opts.js +36 -60
- package/lib/commands/claim.js +2 -4
- package/lib/commands/gate.js +19 -2
- package/lib/commands/hooks.js +294 -12
- package/lib/commands/init.js +26 -20
- package/lib/commands/memory.js +81 -0
- package/lib/commands/migrate.js +0 -161
- package/lib/commands/plan.js +71 -123
- package/lib/commands/pr.js +88 -0
- package/lib/commands/prime.js +8 -1
- package/lib/commands/push.js +66 -0
- package/lib/commands/recall.js +67 -12
- package/lib/commands/recap.js +18 -4
- package/lib/commands/release.js +15 -3
- package/lib/commands/remember.js +86 -20
- package/lib/commands/serve.js +5 -2
- package/lib/commands/setup.js +135 -73
- package/lib/commands/shepherd.js +102 -4
- package/lib/commands/ship.js +40 -4
- package/lib/commands/skill.js +275 -0
- package/lib/commands/status.js +37 -32
- package/lib/commands/test.js +32 -0
- package/lib/commands/worktree.js +87 -5
- package/lib/core/runtime-graph.js +122 -17
- package/lib/forge-issues.js +13 -464
- package/lib/gate-events.js +54 -55
- package/lib/global-flags.js +30 -0
- package/lib/grounding/context-events.js +230 -0
- package/lib/grounding/read-first.js +112 -0
- package/lib/harness-capability-matrix.js +2 -2
- package/lib/hook-renderer.js +144 -12
- package/lib/issue-backend.js +42 -3
- package/lib/kernel/backing-issue.js +7 -1
- package/lib/kernel/broker.js +28 -0
- package/lib/kernel/migrations.js +30 -2
- package/lib/kernel/owned-kernel.js +43 -0
- package/lib/kernel/schema.js +35 -0
- package/lib/kernel/sqlite-driver.js +187 -1
- package/lib/memory-recall.js +151 -0
- package/lib/orientation.js +303 -6
- package/lib/pr-bundle.js +6 -2
- package/lib/pr-monitor/auto-actions.js +175 -0
- package/lib/pr-monitor/digest.js +206 -0
- package/lib/pr-monitor/reconcile-executor.js +659 -0
- package/lib/pr-monitor/reconcile-tick.js +138 -0
- package/lib/pr-monitor/reconcile.js +0 -0
- package/lib/pr-monitor/render-sticky.js +57 -8
- package/lib/pr-monitor/shepherd-lease.js +243 -0
- package/lib/pr-monitor/upsert-sticky.js +169 -0
- package/lib/pr-monitor/watch-lifecycle.js +1 -1
- package/lib/pr-pull.js +113 -17
- package/lib/project-memory.js +8 -0
- package/lib/release-readiness.js +17 -1
- package/lib/rules-sync.js +4 -0
- package/lib/runtime-health.js +15 -46
- package/lib/skill-eval.js +750 -0
- package/lib/status/identity.js +46 -0
- package/lib/status/presenter.js +0 -35
- package/lib/status/snapshot.js +11 -16
- package/lib/upgrade-safety.js +52 -1
- package/lib/using-forge.js +315 -0
- package/lib/workflow/enforce-stage.js +64 -7
- package/lib/workflow/state-manager.js +23 -23
- package/package.json +2 -2
- package/rules/using-forge.md +24 -0
- package/scripts/forge-team/index.sh +0 -5
- package/scripts/forge-team/tests/dispatcher.test.sh +1 -1
- package/scripts/forge-team/tests/workflow-integration.test.sh +0 -1
- package/scripts/pr-auto-actions.js +93 -0
- package/scripts/pr-verdict-label.js +50 -0
- package/scripts/test.js +8 -1
- package/skills/claim-safety/SKILL.md +4 -0
- package/skills/claim-safety/evals/scorecard.json +41 -0
- package/skills/coverage.json +83 -0
- package/skills/dev/SKILL.md +4 -0
- package/skills/dev/evals/scorecard.json +41 -0
- package/skills/gates/SKILL.md +80 -0
- package/skills/gates/evals/evals.json +38 -0
- package/skills/gates/evals/scorecard.json +41 -0
- package/skills/hermes-forge/SKILL.md +1 -0
- package/skills/hermes-forge/evals/scorecard.json +41 -0
- package/skills/issue-basics/SKILL.md +1 -0
- package/skills/issue-basics/evals/scorecard.json +41 -0
- package/skills/kernel/SKILL.md +38 -0
- package/skills/kernel/evals/scorecard.json +41 -0
- package/skills/memory/SKILL.md +16 -1
- package/skills/memory/evals/scorecard.json +41 -0
- package/skills/parallel-deep-research/SKILL.md +1 -0
- package/skills/parallel-deep-research/evals/scorecard.json +41 -0
- package/skills/plan/SKILL.md +6 -0
- package/skills/plan/evals/scorecard.json +41 -0
- package/skills/portability/SKILL.md +47 -0
- package/skills/portability/evals/evals.json +34 -0
- package/skills/portability/evals/scorecard.json +41 -0
- package/skills/research/SKILL.md +1 -0
- package/skills/research/evals/scorecard.json +41 -0
- package/skills/review/SKILL.md +6 -0
- package/skills/review/evals/scorecard.json +41 -0
- package/skills/rollback/SKILL.md +1 -0
- package/skills/rollback/evals/scorecard.json +41 -0
- package/skills/setup/SKILL.md +91 -0
- package/skills/setup/evals/evals.json +42 -0
- package/skills/setup/evals/scorecard.json +41 -0
- package/skills/shepherd/SKILL.md +76 -36
- package/skills/shepherd/evals/evals.json +21 -9
- package/skills/shepherd/evals/scorecard.json +41 -0
- package/skills/ship/SKILL.md +6 -0
- package/skills/ship/evals/scorecard.json +41 -0
- package/skills/smith/SKILL.md +8 -0
- package/skills/smith/evals/scorecard.json +41 -0
- package/skills/sonarcloud/SKILL.md +1 -0
- package/skills/sonarcloud/evals/scorecard.json +41 -0
- package/skills/sonarcloud-analysis/SKILL.md +1 -0
- package/skills/sonarcloud-analysis/evals/scorecard.json +41 -0
- package/skills/status/SKILL.md +3 -0
- package/skills/status/evals/scorecard.json +41 -0
- package/skills/triage-ready/SKILL.md +2 -0
- package/skills/triage-ready/evals/scorecard.json +41 -0
- package/skills/using-forge/SKILL.md +104 -0
- package/skills/using-forge/evals/scorecard.json +41 -0
- package/skills/validate/SKILL.md +4 -0
- package/skills/validate/evals/scorecard.json +41 -0
- package/skills/verify/SKILL.md +4 -0
- package/skills/verify/evals/scorecard.json +41 -0
- package/skills/worktree/SKILL.md +87 -0
- package/skills/worktree/evals/evals.json +38 -0
- package/skills/worktree/evals/scorecard.json +41 -0
- package/lib/adapters/beads-issue-adapter.js +0 -127
- package/lib/commands/board.js +0 -64
- package/lib/status/beads-snapshot.js +0 -145
- package/scripts/forge-team/lib/dashboard.sh +0 -316
- package/scripts/forge-team/tests/dashboard.test.sh +0 -155
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The debounce / cost guard for the autonomous shepherd (W-S4 design §2).
|
|
5
|
+
*
|
|
6
|
+
* `tick()` is the cheap-path-first gate that fires from an arbitrary `forge`
|
|
7
|
+
* command (W-S4b wires the call site). Its whole job is to make the HOT path — a
|
|
8
|
+
* live daemon already converging — cost ~one `readFileSync` and return, so a
|
|
9
|
+
* per-command trigger never regresses into a `gh` call per command.
|
|
10
|
+
*
|
|
11
|
+
* Three gates:
|
|
12
|
+
* G1 A fresh lease (heartbeat < STALE_MS) means a live daemon is converging —
|
|
13
|
+
* do NOTHING (no stat, no enumerate, no spawn).
|
|
14
|
+
* G2 The enumeration window (RECONCILE_MIN_INTERVAL) has not elapsed since the
|
|
15
|
+
* last cold tick — skip enumeration.
|
|
16
|
+
* G3 Cold tick: bump the sentinel mtime FIRST (throttle even if gh is slow),
|
|
17
|
+
* then run the INJECTED enumerate()+reconcile()+execute().
|
|
18
|
+
*
|
|
19
|
+
* The sentinel file `<gitCommonDir>/forge/shepherd.reconcile` is the throttle
|
|
20
|
+
* stamp — its **mtime IS `last_enumerated_at`** (no content, no parse). It is a
|
|
21
|
+
* SEPARATE file from the token-guarded lease payload precisely because a tick
|
|
22
|
+
* fired by an arbitrary command does NOT hold the lease token and so cannot write
|
|
23
|
+
* the lease (design correction #3) — but any process may bump this sentinel.
|
|
24
|
+
*
|
|
25
|
+
* This module does NOT spawn — `enumerate`/`execute`/`reconcile` are injected, so
|
|
26
|
+
* the guard is fully testable with a fake clock + temp dir and 0 real I/O beyond
|
|
27
|
+
* the lock read and sentinel stat/bump. The daemon executor + spawn wiring are
|
|
28
|
+
* W-S4b.
|
|
29
|
+
*
|
|
30
|
+
* @module pr-monitor/reconcile-tick
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const fs = require('node:fs');
|
|
34
|
+
const path = require('node:path');
|
|
35
|
+
const { STALE_MS } = require('./shepherd-lease');
|
|
36
|
+
const { reconcile: defaultReconcile } = require('./reconcile');
|
|
37
|
+
|
|
38
|
+
/** Default minimum interval between cold enumerations, per repo (ms). */
|
|
39
|
+
const RECONCILE_MIN_INTERVAL = 60000;
|
|
40
|
+
|
|
41
|
+
/** Resolve the enumeration window, honoring the env override at call time (tests). */
|
|
42
|
+
function resolveMinInterval() {
|
|
43
|
+
const override = Number(process.env.FORGE_RECONCILE_MIN_INTERVAL);
|
|
44
|
+
return Number.isFinite(override) && override > 0 ? override : RECONCILE_MIN_INTERVAL;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function forgeDir(gitCommonDir) {
|
|
48
|
+
return path.join(gitCommonDir, 'forge');
|
|
49
|
+
}
|
|
50
|
+
function lockPath(gitCommonDir) {
|
|
51
|
+
return path.join(forgeDir(gitCommonDir), 'shepherd.lock');
|
|
52
|
+
}
|
|
53
|
+
function sentinelPath(gitCommonDir) {
|
|
54
|
+
return path.join(forgeDir(gitCommonDir), 'shepherd.reconcile');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Read + parse the lock payload, or null when missing/unreadable/corrupt. */
|
|
58
|
+
function readLock(gitCommonDir) {
|
|
59
|
+
try {
|
|
60
|
+
return JSON.parse(fs.readFileSync(lockPath(gitCommonDir), 'utf8'));
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Stat the sentinel, or null when it does not exist yet. */
|
|
67
|
+
function statSentinel(gitCommonDir) {
|
|
68
|
+
try {
|
|
69
|
+
return fs.statSync(sentinelPath(gitCommonDir));
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Bump the sentinel mtime to `t` (create it if absent). mtime IS last_enumerated_at. */
|
|
76
|
+
function bumpSentinel(gitCommonDir, t) {
|
|
77
|
+
const file = sentinelPath(gitCommonDir);
|
|
78
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
79
|
+
if (!fs.existsSync(file)) fs.writeFileSync(file, '', { mode: 0o600 });
|
|
80
|
+
const secs = t / 1000;
|
|
81
|
+
fs.utimesSync(file, secs, secs);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Run one debounce tick. Returns `{ path: 'G1'|'G2'|'G3', actions? }` describing
|
|
86
|
+
* which gate fired (the `actions` are the reconcile output on the G3 cold path).
|
|
87
|
+
*
|
|
88
|
+
* Injected seams (all keep the guard hermetic — no gh/spawn/clock of its own):
|
|
89
|
+
* now () => ms — clock (default Date.now)
|
|
90
|
+
* enumerate () => {desired, observed} — the expensive gh∩kernel gather (G3 only)
|
|
91
|
+
* execute (actions) => void — the action dispatcher (G3 only)
|
|
92
|
+
* reconcile (desired, observed, now) => {actions} — pure core (default: the real one)
|
|
93
|
+
* minInterval ms — enumeration window (default: env-or-60000)
|
|
94
|
+
*/
|
|
95
|
+
function tick({
|
|
96
|
+
gitCommonDir,
|
|
97
|
+
now = () => Date.now(),
|
|
98
|
+
enumerate,
|
|
99
|
+
execute,
|
|
100
|
+
reconcile = defaultReconcile,
|
|
101
|
+
minInterval = resolveMinInterval(),
|
|
102
|
+
} = {}) {
|
|
103
|
+
const t = now();
|
|
104
|
+
|
|
105
|
+
// G1 — a fresh lease means a live daemon is converging. Do NOTHING (hot path:
|
|
106
|
+
// one readFileSync + JSON.parse, then return; no stat, no enumerate, no spawn).
|
|
107
|
+
const lock = readLock(gitCommonDir);
|
|
108
|
+
if (lock) {
|
|
109
|
+
const beat = Date.parse(lock.heartbeatAt);
|
|
110
|
+
if (Number.isFinite(beat) && (t - beat) < STALE_MS) {
|
|
111
|
+
return { path: 'G1' };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// G2 — the enumeration window has not elapsed since the last cold tick. Trust the
|
|
116
|
+
// last enumeration; skip the expensive gather. (Daemon revive on this path is W-S4b.)
|
|
117
|
+
const sentinelStat = statSentinel(gitCommonDir);
|
|
118
|
+
if (sentinelStat && (t - sentinelStat.mtimeMs) < minInterval) {
|
|
119
|
+
return { path: 'G2' };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// G3 — cold tick: no fresh lease AND the window elapsed. Bump the sentinel mtime
|
|
123
|
+
// FIRST so the throttle holds even if the enumerate() gh call is slow, THEN run the
|
|
124
|
+
// injected enumerate → reconcile → execute.
|
|
125
|
+
bumpSentinel(gitCommonDir, t);
|
|
126
|
+
const { desired, observed } = enumerate();
|
|
127
|
+
const { actions } = reconcile(desired, observed, t);
|
|
128
|
+
execute(actions);
|
|
129
|
+
return { path: 'G3', actions };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
module.exports = {
|
|
133
|
+
tick,
|
|
134
|
+
RECONCILE_MIN_INTERVAL,
|
|
135
|
+
resolveMinInterval,
|
|
136
|
+
lockPath,
|
|
137
|
+
sentinelPath,
|
|
138
|
+
};
|
|
Binary file
|
|
@@ -5,12 +5,13 @@
|
|
|
5
5
|
* result (lib/pr-bundle.js) into the Markdown body of the single sticky PR
|
|
6
6
|
* comment the pr-monitor GitHub workflow keeps up to date.
|
|
7
7
|
*
|
|
8
|
-
* This is the SURFACE half of the monitor: it
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
8
|
+
* This is the SURFACE half of the monitor: it leads with the one-line actionable
|
|
9
|
+
* verdict (mirroring the `pr-verdict:*` label the workflow lands), then lists the
|
|
10
|
+
* unresolved review threads (grouped by author, ANY author) plus the failing and
|
|
11
|
+
* pending checks so async review-bot / human feedback in a window nobody is
|
|
12
|
+
* watching cannot rot. The verdict LABELS state (check-failed / threads-open /
|
|
13
|
+
* mergeable / …); it is NOT a merge action — it never merges, never resolves
|
|
14
|
+
* threads, never blocks, and is fail-closed (`unknown` on unreadable signals).
|
|
14
15
|
*
|
|
15
16
|
* Pure and deterministic: same bundle + same injected clock → same body, which
|
|
16
17
|
* is what lets the workflow rewrite the sticky comment in place without churn.
|
|
@@ -18,6 +19,33 @@
|
|
|
18
19
|
* @module pr-monitor/render-sticky
|
|
19
20
|
*/
|
|
20
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Presentation-only headline for each canonical merge verdict (lib/pr-pull.js).
|
|
24
|
+
* The verdict VALUE is computed once by pr-pull (`forge shepherd --pull --json`)
|
|
25
|
+
* and passed in — this map only decides how to DISPLAY it, so there is no second
|
|
26
|
+
* verdict ladder to drift.
|
|
27
|
+
*/
|
|
28
|
+
const VERDICT_HEADLINE = {
|
|
29
|
+
UNKNOWN: '⚪ **Verdict: `unknown`** — a signal was unreadable; state unconfirmed (fail-closed).',
|
|
30
|
+
'BLOCKED-CONFLICT': '🔀 **Verdict: `blocked-conflict`** — branch conflicts with base; rebase/merge and resolve.',
|
|
31
|
+
BEHIND: '⬇️ **Verdict: `behind`** — branch is behind base; update/rebase (protection requires up-to-date).',
|
|
32
|
+
'BLOCKED-CHECKS': '🔴 **Verdict: `blocked-checks`** — a required check is failing/missing; fix it.',
|
|
33
|
+
'BLOCKED-THREADS': '🟠 **Verdict: `blocked-threads`** — unresolved review threads need addressing.',
|
|
34
|
+
'REVIEW-PENDING': '🟡 **Verdict: `review-pending`** — awaiting review / settle window; not ready yet.',
|
|
35
|
+
'CLEAN-MERGEABLE': '🟢 **Verdict: `clean-mergeable`** — green + zero unresolved threads; ready for a human to merge.',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Render the one-line verdict headline for a canonical verdict string. Unknown or
|
|
40
|
+
* missing input falls closed to the `unknown` headline.
|
|
41
|
+
*
|
|
42
|
+
* @param {string} verdict
|
|
43
|
+
* @returns {string}
|
|
44
|
+
*/
|
|
45
|
+
function verdictHeadline(verdict) {
|
|
46
|
+
return VERDICT_HEADLINE[String(verdict || '').toUpperCase()] || VERDICT_HEADLINE.UNKNOWN;
|
|
47
|
+
}
|
|
48
|
+
|
|
21
49
|
/** Hidden HTML marker: the workflow finds its prior comment by this string and
|
|
22
50
|
* UPDATES it in place, so the monitor never spams a PR with new comments. */
|
|
23
51
|
const STICKY_MARKER = '<!-- forge-pr-monitor -->';
|
|
@@ -119,6 +147,10 @@ function renderChecks(bundle, lines) {
|
|
|
119
147
|
* @param {object} bundle - a `gatherPrBundle` result (lib/pr-bundle.js).
|
|
120
148
|
* @param {object} [opts]
|
|
121
149
|
* @param {Date} [opts.now] - injected clock for deterministic output.
|
|
150
|
+
* @param {string} [opts.verdict] - the canonical `--pull` verdict.
|
|
151
|
+
* @param {string[]} [opts.unreadable] - unreadable signal names (from the `--pull`
|
|
152
|
+
* evidence). Surfaced when the verdict is UNKNOWN so the sticky always says WHICH
|
|
153
|
+
* signal could not be read, not just a bare `unknown`.
|
|
122
154
|
* @returns {{ marker: string, body: string }}
|
|
123
155
|
*/
|
|
124
156
|
function renderStickyComment(bundle = {}, opts = {}) {
|
|
@@ -130,7 +162,23 @@ function renderStickyComment(bundle = {}, opts = {}) {
|
|
|
130
162
|
lines.push(STICKY_MARKER);
|
|
131
163
|
lines.push('## 🔭 Forge PR Monitor');
|
|
132
164
|
lines.push('');
|
|
133
|
-
|
|
165
|
+
// Lead with the actionable verdict — the SAME value as the pr-verdict:* label
|
|
166
|
+
// and `forge shepherd --pull --json` (passed in via opts.verdict, computed once
|
|
167
|
+
// by pr-pull). Surface only: it labels state; this monitor **does not merge**
|
|
168
|
+
// and never resolves review threads.
|
|
169
|
+
lines.push(verdictHeadline(opts.verdict));
|
|
170
|
+
// An `unknown` verdict is useless without the WHY — name the unreadable
|
|
171
|
+
// signal(s) so a human/agent knows what to fix (e.g. `requiredChecks` when both
|
|
172
|
+
// branch-protection AND the rollup fallback could not be read).
|
|
173
|
+
const verdictUpper = String(opts.verdict || '').toUpperCase();
|
|
174
|
+
const isUnknown = verdictUpper === 'UNKNOWN' || !VERDICT_HEADLINE[verdictUpper];
|
|
175
|
+
const unreadable = Array.isArray(opts.unreadable) ? opts.unreadable.filter(Boolean) : [];
|
|
176
|
+
if (isUnknown && unreadable.length > 0) {
|
|
177
|
+
lines.push('');
|
|
178
|
+
lines.push(`> Unreadable signal(s): ${unreadable.map((s) => `\`${s}\``).join(', ')}.`);
|
|
179
|
+
}
|
|
180
|
+
lines.push('');
|
|
181
|
+
lines.push('_Surfaces open review + check state so async feedback never rots. This monitor **does not merge** and never resolves review threads — a human merges in the GitHub UI._');
|
|
134
182
|
lines.push('');
|
|
135
183
|
|
|
136
184
|
renderThreads(bundle, lines);
|
|
@@ -143,13 +191,14 @@ function renderStickyComment(bundle = {}, opts = {}) {
|
|
|
143
191
|
}
|
|
144
192
|
|
|
145
193
|
lines.push('---');
|
|
146
|
-
lines.push(`<sub>Updated ${now.toISOString()} · surface-only monitor · never merges, never
|
|
194
|
+
lines.push(`<sub>Updated ${now.toISOString()} · surface-only monitor · labels state, never merges, never resolves threads.</sub>`);
|
|
147
195
|
|
|
148
196
|
return { marker: STICKY_MARKER, body: lines.join('\n') };
|
|
149
197
|
}
|
|
150
198
|
|
|
151
199
|
module.exports = {
|
|
152
200
|
renderStickyComment,
|
|
201
|
+
verdictHeadline,
|
|
153
202
|
groupByAuthor,
|
|
154
203
|
threadLocator,
|
|
155
204
|
STICKY_MARKER,
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shepherd singleton lease — a machine-wide "one watcher-set" guard for the PR
|
|
5
|
+
* shepherd, fusing two existing precedents:
|
|
6
|
+
*
|
|
7
|
+
* - `serve.lock`'s exclusive-create + foreign-PID block + stale reclaim
|
|
8
|
+
* (`lib/commands/_serve-security.js`), so a live foreign owner is never
|
|
9
|
+
* stolen but a dead/wedged one is reclaimed.
|
|
10
|
+
* - the journal lock's heartbeat + TTL staleness
|
|
11
|
+
* (`lib/pr-monitor/journal.js`), so a slow-but-alive owner refreshes its
|
|
12
|
+
* timestamp and a crashed owner ages out.
|
|
13
|
+
*
|
|
14
|
+
* The lock lives at `<gitCommonDir>/forge/shepherd.lock`, keyed by the SAME
|
|
15
|
+
* `resolveGitCommonDir` the kernel DB uses — so every worktree of a repo shares
|
|
16
|
+
* one lock. This module is the lease PRIMITIVE only: pure fs + I/O, no spawned
|
|
17
|
+
* process and no reconcile loop (the daemon wires those up later).
|
|
18
|
+
*
|
|
19
|
+
* Payload JSON: `{ pid, token, startedAt, heartbeatAt, watchers: [prNumbers] }`.
|
|
20
|
+
*
|
|
21
|
+
* ## Ownership is by TOKEN, not pid
|
|
22
|
+
* Each successful `acquire` mints a unique `token`. Every mutating op
|
|
23
|
+
* (`stamp`/`updateWatchers`/`release`) verifies that token against the on-disk
|
|
24
|
+
* lock before writing. A pid can be reused after a crash/reboot, and a wedged
|
|
25
|
+
* owner can revive after its lease was reclaimed — in both cases the token no
|
|
26
|
+
* longer matches, so the superseded holder can never resurrect or mutate a lease
|
|
27
|
+
* it no longer owns. Takeover of a stale lock is made atomic by an O_EXCL create
|
|
28
|
+
* (only one racer can win it), so two processes reclaiming the same stale lock
|
|
29
|
+
* can never both succeed.
|
|
30
|
+
*
|
|
31
|
+
* @module pr-monitor/shepherd-lease
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const fs = require('node:fs');
|
|
35
|
+
const path = require('node:path');
|
|
36
|
+
const crypto = require('node:crypto');
|
|
37
|
+
const { resolveGitCommonDir } = require('../kernel/broker');
|
|
38
|
+
|
|
39
|
+
/** A wedged owner whose heartbeat is older than this (ms) is reclaimable. */
|
|
40
|
+
const STALE_MS = 30000;
|
|
41
|
+
const LOCK_FILE_MODE = 0o600;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolve the shared lock path. `gitCommonDir` may be injected (tests, or a
|
|
45
|
+
* caller that already resolved it); otherwise it is resolved from `projectRoot`
|
|
46
|
+
* with the same resolver the kernel broker uses.
|
|
47
|
+
*/
|
|
48
|
+
function lockFilePath(projectRoot, opts = {}) {
|
|
49
|
+
const gitCommonDir = opts.gitCommonDir
|
|
50
|
+
? path.resolve(opts.gitCommonDir)
|
|
51
|
+
: resolveGitCommonDir(projectRoot, opts);
|
|
52
|
+
return path.join(gitCommonDir, 'forge', 'shepherd.lock');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Parse the lock payload, or null when missing/unreadable/corrupt. */
|
|
56
|
+
function readLock(file) {
|
|
57
|
+
try {
|
|
58
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Is `pid` a live process? `process.kill(pid, 0)` sends no signal but throws
|
|
65
|
+
// ESRCH when the pid is gone. EPERM means it exists but isn't ours — still live.
|
|
66
|
+
function pidAlive(pid) {
|
|
67
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
68
|
+
try {
|
|
69
|
+
process.kill(pid, 0);
|
|
70
|
+
return true;
|
|
71
|
+
} catch (err) {
|
|
72
|
+
return err.code === 'EPERM';
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Is a held lock stale (dead owner, or heartbeat older than STALE_MS)? */
|
|
77
|
+
function isHeldStale(held, { isAlive, now }) {
|
|
78
|
+
if (!isAlive(held.pid)) return true;
|
|
79
|
+
const beat = Date.parse(held.heartbeatAt);
|
|
80
|
+
if (!Number.isFinite(beat)) return true;
|
|
81
|
+
return now() - beat >= STALE_MS;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A lock is "ours" iff its unique lease `token` matches the one `acquire`
|
|
86
|
+
* returned. Token is authoritative: a pid can be reused after a crash/reboot and
|
|
87
|
+
* a wedged owner can revive after being reclaimed, so a pid match alone is NOT
|
|
88
|
+
* proof of ownership. Only a hypothetical legacy lock with no `token` field falls
|
|
89
|
+
* back to pid comparison.
|
|
90
|
+
*/
|
|
91
|
+
function ownsLock(held, { token, pid }) {
|
|
92
|
+
if (held.token) return token !== undefined && held.token === token;
|
|
93
|
+
return held.pid === pid;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function writeLock(file, payload) {
|
|
97
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
98
|
+
fs.writeFileSync(file, JSON.stringify(payload), { mode: LOCK_FILE_MODE });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Atomically create the lock with O_EXCL and write `payload`. Returns true when
|
|
103
|
+
* we won the create, false on EEXIST (a concurrent contender holds it); other
|
|
104
|
+
* errors rethrow. The O_EXCL create is the single atomic arbiter — of N racers
|
|
105
|
+
* attempting it against the same absent path, exactly one succeeds.
|
|
106
|
+
*/
|
|
107
|
+
function tryExclusiveCreate(file, payload) {
|
|
108
|
+
let fd;
|
|
109
|
+
try {
|
|
110
|
+
fd = fs.openSync(file, 'wx', LOCK_FILE_MODE);
|
|
111
|
+
} catch (err) {
|
|
112
|
+
if (err.code === 'EEXIST') return false;
|
|
113
|
+
throw err;
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
fs.writeSync(fd, JSON.stringify(payload));
|
|
117
|
+
} finally {
|
|
118
|
+
fs.closeSync(fd);
|
|
119
|
+
}
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Try to claim the singleton lease.
|
|
125
|
+
* -> { ok:true, file, token } first claim
|
|
126
|
+
* -> { ok:true, file, token, reclaimed } stale lock (dead/wedged owner) reclaimed
|
|
127
|
+
* -> { ok:false, held } a LIVE, FRESH foreign owner holds it
|
|
128
|
+
* -> { ok:false, held } we lost the atomic takeover race
|
|
129
|
+
*
|
|
130
|
+
* `pid`/`isAlive`/`now`/`token` are injectable for testing. `onBeforeTakeover` is
|
|
131
|
+
* a test seam invoked AFTER the stale lock is removed and BEFORE our exclusive
|
|
132
|
+
* re-create, so a test can simulate a competitor winning the O_EXCL create first
|
|
133
|
+
* (making our takeover lose).
|
|
134
|
+
*/
|
|
135
|
+
function acquire(projectRoot, {
|
|
136
|
+
gitCommonDir,
|
|
137
|
+
pid = process.pid,
|
|
138
|
+
isAlive = pidAlive,
|
|
139
|
+
now = () => Date.now(),
|
|
140
|
+
token = crypto.randomUUID(),
|
|
141
|
+
onBeforeTakeover = null,
|
|
142
|
+
} = {}) {
|
|
143
|
+
const file = lockFilePath(projectRoot, { gitCommonDir });
|
|
144
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
145
|
+
const iso = new Date(now()).toISOString();
|
|
146
|
+
const payload = { pid, token, startedAt: iso, heartbeatAt: iso, watchers: [] };
|
|
147
|
+
|
|
148
|
+
// Fast path: atomic exclusive create. Only ONE caller can win O_EXCL.
|
|
149
|
+
if (tryExclusiveCreate(file, payload)) {
|
|
150
|
+
return { ok: true, file, token };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// A lock exists. A DIFFERENT, live, fresh owner blocks us.
|
|
154
|
+
const held = readLock(file);
|
|
155
|
+
if (held && held.pid !== pid && !isHeldStale(held, { isAlive, now })) {
|
|
156
|
+
return { ok: false, held };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Stale (dead/wedged owner, unreadable, or already ours). Take over ATOMICALLY:
|
|
160
|
+
// remove the stale lock, then re-create it with O_EXCL. If a competitor
|
|
161
|
+
// recreated it first, our exclusive create fails (EEXIST) and we back off — so
|
|
162
|
+
// two racers reclaiming the same stale lock can NEVER both win. A wedged owner
|
|
163
|
+
// that revives after we delete its lock is stopped by the per-lease `token`
|
|
164
|
+
// guard on stamp()/updateWatchers()/release(), never able to resurrect it here.
|
|
165
|
+
fs.rmSync(file, { force: true });
|
|
166
|
+
if (typeof onBeforeTakeover === 'function') onBeforeTakeover();
|
|
167
|
+
if (!tryExclusiveCreate(file, payload)) {
|
|
168
|
+
return { ok: false, held: readLock(file) };
|
|
169
|
+
}
|
|
170
|
+
return { ok: true, file, token, reclaimed: true };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Refresh `heartbeatAt` on OUR lock. Returns false (a no-op) when the lock is
|
|
175
|
+
* missing or NOT ours by `token` — we never stamp a lease we no longer own, so a
|
|
176
|
+
* revived wedged owner cannot resurrect a reclaimed lease.
|
|
177
|
+
*/
|
|
178
|
+
function stamp(projectRoot, { gitCommonDir, token, pid = process.pid, now = () => Date.now() } = {}) {
|
|
179
|
+
const file = lockFilePath(projectRoot, { gitCommonDir });
|
|
180
|
+
const held = readLock(file);
|
|
181
|
+
if (!held || !ownsLock(held, { token, pid })) return false;
|
|
182
|
+
held.heartbeatAt = new Date(now()).toISOString();
|
|
183
|
+
writeLock(file, held);
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Start a heartbeat timer that stamps `heartbeatAt` every STALE_MS/3. The timer
|
|
189
|
+
* is `.unref()`ed so it never keeps the process alive. `opts` MUST carry the
|
|
190
|
+
* `token` returned by `acquire` (threaded straight through to `stamp`). Returns
|
|
191
|
+
* the handle for `stopHeartbeat`.
|
|
192
|
+
*/
|
|
193
|
+
function startHeartbeat(projectRoot, opts = {}) {
|
|
194
|
+
const timer = setInterval(() => stamp(projectRoot, opts), Math.max(1, Math.floor(STALE_MS / 3)));
|
|
195
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
196
|
+
return timer;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Stop a heartbeat timer started by `startHeartbeat`. */
|
|
200
|
+
function stopHeartbeat(timer) {
|
|
201
|
+
if (timer) clearInterval(timer);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Rewrite the `watchers[]` array on OUR lock. Returns false when the lock is
|
|
206
|
+
* missing or NOT ours by `token`.
|
|
207
|
+
*/
|
|
208
|
+
function updateWatchers(projectRoot, prNumbers, { gitCommonDir, token, pid = process.pid } = {}) {
|
|
209
|
+
const file = lockFilePath(projectRoot, { gitCommonDir });
|
|
210
|
+
const held = readLock(file);
|
|
211
|
+
if (!held || !ownsLock(held, { token, pid })) return false;
|
|
212
|
+
held.watchers = Array.isArray(prNumbers) ? prNumbers : [];
|
|
213
|
+
writeLock(file, held);
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Release the lease — delete the lock ONLY when it is ours by `token`, so a
|
|
219
|
+
* foreign or already-reclaimed lock is never removed out from under its owner
|
|
220
|
+
* (and a reused pid can never delete someone else's lease).
|
|
221
|
+
*/
|
|
222
|
+
function release(projectRoot, { gitCommonDir, token, pid = process.pid } = {}) {
|
|
223
|
+
const file = lockFilePath(projectRoot, { gitCommonDir });
|
|
224
|
+
try {
|
|
225
|
+
const held = readLock(file);
|
|
226
|
+
if (held && ownsLock(held, { token, pid })) fs.rmSync(file, { force: true });
|
|
227
|
+
} catch {
|
|
228
|
+
/* best effort — a failed release just leaves a stale lock to be reclaimed */
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
module.exports = {
|
|
233
|
+
STALE_MS,
|
|
234
|
+
lockFilePath,
|
|
235
|
+
pidAlive,
|
|
236
|
+
ownsLock,
|
|
237
|
+
acquire,
|
|
238
|
+
stamp,
|
|
239
|
+
startHeartbeat,
|
|
240
|
+
stopHeartbeat,
|
|
241
|
+
updateWatchers,
|
|
242
|
+
release,
|
|
243
|
+
};
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PR-monitor sticky-comment upsert — race-safe, converges to EXACTLY ONE sticky
|
|
5
|
+
* comment per PR even under a concurrent burst of workflow runs.
|
|
6
|
+
*
|
|
7
|
+
* Why this exists: the pr-monitor workflow deliberately has NO `concurrency:`
|
|
8
|
+
* group. A per-PR group does not help — GitHub's queue replacement cancels the
|
|
9
|
+
* previously-PENDING run in a group UNCONDITIONALLY (independent of
|
|
10
|
+
* cancel-in-progress), and this workflow's triggers (check_suite:completed fires
|
|
11
|
+
* ~10+ times per push, plus reviews/comments) burst hard, so a group left a trail
|
|
12
|
+
* of CANCELLED runs that render as red/non-SUCCESS checks and tripped merge-gate
|
|
13
|
+
* tooling (kernel issue 97e6a146). Dropping the group removes the cancellations,
|
|
14
|
+
* but then two concurrent first-runs on a PR with no sticky yet would BOTH find
|
|
15
|
+
* nothing and BOTH create one → duplicate sticky comments. This module closes
|
|
16
|
+
* that race deterministically instead.
|
|
17
|
+
*
|
|
18
|
+
* Reconcile-to-one algorithm:
|
|
19
|
+
* 1. List marker comments. If none, create one, then RE-LIST (a concurrent run
|
|
20
|
+
* may have created its own in the same burst).
|
|
21
|
+
* 2. Pick the deterministic survivor: the LOWEST comment id (oldest). Every
|
|
22
|
+
* concurrent run picks the SAME survivor, so they never fight.
|
|
23
|
+
* 3. Update the survivor with the latest body; delete every other marker
|
|
24
|
+
* comment. Deletes are idempotent (a 404 means a peer already removed it).
|
|
25
|
+
*
|
|
26
|
+
* A create that lands AFTER a run's re-list is self-healed by the next event:
|
|
27
|
+
* every run reconciles to one, and events keep arriving, so the PR converges to a
|
|
28
|
+
* single sticky comment.
|
|
29
|
+
*
|
|
30
|
+
* @module pr-monitor/upsert-sticky
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const { execFileSync } = require('node:child_process');
|
|
34
|
+
|
|
35
|
+
/** Ids of comments whose body carries the sticky marker. */
|
|
36
|
+
function markerCommentIds(comments, marker) {
|
|
37
|
+
return (Array.isArray(comments) ? comments : [])
|
|
38
|
+
.filter((comment) => typeof comment.body === 'string' && comment.body.includes(marker))
|
|
39
|
+
.map((comment) => comment.id);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Ascending by numeric id, so the survivor (index 0) is the oldest comment. */
|
|
43
|
+
function sortIdsAscending(ids) {
|
|
44
|
+
return [...ids].sort((left, right) => Number(left) - Number(right));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Drive a client to exactly one sticky comment. `client` abstracts the GitHub
|
|
49
|
+
* calls so the reconcile logic is unit-testable without the network:
|
|
50
|
+
* - list() → array of { id, body }
|
|
51
|
+
* - create() → create a new sticky comment (body supplied by the client)
|
|
52
|
+
* - update(id) → overwrite comment `id` with the latest body
|
|
53
|
+
* - remove(id) → delete comment `id` (must tolerate an already-deleted 404)
|
|
54
|
+
*
|
|
55
|
+
* @returns {Promise<{ survivor: (number|string|null), deleted: Array<number|string> }>}
|
|
56
|
+
*/
|
|
57
|
+
async function upsertStickyComment({ marker }, client) {
|
|
58
|
+
let ids = markerCommentIds(await client.list(), marker);
|
|
59
|
+
|
|
60
|
+
if (ids.length === 0) {
|
|
61
|
+
await client.create();
|
|
62
|
+
// Re-list: a concurrent run may have created its own sticky in this burst.
|
|
63
|
+
ids = markerCommentIds(await client.list(), marker);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (ids.length === 0) {
|
|
67
|
+
// The just-created comment is not visible yet (eventual consistency); its
|
|
68
|
+
// body is already correct, and the next event will reconcile if a peer raced.
|
|
69
|
+
return { survivor: null, deleted: [] };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
ids = sortIdsAscending(ids);
|
|
73
|
+
const survivor = ids[0];
|
|
74
|
+
await client.update(survivor);
|
|
75
|
+
|
|
76
|
+
const deleted = [];
|
|
77
|
+
for (const id of ids.slice(1)) {
|
|
78
|
+
await client.remove(id);
|
|
79
|
+
deleted.push(id);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return { survivor, deleted };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Single choke point for the `gh` CLI, matching lib/commands/merge.js. `gh` is a
|
|
86
|
+
// hardcoded literal (never user input) and args are an array (no shell), so the
|
|
87
|
+
// S4036 PATH-search finding is a false positive in this developer-tool context;
|
|
88
|
+
// one annotation here covers every call site. `encoding: 'utf8'` also pipes
|
|
89
|
+
// stderr onto the thrown error, so isAlreadyGone() below can classify failures.
|
|
90
|
+
function runGh(args) {
|
|
91
|
+
return execFileSync('gh', args, { encoding: 'utf8', windowsHide: true }); // NOSONAR S4036 - hardcoded CLI (gh), args array (no shell), developer-tool context. windowsHide: sticky upsert runs in the detached watcher (issue 931e7924).
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* True only when a `gh api` failure means the target comment is already absent
|
|
96
|
+
* (HTTP 404 / 410) — the benign "a peer got there first" case. Auth failures,
|
|
97
|
+
* rate limits (403), and every other error return false so they propagate and
|
|
98
|
+
* surface a diagnostic instead of silently breaking the exactly-one invariant.
|
|
99
|
+
*/
|
|
100
|
+
function isAlreadyGone(error) {
|
|
101
|
+
const text = `${error && error.stderr ? error.stderr : ''} ${error && error.message ? error.message : ''}`;
|
|
102
|
+
return /HTTP 404|HTTP 410|\bNot Found\b|\bGone\b/i.test(text);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* GitHub-backed client (shells to `gh api`, like lib/pr-monitor/gather.js). The
|
|
107
|
+
* reconcile logic above is unit-tested; the error-tolerance in update/remove is
|
|
108
|
+
* too, via an injectable `run`. create/update both send the same pre-rendered
|
|
109
|
+
* payload file the render step wrote, so the body is identical.
|
|
110
|
+
*/
|
|
111
|
+
function ghStickyClient({ repo, pr, payloadFile, run = runGh }) {
|
|
112
|
+
return {
|
|
113
|
+
async list() {
|
|
114
|
+
const out = run(['api', `repos/${repo}/issues/${pr}/comments`, '--paginate']);
|
|
115
|
+
return out && out.trim() ? JSON.parse(out) : [];
|
|
116
|
+
},
|
|
117
|
+
async create() {
|
|
118
|
+
run(['api', '-X', 'POST', `repos/${repo}/issues/${pr}/comments`, '--input', payloadFile]);
|
|
119
|
+
},
|
|
120
|
+
async update(id) {
|
|
121
|
+
try {
|
|
122
|
+
run(['api', '-X', 'PATCH', `repos/${repo}/issues/comments/${id}`, '--input', payloadFile]);
|
|
123
|
+
} catch (error) {
|
|
124
|
+
if (!isAlreadyGone(error)) {
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
// Our chosen survivor was deleted by a peer whose survivor had a lower id:
|
|
128
|
+
// the peer's sticky wins, exactly-one still holds, so we are done.
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
async remove(id) {
|
|
132
|
+
try {
|
|
133
|
+
run(['api', '-X', 'DELETE', `repos/${repo}/issues/comments/${id}`]);
|
|
134
|
+
} catch (error) {
|
|
135
|
+
if (!isAlreadyGone(error)) {
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
// Already gone (a concurrent run deleted it first) — the goal state holds.
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function main() {
|
|
145
|
+
const repo = process.env.GH_REPO;
|
|
146
|
+
const pr = process.env.PR;
|
|
147
|
+
const payloadFile = process.env.STICKY_PAYLOAD_FILE || 'monitor-payload.json';
|
|
148
|
+
const { STICKY_MARKER } = require('./render-sticky');
|
|
149
|
+
|
|
150
|
+
const client = ghStickyClient({ repo, pr, payloadFile });
|
|
151
|
+
const { survivor, deleted } = await upsertStickyComment({ marker: STICKY_MARKER }, client);
|
|
152
|
+
const survivorLabel = survivor === null ? 'created (not yet visible)' : survivor;
|
|
153
|
+
console.log(`Sticky comment reconciled to one: survivor=${survivorLabel}, deleted=${deleted.length}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (require.main === module) {
|
|
157
|
+
main().catch((error) => {
|
|
158
|
+
console.error(error.message);
|
|
159
|
+
process.exit(1);
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
module.exports = {
|
|
164
|
+
upsertStickyComment,
|
|
165
|
+
markerCommentIds,
|
|
166
|
+
sortIdsAscending,
|
|
167
|
+
isAlreadyGone,
|
|
168
|
+
ghStickyClient,
|
|
169
|
+
};
|
|
@@ -35,7 +35,7 @@ function forgeBin() {
|
|
|
35
35
|
function defaultResolveSlug({ cwd, exec = execFileSync }) {
|
|
36
36
|
try {
|
|
37
37
|
const url = exec('git', ['remote', 'get-url', 'origin'], {
|
|
38
|
-
cwd, encoding: 'utf8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'],
|
|
38
|
+
cwd, encoding: 'utf8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true,
|
|
39
39
|
}).trim();
|
|
40
40
|
const match = /[/:][^/]+\/([^/]+?)(?:\.git)?$/.exec(url);
|
|
41
41
|
return match ? match[1] : null;
|