| 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. |
| 17 | import { execFileSync } from "node:child_process"; |
| 18 | import { readFile } from "node:fs/promises"; |
| 19 | import path from "node:path"; |
| 20 | import { fileURLToPath } from "node:url"; |
| 21 | |
| 22 | const REPO = process.env.MIRROR_REPO || "codician-team/diffinery"; |
| 23 | const TOKEN = process.env.MIRROR_TOKEN; |
| 24 | const DRY = process.argv.includes("--dry-run"); |
| 25 | const REPLIES_BRANCH = process.env.REPLIES_BRANCH || "community-replies"; |
| 26 | const DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "github-community"); |
| 27 | |
| 28 | const WELCOME_TITLE = "Welcome to Diffinery"; |
| 29 | const ROADMAP_TITLE = "Roadmap: what Diffinery doesn't do yet"; |
| 30 | const 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 | |
| 36 | const results = []; |
| 37 | const 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 | |
| 43 | class HttpError extends Error { |
| 44 | constructor(status, message) { |
| 45 | super(message); |
| 46 | this.status = status; |
| 47 | } |
| 48 | } |
| 49 | |
| 50 | async 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 | |
| 67 | async 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. */ |
| 74 | const why = (e, permission) => (e instanceof HttpError && (e.status === 403 || e.status === 404) ? `the token needs ${permission}: ${e.message}` : e.message); |
| 75 | |
| 76 | async 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 | |
| 100 | async 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 | |
| 155 | async 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. |
| 226 | function 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 | |
| 238 | function 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 | |
| 263 | async 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 | |
| 295 | async 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 | |
| 319 | async 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 | |
| 348 | async 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 | |
| 356 | if (!TOKEN) { |
| 357 | console.log("::notice::MIRROR_TOKEN is not set, so nothing was changed."); |
| 358 | process.exit(0); |
| 359 | } |
| 360 | try { |
| 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 | } |
| 366 | await labels(); |
| 367 | await discussions(); |
| 368 | await roadmap(); |
| 369 | await replies(); |
| 370 | await summary(); |
| 371 | process.exitCode = results.some((r) => r.outcome === "failed") ? 1 : 0; |