Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

371 lines · 14.6 KBRaw History
1#!/usr/bin/env node
2// Sets up the community side of the GitHub mirror (github.com/codician-team/diffinery) as the
3// account that owns MIRROR_TOKEN, so posts show Codician rather than a person:
4//
5// - the labels `roadmap`, `help wanted` and `good first issue`
6// - Discussions turned on, and the welcome post (github-community/welcome-discussion.md) in Announcements
7// - the roadmap issue (github-community/roadmap-issue.md), labelled and pinned
8// - replies to issues and discussions, read from the `community-replies` branch (see replies() below)
9//
10// Every step looks first and only creates what is missing, so running it again changes nothing.
11// A step the token isn't allowed to do is reported with the permission it needs, and the others
12// still run. Run by the "GitHub community setup" workflow; by hand:
13//
14// MIRROR_TOKEN=github_pat_... node scripts/github-community.mjs [--dry-run]
15//
16// GitHub has no API to pin a discussion or to edit discussion categories, so those stay as they are.
17import { execFileSync } from "node:child_process";
18import { readFile } from "node:fs/promises";
19import path from "node:path";
20import { fileURLToPath } from "node:url";
21
22const REPO = process.env.MIRROR_REPO || "codician-team/diffinery";
23const TOKEN = process.env.MIRROR_TOKEN;
24const DRY = process.argv.includes("--dry-run");
25const REPLIES_BRANCH = process.env.REPLIES_BRANCH || "community-replies";
26const DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "github-community");
27
28const WELCOME_TITLE = "Welcome to Diffinery";
29const ROADMAP_TITLE = "Roadmap: what Diffinery doesn't do yet";
30const LABELS = [
31 { name: "roadmap", color: "f08a45", description: "Known gaps and planned work" },
32 { name: "help wanted", color: "008672", description: "Extra attention is needed" },
33 { name: "good first issue", color: "7057ff", description: "Good for newcomers" },
34];
35
36const results = [];
37const report = (step, outcome, detail = "") => {
38 results.push({ step, outcome, detail });
39 const line = `${step}: ${outcome}${detail ? ` (${detail})` : ""}`;
40 console.log(outcome === "failed" ? `::warning::${line}` : line);
41};
42
43class HttpError extends Error {
44 constructor(status, message) {
45 super(message);
46 this.status = status;
47 }
48}
49
50async function rest(method, url, body) {
51 const res = await fetch(`https://api.github.com${url}`, {
52 method,
53 headers: {
54 authorization: `Bearer ${TOKEN}`,
55 accept: "application/vnd.github+json",
56 "x-github-api-version": "2022-11-28",
57 "user-agent": "diffinery-github-community",
58 ...(body ? { "content-type": "application/json" } : {}),
59 },
60 body: body ? JSON.stringify(body) : undefined,
61 });
62 const text = await res.text();
63 if (!res.ok) throw new HttpError(res.status, `${method} ${url}: ${res.status} ${text.slice(0, 300)}`);
64 return text ? JSON.parse(text) : null;
65}
66
67async function graphql(query, variables) {
68 const data = await rest("POST", "/graphql", { query, variables });
69 if (data.errors?.length) throw new HttpError(403, data.errors.map((e) => e.message).join("; "));
70 return data.data;
71}
72
73/** Why a step failed, in words that name the token permission to add. */
74const why = (e, permission) => (e instanceof HttpError && (e.status === 403 || e.status === 404) ? `the token needs ${permission}: ${e.message}` : e.message);
75
76async function labels() {
77 for (const label of LABELS) {
78 try {
79 await rest("GET", `/repos/${REPO}/labels/${encodeURIComponent(label.name)}`);
80 report(`Label "${label.name}"`, "already there");
81 } catch (e) {
82 if (!(e instanceof HttpError) || e.status !== 404) {
83 report(`Label "${label.name}"`, "failed", why(e, "Issues: Read and write"));
84 continue;
85 }
86 if (DRY) {
87 report(`Label "${label.name}"`, "would create");
88 continue;
89 }
90 try {
91 await rest("POST", `/repos/${REPO}/labels`, label);
92 report(`Label "${label.name}"`, "created");
93 } catch (e2) {
94 report(`Label "${label.name}"`, "failed", why(e2, "Issues: Read and write"));
95 }
96 }
97 }
98}
99
100async function discussions() {
101 const [owner, name] = REPO.split("/");
102 let repo = await rest("GET", `/repos/${REPO}`);
103 if (!repo.has_discussions) {
104 if (DRY) {
105 report("Discussions", "would turn on");
106 return;
107 }
108 try {
109 repo = await rest("PATCH", `/repos/${REPO}`, { has_discussions: true });
110 report("Discussions", "turned on");
111 } catch (e) {
112 report("Discussions", "failed", why(e, "Administration: Read and write (or turn Discussions on in Settings → General → Features)"));
113 return;
114 }
115 } else report("Discussions", "already on");
116
117 try {
118 const q = await graphql(
119 `query($owner: String!, $name: String!) {
120 repository(owner: $owner, name: $name) {
121 id
122 discussionCategories(first: 25) { nodes { id name } }
123 discussions(first: 100) { nodes { title url } }
124 }
125 }`,
126 { owner, name },
127 );
128 const existing = q.repository.discussions.nodes.find((d) => d.title === WELCOME_TITLE);
129 if (existing) {
130 report("Welcome discussion", "already there", existing.url);
131 return;
132 }
133 const category = q.repository.discussionCategories.nodes.find((c) => c.name === "Announcements") ?? q.repository.discussionCategories.nodes.find((c) => c.name === "General");
134 if (!category) {
135 report("Welcome discussion", "failed", "no Announcements or General category");
136 return;
137 }
138 if (DRY) {
139 report("Welcome discussion", "would post", `in ${category.name}`);
140 return;
141 }
142 const body = await readFile(path.join(DIR, "welcome-discussion.md"), "utf8");
143 const created = await graphql(
144 `mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) {
145 createDiscussion(input: { repositoryId: $repositoryId, categoryId: $categoryId, title: $title, body: $body }) { discussion { url } }
146 }`,
147 { repositoryId: q.repository.id, categoryId: category.id, title: WELCOME_TITLE, body },
148 );
149 report("Welcome discussion", "posted", `${created.createDiscussion.discussion.url} in ${category.name}`);
150 } catch (e) {
151 report("Welcome discussion", "failed", why(e, "Discussions: Read and write"));
152 }
153}
154
155async function roadmap() {
156 let issue;
157 try {
158 // Pull requests come back from this endpoint too; they have a pull_request field.
159 for (let page = 1; page <= 10 && !issue; page++) {
160 const list = await rest("GET", `/repos/${REPO}/issues?state=all&per_page=100&page=${page}`);
161 issue = list.find((i) => !i.pull_request && i.title === ROADMAP_TITLE);
162 if (list.length < 100) break;
163 }
164 } catch (e) {
165 report("Roadmap issue", "failed", why(e, "Issues: Read and write"));
166 return;
167 }
168 if (issue) {
169 report("Roadmap issue", "already there", issue.html_url);
170 // GitHub drops labels from a new issue when the token may not label, so add them later.
171 const missing = ["roadmap", "help wanted"].filter((l) => !issue.labels.some((x) => (x.name ?? x) === l));
172 if (missing.length && DRY) report("Roadmap issue labels", "would add", missing.join(", "));
173 else if (missing.length) {
174 try {
175 await rest("POST", `/repos/${REPO}/issues/${issue.number}/labels`, { labels: missing });
176 report("Roadmap issue labels", "added", missing.join(", "));
177 } catch (e) {
178 report("Roadmap issue labels", "failed", why(e, "Issues: Read and write"));
179 }
180 }
181 } else if (DRY) {
182 report("Roadmap issue", "would open");
183 return;
184 } else {
185 try {
186 const body = await readFile(path.join(DIR, "roadmap-issue.md"), "utf8");
187 issue = await rest("POST", `/repos/${REPO}/issues`, { title: ROADMAP_TITLE, body, labels: ["roadmap", "help wanted"] });
188 report("Roadmap issue", "opened", issue.html_url);
189 } catch (e) {
190 report("Roadmap issue", "failed", why(e, "Issues: Read and write"));
191 return;
192 }
193 }
194
195 try {
196 const q = await graphql(`query($id: ID!) { node(id: $id) { ... on Issue { isPinned } } }`, { id: issue.node_id });
197 if (q.node.isPinned) {
198 report("Pin roadmap issue", "already pinned");
199 return;
200 }
201 if (DRY) {
202 report("Pin roadmap issue", "would pin");
203 return;
204 }
205 await graphql(`mutation($id: ID!) { pinIssue(input: { issueId: $id }) { issue { isPinned } } }`, { id: issue.node_id });
206 report("Pin roadmap issue", "pinned");
207 } catch (e) {
208 report("Pin roadmap issue", "failed", why(e, "Issues: Read and write"));
209 }
210}
211
212// Replies to issues and discussions on the mirror, posted as the token's account. They wait on
213// the `community-replies` branch of this repository (never merged, never mirrored) as
214// replies/<id>.md, so posting one needs a push to that branch and a run of this workflow, not a
215// pull request. Each file starts with a header:
216//
217// ---
218// target: issue 4 (or: discussion 2)
219// labels: bug, gh (optional, added to an issue)
220// close: completed (optional: completed or not_planned, closes an issue)
221// ---
222// The reply, in Markdown.
223//
224// The posted comment ends with <!-- diffinery-reply: <id> -->, and a reply whose marker is
225// already on its target is skipped, so running again posts nothing twice.
226function replyFiles() {
227 const git = (...args) => execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
228 try {
229 git("fetch", "--quiet", "--depth=1", "origin", `+refs/heads/${REPLIES_BRANCH}:refs/remotes/origin/${REPLIES_BRANCH}`);
230 } catch {
231 return null;
232 }
233 const ref = `origin/${REPLIES_BRANCH}`;
234 const names = git("ls-tree", "--name-only", ref, "replies/").split("\n").filter((n) => n.endsWith(".md")).map((n) => n.slice("replies/".length));
235 return names.sort().map((name) => ({ id: name.slice(0, -3), text: git("show", `${ref}:replies/${name}`) }));
236}
237
238function parseReply(id, text) {
239 const m = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/.exec(text.replace(/\r\n/g, "\n"));
240 if (!m) throw new Error("no --- header");
241 const head = Object.fromEntries(
242 m[1].split("\n").filter((l) => l.trim()).map((l) => {
243 const i = l.indexOf(":");
244 return [l.slice(0, i).trim(), l.slice(i + 1).trim()];
245 }),
246 );
247 const t = /^(issue|discussion) (\d+)$/.exec(head.target ?? "");
248 if (!t) throw new Error(`target must be "issue N" or "discussion N", not "${head.target ?? ""}"`);
249 if (head.close && !["completed", "not_planned"].includes(head.close)) throw new Error(`close must be completed or not_planned`);
250 const body = m[2].trim();
251 if (!body) throw new Error("empty reply");
252 return {
253 id,
254 kind: t[1],
255 number: Number(t[2]),
256 labels: (head.labels ?? "").split(",").map((l) => l.trim()).filter(Boolean),
257 close: head.close || null,
258 marker: `<!-- diffinery-reply: ${id} -->`,
259 body,
260 };
261}
262
263async function issueReply(r, step) {
264 let posted = false;
265 for (let page = 1; page <= 20 && !posted; page++) {
266 const list = await rest("GET", `/repos/${REPO}/issues/${r.number}/comments?per_page=100&page=${page}`);
267 posted = list.some((c) => c.body?.includes(r.marker));
268 if (list.length < 100) break;
269 }
270 if (posted) report(step, "already posted");
271 else if (DRY) report(step, "would post");
272 else {
273 const c = await rest("POST", `/repos/${REPO}/issues/${r.number}/comments`, { body: `${r.body}\n\n${r.marker}\n` });
274 report(step, "posted", c.html_url);
275 }
276 const issue = await rest("GET", `/repos/${REPO}/issues/${r.number}`);
277 const missing = r.labels.filter((l) => !issue.labels.some((x) => (x.name ?? x) === l));
278 if (missing.length) {
279 if (DRY) report(`${step} labels`, "would add", missing.join(", "));
280 else {
281 await rest("POST", `/repos/${REPO}/issues/${r.number}/labels`, { labels: missing });
282 report(`${step} labels`, "added", missing.join(", "));
283 }
284 }
285 // Close only alongside the first posting, so an issue someone reopened stays open.
286 if (r.close && !posted && issue.state === "open") {
287 if (DRY) report(`${step} close`, "would close", r.close);
288 else {
289 await rest("PATCH", `/repos/${REPO}/issues/${r.number}`, { state: "closed", state_reason: r.close });
290 report(`${step} close`, "closed", r.close);
291 }
292 }
293}
294
295async function discussionReply(r, step) {
296 const [owner, name] = REPO.split("/");
297 const q = await graphql(
298 `query($owner: String!, $name: String!, $number: Int!) {
299 repository(owner: $owner, name: $name) {
300 discussion(number: $number) { id comments(last: 100) { nodes { body replies(last: 50) { nodes { body } } } } }
301 }
302 }`,
303 { owner, name, number: r.number },
304 );
305 const d = q.repository.discussion;
306 if (!d) throw new HttpError(404, `discussion ${r.number} not found`);
307 const bodies = d.comments.nodes.flatMap((c) => [c.body, ...c.replies.nodes.map((x) => x.body)]);
308 if (bodies.some((b) => b?.includes(r.marker))) report(step, "already posted");
309 else if (DRY) report(step, "would post");
310 else {
311 const c = await graphql(
312 `mutation($id: ID!, $body: String!) { addDiscussionComment(input: { discussionId: $id, body: $body }) { comment { url } } }`,
313 { id: d.id, body: `${r.body}\n\n${r.marker}\n` },
314 );
315 report(step, "posted", c.addDiscussionComment.comment.url);
316 }
317}
318
319async function replies() {
320 let files;
321 try {
322 files = replyFiles();
323 } catch (e) {
324 report("Replies", "failed", e.message);
325 return;
326 }
327 if (!files?.length) {
328 report("Replies", "none waiting", `no replies/ on the ${REPLIES_BRANCH} branch`);
329 return;
330 }
331 for (const f of files) {
332 let r;
333 try {
334 r = parseReply(f.id, f.text);
335 } catch (e) {
336 report(`Reply ${f.id}`, "failed", e.message);
337 continue;
338 }
339 const step = `Reply ${r.id} on ${r.kind} ${r.number}`;
340 try {
341 await (r.kind === "issue" ? issueReply(r, step) : discussionReply(r, step));
342 } catch (e) {
343 report(step, "failed", why(e, r.kind === "issue" ? "Issues: Read and write" : "Discussions: Read and write"));
344 }
345 }
346}
347
348async function summary() {
349 const file = process.env.GITHUB_STEP_SUMMARY;
350 if (!file) return;
351 const { appendFile } = await import("node:fs/promises");
352 const rows = results.map((r) => `| ${r.step} | ${r.outcome} | ${r.detail.replace(/\|/g, "\\|")} |`).join("\n");
353 await appendFile(file, `## GitHub community setup${DRY ? " (dry run)" : ""}\n\n| Step | Outcome | Detail |\n| --- | --- | --- |\n${rows}\n`);
354}
355
356if (!TOKEN) {
357 console.log("::notice::MIRROR_TOKEN is not set, so nothing was changed.");
358 process.exit(0);
359}
360try {
361 const me = await rest("GET", "/user");
362 console.log(`Acting as ${me.login} on ${REPO}${DRY ? " (dry run)" : ""}`);
363} catch {
364 console.log(`Acting on ${REPO} with a token that can't read /user${DRY ? " (dry run)" : ""}`);
365}
366await labels();
367await discussions();
368await roadmap();
369await replies();
370await summary();
371process.exitCode = results.some((r) => r.outcome === "failed") ? 1 : 0;