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.

GitHub community: post replies to issues and discussions from a branch #322

Mergedtest-botagenton behalf of cnbrkdmrcimerged into main from claude/community-replies
1 file changed+140−0base 1810677 → head fdddb94
modifiedscripts/github-community.mjs+140−0View file
@@ -5,6 +5,7 @@
55 // - the labels `roadmap`, `help wanted` and `good first issue`
66 // - Discussions turned on, and the welcome post (github-community/welcome-discussion.md) in Announcements
77 // - the roadmap issue (github-community/roadmap-issue.md), labelled and pinned
8+// - replies to issues and discussions, read from the `community-replies` branch (see replies() below)
89 //
910 // Every step looks first and only creates what is missing, so running it again changes nothing.
1011 // A step the token isn't allowed to do is reported with the permission it needs, and the others
@@ -13,6 +14,7 @@
1314 // MIRROR_TOKEN=github_pat_... node scripts/github-community.mjs [--dry-run]
1415 //
1516 // GitHub has no API to pin a discussion or to edit discussion categories, so those stay as they are.
17+import { execFileSync } from "node:child_process";
1618 import { readFile } from "node:fs/promises";
1719 import path from "node:path";
1820 import { fileURLToPath } from "node:url";
@@ -20,6 +22,7 @@
2022 const REPO = process.env.MIRROR_REPO || "codician-team/diffinery";
2123 const TOKEN = process.env.MIRROR_TOKEN;
2224 const DRY = process.argv.includes("--dry-run");
25+const REPLIES_BRANCH = process.env.REPLIES_BRANCH || "community-replies";
2326 const DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "github-community");
2427
2528 const WELCOME_TITLE = "Welcome to Diffinery";
@@ -206,6 +209,142 @@
206209 }
207210 }
208211
212+// Replies to issues and discussions on the mirror, posted as the token's account. They wait on
213+// the `community-replies` branch of this repository (never merged, never mirrored) as
214+// replies/<id>.md, so posting one needs a push to that branch and a run of this workflow, not a
215+// pull request. Each file starts with a header:
216+//
217+// ---
218+// target: issue 4 (or: discussion 2)
219+// labels: bug, gh (optional, added to an issue)
220+// close: completed (optional: completed or not_planned, closes an issue)
221+// ---
222+// The reply, in Markdown.
223+//
224+// The posted comment ends with <!-- diffinery-reply: <id> -->, and a reply whose marker is
225+// already on its target is skipped, so running again posts nothing twice.
226+function replyFiles() {
227+ const git = (...args) => execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
228+ try {
229+ git("fetch", "--quiet", "--depth=1", "origin", `+refs/heads/${REPLIES_BRANCH}:refs/remotes/origin/${REPLIES_BRANCH}`);
230+ } catch {
231+ return null;
232+ }
233+ const ref = `origin/${REPLIES_BRANCH}`;
234+ const names = git("ls-tree", "--name-only", ref, "replies/").split("\n").filter((n) => n.endsWith(".md")).map((n) => n.slice("replies/".length));
235+ return names.sort().map((name) => ({ id: name.slice(0, -3), text: git("show", `${ref}:replies/${name}`) }));
236+}
237+
238+function parseReply(id, text) {
239+ const m = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/.exec(text.replace(/\r\n/g, "\n"));
240+ if (!m) throw new Error("no --- header");
241+ const head = Object.fromEntries(
242+ m[1].split("\n").filter((l) => l.trim()).map((l) => {
243+ const i = l.indexOf(":");
244+ return [l.slice(0, i).trim(), l.slice(i + 1).trim()];
245+ }),
246+ );
247+ const t = /^(issue|discussion) (\d+)$/.exec(head.target ?? "");
248+ if (!t) throw new Error(`target must be "issue N" or "discussion N", not "${head.target ?? ""}"`);
249+ if (head.close && !["completed", "not_planned"].includes(head.close)) throw new Error(`close must be completed or not_planned`);
250+ const body = m[2].trim();
251+ if (!body) throw new Error("empty reply");
252+ return {
253+ id,
254+ kind: t[1],
255+ number: Number(t[2]),
256+ labels: (head.labels ?? "").split(",").map((l) => l.trim()).filter(Boolean),
257+ close: head.close || null,
258+ marker: `<!-- diffinery-reply: ${id} -->`,
259+ body,
260+ };
261+}
262+
263+async function issueReply(r, step) {
264+ let posted = false;
265+ for (let page = 1; page <= 20 && !posted; page++) {
266+ const list = await rest("GET", `/repos/${REPO}/issues/${r.number}/comments?per_page=100&page=${page}`);
267+ posted = list.some((c) => c.body?.includes(r.marker));
268+ if (list.length < 100) break;
269+ }
270+ if (posted) report(step, "already posted");
271+ else if (DRY) report(step, "would post");
272+ else {
273+ const c = await rest("POST", `/repos/${REPO}/issues/${r.number}/comments`, { body: `${r.body}\n\n${r.marker}\n` });
274+ report(step, "posted", c.html_url);
275+ }
276+ const issue = await rest("GET", `/repos/${REPO}/issues/${r.number}`);
277+ const missing = r.labels.filter((l) => !issue.labels.some((x) => (x.name ?? x) === l));
278+ if (missing.length) {
279+ if (DRY) report(`${step} labels`, "would add", missing.join(", "));
280+ else {
281+ await rest("POST", `/repos/${REPO}/issues/${r.number}/labels`, { labels: missing });
282+ report(`${step} labels`, "added", missing.join(", "));
283+ }
284+ }
285+ // Close only alongside the first posting, so an issue someone reopened stays open.
286+ if (r.close && !posted && issue.state === "open") {
287+ if (DRY) report(`${step} close`, "would close", r.close);
288+ else {
289+ await rest("PATCH", `/repos/${REPO}/issues/${r.number}`, { state: "closed", state_reason: r.close });
290+ report(`${step} close`, "closed", r.close);
291+ }
292+ }
293+}
294+
295+async function discussionReply(r, step) {
296+ const [owner, name] = REPO.split("/");
297+ const q = await graphql(
298+ `query($owner: String!, $name: String!, $number: Int!) {
299+ repository(owner: $owner, name: $name) {
300+ discussion(number: $number) { id comments(last: 100) { nodes { body replies(last: 50) { nodes { body } } } } }
301+ }
302+ }`,
303+ { owner, name, number: r.number },
304+ );
305+ const d = q.repository.discussion;
306+ if (!d) throw new HttpError(404, `discussion ${r.number} not found`);
307+ const bodies = d.comments.nodes.flatMap((c) => [c.body, ...c.replies.nodes.map((x) => x.body)]);
308+ if (bodies.some((b) => b?.includes(r.marker))) report(step, "already posted");
309+ else if (DRY) report(step, "would post");
310+ else {
311+ const c = await graphql(
312+ `mutation($id: ID!, $body: String!) { addDiscussionComment(input: { discussionId: $id, body: $body }) { comment { url } } }`,
313+ { id: d.id, body: `${r.body}\n\n${r.marker}\n` },
314+ );
315+ report(step, "posted", c.addDiscussionComment.comment.url);
316+ }
317+}
318+
319+async function replies() {
320+ let files;
321+ try {
322+ files = replyFiles();
323+ } catch (e) {
324+ report("Replies", "failed", e.message);
325+ return;
326+ }
327+ if (!files?.length) {
328+ report("Replies", "none waiting", `no replies/ on the ${REPLIES_BRANCH} branch`);
329+ return;
330+ }
331+ for (const f of files) {
332+ let r;
333+ try {
334+ r = parseReply(f.id, f.text);
335+ } catch (e) {
336+ report(`Reply ${f.id}`, "failed", e.message);
337+ continue;
338+ }
339+ const step = `Reply ${r.id} on ${r.kind} ${r.number}`;
340+ try {
341+ await (r.kind === "issue" ? issueReply(r, step) : discussionReply(r, step));
342+ } catch (e) {
343+ report(step, "failed", why(e, r.kind === "issue" ? "Issues: Read and write" : "Discussions: Read and write"));
344+ }
345+ }
346+}
347+
209348 async function summary() {
210349 const file = process.env.GITHUB_STEP_SUMMARY;
211350 if (!file) return;
@@ -227,5 +366,6 @@
227366 await labels();
228367 await discussions();
229368 await roadmap();
369+await replies();
230370 await summary();
231371 process.exitCode = results.some((r) => r.outcome === "failed") ? 1 : 0;