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 2b080a26d16406f44630aea5b7085a959f907e9c into bf456154c8a9c68eefa27fdf5c19a4e30603cb05

Diffinery authored 2 parents bf456152b080a2commit 0fbd007d26602502e104c855dc802ae3f1ee5d59Browse files
4 files changed+278−0
Files (4)
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/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