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
121 changes: 121 additions & 0 deletions openspec/changes/github-alerts/apply-progress.md

Large diffs are not rendered by default.

8 changes: 5 additions & 3 deletions openspec/changes/github-alerts/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ Chain strategy: stacked-to-main

**PR1 actual size (measured `git diff --stat`, intent-to-add, after apply): 871 authored lines (16 files, 0 deletions) — exceeds the 400-line budget and the ~350 estimate above.** Implementation-only lines (migration, `entities.ts`/`errors.ts`/`ports.ts` additions, `github.ts`, 4 use cases, `migrations.test.ts` table-list fix) total ~356, under budget; the overrun comes entirely from the Strict-TDD RED test files (`test/domain/{github,link-repo-to-topic,unlink-repo,list-repo-links,route-github-event}.test.ts`, 451 lines) plus the three new port fakes in `test/fakes/index.ts` (64 lines). All code is written and every test is green (see apply-progress.md). Flagged for the orchestrator/maintainer to decide before this is committed as PR1: accept as `size:exception`, or split into two chained slices (1a: migration + entities/errors/ports + `github.ts` + `link-repo-to-topic`/`unlink-repo`/`list-repo-links` + their tests + fakes; 1b: `route-github-event.ts` + its test). No commit was made.

**PR2 actual size (measured `wc -l`/`git diff --stat`, after apply): production code (`src/adapters/d1/{github-org-claim-repo,repo-topic-link-repo}.ts`) is 112 lines, well under the 400-line budget and the ~250 estimate. Test code (new `test/adapters/d1/{github-org-claim-repo,repo-topic-link-repo}.test.ts` plus 145 added lines in `test/adapters/migrations.test.ts`) totals 466 lines — the size:exception the user pre-accepted for test overrun. No commit was made.

## Phase 1: Domain Foundation (PR1)

- [x] 1.1 RED: `github.ts` — `parseRepoFullName` (lowercase, `owner/repo` shape), `formatGithubAlert` truncation at 4096 (spec: Message Truncated).
Expand All @@ -42,8 +44,8 @@ Chain strategy: stacked-to-main

## Phase 2: D1 Adapters (PR2)

- [ ] 2.1 RED: migration test — table/FK/UNIQUE/CHECK enforcement, cross-team isolation on links.
- [ ] 2.2 GREEN: `src/adapters/d1/github-org-claim-repo.ts`, `repo-topic-link-repo.ts` (upsert `ON CONFLICT DO UPDATE thread_id`).
- [x] 2.1 RED: migration test — table/FK/UNIQUE/CHECK enforcement, cross-team isolation on links.
- [x] 2.2 GREEN: `src/adapters/d1/github-org-claim-repo.ts`, `repo-topic-link-repo.ts` (upsert `ON CONFLICT DO UPDATE thread_id`).

## Phase 3: Signature and Route Skeleton (PR3)

Expand Down Expand Up @@ -71,4 +73,4 @@ Chain strategy: stacked-to-main

- [ ] 6.1 (after PR3 merges) `npx wrangler secret put GITHUB_WEBHOOK_SECRET`.
- [ ] 6.2 (after PR4 merges) Configure org webhook: content type `application/json`, same secret, Pull requests + Issues events; verify ping returns 200.
- [ ] 6.3 (after PR1 merges, before PR5's `/linkrepo` is used) Claim the org via `wrangler d1 execute` insert into `github_org_claims`.
- [x] 6.3 (after PR1 merges, before PR5's `/linkrepo` is used) Claim the org via `wrangler d1 execute` insert into `github_org_claims`.
32 changes: 32 additions & 0 deletions src/adapters/d1/github-org-claim-repo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { asTeamId } from "../../domain/ids";
import type { TeamId } from "../../domain/ids";
import type { GithubOrgClaimRepo } from "../../domain/ports";

interface ClaimRow {
team_id: string;
}

export function createD1GithubOrgClaimRepo(db: D1Database): GithubOrgClaimRepo {
return {
async findTeamByOrg(orgLogin: string): Promise<TeamId | null> {
// Claims are stored lowercase (CHECK constraint, migrations/0002).
// GitHub sends the org's display case in webhook payloads, so the
// lookup normalizes the input to match.
const row = await db
.prepare("SELECT team_id FROM github_org_claims WHERE org_login = ?")
.bind(orgLogin.toLowerCase())
.first<ClaimRow>();
return row ? asTeamId(row.team_id) : null;
},

async isClaimedBy(teamId: TeamId, orgLogin: string): Promise<boolean> {
const row = await db
.prepare(
"SELECT 1 FROM github_org_claims WHERE team_id = ? AND org_login = ?",
)
.bind(teamId, orgLogin.toLowerCase())
.first();
return row !== null;
},
};
}
91 changes: 91 additions & 0 deletions src/adapters/d1/repo-topic-link-repo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import type { RepoTopicLink } from "../../domain/entities";
import { TenantMismatchError } from "../../domain/errors";
import type { RepoFullName } from "../../domain/github";
import { asTeamId } from "../../domain/ids";
import type { TeamId } from "../../domain/ids";
import type { RepoTopicLinkRepo } from "../../domain/ports";

interface LinkRow {
team_id: string;
repo_full_name: string;
org_login: string;
thread_id: number;
created_at: number;
updated_at: number;
}

function rowToLink(row: LinkRow): RepoTopicLink {
return {
teamId: asTeamId(row.team_id),
repoFullName: row.repo_full_name as RepoFullName,
orgLogin: row.org_login,
threadId: row.thread_id,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}

export function createD1RepoTopicLinkRepo(db: D1Database): RepoTopicLinkRepo {
return {
async get(teamId: TeamId, repo: RepoFullName): Promise<RepoTopicLink | null> {
const row = await db
.prepare(
"SELECT * FROM repo_topic_links WHERE team_id = ? AND repo_full_name = ?",
)
.bind(teamId, repo)
.first<LinkRow>();
return row ? rowToLink(row) : null;
},

async upsert(teamId: TeamId, link: RepoTopicLink): Promise<void> {
// The explicit `teamId` argument is authoritative (ports.ts "Tenancy":
// every tenant-scoped method takes TeamId first) — a caller passing a
// `link.teamId` that disagrees with it is a bug, and MUST NOT silently
// write under the mismatched team (RISK-001/REL-002/READ-001).
if (teamId !== link.teamId) {
throw new TenantMismatchError(
`RepoTopicLinkRepo.upsert: teamId argument ("${teamId}") does not match link.teamId ("${link.teamId}")`,
);
}

// Re-linking an already-linked repo moves it instead of erroring on
// the (team_id, repo_full_name) PK conflict (design.md "One Topic Per
// Repo, Re-Link Moves It" — ports.ts RepoTopicLinkRepo.upsert).
await db
.prepare(
`INSERT INTO repo_topic_links
(team_id, repo_full_name, org_login, thread_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT (team_id, repo_full_name)
DO UPDATE SET thread_id = excluded.thread_id, updated_at = excluded.updated_at`,
)
.bind(
teamId,
link.repoFullName,
link.orgLogin,
link.threadId,
link.createdAt,
link.updatedAt,
)
.run();
},

async remove(teamId: TeamId, repo: RepoFullName): Promise<boolean> {
const result = await db
.prepare(
"DELETE FROM repo_topic_links WHERE team_id = ? AND repo_full_name = ?",
)
.bind(teamId, repo)
.run();
return result.meta.changes === 1;
},

async list(teamId: TeamId): Promise<RepoTopicLink[]> {
const rows = await db
.prepare("SELECT * FROM repo_topic_links WHERE team_id = ?")
.bind(teamId)
.all<LinkRow>();
return rows.results.map(rowToLink);
},
};
}
6 changes: 6 additions & 0 deletions src/domain/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,9 @@ export class OrgNotClaimedError extends DomainError {}
// "send-failed" outcome instead of letting it propagate (design.md
// "GitHub route status policy").
export class AlertSendFailedError extends DomainError {}
// Thrown when a tenant-scoped write's explicit `teamId` argument disagrees
// with the `teamId` embedded in the entity being written (e.g.
// RepoTopicLinkRepo.upsert). The explicit argument is always authoritative
// (design.md/ports.ts "Tenancy": every tenant-scoped method takes TeamId
// first) — this is a caller bug, never a silent cross-tenant write.
export class TenantMismatchError extends DomainError {}
134 changes: 134 additions & 0 deletions test/adapters/d1/github-org-claim-repo.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { env } from "cloudflare:test";
import { describe, expect, it } from "vitest";
import { createD1GithubOrgClaimRepo } from "../../../src/adapters/d1/github-org-claim-repo";
import { asTeamId } from "../../../src/domain/ids";

// RES-001: proves a D1 failure propagates (rejects) rather than being
// swallowed. No adapter method here has a try/catch, so this is really
// characterizing "await on a rejecting D1 call rejects the caller" — but it
// is worth pinning down explicitly, since a future refactor adding
// try/catch (e.g. for constraint-error translation, as team-repo.ts does)
// could accidentally swallow this. The stub only implements `prepare`,
// which is all these two methods call.
function failingDb(message = "D1_ERROR: simulated D1 outage"): D1Database {
const err = new Error(message);
const statement = {
bind: () => statement,
first: async () => {
throw err;
},
run: async () => {
throw err;
},
all: async () => {
throw err;
},
};
return { prepare: () => statement } as unknown as D1Database;
}

async function seedTeam(teamId: string, chatId: number) {
await env.DB.prepare(
"INSERT INTO teams (id, telegram_chat_id, created_at) VALUES (?, ?, ?)",
)
.bind(teamId, chatId, 0)
.run();
}

async function seedClaim(orgLogin: string, teamId: string) {
await env.DB.prepare(
"INSERT INTO github_org_claims (org_login, team_id, created_at) VALUES (?, ?, ?)",
)
.bind(orgLogin, teamId, 0)
.run();
}

describe("createD1GithubOrgClaimRepo", () => {
it("findTeamByOrg returns null when no claim exists for the org", async () => {
const repo = createD1GithubOrgClaimRepo(env.DB);

const result = await repo.findTeamByOrg("no-such-org");

expect(result).toBeNull();
});

it("findTeamByOrg returns the claiming team's id when a claim exists", async () => {
await seedTeam("team-org-1", 701);
await seedClaim("acme-corp", "team-org-1");
const repo = createD1GithubOrgClaimRepo(env.DB);

const result = await repo.findTeamByOrg("acme-corp");

expect(result).toBe(asTeamId("team-org-1"));
});

it("findTeamByOrg matches regardless of the input's case (claims are stored lowercase, GitHub sends the org's display case)", async () => {
await seedTeam("team-org-2", 702);
await seedClaim("mixed-org", "team-org-2");
const repo = createD1GithubOrgClaimRepo(env.DB);

const result = await repo.findTeamByOrg("Mixed-Org");

expect(result).toBe(asTeamId("team-org-2"));
});

it("isClaimedBy returns true when the team has a claim for the org", async () => {
await seedTeam("team-claimed-1", 703);
await seedClaim("claimed-org-1", "team-claimed-1");
const repo = createD1GithubOrgClaimRepo(env.DB);

const result = await repo.isClaimedBy(
asTeamId("team-claimed-1"),
"claimed-org-1",
);

expect(result).toBe(true);
});

it("isClaimedBy returns false for a team that did not claim the org, even when another team did (tenant isolation)", async () => {
await seedTeam("team-claimed-owner", 704);
await seedTeam("team-claimed-other", 705);
await seedClaim("claimed-org-2", "team-claimed-owner");
const repo = createD1GithubOrgClaimRepo(env.DB);

const result = await repo.isClaimedBy(
asTeamId("team-claimed-other"),
"claimed-org-2",
);

expect(result).toBe(false);
});

it("isClaimedBy matches regardless of the input's case, exactly like findTeamByOrg (REL-001)", async () => {
await seedTeam("team-claimed-case", 707);
await seedClaim("case-org", "team-claimed-case");
const repo = createD1GithubOrgClaimRepo(env.DB);

const result = await repo.isClaimedBy(
asTeamId("team-claimed-case"),
"Case-Org",
);

expect(result).toBe(true);
});

it("isClaimedBy returns false when no claim exists at all", async () => {
await seedTeam("team-claimed-none", 706);
const repo = createD1GithubOrgClaimRepo(env.DB);

const result = await repo.isClaimedBy(
asTeamId("team-claimed-none"),
"never-claimed-org",
);

expect(result).toBe(false);
});

it("findTeamByOrg propagates (rejects) when the D1 query fails, instead of swallowing the error (RES-001)", async () => {
const repo = createD1GithubOrgClaimRepo(failingDb());

await expect(repo.findTeamByOrg("any-org")).rejects.toThrow(
"D1_ERROR: simulated D1 outage",
);
});
});
Loading
Loading