patchrelay 0.26.0 → 0.29.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +83 -31
  2. package/dist/agent-session-plan.js +0 -7
  3. package/dist/build-info.json +3 -3
  4. package/dist/cli/args.js +22 -18
  5. package/dist/cli/commands/feed.js +1 -1
  6. package/dist/cli/commands/issues.js +44 -4
  7. package/dist/cli/commands/linear.js +67 -0
  8. package/dist/cli/commands/repo.js +213 -0
  9. package/dist/cli/commands/setup.js +140 -21
  10. package/dist/cli/connect-flow.js +5 -3
  11. package/dist/cli/formatters/text.js +1 -1
  12. package/dist/cli/help.js +134 -63
  13. package/dist/cli/index.js +166 -188
  14. package/dist/cli/interactive.js +25 -0
  15. package/dist/cli/operator-client.js +11 -0
  16. package/dist/cli/service-commands.js +11 -4
  17. package/dist/cli/watch/App.js +1 -1
  18. package/dist/cli/watch/FactoryStateGraph.js +31 -0
  19. package/dist/cli/watch/FeedView.js +3 -2
  20. package/dist/cli/watch/FreshnessBadge.js +13 -0
  21. package/dist/cli/watch/IssueDetailView.js +9 -2
  22. package/dist/cli/watch/IssueListView.js +2 -2
  23. package/dist/cli/watch/IssueRow.js +9 -11
  24. package/dist/cli/watch/QueueObservationView.js +15 -0
  25. package/dist/cli/watch/StateHistoryView.js +0 -1
  26. package/dist/cli/watch/StatusBar.js +5 -2
  27. package/dist/cli/watch/format-utils.js +7 -0
  28. package/dist/cli/watch/freshness.js +30 -0
  29. package/dist/cli/watch/state-visualization.js +147 -0
  30. package/dist/cli/watch/theme.js +6 -7
  31. package/dist/cli/watch/use-watch-stream.js +5 -2
  32. package/dist/cli/watch/watch-state.js +9 -5
  33. package/dist/config.js +129 -36
  34. package/dist/db/linear-installation-store.js +23 -0
  35. package/dist/db/migrations.js +42 -0
  36. package/dist/db/repository-link-store.js +103 -0
  37. package/dist/db.js +61 -11
  38. package/dist/factory-state.js +1 -5
  39. package/dist/github-webhook-handler.js +115 -46
  40. package/dist/github-webhooks.js +4 -0
  41. package/dist/http.js +162 -0
  42. package/dist/install.js +93 -13
  43. package/dist/issue-query-service.js +34 -1
  44. package/dist/linear-client.js +80 -25
  45. package/dist/merge-queue-incident.js +104 -0
  46. package/dist/merge-queue-protocol.js +54 -0
  47. package/dist/preflight.js +28 -1
  48. package/dist/repository-linking.js +42 -0
  49. package/dist/run-orchestrator.js +197 -21
  50. package/dist/runtime-paths.js +0 -8
  51. package/dist/service.js +94 -49
  52. package/package.json +8 -7
  53. package/dist/cli/commands/connect.js +0 -54
  54. package/dist/cli/commands/project.js +0 -146
  55. package/dist/merge-queue.js +0 -200
  56. package/infra/patchrelay-reload.service +0 -6
  57. package/infra/patchrelay.path +0 -13
package/dist/config.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { isIP } from "node:net";
3
+ import { homedir } from "node:os";
3
4
  import path from "node:path";
4
5
  import { z } from "zod";
5
6
  import { getDefaultConfigPath, getDefaultDatabasePath, getDefaultLogPath, getDefaultRuntimeEnvPath, getDefaultServiceEnvPath, getPatchRelayDataDir, } from "./runtime-paths.js";
@@ -40,6 +41,24 @@ const projectSchema = z.object({
40
41
  base_branch: z.string().min(1).optional(),
41
42
  }).optional(),
42
43
  });
