@forwardimpact/libwiki 0.2.27 → 0.2.28

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.
@@ -1,5 +1,22 @@
1
1
  import { addDays } from "@forwardimpact/libutil";
2
2
  import { createLogger } from "@forwardimpact/libtelemetry";
3
+ import { sanitizeCrossingField, sanitizeTitle } from "./sanitize.js";
4
+
5
+ /**
6
+ * Thrown when the tracker query for the agent-experiments materialization fails
7
+ * (non-zero exit or unparseable JSON). Distinct from returning `[]` so the
8
+ * refresh command can keep the previously materialized block instead of wiping
9
+ * the routing surface when the tracker is briefly unavailable.
10
+ */
11
+ export class TrackerQueryError extends Error {
12
+ /** @param {string} reason */
13
+ constructor(reason) {
14
+ super(reason);
15
+ this.name = "TrackerQueryError";
16
+ }
17
+ }
18
+
19
+ const AGENT_LABEL_RE = /^agent:([a-z][a-z-]*)$/;
3
20
 
4
21
  /** Parse `owner/repo` from a git origin URL. Tolerates http(s), ssh, and proxy-rewritten URLs (e.g. `http://host/git/owner/repo`) by taking the last two path segments after stripping `.git`. Returns null when nothing parseable is found. */
5
22
  export function parseRepoSlug(originUrl) {
@@ -88,3 +105,57 @@ export async function renderIssueList({
88
105
  }
89
106
  return lines;
90
107
  }
108
+
109
+ /**
110
+ * Render the attributed per-agent experiments surface. Fetches open
111
+ * issues labeled `experiment`, keeps only those also carrying an
112
+ * `agent:{name}` label, and emits one sanitized, body-free line per issue:
113
+ * `- #<number> [<agent>] <title> (by <author>)`. Issue bodies are never read.
114
+ * Throws {@link TrackerQueryError} on tracker failure so the caller can keep the
115
+ * previously materialized block; never returns `[]` on failure.
116
+ *
117
+ * @param {object} options
118
+ * @param {string} options.cwd
119
+ * @param {string} [options.repo]
120
+ * @param {string} [options.token]
121
+ * @param {import('@forwardimpact/libutil/runtime').Runtime} options.runtime
122
+ * @returns {Promise<string[]>}
123
+ */
124
+ export async function renderAgentExperiments({ cwd, repo, token, runtime }) {
125
+ const args = ["issue", "list"];
126
+ if (repo) args.push("--repo", repo);
127
+ args.push(
128
+ "--label",
129
+ "experiment",
130
+ "--state",
131
+ "open",
132
+ "--json",
133
+ "number,title,labels,author",
134
+ "--limit",
135
+ "100",
136
+ );
137
+ const env = token ? { ...runtime.proc.env, GH_TOKEN: token } : undefined;
138
+ const result = await runtime.subprocess.run("gh", args, { cwd, env });
139
+ if (result.exitCode !== 0) {
140
+ throw new TrackerQueryError("gh issue list failed for agent-experiments");
141
+ }
142
+ let issues;
143
+ try {
144
+ issues = JSON.parse(result.stdout || "[]");
145
+ } catch {
146
+ throw new TrackerQueryError("gh issue list JSON parse failed");
147
+ }
148
+
149
+ const lines = [];
150
+ for (const issue of issues) {
151
+ const agentLabel = (issue.labels || [])
152
+ .map((l) => (typeof l === "string" ? l : l.name))
153
+ .map((name) => name?.match(AGENT_LABEL_RE)?.[1])
154
+ .find(Boolean);
155
+ if (!agentLabel) continue;
156
+ const title = sanitizeTitle(issue.title);
157
+ const author = sanitizeCrossingField(issue.author?.login?.toLowerCase());
158
+ lines.push(`- #${issue.number} [${agentLabel}] ${title} (by ${author})`);
159
+ }
160
+ return lines;
161
+ }
@@ -1,4 +1,6 @@
1
1
  import {
2
+ AGENT_EXPERIMENTS_CLOSE_RE,
3
+ AGENT_EXPERIMENTS_OPEN_RE,
2
4
  ISSUE_CLOSE_RE,
3
5
  ISSUE_OPEN_RE,
4
6
  XMR_CLOSE_RE,
@@ -6,7 +8,9 @@ import {
6
8
  } from "./constants.js";
7
9
 
8
10
  function openLabel(open) {
9
- return open.kind === "xmr" ? open.metric : open.topic;
11
+ if (open.kind === "xmr") return open.metric;
12
+ if (open.kind === "agent-experiments") return "agent-experiments";
13
+ return open.topic;
10
14
  }
11
15
 
12
16
  function warnDangling(open, warn) {
@@ -34,6 +38,9 @@ function tryOpen(line, i) {
34
38
  openLine: i,
35
39
  };
36
40
  }
41
+ if (AGENT_EXPERIMENTS_OPEN_RE.test(line)) {
42
+ return { kind: "agent-experiments", openLine: i };
43
+ }
37
44
  return null;
38
45
  }
39
46
 
@@ -48,6 +55,13 @@ function closePair(open, i) {
48
55
  closeLine: i,
49
56
  };
50
57
  }
