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

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