| 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. |
| 16 | import { readFile } from "node:fs/promises"; |
| 17 | import path from "node:path"; |
| 18 | import { fileURLToPath } from "node:url"; |
| 19 | |
| 20 | const REPO = process.env.MIRROR_REPO || "codician-team/diffinery"; |
| 21 | const TOKEN = process.env.MIRROR_TOKEN; |
| 22 | const DRY = process.argv.includes("--dry-run"); |
| 23 | const DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "github-community"); |
| 24 | |
| 25 | const WELCOME_TITLE = "Welcome to Diffinery"; |
| 26 | const ROADMAP_TITLE = "Roadmap: what Diffinery doesn't do yet"; |
| 27 | const 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 | |
| 33 | const results = []; |
| 34 | const 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 | |
| 40 | class HttpError extends Error { |
| 41 | constructor(status, message) { |
| 42 | super(message); |
| 43 | this.status = status; |
| 44 | } |
| 45 | } |
| 46 | |
| 47 | async 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 | |
| 64 | async 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. */ |
| 71 | const why = (e, permission) => (e instanceof HttpError && (e.status === 403 || e.status === 404) ? `the token needs ${permission}: ${e.message}` : e.message); |
| 72 | |
| 73 | async 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 | |
| 97 | async 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 | |
| 152 | async 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) { |
| 166 | report("Roadmap issue", "already there", issue.html_url); |
| 167 | // GitHub drops labels from a new issue when the token may not label, so add them later. |
| 168 | const missing = ["roadmap", "help wanted"].filter((l) => !issue.labels.some((x) => (x.name ?? x) === l)); |
| 169 | if (missing.length && DRY) report("Roadmap issue labels", "would add", missing.join(", ")); |
| 170 | else if (missing.length) { |
| 171 | try { |
| 172 | await rest("POST", `/repos/${REPO}/issues/${issue.number}/labels`, { labels: missing }); |
| 173 | report("Roadmap issue labels", "added", missing.join(", ")); |
| 174 | } catch (e) { |
| 175 | report("Roadmap issue labels", "failed", why(e, "Issues: Read and write")); |
| 176 | } |
| 177 | } |
| 178 | } else if (DRY) { |
| 179 | report("Roadmap issue", "would open"); |
| 180 | return; |
| 181 | } else { |
| 182 | try { |
| 183 | const body = await readFile(path.join(DIR, "roadmap-issue.md"), "utf8"); |
| 184 | issue = await rest("POST", `/repos/${REPO}/issues`, { title: ROADMAP_TITLE, body, labels: ["roadmap", "help wanted"] }); |
| 185 | report("Roadmap issue", "opened", issue.html_url); |
| 186 | } catch (e) { |
| 187 | report("Roadmap issue", "failed", why(e, "Issues: Read and write")); |
| 188 | return; |
| 189 | } |
| 190 | } |
| 191 | |
| 192 | try { |
| 193 | const q = await graphql(`query($id: ID!) { node(id: $id) { ... on Issue { isPinned } } }`, { id: issue.node_id }); |
| 194 | if (q.node.isPinned) { |
| 195 | report("Pin roadmap issue", "already pinned"); |
| 196 | return; |
| 197 | } |
| 198 | if (DRY) { |
| 199 | report("Pin roadmap issue", "would pin"); |
| 200 | return; |
| 201 | } |
| 202 | await graphql(`mutation($id: ID!) { pinIssue(input: { issueId: $id }) { issue { isPinned } } }`, { id: issue.node_id }); |
| 203 | report("Pin roadmap issue", "pinned"); |
| 204 | } catch (e) { |
| 205 | report("Pin roadmap issue", "failed", why(e, "Issues: Read and write")); |
| 206 | } |
| 207 | } |
| 208 | |
| 209 | async function summary() { |
| 210 | const file = process.env.GITHUB_STEP_SUMMARY; |
| 211 | if (!file) return; |
| 212 | const { appendFile } = await import("node:fs/promises"); |
| 213 | const rows = results.map((r) => `| ${r.step} | ${r.outcome} | ${r.detail.replace(/\|/g, "\\|")} |`).join("\n"); |
| 214 | await appendFile(file, `## GitHub community setup${DRY ? " (dry run)" : ""}\n\n| Step | Outcome | Detail |\n| --- | --- | --- |\n${rows}\n`); |
| 215 | } |
| 216 | |
| 217 | if (!TOKEN) { |
| 218 | console.log("::notice::MIRROR_TOKEN is not set, so nothing was changed."); |
| 219 | process.exit(0); |
| 220 | } |
| 221 | try { |
| 222 | const me = await rest("GET", "/user"); |
| 223 | console.log(`Acting as ${me.login} on ${REPO}${DRY ? " (dry run)" : ""}`); |
| 224 | } catch { |
| 225 | console.log(`Acting on ${REPO} with a token that can't read /user${DRY ? " (dry run)" : ""}`); |
| 226 | } |
| 227 | await labels(); |
| 228 | await discussions(); |
| 229 | await roadmap(); |
| 230 | await summary(); |
| 231 | process.exitCode = results.some((r) => r.outcome === "failed") ? 1 : 0; |