From 44b8fead78bd9ac99fc825c0210c5afb27fc9bea Mon Sep 17 00:00:00 2001 From: RhenCloud Date: Sun, 2 Aug 2026 23:02:37 +0800 Subject: [PATCH] feat: add merge/close PR buttons to Discord PR notifications --- README.md | 4 ++ README.zh.md | 4 ++ docs/api/actions.md | 28 +++++++++++- docs/api/overview.md | 1 + src/action-routes.ts | 24 +++++++++++ src/discord-gateway.ts | 96 +++++++++++++++++++++++++++++++++++++++++- src/formatter.ts | 17 ++++++++ src/github-oauth.ts | 43 +++++++++++++++++++ src/types.ts | 9 ++++ 9 files changed, 223 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index abdd267..fa4e6e0 100644 --- a/README.md +++ b/README.md @@ -208,6 +208,10 @@ When the Gateway is enabled, the bot registers native **slash** and **message co For `edit` / `del`, copy the specific comment link on GitHub (comment ⋯ menu → **Copy link**). `add` / `edit` open a modal to enter/adjust the comment body (prefilled for edit). +**3. Merge / close a PR** — notifications for open PRs include **合并 / 关闭** (merge/close) buttons: + +- Clicking a button merges (squash) or closes the PR as **your linked** GitHub account; GitHub enforces permission. On success the buttons are removed from the notification and the result is shown in an ephemeral reply. + **Requirements:** | Item | How | diff --git a/README.zh.md b/README.zh.md index e0b2110..beaa858 100644 --- a/README.zh.md +++ b/README.zh.md @@ -207,6 +207,10 @@ Discord Gateway 连接仅用于让 bot 显示为**在线**——发送消息** `edit` / `del` 需要具体的评论链接(在 GitHub 上:评论 ⋯ 菜单 → **Copy link**)。`add` / `edit` 会弹出 modal 让你输入 / 修改评论内容(编辑时预填原文)。 +**3. 合并 / 关闭 PR** —— 打开状态的 PR 通知会附带 **合并 / 关闭** 按钮: + +- 点击按钮后以**你绑定**的 GitHub 账号执行合并(squash)或关闭操作,权限交由 GitHub 判定。操作成功后通知上的按钮会被移除,结果以 ephemeral 回复显示。 + **要求:** | 项目 | 说明 | diff --git a/docs/api/actions.md b/docs/api/actions.md index 8d15beb..92da345 100644 --- a/docs/api/actions.md +++ b/docs/api/actions.md @@ -1,6 +1,6 @@ # Actions -User action endpoints allow commenting on issues, merging PRs, and adding reactions. All action endpoints require a valid Bearer token from the OAuth flow. +User action endpoints allow commenting on issues, merging/closing PRs, and adding reactions. All action endpoints require a valid Bearer token from the OAuth flow. ## Authentication @@ -70,6 +70,32 @@ Merges a pull request. **Response:** `200` with GitHub merge response. +### Close Pull Request + +``` +POST /api/close +``` + +Closes a pull request without merging. + +**Request Body:** + +```json +{ + "owner": "org", + "repo": "repo", + "pullNumber": 42 +} +``` + +| Field | Type | Required | Description | +| ------------ | ------ | -------- | ----------------------- | +| `owner` | string | Yes | Repository owner | +| `repo` | string | Yes | Repository name | +| `pullNumber` | number | Yes | Pull request number | + +**Response:** `200` with GitHub update response. + ### Add Reaction ``` diff --git a/docs/api/overview.md b/docs/api/overview.md index cef6ad2..0bebb44 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -19,6 +19,7 @@ https://your-worker.workers.dev | `DELETE` | `/auth/token/:userId` | None | Revoke user token | | `POST` | `/api/comment` | Bearer token | Create issue comment | | `POST` | `/api/merge` | Bearer token | Merge pull request | +| `POST` | `/api/close` | Bearer token | Close pull request | | `POST` | `/api/react` | Bearer token | Add reaction to issue | | `GET` | `/admin` | Admin session | Config console UI | | `GET` | `/admin/api/routes` | Admin session | List routes | diff --git a/src/action-routes.ts b/src/action-routes.ts index 1b8756a..7a522ad 100644 --- a/src/action-routes.ts +++ b/src/action-routes.ts @@ -64,6 +64,30 @@ export function createActionRoutes(): Hono<{ Bindings: Env }> { return c.json({ ok: true }); }); + app.post("/api/close", async (c) => { + const token = extractBearerToken(c); + if (!token) return c.json({ error: "Missing authorization" }, 401); + const userId = await findUserIdByToken(c.env.KV, token); + if (!userId) return c.json({ error: "Invalid or expired token" }, 401); + + const body = await c.req.json<{ + owner: string; + repo: string; + pullNumber: number; + }>(); + const octokit = await getUserOctokit(userId, c.env.KV); + if (!octokit) return c.json({ error: "Not authorized" }, 401); + + await octokit.rest.pulls.update({ + owner: body.owner, + repo: body.repo, + pull_number: body.pullNumber, + state: "closed", + }); + + return c.json({ ok: true }); + }); + app.post("/api/react", async (c) => { const token = extractBearerToken(c); if (!token) return c.json({ error: "Missing authorization" }, 401); diff --git a/src/discord-gateway.ts b/src/discord-gateway.ts index 09b4797..4ded903 100644 --- a/src/discord-gateway.ts +++ b/src/discord-gateway.ts @@ -6,6 +6,8 @@ import { getCommentAsUser, editCommentAsUser, deleteCommentAsUser, + mergePullRequestAsUser, + closePullRequestAsUser, } from "./github-oauth"; import { getDiscordLink, removeDiscordLink } from "./token-store"; import type { Env } from "./types"; @@ -23,8 +25,8 @@ const MAX_RECONNECT_DELAY = 60_000; const ALARM_INTERVAL = 30; // Discord interaction protocol constants -const INTERACTION_TYPE = { COMMAND: 2, MODAL_SUBMIT: 5 } as const; -const CALLBACK_TYPE = { MESSAGE: 4, MODAL: 9 } as const; +const INTERACTION_TYPE = { COMMAND: 2, BUTTON: 3, MODAL_SUBMIT: 5 } as const; +const CALLBACK_TYPE = { MESSAGE: 4, DEFERRED_MESSAGE: 5, MODAL: 9 } as const; const COMMAND_TYPE = { CHAT_INPUT: 1, MESSAGE: 3 } as const; const OPTION_TYPE = { SUB_COMMAND: 1, SUB_COMMAND_GROUP: 2, STRING: 3 } as const; const EPHEMERAL = 64; @@ -38,6 +40,10 @@ const MSG_CMD_DEL = "GitHub: 删除评论"; const MODAL_ADD = "ghc|add|"; // ghc|add|owner|repo|issueNumber const MODAL_EDIT = "ghc|edit|"; // ghc|edit|owner|repo|commentId +// PR notification button custom_id encodings. +const BTN_MERGE = "ghpr|merge|"; // ghpr|merge|owner|repo|pullNumber +const BTN_CLOSE = "ghpr|close|"; // ghpr|close|owner|repo|pullNumber + const APP_COMMANDS = [ { name: "gh", @@ -348,6 +354,7 @@ export class DiscordGateway { token: string; type: number; guild_id?: string; + channel_id?: string; member?: { user?: { id?: string } }; user?: { id?: string }; data?: Record; @@ -356,6 +363,14 @@ export class DiscordGateway { const id = interaction.id; const token = interaction.token; + if (interaction.type === INTERACTION_TYPE.BUTTON) { + const data = interaction.data as { + custom_id?: string; + message?: { id?: string }; + }; + return this.handleButton(id, token, userId, interaction.channel_id, data.message?.id, data.custom_id); + } + if (interaction.type === INTERACTION_TYPE.COMMAND) { const data = interaction.data as { name?: string; @@ -417,6 +432,83 @@ export class DiscordGateway { } } + /** Replace the deferred (ephemeral) response body with the final result. */ + private async updateOriginal(id: string, token: string, content: string): Promise { + const res = await fetch(`${DISCORD_API}/interactions/${id}/${token}/messages/@original`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content }), + }); + if (!res.ok) { + const err = await res.text(); + log.warn({ status: res.status, err }, "Failed to update interaction response"); + } + } + + /** + * PR notification buttons: merge or close the PR as the clicker's linked + * GitHub account. The clicker must have run `/gh login` first. + */ + private async handleButton( + id: string, + token: string, + userId: string | null, + channelId: string | undefined, + messageId: string | undefined, + customId: string | undefined, + ): Promise { + if (!userId) return this.respond(id, token, "无法识别你的 Discord 账号。"); + const githubUserId = await getDiscordLink(this.env.KV, userId); + if (!githubUserId) { + return this.respond(id, token, "你还没有绑定 GitHub 账号,请先使用 `/gh login`。"); + } + + let op: "merge" | "close"; + let rest: string; + if (customId?.startsWith(BTN_MERGE)) { + op = "merge"; + rest = customId.slice(BTN_MERGE.length); + } else if (customId?.startsWith(BTN_CLOSE)) { + op = "close"; + rest = customId.slice(BTN_CLOSE.length); + } else { + return; + } + + const [owner, repo, number] = rest.split("|"); + if (!owner || !repo || !number) return; + + // Acknowledge first (deferred, ephemeral) so the clicker sees a spinner + // while the GitHub API call runs. + await this.interactionCallback(id, token, { + type: CALLBACK_TYPE.DEFERRED_MESSAGE, + data: { flags: EPHEMERAL }, + }); + + try { + if (op === "merge") { + await mergePullRequestAsUser(this.env.KV, githubUserId, owner, repo, Number(number)); + } else { + await closePullRequestAsUser(this.env.KV, githubUserId, owner, repo, Number(number)); + } + // Remove the buttons from the notification so nobody double-clicks. + if (channelId && messageId) { + await fetch(`${DISCORD_API}/channels/${channelId}/messages/${messageId}`, { + method: "PATCH", + headers: { + Authorization: `Bot ${this.botToken()}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ components: [] }), + }).catch((err) => log.warn({ err: String(err) }, "Failed to strip PR buttons")); + } + const label = op === "merge" ? "合并" : "关闭"; + await this.updateOriginal(id, token, `✅ 已${label} PR ${owner}/${repo}#${number}`); + } catch (err) { + await this.updateOriginal(id, token, this.errText(err)); + } + } + private async cmdLogin(id: string, token: string, userId: string | null): Promise { if (!userId) return this.respond(id, token, "无法识别你的 Discord 账号。"); const clientId = this.env.GITHUB_CLIENT_ID; diff --git a/src/formatter.ts b/src/formatter.ts index 9fed5b8..5973b5f 100644 --- a/src/formatter.ts +++ b/src/formatter.ts @@ -311,6 +311,22 @@ function formatPullRequest( }); } + // Merge / close buttons on the Discord notification. They only make sense + // while the PR is still open. custom_id encodes the action + owner/repo/number + // (repo full_name never contains "|"). + const actionable = pr.state === "open" && !!repo && pr.number != null; + const components = actionable + ? [ + { + type: 1, + components: [ + { type: 2, style: 3, label: "合并", custom_id: `ghpr|merge|${repo}|${pr.number}` }, + { type: 2, style: 4, label: "关闭", custom_id: `ghpr|close|${repo}|${pr.number}` }, + ], + }, + ] + : undefined; + return { embeds: [ { @@ -324,6 +340,7 @@ function formatPullRequest( timestamp: new Date().toISOString(), }, ], + ...(components ? { components } : {}), }; } diff --git a/src/github-oauth.ts b/src/github-oauth.ts index 9704b05..c614275 100644 --- a/src/github-oauth.ts +++ b/src/github-oauth.ts @@ -150,3 +150,46 @@ export async function deleteCommentAsUser( throw mapGitHubError(err); } } + +/** Merge a pull request as the linked GitHub user. GitHub enforces permission (403 if not allowed). */ +export async function mergePullRequestAsUser( + kv: KVNamespace, + githubUserId: string, + owner: string, + repo: string, + pullNumber: number, + method: "merge" | "squash" | "rebase" = "squash", +): Promise { + const octokit = await requireOctokit(kv, githubUserId); + try { + await octokit.rest.pulls.merge({ + owner, + repo, + pull_number: pullNumber, + merge_method: method, + }); + } catch (err) { + throw mapGitHubError(err); + } +} + +/** Close a pull request as the linked GitHub user. GitHub enforces permission (403 if not allowed). */ +export async function closePullRequestAsUser( + kv: KVNamespace, + githubUserId: string, + owner: string, + repo: string, + pullNumber: number, +): Promise { + const octokit = await requireOctokit(kv, githubUserId); + try { + await octokit.rest.pulls.update({ + owner, + repo, + pull_number: pullNumber, + state: "closed", + }); + } catch (err) { + throw mapGitHubError(err); + } +} diff --git a/src/types.ts b/src/types.ts index a8eb9af..49608c4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -88,4 +88,13 @@ export interface FormattedMessage { footer?: { text: string }; timestamp?: string; }>; + components?: Array<{ + type: number; + components: Array<{ + type: number; + style?: number; + label?: string; + custom_id?: string; + }>; + }>; }