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.

Merge bb864a3e54ba083c4968d7fac202e7164d627c2a into bcdd5ad4d445c9c773f9e86adc1deee960c4ac98

Diffinery authored 2 parents bcdd5adbb864a3commit cecffcb9d5a2925716bfbe92ab4252f6d7a929c6Browse files
7 files changed+665−0
Files (7)
added.github/workflows/awesome-lists.yml+37−0View file
@@ -1,0 +1,37 @@
1+name: Suggest Diffinery to awesome lists
2+
3+# Opens one pull request per curated list in scripts/awesome-lists/lists.mjs, as the account
4+# that owns OUTREACH_TOKEN, so they show Codician rather than a person. Lists where Diffinery is
5+# already listed or suggested are left alone, and lists whose rules don't allow it yet are
6+# skipped until their day, so running it again is safe. Run it by hand from the Actions tab.
7+#
8+# OUTREACH_TOKEN: a classic personal access token of the Codician GitHub account with only the
9+# public_repo scope (fine-grained tokens can't open pull requests on other people's repositories).
10+on:
11+ workflow_dispatch:
12+ inputs:
13+ lists:
14+ description: "List ids, comma-separated, or 'due' for every list whose rules allow it today"
15+ type: string
16+ default: due
17+ dry_run:
18+ description: Only report what would change
19+ type: boolean
20+ default: false
21+
22+permissions:
23+ contents: read
24+
25+jobs:
26+ suggest:
27+ name: Open the list pull requests
28+ if: ${{ github.server_url != 'https://github.com' && github.repository == 'codician/diffinery' }}
29+ runs-on: ubuntu-latest
30+ environment: github-mirror
31+ timeout-minutes: 10
32+ steps:
33+ - uses: actions/checkout@v4
34+ - run: node scripts/awesome-lists.mjs --lists="$LISTS" ${{ inputs.dry_run && '--dry-run' || '' }}
35+ env:
36+ LISTS: ${{ inputs.lists }}
37+ OUTREACH_TOKEN: ${{ secrets.OUTREACH_TOKEN }}
added.github/workflows/github-community.yml+29−0View file
@@ -1,0 +1,29 @@
1+name: GitHub community setup
2+
3+# Sets up the GitHub mirror's community side as the account that owns MIRROR_TOKEN, so it
4+# shows Codician rather than a person: labels, Discussions with the welcome post, and the
5+# pinned roadmap issue (scripts/github-community.mjs). Only creates what is missing, so
6+# running it again changes nothing. Run it by hand from the Actions tab.
7+on:
8+ workflow_dispatch:
9+ inputs:
10+ dry_run:
11+ description: Only report what would change
12+ type: boolean
13+ default: false
14+
15+permissions:
16+ contents: read
17+
18+jobs:
19+ setup:
20+ name: Labels, welcome discussion and roadmap issue
21+ if: ${{ github.server_url != 'https://github.com' && github.repository == 'codician/diffinery' }}
22+ runs-on: ubuntu-latest
23+ environment: github-mirror
24+ timeout-minutes: 5
25+ steps:
26+ - uses: actions/checkout@v4
27+ - run: node scripts/github-community.mjs ${{ inputs.dry_run && '--dry-run' || '' }}
28+ env:
29+ MIRROR_TOKEN: ${{ secrets.MIRROR_TOKEN }}
addedscripts/awesome-lists.mjs+216−0View file
@@ -1,0 +1,216 @@
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+ 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+
180+async 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+
188+if (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+}
addedscripts/awesome-lists/lists.mjs+134−0View file
@@ -1,0 +1,134 @@
1+// The curated lists Diffinery is suggested to, one pull request each, opened by
2+// scripts/awesome-lists.mjs. Each entry follows that list's own contributing rules (checked
3+// 2026-10-25): its section, its line format and where in the section new lines go.
4+//
5+// id name for the workflow's `lists` input
6+// repo the list's GitHub repository
7+// file the file the line goes into
8+// section the heading line of the section, exactly as in the file
9+// item which lines in the section are entries (a regex on the line)
10+// place "end": after the section's last entry; "alpha": after the last entry that sorts before ours
11+// key for "alpha": the sort key of an entry line
12+// line the line to add
13+// notBefore first day the list's rules allow the suggestion (YYYY-MM-DD), if it has such a rule
14+
15+const REPO = "https://github.com/codician-team/diffinery";
16+const linkText = (line) => (line.match(/\[([^\]]+)\]/)?.[1] ?? "").toLowerCase();
17+
18+export const FOOTER = "\n\n---\n_Generated by [Claude Code](https://claude.ai/code)_\n";
19+
20+export const LISTS = [
21+ {
22+ id: "awesome-mcp-servers",
23+ repo: "punkpeye/awesome-mcp-servers",
24+ file: "README.md",
25+ section: '### 🔄 <a name="version-control"></a>Version Control',
26+ item: /^- \[/,
27+ place: "alpha",
28+ key: linkText,
29+ line: `- [codician-team/diffinery](${REPO}) 📇 ☁️ - MCP server built into Diffinery, an open-source git forge you deploy to your own Cloudflare account: files and commits, pull requests and reviews, issues, CI runs and job logs, releases, sandboxes and agent tasks.`,
30+ // The list fast-tracks pull requests from automated agents that mark their title like this.
31+ title: "Add Diffinery to Version Control 🤖🤖🤖",
32+ body: `Adds [Diffinery](${REPO}) to **Version Control**.
33+
34+Diffinery is an open-source (Apache-2.0) git forge with GitHub Actions compatible CI that you deploy to your own Cloudflare account. Every instance serves an MCP server at \`/mcp\` (streamable HTTP, OAuth or token sign-in) with tools for files and commits, branches, pull requests and reviews, issues, workflow runs and job logs, releases, sandboxes and agent tasks. It's installable and self-hosted, so it fits this list rather than the remote-only one.
35+
36+- Docs: https://diffinery.com/docs/agents/mcp
37+- Official MCP Registry: \`com.diffinery/diffinery\`
38+
39+This pull request was opened by an automated agent for the Codician team, which builds Diffinery.`,
40+ },
41+ {
42+ id: "appcypher-mcp-servers",
43+ repo: "appcypher/awesome-mcp-servers",
44+ file: "README.md",
45+ section: '## 🔄 <a name="version-control"></a>Version Control',
46+ item: /^- /,
47+ place: "end",
48+ line: `- <img src="https://diffinery.com/favicon.svg" height="14"/> [Diffinery](${REPO}) - MCP server built into Diffinery, an open-source git forge on Cloudflare: files and commits, pull requests and reviews, issues, CI runs and job logs, sandboxes and agent tasks`,
49+ title: "Add Diffinery to Version Control",
50+ body: `Adds [Diffinery](${REPO}) at the bottom of **Version Control**.
51+
52+Diffinery is an open-source (Apache-2.0) git forge with GitHub Actions compatible CI that you deploy to your own Cloudflare account. Every instance serves an MCP server at \`/mcp\` with tools for files and commits, pull requests and reviews, issues, workflow runs and job logs, sandboxes and agent tasks. Docs: https://diffinery.com/docs/agents/mcp
53+
54+Opened by an automated agent for the Codician team, which builds Diffinery.`,
55+ },
56+ {
57+ id: "awesome-git",
58+ repo: "dictcp/awesome-git",
59+ file: "README.md",
60+ section: "## Self-Hosted Repository",
61+ item: /^\s*\* /,
62+ place: "end",
63+ line: "* [Diffinery](https://diffinery.com/) - an open-source git forge you deploy to your own Cloudflare account, with pull requests, code review and GitHub Actions compatible CI. Apache-2.0.",
64+ title: "Add Diffinery to Self-Hosted Repository",
65+ body: `Adds [Diffinery](https://diffinery.com/) ([source](${REPO})) to **Self-Hosted Repository**.
66+
67+Diffinery is an open-source (Apache-2.0) git forge: repositories, pull requests with code review, issues and GitHub Actions compatible CI. You host it yourself on your own Cloudflare account (Workers, Durable Objects, D1, R2, Containers) instead of on a server.
68+
69+Opened by an automated agent for the Codician team, which builds Diffinery.`,
70+ },
71+ {
72+ id: "awesome-ci",
73+ repo: "ligurio/awesome-ci",
74+ file: "README.md",
75+ section: "### Introduction",
76+ item: /^\|\[/,
77+ place: "alpha",
78+ key: linkText,
79+ line: `|[Diffinery](https://diffinery.com/) | Git forge with built-in CI that runs GitHub Actions workflow files on Cloudflare Containers. | Languages: everything that runs on Ubuntu 24.04 x64, self-hosted runners for other platforms. Open source (Apache-2.0), self-hosted on your own Cloudflare account | Diffinery | [Documentation](https://diffinery.com/docs/actions/overview) | [Open Source](${REPO}) | [![Stars](https://img.shields.io/github/stars/codician-team/diffinery.svg)](${REPO}) |`,
80+ title: "Add Diffinery",
81+ body: `Adds [Diffinery](https://diffinery.com/) to the table, in alphabetical order.
82+
83+Diffinery is an open-source (Apache-2.0) git forge with built-in CI. It runs GitHub Actions workflow files from \`.github/workflows/\` on Cloudflare Containers (Ubuntu 24.04 x64, 4 vCPU and 12 GiB by default), and supports self-hosted runners for other platforms. You host the whole thing on your own Cloudflare account. Differences from GitHub Actions: https://diffinery.com/docs/actions/compatibility
84+
85+Opened by an automated agent for the Codician team, which builds Diffinery.`,
86+ },
87+ {
88+ id: "awesome-cloudflare-zh",
89+ repo: "zhuima/awesome-cloudflare",
90+ file: "README.md",
91+ section: "## 开发者工具",
92+ item: /^\| \[/,
93+ place: "end",
94+ line: `| [Diffinery](${REPO}) | 完全运行在 Cloudflare 上的开源 Git 代码托管平台:仓库、Pull Request 与代码评审、Issue,兼容 GitHub Actions 的 CI(运行在 Cloudflare Containers 上),以及供 AI 代理使用的 MCP 服务器。基于 Workers + Durable Objects + D1 + R2 + Queues 构建,部署到你自己的 Cloudflare 账户。 | <https://diffinery.com> | 维护中 |`,
95+ title: "添加 Diffinery 到开发者工具 / Add Diffinery to developer tools",
96+ body: `在「开发者工具」末尾添加 [Diffinery](${REPO})。
97+
98+Diffinery 是一个开源(Apache-2.0)的 Git 代码托管平台,完全运行在 Cloudflare 上(Workers、Durable Objects、D1、R2、Queues、Containers):仓库、Pull Request 与代码评审、Issue,兼容 GitHub Actions 的 CI,以及供 AI 代理使用的 MCP 服务器。部署到你自己的 Cloudflare 账户。
99+
100+Adds Diffinery, an open-source git forge that runs entirely on Cloudflare, at the end of the developer tools table. Opened by an automated agent for the Codician team, which builds Diffinery.`,
101+ },
102+ {
103+ id: "awesome-claude-code-plugins",
104+ repo: "ccplugins/awesome-claude-code-plugins",
105+ file: "README.md",
106+ section: "## External Marketplaces",
107+ item: /^\| \[/,
108+ place: "end",
109+ line: `| [codician-team/diffinery](${REPO}) | Plugins for the Diffinery git forge: diffinery (MCP server for repositories, pull requests, issues, CI and sandboxes, plus a skill) and diffinery-ci (live CI status for the commits Claude pushes) | \`claude plugin marketplace add codician-team/diffinery\` then \`claude plugin install diffinery@diffinery\` |`,
110+ title: "Add the Diffinery marketplace",
111+ body: `Adds the [Diffinery](${REPO}) plugin marketplace to **External Marketplaces**.
112+
113+Diffinery is an open-source (Apache-2.0) git forge you deploy to your own Cloudflare account. Its marketplace has two plugins: \`diffinery\` (its MCP server and a skill for working with repositories, pull requests, issues, CI and sandboxes) and \`diffinery-ci\` (tells Claude when CI on a commit it pushed has finished). Docs: https://diffinery.com/docs/connect/ci-status-in-claude
114+
115+Opened by an automated agent for the Codician team, which builds Diffinery.`,
116+ },
117+ {
118+ id: "awesome-cloudflare",
119+ repo: "irazasyed/awesome-cloudflare",
120+ file: "readme.md",
121+ section: "### Open Source",
122+ item: /^- \[/,
123+ place: "end",
124+ line: `- [Diffinery](${REPO}) - Open-source git forge with pull requests, code review and GitHub Actions compatible CI, running on Workers, Durable Objects, D1, R2 and Containers.`,
125+ // contributing.md: "If you just created something, wait at least 30 days before submitting."
126+ notBefore: "2026-11-24",
127+ title: "Add Diffinery",
128+ body: `Adds [Diffinery](${REPO}) at the bottom of **Apps → Open Source**.
129+
130+Diffinery is an open-source (Apache-2.0) git forge that runs entirely on Cloudflare: repositories live in Durable Objects and R2, metadata in D1, and its GitHub Actions compatible CI runs on Containers. You deploy it to your own account. It belongs here because it's a complete, self-hostable app built only from Cloudflare's developer platform; https://diffinery.com/built-on-cloudflare explains how.
131+
132+Opened by an automated agent for the Codician team, which builds Diffinery.`,
133+ },
134+];
addedscripts/github-community.mjs+219−0View file
@@ -1,0 +1,219 @@
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) report("Roadmap issue", "already there", issue.html_url);
166+ else if (DRY) {
167+ report("Roadmap issue", "would open");
168+ return;
169+ } else {
170+ try {
171+ const body = await readFile(path.join(DIR, "roadmap-issue.md"), "utf8");
172+ issue = await rest("POST", `/repos/${REPO}/issues`, { title: ROADMAP_TITLE, body, labels: ["roadmap", "help wanted"] });
173+ report("Roadmap issue", "opened", issue.html_url);
174+ } catch (e) {
175+ report("Roadmap issue", "failed", why(e, "Issues: Read and write"));
176+ return;
177+ }
178+ }
179+
180+ try {
181+ const q = await graphql(`query($id: ID!) { node(id: $id) { ... on Issue { isPinned } } }`, { id: issue.node_id });
182+ if (q.node.isPinned) {
183+ report("Pin roadmap issue", "already pinned");
184+ return;
185+ }
186+ if (DRY) {
187+ report("Pin roadmap issue", "would pin");
188+ return;
189+ }
190+ await graphql(`mutation($id: ID!) { pinIssue(input: { issueId: $id }) { issue { isPinned } } }`, { id: issue.node_id });
191+ report("Pin roadmap issue", "pinned");
192+ } catch (e) {
193+ report("Pin roadmap issue", "failed", why(e, "Issues: Read and write"));
194+ }
195+}
196+
197+async function summary() {
198+ const file = process.env.GITHUB_STEP_SUMMARY;
199+ if (!file) return;
200+ const { appendFile } = await import("node:fs/promises");
201+ const rows = results.map((r) => `| ${r.step} | ${r.outcome} | ${r.detail.replace(/\|/g, "\\|")} |`).join("\n");
202+ await appendFile(file, `## GitHub community setup${DRY ? " (dry run)" : ""}\n\n| Step | Outcome | Detail |\n| --- | --- | --- |\n${rows}\n`);
203+}
204+
205+if (!TOKEN) {
206+ console.log("::notice::MIRROR_TOKEN is not set, so nothing was changed.");
207+ process.exit(0);
208+}
209+try {
210+ const me = await rest("GET", "/user");
211+ console.log(`Acting as ${me.login} on ${REPO}${DRY ? " (dry run)" : ""}`);
212+} catch {
213+ console.log(`Acting on ${REPO} with a token that can't read /user${DRY ? " (dry run)" : ""}`);
214+}
215+await labels();
216+await discussions();
217+await roadmap();
218+await summary();
219+process.exitCode = results.some((r) => r.outcome === "failed") ? 1 : 0;
addedscripts/github-community/roadmap-issue.md+14−0View file
@@ -1,0 +1,14 @@
1+This issue tracks the main gaps people ask about. Nothing here has a date. Comment with what matters most to you, or 👍 an item below, and we'll split items into their own issues as work starts. The full list is in [ROADMAP.md](https://github.com/codician-team/diffinery/blob/main/ROADMAP.md).
2+
3+- [ ] Hosted macOS, Windows and Arm runners (today: [self-hosted runners](https://diffinery.com/docs/actions/runners#self-hosted-runners))
4+- [ ] Git over SSH (today: HTTPS with a credential helper)
5+- [ ] Forks and pull requests between repositories
6+- [ ] Richer reviews: pending reviews, suggested changes, resolving threads
7+- [ ] Code-owner reviews and signed-commit requirements in branch protection
8+- [ ] Workflow events that are accepted but don't start runs yet, such as `issues` and `issue_comment`
9+- [ ] Importing from Gitea, Forgejo and GitLab
10+- [ ] Wikis, discussions and Pages-style static sites
11+
12+**Good places to start contributing:** the gaps in [Compatibility with GitHub Actions](https://diffinery.com/docs/actions/compatibility), each with the GitHub behaviour to match, and issues labelled `good first issue`.
13+
14+– the Codician team
addedscripts/github-community/welcome-discussion.md+16−0View file
@@ -1,0 +1,16 @@
1+Welcome! Diffinery is an open-source GitHub and GitHub Actions alternative that runs on your own Cloudflare account.
2+
3+**Where things go**
4+- **Q&A**: help deploying or running workflows. Include your workflow file and the job log if you can.
5+- **Ideas**: features you'd like. The [roadmap](https://github.com/codician-team/diffinery/blob/main/ROADMAP.md) lists the known gaps.
6+- **Show and tell**: your instance, your migration from GitHub, your agent setup.
7+- **Issues**: bugs, and workflows that run on GitHub but not on Diffinery. Security problems go to [the private report form](https://diffinery.com/security/report), never a public issue.
8+
9+**Getting started**
10+- [Deploy your own](https://diffinery.com/docs/self-hosting/deploy)
11+- [Compatibility with GitHub Actions](https://diffinery.com/docs/actions/compatibility)
12+- [Contributing](https://github.com/codician-team/diffinery/blob/main/CONTRIBUTING.md)
13+
14+Development happens on [diffinery.com/codician/diffinery](https://diffinery.com/codician/diffinery) and is mirrored here; pull requests are welcome in either place.
15+
16+– the Codician team