| 1 | #!/usr/bin/env node |
| 2 | // Suggests Diffinery to curated "awesome" lists on GitHub (awesome-lists/lists.mjs), one pull |
| 3 | // request per list, as the account that owns OUTREACH_TOKEN, so they show Codician rather than |
| 4 | // a person. For each list it forks the repository, adds one line to the right section on a |
| 5 | // branch in the fork, and opens the pull request. |
| 6 | // |
| 7 | // A list where Diffinery is already listed, or where the pull request is already open or was |
| 8 | // closed, is left alone, so running it again changes nothing. A list whose rules don't allow |
| 9 | // the suggestion yet (notBefore) is skipped until that day. Run by the "Suggest Diffinery to |
| 10 | // awesome lists" workflow; by hand: |
| 11 | // |
| 12 | // OUTREACH_TOKEN=ghp_... node scripts/awesome-lists.mjs [--dry-run] [--lists=id,id] |
| 13 | // |
| 14 | // OUTREACH_TOKEN must be a classic personal access token with the public_repo scope: GitHub's |
| 15 | // fine-grained tokens can't contribute to public repositories the account isn't a member of. |
| 16 | import { LISTS, FOOTER } from "./awesome-lists/lists.mjs"; |
| 17 | |
| 18 | const TOKEN = process.env.OUTREACH_TOKEN; |
| 19 | const DRY = process.argv.includes("--dry-run"); |
| 20 | const pick = (process.argv.find((a) => a.startsWith("--lists="))?.slice(8) || "due").trim(); |
| 21 | const today = (process.env.TODAY || new Date().toISOString()).slice(0, 10); |
| 22 | const API = process.env.GITHUB_API_URL_OVERRIDE || "https://api.github.com"; |
| 23 | const BRANCH = "add-diffinery"; |
| 24 | |
| 25 | const results = []; |
| 26 | const report = (list, outcome, detail = "") => { |
| 27 | results.push({ list, outcome, detail }); |
| 28 | const line = `${list}: ${outcome}${detail ? ` (${detail})` : ""}`; |
| 29 | console.log(outcome === "failed" ? `::warning::${line}` : line); |
| 30 | }; |
| 31 | |
| 32 | class HttpError extends Error { |
| 33 | constructor(status, message) { |
| 34 | super(message); |
| 35 | this.status = status; |
| 36 | } |
| 37 | } |
| 38 | |
| 39 | async function rest(method, url, body) { |
| 40 | const res = await fetch(`${API}${url}`, { |
| 41 | method, |
| 42 | headers: { |
| 43 | authorization: `Bearer ${TOKEN}`, |
| 44 | accept: "application/vnd.github+json", |
| 45 | "x-github-api-version": "2022-11-28", |
| 46 | "user-agent": "diffinery-awesome-lists", |
| 47 | ...(body ? { "content-type": "application/json" } : {}), |
| 48 | }, |
| 49 | body: body ? JSON.stringify(body) : undefined, |
| 50 | }); |
| 51 | const text = await res.text(); |
| 52 | if (!res.ok) throw new HttpError(res.status, `${method} ${url}: ${res.status} ${text.slice(0, 300)}`); |
| 53 | return text ? JSON.parse(text) : null; |
| 54 | } |
| 55 | |
| 56 | /** A file's text. The raw media type also works for files over 1 MB, where `content` comes back empty. */ |
| 57 | async function fileText(repo, file, ref) { |
| 58 | const res = await fetch(`${API}/repos/${repo}/contents/${encodeURIComponent(file)}?ref=${encodeURIComponent(ref)}`, { |
| 59 | headers: { authorization: `Bearer ${TOKEN}`, accept: "application/vnd.github.raw+json", "x-github-api-version": "2022-11-28", "user-agent": "diffinery-awesome-lists" }, |
| 60 | }); |
| 61 | const text = await res.text(); |
| 62 | if (!res.ok) throw new HttpError(res.status, `GET ${repo}/${file}: ${res.status} ${text.slice(0, 300)}`); |
| 63 | return text; |
| 64 | } |
| 65 | |
| 66 | const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); |
| 67 | |
| 68 | /** |
| 69 | * The file with `entry.line` added to `entry.section`, or an error message. The section runs |
| 70 | * from its heading to the next heading of the same or a higher level. |
| 71 | */ |
| 72 | export function addLine(text, entry) { |
| 73 | const eol = text.includes("\r\n") ? "\r\n" : "\n"; |
| 74 | const lines = text.split(eol); |
| 75 | const start = lines.findIndex((l) => l.trim() === entry.section); |
| 76 | if (start < 0) return { error: `no section "${entry.section}"` }; |
| 77 | const level = entry.section.match(/^#+/)?.[0].length ?? 6; |
| 78 | let end = lines.length; |
| 79 | for (let i = start + 1; i < lines.length; i++) { |
| 80 | const h = lines[i].match(/^(#+)\s/); |
| 81 | if (h && h[1].length <= level) { |
| 82 | end = i; |
| 83 | break; |
| 84 | } |
| 85 | } |
| 86 | const items = []; |
| 87 | for (let i = start + 1; i < end; i++) if (entry.item.test(lines[i])) items.push(i); |
| 88 | if (!items.length) return { error: `no entries in "${entry.section}"` }; |
| 89 | let at; |
| 90 | if (entry.place === "alpha") { |
| 91 | const ours = entry.key(entry.line); |
| 92 | const before = items.filter((i) => entry.key(lines[i]) < ours); |
| 93 | // Sorts first: goes above the first entry. |
| 94 | at = before.length ? before[before.length - 1] + 1 : items[0]; |
| 95 | // An entry's indented sub-items stay with it. |
| 96 | while (before.length && at < end && /^\s+\S/.test(lines[at]) && !entry.item.test(lines[at])) at++; |
| 97 | } else { |
| 98 | at = items[items.length - 1] + 1; |
| 99 | while (at < end && /^\s+[*-] /.test(lines[at])) at++; |
| 100 | } |
| 101 | lines.splice(at, 0, entry.line); |
| 102 | return { text: lines.join(eol), at: at + 1 }; |
| 103 | } |
| 104 | |
| 105 | async function suggest(entry, me) { |
| 106 | if (entry.notBefore && today < entry.notBefore) { |
| 107 | report(entry.id, "not yet", `its rules allow it from ${entry.notBefore}`); |
| 108 | return; |
| 109 | } |
| 110 | const upstream = await rest("GET", `/repos/${entry.repo}`); |
| 111 | if (upstream.archived) { |
| 112 | report(entry.id, "archived", "the list is read-only now, so drop it from awesome-lists/lists.mjs"); |
| 113 | return; |
| 114 | } |
| 115 | const base = upstream.default_branch; |
| 116 | |
| 117 | // A pull request from our fork, in any state: open means done, closed means the maintainers said no. |
| 118 | const prs = await rest("GET", `/repos/${entry.repo}/pulls?state=all&per_page=100&head=${me}:${BRANCH}`); |
| 119 | if (prs.length) { |
| 120 | const pr = prs[0]; |
| 121 | report(entry.id, pr.merged_at ? "merged" : pr.state === "open" ? "already open" : "closed before, left alone", pr.html_url); |
| 122 | return; |
| 123 | } |
| 124 | |
| 125 | const text = await fileText(entry.repo, entry.file, base); |
| 126 | if (/codician-team\/diffinery|diffinery\.com/i.test(text)) { |
| 127 | report(entry.id, "already listed"); |
| 128 | return; |
| 129 | } |
| 130 | const preview = addLine(text, entry); |
| 131 | if (preview.error) { |
| 132 | report(entry.id, "failed", `${entry.file}: ${preview.error}; the list changed, so update awesome-lists/lists.mjs`); |
| 133 | return; |
| 134 | } |
| 135 | if (DRY) { |
| 136 | report(entry.id, "would open", `${entry.file} line ${preview.at}: ${entry.line}`); |
| 137 | return; |
| 138 | } |
| 139 | |
| 140 | // Named after the owner too: two lists can share a repository name (and a fork that already |
| 141 | // exists comes back under its own name). |
| 142 | const fork = await rest("POST", `/repos/${entry.repo}/forks`, { name: entry.repo.replace("/", "-"), default_branch_only: true }); |
| 143 | const forkBase = fork.default_branch || base; |
| 144 | // Forking is asynchronous; wait until the fork answers. |
| 145 | for (let i = 0; i < 20; i++) { |
| 146 | try { |
| 147 | await rest("GET", `/repos/${fork.full_name}/branches/${encodeURIComponent(forkBase)}`); |
| 148 | break; |
| 149 | } catch (e) { |
| 150 | if (i === 19) throw e; |
| 151 | await sleep(3000); |
| 152 | } |
| 153 | } |
| 154 | // A fork left from an earlier run may be behind; bring it up to date where it can be. |
| 155 | await rest("POST", `/repos/${fork.full_name}/merge-upstream`, { branch: forkBase }).catch(() => {}); |
| 156 | const head = (await rest("GET", `/repos/${fork.full_name}/git/ref/heads/${encodeURIComponent(forkBase)}`)).object.sha; |
| 157 | const tree = (await rest("GET", `/repos/${fork.full_name}/git/commits/${head}`)).tree.sha; |
| 158 | const changed = addLine(await fileText(fork.full_name, entry.file, head), entry); |
| 159 | if (changed.error) { |
| 160 | report(entry.id, "failed", `${entry.file} in ${fork.full_name}: ${changed.error}`); |
| 161 | return; |
| 162 | } |
| 163 | // Through git objects rather than the contents API, which can't write files over 1 MB. |
| 164 | const blob = await rest("POST", `/repos/${fork.full_name}/git/blobs`, { content: Buffer.from(changed.text, "utf8").toString("base64"), encoding: "base64" }); |
| 165 | const newTree = await rest("POST", `/repos/${fork.full_name}/git/trees`, { base_tree: tree, tree: [{ path: entry.file, mode: "100644", type: "blob", sha: blob.sha }] }); |
| 166 | const commit = await rest("POST", `/repos/${fork.full_name}/git/commits`, { message: entry.title.replace(/\s*🤖+$/u, ""), tree: newTree.sha, parents: [head] }); |
| 167 | try { |
| 168 | await rest("POST", `/repos/${fork.full_name}/git/refs`, { ref: `refs/heads/${BRANCH}`, sha: commit.sha }); |
| 169 | } catch (e) { |
| 170 | if (!(e instanceof HttpError) || e.status !== 422) throw e; |
| 171 | // Left from an earlier run that stopped before opening the pull request. |
| 172 | await rest("PATCH", `/repos/${fork.full_name}/git/refs/heads/${BRANCH}`, { sha: commit.sha, force: true }); |
| 173 | } |
| 174 | const pr = await rest("POST", `/repos/${entry.repo}/pulls`, { |
| 175 | title: entry.title, |
| 176 | head: `${me}:${BRANCH}`, |
| 177 | base, |
| 178 | body: entry.body + FOOTER, |
| 179 | maintainer_can_modify: true, |
| 180 | }); |
| 181 | report(entry.id, "opened", pr.html_url); |
| 182 | } |
| 183 | |
| 184 | async function summary() { |
| 185 | const file = process.env.GITHUB_STEP_SUMMARY; |
| 186 | if (!file) return; |
| 187 | const { appendFile } = await import("node:fs/promises"); |
| 188 | const rows = results.map((r) => `| ${r.list} | ${r.outcome} | ${r.detail.replace(/\|/g, "\\|")} |`).join("\n"); |
| 189 | await appendFile(file, `## Suggest Diffinery to awesome lists${DRY ? " (dry run)" : ""}\n\n| List | Outcome | Detail |\n| --- | --- | --- |\n${rows}\n`); |
| 190 | } |
| 191 | |
| 192 | if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) { |
| 193 | const chosen = pick === "due" ? LISTS : LISTS.filter((l) => pick.split(",").map((s) => s.trim()).includes(l.id)); |
| 194 | if (!chosen.length) { |
| 195 | console.log(`::error::No list matches "${pick}". Lists: ${LISTS.map((l) => l.id).join(", ")}`); |
| 196 | process.exit(1); |
| 197 | } |
| 198 | if (!TOKEN) { |
| 199 | console.log("::notice::OUTREACH_TOKEN is not set, so nothing was changed."); |
| 200 | process.exit(0); |
| 201 | } |
| 202 | let me; |
| 203 | try { |
| 204 | me = (await rest("GET", "/user")).login; |
| 205 | } catch (e) { |
| 206 | console.log(`::error::OUTREACH_TOKEN can't read /user: ${e.message}`); |
| 207 | process.exit(1); |
| 208 | } |
| 209 | console.log(`Acting as ${me}${DRY ? " (dry run)" : ""}`); |
| 210 | for (const entry of chosen) { |
| 211 | try { |
| 212 | await suggest(entry, me); |
| 213 | } catch (e) { |
| 214 | const hint = e instanceof HttpError && (e.status === 403 || e.status === 404) ? "the token needs the classic public_repo scope: " : ""; |
| 215 | report(entry.id, "failed", hint + e.message); |
| 216 | } |
| 217 | } |
| 218 | await summary(); |
| 219 | process.exitCode = results.some((r) => r.outcome === "failed") ? 1 : 0; |
| 220 | } |