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.

216 lines · 9.4 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 const base = upstream.default_branch;
112
113 // A pull request from our fork, in any state: open means done, closed means the maintainers said no.
114 const prs = await rest("GET", `/repos/${entry.repo}/pulls?state=all&per_page=100&head=${me}:${BRANCH}`);
115 if (prs.length) {
116 const pr = prs[0];
117 report(entry.id, pr.merged_at ? "merged" : pr.state === "open" ? "already open" : "closed before, left alone", pr.html_url);
118 return;
119 }
120
121 const text = await fileText(entry.repo, entry.file, base);
122 if (/codician-team\/diffinery|diffinery\.com/i.test(text)) {
123 report(entry.id, "already listed");
124 return;
125 }
126 const preview = addLine(text, entry);
127 if (preview.error) {
128 report(entry.id, "failed", `${entry.file}: ${preview.error}; the list changed, so update awesome-lists/lists.mjs`);
129 return;
130 }
131 if (DRY) {
132 report(entry.id, "would open", `${entry.file} line ${preview.at}: ${entry.line}`);
133 return;
134 }
135
136 // Named after the owner too: two lists can share a repository name (and a fork that already
137 // exists comes back under its own name).
138 const fork = await rest("POST", `/repos/${entry.repo}/forks`, { name: entry.repo.replace("/", "-"), default_branch_only: true });
139 const forkBase = fork.default_branch || base;
140 // Forking is asynchronous; wait until the fork answers.
141 for (let i = 0; i < 20; i++) {
142 try {
143 await rest("GET", `/repos/${fork.full_name}/branches/${encodeURIComponent(forkBase)}`);
144 break;
145 } catch (e) {
146 if (i === 19) throw e;
147 await sleep(3000);
148 }
149 }
150 // A fork left from an earlier run may be behind; bring it up to date where it can be.
151 await rest("POST", `/repos/${fork.full_name}/merge-upstream`, { branch: forkBase }).catch(() => {});
152 const head = (await rest("GET", `/repos/${fork.full_name}/git/ref/heads/${encodeURIComponent(forkBase)}`)).object.sha;
153 const tree = (await rest("GET", `/repos/${fork.full_name}/git/commits/${head}`)).tree.sha;
154 const changed = addLine(await fileText(fork.full_name, entry.file, head), entry);
155 if (changed.error) {
156 report(entry.id, "failed", `${entry.file} in ${fork.full_name}: ${changed.error}`);
157 return;
158 }
159 // Through git objects rather than the contents API, which can't write files over 1 MB.
160 const blob = await rest("POST", `/repos/${fork.full_name}/git/blobs`, { content: Buffer.from(changed.text, "utf8").toString("base64"), encoding: "base64" });
161 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 }] });
162 const commit = await rest("POST", `/repos/${fork.full_name}/git/commits`, { message: entry.title.replace(/\s*🤖+$/u, ""), tree: newTree.sha, parents: [head] });
163 try {
164 await rest("POST", `/repos/${fork.full_name}/git/refs`, { ref: `refs/heads/${BRANCH}`, sha: commit.sha });
165 } catch (e) {
166 if (!(e instanceof HttpError) || e.status !== 422) throw e;
167 // Left from an earlier run that stopped before opening the pull request.
168 await rest("PATCH", `/repos/${fork.full_name}/git/refs/heads/${BRANCH}`, { sha: commit.sha, force: true });
169 }
170 const pr = await rest("POST", `/repos/${entry.repo}/pulls`, {
171 title: entry.title,
172 head: `${me}:${BRANCH}`,
173 base,
174 body: entry.body + FOOTER,
175 maintainer_can_modify: true,
176 });
177 report(entry.id, "opened", pr.html_url);
178}
179
180async function summary() {
181 const file = process.env.GITHUB_STEP_SUMMARY;
182 if (!file) return;
183 const { appendFile } = await import("node:fs/promises");
184 const rows = results.map((r) => `| ${r.list} | ${r.outcome} | ${r.detail.replace(/\|/g, "\\|")} |`).join("\n");
185 await appendFile(file, `## Suggest Diffinery to awesome lists${DRY ? " (dry run)" : ""}\n\n| List | Outcome | Detail |\n| --- | --- | --- |\n${rows}\n`);
186}
187
188if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) {
189 const chosen = pick === "due" ? LISTS : LISTS.filter((l) => pick.split(",").map((s) => s.trim()).includes(l.id));
190 if (!chosen.length) {
191 console.log(`::error::No list matches "${pick}". Lists: ${LISTS.map((l) => l.id).join(", ")}`);
192 process.exit(1);
193 }
194 if (!TOKEN) {
195 console.log("::notice::OUTREACH_TOKEN is not set, so nothing was changed.");
196 process.exit(0);
197 }
198 let me;
199 try {
200 me = (await rest("GET", "/user")).login;
201 } catch (e) {
202 console.log(`::error::OUTREACH_TOKEN can't read /user: ${e.message}`);
203 process.exit(1);
204 }
205 console.log(`Acting as ${me}${DRY ? " (dry run)" : ""}`);
206 for (const entry of chosen) {
207 try {
208 await suggest(entry, me);
209 } catch (e) {
210 const hint = e instanceof HttpError && (e.status === 403 || e.status === 404) ? "the token needs the classic public_repo scope: " : "";
211 report(entry.id, "failed", hint + e.message);
212 }
213 }
214 await summary();
215 process.exitCode = results.some((r) => r.outcome === "failed") ? 1 : 0;
216}