@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.
- package/package.json +1 -1
- package/src/audit/rules.js +16 -0
- package/src/boot.js +79 -19
- package/src/cli-definition.js +27 -0
- package/src/commands/claim.js +59 -22
- package/src/commands/product-mix.js +148 -0
- package/src/commands/refresh.js +37 -2
- package/src/commands/sync.js +10 -3
- package/src/constants.js +18 -0
- package/src/issue-list-renderer.js +71 -0
- package/src/marker-scanner.js +18 -1
- package/src/sanitize.js +53 -0
- package/src/wiki-sync.js +573 -56
|
@@ -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
|
+
}
|
package/src/marker-scanner.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/sanitize.js
ADDED
|
@@ -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
|
+
}
|