mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-22 16:11:29 +00:00
feat: add merge/close PR buttons to Discord PR notifications
This commit is contained in:
parent
d40f34cc4d
commit
44b8fead78
9 changed files with 223 additions and 3 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -207,6 +207,10 @@ Discord Gateway 连接仅用于让 bot 显示为**在线**——发送消息**
|
|||
|
||||
`edit` / `del` 需要具体的评论链接(在 GitHub 上:评论 ⋯ 菜单 → **Copy link**)。`add` / `edit` 会弹出 modal 让你输入 / 修改评论内容(编辑时预填原文)。
|
||||
|
||||
**3. 合并 / 关闭 PR** —— 打开状态的 PR 通知会附带 **合并 / 关闭** 按钮:
|
||||
|
||||
- 点击按钮后以**你绑定**的 GitHub 账号执行合并(squash)或关闭操作,权限交由 GitHub 判定。操作成功后通知上的按钮会被移除,结果以 ephemeral 回复显示。
|
||||
|
||||
**要求:**
|
||||
|
||||
| 项目 | 说明 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}>;
|
||||
}>;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue