diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index 3c8a50fff..45e5bf4be 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -19,7 +19,7 @@ "service": { "type": "string", "enum": [ - "gittensory-api" + "loopover-api" ] }, "time": { @@ -52,7 +52,7 @@ "service": { "type": "string", "enum": [ - "gittensory-api" + "loopover-api" ] }, "apiVersion": { diff --git a/src/api/badge.ts b/src/api/badge.ts index cd07bf1b1..9257b4ff7 100644 --- a/src/api/badge.ts +++ b/src/api/badge.ts @@ -5,7 +5,7 @@ import type { PublicRepoQuality, QueueHealthLevel } from "../services/public-rep // before it reaches the SVG so the unauthenticated, embeddable surface cannot be turned into an injection // vector even if upstream values ever change shape. -const LABEL = "gittensory"; +export const LABEL = "loopover"; const QUEUE_COLORS: Record = { low: "#3fb950", diff --git a/src/api/routes.ts b/src/api/routes.ts index 6b299af0c..0e9f33fb2 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -197,7 +197,7 @@ import { buildSelfDogfoodRegistrationPack, resolveSelfDogfoodRepoFullName } from import { buildSubnetInterfaceDescriptor } from "../services/subnet-interface"; import { buildPublicRepoQuality, type PublicRepoQuality } from "../services/public-repo-quality"; import { loadPublicQualityMetrics } from "../services/public-quality-metrics"; -import { buildShieldsBadge, renderBadgeSvg, renderUnavailableBadgeSvg } from "./badge"; +import { buildShieldsBadge, LABEL as PUBLIC_BADGE_LABEL, renderBadgeSvg, renderUnavailableBadgeSvg } from "./badge"; import { buildWeeklyValueReport, formatWeeklyValueReportMarkdown, @@ -936,7 +936,7 @@ export function createApp() { app.get("/health", (c) => c.json({ status: "ok", - service: "gittensory-api", + service: "loopover-api", time: nowIso(), minMcpVersion: MINIMUM_SUPPORTED_MCP_VERSION, latestRecommendedMcpVersion: LATEST_RECOMMENDED_MCP_VERSION, @@ -1003,7 +1003,7 @@ export function createApp() { const quality = await loadPublicRepoBadge(c.env, c.req.param("owner"), c.req.param("repo")); if (!quality) { c.header("Cache-Control", "public, max-age=300"); - return c.json({ schemaVersion: 1, label: "gittensory", message: "unavailable", color: "#9e9e9e", cacheSeconds: 300 }, 404); + return c.json({ schemaVersion: 1, label: PUBLIC_BADGE_LABEL, message: "unavailable", color: "#9e9e9e", cacheSeconds: 300 }, 404); } c.header("Cache-Control", "public, max-age=600, stale-while-revalidate=86400"); return c.json(buildShieldsBadge(quality, 600)); diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 00d896473..b9011a0cf 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -2720,7 +2720,7 @@ export const AgentRunBundleSchema = z export const HealthSchema = z .object({ status: z.literal("ok"), - service: z.literal("gittensory-api"), + service: z.literal("loopover-api"), time: z.string(), minMcpVersion: z.string(), latestRecommendedMcpVersion: z.string(), @@ -2730,7 +2730,7 @@ export const HealthSchema = z export const McpCompatibilitySchema = z .object({ status: z.literal("ok"), - service: z.literal("gittensory-api"), + service: z.literal("loopover-api"), apiVersion: z.string(), mcp: z.object({ packageName: z.string(), diff --git a/src/services/mcp-compatibility.ts b/src/services/mcp-compatibility.ts index 79bd85706..cbb79b450 100644 --- a/src/services/mcp-compatibility.ts +++ b/src/services/mcp-compatibility.ts @@ -25,7 +25,7 @@ export type BreakingChangeNotice = { export type McpCompatibilityMetadata = { status: "ok"; - service: "gittensory-api"; + service: "loopover-api"; apiVersion: string; mcp: { packageName: string; @@ -44,7 +44,7 @@ export type McpCompatibilityMetadata = { export function buildMcpCompatibilityMetadata(generatedAt: string): McpCompatibilityMetadata { return { status: "ok", - service: "gittensory-api", + service: "loopover-api", apiVersion: GITTENSORY_API_VERSION, mcp: { packageName: GITTENSORY_MCP_PACKAGE_NAME, diff --git a/src/services/notify-discord.ts b/src/services/notify-discord.ts index 5c0f893e7..6b69e8815 100644 --- a/src/services/notify-discord.ts +++ b/src/services/notify-discord.ts @@ -103,7 +103,7 @@ async function auditExternalNotification( ): Promise { await recordAuditEvent(env, { eventType: `external_notification.${provider}`, - actor: "gittensory", + actor: "loopover", targetKey: `${params.repoFullName}#${params.pullNumber}`, outcome, detail, @@ -125,7 +125,7 @@ export async function notifyActionToDiscord( } const meta = OUTCOME_META[params.outcome]; const body = { - username: "Gittensory", + username: "LoopOver", embeds: [ { title: `${params.repoFullName}#${params.pullNumber} · ${meta.word}`, @@ -137,7 +137,7 @@ export async function notifyActionToDiscord( { name: "PR", value: `#${params.pullNumber}`, inline: true }, ...(params.submitter ? [{ name: "Submitter", value: `@${params.submitter}`, inline: true }] : []), ], - footer: { text: `Gittensory · ${params.repoFullName}` }, + footer: { text: `LoopOver · ${params.repoFullName}` }, }, ], }; @@ -171,12 +171,12 @@ export async function deliverRecapToDiscord( const url = envString(env, "DISCORD_WEBHOOK_URL"); if (!url || !isValidDiscordWebhook(url)) { const reason = url ? "invalid_global_webhook" : "missing_global_webhook"; - await recordAuditEvent(env, { eventType: "maintainer_recap_notification.discord", actor: "gittensory", targetKey, outcome: "denied", detail: reason, metadata: auditMeta }); + await recordAuditEvent(env, { eventType: "maintainer_recap_notification.discord", actor: "loopover", targetKey, outcome: "denied", detail: reason, metadata: auditMeta }); return { sent: false, reason }; } const description = (formattedBody ?? report.summary.join("\n")).slice(0, 1800); const body = { - username: "Gittensory", + username: "LoopOver", embeds: [ { title: `Maintainer recap · ${report.repos.length} repo(s) · ${report.windowDays}d`, @@ -190,18 +190,18 @@ export async function deliverRecapToDiscord( { name: "Overrides", value: `${report.totals.gateOverrides}`, inline: true }, { name: "Reversals", value: `${report.totals.reversals}`, inline: true }, ], - footer: { text: `Gittensory · generated ${report.generatedAt}` }, + footer: { text: `LoopOver · generated ${report.generatedAt}` }, }, ], }; try { await postWebhook(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), signal: AbortSignal.timeout(10_000) }, "discord"); - await recordAuditEvent(env, { eventType: "maintainer_recap_notification.discord", actor: "gittensory", targetKey, outcome: "completed", detail: "sent", metadata: auditMeta }); + await recordAuditEvent(env, { eventType: "maintainer_recap_notification.discord", actor: "loopover", targetKey, outcome: "completed", detail: "sent", metadata: auditMeta }); return { sent: true }; } catch (error) { const detail = errorMessage(error).slice(0, 160); console.warn(JSON.stringify({ event: "maintainer_recap_discord_failed", message: detail })); - await recordAuditEvent(env, { eventType: "maintainer_recap_notification.discord", actor: "gittensory", targetKey, outcome: "error", detail, metadata: auditMeta }); + await recordAuditEvent(env, { eventType: "maintainer_recap_notification.discord", actor: "loopover", targetKey, outcome: "error", detail, metadata: auditMeta }); return { sent: false, reason: detail }; } } @@ -221,7 +221,7 @@ export async function deliverRecapToSlack( const webhookUrl = envString(env, "SLACK_WEBHOOK_URL"); if (!webhookUrl || !isValidSlackWebhook(webhookUrl)) { const reason = webhookUrl ? "invalid_webhook" : "missing_webhook"; - await recordAuditEvent(env, { eventType: "maintainer_recap_notification.slack", actor: "gittensory", targetKey, outcome: "denied", detail: reason, metadata: auditMeta }); + await recordAuditEvent(env, { eventType: "maintainer_recap_notification.slack", actor: "loopover", targetKey, outcome: "denied", detail: reason, metadata: auditMeta }); return { sent: false, reason }; } const body = { @@ -230,12 +230,12 @@ export async function deliverRecapToSlack( }; try { await postWebhook(webhookUrl, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), signal: AbortSignal.timeout(10_000) }, "slack"); - await recordAuditEvent(env, { eventType: "maintainer_recap_notification.slack", actor: "gittensory", targetKey, outcome: "completed", detail: "sent", metadata: auditMeta }); + await recordAuditEvent(env, { eventType: "maintainer_recap_notification.slack", actor: "loopover", targetKey, outcome: "completed", detail: "sent", metadata: auditMeta }); return { sent: true }; } catch (error) { const detail = errorMessage(error).slice(0, 160); console.warn(JSON.stringify({ event: "maintainer_recap_slack_failed", message: detail })); - await recordAuditEvent(env, { eventType: "maintainer_recap_notification.slack", actor: "gittensory", targetKey, outcome: "error", detail, metadata: auditMeta }); + await recordAuditEvent(env, { eventType: "maintainer_recap_notification.slack", actor: "loopover", targetKey, outcome: "error", detail, metadata: auditMeta }); return { sent: false, reason: detail }; } } diff --git a/src/services/notify-pagerduty.ts b/src/services/notify-pagerduty.ts index 9dfec9c25..7d1ff49b9 100644 --- a/src/services/notify-pagerduty.ts +++ b/src/services/notify-pagerduty.ts @@ -8,7 +8,7 @@ import { errorMessage } from "../utils/json"; // var is declared on the strict Env type (same asymmetry as DISCORD_REPO_WEBHOOKS) — a free-form per-repo // JSON map isn't worth a formal interface field; the global fallbacks are, and are declared in env.d.ts. // -// ALERT FATIGUE: paging is the loudest, most disruptive channel gittensory has — unlike a Discord post or a +// ALERT FATIGUE: paging is the loudest, most disruptive channel loopover has — unlike a Discord post or a // Sentry issue, it can wake someone up. Two independent controls keep it from crying wolf, on top of // PagerDuty's own `dedup_key` coalescing (which prevents duplicate *incidents* but not duplicate *pages* for // a still-open one): @@ -127,7 +127,7 @@ async function auditPagerDutyNotification( ): Promise { await recordAuditEvent(env, { eventType: "external_notification.pagerduty", - actor: "gittensory", + actor: "loopover", targetKey: params.dedupKey, outcome, detail, @@ -175,7 +175,15 @@ export async function triggerPagerDutyIncident( const cooldownMinutes = resolvePagerDutyCooldownMinutes(env, params.repoFullName); const cooldownSinceIso = new Date(Date.now() - cooldownMinutes * 60 * 1000).toISOString(); - const recentPages = await countRecentAuditEventsForActorAndTarget(env, "gittensory", "external_notification.pagerduty", params.dedupKey, cooldownSinceIso); + // Also count the pre-rebrand "gittensory" actor: a page recorded under the OLD actor value just before this + // rebrand deployed must still suppress a duplicate page after it, for as long as the configured cooldown + // window can reach back across the deploy boundary. Querying both actors costs one extra indexed count and + // removes the whole risk category rather than requiring a precisely-timed follow-up cleanup. + const [recentPagesNewActor, recentPagesLegacyActor] = await Promise.all([ + countRecentAuditEventsForActorAndTarget(env, "loopover", "external_notification.pagerduty", params.dedupKey, cooldownSinceIso), + countRecentAuditEventsForActorAndTarget(env, "gittensory", "external_notification.pagerduty", params.dedupKey, cooldownSinceIso), + ]); + const recentPages = recentPagesNewActor + recentPagesLegacyActor; if (recentPages > 0) { await auditPagerDutyNotification(env, { repoFullName: params.repoFullName, dedupKey: params.dedupKey }, "denied", "cooldown_active", { cooldownMinutes }); return; @@ -191,7 +199,7 @@ export async function triggerPagerDutyIncident( dedup_key: params.dedupKey, payload: { summary: params.summary.slice(0, 1024), - source: "gittensory", + source: "loopover", severity: params.severity, timestamp: new Date().toISOString(), component: params.repoFullName, diff --git a/src/services/review-recap.ts b/src/services/review-recap.ts index 4fef950e9..c5de85924 100644 --- a/src/services/review-recap.ts +++ b/src/services/review-recap.ts @@ -144,7 +144,7 @@ export async function sendReviewRecapToDiscord(env: Env, recap: ReviewRecap): Pr if (resolved.status !== "configured") { await recordAuditEvent(env, { eventType: "review_recap_notification.discord", - actor: "gittensory", + actor: "loopover", targetKey: `review-recap:${recap.repoFullName}:${recap.windowDays}`, outcome: "denied", detail: resolved.reason, @@ -153,7 +153,7 @@ export async function sendReviewRecapToDiscord(env: Env, recap: ReviewRecap): Pr return { sent: false, reason: resolved.reason }; } const body = { - username: "Gittensory", + username: "LoopOver", embeds: [ { title: `${recap.repoFullName} · review recap (${recap.windowDays}d)`, @@ -164,7 +164,7 @@ export async function sendReviewRecapToDiscord(env: Env, recap: ReviewRecap): Pr { name: "Closed", value: String(recap.closed), inline: true }, { name: "Still open", value: String(recap.stillOpen), inline: true }, ], - footer: { text: `Gittensory · ${recap.repoFullName}` }, + footer: { text: `LoopOver · ${recap.repoFullName}` }, }, ], }; @@ -178,7 +178,7 @@ export async function sendReviewRecapToDiscord(env: Env, recap: ReviewRecap): Pr if (!response.ok) throw new Error(`discord_webhook_http_${response.status}`); await recordAuditEvent(env, { eventType: "review_recap_notification.discord", - actor: "gittensory", + actor: "loopover", targetKey: `review-recap:${recap.repoFullName}:${recap.windowDays}`, outcome: "completed", detail: "sent", @@ -190,7 +190,7 @@ export async function sendReviewRecapToDiscord(env: Env, recap: ReviewRecap): Pr console.warn(JSON.stringify({ event: "review_recap_discord_failed", repo: recap.repoFullName, message: detail })); await recordAuditEvent(env, { eventType: "review_recap_notification.discord", - actor: "gittensory", + actor: "loopover", targetKey: `review-recap:${recap.repoFullName}:${recap.windowDays}`, outcome: "error", detail, @@ -211,7 +211,7 @@ export async function deliverRecapToSlack(env: Env, recap: ReviewRecap): Promise const reason = typeof webhookUrl === "string" ? "invalid_webhook" : "missing_webhook"; await recordAuditEvent(env, { eventType: "review_recap_notification.slack", - actor: "gittensory", + actor: "loopover", targetKey: `review-recap:${recap.repoFullName}:${recap.windowDays}`, outcome: "denied", detail: reason, @@ -237,7 +237,7 @@ export async function deliverRecapToSlack(env: Env, recap: ReviewRecap): Promise if (!response.ok) throw new Error(`slack_webhook_http_${response.status}`); await recordAuditEvent(env, { eventType: "review_recap_notification.slack", - actor: "gittensory", + actor: "loopover", targetKey: `review-recap:${recap.repoFullName}:${recap.windowDays}`, outcome: "completed", detail: "sent", @@ -249,7 +249,7 @@ export async function deliverRecapToSlack(env: Env, recap: ReviewRecap): Promise console.warn(JSON.stringify({ event: "review_recap_slack_failed", repo: recap.repoFullName, message: detail })); await recordAuditEvent(env, { eventType: "review_recap_notification.slack", - actor: "gittensory", + actor: "loopover", targetKey: `review-recap:${recap.repoFullName}:${recap.windowDays}`, outcome: "error", detail, diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index 4cdd34444..187e7215d 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -107,7 +107,7 @@ describe("api routes", () => { expect(health.status).toBe(200); await expect(health.json()).resolves.toMatchObject({ status: "ok", - service: "gittensory-api", + service: "loopover-api", minMcpVersion: "0.5.0", latestRecommendedMcpVersion: mcpPackageJson.version, }); @@ -117,7 +117,7 @@ describe("api routes", () => { const compatibilityPayload = await compatibility.json(); expect(compatibilityPayload).toMatchObject({ status: "ok", - service: "gittensory-api", + service: "loopover-api", apiVersion: "0.1.0", mcp: { packageName: "@jsonbored/gittensory-mcp", @@ -289,13 +289,13 @@ describe("api routes", () => { expect(svg.headers.get("cache-control")).toContain("stale-while-revalidate"); const svgBody = await svg.text(); expect(svgBody.startsWith(" { it("emits a shields endpoint payload", () => { expect(buildShieldsBadge(quality(), 600)).toEqual({ schemaVersion: 1, - label: "gittensory", + label: "loopover", message: "92% real · merge 30h · queue low", color: "#3fb950", cacheSeconds: 600, @@ -75,7 +75,7 @@ describe("renderBadgeSvg", () => { it("renders a valid SVG carrying the label and message", () => { const svg = renderBadgeSvg(quality()); expect(svg.startsWith(" { + const calls = stubFetch(); + const env = enabledEnv(); + const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000).toISOString(); + await recordAuditEvent(env, { + eventType: "external_notification.pagerduty", + actor: "gittensory", + targetKey: "ops_anomaly:acme/widgets", + outcome: "completed", + detail: "triggered", + metadata: {}, + createdAt: tenMinutesAgo, + }); + await trigger(env); // default cooldown is 60 minutes; the legacy-actor row is only 10 minutes old + expect(calls).toHaveLength(0); + const rows = await pagerDutyAudit(env); + expect(rows[rows.length - 1]).toEqual(expect.objectContaining({ outcome: "denied", detail: "cooldown_active" })); + }); }); describe("triggerPagerDutyIncident — HTTP delivery", () => { @@ -241,7 +260,7 @@ describe("triggerPagerDutyIncident — HTTP delivery", () => { routing_key: VALID_KEY, event_action: "trigger", dedup_key: "ops_anomaly:acme/widgets", - payload: { summary: "review burst on acme/widgets", source: "gittensory", severity: "critical", component: "acme/widgets", custom_details: { anomalies: ["a", "b"] } }, + payload: { summary: "review burst on acme/widgets", source: "loopover", severity: "critical", component: "acme/widgets", custom_details: { anomalies: ["a", "b"] } }, }); expect(await pagerDutyAudit(env)).toEqual([expect.objectContaining({ outcome: "completed", detail: "triggered" })]); }); diff --git a/test/unit/support/mcp-cli-harness.ts b/test/unit/support/mcp-cli-harness.ts index 2e2a3b557..271c7121c 100644 --- a/test/unit/support/mcp-cli-harness.ts +++ b/test/unit/support/mcp-cli-harness.ts @@ -140,7 +140,7 @@ export async function startFixtureServer( response.end( JSON.stringify({ status: "ok", - service: "gittensory-api", + service: "loopover-api", apiVersion: "0.1.0", mcp: { packageName: "@jsonbored/gittensory-mcp", @@ -159,7 +159,7 @@ export async function startFixtureServer( return; } if (request.url === "/health") { - response.end(JSON.stringify({ status: "ok", service: "gittensory-api", ...(options.minMcpVersion ? { minMcpVersion: options.minMcpVersion } : {}) })); + response.end(JSON.stringify({ status: "ok", service: "loopover-api", ...(options.minMcpVersion ? { minMcpVersion: options.minMcpVersion } : {}) })); return; } if (request.url === "/v1/auth/github/session" && request.method === "POST") { diff --git a/test/workers/worker-runtime.test.ts b/test/workers/worker-runtime.test.ts index 214772928..1fa97aaf8 100644 --- a/test/workers/worker-runtime.test.ts +++ b/test/workers/worker-runtime.test.ts @@ -8,7 +8,7 @@ describe("worker runtime", () => { const health = await worker.fetch(new Request("https://gittensory.test/health"), {} as Env, ctx); await waitOnExecutionContext(ctx); expect(health.status).toBe(200); - await expect(health.json()).resolves.toMatchObject({ status: "ok", service: "gittensory-api" }); + await expect(health.json()).resolves.toMatchObject({ status: "ok", service: "loopover-api" }); const openApi = await worker.fetch(new Request("https://gittensory.test/openapi.json"), {} as Env, createExecutionContext()); expect(openApi.status).toBe(200);