44
+ const repositorySchema = z.object({
45
+ github_repo: z.string().min(1),
46
+ local_path: z.string().min(1).optional(),
47
+ workspace: z.string().min(1).optional(),
48
+ linear_team_ids: z.array(z.string().min(1)).default([]),
49
+ linear_project_ids: z.array(z.string().min(1)).default([]),
50
+ issue_key_prefixes: z.array(z.string().min(1)).default([]),
51
+ review_checks: z.array(z.string().min(1)).default([]),
52
+ gate_checks: z.array(z.string().min(1)).default([]),
53
+ trigger_events: z.array(z.string().min(1)).min(1).optional(),
54
+ branch_prefix: z.string().min(1).optional(),
55
+ github: z.object({
56
+ webhook_secret: z.string().min(1).optional(),
57
+ base_branch: z.string().min(1).optional(),
58
+ merge_queue_label: z.string().min(1).optional(),
59
+ merge_queue_check_name: z.string().min(1).optional(),
60
+ }).optional(),
61
+ });
43
62
  const configSchema = z.object({
44
63
  server: z.object({
45
64
  bind: z.string().default("127.0.0.1"),
@@ -102,7 +121,11 @@ const configSchema = z.object({
102
121
  experimental_raw_events: z.boolean().default(true),
103
122
  }),
104
123
  }),
124
+ repos: z.object({
125
+ root: z.string().min(1).default(path.join(homedir(), "projects")),
126
+ }).default(() => ({ root: path.join(homedir(), "projects") })),
105
127
  projects: z.array(projectSchema).default([]),
128
+ repositories: z.array(repositorySchema).default([]),
106
129
  });
