Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/gittensory-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
"service": {
"type": "string",
"enum": [
"gittensory-api"
"loopover-api"
]
},
"time": {
Expand Down Expand Up @@ -52,7 +52,7 @@
"service": {
"type": "string",
"enum": [
"gittensory-api"
"loopover-api"
]
},
"apiVersion": {
Expand Down
2 changes: 1 addition & 1 deletion src/api/badge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<QueueHealthLevel, string> = {
low: "#3fb950",
Expand Down
6 changes: 3 additions & 3 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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));
Expand Down
4 changes: 2 additions & 2 deletions src/openapi/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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(),
Expand Down
4 changes: 2 additions & 2 deletions src/services/mcp-compatibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export type BreakingChangeNotice = {

export type McpCompatibilityMetadata = {
status: "ok";
service: "gittensory-api";
service: "loopover-api";
apiVersion: string;
mcp: {
packageName: string;
Expand All @@ -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,
Expand Down
22 changes: 11 additions & 11 deletions src/services/notify-discord.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ async function auditExternalNotification(
): Promise<void> {
await recordAuditEvent(env, {
eventType: `external_notification.${provider}`,
actor: "gittensory",
actor: "loopover",
targetKey: `${params.repoFullName}#${params.pullNumber}`,
outcome,
detail,
Expand All @@ -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}`,
Expand All @@ -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}` },
},
],
};
Expand Down Expand Up @@ -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`,
Expand All @@ -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 };
}
}
Expand All @@ -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 = {
Expand All @@ -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 };
}
}
Expand Down
16 changes: 12 additions & 4 deletions src/services/notify-pagerduty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -127,7 +127,7 @@ async function auditPagerDutyNotification(
): Promise<void> {
await recordAuditEvent(env, {
eventType: "external_notification.pagerduty",
actor: "gittensory",
actor: "loopover",
targetKey: params.dedupKey,
outcome,
detail,
Expand Down Expand Up @@ -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;
Expand All @@ -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,
Expand Down
16 changes: 8 additions & 8 deletions src/services/review-recap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)`,
Expand All @@ -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}` },
},
],
};
Expand All @@ -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",
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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",
Expand All @@ -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,
Expand Down
8 changes: 4 additions & 4 deletions test/integration/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand All @@ -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",
Expand Down Expand Up @@ -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("<svg")).toBe(true);
expect(svgBody).toContain("gittensory");
expect(svgBody).toContain("loopover");
expect(svgBody).toContain("% real");
expect(svgBody).not.toMatch(FORBIDDEN_PUBLIC_REPORT_TERMS);

const json = await app.request("/v1/public/repos/acme/badged/badge.json", {}, env);
expect(json.status).toBe(200);
await expect(json.json()).resolves.toMatchObject({ schemaVersion: 1, label: "gittensory", message: expect.stringContaining("real") });
await expect(json.json()).resolves.toMatchObject({ schemaVersion: 1, label: "loopover", message: expect.stringContaining("real") });

// Installed but NOT opted in → unavailable, no metrics.
await upsertRepositoryFromGitHub(env, { name: "not-opted-in", full_name: "acme/not-opted-in", private: false, owner: { login: "acme" }, default_branch: "main" }, 556);
Expand Down
4 changes: 2 additions & 2 deletions test/unit/badge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ describe("buildShieldsBadge", () => {
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,
Expand All @@ -75,7 +75,7 @@ describe("renderBadgeSvg", () => {
it("renders a valid SVG carrying the label and message", () => {
const svg = renderBadgeSvg(quality());
expect(svg.startsWith("<svg")).toBe(true);
expect(svg).toContain("gittensory");
expect(svg).toContain("loopover");
expect(svg).toContain("92% real");
expect(svg).toContain('role="img"');
expect(svg).not.toMatch(/wallet|hotkey|trust|reward|login/i);
Expand Down
23 changes: 21 additions & 2 deletions test/unit/notify-pagerduty.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ describe("triggerPagerDutyIncident — cooldown gate (alert fatigue control #2)"
const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString();
await recordAuditEvent(env, {
eventType: "external_notification.pagerduty",
actor: "gittensory",
actor: "loopover",
targetKey: "ops_anomaly:acme/widgets",
outcome: "completed",
detail: "triggered",
Expand All @@ -228,6 +228,25 @@ describe("triggerPagerDutyIncident — cooldown gate (alert fatigue control #2)"
await trigger(env); // default cooldown is 60 minutes; the seeded row is 2 hours old
expect(calls).toHaveLength(1);
});

it("REGRESSION: a recent page recorded under the pre-rebrand 'gittensory' actor still suppresses a duplicate within the cooldown window", async () => {
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", () => {
Expand All @@ -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" })]);
});
Expand Down
Loading
Loading