@forwardimpact/libwiki 0.2.28 → 0.2.30
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 +5 -2
- package/bin/fit-wiki.js +11 -1
- package/package.json +1 -1
- package/src/audit/rule-builders.js +247 -0
- package/src/audit/rules.js +89 -196
- package/src/audit/scopes.js +34 -2
- package/src/budget-gate.js +193 -0
- package/src/cli-definition.js +53 -3
- package/src/commands/claim.js +3 -1
- package/src/commands/ledger.js +208 -0
- package/src/commands/refresh.js +32 -2
- package/src/commands/sync.js +6 -1
- package/src/constants.js +18 -5
- package/src/ledger/anchor.js +96 -0
- package/src/ledger/projection.js +242 -0
- package/src/ledger/reader.js +45 -0
- package/src/wiki-sync.js +167 -32
package/src/constants.js
CHANGED
|
@@ -48,17 +48,19 @@ export const PRIORITY_INDEX_TABLE_HEADER =
|
|
|
48
48
|
"| Item | Agents | Owner | Status | Added |";
|
|
49
49
|
export const DECISION_HEADING = "### Decision";
|
|
50
50
|
|
|
51
|
-
// Unified budgets for the
|
|
52
|
-
//
|
|
53
|
-
// context-tax
|
|
54
|
-
//
|
|
55
|
-
//
|
|
51
|
+
// Unified budgets for the audited surfaces (summary, weekly-log, storyboard,
|
|
52
|
+
// memory). They keep per-surface rule pairs so the limits can diverge as the
|
|
53
|
+
// context-tax model says one surface should be looser or tighter. MEMORY.md is
|
|
54
|
+
// the tightest: it is read on every boot and holds settled cross-cutting state,
|
|
55
|
+
// not history, so its budget is sized to keep the on-boot read cheap.
|
|
56
56
|
export const SUMMARY_LINE_BUDGET = 496;
|
|
57
57
|
export const SUMMARY_WORD_BUDGET = 2048;
|
|
58
58
|
export const WEEKLY_LOG_LINE_BUDGET = 496;
|
|
59
59
|
export const WEEKLY_LOG_WORD_BUDGET = 6400;
|
|
60
60
|
export const STORYBOARD_LINE_BUDGET = 496;
|
|
61
61
|
export const STORYBOARD_WORD_BUDGET = 6400;
|
|
62
|
+
export const MEMORY_LINE_BUDGET = 128;
|
|
63
|
+
export const MEMORY_WORD_BUDGET = 2048;
|
|
62
64
|
|
|
63
65
|
// Weekly-log filename convention: `<agent>-YYYY-Www.md` for the live main log
|
|
64
66
|
// and `<agent>-YYYY-Www-partN.md` for a sealed part. Capture groups are
|
|
@@ -80,6 +82,17 @@ export const WEEKLY_LOG_SEAM_RE = /^## (\d{4}-\d{2}-\d{2})/;
|
|
|
80
82
|
// than this delimit sessions in the wiki history. 30 minutes.
|
|
81
83
|
export const SESSION_GAP_MS = 30 * 60 * 1000;
|
|
82
84
|
|
|
85
|
+
// Carry-surface filename and H1 convention: `<agent>-carries.md` with an H1
|
|
86
|
+
// `# <agent> — Carries`. The name capture group is the agent prefix (used by
|
|
87
|
+
// the H1↔filename agreement rule). A Carry entry names its clearance trigger
|
|
88
|
+
// with the `**Carry-clearance:**` marker — the existing live convention in
|
|
89
|
+
// `wiki/release-engineer.md § Message Inbox`, preserved verbatim so the
|
|
90
|
+
// migration relocates without re-marking. One home so the audit's classifier
|
|
91
|
+
// (audit/scopes.js) and rules (audit/rules.js) cannot drift on the syntax.
|
|
92
|
+
export const CARRY_SURFACE_NAME_RE = /^(.+)-carries\.md$/;
|
|
93
|
+
export const CARRY_SURFACE_H1_RE = /^# (.+) — Carries$/;
|
|
94
|
+
export const CARRY_CLEARANCE_MARKER_RE = /\*\*Carry-clearance:\*\*/;
|
|
95
|
+
|
|
83
96
|
// Storyboard marker syntax. An open or close marker tolerates optional trailing
|
|
84
97
|
// text after the tag (typically an inline "Do not edit. Generated from fit-wiki
|
|
85
98
|
// refresh." notice). One home so the marker scanner (marker-scanner.js) and the
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Allocation-anchor body format for the parallel-collision ledger. An anchor is
|
|
3
|
+
* an append-only comment on the obstacle issue, so its identity is durable
|
|
4
|
+
* independent of the merge-contested ledger page. Each anchor carries one
|
|
5
|
+
* fenced block:
|
|
6
|
+
*
|
|
7
|
+
* ```text
|
|
8
|
+
* ```yaml alloc
|
|
9
|
+
* kind: occ
|
|
10
|
+
* ids: ["#97", "#98"]
|
|
11
|
+
* event: 7d0f8bca
|
|
12
|
+
* note: dual-execution episode
|
|
13
|
+
* ```
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* The block is parsed by structure, not by a general YAML engine, so libwiki
|
|
17
|
+
* adds no parser dependency. `kind` is one of `occ`, `nm`, `fold`, `meta`;
|
|
18
|
+
* `ids` is a list of display labels; `event` is the durable key (a SHA or a
|
|
19
|
+
* prior anchor id); `note` is free text. The durable key is `event`; labels are
|
|
20
|
+
* display only, so relabeling is lossless.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const FENCE_OPEN = "```yaml alloc";
|
|
24
|
+
const FENCE_CLOSE = "```";
|
|
25
|
+
const KINDS = new Set(["occ", "nm", "fold", "meta"]);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Parse the allocation anchor out of a comment body, or `null` when the body
|
|
29
|
+
* carries no `yaml alloc` fenced block.
|
|
30
|
+
*
|
|
31
|
+
* @param {string} body - The full comment body.
|
|
32
|
+
* @returns {{kind: string, ids: string[], event: string, note: string} | null}
|
|
33
|
+
*/
|
|
34
|
+
export function parseAnchor(body) {
|
|
35
|
+
if (typeof body !== "string") return null;
|
|
36
|
+
const lines = body.split("\n");
|
|
37
|
+
const open = lines.findIndex((l) => l.trim() === FENCE_OPEN);
|
|
38
|
+
if (open === -1) return null;
|
|
39
|
+
const rest = lines.slice(open + 1);
|
|
40
|
+
const close = rest.findIndex((l) => l.trim() === FENCE_CLOSE);
|
|
41
|
+
if (close === -1) return null;
|
|
42
|
+
const block = rest.slice(0, close);
|
|
43
|
+
|
|
44
|
+
const fields = {};
|
|
45
|
+
for (const line of block) {
|
|
46
|
+
const idx = line.indexOf(":");
|
|
47
|
+
if (idx === -1) continue;
|
|
48
|
+
const key = line.slice(0, idx).trim();
|
|
49
|
+
const value = line.slice(idx + 1).trim();
|
|
50
|
+
fields[key] = value;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const kind = fields.kind;
|
|
54
|
+
if (!KINDS.has(kind)) return null;
|
|
55
|
+
const ids = parseIdList(fields.ids);
|
|
56
|
+
const event = fields.event ?? "";
|
|
57
|
+
if (event === "") return null;
|
|
58
|
+
return { kind, ids, event, note: fields.note ?? "" };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Render the canonical anchor body for posting.
|
|
63
|
+
*
|
|
64
|
+
* @param {{kind: string, ids: string[], event: string, note?: string}} anchor
|
|
65
|
+
* @returns {string}
|
|
66
|
+
*/
|
|
67
|
+
export function renderAnchorBody({ kind, ids, event, note = "" }) {
|
|
68
|
+
if (!KINDS.has(kind)) {
|
|
69
|
+
throw new Error(`renderAnchorBody: unknown kind "${kind}"`);
|
|
70
|
+
}
|
|
71
|
+
const lines = [
|
|
72
|
+
FENCE_OPEN,
|
|
73
|
+
`kind: ${kind}`,
|
|
74
|
+
`ids: ${renderIdList(ids)}`,
|
|
75
|
+
`event: ${event}`,
|
|
76
|
+
];
|
|
77
|
+
if (note) lines.push(`note: ${note}`);
|
|
78
|
+
lines.push(FENCE_CLOSE);
|
|
79
|
+
return lines.join("\n");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The set of valid kinds. */
|
|
83
|
+
export const ANCHOR_KINDS = KINDS;
|
|
84
|
+
|
|
85
|
+
function parseIdList(raw) {
|
|
86
|
+
if (!raw) return [];
|
|
87
|
+
const inner = raw.replace(/^\[/, "").replace(/\]$/, "");
|
|
88
|
+
return inner
|
|
89
|
+
.split(",")
|
|
90
|
+
.map((s) => s.trim().replace(/^["']/, "").replace(/["']$/, ""))
|
|
91
|
+
.filter(Boolean);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function renderIdList(ids) {
|
|
95
|
+
return `[${(ids ?? []).map((id) => `"${id}"`).join(", ")}]`;
|
|
96
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fold the ordered allocation-anchor sequence into id assignments and render
|
|
3
|
+
* the two derived projections — the ledger page body and the MEMORY
|
|
4
|
+
* cross-cutting row. The anchor record is authoritative; these projections hold
|
|
5
|
+
* no sole-copy state and are rebuildable from it, so erasure of a projection is
|
|
6
|
+
* a cache miss repaired by rebuild, not a loss event.
|
|
7
|
+
*
|
|
8
|
+
* Identity is the `event` key; labels are display output, so a double-allocation
|
|
9
|
+
* resolves first-published-wins and the loser is re-labeled without losing any
|
|
10
|
+
* record. The labeling policy is a `labelMode` parameter: `renumber` (the
|
|
11
|
+
* default, matching the team's established convention) keeps the labels dense
|
|
12
|
+
* and re-mints the loser at the next free index; `gapped` leaves a gap so a
|
|
13
|
+
* label never moves. Both are supported; neither is forced.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {object} AnchorRecord
|
|
18
|
+
* @property {number} id - The comment id (the serialization key).
|
|
19
|
+
* @property {string} createdAt
|
|
20
|
+
* @property {{kind: string, ids: string[], event: string, note: string}} anchor
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Fold anchors into assignments and conflicts. First-published (lowest comment
|
|
25
|
+
* `id`) wins each contested label.
|
|
26
|
+
*
|
|
27
|
+
* @param {AnchorRecord[]} anchors - Anchors in ascending `id` order.
|
|
28
|
+
* @returns {{assignments: Map<string, AnchorRecord>, conflicts: Array<{label: string, winner: AnchorRecord, losers: AnchorRecord[]}>}}
|
|
29
|
+
*/
|
|
30
|
+
export function foldAnchors(anchors) {
|
|
31
|
+
const assignments = new Map();
|
|
32
|
+
const contested = new Map();
|
|
33
|
+
for (const record of anchors) {
|
|
34
|
+
for (const label of record.anchor.ids) {
|
|
35
|
+
const existing = assignments.get(label);
|
|
36
|
+
if (!existing) {
|
|
37
|
+
assignments.set(label, record);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
// existing was published earlier (anchors are id-ordered): it wins.
|
|
41
|
+
if (!contested.has(label)) contested.set(label, []);
|
|
42
|
+
contested.get(label).push(record);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const conflicts = [...contested.entries()].map(([label, losers]) => ({
|
|
46
|
+
label,
|
|
47
|
+
winner: assignments.get(label),
|
|
48
|
+
losers,
|
|
49
|
+
}));
|
|
50
|
+
return { assignments, conflicts };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Render the ledger page body from a fold. Entries are grouped by kind and
|
|
55
|
+
* ordered by their winning anchor's id. Authored prose carried by
|
|
56
|
+
* `<!-- anchor:ID -->`-cited blocks is re-emitted in anchor-id order; a cited
|
|
57
|
+
* anchor that does not exist is reported in the returned `missingProse` list,
|
|
58
|
+
* never silently dropped. `labelMode` selects the loser re-mint guidance for a
|
|
59
|
+
* double-allocation: `renumber` (default) re-mints at the next free index,
|
|
60
|
+
* `gapped` leaves the loser's index as a gap.
|
|
61
|
+
*
|
|
62
|
+
* @param {{assignments: Map, conflicts: Array}} fold
|
|
63
|
+
* @param {Array<{anchorId: number, text: string}>} [prose] - Anchor-cited prose blocks.
|
|
64
|
+
* @param {{labelMode?: "renumber" | "gapped"}} [opts]
|
|
65
|
+
* @returns {{body: string, missingProse: number[]}}
|
|
66
|
+
*/
|
|
67
|
+
export function renderLedgerPage(
|
|
68
|
+
fold,
|
|
69
|
+
prose = [],
|
|
70
|
+
{ labelMode = "renumber" } = {},
|
|
71
|
+
) {
|
|
72
|
+
const lines = [
|
|
73
|
+
"# Parallel-Collision Ledger",
|
|
74
|
+
"",
|
|
75
|
+
"Derived projection of the allocation-anchor record. Rebuilt by `fit-wiki ledger rebuild`; do not hand-edit identifiers here — allocate at an anchor.",
|
|
76
|
+
"",
|
|
77
|
+
...renderKindSections(fold),
|
|
78
|
+
...renderConflicts(fold, labelMode),
|
|
79
|
+
];
|
|
80
|
+
const missingProse = appendProse(lines, fold, prose);
|
|
81
|
+
return { body: `${lines.join("\n").trimEnd()}\n`, missingProse };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function renderKindSections(fold) {
|
|
85
|
+
const byKind = { occ: [], nm: [], fold: [], meta: [] };
|
|
86
|
+
for (const [label, record] of fold.assignments) {
|
|
87
|
+
byKind[record.anchor.kind]?.push({ label, record });
|
|
88
|
+
}
|
|
89
|
+
const lines = [];
|
|
90
|
+
for (const [kind, heading] of KIND_HEADINGS) {
|
|
91
|
+
byKind[kind].sort((a, b) => a.record.id - b.record.id);
|
|
92
|
+
lines.push(`## ${heading}`, "");
|
|
93
|
+
for (const { label, record } of byKind[kind]) {
|
|
94
|
+
const note = record.anchor.note ? ` — ${record.anchor.note}` : "";
|
|
95
|
+
lines.push(`- ${label} (event ${record.anchor.event})${note}`);
|
|
96
|
+
}
|
|
97
|
+
lines.push("");
|
|
98
|
+
}
|
|
99
|
+
return lines;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function renderConflicts(fold, labelMode) {
|
|
103
|
+
if (fold.conflicts.length === 0) return [];
|
|
104
|
+
const guidance =
|
|
105
|
+
labelMode === "gapped"
|
|
106
|
+
? "leave the contested index as a gap"
|
|
107
|
+
: "re-mint the loser at the next free index";
|
|
108
|
+
const lines = [
|
|
109
|
+
`## Double-allocations (first-published wins; ${guidance})`,
|
|
110
|
+
"",
|
|
111
|
+
];
|
|
112
|
+
for (const c of fold.conflicts) {
|
|
113
|
+
const losers = c.losers
|
|
114
|
+
.map((l) => `${l.anchor.event} (id ${l.id})`)
|
|
115
|
+
.join(", ");
|
|
116
|
+
lines.push(
|
|
117
|
+
`- ${c.label}: winner ${c.winner.anchor.event} (id ${c.winner.id}); re-mint required for ${losers}`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
lines.push("");
|
|
121
|
+
return lines;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Extract `<!-- anchor:ID -->`-cited prose blocks from an existing ledger-page
|
|
126
|
+
* body so a rebuild re-emits them rather than dropping them. Each block runs
|
|
127
|
+
* from its citation marker to the next marker or end of input.
|
|
128
|
+
*
|
|
129
|
+
* @param {string} pageBody - The current ledger-page text.
|
|
130
|
+
* @returns {Array<{anchorId: number, text: string}>}
|
|
131
|
+
*/
|
|
132
|
+
export function extractProse(pageBody) {
|
|
133
|
+
if (!pageBody) return [];
|
|
134
|
+
const marker = /<!--\s*anchor:(\d+)\s*-->\n?/g;
|
|
135
|
+
const blocks = [];
|
|
136
|
+
let match = marker.exec(pageBody);
|
|
137
|
+
while (match) {
|
|
138
|
+
const anchorId = Number.parseInt(match[1], 10);
|
|
139
|
+
const start = match.index + match[0].length;
|
|
140
|
+
const next = marker.exec(pageBody);
|
|
141
|
+
const end = next ? next.index : pageBody.length;
|
|
142
|
+
const text = pageBody.slice(start, end).trim();
|
|
143
|
+
if (text) blocks.push({ anchorId, text });
|
|
144
|
+
match = next;
|
|
145
|
+
}
|
|
146
|
+
return blocks;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function appendProse(lines, fold, prose) {
|
|
150
|
+
const missingProse = [];
|
|
151
|
+
const knownIds = new Set([...fold.assignments.values()].map((r) => r.id));
|
|
152
|
+
const ordered = [...prose].sort((a, b) => a.anchorId - b.anchorId);
|
|
153
|
+
if (ordered.length === 0) return missingProse;
|
|
154
|
+
lines.push("## Conventions and floors (binding)", "");
|
|
155
|
+
for (const block of ordered) {
|
|
156
|
+
if (!knownIds.has(block.anchorId)) missingProse.push(block.anchorId);
|
|
157
|
+
lines.push(`<!-- anchor:${block.anchorId} -->`, block.text, "");
|
|
158
|
+
}
|
|
159
|
+
return missingProse;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Render the MEMORY cross-cutting row counters from a fold: next-free index per
|
|
164
|
+
* kind, plus the total assigned count.
|
|
165
|
+
*
|
|
166
|
+
* @param {{assignments: Map}} fold
|
|
167
|
+
* @returns {string}
|
|
168
|
+
*/
|
|
169
|
+
export function renderMemoryRow(fold) {
|
|
170
|
+
const next = {
|
|
171
|
+
occ: nextFree(fold, "occ", "#"),
|
|
172
|
+
nm: nextFree(fold, "nm", "NM"),
|
|
173
|
+
fold: nextFree(fold, "fold", "n="),
|
|
174
|
+
meta: nextFree(fold, "meta", "M"),
|
|
175
|
+
};
|
|
176
|
+
return (
|
|
177
|
+
`Parallel-collision allocation (derived from the anchor record): ` +
|
|
178
|
+
`${fold.assignments.size} ids assigned; next free #${next.occ}, NM${next.nm}, ` +
|
|
179
|
+
`n=${next.fold}, M${next.meta}. Allocate at an anchor, never by editing this row.`
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const MEMORY_REGION_OPEN = "<!-- ledger:memory-row -->";
|
|
184
|
+
const MEMORY_REGION_CLOSE = "<!-- /ledger:memory-row -->";
|
|
185
|
+
const MEMORY_REGION_RE = new RegExp(
|
|
186
|
+
`${MEMORY_REGION_OPEN}\\n[\\s\\S]*?\\n${MEMORY_REGION_CLOSE}`,
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Write the derived MEMORY-row counters into a delimited region of a MEMORY.md
|
|
191
|
+
* body, so the row is a rebuildable projection of the anchor record
|
|
192
|
+
* without overwriting the surrounding authored narrative. The region is the
|
|
193
|
+
* only sole-copy-free surface: its interior is fully regenerated, everything
|
|
194
|
+
* outside it is preserved byte-for-byte. If the region is absent it is appended
|
|
195
|
+
* under a heading; if present, only its interior is replaced.
|
|
196
|
+
*
|
|
197
|
+
* @param {string} memoryBody - Current `MEMORY.md` text.
|
|
198
|
+
* @param {{assignments: Map}} fold
|
|
199
|
+
* @returns {string} The updated body.
|
|
200
|
+
*/
|
|
201
|
+
export function writeMemoryRowRegion(memoryBody, fold) {
|
|
202
|
+
const region = `${MEMORY_REGION_OPEN}\n${renderMemoryRow(fold)}\n${MEMORY_REGION_CLOSE}`;
|
|
203
|
+
const body = memoryBody ?? "";
|
|
204
|
+
if (MEMORY_REGION_RE.test(body)) {
|
|
205
|
+
return body.replace(MEMORY_REGION_RE, region);
|
|
206
|
+
}
|
|
207
|
+
const trimmed = body.trimEnd();
|
|
208
|
+
return `${trimmed}${trimmed ? "\n\n" : ""}${region}\n`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Extract the derived MEMORY-row region interior from a MEMORY.md body, or
|
|
213
|
+
* `null` if the region is absent. Used by `verify` to diff the projection
|
|
214
|
+
* surface alone, never the surrounding narrative.
|
|
215
|
+
*
|
|
216
|
+
* @param {string} memoryBody
|
|
217
|
+
* @returns {string|null}
|
|
218
|
+
*/
|
|
219
|
+
export function readMemoryRowRegion(memoryBody) {
|
|
220
|
+
const m = (memoryBody ?? "").match(MEMORY_REGION_RE);
|
|
221
|
+
if (!m) return null;
|
|
222
|
+
return m[0]
|
|
223
|
+
.replace(`${MEMORY_REGION_OPEN}\n`, "")
|
|
224
|
+
.replace(`\n${MEMORY_REGION_CLOSE}`, "");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const KIND_HEADINGS = [
|
|
228
|
+
["occ", "Occurrences"],
|
|
229
|
+
["nm", "Near-misses"],
|
|
230
|
+
["fold", "Folds"],
|
|
231
|
+
["meta", "Meta-instances"],
|
|
232
|
+
];
|
|
233
|
+
|
|
234
|
+
function nextFree(fold, kind, prefix) {
|
|
235
|
+
let max = 0;
|
|
236
|
+
for (const [label, record] of fold.assignments) {
|
|
237
|
+
if (record.anchor.kind !== kind) continue;
|
|
238
|
+
const n = Number.parseInt(label.replace(prefix, ""), 10);
|
|
239
|
+
if (Number.isFinite(n) && n > max) max = n;
|
|
240
|
+
}
|
|
241
|
+
return max + 1;
|
|
242
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { parseAnchor } from "./anchor.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The obstacle issue whose comment thread is the allocation-anchor surface.
|
|
5
|
+
* GitHub serializes comment creation and assigns a monotonic `id`, so that `id`
|
|
6
|
+
* order is the allocation serialization no merge can erase.
|
|
7
|
+
*/
|
|
8
|
+
export const DEFAULT_ANCHOR_ISSUE = 1564;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Read every allocation anchor from the obstacle issue's comment thread, in
|
|
12
|
+
* server `id` order ascending. The lowest comment `id` claiming a given label
|
|
13
|
+
* is its winner (first published wins). Comments carrying no anchor block are
|
|
14
|
+
* skipped.
|
|
15
|
+
*
|
|
16
|
+
* @param {object} ghClient - A GhClient (or mock) exposing `apiGetPaginated`.
|
|
17
|
+
* @param {object} opts
|
|
18
|
+
* @param {string} opts.owner - Repository owner.
|
|
19
|
+
* @param {string} opts.repo - Repository name.
|
|
20
|
+
* @param {number} [opts.issue] - Issue number (defaults to the obstacle issue).
|
|
21
|
+
* @param {string} [opts.cwd] - Working directory for the gh invocation.
|
|
22
|
+
* @returns {Promise<Array<{id: number, createdAt: string, anchor: object}>>}
|
|
23
|
+
*/
|
|
24
|
+
export async function readAnchors(
|
|
25
|
+
ghClient,
|
|
26
|
+
{ owner, repo, issue = DEFAULT_ANCHOR_ISSUE, cwd } = {},
|
|
27
|
+
) {
|
|
28
|
+
if (!owner || !repo) {
|
|
29
|
+
throw new Error("readAnchors: owner and repo are required");
|
|
30
|
+
}
|
|
31
|
+
const path = `repos/${owner}/${repo}/issues/${issue}/comments`;
|
|
32
|
+
const comments = await ghClient.apiGetPaginated(path, { cwd });
|
|
33
|
+
const anchors = [];
|
|
34
|
+
for (const comment of comments ?? []) {
|
|
35
|
+
const anchor = parseAnchor(comment.body ?? "");
|
|
36
|
+
if (!anchor) continue;
|
|
37
|
+
anchors.push({
|
|
38
|
+
id: comment.id,
|
|
39
|
+
createdAt: comment.created_at,
|
|
40
|
+
anchor,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
anchors.sort((a, b) => a.id - b.id);
|
|
44
|
+
return anchors;
|
|
45
|
+
}
|