replume-cli 0.2.5 → 0.2.6

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/package.json CHANGED
@@ -1,14 +1,15 @@
1
1
  {
2
2
  "name": "replume-cli",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "replume": "dist/cli.js"
7
7
  },
8
8
  "scripts": {
9
- "build": "tsup src/cli.ts --format esm --target node20 --out-dir dist --shims",
9
+ "build": "tsup src/cli.ts --format esm --target node20 --out-dir dist --shims --env.REPLUME_TELEMETRY_DEFAULT ${REPLUME_TELEMETRY_DEFAULT:-on}",
10
10
  "test": "vitest run",
11
11
  "typecheck": "tsc --noEmit",
12
+ "check:error-codes": "node scripts/check-error-codes.mjs",
12
13
  "check-context": "node scripts/check-context.mjs",
13
14
  "prepublishOnly": "npm run build && npm run typecheck && npm run check-context && npm test",
14
15
  "eval": "node eval/run.mjs"
@@ -1,10 +1,18 @@
1
1
  // Copy beside the workflow as .github/replume-pr-check-publish.mjs on the trusted base branch.
2
2
  // The publisher reads the verify artifact; --check-policy reads committed policy from the PR base.
3
3
  import { execFileSync } from "node:child_process";
4
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
5
5
  import { dirname, join } from "node:path";
6
6
 
7
- const marker = "<!-- replume-pr-check -->";
7
+ // Before 0.2.6 every workflow shared this marker; such a comment is taken over, so an upgrade does not leave two.
8
+ const legacyMarker = "<!-- replume-pr-check -->";
9
+ const safeKey = (value) => String(value ?? "").replace(/[^A-Za-z0-9 ._/-]+/g, "-").trim().slice(0, 120);
10
+ // One comment for each workflow and job. A matrix job sets REPLUME_CHECK_JOB to tell its legs apart.
11
+ const checkKey = (env) => `${safeKey(env.GITHUB_WORKFLOW) || "workflow"} / ${safeKey(env.REPLUME_CHECK_JOB || env.GITHUB_JOB) || "job"}`;
12
+ const markerFor = (env) => `<!-- replume-pr-check: ${checkKey(env)} -->`;
13
+ const EARLIER_RUNS = 10;
14
+ const runTag = /<!-- replume-run: sha=([0-9a-f]{7,40}) run=(\S+) attempt=(\S+) verdict=(PASS|FAIL|INCOMPLETE) -->/;
15
+ const earlierLine = /^- `[0-9a-f]{7}` run \S+ attempt \S+: \*\*(?:PASS|FAIL|INCOMPLETE)\*\*$/;
8
16
  const text = (value) => String(value ?? "").replace(/[\r\n\t]+/g, " ").replace(/[<>`]/g, "").slice(0, 350);
9
17
  const items = (entries) => entries.length ? entries.map((entry) => `- ${entry}`).join("\n") : "- None recorded.";
10
18
  const contractPath = ".replume/contract.yaml";
@@ -46,11 +54,28 @@ function result(reportPath, exitPath, policyPath = join(dirname(reportPath), "po
46
54
  try { if (existsSync(exitPath)) exit = readFileSync(exitPath, "utf8").trim(); } catch { /* incomplete below */ }
47
55
  const expected = report?.verdict === "pass" ? "0" : report?.verdict === "fail" ? "1" : report?.verdict === "incomplete" ? "2" : "";
48
56
  const verdict = expected && exit === expected ? report.verdict.toUpperCase() : "INCOMPLETE";
57
+ // never green on nothing: a PASS has to name a check that ran
58
+ if (verdict === "PASS" && !(Array.isArray(report.done) && report.done.length)) return { report, verdict: "INCOMPLETE", reason: "Verification reported PASS but names no check that ran." };
49
59
  return { report: report && typeof report === "object" ? report : null, verdict,
50
60
  reason: verdict === "INCOMPLETE" && expected !== exit ? "Verification did not produce a usable, matching result." : "" };
51
61
  }
52
62
 
53
- export function commentBody(reportPath, exitPath, policyPath) {
63
+ /** The earlier runs kept in a comment, plus the run that comment showed, as one list. */
64
+ function earlierRuns(previous) {
65
+ const block = /<!-- replume-earlier -->\n([\s\S]*?)\n<!-- \/replume-earlier -->/.exec(previous ?? "")?.[1] ?? "";
66
+ return block.split("\n").filter((line) => earlierLine.test(line));
67
+ }
68
+ const runLine = (sha, run, attempt, verdict) => `- \`${String(sha).slice(0, 7)}\` run ${run} attempt ${attempt}: **${verdict}**`;
69
+ function foldedRuns(lines) {
70
+ if (!lines.length) return [];
71
+ return [`<details><summary>Earlier runs (${lines.length})</summary>`, "", "<!-- replume-earlier -->", ...lines, "<!-- /replume-earlier -->", "", "</details>", ""];
72
+ }
73
+ /** Puts the earlier runs, newest first, into a body that has none yet. */
74
+ function withEarlier(body, lines) {
75
+ return lines.length ? `${body}\n\n${foldedRuns(lines.slice(0, EARLIER_RUNS)).join("\n")}`.trimEnd() : body;
76
+ }
77
+
78
+ export function commentBody(reportPath, exitPath, policyPath, env = {}) {
54
79
  const { report, verdict, reason } = result(reportPath, exitPath, policyPath);
55
80
  const done = Array.isArray(report?.done) ? report.done : [];
56
81
  const failed = Array.isArray(report?.failed) ? report.failed : [];
@@ -68,7 +93,7 @@ export function commentBody(reportPath, exitPath, policyPath) {
68
93
  "CI did not observe agent steps; this result covers the commit diff and checks run here.",
69
94
  ];
70
95
  if (reason) unchecked.unshift(reason);
71
- return [marker, `**Replume: ${verdict}${reason ? ` — ${text(reason)}` : ""}**`, "",
96
+ return [markerFor(env), `<!-- replume-run: sha=${/^[0-9a-f]{7,40}$/i.test(env.HEAD_SHA ?? "") ? env.HEAD_SHA.toLowerCase() : "0000000"} run=${safeKey(env.GITHUB_RUN_ID).replace(/\s/g, "-") || "-"} attempt=${safeKey(env.GITHUB_RUN_ATTEMPT).replace(/\s/g, "-") || "1"} verdict=${verdict} -->`, `**Replume: ${verdict}${reason ? ` — ${text(reason)}` : ""}**`, "",
72
97
  "## Verified", items(verified), "",
73
98
  ...(failures.length ? ["## Failed", items(failures), ""] : []),
74
99
  "## Changed beyond the task", items(beyond), "",
@@ -84,10 +109,17 @@ async function api(path, method, token, body) {
84
109
  headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json", "content-type": "application/json" },
85
110
  ...(body === undefined ? {} : { body: JSON.stringify(body) }),
86
111
  });
87
- if (!response.ok) throw new Error(`GitHub ${method} ${path} returned HTTP ${response.status}`);
112
+ if (!response.ok) throw Object.assign(new Error(`GitHub ${method} ${path} returned HTTP ${response.status}`), { status: response.status });
88
113
  return response.json();
89
114
  }
90
115
 
116
+ // A fork's token and a Dependabot token are read-only: GitHub answers 403, or 404 for what the token may not see.
117
+ const readOnlyError = (error) => error?.status === 403 || error?.status === 404;
118
+ function toRunPage(env, body) {
119
+ if (!env.GITHUB_STEP_SUMMARY) return;
120
+ try { appendFileSync(env.GITHUB_STEP_SUMMARY, `${body}\n`); } catch { /* the page is an extra; the job's exit is the result */ }
121
+ }
122
+
91
123
  export async function publish(env = process.env) {
92
124
  const repo = env.GITHUB_REPOSITORY, pr = Number(env.PR_NUMBER), sha = env.HEAD_SHA, token = env.GITHUB_TOKEN;
93
125
  if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo ?? "") || !Number.isSafeInteger(pr) || pr < 1 || !/^[a-fA-F0-9]{40}$/.test(sha ?? "") || !token)
@@ -95,23 +127,71 @@ export async function publish(env = process.env) {
95
127
  const reportPath = env.REPLUME_REPORT || "replume-ci/report.json";
96
128
  const exitPath = env.REPLUME_VERIFY_EXIT_FILE || "replume-ci/exit.txt";
97
129
  const policyPath = env.REPLUME_POLICY_NOTE || join(dirname(reportPath), "policy.txt");
98
- const body = commentBody(reportPath, exitPath, policyPath);
130
+ const fresh = commentBody(reportPath, exitPath, policyPath, env);
99
131
  const verdict = result(reportPath, exitPath, policyPath).verdict;
100
- let existing = null;
101
- for (let page = 1; page <= 20; page++) {
102
- const comments = await api(`/repos/${repo}/issues/${pr}/comments?per_page=100&page=${page}`, "GET", token);
103
- if (!Array.isArray(comments)) throw new Error("GitHub comments response was not a list");
104
- existing = comments.find((entry) => entry?.body?.startsWith(marker) && entry?.user?.login === "github-actions[bot]") ?? null;
105
- if (existing || comments.length < 100) break;
132
+ // the run's result stays on its own page in every case, with or without a comment
133
+ toRunPage(env, fresh);
134
+ if (env.HEAD_REPO && env.HEAD_REPO !== repo) {
135
+ process.stdout.write(`Replume: ${verdict}; a fork's PR: no comment and no check from this token. The result is on the run page.\n`);
136
+ return;
106
137
  }
107
- if (existing) await api(`/repos/${repo}/issues/comments/${existing.id}`, "PATCH", token, { body });
108
- else await api(`/repos/${repo}/issues/${pr}/comments`, "POST", token, { body });
109
- const conclusion = verdict === "PASS" ? "success" : verdict === "FAIL" ? "failure" : "neutral";
110
- await api(`/repos/${repo}/check-runs`, "POST", token, {
111
- name: "Replume / task", head_sha: sha, status: "completed", conclusion,
112
- output: { title: `Replume: ${verdict}`, summary: body.slice(0, 60000) },
113
- });
114
- process.stdout.write(`Replume: ${verdict}; ${existing ? "updated" : "posted"} one PR comment; check ${conclusion}.\n`);
138
+ const marker = markerFor(env);
139
+ const ours = (entry, prefix) => entry?.body?.split("\n")[0] === prefix && entry?.user?.login === "github-actions[bot]";
140
+ const notes = [];
141
+ let action = "no comment";
142
+ // a check is not green because a comment could not be written, or the other way round: each has its own outcome
143
+ try {
144
+ let existing = null, legacy = null;
145
+ for (let page = 1; page <= 20; page++) {
146
+ const comments = await api(`/repos/${repo}/issues/${pr}/comments?per_page=100&page=${page}`, "GET", token);
147
+ if (!Array.isArray(comments)) throw new Error("GitHub comments response was not a list");
148
+ existing = comments.find((entry) => ours(entry, marker)) ?? null;
149
+ legacy ??= comments.find((entry) => ours(entry, legacyMarker)) ?? null;
150
+ if (existing || comments.length < 100) break;
151
+ }
152
+ const current = existing ?? legacy;
153
+ const prior = runTag.exec(current?.body ?? "");
154
+ const earlier = earlierRuns(current?.body);
155
+ const thisRun = runTag.exec(fresh);
156
+ const sameRun = prior && thisRun && prior[2] === thisRun[2] && prior[3] === thisRun[3];
157
+ // a rerun of an older commit does not take the place of the newest commit's result: it goes under Earlier runs
158
+ let newest = null;
159
+ try { newest = (await api(`/repos/${repo}/pulls/${pr}`, "GET", token))?.head?.sha ?? null; } catch { /* unknown: treat this run as the newest */ }
160
+ const stale = newest && newest.toLowerCase() !== sha.toLowerCase() && current;
161
+ const body = stale
162
+ ? current.body.replace(/\n\n<details><summary>Earlier runs[\s\S]*$/, "").concat(`\n\n${foldedRuns([runLine(sha, thisRun[2], thisRun[3], verdict), ...earlier].slice(0, EARLIER_RUNS)).join("\n")}`).trimEnd()
163
+ : withEarlier(fresh, sameRun || !prior ? earlier : [runLine(prior[1], prior[2], prior[3], prior[4]), ...earlier]);
164
+ if (current) await api(`/repos/${repo}/issues/comments/${current.id}`, "PATCH", token, { body });
165
+ else await api(`/repos/${repo}/issues/${pr}/comments`, "POST", token, { body });
166
+ action = current ? "updated one PR comment" : "posted one PR comment";
167
+ } catch (error) {
168
+ if (!readOnlyError(error)) throw error;
169
+ notes.push("the token cannot write comments here (a fork or a read-only token), so the result is on the run page");
170
+ }
171
+ // neutral counts as passing for a required check, so INCOMPLETE is not neutral: nothing that was not checked turns a PR green
172
+ const conclusion = verdict === "PASS" ? "success" : verdict === "FAIL" ? "failure" : "action_required";
173
+ const runPage = env.GITHUB_RUN_ID ? `${(env.GITHUB_SERVER_URL || "https://github.com").replace(/\/$/, "")}/${repo}/actions/runs/${env.GITHUB_RUN_ID}` : null;
174
+ let check = conclusion;
175
+ try {
176
+ await api(`/repos/${repo}/check-runs`, "POST", token, {
177
+ name: "Replume / task", head_sha: sha, status: "completed", conclusion, ...(runPage ? { details_url: runPage } : {}),
178
+ output: { title: `Replume: ${verdict}`, summary: fresh.slice(0, 60000) },
179
+ });
180
+ } catch (error) {
181
+ if (!readOnlyError(error)) throw error;
182
+ check = "not written";
183
+ notes.push("the token cannot write checks here");
184
+ }
185
+ process.stdout.write(`Replume: ${verdict}; ${action}; check ${check}.${notes.length ? ` ${notes.join("; ")}.` : ""}\n`);
186
+ }
187
+
188
+ /** The verify job's last step: the run page gets the result, and only PASS exits 0. Needs no token, so it works on a fork. */
189
+ export function requirePass(dir, env = process.env) {
190
+ const reportPath = join(dir, "report.json"), exitPath = join(dir, "exit.txt"), policyPath = join(dir, "policy.txt");
191
+ toRunPage(env, commentBody(reportPath, exitPath, policyPath, env));
192
+ const { verdict } = result(reportPath, exitPath, policyPath);
193
+ process.stdout.write(`Replume: ${verdict}\n`);
194
+ return verdict === "PASS" ? 0 : verdict === "FAIL" ? 1 : 2;
115
195
  }
116
196
 
117
197
  if (process.argv[1] && new URL(import.meta.url).pathname === process.argv[1]) {
@@ -121,5 +201,7 @@ if (process.argv[1] && new URL(import.meta.url).pathname === process.argv[1]) {
121
201
  writeFileSync(process.argv[4], reason ? `${reason}\n` : "");
122
202
  if (reason) { process.stdout.write(`Replume: INCOMPLETE — ${reason}\n`); process.exitCode = 2; }
123
203
  } catch (error) { process.stderr.write(`replume-pr-check: ${error.message}\n`); process.exitCode = 2; }
204
+ } else if (process.argv[2] === "--require") {
205
+ process.exitCode = requirePass(process.argv[3]);
124
206
  } else publish().catch((error) => { process.stderr.write(`replume-pr-check: ${error.message}\n`); process.exitCode = 1; });
125
207
  }
@@ -21,7 +21,7 @@ jobs:
21
21
  with:
22
22
  node-version: '20'
23
23
  - run: mkdir -p "$RUNNER_TEMP/replume-ci"
24
- - run: npm install -g replume-cli@0.2.4
24
+ - run: npm install -g replume-cli@0.2.5
25
25
  - name: Install project dependencies
26
26
  if: ${{ hashFiles('package-lock.json') != '' }}
27
27
  run: npm ci
@@ -56,12 +56,16 @@ jobs:
56
56
  if: always()
57
57
  shell: bash
58
58
  run: |
59
+ if grep -qs -- '--require' "$RUNNER_TEMP/replume-base-publisher.mjs"; then
60
+ node "$RUNNER_TEMP/replume-base-publisher.mjs" --require "$RUNNER_TEMP/replume-ci"
61
+ exit $?
62
+ fi
59
63
  if [ ! -f "$RUNNER_TEMP/replume-ci/exit.txt" ]; then exit 2; fi
60
64
  exit "$(cat "$RUNNER_TEMP/replume-ci/exit.txt")"
61
65
 
62
66
  publish:
63
67
  name: Replume / PR result
64
- if: ${{ always() && github.event.pull_request.head.repo.full_name == github.repository }}
68
+ if: ${{ !cancelled() && github.event.pull_request.head.repo.full_name == github.repository }}
65
69
  needs: verify
66
70
  runs-on: ubuntu-latest
67
71
  timeout-minutes: 5
@@ -88,6 +92,8 @@ jobs:
88
92
  GITHUB_REPOSITORY: ${{ github.repository }}
89
93
  PR_NUMBER: ${{ github.event.pull_request.number }}
90
94
  HEAD_SHA: ${{ github.event.pull_request.head.sha }}
95
+ HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
96
+ REPLUME_CHECK_JOB: verify
91
97
  REPLUME_REPORT: replume-ci/report.json
92
98
  REPLUME_VERIFY_EXIT_FILE: replume-ci/exit.txt
93
99
  REPLUME_POLICY_NOTE: replume-ci/policy.txt