feat(groups): host-based forge sources with optional display name

- forgeSources entries are now { host, type, name? }: the repository URL's
  hostname is matched case-insensitively against host (github.com for GitHub,
  distinct hosts for multiple Gitea instances); the footer label is the
  optional name, falling back to the host
- GroupEditor renders one row per source: host input + type select + optional
  display name (grid layout); hostname validation mirrors the server
- fix: apiFetch sends Content-Type: application/json — h3's readBody only
  parses JSON bodies with that header, so every PUT/POST from the refactored
  console arrived as a raw string and failed with 'groups must be an array'
- hardening: readJsonBody (admin + actions) JSON-parses string bodies so curl
  and older clients without the content-type header still work
- regression test: groups PUT without content-type + forgeSources round-trip
- docs: groups.md/message-format.md (en/zh), AGENTS.md, config.example.yaml
This commit is contained in:
RhenCloud 2026-08-14 08:49:48 +08:00
parent 17d10db845
commit 3f6f7f17b5
No known key found for this signature in database
GPG key ID: A574A617378C4E0B
19 changed files with 469 additions and 108 deletions

View file

@ -1,5 +1,10 @@
import { describe, it, expect } from "bun:test";
import { adminGroupRename, adminGroupRoutesGet, adminApiMe } from "../server/lib/web/admin";
import {
adminGroupRename,
adminGroupRoutesGet,
adminApiGroupsPut,
adminApiMe,
} from "../server/lib/web/admin";
import { createAdminSession, adminCookie } from "../server/lib/web/session";
import { loadGroups } from "../server/lib/web/groups";
import { loadRoutes } from "../server/lib/config";
@ -116,6 +121,46 @@ describe("admin handlers (h3)", () => {
expect(await listInvites(kv, "old-team")).toHaveLength(0);
});
it("accepts a groups PUT without the application/json content-type", async () => {
const kv = createMockKV();
await kv.put(
"config:groups",
JSON.stringify([
{ id: "team", name: "Team", adminIds: [], members: [{ login: "alice", role: "owner" }] },
]),
);
const env = createEnv({ KV: kv });
const sessionId = await createAdminSession(kv, "1001", "alice");
// The console's fetch helper always declares application/json, but curl
// and older clients may omit it — h3's readBody then returns the raw
// string, which used to fail validation with "groups must be an array".
const event = makeEvent("/api/groups", {
method: "PUT",
headers: { cookie: adminCookie(sessionId) },
body: JSON.stringify({
groups: [
{
id: "team",
name: "Team",
adminIds: [],
members: [{ login: "alice", role: "owner" }],
forgeSources: [{ host: "git.example.com", type: "gitea", name: "内网 Gitea" }],
},
],
}),
env,
});
const result = (await adminApiGroupsPut(event)) as { ok?: boolean };
expect(responseStatus(event)).toBe(200);
expect(result.ok).toBe(true);
const groups = await loadGroups(kv);
expect(groups).toHaveLength(1);
expect(groups[0]!.forgeSources).toEqual([
{ host: "git.example.com", type: "gitea", name: "内网 Gitea" },
]);
});
it("forbids non-owner members from renaming", async () => {
const kv = createMockKV();
await kv.put(

View file

@ -385,7 +385,7 @@ describe("dispatchEvent fallback routing", () => {
expect(logBody.embeds?.[0]?.fields?.[0]?.value).toContain("❌ Push Route → 111");
});
it("attaches the forge label when the group enables it", async () => {
it("attaches the forge label when the group defines a matching host", async () => {
const bodies: string[] = [];
mockFetch((url, init) => {
bodies.push(String(init?.body ?? ""));
@ -395,7 +395,12 @@ describe("dispatchEvent fallback routing", () => {
await kv.put(
"config:groups",
JSON.stringify([
{ id: "gh", name: "GH", adminIds: [], forgeLabel: true },
{
id: "gh",
name: "GH",
adminIds: [],
forgeSources: [{ host: "git.example.com", type: "gitea", name: "内网 Gitea" }],
},
{ id: "plain", name: "Plain", adminIds: [] },
]),
);
@ -445,7 +450,7 @@ describe("dispatchEvent fallback routing", () => {
return parsed.embeds?.[0]?.footer;
});
expect(footers).toContainEqual({
text: "git.example.com · owner/repo",
text: "内网 Gitea · owner/repo",
icon_url: "https://git.example.com/favicon.ico",
});
expect(footers).toContainEqual({ text: "owner/repo" });

View file

@ -520,32 +520,129 @@ describe("limits and localization", () => {
});
describe("forge source branding", () => {
it("github events brand as GitHub with the favicon", () => {
expect(forgeInfo({ event: "push", provider: "github", payload: {} })).toEqual({
name: "GitHub",
const ghHost = { host: "github.com", type: "github" as const };
it("labels github events with the configured host and the github favicon", () => {
expect(
forgeInfo(
{ event: "push", provider: "github", payload: { repository: { html_url: "https://github.com/org/repo" } } },
[ghHost],
),
).toEqual({
name: "github.com",
url: "https://github.com",
iconUrl: "https://github.com/favicon.ico",
});
});
it("gitea events brand as the instance hostname derived from the repo url", () => {
it("labels github events without a repository (ping) via the github.com fallback", () => {
expect(
forgeInfo({
event: "push",
provider: "gitea",
payload: { repository: { html_url: "https://git.example.com/org/repo" } },
}),
forgeInfo({ event: "ping", provider: "github", payload: {} }, [ghHost]),
).toEqual({
name: "git.example.com",
url: "https://git.example.com",
iconUrl: "https://git.example.com/favicon.ico",
name: "github.com",
url: "https://github.com",
iconUrl: "https://github.com/favicon.ico",
});
expect(forgeInfo({ event: "push", provider: "gitea", payload: {} })).toBeUndefined();
});
it("custom events brand as a plain label without links", () => {
expect(forgeInfo({ event: "custom", provider: "custom", payload: {} })).toEqual({
name: "Custom",
it("matches distinct gitea instances by their own host", () => {
const sources = [
{ host: "git1.example.com", type: "gitea" as const },
{ host: "git2.example.com", type: "gitea" as const },
];
expect(
forgeInfo(
{ event: "push", provider: "gitea", payload: { repository: { html_url: "https://git1.example.com/org/a" } } },
sources,
),
).toEqual({
name: "git1.example.com",
url: "https://git1.example.com",
iconUrl: "https://git1.example.com/favicon.ico",
});
expect(
forgeInfo(
{ event: "push", provider: "gitea", payload: { repository: { html_url: "https://git2.example.com/org/b" } } },
sources,
),
).toEqual({
name: "git2.example.com",
url: "https://git2.example.com",
iconUrl: "https://git2.example.com/favicon.ico",
});
});
it("labels gitea events with the configured name when set, else the host", () => {
const sources = [
{ host: "git1.example.com", type: "gitea" as const, name: "内网 Gitea" },
{ host: "git2.example.com", type: "gitea" as const },
];
expect(
forgeInfo(
{ event: "push", provider: "gitea", payload: { repository: { html_url: "https://git1.example.com/org/a" } } },
sources,
),
).toEqual({
name: "内网 Gitea",
url: "https://git1.example.com",
iconUrl: "https://git1.example.com/favicon.ico",
});
expect(
forgeInfo(
{ event: "push", provider: "gitea", payload: { repository: { html_url: "https://git2.example.com/org/b" } } },
sources,
),
).toEqual({
name: "git2.example.com",
url: "https://git2.example.com",
iconUrl: "https://git2.example.com/favicon.ico",
});
expect(
forgeInfo(
{ event: "push", provider: "gitea", payload: { repository: { html_url: "https://git1.example.com/org/a" } } },
[{ host: "git1.example.com", type: "gitea", name: " " }],
),
).toEqual({
name: "git1.example.com",
url: "https://git1.example.com",
iconUrl: "https://git1.example.com/favicon.ico",
});
});
it("matches hosts case-insensitively", () => {
expect(
forgeInfo(
{ event: "push", provider: "gitea", payload: { repository: { html_url: "https://GIT1.Example.COM/org/a" } } },
[{ host: "Git1.Example.com", type: "gitea" }],
),
).toEqual({
name: "Git1.Example.com",
url: "https://git1.example.com",
iconUrl: "https://git1.example.com/favicon.ico",
});
});
it("returns no label when the gitea repo url is missing or unparseable", () => {
expect(
forgeInfo({ event: "push", provider: "gitea", payload: {} }, [{ host: "git1.example.com", type: "gitea" }]),
).toBeUndefined();
expect(
forgeInfo(
{ event: "push", provider: "gitea", payload: { repository: { html_url: "not-a-url" } } },
[{ host: "git1.example.com", type: "gitea" }],
),
).toBeUndefined();
});
it("returns undefined when no source matches the provider or host", () => {
expect(forgeInfo({ event: "custom", provider: "custom", payload: {} }, [ghHost])).toBeUndefined();
expect(
forgeInfo(
{ event: "push", provider: "gitea", payload: { repository: { html_url: "https://other.example.com/org/a" } } },
[ghHost],
),
).toBeUndefined();
expect(forgeInfo({ event: "push", provider: "github", payload: {} }, [])).toBeUndefined();
expect(forgeInfo({ event: "push", provider: "github", payload: {} })).toBeUndefined();
});
});