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.

219 lines · 8.4 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//
9// Every step looks first and only creates what is missing, so running it again changes nothing.
10// A step the token isn't allowed to do is reported with the permission it needs, and the others
11// still run. Run by the "GitHub community setup" workflow; by hand:
12//
13// MIRROR_TOKEN=github_pat_... node scripts/github-community.mjs [--dry-run]
14//
15// GitHub has no API to pin a discussion or to edit discussion categories, so those stay as they are.
16import { readFile } from "node:fs/promises";
17import path from "node:path";
18import { fileURLToPath } from "node:url";
19
20const REPO = process.env.MIRROR_REPO || "codician-team/diffinery";
21const TOKEN = process.env.MIRROR_TOKEN;
22const DRY = process.argv.includes("--dry-run");
23const DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "github-community");
24
25const WELCOME_TITLE = "Welcome to Diffinery";
26const ROADMAP_TITLE = "Roadmap: what Diffinery doesn't do yet";
27const LABELS = [
28 { name: "roadmap", color: "f08a45", description: "Known gaps and planned work" },
29 { name: "help wanted", color: "008672", description: "Extra attention is needed" },
30 { name: "good first issue", color: "7057ff", description: "Good for newcomers" },
31];
32
33const results = [];
34const report = (step, outcome, detail = "") => {
35 results.push({ step, outcome, detail });
36 const line = `${step}: ${outcome}${detail ? ` (${detail})` : ""}`;
37 console.log(outcome === "failed" ? `::warning::${line}` : line);
38};
39
40class HttpError extends Error {
41 constructor(status, message) {
42 super(message);
43 this.status = status;
44 }
45}
46
47async function rest(method, url, body) {
48 const res = await fetch(`https://api.github.com${url}`, {
49 method,
50 headers: {
51 authorization: `Bearer ${TOKEN}`,
52 accept: "application/vnd.github+json",
53 "x-github-api-version": "2022-11-28",
54 "user-agent": "diffinery-github-community",
55 ...(body ? { "content-type": "application/json" } : {}),
56 },
57 body: body ? JSON.stringify(body) : undefined,
58 });
59 const text = await res.text();
60 if (!res.ok) throw new HttpError(res.status, `${method} ${url}: ${res.status} ${text.slice(0, 300)}`);
61 return text ? JSON.parse(text) : null;
62}
63
64async function graphql(query, variables) {
65 const data = await rest("POST", "/graphql", { query, variables });
66 if (data.errors?.length) throw new HttpError(403, data.errors.map((e) => e.message).join("; "));
67 return data.data;
68}
69
70/** Why a step failed, in words that name the token permission to add. */
71const why = (e, permission) => (e instanceof HttpError && (e.status === 403 || e.status === 404) ? `the token needs ${permission}: ${e.message}` : e.message);
72
73async function labels() {
74 for (const label of LABELS) {
75 try {
76 await rest("GET", `/repos/${REPO}/labels/${encodeURIComponent(label.name)}`);
77 report(`Label "${label.name}"`, "already there");
78 } catch (e) {
79 if (!(e instanceof HttpError) || e.status !== 404) {
80 report(`Label "${label.name}"`, "failed", why(e, "Issues: Read and write"));
81 continue;
82 }
83 if (DRY) {
84 report(`Label "${label.name}"`, "would create");
85 continue;
86 }
87 try {
88 await rest("POST", `/repos/${REPO}/labels`, label);
89 report(`Label "${label.name}"`, "created");
90 } catch (e2) {
91 report(`Label "${label.name}"`, "failed", why(e2, "Issues: Read and write"));
92 }
93 }
94 }
95}
96
97async function discussions() {
98 const [owner, name] = REPO.split("/");
99 let repo = await rest("GET", `/repos/${REPO}`);
100 if (!repo.has_discussions) {
101 if (DRY) {
102 report("Discussions", "would turn on");
103 return;
104 }
105 try {
106 repo = await rest("PATCH", `/repos/${REPO}`, { has_discussions: true });
107 report("Discussions", "turned on");
108 } catch (e) {
109 report("Discussions", "failed", why(e, "Administration: Read and write (or turn Discussions on in Settings → General → Features)"));
110 return;
111 }
112 } else report("Discussions", "already on");
113
114 try {
115 const q = await graphql(
116 `query($owner: String!, $name: String!) {
117 repository(owner: $owner, name: $name) {
118 id
119 discussionCategories(first: 25) { nodes { id name } }
120 discussions(first: 100) { nodes { title url } }
121 }
122 }`,
123 { owner, name },
124 );
125 const existing = q.repository.discussions.nodes.find((d) => d.title === WELCOME_TITLE);
126 if (existing) {
127 report("Welcome discussion", "already there", existing.url);
128 return;
129 }
130 const category = q.repository.discussionCategories.nodes.find((c) => c.name === "Announcements") ?? q.repository.discussionCategories.nodes.find((c) => c.name === "General");
131 if (!category) {
132 report("Welcome discussion", "failed", "no Announcements or General category");
133 return;
134 }
135 if (DRY) {
136 report("Welcome discussion", "would post", `in ${category.name}`);
137 return;
138 }
139 const body = await readFile(path.join(DIR, "welcome-discussion.md"), "utf8");
140 const created = await graphql(
141 `mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) {
142 createDiscussion(input: { repositoryId: $repositoryId, categoryId: $categoryId, title: $title, body: $body }) { discussion { url } }
143 }`,
144 { repositoryId: q.repository.id, categoryId: category.id, title: WELCOME_TITLE, body },
145 );
146 report("Welcome discussion", "posted", `${created.createDiscussion.discussion.url} in ${category.name}`);
147 } catch (e) {
148 report("Welcome discussion", "failed", why(e, "Discussions: Read and write"));
149 }
150}
151
152async function roadmap() {
153 let issue;
154 try {
155 // Pull requests come back from this endpoint too; they have a pull_request field.
156 for (let page = 1; page <= 10 && !issue; page++) {
157 const list = await rest("GET", `/repos/${REPO}/issues?state=all&per_page=100&page=${page}`);
158 issue = list.find((i) => !i.pull_request && i.title === ROADMAP_TITLE);
159 if (list.length < 100) break;
160 }
161 } catch (e) {
162 report("Roadmap issue", "failed", why(e, "Issues: Read and write"));
163 return;
164 }
165 if (issue) report("Roadmap issue", "already there", issue.html_url);
166 else if (DRY) {
167 report("Roadmap issue", "would open");
168 return;
169 } else {
170 try {
171 const body = await readFile(path.join(DIR, "roadmap-issue.md"), "utf8");
172 issue = await rest("POST", `/repos/${REPO}/issues`, { title: ROADMAP_TITLE, body, labels: ["roadmap", "help wanted"] });
173 report("Roadmap issue", "opened", issue.html_url);
174 } catch (e) {
175 report("Roadmap issue", "failed", why(e, "Issues: Read and write"));
176 return;
177 }
178 }
179
180 try {
181 const q = await graphql(`query($id: ID!) { node(id: $id) { ... on Issue { isPinned } } }`, { id: issue.node_id });
182 if (q.node.isPinned) {
183 report("Pin roadmap issue", "already pinned");
184 return;
185 }
186 if (DRY) {
187 report("Pin roadmap issue", "would pin");
188 return;
189 }
190 await graphql(`mutation($id: ID!) { pinIssue(input: { issueId: $id }) { issue { isPinned } } }`, { id: issue.node_id });
191 report("Pin roadmap issue", "pinned");
192 } catch (e) {
193 report("Pin roadmap issue", "failed", why(e, "Issues: Read and write"));
194 }
195}
196
197async function summary() {
198 const file = process.env.GITHUB_STEP_SUMMARY;
199 if (!file) return;
200 const { appendFile } = await import("node:fs/promises");
201 const rows = results.map((r) => `| ${r.step} | ${r.outcome} | ${r.detail.replace(/\|/g, "\\|")} |`).join("\n");
202 await appendFile(file, `## GitHub community setup${DRY ? " (dry run)" : ""}\n\n| Step | Outcome | Detail |\n| --- | --- | --- |\n${rows}\n`);
203}
204
205if (!TOKEN) {
206 console.log("::notice::MIRROR_TOKEN is not set, so nothing was changed.");
207 process.exit(0);
208}
209try {
210 const me = await rest("GET", "/user");
211 console.log(`Acting as ${me.login} on ${REPO}${DRY ? " (dry run)" : ""}`);
212} catch {
213 console.log(`Acting on ${REPO} with a token that can't read /user${DRY ? " (dry run)" : ""}`);
214}
215await labels();
216await discussions();
217await roadmap();
218await summary();
219process.exitCode = results.some((r) => r.outcome === "failed") ? 1 : 0;