107
130
  function defaultTriggerEvents(actor) {
108
131
  if (actor === "app") {
@@ -141,7 +164,9 @@ function withSectionDefaults(input) {
141
164
  logging: {},
142
165
  database: {},
143
166
  operator_api: {},
167
+ repos: {},
144
168
  projects: [],
169
+ repositories: [],
145
170
  ...rest,
146
171
  linear: {
147
172
  ...linear,
@@ -250,6 +275,13 @@ function readRepoSettings(repoPath, env) {
250
275
  function defaultWorktreeRoot(projectId) {
251
276
  return path.join(getPatchRelayDataDir(), "worktrees", projectId);
252
277
  }
278
+ function defaultRepositoryLocalPath(reposRoot, githubRepo) {
279
+ const repoName = githubRepo.split("/").pop()?.trim();
280
+ if (!repoName) {
281
+ throw new Error(`Invalid github_repo: ${githubRepo}`);
282
+ }
283
+ return path.join(ensureAbsolutePath(reposRoot), repoName);
284
+ }
253
285
  function defaultBranchPrefix(projectId) {
254
286
  const sanitized = projectId
255
287
  .trim()
@@ -317,6 +349,84 @@ export function loadConfig(configPath = process.env.PATCHRELAY_CONFIG ?? getDefa
317
349
  }
318
350
  const logFilePath = env.PATCHRELAY_LOG_FILE ?? parsed.logging.file_path;
319
351
  const oauthRedirectUri = parsed.linear.oauth.redirect_uri ?? deriveLinearOAuthRedirectUri(parsed.server);
352
+ const reposRoot = ensureAbsolutePath(parsed.repos.root);
353
+ const repositories = parsed.repositories.map((repository) => {
354
+ const localPath = ensureAbsolutePath(repository.local_path ?? defaultRepositoryLocalPath(reposRoot, repository.github_repo));
355
+ return {
356
+ githubRepo: repository.github_repo,
357
+ localPath,
358
+ ...(repository.workspace ? { workspace: repository.workspace } : {}),
359
+ linearTeamIds: repository.linear_team_ids,
360
+ linearProjectIds: repository.linear_project_ids,
361
+ issueKeyPrefixes: repository.issue_key_prefixes,
362
+ reviewChecks: repository.review_checks,
363
+ gateChecks: repository.gate_checks,
364
+ triggerEvents: repository.trigger_events,
365
+ branchPrefix: repository.branch_prefix,
366
+ github: repository.github,
367
+ };
368
+ });
369
+ const repositoryProjects = repositories.map((repository) => {
370
+ const repoSettings = readRepoSettings(repository.localPath, env);
371
+ return {
372
+ id: repository.githubRepo,
373
+ repoPath: repository.localPath,
374
+ worktreeRoot: ensureAbsolutePath(defaultWorktreeRoot(repository.githubRepo)),
375
+ issueKeyPrefixes: repository.issueKeyPrefixes,
376
+ linearTeamIds: repository.linearTeamIds,
377
+ allowLabels: [],
378
+ reviewChecks: repository.reviewChecks,
379
+ gateChecks: repository.gateChecks,
380
+ triggerEvents: normalizeTriggerEvents(parsed.linear.oauth.actor, repoSettings?.trigger_events ?? repository.triggerEvents),
381
+ branchPrefix: repoSettings?.branch_prefix ?? repository.branchPrefix ?? defaultBranchPrefix(repository.githubRepo),
382
+ ...(repoSettings?.configPath ? { repoSettingsPath: repoSettings.configPath } : {}),
383
+ github: {
384
+ repoFullName: repository.githubRepo,
385
+ ...(repository.github?.base_branch ? { baseBranch: repository.github.base_branch } : {}),
386
+ ...(repository.github?.webhook_secret ? { webhookSecret: repository.github.webhook_secret } : {}),
387
+ ...(repository.github?.merge_queue_label ? { mergeQueueLabel: repository.github.merge_queue_label } : {}),
388
+ ...(repository.github?.merge_queue_check_name ? { mergeQueueCheckName: repository.github.merge_queue_check_name } : {}),
389
+ },
390
+ };
391
+ });
392
+ const legacyProjects = parsed.projects.map((project) => {
393
+ const repoPath = ensureAbsolutePath(project.repo_path);
394
+ const repoSettings = readRepoSettings(repoPath, env);
395
+ const trustedActors = project.trusted_actors;
396
+ return {
397
+ id: project.id,
398
+ repoPath,
399
+ worktreeRoot: ensureAbsolutePath(project.worktree_root ?? defaultWorktreeRoot(project.id)),
400
+ ...(trustedActors
401
+ ? {
402
+ trustedActors: {
403
+ ids: trustedActors.ids,
404
+ names: trustedActors.names,
405
+ emails: trustedActors.emails,
406
+ emailDomains: trustedActors.email_domains,
407
+ },
408
+ }
409
+ : {}),
410
+ issueKeyPrefixes: project.issue_key_prefixes,
411
+ linearTeamIds: project.linear_team_ids,
412
+ allowLabels: project.allow_labels,
413
+ reviewChecks: project.review_checks,
414
+ gateChecks: project.gate_checks,
415
+ triggerEvents: normalizeTriggerEvents(parsed.linear.oauth.actor, repoSettings?.trigger_events ??
416
+ project.trigger_events),
417
+ branchPrefix: repoSettings?.branch_prefix ?? project.branch_prefix ?? defaultBranchPrefix(project.id),
418
+ ...(repoSettings?.configPath ? { repoSettingsPath: repoSettings.configPath } : {}),
419
+ ...(project.github ? {
420
+ github: {
421
+ ...(project.github.webhook_secret ? { webhookSecret: project.github.webhook_secret } : {}),
422
+ ...(project.github.repo_full_name ? { repoFullName: project.github.repo_full_name } : {}),
423
+ ...(project.github.base_branch ? { baseBranch: project.github.base_branch } : {}),
424
+ },
425
+ } : {}),
426
+ };
427
+ });
428
+ const repositoryPaths = new Set(repositoryProjects.map((project) => project.repoPath));
429
+ const derivedProjects = [...repositoryProjects, ...legacyProjects.filter((project) => !repositoryPaths.has(project.repoPath))];
320
430
  const config = {
321
431
  server: {
322
432
  bind: parsed.server.bind,
@@ -377,42 +487,18 @@ export function loadConfig(configPath = process.env.PATCHRELAY_CONFIG ?? getDefa
377
487
  experimentalRawEvents: parsed.runner.codex.experimental_raw_events,
378
488
  },
379
489
  },
380
- projects: parsed.projects.map((project) => {
381
- const repoPath = ensureAbsolutePath(project.repo_path);
382
- const repoSettings = readRepoSettings(repoPath, env);
383
- const trustedActors = project.trusted_actors;
384
- return {
385
- id: project.id,
386
- repoPath,
387
- worktreeRoot: ensureAbsolutePath(project.worktree_root ?? defaultWorktreeRoot(project.id)),
388
- ...(trustedActors
389
- ? {
390
- trustedActors: {
391
- ids: trustedActors.ids,
392
- names: trustedActors.names,
393
- emails: trustedActors.emails,
394
- emailDomains: trustedActors.email_domains,
395
- },
396
- }
397
- : {}),
398
- issueKeyPrefixes: project.issue_key_prefixes,
399
- linearTeamIds: project.linear_team_ids,
400
- allowLabels: project.allow_labels,
401
- reviewChecks: project.review_checks,
402
- gateChecks: project.gate_checks,
403
- triggerEvents: normalizeTriggerEvents(parsed.linear.oauth.actor, repoSettings?.trigger_events ??
404
- project.trigger_events),
405
- branchPrefix: repoSettings?.branch_prefix ?? project.branch_prefix ?? defaultBranchPrefix(project.id),
406
- ...(repoSettings?.configPath ? { repoSettingsPath: repoSettings.configPath } : {}),
407
- ...(project.github ? {
408
- github: {
409
- ...(project.github.webhook_secret ? { webhookSecret: project.github.webhook_secret } : {}),
410
- ...(project.github.repo_full_name ? { repoFullName: project.github.repo_full_name } : {}),
411
- ...(project.github.base_branch ? { baseBranch: project.github.base_branch } : {}),
412
- },
413
- } : {}),
414
- };
415
- }),
490
+ repos: {
491
+ root: reposRoot,
492
+ },
493
+ repositories: repositories.map((repository) => ({
494
+ githubRepo: repository.githubRepo,
495
+ localPath: repository.localPath,
496
+ ...(repository.workspace ? { workspace: repository.workspace } : {}),
497
+ linearTeamIds: repository.linearTeamIds,
498
+ linearProjectIds: repository.linearProjectIds,
499
+ issueKeyPrefixes: repository.issueKeyPrefixes,
500
+ })),
501
+ projects: derivedProjects,
416
502
  secretSources: {
417
503
  "linear-webhook-secret": rWebhookSecret.source,
418
504
  "token-encryption-key": rTokenEncryptionKey.source,
@@ -462,8 +548,15 @@ function validateConfigSemantics(config, options) {
462
548
  throw new Error(`linear.oauth.redirect_uri must use the fixed "${LINEAR_OAUTH_CALLBACK_PATH}" path`);
463
549
  }
464
550
  const projectIds = new Set();
551
+ const githubRepos = new Set();
465
552
  const issuePrefixes = new Map();
466
553
  const linearTeamIds = new Map();
554
+ for (const repository of config.repositories) {
555
+ if (githubRepos.has(repository.githubRepo)) {
556
+ throw new Error(`Duplicate repository github_repo: ${repository.githubRepo}`);
557
+ }
558
+ githubRepos.add(repository.githubRepo);
559
+ }
467
560
  for (const project of config.projects) {
468
561
  if (projectIds.has(project.id)) {
469
562
  throw new Error(`Duplicate project id: ${project.id}`);
@@ -79,6 +79,23 @@ export class LinearInstallationStore {
79
79
  .all();
80
80
  return rows.map((row) => mapLinearInstallation(row));
81
81
  }
82
+ findLinearInstallationByWorkspace(query) {
83
+ const normalized = query.trim().toLowerCase();
84
+ if (!normalized)
85
+ return undefined;
86
+ const row = this.connection
87
+ .prepare(`
88
+ SELECT *
89
+ FROM linear_installations
90
+ WHERE LOWER(COALESCE(workspace_key, '')) = ?
91
+ OR LOWER(COALESCE(workspace_name, '')) = ?
92
+ OR LOWER(COALESCE(workspace_id, '')) = ?
93
+ ORDER BY updated_at DESC, id DESC
94
+ LIMIT 1
95
+ `)
96
+ .get(normalized, normalized, normalized);
97
+ return row ? mapLinearInstallation(row) : undefined;
98
+ }
82
99
  linkProjectInstallation(projectId, installationId) {
83
100
  const now = isoNow();
84
101
  this.connection
@@ -108,6 +125,12 @@ export class LinearInstallationStore {
108
125
  unlinkProjectInstallation(projectId) {
109
126
  this.connection.prepare("DELETE FROM project_installations WHERE project_id = ?").run(projectId);
110
127
  }
128
+ unlinkInstallationProjects(installationId) {
129
+ this.connection.prepare("DELETE FROM project_installations WHERE installation_id = ?").run(installationId);
130
+ }
131
+ deleteLinearInstallation(installationId) {
132
+ this.connection.prepare("DELETE FROM linear_installations WHERE id = ?").run(installationId);
133
+ }
111
134
  getLinearInstallationForProject(projectId) {
112
135
  const row = this.connection
113
136
  .prepare(`
@@ -87,6 +87,37 @@ CREATE TABLE IF NOT EXISTS project_installations (
87
87
  linked_at TEXT NOT NULL
88
88
  );
89
89
 
90
+ CREATE TABLE IF NOT EXISTS repository_links (
91
+ github_repo TEXT PRIMARY KEY,
92
+ local_path TEXT NOT NULL,
93
+ installation_id INTEGER NOT NULL,
94
+ linear_team_ids_json TEXT NOT NULL DEFAULT '[]',
95
+ linear_project_ids_json TEXT NOT NULL DEFAULT '[]',
96
+ issue_key_prefixes_json TEXT NOT NULL DEFAULT '[]',
97
+ linked_at TEXT NOT NULL,
98
+ updated_at TEXT NOT NULL
99
+ );
100
+
101
+ CREATE TABLE IF NOT EXISTS linear_catalog_teams (
102
+ installation_id INTEGER NOT NULL,
103
+ team_id TEXT NOT NULL,
104
+ team_key TEXT,
105
+ team_name TEXT,
106
+ active INTEGER NOT NULL DEFAULT 1,
107
+ updated_at TEXT NOT NULL,
108
+ PRIMARY KEY (installation_id, team_id)
109
+ );
110
+
111
+ CREATE TABLE IF NOT EXISTS linear_catalog_projects (
112
+ installation_id INTEGER NOT NULL,
113
+ project_id TEXT NOT NULL,
114
+ project_name TEXT,
115
+ team_ids_json TEXT NOT NULL DEFAULT '[]',
116
+ active INTEGER NOT NULL DEFAULT 1,
117
+ updated_at TEXT NOT NULL,
118
+ PRIMARY KEY (installation_id, project_id)
119
+ );
120
+
90
121
  CREATE TABLE IF NOT EXISTS oauth_states (
91
122
  id INTEGER PRIMARY KEY AUTOINCREMENT,
92
123
  provider TEXT NOT NULL,
@@ -124,6 +155,9 @@ CREATE INDEX IF NOT EXISTS idx_runs_thread ON runs(thread_id);
124
155
  CREATE INDEX IF NOT EXISTS idx_run_thread_events_run ON run_thread_events(run_id, id);
125
156
  CREATE INDEX IF NOT EXISTS idx_operator_feed_events_issue ON operator_feed_events(issue_key, id);
126
157
  CREATE INDEX IF NOT EXISTS idx_operator_feed_events_project ON operator_feed_events(project_id, id);
158
+ CREATE INDEX IF NOT EXISTS idx_repository_links_installation ON repository_links(installation_id, github_repo);
159
+ CREATE INDEX IF NOT EXISTS idx_linear_catalog_teams_installation ON linear_catalog_teams(installation_id, team_key, team_name);
160
+ CREATE INDEX IF NOT EXISTS idx_linear_catalog_projects_installation ON linear_catalog_projects(installation_id, project_name);
127
161
  `;
128
162
  export function runPatchRelayMigrations(connection) {
129
163
  connection.exec(schema);
@@ -144,6 +178,14 @@ export function runPatchRelayMigrations(connection) {
144
178
  // Zombie/stale recovery backoff
145
179
  addColumnIfMissing(connection, "issues", "zombie_recovery_attempts", "INTEGER NOT NULL DEFAULT 0");
146
180
  addColumnIfMissing(connection, "issues", "last_zombie_recovery_at", "TEXT");
181
+ // Preserve GitHub failure provenance so reconciliation can distinguish
182
+ // branch CI failures from merge-queue evictions after webhook delivery.
183
+ addColumnIfMissing(connection, "issues", "last_github_failure_source", "TEXT");
184
+ addColumnIfMissing(connection, "issues", "last_github_failure_check_name", "TEXT");
185
+ addColumnIfMissing(connection, "issues", "last_github_failure_check_url", "TEXT");
186
+ addColumnIfMissing(connection, "issues", "last_github_failure_at", "TEXT");
187
+ addColumnIfMissing(connection, "issues", "last_queue_signal_at", "TEXT");
188
+ addColumnIfMissing(connection, "issues", "last_queue_incident_json", "TEXT");
147
189
  }
148
190
  function addColumnIfMissing(connection, table, column, definition) {
149
191
  const cols = connection.prepare(`PRAGMA table_info(${table})`).all();
@@ -0,0 +1,103 @@
1
+ import { isoNow } from "./shared.js";
2
+ export class RepositoryLinkStore {
3
+ connection;
4
+ constructor(connection) {
5
+ this.connection = connection;
6
+ }
7
+ upsertRepositoryLink(params) {
8
+ const now = isoNow();
9
+ this.connection
10
+ .prepare(`
11
+ INSERT INTO repository_links (
12
+ github_repo, local_path, installation_id, linear_team_ids_json, linear_project_ids_json, issue_key_prefixes_json, linked_at, updated_at
13
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
14
+ ON CONFLICT(github_repo) DO UPDATE SET
15
+ local_path = excluded.local_path,
16
+ installation_id = excluded.installation_id,
17
+ linear_team_ids_json = excluded.linear_team_ids_json,
18
+ linear_project_ids_json = excluded.linear_project_ids_json,
19
+ issue_key_prefixes_json = excluded.issue_key_prefixes_json,
20
+ updated_at = excluded.updated_at
21
+ `)
22
+ .run(params.githubRepo, params.localPath, params.installationId, JSON.stringify(params.linearTeamIds), JSON.stringify(params.linearProjectIds ?? []), JSON.stringify(params.issueKeyPrefixes ?? []), now, now);
23
+ return this.getRepositoryLink(params.githubRepo);
24
+ }
25
+ getRepositoryLink(githubRepo) {
26
+ const row = this.connection
27
+ .prepare("SELECT * FROM repository_links WHERE github_repo = ?")
28
+ .get(githubRepo);
29
+ return row ? mapRepositoryLink(row) : undefined;
30
+ }
31
+ listRepositoryLinks() {
32
+ const rows = this.connection
33
+ .prepare("SELECT * FROM repository_links ORDER BY github_repo")
34
+ .all();
35
+ return rows.map(mapRepositoryLink);
36
+ }
37
+ deleteRepositoryLink(githubRepo) {
38
+ this.connection.prepare("DELETE FROM repository_links WHERE github_repo = ?").run(githubRepo);
39
+ }
40
+ replaceCatalog(params) {
41
+ const now = isoNow();
42
+ this.connection.prepare("DELETE FROM linear_catalog_teams WHERE installation_id = ?").run(params.installationId);
43
+ this.connection.prepare("DELETE FROM linear_catalog_projects WHERE installation_id = ?").run(params.installationId);
44
+ const teamStmt = this.connection.prepare(`
45
+ INSERT INTO linear_catalog_teams (installation_id, team_id, team_key, team_name, active, updated_at)
46
+ VALUES (?, ?, ?, ?, 1, ?)
47
+ `);
48
+ for (const team of params.teams) {
49
+ teamStmt.run(params.installationId, team.id, team.key ?? null, team.name ?? null, now);
50
+ }
51
+ const projectStmt = this.connection.prepare(`
52
+ INSERT INTO linear_catalog_projects (installation_id, project_id, project_name, team_ids_json, active, updated_at)
53
+ VALUES (?, ?, ?, ?, 1, ?)
54
+ `);
55
+ for (const project of params.projects) {
56
+ projectStmt.run(params.installationId, project.id, project.name ?? null, JSON.stringify(project.teamIds), now);
57
+ }
58
+ }
59
+ listCatalogTeams(installationId) {
60
+ const rows = this.connection
61
+ .prepare("SELECT * FROM linear_catalog_teams WHERE installation_id = ? ORDER BY COALESCE(team_key, team_name, team_id)")
62
+ .all(installationId);
63
+ return rows.map(mapCatalogTeam);
64
+ }
65
+ listCatalogProjects(installationId) {
66
+ const rows = this.connection
67
+ .prepare("SELECT * FROM linear_catalog_projects WHERE installation_id = ? ORDER BY COALESCE(project_name, project_id)")
68
+ .all(installationId);
69
+ return rows.map(mapCatalogProject);
70
+ }
71
+ }
72
+ function mapRepositoryLink(row) {
73
+ return {
74
+ githubRepo: String(row.github_repo),
75
+ localPath: String(row.local_path),
76
+ installationId: Number(row.installation_id),
77
+ linearTeamIdsJson: String(row.linear_team_ids_json ?? "[]"),
78
+ linearProjectIdsJson: String(row.linear_project_ids_json ?? "[]"),
79
+ issueKeyPrefixesJson: String(row.issue_key_prefixes_json ?? "[]"),
80
+ linkedAt: String(row.linked_at),
81
+ updatedAt: String(row.updated_at),
82
+ };
83
+ }
84
+ function mapCatalogTeam(row) {
85
+ return {
86
+ installationId: Number(row.installation_id),
87
+ teamId: String(row.team_id),
88
+ ...(row.team_key === null ? {} : { key: String(row.team_key) }),
89
+ ...(row.team_name === null ? {} : { name: String(row.team_name) }),
90
+ active: Number(row.active ?? 0) !== 0,
91
+ updatedAt: String(row.updated_at),
92
+ };
93
+ }
94
+ function mapCatalogProject(row) {
95
+ return {
96
+ installationId: Number(row.installation_id),
97
+ projectId: String(row.project_id),
98
+ ...(row.project_name === null ? {} : { name: String(row.project_name) }),
99
+ teamIdsJson: String(row.team_ids_json ?? "[]"),
100
+ active: Number(row.active ?? 0) !== 0,
101
+ updatedAt: String(row.updated_at),
102
+ };
103
+ }
package/dist/db.js CHANGED
@@ -1,11 +1,13 @@
1
1
  import { LinearInstallationStore } from "./db/linear-installation-store.js";
2
2
  import { OperatorFeedStore } from "./db/operator-feed-store.js";
3
+ import { RepositoryLinkStore } from "./db/repository-link-store.js";
3
4
  import { runPatchRelayMigrations } from "./db/migrations.js";
4
5
  import { SqliteConnection, isoNow } from "./db/shared.js";
5
6
  export class PatchRelayDatabase {
6
7
  connection;
7
8
  linearInstallations;
8
9
  operatorFeed;
10
+ repositories;
9
11
  constructor(databasePath, wal) {
10
12
  this.connection = new SqliteConnection(databasePath);
11
13
  this.connection.pragma("foreign_keys = ON");
@@ -14,6 +16,7 @@ export class PatchRelayDatabase {
14
16
  }
15
17
  this.linearInstallations = new LinearInstallationStore(this.connection);
16
18
  this.operatorFeed = new OperatorFeedStore(this.connection);
19
+ this.repositories = new RepositoryLinkStore(this.connection);
17
20
  }
18
21
  runMigrations() {
19
22
  runPatchRelayMigrations(this.connection);
@@ -153,6 +156,30 @@ export class PatchRelayDatabase {
153
156
  sets.push("pr_check_status = @prCheckStatus");
154
157
  values.prCheckStatus = params.prCheckStatus;
155
158
  }
159
+ if (params.lastGitHubFailureSource !== undefined) {
160
+ sets.push("last_github_failure_source = @lastGitHubFailureSource");
161
+ values.lastGitHubFailureSource = params.lastGitHubFailureSource;
162
+ }
163
+ if (params.lastGitHubFailureCheckName !== undefined) {
164
+ sets.push("last_github_failure_check_name = @lastGitHubFailureCheckName");
165
+ values.lastGitHubFailureCheckName = params.lastGitHubFailureCheckName;
166
+ }
167
+ if (params.lastGitHubFailureCheckUrl !== undefined) {
168
+ sets.push("last_github_failure_check_url = @lastGitHubFailureCheckUrl");
169
+ values.lastGitHubFailureCheckUrl = params.lastGitHubFailureCheckUrl;
170
+ }
171
+ if (params.lastGitHubFailureAt !== undefined) {
172
+ sets.push("last_github_failure_at = @lastGitHubFailureAt");
173
+ values.lastGitHubFailureAt = params.lastGitHubFailureAt;
174
+ }
175
+ if (params.lastQueueSignalAt !== undefined) {
176
+ sets.push("last_queue_signal_at = @lastQueueSignalAt");
177
+ values.lastQueueSignalAt = params.lastQueueSignalAt;
178
+ }
179
+ if (params.lastQueueIncidentJson !== undefined) {
180
+ sets.push("last_queue_incident_json = @lastQueueIncidentJson");
181
+ values.lastQueueIncidentJson = params.lastQueueIncidentJson;
182
+ }
156
183
  if (params.ciRepairAttempts !== undefined) {
157
184
  sets.push("ci_repair_attempts = @ciRepairAttempts");
158
185
  values.ciRepairAttempts = params.ciRepairAttempts;
@@ -165,14 +192,6 @@ export class PatchRelayDatabase {
165
192
  sets.push("review_fix_attempts = @reviewFixAttempts");
166
193
  values.reviewFixAttempts = params.reviewFixAttempts;
167
194
  }
168
- if (params.mergePrepAttempts !== undefined) {
169
- sets.push("merge_prep_attempts = @mergePrepAttempts");
170
- values.mergePrepAttempts = params.mergePrepAttempts;
171
- }
172
- if (params.pendingMergePrep !== undefined) {
173
- sets.push("pending_merge_prep = @pendingMergePrep");
174
- values.pendingMergePrep = params.pendingMergePrep ? 1 : 0;
175
- }
176
195
  if (params.zombieRecoveryAttempts !== undefined) {
177
196
  sets.push("zombie_recovery_attempts = @zombieRecoveryAttempts");
178
197
  values.zombieRecoveryAttempts = params.zombieRecoveryAttempts;
@@ -191,6 +210,8 @@ export class PatchRelayDatabase {
191
210
  current_linear_state, factory_state, pending_run_type, pending_run_context_json,
192
211
  branch_name, worktree_path, thread_id, active_run_id,
193
212
  agent_session_id,
213
+ pr_number, pr_url, pr_state, pr_review_state, pr_check_status,
214
+ last_github_failure_source, last_github_failure_check_name, last_github_failure_check_url, last_github_failure_at, last_queue_signal_at, last_queue_incident_json,
194
215
  updated_at
195
216
  ) VALUES (
196
217
  @projectId, @linearIssueId, @issueKey, @title, @description, @url,
@@ -198,6 +219,8 @@ export class PatchRelayDatabase {
198
219
  @currentLinearState, @factoryState, @pendingRunType, @pendingRunContextJson,
199
220
  @branchName, @worktreePath, @threadId, @activeRunId,
200
221
  @agentSessionId,
222
+ @prNumber, @prUrl, @prState, @prReviewState, @prCheckStatus,
223
+ @lastGitHubFailureSource, @lastGitHubFailureCheckName, @lastGitHubFailureCheckUrl, @lastGitHubFailureAt, @lastQueueSignalAt, @lastQueueIncidentJson,
201
224
  @now
202
225
  )
203
226
  `).run({
@@ -218,6 +241,17 @@ export class PatchRelayDatabase {
218
241
  threadId: params.threadId ?? null,
219
242
  activeRunId: params.activeRunId ?? null,
220
243
  agentSessionId: params.agentSessionId ?? null,
244
+ prNumber: params.prNumber ?? null,
245
+ prUrl: params.prUrl ?? null,
246
+ prState: params.prState ?? null,
247
+ prReviewState: params.prReviewState ?? null,
248
+ prCheckStatus: params.prCheckStatus ?? null,
249
+ lastGitHubFailureSource: params.lastGitHubFailureSource ?? null,
250
+ lastGitHubFailureCheckName: params.lastGitHubFailureCheckName ?? null,
251
+ lastGitHubFailureCheckUrl: params.lastGitHubFailureCheckUrl ?? null,
252
+ lastGitHubFailureAt: params.lastGitHubFailureAt ?? null,
253
+ lastQueueSignalAt: params.lastQueueSignalAt ?? null,
254
+ lastQueueIncidentJson: params.lastQueueIncidentJson ?? null,
221
255
  now,
222
256
  });
223
257
  }
@@ -247,7 +281,7 @@ export class PatchRelayDatabase {
247
281
  }
248
282
  listIssuesReadyForExecution() {
249
283
  const rows = this.connection
250
- .prepare("SELECT project_id, linear_issue_id FROM issues WHERE (pending_run_type IS NOT NULL OR pending_merge_prep = 1) AND active_run_id IS NULL")
284
+ .prepare("SELECT project_id, linear_issue_id FROM issues WHERE pending_run_type IS NOT NULL AND active_run_id IS NULL")
251
285
  .all();
252
286
  return rows.map((row) => ({
253
287
  projectId: String(row.project_id),
@@ -427,11 +461,27 @@ function mapIssueRow(row) {
427
461
  ...(row.pr_state !== null && row.pr_state !== undefined ? { prState: String(row.pr_state) } : {}),
428
462
  ...(row.pr_review_state !== null && row.pr_review_state !== undefined ? { prReviewState: String(row.pr_review_state) } : {}),
429
463
  ...(row.pr_check_status !== null && row.pr_check_status !== undefined ? { prCheckStatus: String(row.pr_check_status) } : {}),
464
+ ...(row.last_github_failure_source !== null && row.last_github_failure_source !== undefined
465
+ ? { lastGitHubFailureSource: String(row.last_github_failure_source) }
466
+ : {}),
467
+ ...(row.last_github_failure_check_name !== null && row.last_github_failure_check_name !== undefined
468
+ ? { lastGitHubFailureCheckName: String(row.last_github_failure_check_name) }
469
+ : {}),
470
+ ...(row.last_github_failure_check_url !== null && row.last_github_failure_check_url !== undefined
471
+ ? { lastGitHubFailureCheckUrl: String(row.last_github_failure_check_url) }
472
+ : {}),
473
+ ...(row.last_github_failure_at !== null && row.last_github_failure_at !== undefined
474
+ ? { lastGitHubFailureAt: String(row.last_github_failure_at) }
475
+ : {}),
476
+ ...(row.last_queue_signal_at !== null && row.last_queue_signal_at !== undefined
477
+ ? { lastQueueSignalAt: String(row.last_queue_signal_at) }
478
+ : {}),
479
+ ...(row.last_queue_incident_json !== null && row.last_queue_incident_json !== undefined
480
+ ? { lastQueueIncidentJson: String(row.last_queue_incident_json) }
481
+ : {}),
430
482
  ciRepairAttempts: Number(row.ci_repair_attempts ?? 0),
431
483
  queueRepairAttempts: Number(row.queue_repair_attempts ?? 0),
432
484
  reviewFixAttempts: Number(row.review_fix_attempts ?? 0),
433
- mergePrepAttempts: Number(row.merge_prep_attempts ?? 0),
434
- pendingMergePrep: Boolean(row.pending_merge_prep),
435
485
  zombieRecoveryAttempts: Number(row.zombie_recovery_attempts ?? 0),
436
486
  ...(row.last_zombie_recovery_at !== null && row.last_zombie_recovery_at !== undefined ? { lastZombieRecoveryAt: String(row.last_zombie_recovery_at) } : {}),
437
487
  };
@@ -61,12 +61,8 @@ const TRANSITION_RULES = [
61
61
  { event: "check_failed",
62
62
  guard: (s, ctx) => isOpen(s) && ctx.activeRunId === undefined,
63
63
  to: "repairing_ci" },
64
- // ── Merge queue events ─────────────────────────────────────────
65
- { event: "merge_group_failed",
66
- guard: (s) => s === "awaiting_queue",
67
- to: "repairing_queue" },
68
- // merge_group_passed: no rule → no transition (merge event follows)
69
64
  // pr_synchronize: no rule → no transition (resets counters only)
65
+ // merge_group events: not used — merge queue is handled by external steward
70
66
  ];
71
67
  /**
72
68
  * Resolve the next factory state from a GitHub webhook event.