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.

220 lines · 9.6 KBRaw History
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.
16import { LISTS, FOOTER } from "./awesome-lists/lists.mjs";
17
18const TOKEN = process.env.OUTREACH_TOKEN;
19const DRY = process.argv.includes("--dry-run");
20const pick = (process.argv.find((a) => a.startsWith("--lists="))?.slice(8) || "due").trim();
21const today = (process.env.TODAY || new Date().toISOString()).slice(0, 10);
22const API = process.env.GITHUB_API_URL_OVERRIDE || "https://api.github.com";
23const BRANCH = "add-diffinery";
24
25const results = [];
26const 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
32class HttpError extends Error {
33 constructor(status, message) {
34 super(message);
35 this.status = status;
36 }
37}
38
39async 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. */
57async 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
66const 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 */
72export 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
105async 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
184async 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
192if (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}