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).
|
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:**
|
**Requirements:**
|
||||||
|
|
||||||
| Item | How |
|
| Item | How |
|
||||||
|
|
|
||||||
|
|
@ -207,6 +207,10 @@ Discord Gateway 连接仅用于让 bot 显示为**在线**——发送消息**
|
||||||
|
|
||||||
`edit` / `del` 需要具体的评论链接(在 GitHub 上:评论 ⋯ 菜单 → **Copy link**)。`add` / `edit` 会弹出 modal 让你输入 / 修改评论内容(编辑时预填原文)。
|
`edit` / `del` 需要具体的评论链接(在 GitHub 上:评论 ⋯ 菜单 → **Copy link**)。`add` / `edit` 会弹出 modal 让你输入 / 修改评论内容(编辑时预填原文)。
|
||||||
|
|
||||||
|
**3. 合并 / 关闭 PR** —— 打开状态的 PR 通知会附带 **合并 / 关闭** 按钮:
|
||||||
|
|
||||||
|
- 点击按钮后以**你绑定**的 GitHub 账号执行合并(squash)或关闭操作,权限交由 GitHub 判定。操作成功后通知上的按钮会被移除,结果以 ephemeral 回复显示。
|
||||||
|
|
||||||
**要求:**
|
**要求:**
|
||||||
|
|
||||||
| 项目 | 说明 |
|
| 项目 | 说明 |
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Actions
|
# 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
|
## Authentication
|
||||||
|
|
||||||
|
|
@ -70,6 +70,32 @@ Merges a pull request.
|
||||||
|
|
||||||
**Response:** `200` with GitHub merge response.
|
**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
|
### Add Reaction
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ https://your-worker.workers.dev
|
||||||
| `DELETE` | `/auth/token/:userId` | None | Revoke user token |
|
| `DELETE` | `/auth/token/:userId` | None | Revoke user token |
|
||||||
| `POST` | `/api/comment` | Bearer token | Create issue comment |
|
| `POST` | `/api/comment` | Bearer token | Create issue comment |
|
||||||
| `POST` | `/api/merge` | Bearer token | Merge pull request |
|
| `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 |
|
| `POST` | `/api/react` | Bearer token | Add reaction to issue |
|
||||||
| `GET` | `/admin` | Admin session | Config console UI |
|
| `GET` | `/admin` | Admin session | Config console UI |
|
||||||
| `GET` | `/admin/api/routes` | Admin session | List routes |
|
| `GET` | `/admin/api/routes` | Admin session | List routes |
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,30 @@ export function createActionRoutes(): Hono<{ Bindings: Env }> {
|
||||||
return c.json({ ok: true });
|
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) => {
|
app.post("/api/react", async (c) => {
|
||||||
const token = extractBearerToken(c);
|
const token = extractBearerToken(c);
|
||||||
if (!token) return c.json({ error: "Missing authorization" }, 401);
|
if (!token) return c.json({ error: "Missing authorization" }, 401);
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ import {
|
||||||
getCommentAsUser,
|
getCommentAsUser,
|
||||||
editCommentAsUser,
|
editCommentAsUser,
|
||||||
deleteCommentAsUser,
|
deleteCommentAsUser,
|
||||||
|
mergePullRequestAsUser,
|
||||||
|
closePullRequestAsUser,
|
||||||
} from "./github-oauth";
|
} from "./github-oauth";
|
||||||
import { getDiscordLink, removeDiscordLink } from "./token-store";
|
import { getDiscordLink, removeDiscordLink } from "./token-store";
|
||||||
import type { Env } from "./types";
|
import type { Env } from "./types";
|
||||||
|
|
@ -23,8 +25,8 @@ const MAX_RECONNECT_DELAY = 60_000;
|
||||||
const ALARM_INTERVAL = 30;
|
const ALARM_INTERVAL = 30;
|
||||||
|
|
||||||
// Discord interaction protocol constants
|
// Discord interaction protocol constants
|
||||||
const INTERACTION_TYPE = { COMMAND: 2, MODAL_SUBMIT: 5 } as const;
|
const INTERACTION_TYPE = { COMMAND: 2, BUTTON: 3, MODAL_SUBMIT: 5 } as const;
|
||||||
const CALLBACK_TYPE = { MESSAGE: 4, MODAL: 9 } 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 COMMAND_TYPE = { CHAT_INPUT: 1, MESSAGE: 3 } as const;
|
||||||
const OPTION_TYPE = { SUB_COMMAND: 1, SUB_COMMAND_GROUP: 2, STRING: 3 } as const;
|
const OPTION_TYPE = { SUB_COMMAND: 1, SUB_COMMAND_GROUP: 2, STRING: 3 } as const;
|
||||||
const EPHEMERAL = 64;
|
const EPHEMERAL = 64;
|
||||||
|
|
@ -38,6 +40,10 @@ const MSG_CMD_DEL = "GitHub: 删除评论";
|
||||||
const MODAL_ADD = "ghc|add|"; // ghc|add|owner|repo|issueNumber
|
const MODAL_ADD = "ghc|add|"; // ghc|add|owner|repo|issueNumber
|
||||||
const MODAL_EDIT = "ghc|edit|"; // ghc|edit|owner|repo|commentId
|
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 = [
|
const APP_COMMANDS = [
|
||||||
{
|
{
|
||||||
name: "gh",
|
name: "gh",
|
||||||
|
|
@ -348,6 +354,7 @@ export class DiscordGateway {
|
||||||
token: string;
|
token: string;
|
||||||
type: number;
|
type: number;
|
||||||
guild_id?: string;
|
guild_id?: string;
|
||||||
|
channel_id?: string;
|
||||||
member?: { user?: { id?: string } };
|
member?: { user?: { id?: string } };
|
||||||
user?: { id?: string };
|
user?: { id?: string };
|
||||||
data?: Record<string, unknown>;
|
data?: Record<string, unknown>;
|
||||||
|
|
@ -356,6 +363,14 @@ export class DiscordGateway {
|
||||||
const id = interaction.id;
|
const id = interaction.id;
|
||||||
const token = interaction.token;
|
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) {
|
if (interaction.type === INTERACTION_TYPE.COMMAND) {
|
||||||
const data = interaction.data as {
|
const data = interaction.data as {
|
||||||
name?: string;
|
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> {
|
private async cmdLogin(id: string, token: string, userId: string | null): Promise<void> {
|
||||||
if (!userId) return this.respond(id, token, "无法识别你的 Discord 账号。");
|
if (!userId) return this.respond(id, token, "无法识别你的 Discord 账号。");
|
||||||
const clientId = this.env.GITHUB_CLIENT_ID;
|
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 {
|
return {
|
||||||
embeds: [
|
embeds: [
|
||||||
{
|
{
|
||||||
|
|
@ -324,6 +340,7 @@ function formatPullRequest(
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
...(components ? { components } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -150,3 +150,46 @@ export async function deleteCommentAsUser(
|
||||||
throw mapGitHubError(err);
|
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 };
|
footer?: { text: string };
|
||||||
timestamp?: 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