mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-22 16:11:29 +00:00
fix(formatters): localize PR buttons, cap oversized content, dedupe status logic
- PR merge/close button labels now follow the group language (actions.merge/close)
instead of hardcoded Chinese
- clamp content to Discord embed limits (title 256, description 4096, field value
1024, 25 fields) in formatters plus a render-layer safety net; Telegram gets a
tag-safe 4096-char cap (capHtml closes dangling tags)
- raise commit subject truncation from 72 to 200 chars (MAX_COMMIT_SUBJECT)
- extract workflowStatus/workflowRunStatus/statusColorKey helpers shared by
check_run/check_suite/workflow_run/workflow_job
- dedupe deployment ref/sha fields via addDeploymentRefFields
- commit_comment without a commit id uses title_plain (no dangling ???????)
- tag pushes now report 'Tag created'; zh push title includes the {ref}
- sender profile link derives from the repo's forge origin instead of github.com
- group webhook log emoji injected via emojiPrefix, removed from locale files
- dispatchEvent accepts preloaded groups (single KV read per webhook)
- webhook 401 logs distinguish 'secret not configured' from 'invalid signature'
This commit is contained in:
parent
e59b10f739
commit
a5324fb0ea
20 changed files with 425 additions and 136 deletions
|
|
@ -155,6 +155,10 @@ tests/ # bun test unit tests (webhook, formatter, discord, tel
|
||||||
(`commitLink`/`branchLink`/`tagLink` helpers in `server/lib/formatters/helpers.ts`, e.g.
|
(`commitLink`/`branchLink`/`tagLink` helpers in `server/lib/formatters/helpers.ts`, e.g.
|
||||||
``[`abc123d`](https://.../commit/abc123def456)``, ``[`main`](https://.../tree/main)``),
|
``[`abc123d`](https://.../commit/abc123def456)``, ``[`main`](https://.../tree/main)``),
|
||||||
falling back to plain inline code when the repo base URL is unavailable.
|
falling back to plain inline code when the repo base URL is unavailable.
|
||||||
|
- Content is clamped to the Discord embed limits (title 256, description 4096, field value
|
||||||
|
1024, 25 fields) both in the formatters and as a final safety net in the Discord render;
|
||||||
|
the Telegram render caps the whole message at 4096 chars with tag-safe truncation. Commit
|
||||||
|
subjects render only the first line, truncated to 200 chars.
|
||||||
- Locale templates use a `{emoji}` placeholder immediately followed by the text (no space);
|
- Locale templates use a `{emoji}` placeholder immediately followed by the text (no space);
|
||||||
the formatter injects `em(...)` which carries the trailing space.
|
the formatter injects `em(...)` which carries the trailing space.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ Two equivalent ways:
|
||||||
|
|
||||||
### Merge / close a PR
|
### Merge / close a PR
|
||||||
|
|
||||||
Notifications for open PRs include **合并 / 关闭** (merge/close) buttons:
|
Notifications for open PRs include **Merge / Close** buttons (labels follow the group's message language):
|
||||||
|
|
||||||
- Clicking a button merges (squash) or closes the PR as your linked GitHub account; GitHub enforces permission.
|
- 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.
|
- On success the buttons are removed from the notification and the result is shown in an ephemeral reply.
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ Discord 命令为**斜杠命令**与**消息右键菜单命令**,由定时任
|
||||||
|
|
||||||
### 合并 / 关闭 PR
|
### 合并 / 关闭 PR
|
||||||
|
|
||||||
开放 PR 的通知附带 **合并 / 关闭** 按钮:
|
开放 PR 的通知附带 **合并 / 关闭** 按钮(文案跟随分组的消息语言):
|
||||||
|
|
||||||
- 点击按钮即以你绑定的 GitHub 账号合并(squash)或关闭 PR;权限由 GitHub 强制校验。
|
- 点击按钮即以你绑定的 GitHub 账号合并(squash)或关闭 PR;权限由 GitHub 强制校验。
|
||||||
- 成功后按钮会从通知中移除,结果以临时消息展示。
|
- 成功后按钮会从通知中移除,结果以临时消息展示。
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { Config, WebhookEvent, Env, Route, NeutralMessage } from "../types";
|
import type { Config, WebhookEvent, Env, Route, Group, NeutralMessage } from "../types";
|
||||||
import { formatEvent } from "../formatters";
|
import { formatEvent } from "../formatters";
|
||||||
|
import { emojiPrefix } from "../formatters/helpers";
|
||||||
import { matchRoute, eventOwners } from "../events/match";
|
import { matchRoute, eventOwners } from "../events/match";
|
||||||
import { log } from "../lib/log";
|
import { log } from "../lib/log";
|
||||||
import { loadTranslations, t as translate, type Translations } from "../lib/i18n";
|
import { loadTranslations, t as translate, type Translations } from "../lib/i18n";
|
||||||
|
|
@ -23,12 +24,17 @@ interface DispatchAttempt {
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function dispatchEvent(config: Config, event: WebhookEvent, env: Env): Promise<void> {
|
export async function dispatchEvent(
|
||||||
const groups = await loadGroups(env.KV);
|
config: Config,
|
||||||
const groupById = new Map(groups.map((g) => [g.id, g]));
|
event: WebhookEvent,
|
||||||
|
env: Env,
|
||||||
|
groups?: Group[],
|
||||||
|
): Promise<void> {
|
||||||
|
const loadedGroups = groups ?? (await loadGroups(env.KV));
|
||||||
|
const groupById = new Map(loadedGroups.map((g) => [g.id, g]));
|
||||||
|
|
||||||
// Message language is configured per group (Group.lang), not per route.
|
// Message language is configured per group (Group.lang), not per route.
|
||||||
const langs = [...new Set(groups.map((g) => g.lang ?? "en"))];
|
const langs = [...new Set(loadedGroups.map((g) => g.lang ?? "en"))];
|
||||||
const trMap = new Map<string, Translations>();
|
const trMap = new Map<string, Translations>();
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
langs.map(async (lang) => {
|
langs.map(async (lang) => {
|
||||||
|
|
@ -89,8 +95,15 @@ export async function dispatchEvent(config: Config, event: WebhookEvent, env: En
|
||||||
.slice(0, 10)
|
.slice(0, 10)
|
||||||
.map((a) =>
|
.map((a) =>
|
||||||
a.ok
|
a.ok
|
||||||
? translate("log.route_ok", { route: a.routeName, target: a.target }, undefined, tr)
|
? emojiPrefix("✅", true) +
|
||||||
: translate(
|
translate(
|
||||||
|
"log.route_ok",
|
||||||
|
{ route: a.routeName, target: a.target },
|
||||||
|
undefined,
|
||||||
|
tr,
|
||||||
|
)
|
||||||
|
: emojiPrefix("❌", true) +
|
||||||
|
translate(
|
||||||
"log.route_fail",
|
"log.route_fail",
|
||||||
{ route: a.routeName, target: a.target, error: a.error ?? "?" },
|
{ route: a.routeName, target: a.target, error: a.error ?? "?" },
|
||||||
undefined,
|
undefined,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,14 @@
|
||||||
import type { FormattedMessage, NeutralActionStyle, NeutralMessage } from "../../types";
|
import type { FormattedMessage, NeutralActionStyle, NeutralMessage } from "../../types";
|
||||||
import { repoUrlFromMessage, splitMessageTitle } from "../../formatters/helpers";
|
import {
|
||||||
|
cap,
|
||||||
|
MAX_DESCRIPTION,
|
||||||
|
MAX_FIELDS,
|
||||||
|
MAX_FIELD_VALUE,
|
||||||
|
MAX_FOOTER,
|
||||||
|
MAX_TITLE,
|
||||||
|
repoUrlFromMessage,
|
||||||
|
splitMessageTitle,
|
||||||
|
} from "../../formatters/helpers";
|
||||||
|
|
||||||
function toStyle(style: NeutralActionStyle): number {
|
function toStyle(style: NeutralActionStyle): number {
|
||||||
switch (style) {
|
switch (style) {
|
||||||
|
|
@ -22,29 +31,37 @@ export function renderNeutralMessage(message: NeutralMessage): FormattedMessage
|
||||||
// rendered as the first line of the description, unlinked.
|
// rendered as the first line of the description, unlinked.
|
||||||
const { head, subject } = splitMessageTitle(message.title);
|
const { head, subject } = splitMessageTitle(message.title);
|
||||||
const repoUrl = repoUrlFromMessage(message.url);
|
const repoUrl = repoUrlFromMessage(message.url);
|
||||||
const description = subject
|
const rawDescription = subject
|
||||||
? message.description
|
? message.description
|
||||||
? `${subject}\n${message.description}`
|
? `${subject}\n${message.description}`
|
||||||
: subject
|
: subject
|
||||||
: message.description;
|
: (message.description ?? "");
|
||||||
|
|
||||||
|
// Safety net: clamp every embed part to the Discord API limits so a single
|
||||||
|
// oversized formatter or custom payload can never hard-fail the request.
|
||||||
return {
|
return {
|
||||||
content,
|
content,
|
||||||
embeds: [
|
embeds: [
|
||||||
{
|
{
|
||||||
title: head,
|
title: cap(head, MAX_TITLE),
|
||||||
url: subject ? repoUrl : message.url,
|
url: subject ? repoUrl : message.url,
|
||||||
color: message.color,
|
color: message.color,
|
||||||
description,
|
description: cap(rawDescription, MAX_DESCRIPTION) || undefined,
|
||||||
author: message.author
|
author: message.author
|
||||||
? {
|
? {
|
||||||
name: message.author.name,
|
name: cap(message.author.name, MAX_TITLE),
|
||||||
icon_url: message.author.iconUrl,
|
icon_url: message.author.iconUrl,
|
||||||
url: message.author.url,
|
url: message.author.url,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
fields: message.fields,
|
fields: message.fields
|
||||||
footer: message.footer ? { text: message.footer } : undefined,
|
?.slice(0, MAX_FIELDS)
|
||||||
|
.map((f) => ({
|
||||||
|
name: cap(f.name, MAX_TITLE),
|
||||||
|
value: cap(f.value, MAX_FIELD_VALUE),
|
||||||
|
inline: f.inline,
|
||||||
|
})),
|
||||||
|
footer: message.footer ? { text: cap(message.footer, MAX_FOOTER) } : undefined,
|
||||||
timestamp: message.timestamp,
|
timestamp: message.timestamp,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import type { NeutralMessage } from "../../types";
|
import type { NeutralMessage } from "../../types";
|
||||||
import { repoUrlFromMessage, splitMessageTitle } from "../../formatters/helpers";
|
import { repoUrlFromMessage, splitMessageTitle } from "../../formatters/helpers";
|
||||||
|
|
||||||
|
/** Telegram caps a single message at 4096 characters (HTML entities included). */
|
||||||
|
const MAX_TEXT = 4096;
|
||||||
|
|
||||||
function esc(s: string): string {
|
function esc(s: string): string {
|
||||||
return s
|
return s
|
||||||
.replace(/&/g, "&")
|
.replace(/&/g, "&")
|
||||||
|
|
@ -30,6 +33,33 @@ function formatTimestamp(ts?: string): string {
|
||||||
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())} UTC`;
|
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())} UTC`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Truncate Telegram HTML to `max` characters without breaking markup: drops
|
||||||
|
* an incomplete trailing tag and closes every tag still open at the cut
|
||||||
|
* point, so parse_mode HTML never rejects the message.
|
||||||
|
*/
|
||||||
|
function capHtml(text: string, max: number): string {
|
||||||
|
if (text.length <= max) return text;
|
||||||
|
let cut = text.slice(0, max).replace(/<[^>]*$/u, "");
|
||||||
|
const stack: string[] = [];
|
||||||
|
const re = /<(\/?)([a-z]+)(?:\s[^>]*)?>/g;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = re.exec(cut)) !== null) {
|
||||||
|
const tag = m[2]!;
|
||||||
|
if (m[1] === "/") {
|
||||||
|
const open = stack.lastIndexOf(tag);
|
||||||
|
if (open >= 0) stack.splice(open);
|
||||||
|
} else {
|
||||||
|
stack.push(tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const closers = stack
|
||||||
|
.reverse()
|
||||||
|
.map((tag) => `</${tag}>`)
|
||||||
|
.join("");
|
||||||
|
return `${cut}…${closers}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function renderNeutralMessage(message: NeutralMessage): string {
|
export function renderNeutralMessage(message: NeutralMessage): string {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
|
@ -66,5 +96,5 @@ export function renderNeutralMessage(message: NeutralMessage): string {
|
||||||
parts.push(`<i>${meta.join(" · ")}</i>`);
|
parts.push(`<i>${meta.join(" · ")}</i>`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return parts.join("\n");
|
return capHtml(parts.join("\n"), MAX_TEXT);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,17 @@
|
||||||
import type { NeutralMessage, NeutralAuthor } from "../types";
|
import type { NeutralMessage, NeutralAuthor } from "../types";
|
||||||
import { GITHUB_COLORS, WORKFLOW_CONCLUSION_EMOJI } from "./colors";
|
import { GITHUB_COLORS, WORKFLOW_CONCLUSION_EMOJI } from "./colors";
|
||||||
import { branchLink, commitLink, emojiPrefix, type T, buildMessage, repoBaseUrl } from "./helpers";
|
import {
|
||||||
|
branchLink,
|
||||||
|
cap,
|
||||||
|
commitLink,
|
||||||
|
emojiPrefix,
|
||||||
|
MAX_FIELD_VALUE,
|
||||||
|
statusColorKey,
|
||||||
|
type T,
|
||||||
|
buildMessage,
|
||||||
|
repoBaseUrl,
|
||||||
|
workflowStatus,
|
||||||
|
} from "./helpers";
|
||||||
|
|
||||||
export function formatCheckSuite(
|
export function formatCheckSuite(
|
||||||
payload: Record<string, unknown>,
|
payload: Record<string, unknown>,
|
||||||
|
|
@ -18,21 +29,11 @@ export function formatCheckSuite(
|
||||||
html_url?: string;
|
html_url?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const status =
|
const status = workflowStatus(suite.status, suite.conclusion);
|
||||||
suite.status === "queued"
|
|
||||||
? "queued"
|
|
||||||
: suite.status === "in_progress"
|
|
||||||
? "running"
|
|
||||||
: (suite.conclusion ?? "pending");
|
|
||||||
const baseUrl = repoBaseUrl(payload, repo);
|
const baseUrl = repoBaseUrl(payload, repo);
|
||||||
const emoji = WORKFLOW_CONCLUSION_EMOJI[status] ?? "⏳";
|
const emoji = WORKFLOW_CONCLUSION_EMOJI[status] ?? "⏳";
|
||||||
const em = (e: string): string => emojiPrefix(e, showEmoji);
|
const em = (e: string): string => emojiPrefix(e, showEmoji);
|
||||||
const colorKey =
|
const colorKey = statusColorKey("check_run", status);
|
||||||
status === "success"
|
|
||||||
? "check_run_success"
|
|
||||||
: status === "failure"
|
|
||||||
? "check_run_failure"
|
|
||||||
: "check_run_other";
|
|
||||||
|
|
||||||
const fields: Array<{ name: string; value: string; inline?: boolean }> = [];
|
const fields: Array<{ name: string; value: string; inline?: boolean }> = [];
|
||||||
|
|
||||||
|
|
@ -172,20 +173,10 @@ export function formatCheckRun(
|
||||||
output?: { title?: string; summary?: string };
|
output?: { title?: string; summary?: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
const status =
|
const status = workflowStatus(checkRun.status, checkRun.conclusion);
|
||||||
checkRun.status === "queued"
|
|
||||||
? "queued"
|
|
||||||
: checkRun.status === "in_progress"
|
|
||||||
? "running"
|
|
||||||
: (checkRun.conclusion ?? "pending");
|
|
||||||
const emoji = WORKFLOW_CONCLUSION_EMOJI[status] ?? "⏳";
|
const emoji = WORKFLOW_CONCLUSION_EMOJI[status] ?? "⏳";
|
||||||
const em = (e: string): string => emojiPrefix(e, showEmoji);
|
const em = (e: string): string => emojiPrefix(e, showEmoji);
|
||||||
const colorKey =
|
const colorKey = statusColorKey("check_run", status);
|
||||||
status === "success"
|
|
||||||
? "check_run_success"
|
|
||||||
: status === "failure"
|
|
||||||
? "check_run_failure"
|
|
||||||
: "check_run_other";
|
|
||||||
|
|
||||||
const fields: Array<{ name: string; value: string; inline?: boolean }> = [];
|
const fields: Array<{ name: string; value: string; inline?: boolean }> = [];
|
||||||
|
|
||||||
|
|
@ -198,7 +189,7 @@ export function formatCheckRun(
|
||||||
if (checkRun.output?.title) {
|
if (checkRun.output?.title) {
|
||||||
fields.push({
|
fields.push({
|
||||||
name: t("fields.details"),
|
name: t("fields.details"),
|
||||||
value: checkRun.output.title,
|
value: cap(checkRun.output.title, MAX_FIELD_VALUE),
|
||||||
inline: false,
|
inline: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,13 +22,14 @@ export function formatCommitComment(
|
||||||
const truncated = comment.body && comment.body.length > 500;
|
const truncated = comment.body && comment.body.length > 500;
|
||||||
const baseUrl = repoBaseUrl(payload, repo);
|
const baseUrl = repoBaseUrl(payload, repo);
|
||||||
const shortSha = comment.commit_id?.slice(0, 7) ?? "???????";
|
const shortSha = comment.commit_id?.slice(0, 7) ?? "???????";
|
||||||
|
const shaLink = comment.commit_id ? commitLink(baseUrl, comment.commit_id, shortSha) : undefined;
|
||||||
|
|
||||||
const fields: Array<{ name: string; value: string; inline?: boolean }> = [];
|
const fields: Array<{ name: string; value: string; inline?: boolean }> = [];
|
||||||
|
|
||||||
if (comment.commit_id) {
|
if (comment.commit_id) {
|
||||||
fields.push({
|
fields.push({
|
||||||
name: t("fields.commit"),
|
name: t("fields.commit"),
|
||||||
value: commitLink(baseUrl, comment.commit_id, shortSha),
|
value: shaLink!,
|
||||||
inline: true,
|
inline: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -36,10 +37,14 @@ export function formatCommitComment(
|
||||||
return buildMessage(
|
return buildMessage(
|
||||||
{
|
{
|
||||||
author,
|
author,
|
||||||
title: t("events.commit_comment.title", {
|
title: shaLink
|
||||||
repo: repo ?? t("common.repository"),
|
? t("events.commit_comment.title", {
|
||||||
sha: commitLink(baseUrl, comment.commit_id ?? "", shortSha),
|
repo: repo ?? t("common.repository"),
|
||||||
}),
|
sha: shaLink,
|
||||||
|
})
|
||||||
|
: t("events.commit_comment.title_plain", {
|
||||||
|
repo: repo ?? t("common.repository"),
|
||||||
|
}),
|
||||||
url: comment.html_url,
|
url: comment.html_url,
|
||||||
color: GITHUB_COLORS.commit_comment,
|
color: GITHUB_COLORS.commit_comment,
|
||||||
description: `${t("events.commit_comment.action_comment", { emoji: em("💬"), action: al })}\n\n> ${commentBody}${truncated ? "..." : ""}`,
|
description: `${t("events.commit_comment.action_comment", { emoji: em("💬"), action: al })}\n\n> ${commentBody}${truncated ? "..." : ""}`,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,13 @@
|
||||||
import type { NeutralAuthor, NeutralField, NeutralMessage } from "../types";
|
import type { NeutralAuthor, NeutralField, NeutralMessage } from "../types";
|
||||||
import { type T, buildMessage } from "./helpers";
|
import {
|
||||||
|
cap,
|
||||||
|
MAX_DESCRIPTION,
|
||||||
|
MAX_FIELDS,
|
||||||
|
MAX_FIELD_VALUE,
|
||||||
|
MAX_TITLE,
|
||||||
|
type T,
|
||||||
|
buildMessage,
|
||||||
|
} from "./helpers";
|
||||||
|
|
||||||
const COLOR_WORDS: Record<string, number> = {
|
const COLOR_WORDS: Record<string, number> = {
|
||||||
red: 0xf85149,
|
red: 0xf85149,
|
||||||
|
|
@ -29,12 +37,12 @@ function parseFields(raw: unknown): NeutralField[] | undefined {
|
||||||
const value = (f as Record<string, unknown>).value;
|
const value = (f as Record<string, unknown>).value;
|
||||||
if (typeof name !== "string" || typeof value !== "string") continue;
|
if (typeof name !== "string" || typeof value !== "string") continue;
|
||||||
fields.push({
|
fields.push({
|
||||||
name: name || "\u200b",
|
name: cap(name || "\u200b", MAX_TITLE),
|
||||||
value,
|
value: cap(value, MAX_FIELD_VALUE),
|
||||||
inline: (f as Record<string, unknown>).inline === true,
|
inline: (f as Record<string, unknown>).inline === true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return fields.length > 0 ? fields : undefined;
|
return fields.length > 0 ? fields.slice(0, MAX_FIELDS) : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -50,15 +58,18 @@ export function formatCustom(
|
||||||
t: T,
|
t: T,
|
||||||
_showEmoji: boolean,
|
_showEmoji: boolean,
|
||||||
): NeutralMessage {
|
): NeutralMessage {
|
||||||
const title =
|
const title = cap(
|
||||||
typeof payload.title === "string" && payload.title.trim()
|
typeof payload.title === "string" && payload.title.trim()
|
||||||
? payload.title.trim()
|
? payload.title.trim()
|
||||||
: t("custom.title_fallback");
|
: t("custom.title_fallback"),
|
||||||
|
MAX_TITLE,
|
||||||
|
);
|
||||||
const payloadRepo =
|
const payloadRepo =
|
||||||
typeof payload.repo === "string" && payload.repo.trim() ? payload.repo.trim() : undefined;
|
typeof payload.repo === "string" && payload.repo.trim() ? payload.repo.trim() : undefined;
|
||||||
const effectiveRepo = payloadRepo ?? repo;
|
const effectiveRepo = payloadRepo ?? repo;
|
||||||
const fullTitle = effectiveRepo ? `${effectiveRepo}: ${title}` : title;
|
const fullTitle = effectiveRepo ? `${effectiveRepo}: ${title}` : title;
|
||||||
const description = typeof payload.description === "string" ? payload.description : undefined;
|
const description =
|
||||||
|
typeof payload.description === "string" ? cap(payload.description, MAX_DESCRIPTION) : undefined;
|
||||||
const url = typeof payload.url === "string" ? payload.url : undefined;
|
const url = typeof payload.url === "string" ? payload.url : undefined;
|
||||||
const footer = typeof payload.footer === "string" ? payload.footer : undefined;
|
const footer = typeof payload.footer === "string" ? payload.footer : undefined;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,30 @@ import {
|
||||||
repoBaseUrl,
|
repoBaseUrl,
|
||||||
} from "./helpers";
|
} from "./helpers";
|
||||||
|
|
||||||
|
/** Shared "branch/tag + commit" fields for deployment events. */
|
||||||
|
function addDeploymentRefFields(
|
||||||
|
fields: Array<{ name: string; value: string; inline?: boolean }>,
|
||||||
|
deployment: { ref?: string; sha?: string },
|
||||||
|
baseUrl: string | undefined,
|
||||||
|
t: T,
|
||||||
|
): void {
|
||||||
|
if (deployment.ref) {
|
||||||
|
const isTag = deployment.ref.startsWith("refs/tags/");
|
||||||
|
fields.push({
|
||||||
|
name: t("fields.branch_tag"),
|
||||||
|
value: isTag ? tagLink(baseUrl, deployment.ref) : branchLink(baseUrl, deployment.ref),
|
||||||
|
inline: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (deployment.sha) {
|
||||||
|
fields.push({
|
||||||
|
name: t("fields.commit"),
|
||||||
|
value: commitLink(baseUrl, deployment.sha, deployment.sha.slice(0, 7)),
|
||||||
|
inline: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function formatDeployment(
|
export function formatDeployment(
|
||||||
payload: Record<string, unknown>,
|
payload: Record<string, unknown>,
|
||||||
repo: string | undefined,
|
repo: string | undefined,
|
||||||
|
|
@ -29,7 +53,6 @@ export function formatDeployment(
|
||||||
const emoji = "🚀";
|
const emoji = "🚀";
|
||||||
const em = (e: string): string => emojiPrefix(e, showEmoji);
|
const em = (e: string): string => emojiPrefix(e, showEmoji);
|
||||||
const env = deployment.environment ?? t("common.unknown");
|
const env = deployment.environment ?? t("common.unknown");
|
||||||
const shortSha = deployment.sha?.slice(0, 7) ?? "???????";
|
|
||||||
const baseUrl = repoBaseUrl(payload, repo);
|
const baseUrl = repoBaseUrl(payload, repo);
|
||||||
const fields: Array<{ name: string; value: string; inline?: boolean }> = [];
|
const fields: Array<{ name: string; value: string; inline?: boolean }> = [];
|
||||||
|
|
||||||
|
|
@ -45,22 +68,7 @@ export function formatDeployment(
|
||||||
inline: true,
|
inline: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (deployment.ref) {
|
addDeploymentRefFields(fields, deployment, baseUrl, t);
|
||||||
const isTag = deployment.ref.startsWith("refs/tags/");
|
|
||||||
fields.push({
|
|
||||||
name: t("fields.branch_tag"),
|
|
||||||
value: isTag ? tagLink(baseUrl, deployment.ref) : branchLink(baseUrl, deployment.ref),
|
|
||||||
inline: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (deployment.sha) {
|
|
||||||
fields.push({
|
|
||||||
name: t("fields.commit"),
|
|
||||||
value: commitLink(baseUrl, deployment.sha, shortSha),
|
|
||||||
inline: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (deployment.description) {
|
if (deployment.description) {
|
||||||
fields.push({
|
fields.push({
|
||||||
|
|
@ -116,7 +124,6 @@ export function formatDeploymentStatus(
|
||||||
const emoji = state === "success" ? "✅" : state === "failure" ? "❌" : "⏳";
|
const emoji = state === "success" ? "✅" : state === "failure" ? "❌" : "⏳";
|
||||||
const em = (e: string): string => emojiPrefix(e, showEmoji);
|
const em = (e: string): string => emojiPrefix(e, showEmoji);
|
||||||
const env = status.environment ?? deployment.environment ?? t("common.unknown");
|
const env = status.environment ?? deployment.environment ?? t("common.unknown");
|
||||||
const shortSha = deployment.sha?.slice(0, 7) ?? "???????";
|
|
||||||
const baseUrl = repoBaseUrl(payload, repo);
|
const baseUrl = repoBaseUrl(payload, repo);
|
||||||
|
|
||||||
const fields: Array<{ name: string; value: string; inline?: boolean }> = [];
|
const fields: Array<{ name: string; value: string; inline?: boolean }> = [];
|
||||||
|
|
@ -133,22 +140,7 @@ export function formatDeploymentStatus(
|
||||||
inline: true,
|
inline: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (deployment.ref) {
|
addDeploymentRefFields(fields, deployment, baseUrl, t);
|
||||||
const isTag = deployment.ref.startsWith("refs/tags/");
|
|
||||||
fields.push({
|
|
||||||
name: t("fields.branch_tag"),
|
|
||||||
value: isTag ? tagLink(baseUrl, deployment.ref) : branchLink(baseUrl, deployment.ref),
|
|
||||||
inline: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (deployment.sha) {
|
|
||||||
fields.push({
|
|
||||||
name: t("fields.commit"),
|
|
||||||
value: commitLink(baseUrl, deployment.sha, shortSha),
|
|
||||||
inline: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status.environment_url) {
|
if (status.environment_url) {
|
||||||
fields.push({
|
fields.push({
|
||||||
|
|
|
||||||
|
|
@ -99,3 +99,64 @@ export function tagLink(baseUrl: string | undefined, tag: string, label?: string
|
||||||
? `[\`${display}\`](${baseUrl}/releases/tag/${encodeRefPath(clean)})`
|
? `[\`${display}\`](${baseUrl}/releases/tag/${encodeRefPath(clean)})`
|
||||||
: `\`${display}\``;
|
: `\`${display}\``;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Content size limits (mirror the Discord embed limits) ---- */
|
||||||
|
export const MAX_TITLE = 256;
|
||||||
|
export const MAX_DESCRIPTION = 4096;
|
||||||
|
export const MAX_FIELD_VALUE = 1024;
|
||||||
|
export const MAX_FIELDS = 25;
|
||||||
|
export const MAX_FOOTER = 2048;
|
||||||
|
/** First-line commit message length (stays well under the field value budget). */
|
||||||
|
export const MAX_COMMIT_SUBJECT = 200;
|
||||||
|
|
||||||
|
/** Truncate a string to `max` characters. */
|
||||||
|
export function cap(text: string, max: number): string {
|
||||||
|
return text.length > max ? text.slice(0, max) : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a check/workflow status: `queued` / `in_progress` keep their
|
||||||
|
* value (the latter becomes `running`), anything else falls back to the
|
||||||
|
* conclusion and finally `pending`.
|
||||||
|
*/
|
||||||
|
export function workflowStatus(status?: string, conclusion?: string): string {
|
||||||
|
if (status === "queued") return "queued";
|
||||||
|
if (status === "in_progress") return "running";
|
||||||
|
return conclusion ?? "pending";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** workflow_run events derive their progress from the action, not `status`. */
|
||||||
|
export function workflowRunStatus(action?: string, conclusion?: string): string {
|
||||||
|
if (action === "in_progress") return "running";
|
||||||
|
if (action === "requested") return "queued";
|
||||||
|
return conclusion ?? "pending";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GITHUB_COLORS key for a success/failure/other status. */
|
||||||
|
export function statusColorKey(
|
||||||
|
prefix: "check_run" | "workflow_run",
|
||||||
|
status: string,
|
||||||
|
): `${typeof prefix}_${"success" | "failure" | "other"}` {
|
||||||
|
return status === "success"
|
||||||
|
? `${prefix}_success`
|
||||||
|
: status === "failure"
|
||||||
|
? `${prefix}_failure`
|
||||||
|
: `${prefix}_other`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The sender's profile URL on the same forge as the repo (derived from the
|
||||||
|
* repo's html_url origin, e.g. `https://github.com/owner/repo` →
|
||||||
|
* `https://github.com/login`). Falls back to github.com when the repo URL is
|
||||||
|
* unavailable or unparseable.
|
||||||
|
*/
|
||||||
|
export function senderProfileUrl(repoUrl: string | undefined, login: string): string {
|
||||||
|
if (repoUrl) {
|
||||||
|
try {
|
||||||
|
return `${new URL(repoUrl).origin}/${encodeURIComponent(login)}`;
|
||||||
|
} catch {
|
||||||
|
// unparseable repo URL — fall through to github.com
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `https://github.com/${login}`;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import type { Route, WebhookEvent, NeutralMessage, NeutralAuthor } from "../types";
|
import type { Route, WebhookEvent, NeutralMessage, NeutralAuthor } from "../types";
|
||||||
import type { Translations } from "../lib/i18n";
|
import type { Translations } from "../lib/i18n";
|
||||||
import { makeT, type T } from "./helpers";
|
import { makeT, senderProfileUrl, type T } from "./helpers";
|
||||||
import { formatPush } from "./push";
|
import { formatPush } from "./push";
|
||||||
import { formatPullRequest } from "./pull-request";
|
import { formatPullRequest } from "./pull-request";
|
||||||
import { formatPullRequestReview, formatPullRequestReviewComment } from "./review";
|
import { formatPullRequestReview, formatPullRequestReviewComment } from "./review";
|
||||||
|
|
@ -41,7 +41,7 @@ export function formatEvent(
|
||||||
const author: NeutralAuthor = {
|
const author: NeutralAuthor = {
|
||||||
name: sender ?? t("common.unknown"),
|
name: sender ?? t("common.unknown"),
|
||||||
iconUrl: senderAvatar,
|
iconUrl: senderAvatar,
|
||||||
url: senderUrl ?? (sender ? `https://github.com/${sender}` : undefined),
|
url: senderUrl ?? (sender ? senderProfileUrl(repoUrl, sender) : undefined),
|
||||||
};
|
};
|
||||||
|
|
||||||
switch (eventType) {
|
switch (eventType) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import type { NeutralMessage, NeutralAuthor } from "../types";
|
import type { NeutralMessage, NeutralAuthor } from "../types";
|
||||||
import { GITHUB_COLORS } from "./colors";
|
import { GITHUB_COLORS } from "./colors";
|
||||||
import { emojiPrefix, type T, buildMessage } from "./helpers";
|
import { cap, emojiPrefix, MAX_FIELD_VALUE, type T, buildMessage } from "./helpers";
|
||||||
|
|
||||||
export function formatIssues(
|
export function formatIssues(
|
||||||
payload: Record<string, unknown>,
|
payload: Record<string, unknown>,
|
||||||
|
|
@ -47,7 +47,7 @@ export function formatIssues(
|
||||||
if (issue.labels && issue.labels.length > 0) {
|
if (issue.labels && issue.labels.length > 0) {
|
||||||
fields.push({
|
fields.push({
|
||||||
name: t("fields.labels"),
|
name: t("fields.labels"),
|
||||||
value: issue.labels.map((l) => l.name).join(", "),
|
value: cap(issue.labels.map((l) => l.name).join(", "), MAX_FIELD_VALUE),
|
||||||
inline: true,
|
inline: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -55,7 +55,7 @@ export function formatIssues(
|
||||||
if (issue.assignees && issue.assignees.length > 0) {
|
if (issue.assignees && issue.assignees.length > 0) {
|
||||||
fields.push({
|
fields.push({
|
||||||
name: t("fields.assignees"),
|
name: t("fields.assignees"),
|
||||||
value: issue.assignees.map((a) => a.login).join(", "),
|
value: cap(issue.assignees.map((a) => a.login).join(", "), MAX_FIELD_VALUE),
|
||||||
inline: true,
|
inline: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,14 @@
|
||||||
import type { NeutralMessage, NeutralAuthor, NeutralAction } from "../types";
|
import type { NeutralMessage, NeutralAuthor, NeutralAction } from "../types";
|
||||||
import { GITHUB_COLORS } from "./colors";
|
import { GITHUB_COLORS } from "./colors";
|
||||||
import { branchLink, emojiPrefix, type T, buildMessage, repoBaseUrl } from "./helpers";
|
import {
|
||||||
|
branchLink,
|
||||||
|
cap,
|
||||||
|
emojiPrefix,
|
||||||
|
MAX_FIELD_VALUE,
|
||||||
|
type T,
|
||||||
|
buildMessage,
|
||||||
|
repoBaseUrl,
|
||||||
|
} from "./helpers";
|
||||||
|
|
||||||
export function formatPullRequest(
|
export function formatPullRequest(
|
||||||
payload: Record<string, unknown>,
|
payload: Record<string, unknown>,
|
||||||
|
|
@ -82,7 +90,7 @@ export function formatPullRequest(
|
||||||
if (pr.labels && pr.labels.length > 0) {
|
if (pr.labels && pr.labels.length > 0) {
|
||||||
fields.push({
|
fields.push({
|
||||||
name: t("fields.labels"),
|
name: t("fields.labels"),
|
||||||
value: pr.labels.map((l) => l.name).join(", "),
|
value: cap(pr.labels.map((l) => l.name).join(", "), MAX_FIELD_VALUE),
|
||||||
inline: true,
|
inline: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -91,8 +99,16 @@ export function formatPullRequest(
|
||||||
const actionable = pr.state === "open" && !!repoOwner && !!repoName && pr.number != null;
|
const actionable = pr.state === "open" && !!repoOwner && !!repoName && pr.number != null;
|
||||||
const actions: NeutralAction[] | undefined = actionable
|
const actions: NeutralAction[] | undefined = actionable
|
||||||
? [
|
? [
|
||||||
{ id: `ghpr|merge|${repoOwner}|${repoName}|${pr.number}`, label: "合并", style: "primary" },
|
{
|
||||||
{ id: `ghpr|close|${repoOwner}|${repoName}|${pr.number}`, label: "关闭", style: "danger" },
|
id: `ghpr|merge|${repoOwner}|${repoName}|${pr.number}`,
|
||||||
|
label: t("actions.merge"),
|
||||||
|
style: "primary",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: `ghpr|close|${repoOwner}|${repoName}|${pr.number}`,
|
||||||
|
label: t("actions.close"),
|
||||||
|
style: "danger",
|
||||||
|
},
|
||||||
]
|
]
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,14 @@
|
||||||
import type { NeutralMessage, NeutralAuthor } from "../types";
|
import type { NeutralMessage, NeutralAuthor } from "../types";
|
||||||
import { GITHUB_COLORS } from "./colors";
|
import { GITHUB_COLORS } from "./colors";
|
||||||
import { branchLink, emojiPrefix, tagLink, type T, buildMessage, repoBaseUrl } from "./helpers";
|
import {
|
||||||
|
branchLink,
|
||||||
|
emojiPrefix,
|
||||||
|
MAX_COMMIT_SUBJECT,
|
||||||
|
tagLink,
|
||||||
|
type T,
|
||||||
|
buildMessage,
|
||||||
|
repoBaseUrl,
|
||||||
|
} from "./helpers";
|
||||||
|
|
||||||
export function formatPush(
|
export function formatPush(
|
||||||
payload: Record<string, unknown>,
|
payload: Record<string, unknown>,
|
||||||
|
|
@ -55,7 +63,10 @@ export function formatPush(
|
||||||
descriptionParts.push(em("⚠️") + t("events.push.force_push"));
|
descriptionParts.push(em("⚠️") + t("events.push.force_push"));
|
||||||
}
|
}
|
||||||
if (created) {
|
if (created) {
|
||||||
descriptionParts.push(em("🆕") + t("events.push.branch_created"));
|
descriptionParts.push(
|
||||||
|
em("🆕") +
|
||||||
|
t(isTagPush ? "events.push.tag_created" : "events.push.branch_created"),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (compareUrl) {
|
if (compareUrl) {
|
||||||
|
|
@ -66,7 +77,8 @@ export function formatPush(
|
||||||
|
|
||||||
const commitField = (c: (typeof commits)[number]): { name: string; value: string } => {
|
const commitField = (c: (typeof commits)[number]): { name: string; value: string } => {
|
||||||
const shortId = c.id?.slice(0, 7) ?? "???????";
|
const shortId = c.id?.slice(0, 7) ?? "???????";
|
||||||
const msg = (c.message?.split("\n")[0] ?? "").slice(0, 72) || t("common.no_message");
|
const msg = (c.message?.split("\n")[0] ?? "").slice(0, MAX_COMMIT_SUBJECT) ||
|
||||||
|
t("common.no_message");
|
||||||
const url = baseUrl && c.id ? `${baseUrl}/commit/${c.id}` : null;
|
const url = baseUrl && c.id ? `${baseUrl}/commit/${c.id}` : null;
|
||||||
const hash = url ? `[\`${shortId}\`](${url})` : `\`${shortId}\``;
|
const hash = url ? `[\`${shortId}\`](${url})` : `\`${shortId}\``;
|
||||||
return { name: `\u200b`, value: `${hash} ${msg}` };
|
return { name: `\u200b`, value: `${hash} ${msg}` };
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,18 @@
|
||||||
import type { NeutralMessage, NeutralAuthor } from "../types";
|
import type { NeutralMessage, NeutralAuthor } from "../types";
|
||||||
import { GITHUB_COLORS, WORKFLOW_CONCLUSION_EMOJI } from "./colors";
|
import { GITHUB_COLORS, WORKFLOW_CONCLUSION_EMOJI } from "./colors";
|
||||||
import { emojiPrefix, type T, buildMessage, branchLink, commitLink, repoBaseUrl } from "./helpers";
|
import {
|
||||||
|
branchLink,
|
||||||
|
cap,
|
||||||
|
commitLink,
|
||||||
|
emojiPrefix,
|
||||||
|
MAX_FIELD_VALUE,
|
||||||
|
statusColorKey,
|
||||||
|
type T,
|
||||||
|
buildMessage,
|
||||||
|
repoBaseUrl,
|
||||||
|
workflowRunStatus,
|
||||||
|
workflowStatus,
|
||||||
|
} from "./helpers";
|
||||||
|
|
||||||
export function formatWorkflowJob(
|
export function formatWorkflowJob(
|
||||||
payload: Record<string, unknown>,
|
payload: Record<string, unknown>,
|
||||||
|
|
@ -20,21 +32,11 @@ export function formatWorkflowJob(
|
||||||
run_id?: number;
|
run_id?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const status =
|
const status = workflowStatus(job.status, job.conclusion ?? undefined);
|
||||||
job.status === "queued"
|
|
||||||
? "queued"
|
|
||||||
: job.status === "in_progress"
|
|
||||||
? "running"
|
|
||||||
: (job.conclusion ?? "pending");
|
|
||||||
const emoji = WORKFLOW_CONCLUSION_EMOJI[status] ?? "⏳";
|
const emoji = WORKFLOW_CONCLUSION_EMOJI[status] ?? "⏳";
|
||||||
const em = (e: string): string => emojiPrefix(e, showEmoji);
|
const em = (e: string): string => emojiPrefix(e, showEmoji);
|
||||||
const baseUrl = repoBaseUrl(payload, repo);
|
const baseUrl = repoBaseUrl(payload, repo);
|
||||||
const colorKey =
|
const colorKey = statusColorKey("workflow_run", status);
|
||||||
status === "success"
|
|
||||||
? "workflow_run_success"
|
|
||||||
: status === "failure"
|
|
||||||
? "workflow_run_failure"
|
|
||||||
: "workflow_run_other";
|
|
||||||
|
|
||||||
const fields: Array<{ name: string; value: string; inline?: boolean }> = [];
|
const fields: Array<{ name: string; value: string; inline?: boolean }> = [];
|
||||||
|
|
||||||
|
|
@ -114,21 +116,11 @@ export function formatWorkflowRun(
|
||||||
};
|
};
|
||||||
|
|
||||||
const action = payload.action as string | undefined;
|
const action = payload.action as string | undefined;
|
||||||
const status =
|
const status = workflowRunStatus(action, workflow.conclusion);
|
||||||
action === "in_progress"
|
|
||||||
? "running"
|
|
||||||
: action === "requested"
|
|
||||||
? "queued"
|
|
||||||
: (workflow.conclusion ?? "pending");
|
|
||||||
const emoji = WORKFLOW_CONCLUSION_EMOJI[status] ?? "⏳";
|
const emoji = WORKFLOW_CONCLUSION_EMOJI[status] ?? "⏳";
|
||||||
const em = (e: string): string => emojiPrefix(e, showEmoji);
|
const em = (e: string): string => emojiPrefix(e, showEmoji);
|
||||||
const baseUrl = repoBaseUrl(payload, repo);
|
const baseUrl = repoBaseUrl(payload, repo);
|
||||||
const colorKey =
|
const colorKey = statusColorKey("workflow_run", status);
|
||||||
status === "success"
|
|
||||||
? "workflow_run_success"
|
|
||||||
: status === "failure"
|
|
||||||
? "workflow_run_failure"
|
|
||||||
: "workflow_run_other";
|
|
||||||
|
|
||||||
const fields: Array<{ name: string; value: string; inline?: boolean }> = [];
|
const fields: Array<{ name: string; value: string; inline?: boolean }> = [];
|
||||||
|
|
||||||
|
|
@ -139,12 +131,13 @@ export function formatWorkflowRun(
|
||||||
});
|
});
|
||||||
|
|
||||||
if (workflow.jobs?.length) {
|
if (workflow.jobs?.length) {
|
||||||
|
// Many-jobs workflows must stay under the Discord field value limit.
|
||||||
const jobLines = workflow.jobs.map(
|
const jobLines = workflow.jobs.map(
|
||||||
(j) => `${em(WORKFLOW_CONCLUSION_EMOJI[j.conclusion ?? ""] ?? "⏳")}${j.name ?? ""}`,
|
(j) => `${em(WORKFLOW_CONCLUSION_EMOJI[j.conclusion ?? ""] ?? "⏳")}${cap(j.name ?? "", 200)}`,
|
||||||
);
|
);
|
||||||
fields.push({
|
fields.push({
|
||||||
name: t("fields.job"),
|
name: t("fields.job"),
|
||||||
value: jobLines.join("\n"),
|
value: cap(jobLines.join("\n"), MAX_FIELD_VALUE),
|
||||||
inline: false,
|
inline: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,8 @@ export const en = {
|
||||||
appeared_in_branch: "Appeared in Branch",
|
appeared_in_branch: "Appeared in Branch",
|
||||||
reopened_by_user: "Reopened by User",
|
reopened_by_user: "Reopened by User",
|
||||||
closed_by_user: "Closed by User",
|
closed_by_user: "Closed by User",
|
||||||
|
merge: "Merge",
|
||||||
|
close: "Close",
|
||||||
},
|
},
|
||||||
fields: {
|
fields: {
|
||||||
branch: "Branch",
|
branch: "Branch",
|
||||||
|
|
@ -88,6 +90,7 @@ export const en = {
|
||||||
push: {
|
push: {
|
||||||
force_push: "**Force push**",
|
force_push: "**Force push**",
|
||||||
branch_created: "Branch created",
|
branch_created: "Branch created",
|
||||||
|
tag_created: "Tag created",
|
||||||
view_comparison: "[View comparison]({url})",
|
view_comparison: "[View comparison]({url})",
|
||||||
added: "+{count} added",
|
added: "+{count} added",
|
||||||
removed: "-{count} removed",
|
removed: "-{count} removed",
|
||||||
|
|
@ -154,6 +157,7 @@ export const en = {
|
||||||
commit_comment: {
|
commit_comment: {
|
||||||
action_comment: "{emoji}**{action}**",
|
action_comment: "{emoji}**{action}**",
|
||||||
title: "{repo}: Comment on commit {sha}",
|
title: "{repo}: Comment on commit {sha}",
|
||||||
|
title_plain: "{repo}: Comment on commit",
|
||||||
},
|
},
|
||||||
deployment: {
|
deployment: {
|
||||||
title: "{repo}: Deployment to `{env}` — {state}",
|
title: "{repo}: Deployment to `{env}` — {state}",
|
||||||
|
|
@ -201,7 +205,7 @@ export const en = {
|
||||||
title: "{repo}: {event}{action}",
|
title: "{repo}: {event}{action}",
|
||||||
routes: "Routes",
|
routes: "Routes",
|
||||||
delivery: "Delivery",
|
delivery: "Delivery",
|
||||||
route_ok: "✅ {route} → {target}",
|
route_ok: "{route} → {target}",
|
||||||
route_fail: "❌ {route} → {target}: {error}",
|
route_fail: "{route} → {target}: {error}",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,8 @@ export const zh = {
|
||||||
appeared_in_branch: "出现在分支中",
|
appeared_in_branch: "出现在分支中",
|
||||||
reopened_by_user: "用户重新打开",
|
reopened_by_user: "用户重新打开",
|
||||||
closed_by_user: "用户关闭",
|
closed_by_user: "用户关闭",
|
||||||
|
merge: "合并",
|
||||||
|
close: "关闭",
|
||||||
},
|
},
|
||||||
fields: {
|
fields: {
|
||||||
branch: "分支",
|
branch: "分支",
|
||||||
|
|
@ -88,12 +90,13 @@ export const zh = {
|
||||||
push: {
|
push: {
|
||||||
force_push: "**强制推送**",
|
force_push: "**强制推送**",
|
||||||
branch_created: "分支已创建",
|
branch_created: "分支已创建",
|
||||||
|
tag_created: "标签已创建",
|
||||||
commits_pushed: "**{count}** 个提交已推送到 {ref}",
|
commits_pushed: "**{count}** 个提交已推送到 {ref}",
|
||||||
view_comparison: "[查看比较]({url})",
|
view_comparison: "[查看比较]({url})",
|
||||||
added: "+{count} 新增",
|
added: "+{count} 新增",
|
||||||
removed: "-{count} 删除",
|
removed: "-{count} 删除",
|
||||||
modified: "~{count} 修改",
|
modified: "~{count} 修改",
|
||||||
title: "{repo}: 推送了 {count} 个提交",
|
title: "{repo}: 推送了 {count} 个提交到 {ref}",
|
||||||
},
|
},
|
||||||
pr: {
|
pr: {
|
||||||
action_pr: "{emoji}**{action}** 拉取请求",
|
action_pr: "{emoji}**{action}** 拉取请求",
|
||||||
|
|
@ -155,6 +158,7 @@ export const zh = {
|
||||||
commit_comment: {
|
commit_comment: {
|
||||||
action_comment: "{emoji}**{action}**",
|
action_comment: "{emoji}**{action}**",
|
||||||
title: "{repo}: 提交 {sha} 的评论",
|
title: "{repo}: 提交 {sha} 的评论",
|
||||||
|
title_plain: "{repo}: 提交评论",
|
||||||
},
|
},
|
||||||
deployment: {
|
deployment: {
|
||||||
title: "{repo}: 部署到 `{env}` — {state}",
|
title: "{repo}: 部署到 `{env}` — {state}",
|
||||||
|
|
@ -202,7 +206,7 @@ export const zh = {
|
||||||
title: "{repo}: {event}{action}",
|
title: "{repo}: {event}{action}",
|
||||||
routes: "路由",
|
routes: "路由",
|
||||||
delivery: "投递",
|
delivery: "投递",
|
||||||
route_ok: "✅ {route} → {target}",
|
route_ok: "{route} → {target}",
|
||||||
route_fail: "❌ {route} → {target}: {error}",
|
route_fail: "{route} → {target}: {error}",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -33,8 +33,8 @@ export async function processWebhook(
|
||||||
tenantId?: string,
|
tenantId?: string,
|
||||||
): Promise<WebhookResult> {
|
): Promise<WebhookResult> {
|
||||||
let effectiveEnv = env;
|
let effectiveEnv = env;
|
||||||
|
const groups = await loadGroups(env.KV);
|
||||||
if (tenantId) {
|
if (tenantId) {
|
||||||
const groups = await loadGroups(env.KV);
|
|
||||||
if (!groups.some((g) => g.id === tenantId)) {
|
if (!groups.some((g) => g.id === tenantId)) {
|
||||||
return { status: 404, body: { error: "Group not found" } };
|
return { status: 404, body: { error: "Group not found" } };
|
||||||
}
|
}
|
||||||
|
|
@ -51,6 +51,17 @@ export async function processWebhook(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(await provider.verify(body, headers, effectiveEnv))) {
|
if (!(await provider.verify(body, headers, effectiveEnv))) {
|
||||||
|
// Log the actual cause: a missing provider secret is a deployment problem,
|
||||||
|
// while a mismatched signature usually means the sender used the wrong secret.
|
||||||
|
const secret =
|
||||||
|
provider.id === "gitea"
|
||||||
|
? effectiveEnv.GITEA_WEBHOOK_SECRET
|
||||||
|
: effectiveEnv.GITHUB_WEBHOOK_SECRET;
|
||||||
|
if (!secret) {
|
||||||
|
log.warn({ provider: provider.id }, "Webhook rejected: provider secret is not configured");
|
||||||
|
} else {
|
||||||
|
log.warn({ provider: provider.id }, "Webhook rejected: invalid signature");
|
||||||
|
}
|
||||||
return { status: 401, body: { error: "Invalid signature" } };
|
return { status: 401, body: { error: "Invalid signature" } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -108,7 +119,7 @@ export async function processWebhook(
|
||||||
config.routes = config.routes.filter((r) => r.groupId === tenantId);
|
config.routes = config.routes.filter((r) => r.groupId === tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const dispatch = dispatchEvent(config, event, env).catch((err) =>
|
const dispatch = dispatchEvent(config, event, env, groups).catch((err) =>
|
||||||
log.error(err, "Dispatch failed"),
|
log.error(err, "Dispatch failed"),
|
||||||
);
|
);
|
||||||
waitUntil(dispatch);
|
waitUntil(dispatch);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { describe, it, expect } from "bun:test";
|
import { describe, it, expect } from "bun:test";
|
||||||
import { formatEvent } from "../server/lib/formatters";
|
import { formatEvent } from "../server/lib/formatters";
|
||||||
|
import { MAX_COMMIT_SUBJECT } from "../server/lib/formatters/helpers";
|
||||||
import type { Route, WebhookEvent } from "../server/lib/types";
|
import type { Route, WebhookEvent } from "../server/lib/types";
|
||||||
|
|
||||||
const route: Route = {
|
const route: Route = {
|
||||||
|
|
@ -393,3 +394,127 @@ describe("group emoji toggle", () => {
|
||||||
expect(msg.fields![1].value).toBe("build");
|
expect(msg.fields![1].value).toBe("build");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("limits and localization", () => {
|
||||||
|
it("pull_request buttons use localized labels", () => {
|
||||||
|
const msg = formatEvent(
|
||||||
|
route,
|
||||||
|
event("pull_request", {
|
||||||
|
action: "opened",
|
||||||
|
pull_request: {
|
||||||
|
title: "Add feature",
|
||||||
|
number: 7,
|
||||||
|
state: "open",
|
||||||
|
merged: false,
|
||||||
|
html_url: "https://github.com/acme/widget/pull/7",
|
||||||
|
head: { ref: "feat" },
|
||||||
|
base: { ref: "main" },
|
||||||
|
},
|
||||||
|
repository: repo,
|
||||||
|
sender,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(msg.actions?.map((a) => a.label)).toEqual(["Merge", "Close"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("workflow_run job list is capped to the field value limit", () => {
|
||||||
|
const jobs = Array.from({ length: 60 }, (_, i) => ({
|
||||||
|
name: `job-${i}`,
|
||||||
|
conclusion: "success",
|
||||||
|
}));
|
||||||
|
const msg = formatEvent(
|
||||||
|
route,
|
||||||
|
event("workflow_run", {
|
||||||
|
action: "completed",
|
||||||
|
workflow_run: { name: "CI", conclusion: "success", run_number: 1, jobs },
|
||||||
|
repository: repo,
|
||||||
|
sender,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const jobField = msg.fields!.find((f) => f.name === "Job");
|
||||||
|
expect(jobField!.value!.length).toBeLessThanOrEqual(1024);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("commit message is truncated at the subject limit", () => {
|
||||||
|
const link = "[`abcd123`](https://github.com/acme/widget/commit/abcd1234ef)";
|
||||||
|
const msg = formatEvent(
|
||||||
|
route,
|
||||||
|
event("push", {
|
||||||
|
ref: "refs/heads/main",
|
||||||
|
created: false,
|
||||||
|
forced: false,
|
||||||
|
commits: [
|
||||||
|
{ id: "abcd1234ef", message: "x".repeat(300), added: [], removed: [], modified: [] },
|
||||||
|
],
|
||||||
|
repository: repo,
|
||||||
|
sender,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(msg.fields![0].value).toBe(`${link} ${"x".repeat(MAX_COMMIT_SUBJECT)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tag push created mentions the tag", () => {
|
||||||
|
const msg = formatEvent(
|
||||||
|
route,
|
||||||
|
event("push", {
|
||||||
|
ref: "refs/tags/v1.0",
|
||||||
|
created: true,
|
||||||
|
forced: false,
|
||||||
|
deleted: false,
|
||||||
|
commits: [],
|
||||||
|
repository: repo,
|
||||||
|
sender,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(msg.description).toBe("🆕 Tag created");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("commit_comment without a commit id omits the sha", () => {
|
||||||
|
const msg = formatEvent(
|
||||||
|
route,
|
||||||
|
event("commit_comment", {
|
||||||
|
action: "created",
|
||||||
|
comment: { body: "why?" },
|
||||||
|
repository: repo,
|
||||||
|
sender,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(msg.title).toBe("acme/widget: Comment on commit");
|
||||||
|
expect(msg.fields).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sender profile link follows the forge when html_url is missing", () => {
|
||||||
|
const msg = formatEvent(
|
||||||
|
route,
|
||||||
|
event("push", {
|
||||||
|
ref: "refs/heads/main",
|
||||||
|
created: false,
|
||||||
|
forced: false,
|
||||||
|
commits: [{ id: "abcd1234ef", message: "x", added: [], removed: [], modified: [] }],
|
||||||
|
repository: { full_name: "org/repo", html_url: "https://git.example.com/org/repo" },
|
||||||
|
sender: { login: "octo" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(msg.author?.url).toBe("https://git.example.com/octo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("custom payload fields and values are capped", () => {
|
||||||
|
const fields = Array.from({ length: 30 }, (_, i) => ({
|
||||||
|
name: `f${i}`,
|
||||||
|
value: "y".repeat(2000),
|
||||||
|
}));
|
||||||
|
const msg = formatEvent(
|
||||||
|
route,
|
||||||
|
event("custom", {
|
||||||
|
title: "Deploy failed",
|
||||||
|
repo: "acme/widget",
|
||||||
|
color: "red",
|
||||||
|
description: "x".repeat(5000),
|
||||||
|
fields,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(msg.fields!.length).toBe(25);
|
||||||
|
expect(msg.fields!.every((f) => f.value.length <= 1024)).toBe(true);
|
||||||
|
expect(msg.description!.length).toBe(4096);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue