Skip to content

Commit 5d2e610

Browse files
cojiclaude
andcommitted
feat(batch): backfill-installation-membership CLI for PR 7 deploy prep (#283 PR 6/7)
One-shot data migration that runs after PR 1's schema deploy and before PR 7 flips repository lookups to strict mode. Assigns github_installation_id and seeds repository_installation_memberships for organizations whose GitHub App mode has exactly one active installation. batch/commands/backfill-installation-membership.ts: - Decision rules per org: - integrations.method = 'token' → skip (PAT mode never uses installation ids) - 0 active GitHub App links → skip + warn (org needs reinstall before PR 7) - exactly 1 active link → assign every NULL repo to that installation - 2+ active links → skip and list (operator must use reassign-broken-repositories) - --dry-run flag for safe inspection - Best-effort GitHub API call to seed memberships from installation_repositories; failures fall back to crawl-time membership repair - Cross-store rule (tenant first / shared second) maintained - Idempotent: WHERE github_installation_id IS NULL filter batch/cli.ts: - registered backfill-installation-membership command tests (backfill-installation-membership.test.ts): - single active link → backfills repos and memberships - token method → skipped, no writes - multi active link → not backfilled - dry-run does not write - idempotent: re-running on already-backfilled rows is a no-op Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 0e2f6f5 commit 5d2e610

3 files changed

Lines changed: 594 additions & 0 deletions

File tree

batch/cli.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,33 @@ const report = command(
145145
},
146146
)
147147