58
+ if (open.kind === "agent-experiments") {
59
+ return {
60
+ kind: "agent-experiments",
61
+ openLine: open.openLine,
62
+ closeLine: i,
63
+ };
64
+ }
51
65
  return {
52
66
  kind: "issue-list",
53
67
  topic: open.topic,
@@ -61,6 +75,9 @@ function closePair(open, i) {
61
75
  function matchClose(line, open) {
62
76
  if (!open) return false;
63
77
  if (open.kind === "xmr") return XMR_CLOSE_RE.test(line);
78
+ if (open.kind === "agent-experiments") {
79
+ return AGENT_EXPERIMENTS_CLOSE_RE.test(line);
80
+ }
64
81
  const m = line.match(ISSUE_CLOSE_RE);
65
82
  return Boolean(m && open.kind === "issue-list" && open.topic === m[1]);
66
83
  }
@@ -0,0 +1,53 @@
1
+ const FIELD_CAP = 200;
2
+ const ELLIPSIS = "…";
3
+ const ZERO_WIDTH_SPACE = "\u200b";
4
+
5
+ // Replace every newline, control character, or whitespace code point with a
6
+ // single space, then collapse runs. Done by code-point inspection rather than a
7
+ // character-class range so no literal hyphen is ever folded into a range and
8
+ // hyphenated identifiers ("staff-engineer", "dick-olsson") survive intact.
9
+ function flattenWhitespace(input) {
10
+ let out = "";
11
+ for (const ch of input) {
12
+ const code = ch.codePointAt(0);
13
+ const isControl = code <= 0x1f || code === 0x7f;
14
+ const isSpace = /\s/.test(ch);
15
+ out += isControl || isSpace ? " " : ch;
16
+ }
17
+ return out.replace(/ {2,}/g, " ");
18
+ }
19
+
20
+ /**
21
+ * Neutralize an anyone-editable issue-tracker field before it crosses into a
22
+ * boot-readable wiki surface. Flattens newlines / control characters /
23
+ * whitespace to single spaces (a multi-line value is what would let a field
24
+ * inject a heading or block marker and move section boundaries), escapes a
25
+ * leading protocol sigil ("[" or "<") so "[ask#N]" / "<tag>" / HTML-comment
26
+ * lookalikes render inert, and length-caps the result.
27
+ * @param {string|null|undefined} value
28
+ * @param {number} [maxLen]
29
+ * @returns {string}
30
+ */
31
+ export function sanitizeCrossingField(value, maxLen = FIELD_CAP) {
32
+ if (value == null) return "";
33
+ let s = flattenWhitespace(String(value)).trim();
34
+ s = s.replace(/^\[/, "\\[").replace(/^</, "\\<");
35
+ if (s.length > maxLen) s = s.slice(0, maxLen - 1) + ELLIPSIS;
36
+ return s;
37
+ }
38
+
39
+ /**
40
+ * Sanitize a materialized item title. Beyond {@link sanitizeCrossingField}, it
41
+ * defuses the literal author-suffix token " (by " by inserting a zero-width
42
+ * space after "(by", so a title can never be mistaken for the trailing
43
+ * "(by <author>)" provenance suffix when the line is parsed back at boot.
44
+ * @param {string|null|undefined} value
45
+ * @param {number} [maxLen]
46
+ * @returns {string}
47
+ */
48
+ export function sanitizeTitle(value, maxLen = FIELD_CAP) {
49
+ return sanitizeCrossingField(value, maxLen).replace(
50
+ / \(by /g,
51
+ " (by" + ZERO_WIDTH_SPACE + " ",
52
+ );
53
+ }