Merge fdddb94175af5c52e1e3fe4c5fe78337d65e4b1e into 18106774c0d91694b644da103624c7ab172ddbea
Diffinery authored 2 parents 1810677fdddb94commit 3ea0f1f3deb85a8a5be242e909009a8758464ea2Browse files
1 file changed+140−0
modifiedscripts/github-community.mjs+140−0View file
| @@ -5,6 +5,7 @@ | ||
| 5 | 5 | // - the labels `roadmap`, `help wanted` and `good first issue` |
| 6 | 6 | // - Discussions turned on, and the welcome post (github-community/welcome-discussion.md) in Announcements |
| 7 | 7 | // - 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) | |
| 8 | 9 | // |
| 9 | 10 | // Every step looks first and only creates what is missing, so running it again changes nothing. |
| 10 | 11 | // A step the token isn't allowed to do is reported with the permission it needs, and the others |
| @@ -13,6 +14,7 @@ | ||
| 13 | 14 | // MIRROR_TOKEN=github_pat_... node scripts/github-community.mjs [--dry-run] |
| 14 | 15 | // |
| 15 | 16 | // 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"; | |
| 16 | 18 | import { readFile } from "node:fs/promises"; |
| 17 | 19 | import path from "node:path"; |
| 18 | 20 | import { fileURLToPath } from "node:url"; |
| @@ -20,6 +22,7 @@ | ||
| 20 | 22 | const REPO = process.env.MIRROR_REPO || "codician-team/diffinery"; |
| 21 | 23 | const TOKEN = process.env.MIRROR_TOKEN; |
| 22 | 24 | const DRY = process.argv.includes("--dry-run"); |
| 25 | +const REPLIES_BRANCH = process.env.REPLIES_BRANCH || "community-replies"; | |
| 23 | 26 | const DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "github-community"); |
| 24 | 27 | |
| 25 | 28 | const WELCOME_TITLE = "Welcome to Diffinery"; |
| @@ -206,6 +209,142 @@ | ||
| 206 | 209 | } |
| 207 | 210 | } |
| 208 | 211 | |
| 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 | + | |
| 209 | 348 | async function summary() { |
| 210 | 349 | const file = process.env.GITHUB_STEP_SUMMARY; |
| 211 | 350 | if (!file) return; |
| @@ -227,5 +366,6 @@ | ||
| 227 | 366 | await labels(); |
| 228 | 367 | await discussions(); |
| 229 | 368 | await roadmap(); |
| 369 | +await replies(); | |
| 230 | 370 | await summary(); |
| 231 | 371 | process.exitCode = results.some((r) => r.outcome === "failed") ? 1 : 0; |