feat: add merge/close PR buttons to Discord PR notifications

This commit is contained in:
RhenCloud 2026-08-02 23:02:37 +08:00
parent d40f34cc4d
commit 44b8fead78
No known key found for this signature in database
GPG key ID: A574A617378C4E0B
9 changed files with 223 additions and 3 deletions

View file

@ -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 |

View file

@ -207,6 +207,10 @@ Discord Gateway 连接仅用于让 bot 显示为**在线**——发送消息**
`edit` / `del` 需要具体的评论链接(在 GitHub 上:评论 ⋯ 菜单 → **Copy link**)。`add` / `edit` 会弹出 modal 让你输入 / 修改评论内容(编辑时预填原文)。
**3. 合并 / 关闭 PR** —— 打开状态的 PR 通知会附带 **合并 / 关闭** 按钮:
- 点击按钮后以**你绑定**的 GitHub 账号执行合并squash或关闭操作权限交由 GitHub 判定。操作成功后通知上的按钮会被移除,结果以 ephemeral 回复显示。
**要求:**
| 项目 | 说明 |

View file

@ -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
```

View file

@ -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 |

View file

@ -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);

View file

@ -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<string, unknown>;
@ -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<void> {
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<void> {
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<void> {
if (!userId) return this.respond(id, token, "无法识别你的 Discord 账号。");
const clientId = this.env.GITHUB_CLIENT_ID;

View file

@ -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 } : {}),
};
}

View file

@ -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<void> {
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<void> {
const octokit = await requireOctokit(kv, githubUserId);
try {
await octokit.rest.pulls.update({
owner,
repo,
pull_number: pullNumber,
state: "closed",
});
} catch (err) {
throw mapGitHubError(err);
}
}

View file

@ -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;
}>;
}>;
}