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 |