1import { createHmac } from "node:crypto"
2import { readFile } from "node:fs/promises"
3import { pathToFileURL } from "node:url"
4
5export const botLogin = "effect-bot"
6export const isCommand = (body) => /^@effect-bot(?![\w-])/iu.test(body)
7export const deliveryKey = (eventName, event) =>
8 `${event.repository.full_name}:${eventName}:${event.comment.id}`
9
10export async function relayComment(options) {
11 const { eventName, event, githubToken, webhookUrl, webhookSecret } = options
12 const fetch = options.fetch ?? globalThis.fetch
13 const matches = options.isCommand ?? isCommand
14 const keyFor = options.deliveryKey ?? deliveryKey
15 if (eventName !== "issue_comment" ||
16 event?.action !== "created" || typeof event.comment?.body !== "string" ||
17 // Never let the bot's own comments re-trigger it.
18 String(event.comment.user?.login).toLowerCase() === botLogin ||
19 !matches(event.comment.body)) return { status: "ignored" }
20
21 const repo = event.repository?.full_name
22 const number = event.issue?.number
23 const comment = event.comment
24 const author = comment.user?.login
25 if (typeof repo !== "string" || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(repo) ||
26 typeof author !== "string" || !/^[A-Za-z0-9-]+(?:\[bot\])?$/u.test(author) ||
27 !Number.isSafeInteger(number) || number <= 0 ||
28 !Number.isSafeInteger(comment.id) || comment.id <= 0 ||
29 typeof comment.html_url !== "string") throw new Error("Malformed comment event")
30 if (!githubToken || !webhookUrl || typeof webhookSecret !== "string" || !webhookSecret) {
31 throw new Error("Missing relay configuration")
32 }
33 const endpoint = new URL(webhookUrl)
34 if (endpoint.protocol !== "https:" || endpoint.username || endpoint.password) {
35 throw new Error("Webhook must be an HTTPS URL without credentials")
36 }
37 const githubHeaders = {
38 Authorization: `Bearer ${githubToken}`,
39 Accept: "application/vnd.github+json",
40 "X-GitHub-Api-Version": "2022-11-28",
41 "Content-Type": "application/json"
42 }
43 const request = (url, init) => fetch(url, {
44 ...init,
45 redirect: "error",
46 signal: AbortSignal.timeout(30_000)
47 })
48 const githubBase = `https://api.github.com/repos/${repo}`
49 const permission = await request(`${githubBase}/collaborators/${encodeURIComponent(author)}/permission`, {
50 headers: githubHeaders
51 })
52 if (!permission.ok) throw new Error(`Permission lookup failed: HTTP ${permission.status}`)
53 const { role_name: role } = await permission.json()
54 // The live role is authoritative. author_association only narrows write
55 // access to outside collaborators, and reflects affiliation when the comment
56 // was created; no permission fallback or custom-role inference.
57 const outsideWriter = role === "write" && comment.author_association === "COLLABORATOR"
58 if (role !== "admin" && role !== "maintain" && !outsideWriter) return { status: "rejected" }
59
60 const payload = {
61 action: "created",
62 repo,
63 number,
64 isPullRequest: Boolean(event.issue?.pull_request),
65 commentId: comment.id,
66 commentUrl: comment.html_url,
67 body: comment.body,
68 author
69 }
70 const body = JSON.stringify(payload)
71 const signature = `sha256=${createHmac("sha256", webhookSecret).update(body, "utf8").digest("hex")}`
72 const key = keyFor(eventName, event)
73 if (typeof key !== "string" || !key || /[\r\n]/u.test(key)) throw new Error("Invalid delivery key")
74 const response = await request(webhookUrl, {
75 method: "POST",
76 headers: {
77 "Content-Type": "application/json",
78 "X-GitHub-Event": eventName,
79 "X-GitHub-Delivery": key,
80 "X-Hub-Signature-256": signature
81 },
82 body
83 })
84 if (!response.ok) throw new Error(`Webhook failed: HTTP ${response.status}`)
85 const result = await response.json()
86 if (!["accepted", "duplicate", "ignored", "skipped"].includes(result?.status)) {
87 throw new Error("Unrecognized webhook admission response")
88 }
89 if ((result.status === "accepted" || result.status === "duplicate") &&
90 typeof result.run_id === "string" && result.run_id.length > 0) {
91 try {
92 const reaction = await request(`${githubBase}/issues/comments/${comment.id}/reactions`, {
93 method: "POST", headers: githubHeaders, body: JSON.stringify({ content: "eyes" })
94 })
95 if (!reaction.ok) throw new Error("Reaction HTTP error")
96 } catch {
97 // An admitted request must never be redelivered merely to retry feedback.
98 return { ...result, reactionFailed: true }
99 }
100 }
101 return result
102}
103
104if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
105 try {
106 const event = JSON.parse(await readFile(process.env.GITHUB_EVENT_PATH, "utf8"))
107 // Fixed repository boundary; no event-supplied API hosts or executable text.
108 if (event.repository?.full_name !== "Effect-TS/effect") throw new Error("Unexpected repository")
109 const result = await relayComment({
110 eventName: process.env.GITHUB_EVENT_NAME, event,
111 githubToken: process.env.GITHUB_TOKEN,
112 webhookUrl: process.env.MULTICA_EFFECT_BOT_WEBHOOK_URL,
113 webhookSecret: process.env.MULTICA_EFFECT_BOT_WEBHOOK_SECRET
114 })
115 // Never log the body, credentials, signature, endpoint, or raw remote errors.
116 console.log(`Effect bot relay: ${result.status}${result.reactionFailed ? " (reaction failed)" : ""}`)
117 if (["ignored", "skipped"].includes(result.status)) process.exitCode = 1
118 } catch {
119 console.error("Effect bot relay failed; check configuration and API permissions. No automatic retry.")
120 process.exitCode = 1
121 }
122}