@docsworth/cli 0.0.0-stage → 0.3.0
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/LICENSE +55 -0
- package/README.md +238 -2
- package/dist/cli.d.ts +11 -0
- package/dist/cli.js +458 -0
- package/dist/core/acl.d.ts +37 -0
- package/dist/core/acl.js +136 -0
- package/dist/core/adopt.d.ts +68 -0
- package/dist/core/adopt.js +665 -0
- package/dist/core/audit-log.d.ts +133 -0
- package/dist/core/audit-log.js +290 -0
- package/dist/core/blocks.d.ts +35 -0
- package/dist/core/blocks.js +417 -0
- package/dist/core/codeowners.d.ts +62 -0
- package/dist/core/codeowners.js +133 -0
- package/dist/core/config.d.ts +101 -0
- package/dist/core/config.js +175 -0
- package/dist/core/coverage.d.ts +25 -0
- package/dist/core/coverage.js +41 -0
- package/dist/core/duration.d.ts +20 -0
- package/dist/core/duration.js +60 -0
- package/dist/core/edits.d.ts +13 -0
- package/dist/core/edits.js +20 -0
- package/dist/core/errors.d.ts +11 -0
- package/dist/core/errors.js +13 -0
- package/dist/core/frontmatter.d.ts +29 -0
- package/dist/core/frontmatter.js +49 -0
- package/dist/core/gate.d.ts +17 -0
- package/dist/core/gate.js +25 -0
- package/dist/core/gfm.d.ts +13 -0
- package/dist/core/gfm.js +244 -0
- package/dist/core/github.d.ts +152 -0
- package/dist/core/github.js +234 -0
- package/dist/core/host.d.ts +125 -0
- package/dist/core/host.js +22 -0
- package/dist/core/identity.d.ts +39 -0
- package/dist/core/identity.js +127 -0
- package/dist/core/init.d.ts +25 -0
- package/dist/core/init.js +86 -0
- package/dist/core/links.d.ts +82 -0
- package/dist/core/links.js +345 -0
- package/dist/core/lint.d.ts +16 -0
- package/dist/core/lint.js +149 -0
- package/dist/core/mcp/audit-transport.d.ts +16 -0
- package/dist/core/mcp/audit-transport.js +238 -0
- package/dist/core/mcp/server.d.ts +19 -0
- package/dist/core/mcp/server.js +216 -0
- package/dist/core/mcp/text.d.ts +14 -0
- package/dist/core/mcp/text.js +43 -0
- package/dist/core/mirror-view.d.ts +68 -0
- package/dist/core/mirror-view.js +367 -0
- package/dist/core/new-page.d.ts +52 -0
- package/dist/core/new-page.js +49 -0
- package/dist/core/policy.d.ts +10 -0
- package/dist/core/policy.js +30 -0
- package/dist/core/polish.d.ts +42 -0
- package/dist/core/polish.js +1000 -0
- package/dist/core/printable.d.ts +4 -0
- package/dist/core/printable.js +16 -0
- package/dist/core/references.d.ts +87 -0
- package/dist/core/references.js +1744 -0
- package/dist/core/repo.d.ts +46 -0
- package/dist/core/repo.js +118 -0
- package/dist/core/schema.d.ts +50 -0
- package/dist/core/schema.js +51 -0
- package/dist/core/search.d.ts +32 -0
- package/dist/core/search.js +132 -0
- package/dist/core/serve/auth.d.ts +23 -0
- package/dist/core/serve/auth.js +38 -0
- package/dist/core/serve/server.d.ts +21 -0
- package/dist/core/serve/server.js +138 -0
- package/dist/core/serve/sessions.d.ts +40 -0
- package/dist/core/serve/sessions.js +203 -0
- package/dist/core/serve/token.d.ts +10 -0
- package/dist/core/serve/token.js +96 -0
- package/dist/core/service.d.ts +473 -0
- package/dist/core/service.js +973 -0
- package/dist/core/sla.d.ts +16 -0
- package/dist/core/sla.js +20 -0
- package/dist/core/stale.d.ts +16 -0
- package/dist/core/stale.js +24 -0
- package/dist/core/tool-registry.d.ts +31 -0
- package/dist/core/tool-registry.js +80 -0
- package/dist/core/url.d.ts +4 -0
- package/dist/core/url.js +31 -0
- package/dist/core/verify-pages.d.ts +19 -0
- package/dist/core/verify-pages.js +110 -0
- package/dist/core/version.d.ts +22 -0
- package/dist/core/version.js +32 -0
- package/dist/core/writes.d.ts +58 -0
- package/dist/core/writes.js +113 -0
- package/dist/web/assets/index-8J-FUbb0.js +29 -0
- package/dist/web/assets/index-BGpAZzeM.css +1 -0
- package/dist/web/index.html +14 -0
- package/docs/format-spec.md +349 -0
- package/package.json +75 -4
- package/plugin/.claude-plugin/plugin.json +8 -0
- package/plugin/.mcp.json +8 -0
- package/plugin/bin/docsworth-mcp.mjs +46 -0
- package/plugin/skills/draft-runbook/SKILL.md +75 -0
- package/plugin/skills/polish-page/SKILL.md +64 -0
- package/plugin/skills/update-after-incident/SKILL.md +59 -0
- package/plugin/skills/verify-page/SKILL.md +46 -0
- package/templates/github/knowledge.yml +51 -0
package/dist/core/gfm.js
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// GFM tables on commonmark.js: the parse the web app renders pages from
|
|
2
|
+
// (packages/ui/src/markdown.ts), and search's text snippets read (`plainText` in
|
|
3
|
+
// search.ts), so a snippet holds exactly the words the page shows.
|
|
4
|
+
import { Node, Parser } from "commonmark";
|
|
5
|
+
/**
|
|
6
|
+
* Whether a line, indented as it was, starts a block of its own: CommonMark's own grammar,
|
|
7
|
+
* by parsing the line alone. A table row is a paragraph; a list item, indented code or an
|
|
8
|
+
* HTML tag isn't, and in GFM it ends the table.
|
|
9
|
+
*/
|
|
10
|
+
function startsBlock(line, indent) {
|
|
11
|
+
const first = new Parser().parse(" ".repeat(indent) + line).firstChild;
|
|
12
|
+
return first !== null && first.type !== "paragraph";
|
|
13
|
+
}
|
|
14
|
+
/** A row's cells: outer pipes optional, `\|` is a pipe inside a cell (in code too, as GFM). */
|
|
15
|
+
function cellsOf(line) {
|
|
16
|
+
let s = line.trim();
|
|
17
|
+
if (s.startsWith("|"))
|
|
18
|
+
s = s.slice(1);
|
|
19
|
+
if (s.endsWith("|") && !s.endsWith("\\|"))
|
|
20
|
+
s = s.slice(0, -1);
|
|
21
|
+
return s.split(/(?<!\\)\|/).map((c) => c.trim().replace(/\\\|/g, "|"));
|
|
22
|
+
}
|
|
23
|
+
/** The alignments a delimiter row (`| :--- | ---: |`) sets, or null if the line isn't one. */
|
|
24
|
+
function delimiterRow(line) {
|
|
25
|
+
if (!line.includes("|"))
|
|
26
|
+
return null;
|
|
27
|
+
const cells = cellsOf(line);
|
|
28
|
+
if (!cells.every((c) => /^:?-+:?$/.test(c)))
|
|
29
|
+
return null;
|
|
30
|
+
return cells.map((c) => (c.startsWith(":") ? (c.endsWith(":") ? "center" : "left") : c.endsWith(":") ? "right" : null));
|
|
31
|
+
}
|
|
32
|
+
/** A node that renders as the given fixed tags around its children. Blocks get their own line. */
|
|
33
|
+
function custom(type, onEnter, onExit) {
|
|
34
|
+
const n = new Node(type);
|
|
35
|
+
n.onEnter = onEnter;
|
|
36
|
+
n.onExit = onExit;
|
|
37
|
+
return n;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Replace `block` (a paragraph, or a setext heading with its `underline`) with the table its
|
|
41
|
+
* text holds, if it holds one. `info[i]` is what line i was.
|
|
42
|
+
*/
|
|
43
|
+
function rebuildTable(block, text, info, underline, budget, inlines, blocks) {
|
|
44
|
+
const lines = text.replace(/\n$/, "").split("\n");
|
|
45
|
+
const endsTable = (i) => info[i]?.lazy || startsBlock(lines[i], info[i]?.indent ?? 0);
|
|
46
|
+
for (let h = 0; h + 1 < lines.length; h++) {
|
|
47
|
+
if (info[h]?.lazy || info[h + 1]?.lazy)
|
|
48
|
+
continue;
|
|
49
|
+
const align = delimiterRow(lines[h + 1]);
|
|
50
|
+
if (!align || cellsOf(lines[h]).length !== align.length)
|
|
51
|
+
continue;
|
|
52
|
+
let end = h + 2;
|
|
53
|
+
while (end < lines.length && !endsTable(end))
|
|
54
|
+
end++;
|
|
55
|
+
const setext = block.type === "heading";
|
|
56
|
+
const rule = setext && /^-{3,}$/.test(underline);
|
|
57
|
+
const early = end < lines.length;
|
|
58
|
+
const bodyLines = [...lines.slice(h + 2, end), ...(setext && !rule && !early ? [underline] : [])];
|
|
59
|
+
// Padding a short row costs no input, so a table's cells could outgrow the page by far
|
|
60
|
+
// (800 header cells and 800 one-cell rows). A table past the page's budget stays text.
|
|
61
|
+
const cells = align.length * (1 + bodyLines.length);
|
|
62
|
+
if (cells > budget.cells)
|
|
63
|
+
return;
|
|
64
|
+
budget.cells -= cells;
|
|
65
|
+
const row = (line, tag) => {
|
|
66
|
+
const tr = custom("custom_block", "<tr>", "</tr>");
|
|
67
|
+
const cells = cellsOf(line);
|
|
68
|
+
align.forEach((a, i) => {
|
|
69
|
+
const cell = custom("custom_inline", a ? `<${tag} class="dw-align-${a}">` : `<${tag}>`, `</${tag}>`);
|
|
70
|
+
const content = inlines(cells[i] ?? "");
|
|
71
|
+
while (content.firstChild)
|
|
72
|
+
cell.appendChild(content.firstChild);
|
|
73
|
+
tr.appendChild(cell);
|
|
74
|
+
});
|
|
75
|
+
return tr;
|
|
76
|
+
};
|
|
77
|
+
const table = custom("custom_block", '<div class="dw-table-scroll"><table>', "</table></div>");
|
|
78
|
+
const head = custom("custom_block", "<thead>", "</thead>");
|
|
79
|
+
head.appendChild(row(lines[h], "th"));
|
|
80
|
+
table.appendChild(head);
|
|
81
|
+
if (bodyLines.length > 0) {
|
|
82
|
+
const body = custom("custom_block", "<tbody>", "</tbody>");
|
|
83
|
+
for (const line of bodyLines)
|
|
84
|
+
body.appendChild(row(line, "td"));
|
|
85
|
+
table.appendChild(body);
|
|
86
|
+
}
|
|
87
|
+
if (h > 0)
|
|
88
|
+
block.insertBefore(inlines(lines.slice(0, h).join("\n")));
|
|
89
|
+
block.insertBefore(table);
|
|
90
|
+
if (early) {
|
|
91
|
+
// The lines after the table, indented as they were, read as blocks again.
|
|
92
|
+
const rest = blocks(lines.slice(end).map((line, i) => " ".repeat(info[end + i]?.indent ?? 0) + line).join("\n"));
|
|
93
|
+
while (rest.firstChild)
|
|
94
|
+
block.insertBefore(rest.firstChild);
|
|
95
|
+
}
|
|
96
|
+
if (rule)
|
|
97
|
+
block.insertBefore(new Node("thematic_break"));
|
|
98
|
+
else if (setext && early)
|
|
99
|
+
block.insertBefore(inlines(underline));
|
|
100
|
+
block.unlink();
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* commonmark.js's tree, with GFM tables as tables. `refmap`: link definitions from the page
|
|
106
|
+
* this text was cut from (the blocks after a table), which its own may add to. `budget.left`:
|
|
107
|
+
* how many more characters may be parsed again after tables, for the whole page. Tables chained
|
|
108
|
+
* in one paragraph (each ended by a line that can't interrupt it) re-parse ever shorter tails,
|
|
109
|
+
* which is quadratic, and nests a parse per table; page text is untrusted, so past eight
|
|
110
|
+
* times the page's length, or 16 tables deep, the tail is read as plain CommonMark instead
|
|
111
|
+
* (its tables as text). Real pages chain a table or two.
|
|
112
|
+
*/
|
|
113
|
+
function parse(markdown, refmap, budget = { left: 8 * markdown.length, cells: markdown.length }, depth = 0) {
|
|
114
|
+
const parser = new Parser();
|
|
115
|
+
const internals = parser;
|
|
116
|
+
const inline = internals.inlineParser;
|
|
117
|
+
const { addLine, processInlines } = internals;
|
|
118
|
+
if (typeof inline?.parse !== "function" || typeof addLine !== "function" || typeof processInlines !== "function")
|
|
119
|
+
return parser.parse(markdown);
|
|
120
|
+
internals.processInlines = function (block) {
|
|
121
|
+
// The tail's own definitions win: GitHub links a label defined again after a table to
|
|
122
|
+
// that later definition.
|
|
123
|
+
this.refmap = { ...refmap, ...this.refmap };
|
|
124
|
+
processInlines.call(this, block);
|
|
125
|
+
};
|
|
126
|
+
// Each line a paragraph is given: whether it came lazily (its containers unmatched), and its
|
|
127
|
+
// indent. Keyed by the paragraph's sourcepos array: commonmark.js updates it in place, and a
|
|
128
|
+
// setext heading made from the paragraph is given the same array.
|
|
129
|
+
const lineInfo = new WeakMap();
|
|
130
|
+
internals.addLine = function () {
|
|
131
|
+
const key = this.tip.sourcepos;
|
|
132
|
+
if (this.tip.type === "paragraph" && key) {
|
|
133
|
+
let lines = lineInfo.get(key);
|
|
134
|
+
if (!lines)
|
|
135
|
+
lineInfo.set(key, (lines = []));
|
|
136
|
+
lines.push({ lazy: !this.allClosed, indent: this.indent });
|
|
137
|
+
}
|
|
138
|
+
addLine.call(this);
|
|
139
|
+
};
|
|
140
|
+
// The placeholder is never a link definition: redacted, `[term](page.md): word` reads
|
|
141
|
+
// `[restricted page]: word`, which CommonMark would take as one and drop from the page.
|
|
142
|
+
const { parseReference } = inline;
|
|
143
|
+
if (typeof parseReference === "function")
|
|
144
|
+
inline.parseReference = (s, refmap) => (PLACEHOLDER_DEFINITION.test(s) ? 0 : parseReference.call(inline, s, refmap));
|
|
145
|
+
const parseInlines = inline.parse.bind(inline);
|
|
146
|
+
const raw = new Map();
|
|
147
|
+
inline.parse = (block) => {
|
|
148
|
+
const text = block._string_content;
|
|
149
|
+
if ((block.type === "paragraph" || block.type === "heading") && text?.includes("|"))
|
|
150
|
+
raw.set(block, text);
|
|
151
|
+
parseInlines(block);
|
|
152
|
+
};
|
|
153
|
+
const doc = parser.parse(markdown);
|
|
154
|
+
const inlines = (text) => {
|
|
155
|
+
const p = new Node("paragraph");
|
|
156
|
+
p._string_content = text;
|
|
157
|
+
parseInlines(p);
|
|
158
|
+
return p;
|
|
159
|
+
};
|
|
160
|
+
const source = markdown.split(/\r\n|\n|\r/);
|
|
161
|
+
for (const [block, text] of raw) {
|
|
162
|
+
// Reference definitions are taken from the front of a paragraph: its last lines are the text's.
|
|
163
|
+
const lines = text.replace(/\n$/, "").split("\n").length;
|
|
164
|
+
// A setext heading ends on its underline's line, at the underline's last character.
|
|
165
|
+
const [endLine = 0, endCol = 0] = block.sourcepos?.[1] ?? [];
|
|
166
|
+
const underline = block.type === "heading" ? (/([=-]+)\s*$/.exec(source[endLine - 1]?.slice(0, endCol) ?? "")?.[1] ?? "") : "";
|
|
167
|
+
const blocks = (rest) => {
|
|
168
|
+
if (rest.length > budget.left || depth >= 16)
|
|
169
|
+
return new Parser().parse(rest);
|
|
170
|
+
budget.left -= rest.length;
|
|
171
|
+
return parse(rest, internals.refmap, budget, depth + 1);
|
|
172
|
+
};
|
|
173
|
+
rebuildTable(block, text, ((block.sourcepos && lineInfo.get(block.sourcepos)) || []).slice(-lines), underline, budget, inlines, blocks);
|
|
174
|
+
}
|
|
175
|
+
return doc;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* `[restricted page]` (`RESTRICTED`, src/core/acl.ts): the placeholder redaction leaves for a
|
|
179
|
+
* link to a page the caller can't see. Not imported: acl.ts would bring its dependencies into
|
|
180
|
+
* the web app.
|
|
181
|
+
*/
|
|
182
|
+
const PLACEHOLDER = "[restricted page]";
|
|
183
|
+
const MAX_NESTING = 32;
|
|
184
|
+
const PLACEHOLDER_DEFINITION = /^\[\s*restricted\s+page\s*\]:/i;
|
|
185
|
+
/**
|
|
186
|
+
* commonmark.js's tree for `markdown`, with GFM tables as tables (see above). The placeholder
|
|
187
|
+
* is never a link or an image, whatever a page writes around it: a definition for it, a
|
|
188
|
+
* `(target)` or `[label]` right after the link redaction replaced, or a link around it. A
|
|
189
|
+
* link or image labelled "restricted page" becomes the placeholder's text, and one whose
|
|
190
|
+
* label holds the placeholder becomes its label's text.
|
|
191
|
+
*/
|
|
192
|
+
export function parseGfm(markdown) {
|
|
193
|
+
const doc = parse(markdown);
|
|
194
|
+
// Links and images nested deeper than MAX_NESTING are read as their text. Each check below
|
|
195
|
+
// walks what's inside a link, so unbounded nesting would make it quadratic (page text is
|
|
196
|
+
// untrusted); 32 levels is far past anything a page means.
|
|
197
|
+
const deep = [];
|
|
198
|
+
let nesting = 0;
|
|
199
|
+
const outer = doc.walker();
|
|
200
|
+
for (let e = outer.next(); e; e = outer.next()) {
|
|
201
|
+
if (e.node.type !== "link" && e.node.type !== "image")
|
|
202
|
+
continue;
|
|
203
|
+
if (!e.entering)
|
|
204
|
+
nesting--;
|
|
205
|
+
else if (++nesting > MAX_NESTING)
|
|
206
|
+
deep.push(e.node);
|
|
207
|
+
}
|
|
208
|
+
for (const n of deep) {
|
|
209
|
+
while (n.firstChild)
|
|
210
|
+
n.insertBefore(n.firstChild);
|
|
211
|
+
n.unlink();
|
|
212
|
+
}
|
|
213
|
+
// Innermost first: a link is judged by its label once what's inside it is replaced, so an
|
|
214
|
+
// image inside a link that becomes the placeholder takes the link's target away too.
|
|
215
|
+
const links = [];
|
|
216
|
+
const walker = doc.walker();
|
|
217
|
+
for (let e = walker.next(); e; e = walker.next())
|
|
218
|
+
if (!e.entering && (e.node.type === "link" || e.node.type === "image"))
|
|
219
|
+
links.push(e.node);
|
|
220
|
+
for (const n of links) {
|
|
221
|
+
const label = textIn(n).replace(/\s+/g, " ").trim();
|
|
222
|
+
const literal = `[${label.toLowerCase()}]` === PLACEHOLDER ? PLACEHOLDER : label.toLowerCase().includes(PLACEHOLDER) ? label : null;
|
|
223
|
+
if (literal === null)
|
|
224
|
+
continue;
|
|
225
|
+
const text = new Node("text");
|
|
226
|
+
text.literal = literal;
|
|
227
|
+
n.insertBefore(text);
|
|
228
|
+
n.unlink();
|
|
229
|
+
}
|
|
230
|
+
return doc;
|
|
231
|
+
}
|
|
232
|
+
/** The text and code inside `node`: a link's or image's label, as the reader sees it. */
|
|
233
|
+
export function textIn(node) {
|
|
234
|
+
let out = "";
|
|
235
|
+
const walker = node.walker();
|
|
236
|
+
for (let e = walker.next(); e; e = walker.next())
|
|
237
|
+
if (e.entering && (e.node.type === "text" || e.node.type === "code"))
|
|
238
|
+
out += e.node.literal ?? "";
|
|
239
|
+
return out;
|
|
240
|
+
}
|
|
241
|
+
/** A table cell (`th` or `td`) in a tree from `parseGfm`; its children are the cell's content. */
|
|
242
|
+
export function isTableCell(node) {
|
|
243
|
+
return node.type === "custom_inline" && /^<t[hd][\s>]/.test(node.onEnter ?? "");
|
|
244
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
export type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
|
|
2
|
+
export declare class GitHubError extends Error {
|
|
3
|
+
/** The HTTP status; 0 when GitHub didn't answer (a network error or a timeout). */
|
|
4
|
+
readonly status: number;
|
|
5
|
+
readonly method: string;
|
|
6
|
+
readonly path: string;
|
|
7
|
+
/** Response headers, e.g. `x-github-sso` on a 403 from an SSO-enforcing org. */
|
|
8
|
+
readonly headers: Headers | null;
|
|
9
|
+
/** GitHub's rate limits: a 429, or a 403 that says so (primary or secondary limit). */
|
|
10
|
+
readonly rateLimited: boolean;
|
|
11
|
+
/** Seconds GitHub asked to wait (`retry-after`, or until `x-ratelimit-reset`), if it said. */
|
|
12
|
+
readonly retryAfter: number | null;
|
|
13
|
+
constructor(
|
|
14
|
+
/** The HTTP status; 0 when GitHub didn't answer (a network error or a timeout). */
|
|
15
|
+
status: number, method: string, path: string, message: string,
|
|
16
|
+
/** Response headers, e.g. `x-github-sso` on a 403 from an SSO-enforcing org. */
|
|
17
|
+
headers?: Headers | null, now?: number);
|
|
18
|
+
}
|
|
19
|
+
export interface RepoInfo {
|
|
20
|
+
full_name: string;
|
|
21
|
+
default_branch: string;
|
|
22
|
+
private: boolean;
|
|
23
|
+
permissions?: {
|
|
24
|
+
admin?: boolean;
|
|
25
|
+
push?: boolean;
|
|
26
|
+
pull?: boolean;
|
|
27
|
+
};
|
|
28
|
+
owner?: {
|
|
29
|
+
login: string;
|
|
30
|
+
id: number;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
export interface TeamInfo {
|
|
34
|
+
slug: string;
|
|
35
|
+
organization: {
|
|
36
|
+
login: string;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export interface ContentFile {
|
|
40
|
+
sha: string;
|
|
41
|
+
content: string;
|
|
42
|
+
encoding: string;
|
|
43
|
+
path: string;
|
|
44
|
+
}
|
|
45
|
+
export interface PullInfo {
|
|
46
|
+
number: number;
|
|
47
|
+
html_url: string;
|
|
48
|
+
head: {
|
|
49
|
+
ref: string;
|
|
50
|
+
};
|
|
51
|
+
base: {
|
|
52
|
+
ref: string;
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
export declare class GitHubClient {
|
|
56
|
+
private readonly token;
|
|
57
|
+
private readonly fetch;
|
|
58
|
+
private readonly baseUrl;
|
|
59
|
+
private readonly sleep;
|
|
60
|
+
private readonly now;
|
|
61
|
+
private readonly timeoutMs;
|
|
62
|
+
constructor(token: string, opts?: {
|
|
63
|
+
fetch?: FetchLike;
|
|
64
|
+
baseUrl?: string;
|
|
65
|
+
sleep?: (ms: number) => Promise<void>;
|
|
66
|
+
now?: () => number;
|
|
67
|
+
timeoutMs?: number;
|
|
68
|
+
});
|
|
69
|
+
request<T>(method: string, path: string, body?: unknown, accept?: string): Promise<{
|
|
70
|
+
data: T;
|
|
71
|
+
headers: Headers;
|
|
72
|
+
}>;
|
|
73
|
+
private get;
|
|
74
|
+
/** Follow `Link: rel="next"` pagination; `onHeaders` sees every page's headers. */
|
|
75
|
+
paginate<T>(path: string, onHeaders?: (h: Headers) => void): Promise<T[]>;
|
|
76
|
+
getUser(): Promise<{
|
|
77
|
+
login: string;
|
|
78
|
+
}>;
|
|
79
|
+
getRepo(repo: string): Promise<RepoInfo>;
|
|
80
|
+
/**
|
|
81
|
+
* The caller's teams, and the ids of orgs GitHub left out because the token isn't
|
|
82
|
+
* authorized for their SAML SSO (`x-github-sso: partial-results; organizations=…`).
|
|
83
|
+
*/
|
|
84
|
+
listMyTeams(): Promise<{
|
|
85
|
+
teams: TeamInfo[];
|
|
86
|
+
ssoWithheldOrgIds: string[];
|
|
87
|
+
}>;
|
|
88
|
+
/** Logins of a team's members (needs read:org, or members:read on a fine-grained token). */
|
|
89
|
+
listTeamMembers(org: string, team: string): Promise<string[]>;
|
|
90
|
+
/**
|
|
91
|
+
* Does a team have write access to a repo? GitHub ignores a CODEOWNERS team without it.
|
|
92
|
+
* true/false when GitHub shows the permission; null on a 404, which GitHub gives both
|
|
93
|
+
* for "no access" and for a team this caller can't see, so it proves nothing.
|
|
94
|
+
*/
|
|
95
|
+
teamCanPush(org: string, team: string, repo: string): Promise<boolean | null>;
|
|
96
|
+
/** A user's permission on a repo: admin, maintain, write, triage, read or none. */
|
|
97
|
+
getUserPermission(repo: string, login: string): Promise<string>;
|
|
98
|
+
/** Ruleset rules that apply to a branch (readable with read access). */
|
|
99
|
+
getBranchRules(repo: string, branch: string): Promise<{
|
|
100
|
+
type: string;
|
|
101
|
+
parameters?: Record<string, unknown>;
|
|
102
|
+
ruleset_id?: number;
|
|
103
|
+
}[]>;
|
|
104
|
+
/** A ruleset, with `bypass_actors` when this token may see them (admins). */
|
|
105
|
+
getRuleset(repo: string, id: number): Promise<{
|
|
106
|
+
bypass_actors?: {
|
|
107
|
+
actor_type: string;
|
|
108
|
+
bypass_mode?: string;
|
|
109
|
+
}[];
|
|
110
|
+
}>;
|
|
111
|
+
/** Classic branch protection (GitHub answers 403/404 to callers without admin). */
|
|
112
|
+
getBranchProtection(repo: string, branch: string): Promise<{
|
|
113
|
+
required_pull_request_reviews?: {
|
|
114
|
+
require_code_owner_reviews?: boolean;
|
|
115
|
+
bypass_pull_request_allowances?: {
|
|
116
|
+
users?: unknown[];
|
|
117
|
+
teams?: unknown[];
|
|
118
|
+
apps?: unknown[];
|
|
119
|
+
};
|
|
120
|
+
};
|
|
121
|
+
enforce_admins?: {
|
|
122
|
+
enabled?: boolean;
|
|
123
|
+
};
|
|
124
|
+
}>;
|
|
125
|
+
getFile(repo: string, path: string, ref: string): Promise<ContentFile>;
|
|
126
|
+
getBranchSha(repo: string, branch: string): Promise<string>;
|
|
127
|
+
createBranch(repo: string, branch: string, sha: string): Promise<void>;
|
|
128
|
+
deleteBranch(repo: string, branch: string): Promise<void>;
|
|
129
|
+
/** Always pass `branch`: omitting it makes GitHub write to the default branch. */
|
|
130
|
+
putFile(repo: string, path: string, args: {
|
|
131
|
+
message: string;
|
|
132
|
+
content: string;
|
|
133
|
+
sha: string | undefined;
|
|
134
|
+
branch: string;
|
|
135
|
+
}): Promise<void>;
|
|
136
|
+
/** An open PR from `branch` into `base` in the same repo, if one exists. */
|
|
137
|
+
findPull(repo: string, branch: string, base: string): Promise<PullInfo | null>;
|
|
138
|
+
createPull(repo: string, args: {
|
|
139
|
+
title: string;
|
|
140
|
+
head: string;
|
|
141
|
+
base: string;
|
|
142
|
+
body: string;
|
|
143
|
+
draft?: boolean;
|
|
144
|
+
}): Promise<PullInfo>;
|
|
145
|
+
}
|
|
146
|
+
/** `required; url=https://…` or `partial-results; organizations=1,2`. */
|
|
147
|
+
export declare function parseSsoHeader(value: string | null): {
|
|
148
|
+
required: boolean;
|
|
149
|
+
url: string | null;
|
|
150
|
+
organizations: string[];
|
|
151
|
+
};
|
|
152
|
+
export declare function decodeContent(file: ContentFile): string;
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// Minimal GitHub REST client over fetch. Deliberately small: every call the server makes
|
|
2
|
+
// is listed here, which keeps the write path reviewable and lets tests inject a fake.
|
|
3
|
+
import { insecureUrl, urlForError } from "./url.js";
|
|
4
|
+
export class GitHubError extends Error {
|
|
5
|
+
status;
|
|
6
|
+
method;
|
|
7
|
+
path;
|
|
8
|
+
headers;
|
|
9
|
+
/** GitHub's rate limits: a 429, or a 403 that says so (primary or secondary limit). */
|
|
10
|
+
rateLimited;
|
|
11
|
+
/** Seconds GitHub asked to wait (`retry-after`, or until `x-ratelimit-reset`), if it said. */
|
|
12
|
+
retryAfter;
|
|
13
|
+
constructor(
|
|
14
|
+
/** The HTTP status; 0 when GitHub didn't answer (a network error or a timeout). */
|
|
15
|
+
status, method, path, message,
|
|
16
|
+
/** Response headers, e.g. `x-github-sso` on a 403 from an SSO-enforcing org. */
|
|
17
|
+
headers = null, now = Date.now()) {
|
|
18
|
+
const limited = status === 429 ||
|
|
19
|
+
// GitHub's secondary limits come as a 403 (or 429) with `retry-after`, its primary
|
|
20
|
+
// limit with `x-ratelimit-remaining: 0`; the message says so too.
|
|
21
|
+
(status === 403 && (headers?.get("x-ratelimit-remaining") === "0" || Boolean(headers?.has("retry-after")) || /rate limit/i.test(message)));
|
|
22
|
+
const after = Number(headers?.get("retry-after"));
|
|
23
|
+
const reset = Number(headers?.get("x-ratelimit-reset"));
|
|
24
|
+
const wait = Number.isFinite(after) && headers?.has("retry-after") ? after : limited && Number.isFinite(reset) && reset > 0 ? Math.max(0, Math.ceil(reset - now / 1000)) : null;
|
|
25
|
+
super(`GitHub ${method} ${path} → ${status || "no answer"}: ${message}${limited ? ` (rate limited${wait !== null ? `; retry in ${wait} s` : ""})` : ""}`);
|
|
26
|
+
this.status = status;
|
|
27
|
+
this.method = method;
|
|
28
|
+
this.path = path;
|
|
29
|
+
this.headers = headers;
|
|
30
|
+
this.rateLimited = limited;
|
|
31
|
+
this.retryAfter = wait;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
// Reads are retried on what passes (no answer, a 5xx, a rate limit GitHub says lifts
|
|
35
|
+
// within a few seconds); writes never are, since a retried POST could open a second PR.
|
|
36
|
+
const ATTEMPTS = 3;
|
|
37
|
+
const BACKOFF_MS = [500, 1500];
|
|
38
|
+
const MAX_WAIT_S = 10;
|
|
39
|
+
const TIMEOUT_MS = 30_000;
|
|
40
|
+
export class GitHubClient {
|
|
41
|
+
token;
|
|
42
|
+
fetch;
|
|
43
|
+
baseUrl;
|
|
44
|
+
sleep;
|
|
45
|
+
now;
|
|
46
|
+
timeoutMs;
|
|
47
|
+
constructor(token, opts = {}) {
|
|
48
|
+
this.token = token;
|
|
49
|
+
this.fetch = opts.fetch ?? ((url, init) => fetch(url, init));
|
|
50
|
+
this.baseUrl = (opts.baseUrl ?? "https://api.github.com").replace(/\/$/, "");
|
|
51
|
+
this.sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
52
|
+
this.now = opts.now ?? Date.now;
|
|
53
|
+
this.timeoutMs = opts.timeoutMs ?? TIMEOUT_MS;
|
|
54
|
+
// Also reached from DOCSWORTH_GITHUB_API_URL, which no config schema sees.
|
|
55
|
+
const why = insecureUrl(this.baseUrl);
|
|
56
|
+
if (why)
|
|
57
|
+
throw new Error(`the GitHub API URL (${urlForError(this.baseUrl)}) ${why}`);
|
|
58
|
+
}
|
|
59
|
+
async request(method, path, body, accept = "application/vnd.github+json") {
|
|
60
|
+
const read = method === "GET" || method === "HEAD";
|
|
61
|
+
// Serialized once, outside the retry: a body that can't be (a fault here) throws as is.
|
|
62
|
+
const payload = body === undefined ? undefined : JSON.stringify(body);
|
|
63
|
+
for (let attempt = 1;; attempt++) {
|
|
64
|
+
let res;
|
|
65
|
+
let text;
|
|
66
|
+
try {
|
|
67
|
+
res = await this.fetch(`${this.baseUrl}${path}`, {
|
|
68
|
+
method,
|
|
69
|
+
headers: {
|
|
70
|
+
authorization: `Bearer ${this.token}`,
|
|
71
|
+
accept,
|
|
72
|
+
"x-github-api-version": "2022-11-28",
|
|
73
|
+
"user-agent": "docsworth",
|
|
74
|
+
...(body === undefined ? {} : { "content-type": "application/json" }),
|
|
75
|
+
},
|
|
76
|
+
body: payload,
|
|
77
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
78
|
+
});
|
|
79
|
+
text = await res.text();
|
|
80
|
+
}
|
|
81
|
+
catch (cause) {
|
|
82
|
+
// No answer: the connection failed (fetch's TypeError, or undici's "terminated" when
|
|
83
|
+
// it drops while the body is read), or nothing came within the timeout. Anything
|
|
84
|
+
// else is a fault here, not GitHub's, and isn't retried.
|
|
85
|
+
const timedOut = cause instanceof Error && (cause.name === "TimeoutError" || cause.name === "AbortError");
|
|
86
|
+
if (!timedOut && !(cause instanceof TypeError))
|
|
87
|
+
throw cause;
|
|
88
|
+
const err = new GitHubError(0, method, path, timedOut ? `no answer within ${this.timeoutMs / 1000} s` : `network error (${cause.message})`, null, this.now());
|
|
89
|
+
if (read && attempt < ATTEMPTS) {
|
|
90
|
+
await this.sleep(BACKOFF_MS[attempt - 1]);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
throw err;
|
|
94
|
+
}
|
|
95
|
+
if (!res.ok) {
|
|
96
|
+
let message = text.slice(0, 300);
|
|
97
|
+
try {
|
|
98
|
+
message = JSON.parse(text).message ?? message;
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
/* non-JSON error body */
|
|
102
|
+
}
|
|
103
|
+
const err = new GitHubError(res.status, method, path, message, res.headers, this.now());
|
|
104
|
+
// A 5xx is retried on the backoff. A rate limit only when GitHub says it lifts
|
|
105
|
+
// within 10 s: without a time, retrying soon prolongs it (GitHub asks for a minute).
|
|
106
|
+
const wait = err.rateLimited ? (err.retryAfter !== null && err.retryAfter <= MAX_WAIT_S ? err.retryAfter * 1000 : null) : res.status >= 500 ? BACKOFF_MS[attempt - 1] : null;
|
|
107
|
+
if (read && wait !== null && attempt < ATTEMPTS) {
|
|
108
|
+
await this.sleep(wait);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
throw err;
|
|
112
|
+
}
|
|
113
|
+
return { data: (text ? JSON.parse(text) : null), headers: res.headers };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
async get(path) {
|
|
117
|
+
return (await this.request("GET", path)).data;
|
|
118
|
+
}
|
|
119
|
+
/** Follow `Link: rel="next"` pagination; `onHeaders` sees every page's headers. */
|
|
120
|
+
async paginate(path, onHeaders) {
|
|
121
|
+
const out = [];
|
|
122
|
+
let next = `${path}${path.includes("?") ? "&" : "?"}per_page=100`;
|
|
123
|
+
while (next) {
|
|
124
|
+
const { data, headers } = await this.request("GET", next);
|
|
125
|
+
onHeaders?.(headers);
|
|
126
|
+
out.push(...data);
|
|
127
|
+
const link = /<([^>]+)>;\s*rel="next"/.exec(headers.get("link") ?? "");
|
|
128
|
+
next = link ? link[1].replace(this.baseUrl, "") : null;
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
getUser() {
|
|
133
|
+
return this.get("/user");
|
|
134
|
+
}
|
|
135
|
+
getRepo(repo) {
|
|
136
|
+
return this.get(`/repos/${repo}`);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* The caller's teams, and the ids of orgs GitHub left out because the token isn't
|
|
140
|
+
* authorized for their SAML SSO (`x-github-sso: partial-results; organizations=…`).
|
|
141
|
+
*/
|
|
142
|
+
async listMyTeams() {
|
|
143
|
+
const withheld = new Set();
|
|
144
|
+
const teams = await this.paginate("/user/teams", (h) => {
|
|
145
|
+
for (const id of parseSsoHeader(h.get("x-github-sso")).organizations)
|
|
146
|
+
withheld.add(id);
|
|
147
|
+
});
|
|
148
|
+
return { teams, ssoWithheldOrgIds: [...withheld] };
|
|
149
|
+
}
|
|
150
|
+
/** Logins of a team's members (needs read:org, or members:read on a fine-grained token). */
|
|
151
|
+
async listTeamMembers(org, team) {
|
|
152
|
+
return (await this.paginate(`/orgs/${encodeURIComponent(org)}/teams/${encodeURIComponent(team)}/members`)).map((m) => m.login);
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Does a team have write access to a repo? GitHub ignores a CODEOWNERS team without it.
|
|
156
|
+
* true/false when GitHub shows the permission; null on a 404, which GitHub gives both
|
|
157
|
+
* for "no access" and for a team this caller can't see, so it proves nothing.
|
|
158
|
+
*/
|
|
159
|
+
async teamCanPush(org, team, repo) {
|
|
160
|
+
try {
|
|
161
|
+
const { data } = await this.request("GET", `/orgs/${encodeURIComponent(org)}/teams/${encodeURIComponent(team)}/repos/${repo}`, undefined, "application/vnd.github.v3.repository+json");
|
|
162
|
+
return Boolean(data?.permissions?.push);
|
|
163
|
+
}
|
|
164
|
+
catch (err) {
|
|
165
|
+
if (err instanceof GitHubError && err.status === 404)
|
|
166
|
+
return null;
|
|
167
|
+
throw err;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/** A user's permission on a repo: admin, maintain, write, triage, read or none. */
|
|
171
|
+
async getUserPermission(repo, login) {
|
|
172
|
+
return (await this.get(`/repos/${repo}/collaborators/${encodeURIComponent(login)}/permission`)).permission;
|
|
173
|
+
}
|
|
174
|
+
/** Ruleset rules that apply to a branch (readable with read access). */
|
|
175
|
+
getBranchRules(repo, branch) {
|
|
176
|
+
return this.get(`/repos/${repo}/rules/branches/${encodePath(branch)}`);
|
|
177
|
+
}
|
|
178
|
+
/** A ruleset, with `bypass_actors` when this token may see them (admins). */
|
|
179
|
+
getRuleset(repo, id) {
|
|
180
|
+
return this.get(`/repos/${repo}/rulesets/${id}`);
|
|
181
|
+
}
|
|
182
|
+
/** Classic branch protection (GitHub answers 403/404 to callers without admin). */
|
|
183
|
+
getBranchProtection(repo, branch) {
|
|
184
|
+
return this.get(`/repos/${repo}/branches/${encodePath(branch)}/protection`);
|
|
185
|
+
}
|
|
186
|
+
getFile(repo, path, ref) {
|
|
187
|
+
return this.get(`/repos/${repo}/contents/${encodePath(path)}?ref=${encodeURIComponent(ref)}`);
|
|
188
|
+
}
|
|
189
|
+
async getBranchSha(repo, branch) {
|
|
190
|
+
return (await this.get(`/repos/${repo}/git/ref/heads/${encodePath(branch)}`)).object.sha;
|
|
191
|
+
}
|
|
192
|
+
async createBranch(repo, branch, sha) {
|
|
193
|
+
await this.request("POST", `/repos/${repo}/git/refs`, { ref: `refs/heads/${branch}`, sha });
|
|
194
|
+
}
|
|
195
|
+
async deleteBranch(repo, branch) {
|
|
196
|
+
await this.request("DELETE", `/repos/${repo}/git/refs/heads/${encodePath(branch)}`);
|
|
197
|
+
}
|
|
198
|
+
/** Always pass `branch`: omitting it makes GitHub write to the default branch. */
|
|
199
|
+
async putFile(repo, path, args) {
|
|
200
|
+
await this.request("PUT", `/repos/${repo}/contents/${encodePath(path)}`, {
|
|
201
|
+
message: args.message,
|
|
202
|
+
content: Buffer.from(args.content, "utf8").toString("base64"),
|
|
203
|
+
sha: args.sha,
|
|
204
|
+
branch: args.branch,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
/** An open PR from `branch` into `base` in the same repo, if one exists. */
|
|
208
|
+
async findPull(repo, branch, base) {
|
|
209
|
+
const owner = repo.split("/")[0];
|
|
210
|
+
const q = `head=${encodeURIComponent(`${owner}:${branch}`)}&base=${encodeURIComponent(base)}&state=open`;
|
|
211
|
+
return (await this.get(`/repos/${repo}/pulls?${q}`))[0] ?? null;
|
|
212
|
+
}
|
|
213
|
+
async createPull(repo, args) {
|
|
214
|
+
return (await this.request("POST", `/repos/${repo}/pulls`, args)).data;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
/** `required; url=https://…` or `partial-results; organizations=1,2`. */
|
|
218
|
+
export function parseSsoHeader(value) {
|
|
219
|
+
if (!value)
|
|
220
|
+
return { required: false, url: null, organizations: [] };
|
|
221
|
+
const parts = value.split(";").map((p) => p.trim());
|
|
222
|
+
const field = (k) => parts.find((p) => p.startsWith(`${k}=`))?.slice(k.length + 1) ?? null;
|
|
223
|
+
return {
|
|
224
|
+
required: parts[0] === "required",
|
|
225
|
+
url: field("url"),
|
|
226
|
+
organizations: (field("organizations") ?? "").split(",").map((s) => s.trim()).filter(Boolean),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
function encodePath(p) {
|
|
230
|
+
return p.split("/").map(encodeURIComponent).join("/");
|
|
231
|
+
}
|
|
232
|
+
export function decodeContent(file) {
|
|
233
|
+
return Buffer.from(file.content, file.encoding === "base64" ? "base64" : "utf8").toString("utf8");
|
|
234
|
+
}
|