@forwardimpact/libwiki 0.2.32 → 0.2.33

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@forwardimpact/libwiki",
3
- "version": "0.2.32",
3
+ "version": "0.2.33",
4
4
  "description": "Wiki lifecycle for agent teams — persistent memory, declarative integrity audits, and a collision ledger so coordination survives across sessions and parallel work.",
5
5
  "keywords": [
6
6
  "wiki",
@@ -16,24 +16,62 @@ const LABEL = {
16
16
  };
17
17
  const TITLE = "Wiki curation: shared-state audit findings";
18
18
 
19
+ // GitHub rejects an issue or comment body over 65536 characters. Keep the whole
20
+ // body under a margin below that so the preamble, JSON fence, and truncation
21
+ // notice always fit. When the findings overflow, the body carries the first N
22
+ // that fit plus a count; the full list stays reproducible via `fit-wiki audit`.
23
+ const MAX_BODY = 65000;
24
+
19
25
  /**
20
26
  * Compose the issue body from the audit's JSON findings. The findings ride a
21
27
  * fenced ```json block; the body is passed to `gh` via `--body-file` (a temp
22
28
  * file), never argv, so untrusted finding text cannot be misread as a flag.
23
29
  * @param {string} findingsJson
30
+ * @param {{shown?: number, total?: number}} [trunc]
24
31
  * @returns {string}
25
32
  */
26
- function buildBody(findingsJson) {
27
- return [
33
+ function buildBody(findingsJson, { shown, total } = {}) {
34
+ const lines = [
28
35
  "Scheduled `curate-wiki` audit found shared-wiki violations.",
29
36
  "",
30
37
  "Owner: **technical-writer** (service these via the curation shift; the per-PR `wiki` gate no longer reads shared wiki state).",
31
38
  "",
32
- "```json",
33
- findingsJson,
34
- "```",
35
- "",
36
- ].join("\n");
39
+ ];
40
+ if (total != null && shown != null && shown < total) {
41
+ lines.push(
42
+ `Showing ${shown} of ${total} findings — the body was truncated to fit GitHub's comment limit. Run \`fit-wiki audit\` for the full list.`,
43
+ "",
44
+ );
45
+ }
46
+ lines.push("```json", findingsJson, "```", "");
47
+ return lines.join("\n");
48
+ }
49
+
50
+ /**
51
+ * Build the largest postable body for the audit findings. The full findings
52
+ * usually fit; when they don't, keep the first N `fail` findings that stay
53
+ * under GitHub's body limit and label the body as truncated. The shrink steps
54
+ * down proportionally to the overflow, so it converges in a couple of passes.
55
+ * @param {{level: string}[]} findings
56
+ * @returns {string}
57
+ */
58
+ function fitBody(findings) {
59
+ const total = findings.filter((f) => f.level === "fail").length;
60
+ const full = buildBody(emitFindingsJson(findings), { shown: total, total });
61
+ if (full.length <= MAX_BODY) return full;
62
+
63
+ const failures = findings.filter((f) => f.level === "fail");
64
+ let shown = failures.length;
65
+ while (shown > 0) {
66
+ const body = buildBody(emitFindingsJson(failures.slice(0, shown)), {
67
+ shown,
68
+ total,
69
+ });
70
+ if (body.length <= MAX_BODY) return body;
71
+ const next = Math.floor((shown * MAX_BODY) / body.length);
72
+ shown = next < shown ? next : shown - 1;
73
+ }
74
+ return buildBody(emitFindingsJson([]), { shown: 0, total });
37
75
  }
38
76
 
39
77
  // Resolve the monorepo's `owner/repo` slug the way refresh.js/product-mix.js
@@ -60,35 +98,49 @@ async function resolveToken() {
60
98
  }
61
99
 
62
100
  /**
63
- * Audit the shared wiki and, when it is dirty, route the findings to the
64
- * single `wiki-curation` issue (create or comment) addressed to the
65
- * technical-writer. This is the SOLE home of the shared-wiki audit verdict; the
66
- * per-PR `wiki` gate no longer reads live wiki state. A clean wiki routes
67
- * nothing. The label/search/create-or-comment logic lives here, not in the
68
- * workflow, so the curation step is one CLI call.
69
- *
101
+ * Find the open `wiki-curation` issue by its verbatim title, or null. Any
102
+ * parse failure or empty result is treated as "no issue" (create path).
103
+ * @param {import("@forwardimpact/libcli").InvocationContext["deps"]["runtime"]} runtime
104
+ * @param {string[]} repoArgs
105
+ * @param {{cwd: string, env: object}} opts
106
+ * @returns {Promise<number|null>}
107
+ */
108
+ async function findOpenIssue(runtime, repoArgs, opts) {
109
+ const list = await runtime.subprocess.run(
110
+ "gh",
111
+ [
112
+ "issue",
113
+ "list",
114
+ "--search",
115
+ `${TITLE} in:title`,
116
+ "--state",
117
+ "open",
118
+ "--json",
119
+ "number",
120
+ ...repoArgs,
121
+ ],
122
+ opts,
123
+ );
124
+ try {
125
+ return JSON.parse(list.stdout || "[]")[0]?.number ?? null;
126
+ } catch {
127
+ return null;
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Route the composed body to the single `wiki-curation` issue: ensure the
133
+ * label, find the open issue by title, then comment on it or create it. The
134
+ * body goes through a temp file (never argv) so untrusted finding text cannot
135
+ * be read as a flag. On a `gh` failure the reason is logged and `ok:false`
136
+ * returned so the caller exits non-zero.
70
137
  * @param {import("@forwardimpact/libcli").InvocationContext} ctx
138
+ * @param {string} body
139
+ * @param {ReturnType<typeof createLogger>} logger
71
140
  * @returns {Promise<{ok: boolean}>}
72
141
  */
73
- export async function runCurateCommand(ctx) {
142
+ async function routeFindings(ctx, body, logger) {
74
143
  const { runtime, gitClient } = ctx.deps;
75
- const logger = createLogger("wiki", runtime);
76
- const { findings } = auditWiki(ctx);
77
-
78
- if (!findings.some((f) => f.level === "fail")) {
79
- runtime.proc.stdout.write("wiki audit clean — no curation issue routed\n");
80
- return { ok: true };
81
- }
82
-
83
- const body = buildBody(emitFindingsJson(findings));
84
-
85
- if (ctx.options["dry-run"]) {
86
- runtime.proc.stdout.write(
87
- `[dry-run] would route findings to issue "${TITLE}":\n\n${body}`,
88
- );
89
- return { ok: true };
90
- }
91
-
92
144
  const cwd = resolveProjectRoot(runtime);
93
145
  const repo =
94
146
  ctx.options.repo || (await deriveRepo(gitClient, cwd, runtime.proc.env));
@@ -115,27 +167,7 @@ export async function runCurateCommand(ctx) {
115
167
  { cwd, env },
116
168
  );
117
169
 
118
- const list = await runtime.subprocess.run(
119
- "gh",
120
- [
121
- "issue",
122
- "list",
123
- "--search",
124
- `${TITLE} in:title`,
125
- "--state",
126
- "open",
127
- "--json",
128
- "number",
129
- ...repoArgs,
130
- ],
131
- { cwd, env },
132
- );
133
- let number = null;
134
- try {
135
- number = JSON.parse(list.stdout || "[]")[0]?.number ?? null;
136
- } catch {
137
- number = null;
138
- }
170
+ const number = await findOpenIssue(runtime, repoArgs, { cwd, env });
139
171
 
140
172
  // Pass the body through a temp file, not argv — robust to length and immune
141
173
  // to finding text being read as a flag.
@@ -143,37 +175,28 @@ export async function runCurateCommand(ctx) {
143
175
  const bodyFile = path.join(tmp, "wiki-curation-body.md");
144
176
  runtime.fsSync.writeFileSync(bodyFile, body);
145
177
 
146
- const result = number
147
- ? await runtime.subprocess.run(
148
- "gh",
149
- [
150
- "issue",
151
- "comment",
152
- String(number),
153
- "--body-file",
154
- bodyFile,
155
- ...repoArgs,
156
- ],
157
- { cwd, env },
158
- )
159
- : await runtime.subprocess.run(
160
- "gh",
161
- [
162
- "issue",
163
- "create",
164
- "--title",
165
- TITLE,
166
- "--body-file",
167
- bodyFile,
168
- "--label",
169
- LABEL.name,
170
- ...repoArgs,
171
- ],
172
- { cwd, env },
173
- );
178
+ const args = number
179
+ ? ["issue", "comment", String(number), "--body-file", bodyFile, ...repoArgs]
180
+ : [
181
+ "issue",
182
+ "create",
183
+ "--title",
184
+ TITLE,
185
+ "--body-file",
186
+ bodyFile,
187
+ "--label",
188
+ LABEL.name,
189
+ ...repoArgs,
190
+ ];
191
+ const result = await runtime.subprocess.run("gh", args, { cwd, env });
174
192
 
175
193
  if (result.exitCode !== 0) {
176
- logger.warn("curate", `gh issue ${number ? "comment" : "create"} failed`);
194
+ const detail = (result.stderr || result.stdout || "").trim();
195
+ const action = number ? "comment" : "create";
196
+ logger.warn(
197
+ "curate",
198
+ `gh issue ${action} failed${detail ? `: ${detail}` : ""}`,
199
+ );
177
200
  return { ok: false };
178
201
  }
179
202
  runtime.proc.stdout.write(
@@ -183,3 +206,36 @@ export async function runCurateCommand(ctx) {
183
206
  );
184
207
  return { ok: true };
185
208
  }
209
+
210
+ /**
211
+ * Audit the shared wiki and, when it is dirty, route the findings to the
212
+ * single `wiki-curation` issue (create or comment) addressed to the
213
+ * technical-writer. This is the SOLE home of the shared-wiki audit verdict; the
214
+ * per-PR `wiki` gate no longer reads live wiki state. A clean wiki routes
215
+ * nothing. The label/search/create-or-comment logic lives here, not in the
216
+ * workflow, so the curation step is one CLI call.
217
+ *
218
+ * @param {import("@forwardimpact/libcli").InvocationContext} ctx
219
+ * @returns {Promise<{ok: boolean}>}
220
+ */
221
+ export async function runCurateCommand(ctx) {
222
+ const { runtime } = ctx.deps;
223
+ const logger = createLogger("wiki", runtime);
224
+ const { findings } = auditWiki(ctx);
225
+
226
+ if (!findings.some((f) => f.level === "fail")) {
227
+ runtime.proc.stdout.write("wiki audit clean — no curation issue routed\n");
228
+ return { ok: true };
229
+ }
230
+
231
+ const body = fitBody(findings);
232
+
233
+ if (ctx.options["dry-run"]) {
234
+ runtime.proc.stdout.write(
235
+ `[dry-run] would route findings to issue "${TITLE}":\n\n${body}`,
236
+ );
237
+ return { ok: true };
238
+ }
239
+
240
+ return routeFindings(ctx, body, logger);
241
+ }