supipowers 2.0.1 → 2.1.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.
- package/README.md +10 -6
- package/package.json +4 -2
- package/skills/harness/SKILL.md +1 -0
- package/src/bootstrap.ts +5 -133
- package/src/commands/clear.ts +6 -6
- package/src/commands/release.ts +3 -1
- package/src/commands/update.ts +1 -1
- package/src/config/defaults.ts +5 -5
- package/src/config/loader.ts +1 -0
- package/src/config/schema.ts +2 -6
- package/src/context/analyzer.ts +104 -35
- package/src/context-mode/knowledge/store.ts +381 -43
- package/src/context-mode/tools.ts +41 -3
- package/src/deps/registry.ts +1 -12
- package/src/fix-pr/assessment.ts +1 -0
- package/src/fix-pr/prompt-builder.ts +1 -0
- package/src/git/commit.ts +76 -18
- package/src/harness/command.ts +103 -6
- package/src/harness/default-agents/docs.md +39 -0
- package/src/harness/docs/config.ts +29 -0
- package/src/harness/docs/glob-match.ts +27 -0
- package/src/harness/docs/index-renderer.ts +82 -0
- package/src/harness/docs/provenance.ts +125 -0
- package/src/harness/docs/regen-decision.ts +167 -0
- package/src/harness/docs/representative-files.ts +175 -0
- package/src/harness/docs/source-hash.ts +106 -0
- package/src/harness/docs/validator.ts +233 -0
- package/src/harness/hooks/layer-context-inject.ts +35 -1
- package/src/harness/hooks/register.ts +24 -3
- package/src/harness/pipeline.ts +20 -5
- package/src/harness/pr-comment/baseline.ts +105 -0
- package/src/harness/pr-comment/ci-env.ts +120 -0
- package/src/harness/pr-comment/gh-poster.ts +227 -0
- package/src/harness/pr-comment/handler.ts +198 -0
- package/src/harness/pr-comment/render.ts +297 -0
- package/src/harness/pr-comment/status.ts +95 -0
- package/src/harness/pr-comment/types.ts +73 -0
- package/src/harness/pr-comment/workflow-summary.ts +47 -0
- package/src/harness/project-paths.ts +95 -0
- package/src/harness/stages/design.ts +1 -0
- package/src/harness/stages/discover.ts +1 -13
- package/src/harness/stages/docs.ts +708 -0
- package/src/harness/stages/implement-apply.ts +877 -0
- package/src/harness/stages/implement.ts +64 -51
- package/src/harness/stages/plan.ts +25 -16
- package/src/harness/stages/validate.ts +370 -0
- package/src/harness/storage.ts +142 -0
- package/src/harness/tools.ts +130 -0
- package/src/mempalace/bridge.ts +207 -41
- package/src/mempalace/config.ts +10 -4
- package/src/mempalace/format.ts +122 -6
- package/src/mempalace/hooks.ts +204 -56
- package/src/mempalace/installer-helper.ts +18 -4
- package/src/mempalace/python/mempalace_bridge.py +128 -3
- package/src/mempalace/runtime.ts +55 -18
- package/src/mempalace/schema.ts +151 -30
- package/src/mempalace/session-summary.ts +5 -0
- package/src/mempalace/tool.ts +17 -4
- package/src/mempalace/upstream-limits.ts +69 -0
- package/src/planning/approval-flow.ts +25 -2
- package/src/planning/planning-ask-tool.ts +34 -4
- package/src/planning/system-prompt.ts +1 -1
- package/src/tool-catalog/active-tool-controller.ts +0 -22
- package/src/tool-catalog/active-tool-planner.ts +0 -26
- package/src/tool-catalog/tool-groups.ts +1 -9
- package/src/types.ts +87 -8
- package/src/ui-design/session.ts +114 -10
- package/src/utils/executable.ts +10 -1
- package/src/workspace/state-paths.ts +1 -1
- package/src/commands/mcp.ts +0 -814
- package/src/mcp/activation.ts +0 -77
- package/src/mcp/config.ts +0 -223
- package/src/mcp/docs.ts +0 -154
- package/src/mcp/gateway.ts +0 -103
- package/src/mcp/lifecycle.ts +0 -79
- package/src/mcp/manager-tool.ts +0 -104
- package/src/mcp/mcpc.ts +0 -113
- package/src/mcp/registry.ts +0 -98
- package/src/mcp/triggers.ts +0 -62
- package/src/mcp/types.ts +0 -95
|
@@ -31,6 +31,8 @@ import {
|
|
|
31
31
|
getHarnessArchitectureDocPath,
|
|
32
32
|
getHarnessMarkerPath,
|
|
33
33
|
} from "../project-paths.js";
|
|
34
|
+
import { extractAgentContextSection } from "../docs/validator.js";
|
|
35
|
+
import { parseProvenance } from "../docs/provenance.js";
|
|
34
36
|
|
|
35
37
|
export interface LayerContextHookOptions {
|
|
36
38
|
/**
|
|
@@ -86,6 +88,13 @@ export interface LayerContextInjectionResult {
|
|
|
86
88
|
/**
|
|
87
89
|
* Compute the addendum for a single hook invocation. Pure-ish: reads the file system but
|
|
88
90
|
* never mutates state. Tests call this directly with a known cwd + candidate file.
|
|
91
|
+
*
|
|
92
|
+
* Resolution order:
|
|
93
|
+
* 1. If `docs/layers/<layerId>.md` exists, extract its `## Agent context` section and
|
|
94
|
+
* return it (capped at `addendum_max_chars`). This is the preferred path once the
|
|
95
|
+
* docs stage has run.
|
|
96
|
+
* 2. Otherwise, fall back to the architecture-doc-derived addendum so projects that
|
|
97
|
+
* have not generated per-layer docs still receive a useful reminder.
|
|
89
98
|
*/
|
|
90
99
|
export function computeLayerAddendum(input: {
|
|
91
100
|
cwd: string;
|
|
@@ -93,6 +102,8 @@ export function computeLayerAddendum(input: {
|
|
|
93
102
|
config: HarnessHookConfig["layer_context_inject"];
|
|
94
103
|
/** Override the resolved architecture-doc path; tests use this to point at a fixture. */
|
|
95
104
|
archPath?: string;
|
|
105
|
+
/** Override the resolved per-layer doc path; tests use this to point at a fixture. */
|
|
106
|
+
layerDocPath?: (layerId: string) => string;
|
|
96
107
|
}): LayerContextInjectionResult {
|
|
97
108
|
if (!input.config.enabled) return { addendum: "", reason: "disabled" };
|
|
98
109
|
if (!input.candidateFile) return { addendum: "", reason: "no candidate file" };
|
|
@@ -101,8 +112,31 @@ export function computeLayerAddendum(input: {
|
|
|
101
112
|
if (rules.length === 0) return { addendum: "", reason: "no rules parsed" };
|
|
102
113
|
const rule = resolveLayerForFile(input.candidateFile, rules);
|
|
103
114
|
if (!rule) return { addendum: "", reason: "no rule matches candidate file" };
|
|
115
|
+
|
|
116
|
+
// Preferred path: per-layer agent doc.
|
|
117
|
+
const docPath = input.layerDocPath
|
|
118
|
+
? input.layerDocPath(rule.layer)
|
|
119
|
+
: `${input.cwd}/docs/layers/${rule.layer}.md`;
|
|
120
|
+
if (fs.existsSync(docPath)) {
|
|
121
|
+
try {
|
|
122
|
+
const contents = fs.readFileSync(docPath, "utf8");
|
|
123
|
+
const parsed = parseProvenance(contents);
|
|
124
|
+
const body = parsed ? parsed.body : contents;
|
|
125
|
+
const section = extractAgentContextSection(body);
|
|
126
|
+
if (section.length > 0) {
|
|
127
|
+
const cap = input.config.addendum_max_chars;
|
|
128
|
+
const capped = section.length <= cap
|
|
129
|
+
? section
|
|
130
|
+
: `${section.slice(0, Math.max(0, cap - 1))}…`;
|
|
131
|
+
return { addendum: capped, reason: "matched (per-layer doc)" };
|
|
132
|
+
}
|
|
133
|
+
} catch {
|
|
134
|
+
// fall through to architecture-doc fallback on any read error
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
104
138
|
const addendum = buildLayerAddendum(input.candidateFile, rule, input.config.addendum_max_chars);
|
|
105
|
-
return { addendum, reason: "matched" };
|
|
139
|
+
return { addendum, reason: "matched (architecture.md fallback)" };
|
|
106
140
|
}
|
|
107
141
|
|
|
108
142
|
/**
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import type { Platform } from "../../platform/types.js";
|
|
15
|
-
import type { HarnessConfig, HarnessHookConfig } from "../../types.js";
|
|
15
|
+
import type { HarnessConfig, HarnessDocsConfig, HarnessHookConfig } from "../../types.js";
|
|
16
16
|
import { buildBackendAdapter } from "../anti_slop/backend-factory.js";
|
|
17
17
|
import {
|
|
18
18
|
registerLayerContextInjectHook,
|
|
@@ -31,9 +31,21 @@ export const DEFAULT_HARNESS_HOOK_CONFIG: HarnessHookConfig = {
|
|
|
31
31
|
score_floor: { strict: 75, lenient: 90, release_blocking: false },
|
|
32
32
|
};
|
|
33
33
|
|
|
34
|
+
export const DEFAULT_HARNESS_DOCS_CONFIG: HarnessDocsConfig = {
|
|
35
|
+
tier: "simple",
|
|
36
|
+
max_per_doc_loc: 150,
|
|
37
|
+
agent_context_loc: 30,
|
|
38
|
+
max_index_loc: 50,
|
|
39
|
+
max_units: 12,
|
|
40
|
+
max_concurrent_subagents: null,
|
|
41
|
+
drift_warning: { enabled: true },
|
|
42
|
+
regen_preview_threshold: 1,
|
|
43
|
+
};
|
|
44
|
+
|
|
34
45
|
export const DEFAULT_HARNESS_CONFIG: HarnessConfig = {
|
|
35
46
|
anti_slop: DEFAULT_HARNESS_HOOK_CONFIG,
|
|
36
47
|
implement_in_session_threshold: 10,
|
|
48
|
+
docs: DEFAULT_HARNESS_DOCS_CONFIG,
|
|
37
49
|
};
|
|
38
50
|
|
|
39
51
|
export interface HarnessHookRegistration {
|
|
@@ -54,19 +66,28 @@ export interface RegisterHooksOptions {
|
|
|
54
66
|
* unless a real resolver is wired).
|
|
55
67
|
*/
|
|
56
68
|
resolveCandidateFile?: (event: unknown, ctx: unknown) => string | null;
|
|
69
|
+
/** CWD whose repo-local marker controls registration. Defaults to process.cwd(). */
|
|
70
|
+
cwd?: string;
|
|
57
71
|
}
|
|
58
72
|
|
|
59
73
|
// Re-export so existing call sites keep working without an import path change.
|
|
60
74
|
export { buildBackendAdapter };
|
|
61
75
|
|
|
62
76
|
/**
|
|
63
|
-
* Register every harness hook.
|
|
64
|
-
*
|
|
77
|
+
* Register every harness hook. Hooks subscribe unconditionally at bootstrap time; each
|
|
78
|
+
* hook checks the repo-local marker per event, so creating the marker after install
|
|
79
|
+
* activates already-registered handlers without an OMP restart, and removing the marker
|
|
80
|
+
* disables them. `dispose()` is idempotent.
|
|
81
|
+
*
|
|
82
|
+
* The `cwd` option is retained for tests that exercise the legacy marker check; it is
|
|
83
|
+
* unused by the new registration path because per-event handlers resolve cwd from the
|
|
84
|
+
* event payload.
|
|
65
85
|
*/
|
|
66
86
|
export function registerHarnessHooks(
|
|
67
87
|
platform: Platform,
|
|
68
88
|
options: RegisterHooksOptions = {},
|
|
69
89
|
): HarnessHookRegistration {
|
|
90
|
+
void options.cwd; // reserved for future per-repo gating
|
|
70
91
|
const backend = options.backend ?? "fallow";
|
|
71
92
|
const hooks = options.hooks ?? DEFAULT_HARNESS_HOOK_CONFIG;
|
|
72
93
|
const adapter = buildBackendAdapter(backend);
|
package/src/harness/pipeline.ts
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
} from "./stages/design.js";
|
|
32
32
|
import { HarnessPlanStage, type PlanStageInput } from "./stages/plan.js";
|
|
33
33
|
import { HarnessImplementStage, type ImplementStageInput } from "./stages/implement.js";
|
|
34
|
+
import { HarnessDocsStage, type DocsStageInput } from "./stages/docs.js";
|
|
34
35
|
import { HarnessValidateStage, type ValidateStageInput } from "./stages/validate.js";
|
|
35
36
|
import { loadHarnessDesignSpecJson, loadHarnessDiscover } from "./storage.js";
|
|
36
37
|
import { buildBackendAdapter } from "./anti_slop/backend-factory.js";
|
|
@@ -52,6 +53,7 @@ const STAGE_ORDER: readonly HarnessStage[] = [
|
|
|
52
53
|
"design",
|
|
53
54
|
"plan",
|
|
54
55
|
"implement",
|
|
56
|
+
"docs",
|
|
55
57
|
"validate",
|
|
56
58
|
];
|
|
57
59
|
|
|
@@ -60,6 +62,7 @@ const GATE_STAGES_DEFAULT: ReadonlySet<HarnessStage> = new Set([
|
|
|
60
62
|
"discover",
|
|
61
63
|
"design",
|
|
62
64
|
"plan",
|
|
65
|
+
"docs",
|
|
63
66
|
"validate",
|
|
64
67
|
]);
|
|
65
68
|
const GATE_STAGES_MANUAL: ReadonlySet<HarnessStage> = new Set([
|
|
@@ -68,6 +71,7 @@ const GATE_STAGES_MANUAL: ReadonlySet<HarnessStage> = new Set([
|
|
|
68
71
|
"design",
|
|
69
72
|
"plan",
|
|
70
73
|
"implement",
|
|
74
|
+
"docs",
|
|
71
75
|
"validate",
|
|
72
76
|
]);
|
|
73
77
|
|
|
@@ -89,6 +93,8 @@ export interface BuildRunnerInput {
|
|
|
89
93
|
planInput?: PlanStageInput;
|
|
90
94
|
/** Required when running the implement stage. */
|
|
91
95
|
implementInput?: ImplementStageInput;
|
|
96
|
+
/** Optional override for the docs stage (tier, max-units, test-only factories). */
|
|
97
|
+
docsInput?: DocsStageInput;
|
|
92
98
|
/** Required when running the validate stage. */
|
|
93
99
|
validateInput?: ValidateStageInput;
|
|
94
100
|
}
|
|
@@ -111,6 +117,8 @@ export function buildHarnessRunner(stage: HarnessStage, input: BuildRunnerInput)
|
|
|
111
117
|
throw new Error("buildHarnessRunner: implement stage requires implementInput");
|
|
112
118
|
}
|
|
113
119
|
return new HarnessImplementStage(input.implementInput);
|
|
120
|
+
case "docs":
|
|
121
|
+
return new HarnessDocsStage(input.docsInput ?? {});
|
|
114
122
|
case "validate":
|
|
115
123
|
if (!input.validateInput) {
|
|
116
124
|
throw new Error("buildHarnessRunner: validate stage requires validateInput");
|
|
@@ -215,6 +223,15 @@ function formatStageDetail(result: HarnessStageRunResult): string {
|
|
|
215
223
|
const layers = typeof d.layerCount === "number" ? `${d.layerCount} layers` : "";
|
|
216
224
|
return layers ? `${backend} · ${layers}` : `${backend}`;
|
|
217
225
|
}
|
|
226
|
+
if (result.stage === "docs") {
|
|
227
|
+
const regen = Array.isArray(d.regenerated) ? (d.regenerated as string[]).length : 0;
|
|
228
|
+
const skip = Array.isArray(d.skipped) ? (d.skipped as string[]).length : 0;
|
|
229
|
+
const user = Array.isArray(d.userEdited) ? (d.userEdited as string[]).length : 0;
|
|
230
|
+
if (typeof d.tier === "string" && d.tier === "extensive") {
|
|
231
|
+
return `${regen} regen · ${skip} skip${user > 0 ? ` · ${user} user-edited` : ""}`;
|
|
232
|
+
}
|
|
233
|
+
if (typeof d.reason === "string") return d.reason;
|
|
234
|
+
}
|
|
218
235
|
if (result.stage === "validate" && typeof d.passed === "boolean") {
|
|
219
236
|
return d.passed ? "passed" : "issues found";
|
|
220
237
|
}
|
|
@@ -279,9 +296,9 @@ export async function runHarnessPipelineUntilGate(
|
|
|
279
296
|
|
|
280
297
|
const result = await runner.run(ctx);
|
|
281
298
|
|
|
282
|
-
// In auto mode, awaiting-user is equivalent to
|
|
283
|
-
//
|
|
284
|
-
//
|
|
299
|
+
// In auto mode, awaiting-user from authoring stages (design, etc.) is equivalent to
|
|
300
|
+
// completed: the artifact is on disk and the next stage can consume it. Gates honor
|
|
301
|
+
// awaiting-user as a real stop signal.
|
|
285
302
|
const isGate = gateStages.has(stage);
|
|
286
303
|
const normalizedStatus: HarnessStageRunResult["status"] =
|
|
287
304
|
result.status === "awaiting-user" && !isGate
|
|
@@ -307,8 +324,6 @@ export async function runHarnessPipelineUntilGate(
|
|
|
307
324
|
};
|
|
308
325
|
}
|
|
309
326
|
|
|
310
|
-
// In auto mode, awaiting-user is equivalent to completed — the pipeline
|
|
311
|
-
// continues without stopping. Only surface the distinction when gated.
|
|
312
327
|
if (normalizedStatus === "awaiting-user" && isGate) {
|
|
313
328
|
input.onProgress?.({ type: "awaiting-user", stage, detail: awaitUserDetail(result) });
|
|
314
329
|
} else {
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Load the trend baseline from `score-history.jsonl`.
|
|
3
|
+
*
|
|
4
|
+
* The validate stage appends one record per run to this file. We split that history
|
|
5
|
+
* into:
|
|
6
|
+
* - `previousScore`: the most recent prior entry (so we can compute a delta vs the
|
|
7
|
+
* score we just wrote), or null when there is nothing to compare against;
|
|
8
|
+
* - `trend`: the last N entries oldest-first, for the inline sparkline.
|
|
9
|
+
*
|
|
10
|
+
* Score-history v1 records are `{ recordedAt, sessionId, strict, lenient }` (see
|
|
11
|
+
* `src/harness/stages/validate.ts`). Per-dimension breakdowns are NOT persisted, so we
|
|
12
|
+
* surface them as `undefined` and the renderer shows "—" for the dimension Δ column.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { PlatformPaths } from "../../platform/types.js";
|
|
16
|
+
import type { UltraPlanStorageResult } from "../../types.js";
|
|
17
|
+
import { readJsonl } from "../storage.js";
|
|
18
|
+
import { getHarnessScoreHistoryPath } from "../project-paths.js";
|
|
19
|
+
import type { PrCommentPreviousScore, PrCommentTrendPoint } from "./types.js";
|
|
20
|
+
|
|
21
|
+
/** Raw score-history record as written by Validate. */
|
|
22
|
+
interface ScoreHistoryRecord {
|
|
23
|
+
recordedAt: string;
|
|
24
|
+
sessionId: string;
|
|
25
|
+
strict: number;
|
|
26
|
+
lenient: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface Baseline {
|
|
30
|
+
/** Most recent prior entry. null when history is empty or has only one record. */
|
|
31
|
+
previousScore: PrCommentPreviousScore | null;
|
|
32
|
+
/** Last `limit` entries, oldest first. Empty when no history. */
|
|
33
|
+
trend: readonly PrCommentTrendPoint[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const DEFAULT_TREND_LIMIT = 5;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Read score-history.jsonl and split it into (previous, trend).
|
|
40
|
+
*
|
|
41
|
+
* `currentSessionId` is what just ran — we drop ALL trailing records that match it so we
|
|
42
|
+
* never compare a score against itself, even when validate is re-run for the same session.
|
|
43
|
+
*
|
|
44
|
+
* Returns an empty baseline (`previousScore: null`, `trend: []`) when the history file is
|
|
45
|
+
* missing or unreadable. We deliberately swallow IO errors here: a corrupted history file
|
|
46
|
+
* should degrade gracefully to "no baseline" rather than block PR comment generation.
|
|
47
|
+
*/
|
|
48
|
+
export function loadBaseline(
|
|
49
|
+
paths: PlatformPaths,
|
|
50
|
+
cwd: string,
|
|
51
|
+
options: { currentSessionId?: string; limit?: number } = {},
|
|
52
|
+
): Baseline {
|
|
53
|
+
const limit = options.limit ?? DEFAULT_TREND_LIMIT;
|
|
54
|
+
const result: UltraPlanStorageResult<ScoreHistoryRecord[]> = readJsonl<ScoreHistoryRecord>(
|
|
55
|
+
getHarnessScoreHistoryPath(paths, cwd),
|
|
56
|
+
);
|
|
57
|
+
if (!result.ok) {
|
|
58
|
+
return { previousScore: null, trend: [] };
|
|
59
|
+
}
|
|
60
|
+
const records = result.value.filter((record) => isWellFormed(record));
|
|
61
|
+
|
|
62
|
+
// Strip the trailing run(s) that belong to the current session so we compare against the
|
|
63
|
+
// PRIOR run. When currentSessionId is omitted (local dry-run with no session context),
|
|
64
|
+
// we treat the most recent record as the baseline.
|
|
65
|
+
let priorEnd = records.length;
|
|
66
|
+
if (options.currentSessionId) {
|
|
67
|
+
while (priorEnd > 0 && records[priorEnd - 1].sessionId === options.currentSessionId) {
|
|
68
|
+
priorEnd -= 1;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const previousRecord = priorEnd > 0 ? records[priorEnd - 1] : null;
|
|
73
|
+
const previousScore: PrCommentPreviousScore | null = previousRecord
|
|
74
|
+
? {
|
|
75
|
+
recordedAt: previousRecord.recordedAt,
|
|
76
|
+
strict: previousRecord.strict,
|
|
77
|
+
lenient: previousRecord.lenient,
|
|
78
|
+
}
|
|
79
|
+
: null;
|
|
80
|
+
|
|
81
|
+
// Trend is the last `limit` records oldest-first. We include the current run so the
|
|
82
|
+
// sparkline ends with the just-computed score; the renderer can choose whether to
|
|
83
|
+
// highlight it.
|
|
84
|
+
const trendSlice = records.slice(Math.max(0, records.length - limit));
|
|
85
|
+
const trend: PrCommentTrendPoint[] = trendSlice.map((record) => ({
|
|
86
|
+
ts: record.recordedAt,
|
|
87
|
+
strict: record.strict,
|
|
88
|
+
lenient: record.lenient,
|
|
89
|
+
}));
|
|
90
|
+
|
|
91
|
+
return { previousScore, trend };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function isWellFormed(record: unknown): record is ScoreHistoryRecord {
|
|
95
|
+
if (record === null || typeof record !== "object") return false;
|
|
96
|
+
const r = record as Record<string, unknown>;
|
|
97
|
+
return (
|
|
98
|
+
typeof r.recordedAt === "string" &&
|
|
99
|
+
typeof r.sessionId === "string" &&
|
|
100
|
+
typeof r.strict === "number" &&
|
|
101
|
+
typeof r.lenient === "number" &&
|
|
102
|
+
Number.isFinite(r.strict) &&
|
|
103
|
+
Number.isFinite(r.lenient)
|
|
104
|
+
);
|
|
105
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub Actions environment detection for the PR comment subcommand.
|
|
3
|
+
*
|
|
4
|
+
* The harness PR comment workflow runs in two contexts:
|
|
5
|
+
* - inside GitHub Actions on a `pull_request` event (real CI run), and
|
|
6
|
+
* - locally for `--dry-run` previews and ad-hoc testing.
|
|
7
|
+
*
|
|
8
|
+
* This module owns the detection of the former. It deliberately does no IO except reading
|
|
9
|
+
* a single event JSON file when `GITHUB_EVENT_PATH` is provided.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import * as fs from "node:fs";
|
|
13
|
+
|
|
14
|
+
export interface CiContext {
|
|
15
|
+
/** "owner/repo" — extracted from GITHUB_REPOSITORY or supplied via flag. */
|
|
16
|
+
repo: string;
|
|
17
|
+
/** PR number — from the event payload or the --pr flag. */
|
|
18
|
+
prNumber: number;
|
|
19
|
+
/** Optional run URL, used in the comment footer. */
|
|
20
|
+
runUrl?: string;
|
|
21
|
+
/** Optional base ref, e.g. "main@a1b2c3d", used in the summary line. */
|
|
22
|
+
baseRef?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Manual overrides parsed from CLI flags; flag values win over env. */
|
|
26
|
+
export interface CiContextOverrides {
|
|
27
|
+
repo?: string;
|
|
28
|
+
prNumber?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Detect the CI context from environment variables, applying optional overrides on top.
|
|
33
|
+
*
|
|
34
|
+
* Returns null when neither the env nor the overrides produce a complete `{repo, prNumber}`
|
|
35
|
+
* pair — that's how the handler decides to fall back to the workflow summary.
|
|
36
|
+
*/
|
|
37
|
+
export function detectCiContext(
|
|
38
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
39
|
+
overrides: CiContextOverrides = {},
|
|
40
|
+
): CiContext | null {
|
|
41
|
+
const repo = overrides.repo ?? env.GITHUB_REPOSITORY;
|
|
42
|
+
if (!repo || !/^[^/\s]+\/[^/\s]+$/.test(repo)) {
|
|
43
|
+
if (!repo) return null;
|
|
44
|
+
// Malformed repo string (e.g. missing slash). Return null rather than corrupting URLs.
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let prNumber = overrides.prNumber;
|
|
49
|
+
let baseRef: string | undefined;
|
|
50
|
+
if (prNumber === undefined) {
|
|
51
|
+
const fromEvent = readPullRequestFromEvent(env);
|
|
52
|
+
if (fromEvent) {
|
|
53
|
+
prNumber = fromEvent.prNumber;
|
|
54
|
+
baseRef = fromEvent.baseRef;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (prNumber === undefined || !Number.isFinite(prNumber) || prNumber <= 0) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const runUrl = buildRunUrl(env, repo);
|
|
62
|
+
const ctx: CiContext = { repo, prNumber };
|
|
63
|
+
if (runUrl) ctx.runUrl = runUrl;
|
|
64
|
+
if (baseRef) ctx.baseRef = baseRef;
|
|
65
|
+
return ctx;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface PullRequestEventFields {
|
|
69
|
+
prNumber: number;
|
|
70
|
+
baseRef?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function readPullRequestFromEvent(env: NodeJS.ProcessEnv): PullRequestEventFields | null {
|
|
74
|
+
const eventPath = env.GITHUB_EVENT_PATH;
|
|
75
|
+
if (!eventPath) return null;
|
|
76
|
+
let raw: string;
|
|
77
|
+
try {
|
|
78
|
+
raw = fs.readFileSync(eventPath, "utf8");
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
let parsed: unknown;
|
|
83
|
+
try {
|
|
84
|
+
parsed = JSON.parse(raw);
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
if (parsed === null || typeof parsed !== "object") return null;
|
|
89
|
+
const obj = parsed as Record<string, unknown>;
|
|
90
|
+
const pr = obj.pull_request;
|
|
91
|
+
if (pr === null || typeof pr !== "object") {
|
|
92
|
+
// Some events (issue_comment on a PR) carry `issue.pull_request` instead. We only
|
|
93
|
+
// support the `pull_request` event in v1; everything else returns null.
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
const prRecord = pr as Record<string, unknown>;
|
|
97
|
+
const number = prRecord.number;
|
|
98
|
+
if (typeof number !== "number" || !Number.isFinite(number)) return null;
|
|
99
|
+
|
|
100
|
+
let baseRef: string | undefined;
|
|
101
|
+
const base = prRecord.base;
|
|
102
|
+
if (base && typeof base === "object") {
|
|
103
|
+
const baseRecord = base as Record<string, unknown>;
|
|
104
|
+
const ref = baseRecord.ref;
|
|
105
|
+
const sha = baseRecord.sha;
|
|
106
|
+
if (typeof ref === "string" && typeof sha === "string") {
|
|
107
|
+
baseRef = `${ref}@${sha.slice(0, 7)}`;
|
|
108
|
+
} else if (typeof ref === "string") {
|
|
109
|
+
baseRef = ref;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return { prNumber: number, baseRef };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function buildRunUrl(env: NodeJS.ProcessEnv, repo: string): string | undefined {
|
|
116
|
+
const server = env.GITHUB_SERVER_URL;
|
|
117
|
+
const runId = env.GITHUB_RUN_ID;
|
|
118
|
+
if (!server || !runId) return undefined;
|
|
119
|
+
return `${server}/${repo}/actions/runs/${runId}`;
|
|
120
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `gh` CLI wrapper for the harness PR sticky comment.
|
|
3
|
+
*
|
|
4
|
+
* Fail-open by design: every failure path returns a typed `PostOutcome` instead of
|
|
5
|
+
* throwing, so the caller can decide whether to surface a workflow-summary fallback. The
|
|
6
|
+
* pipeline never blocks on PR-comment posting.
|
|
7
|
+
*
|
|
8
|
+
* Pattern mirrors `src/fix-pr/fetch-comments.ts` and `src/release/channels/github.ts`:
|
|
9
|
+
* we never construct an Octokit client; `platform.exec("gh", [...])` is the only
|
|
10
|
+
* dependency.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Platform } from "../../platform/types.js";
|
|
14
|
+
import { parseMarker, STICKY_MARKER_PREFIX } from "./status.js";
|
|
15
|
+
import type { PrCommentStatus } from "./types.js";
|
|
16
|
+
|
|
17
|
+
/** Outcome of an upsert attempt. */
|
|
18
|
+
export type PostOutcome =
|
|
19
|
+
| { kind: "created"; commentId: number }
|
|
20
|
+
| { kind: "updated"; commentId: number }
|
|
21
|
+
| { kind: "unchanged"; commentId: number; reason: "status-unchanged" }
|
|
22
|
+
| { kind: "skipped"; reason: "no-auth" | "no-cli" | "no-pr-env" }
|
|
23
|
+
| { kind: "failed"; reason: string };
|
|
24
|
+
|
|
25
|
+
export interface PostStickyOptions {
|
|
26
|
+
repo: string;
|
|
27
|
+
prNumber: number;
|
|
28
|
+
cwd: string;
|
|
29
|
+
body: string;
|
|
30
|
+
mode: "every-push" | "on-status-change";
|
|
31
|
+
currentStatus: PrCommentStatus;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Idempotent upsert of the sticky comment.
|
|
36
|
+
*
|
|
37
|
+
* 1. Verify `gh` is installed and authenticated.
|
|
38
|
+
* 2. List PR comments; find the first whose body starts with the harness marker prefix.
|
|
39
|
+
* 3. When `mode === "on-status-change"`, parse the previous status; bail with `unchanged`
|
|
40
|
+
* when it matches `currentStatus`.
|
|
41
|
+
* 4. PATCH the existing comment, or POST a new one when nothing matched.
|
|
42
|
+
*/
|
|
43
|
+
export async function postStickyComment(
|
|
44
|
+
platform: Platform,
|
|
45
|
+
options: PostStickyOptions,
|
|
46
|
+
): Promise<PostOutcome> {
|
|
47
|
+
const { repo, prNumber, cwd, body, mode, currentStatus } = options;
|
|
48
|
+
|
|
49
|
+
const auth = await checkAuth(platform, cwd);
|
|
50
|
+
if (auth.kind !== "ok") return auth;
|
|
51
|
+
|
|
52
|
+
const existing = await findStickyComment(platform, repo, prNumber, cwd);
|
|
53
|
+
if (existing.kind === "failed") return existing;
|
|
54
|
+
|
|
55
|
+
if (existing.kind === "found") {
|
|
56
|
+
if (mode === "on-status-change") {
|
|
57
|
+
const parsed = parseMarker(existing.body);
|
|
58
|
+
if (parsed && parsed.status === currentStatus) {
|
|
59
|
+
return { kind: "unchanged", commentId: existing.id, reason: "status-unchanged" };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const patched = await patchComment(platform, repo, existing.id, body, cwd);
|
|
63
|
+
return patched;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// No sticky yet — create one.
|
|
67
|
+
return createComment(platform, repo, prNumber, body, cwd);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// Internals
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
async function checkAuth(
|
|
75
|
+
platform: Platform,
|
|
76
|
+
cwd: string,
|
|
77
|
+
): Promise<{ kind: "ok" } | { kind: "skipped"; reason: "no-auth" | "no-cli" }> {
|
|
78
|
+
let result: Awaited<ReturnType<Platform["exec"]>>;
|
|
79
|
+
try {
|
|
80
|
+
result = await platform.exec("gh", ["auth", "status"], { cwd });
|
|
81
|
+
} catch {
|
|
82
|
+
// ENOENT (gh missing) or other spawn-time failure — treat as no-cli.
|
|
83
|
+
return { kind: "skipped", reason: "no-cli" };
|
|
84
|
+
}
|
|
85
|
+
if (result.code === 0) return { kind: "ok" };
|
|
86
|
+
return { kind: "skipped", reason: "no-auth" };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
type FindResult =
|
|
90
|
+
| { kind: "found"; id: number; body: string }
|
|
91
|
+
| { kind: "not-found" }
|
|
92
|
+
| { kind: "failed"; reason: string };
|
|
93
|
+
|
|
94
|
+
async function findStickyComment(
|
|
95
|
+
platform: Platform,
|
|
96
|
+
repo: string,
|
|
97
|
+
prNumber: number,
|
|
98
|
+
cwd: string,
|
|
99
|
+
): Promise<FindResult> {
|
|
100
|
+
let result: Awaited<ReturnType<Platform["exec"]>>;
|
|
101
|
+
try {
|
|
102
|
+
result = await platform.exec(
|
|
103
|
+
"gh",
|
|
104
|
+
[
|
|
105
|
+
"api",
|
|
106
|
+
"--paginate",
|
|
107
|
+
`repos/${repo}/issues/${prNumber}/comments`,
|
|
108
|
+
"--jq",
|
|
109
|
+
".[] | {id, body}",
|
|
110
|
+
],
|
|
111
|
+
{ cwd },
|
|
112
|
+
);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
return { kind: "failed", reason: error instanceof Error ? error.message : String(error) };
|
|
115
|
+
}
|
|
116
|
+
if (result.code !== 0) {
|
|
117
|
+
return {
|
|
118
|
+
kind: "failed",
|
|
119
|
+
reason: result.stderr.trim() || `gh api exited with code ${result.code}`,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
// `--jq '.[] | {id, body}'` emits one JSON object per line (NOT a JSON array). Crucially,
|
|
123
|
+
// bodies may contain newlines — the `--jq` filter on a *list* shouldn't, because jq's
|
|
124
|
+
// default emits compact JSON for objects, but we still parse defensively.
|
|
125
|
+
for (const line of splitJsonObjects(result.stdout)) {
|
|
126
|
+
let parsed: unknown;
|
|
127
|
+
try {
|
|
128
|
+
parsed = JSON.parse(line);
|
|
129
|
+
} catch {
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (parsed === null || typeof parsed !== "object") continue;
|
|
133
|
+
const obj = parsed as { id?: unknown; body?: unknown };
|
|
134
|
+
if (typeof obj.id !== "number" || typeof obj.body !== "string") continue;
|
|
135
|
+
if (obj.body.startsWith(STICKY_MARKER_PREFIX)) {
|
|
136
|
+
return { kind: "found", id: obj.id, body: obj.body };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return { kind: "not-found" };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function createComment(
|
|
143
|
+
platform: Platform,
|
|
144
|
+
repo: string,
|
|
145
|
+
prNumber: number,
|
|
146
|
+
body: string,
|
|
147
|
+
cwd: string,
|
|
148
|
+
): Promise<PostOutcome> {
|
|
149
|
+
let result: Awaited<ReturnType<Platform["exec"]>>;
|
|
150
|
+
try {
|
|
151
|
+
result = await platform.exec(
|
|
152
|
+
"gh",
|
|
153
|
+
[
|
|
154
|
+
"api",
|
|
155
|
+
"-X", "POST",
|
|
156
|
+
`repos/${repo}/issues/${prNumber}/comments`,
|
|
157
|
+
"-f", `body=${body}`,
|
|
158
|
+
],
|
|
159
|
+
{ cwd },
|
|
160
|
+
);
|
|
161
|
+
} catch (error) {
|
|
162
|
+
return { kind: "failed", reason: error instanceof Error ? error.message : String(error) };
|
|
163
|
+
}
|
|
164
|
+
if (result.code !== 0) {
|
|
165
|
+
return { kind: "failed", reason: result.stderr.trim() || `gh api POST exited with code ${result.code}` };
|
|
166
|
+
}
|
|
167
|
+
const id = extractCommentId(result.stdout);
|
|
168
|
+
if (id === null) {
|
|
169
|
+
return { kind: "failed", reason: "gh api POST succeeded but response is missing comment id" };
|
|
170
|
+
}
|
|
171
|
+
return { kind: "created", commentId: id };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function patchComment(
|
|
175
|
+
platform: Platform,
|
|
176
|
+
repo: string,
|
|
177
|
+
commentId: number,
|
|
178
|
+
body: string,
|
|
179
|
+
cwd: string,
|
|
180
|
+
): Promise<PostOutcome> {
|
|
181
|
+
let result: Awaited<ReturnType<Platform["exec"]>>;
|
|
182
|
+
try {
|
|
183
|
+
result = await platform.exec(
|
|
184
|
+
"gh",
|
|
185
|
+
[
|
|
186
|
+
"api",
|
|
187
|
+
"-X", "PATCH",
|
|
188
|
+
`repos/${repo}/issues/comments/${commentId}`,
|
|
189
|
+
"-f", `body=${body}`,
|
|
190
|
+
],
|
|
191
|
+
{ cwd },
|
|
192
|
+
);
|
|
193
|
+
} catch (error) {
|
|
194
|
+
return { kind: "failed", reason: error instanceof Error ? error.message : String(error) };
|
|
195
|
+
}
|
|
196
|
+
if (result.code !== 0) {
|
|
197
|
+
return { kind: "failed", reason: result.stderr.trim() || `gh api PATCH exited with code ${result.code}` };
|
|
198
|
+
}
|
|
199
|
+
return { kind: "updated", commentId };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function extractCommentId(stdout: string): number | null {
|
|
203
|
+
try {
|
|
204
|
+
const parsed = JSON.parse(stdout);
|
|
205
|
+
if (parsed && typeof parsed === "object" && typeof (parsed as { id?: unknown }).id === "number") {
|
|
206
|
+
return (parsed as { id: number }).id;
|
|
207
|
+
}
|
|
208
|
+
} catch {
|
|
209
|
+
// Fall through to regex scan; gh api can be configured with --jq for partial outputs.
|
|
210
|
+
}
|
|
211
|
+
const match = /"id"\s*:\s*(\d+)/.exec(stdout);
|
|
212
|
+
return match ? Number(match[1]) : null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Split jq stream output into individual JSON object strings. jq's stream mode separates
|
|
217
|
+
* objects with a single newline, but body fields may contain unescaped newlines when the
|
|
218
|
+
* comment uses raw markdown. We rely on `JSON.parse` to validate each candidate and fall
|
|
219
|
+
* back to a line-based split.
|
|
220
|
+
*/
|
|
221
|
+
function splitJsonObjects(raw: string): string[] {
|
|
222
|
+
const trimmed = raw.trim();
|
|
223
|
+
if (trimmed.length === 0) return [];
|
|
224
|
+
// Fast path: each line is its own object (the common case for `--jq '.[] | {id, body}'`).
|
|
225
|
+
const lines = trimmed.split(/\n(?=\{)/).map((s) => s.trim()).filter((s) => s.length > 0);
|
|
226
|
+
return lines;
|
|
227
|
+
}
|