148+
const backfillInstallationMembership = command(
149+
{
150+
name: 'backfill-installation-membership',
151+
parameters: ['[organization id]'],
152+
flags: {
153+
dryRun: {
154+
type: Boolean,
155+
description:
156+
'Print the planned changes without writing to the database',
157+
default: false,
158+
},
159+
},
160+
help: {
161+
description:
162+
'One-shot migration: assign github_installation_id to repositories and seed memberships for orgs whose GitHub App method has exactly one active installation. Run before deploying PR 7 strict lookup.',
163+
},
164+
},
165+
async (argv) => {
166+
const { backfillInstallationMembershipCommand } =
167+
await import('./commands/backfill-installation-membership')
168+
await backfillInstallationMembershipCommand({
169+
organizationId: argv._.organizationId,
170+
dryRun: argv.flags.dryRun,
171+
})
172+
},
173+
)
174+
148175
const reassignBrokenRepositories = command(
149176
{
150177
name: 'reassign-broken-repositories',
@@ -184,6 +211,7 @@ cli({
184211
classify,
185212
backfill,
186213
report,
214+
backfillInstallationMembership,
187215
reassignBrokenRepositories,
188216
],
189217
})
Lines changed: 343 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,343 @@
1+
import SQLite from 'better-sqlite3'
2+
import { mkdirSync, writeFileSync } from 'node:fs'
3+
import { tmpdir } from 'node:os'
4+
import path from 'node:path'
5+
import {
6+
afterAll,
7+
afterEach,
8+
beforeAll,
9+
beforeEach,
10+
describe,
11+
expect,
12+
test,
13+
vi,
14+
} from 'vitest'
15+
import { closeDb, db } from '~/app/services/db.server'
16+
import { closeAllTenantDbs, getTenantDb } from '~/app/services/tenant-db.server'
17+
import type { OrganizationId } from '~/app/types/organization'
18+
19+
type ApiRepo = { owner: string; name: string }
20+
const fetchInstallationRepositoriesMock = vi.fn<() => Promise<ApiRepo[]>>(
21+
async () => [],
22+
)
23+
vi.mock('~/app/services/github-installation-repos.server', () => ({
24+
fetchInstallationRepositories: () => fetchInstallationRepositoriesMock(),
25+
}))
26+
27+
const testDir = path.join(tmpdir(), `backfill-membership-${Date.now()}`)
28+
mkdirSync(testDir, { recursive: true })
29+
const sharedDbPath = path.join(testDir, 'data.db')
30+
writeFileSync(sharedDbPath, '')
31+
32+
const setupSharedDb = () => {
33+
const sql = new SQLite(sharedDbPath)
34+
sql.exec(`
35+
CREATE TABLE organizations (
36+
id text NOT NULL PRIMARY KEY,
37+
name text NOT NULL,
38+
slug text NOT NULL
39+
);
40+
CREATE TABLE integrations (
41+
id text NOT NULL PRIMARY KEY,
42+
organization_id text NOT NULL,
43+
provider text NOT NULL DEFAULT 'github',
44+
method text NOT NULL DEFAULT 'token',
45+
private_token text,
46+
app_suspended_at text,
47+
created_at text NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
48+
updated_at text NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
49+
);
50+
CREATE TABLE github_app_links (
51+
organization_id text NOT NULL,
52+
installation_id integer NOT NULL,
53+
github_account_id integer NOT NULL,
54+
github_account_type text,
55+
github_org text NOT NULL,
56+
app_repository_selection text NOT NULL DEFAULT 'all',
57+
suspended_at text,
58+
membership_initialized_at text,
59+
deleted_at text,
60+
created_at text NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
61+
updated_at text NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
62+
PRIMARY KEY (organization_id, installation_id)
63+
);
64+
CREATE TABLE github_app_link_events (
65+
id integer NOT NULL PRIMARY KEY AUTOINCREMENT,
66+
organization_id text NOT NULL,
67+
installation_id integer NOT NULL,
68+
event_type text NOT NULL,
69+
source text NOT NULL,
70+
status text NOT NULL,
71+
details_json text,
72+
created_at text NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
73+
);
74+
`)
75+
sql.close()
76+
}
77+
setupSharedDb()
78+
79+
const ensureTenantDbFile = (orgId: OrganizationId) => {
80+
const tenantDbPath = path.join(testDir, `tenant_${orgId}.db`)
81+
writeFileSync(tenantDbPath, '')
82+
const sql = new SQLite(tenantDbPath)
83+
sql.exec(`
84+
CREATE TABLE repositories (
85+
id text NOT NULL PRIMARY KEY,
86+
integration_id text NOT NULL,
87+
provider text NOT NULL,
88+
owner text NOT NULL,
89+
repo text NOT NULL,
90+
github_installation_id integer,
91+
release_detection_method text NOT NULL DEFAULT 'branch',
92+
release_detection_key text NOT NULL DEFAULT 'production',
93+
updated_at text NOT NULL,
94+
created_at text NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
95+
team_id text,
96+
scan_watermark text
97+
);
98+
CREATE TABLE repository_installation_memberships (
99+
repository_id text NOT NULL,
100+
installation_id integer NOT NULL,
101+
created_at text NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
102+
updated_at text NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
103+
deleted_at text,
104+
PRIMARY KEY (repository_id, installation_id)
105+
);
106+
`)
107+
sql.close()
108+
}
109+
110+
vi.stubEnv('UPFLOW_DATA_DIR', testDir)
111+
112+
const SINGLE_ORG = 'org-single' as OrganizationId
113+
const MULTI_ORG = 'org-multi' as OrganizationId
114+
const TOKEN_ORG = 'org-token' as OrganizationId
115+
ensureTenantDbFile(SINGLE_ORG)
116+
ensureTenantDbFile(MULTI_ORG)
117+
ensureTenantDbFile(TOKEN_ORG)
118+
119+
const insertOrg = async (id: string, method: 'token' | 'github_app') => {
120+
await db
121+
.insertInto('organizations')
122+
.values({ id, name: id, slug: id })
123+
.execute()
124+
await db
125+
.insertInto('integrations')
126+
.values({
127+
id: `int-${id}`,
128+
organizationId: id,
129+
provider: 'github',
130+
method,
131+
privateToken: null,
132+
appSuspendedAt: null,
133+
})
134+
.execute()
135+
}
136+
137+
const insertLink = async (orgId: string, installationId: number) => {
138+
await db
139+
.insertInto('githubAppLinks')
140+
.values({
141+
organizationId: orgId,
142+
installationId,
143+
githubAccountId: installationId,
144+
githubAccountType: 'Organization',
145+
githubOrg: `org-${installationId}`,
146+
appRepositorySelection: 'all',
147+
suspendedAt: null,
148+
membershipInitializedAt: '2026-04-07T00:00:00Z',
149+
deletedAt: null,
150+
})
151+
.execute()
152+
}
153+
154+
const insertRepo = async (orgId: OrganizationId, id: string) => {
155+
const tenantDb = getTenantDb(orgId)
156+
await tenantDb
157+
.insertInto('repositories')
158+
.values({
159+
id,
160+
integrationId: `int-${orgId}`,
161+
provider: 'github',
162+
owner: 'octo',
163+
repo: id,
164+
githubInstallationId: null,
165+
updatedAt: '2026-04-07T00:00:00Z',
166+
})
167+
.execute()
168+
}
169+
170+
describe('backfillInstallationMembershipCommand', () => {
171+
beforeAll(async () => {
172+
await db.selectFrom('organizations').select('id').execute()
173+
})
174+
175+
afterAll(async () => {
176+
await closeAllTenantDbs()
177+
await closeDb()
178+
})
179+
180+
beforeEach(async () => {
181+
await db.deleteFrom('githubAppLinkEvents').execute()
182+
await db.deleteFrom('githubAppLinks').execute()
183+
await db.deleteFrom('integrations').execute()
184+
await db.deleteFrom('organizations').execute()
185+
for (const id of [SINGLE_ORG, MULTI_ORG, TOKEN_ORG]) {
186+
const tenantDb = getTenantDb(id)
187+
await tenantDb.deleteFrom('repositoryInstallationMemberships').execute()
188+
await tenantDb.deleteFrom('repositories').execute()
189+
}
190+
})
191+
192+
afterEach(() => {
193+
vi.clearAllMocks()
194+
})
195+
196+
test('single active link → backfills repos and memberships', async () => {
197+
await insertOrg(SINGLE_ORG, 'github_app')
198+
await insertLink(SINGLE_ORG, 100)
199+
await insertRepo(SINGLE_ORG, 'repo-1')
200+
await insertRepo(SINGLE_ORG, 'repo-2')
201+
fetchInstallationRepositoriesMock.mockResolvedValueOnce([
202+
{ owner: 'octo', name: 'repo-1' },
203+
{ owner: 'octo', name: 'repo-2' },
204+
])
205+
206+
const { backfillInstallationMembershipCommand } =
207+
await import('./backfill-installation-membership')
208+
await backfillInstallationMembershipCommand({
209+
organizationId: SINGLE_ORG,
210+
})
211+
212+
const tenantDb = getTenantDb(SINGLE_ORG)
213+
const repos = await tenantDb
214+
.selectFrom('repositories')
215+
.select(['id', 'githubInstallationId'])
216+
.execute()
217+
expect(repos.every((r) => r.githubInstallationId === 100)).toBe(true)
218+
219+
const memberships = await tenantDb
220+
.selectFrom('repositoryInstallationMemberships')
221+
.select(['repositoryId', 'installationId'])
222+
.execute()
223+
expect(memberships).toHaveLength(2)
224+
expect(memberships.every((m) => m.installationId === 100)).toBe(true)
225+
})
226+
227+
test('GitHub API failure falls back to orphan-only membership upsert', async () => {
228+
await insertOrg(SINGLE_ORG, 'github_app')
229+
await insertLink(SINGLE_ORG, 100)
230+
await insertRepo(SINGLE_ORG, 'repo-fallback')
231+
fetchInstallationRepositoriesMock.mockRejectedValueOnce(
232+
new Error('GitHub API down'),
233+
)
234+
235+
const { backfillInstallationMembershipCommand } =
236+
await import('./backfill-installation-membership')
237+
await backfillInstallationMembershipCommand({
238+
organizationId: SINGLE_ORG,
239+
})
240+
241+
const tenantDb = getTenantDb(SINGLE_ORG)
242+
const repos = await tenantDb
243+
.selectFrom('repositories')
244+
.select('githubInstallationId')
245+
.execute()
246+
expect(repos.every((r) => r.githubInstallationId === 100)).toBe(true)
247+
248+
const memberships = await tenantDb
249+
.selectFrom('repositoryInstallationMemberships')
250+
.select(['repositoryId', 'installationId'])
251+
.execute()
252+
expect(memberships).toHaveLength(1)
253+
expect(memberships[0].installationId).toBe(100)
254+
})
255+
256+
test('token method → skipped, no writes', async () => {
257+
await insertOrg(TOKEN_ORG, 'token')
258+
await insertRepo(TOKEN_ORG, 'repo-x')
259+
260+
const { backfillInstallationMembershipCommand } =
261+
await import('./backfill-installation-membership')
262+
await backfillInstallationMembershipCommand({
263+
organizationId: TOKEN_ORG,
264+
})
265+
266+
const tenantDb = getTenantDb(TOKEN_ORG)
267+
const repo = await tenantDb
268+
.selectFrom('repositories')
269+
.select('githubInstallationId')
270+
.where('id', '=', 'repo-x')
271+
.executeTakeFirstOrThrow()
272+
expect(repo.githubInstallationId).toBeNull()
273+
})
274+
275+
test('multi active link → not backfilled, repo stays NULL', async () => {
276+
await insertOrg(MULTI_ORG, 'github_app')
277+
await insertLink(MULTI_ORG, 200)
278+
await insertLink(MULTI_ORG, 201)
279+
await insertRepo(MULTI_ORG, 'repo-y')
280+
281+
const { backfillInstallationMembershipCommand } =
282+
await import('./backfill-installation-membership')
283+
await backfillInstallationMembershipCommand({
284+
organizationId: MULTI_ORG,
285+
})
286+
287+
const tenantDb = getTenantDb(MULTI_ORG)
288+
const repo = await tenantDb
289+
.selectFrom('repositories')
290+
.select('githubInstallationId')
291+
.where('id', '=', 'repo-y')
292+
.executeTakeFirstOrThrow()
293+
expect(repo.githubInstallationId).toBeNull()
294+
})
295+
296+
test('dry-run does not write or call GitHub API', async () => {
297+
await insertOrg(SINGLE_ORG, 'github_app')
298+
await insertLink(SINGLE_ORG, 300)
299+
await insertRepo(SINGLE_ORG, 'repo-dry')
300+
fetchInstallationRepositoriesMock.mockClear()
301+
302+
const { backfillInstallationMembershipCommand } =
303+
await import('./backfill-installation-membership')
304+
await backfillInstallationMembershipCommand({
305+
organizationId: SINGLE_ORG,
306+
dryRun: true,
307+
})
308+
309+
const tenantDb = getTenantDb(SINGLE_ORG)
310+
const repo = await tenantDb
311+
.selectFrom('repositories')
312+
.select('githubInstallationId')
313+
.where('id', '=', 'repo-dry')
314+
.executeTakeFirstOrThrow()
315+
expect(repo.githubInstallationId).toBeNull()
316+
expect(fetchInstallationRepositoriesMock).not.toHaveBeenCalled()
317+
})
318+
319+
test('idempotent: re-running on already-backfilled rows is a no-op', async () => {
320+
await insertOrg(SINGLE_ORG, 'github_app')
321+
await insertLink(SINGLE_ORG, 100)
322+
await insertRepo(SINGLE_ORG, 'repo-1')
323+
fetchInstallationRepositoriesMock.mockResolvedValue([
324+
{ owner: 'octo', name: 'repo-1' },
325+
])
326+
327+
const { backfillInstallationMembershipCommand } =
328+
await import('./backfill-installation-membership')
329+
await backfillInstallationMembershipCommand({
330+
organizationId: SINGLE_ORG,
331+
})
332+
await backfillInstallationMembershipCommand({
333+
organizationId: SINGLE_ORG,
334+
})
335+
336+
const tenantDb = getTenantDb(SINGLE_ORG)
337+
const memberships = await tenantDb
338+
.selectFrom('repositoryInstallationMemberships')
339+
.select(['repositoryId', 'installationId'])
340+
.execute()
341+
expect(memberships).toHaveLength(1)
342+
})
343+
})

0 commit comments

Comments
 (0)