patchrelay 0.54.4 → 0.55.0

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.
package/README.md CHANGED
@@ -61,6 +61,8 @@ patchrelay service status
61
61
  patchrelay dashboard
62
62
  ```
63
63
 
64
+ When one Linear team owns issues for multiple repositories, include `--project <Linear project>` on each `repo link`. PatchRelay routes Linear webhooks by project first, so separate projects inside the same `USE` team can map to separate GitHub repos.
65
+
64
66
  Each repo needs two workflow files for repo-specific run behavior:
65
67
 
66
68
  - `IMPLEMENTATION_WORKFLOW.md` — implementation, CI repair, queue repair runs
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.54.4",
4
- "commit": "fa7ff16eaeff",
5
- "builtAt": "2026-04-30T14:26:42.396Z"
3
+ "version": "0.55.0",
4
+ "commit": "bd44ea445936",
5
+ "builtAt": "2026-05-01T12:06:57.419Z"
6
6
  }
package/dist/config.js CHANGED
@@ -52,6 +52,7 @@ const projectSchema = z.object({
52
52
  trusted_actors: trustedActorsSchema,
53
53
  issue_key_prefixes: z.array(z.string().min(1)).default([]),
54
54
  linear_team_ids: z.array(z.string().min(1)).default([]),
55
+ linear_project_ids: z.array(z.string().min(1)).default([]),
55
56
  allow_labels: z.array(z.string().min(1)).default([]),
56
57
  trigger_events: z.array(z.string().min(1)).min(1).optional(),
57
58
  branch_prefix: z.string().min(1).optional(),
@@ -466,6 +467,7 @@ export function loadConfig(configPath = process.env.PATCHRELAY_CONFIG ?? getDefa
466
467
  worktreeRoot: ensureAbsolutePath(defaultWorktreeRoot(repository.githubRepo)),
467
468
  issueKeyPrefixes: repository.issueKeyPrefixes,
468
469
  linearTeamIds: repository.linearTeamIds,
470
+ linearProjectIds: repository.linearProjectIds,
469
471
  allowLabels: [],
470
472
  reviewChecks: repository.reviewChecks,
471
473
  gateChecks: repository.gateChecks,
@@ -503,6 +505,7 @@ export function loadConfig(configPath = process.env.PATCHRELAY_CONFIG ?? getDefa
503
505
  : {}),
504
506
  issueKeyPrefixes: project.issue_key_prefixes,
505
507
  linearTeamIds: project.linear_team_ids,
508
+ linearProjectIds: project.linear_project_ids,
506
509
  allowLabels: project.allow_labels,
507
510
  reviewChecks: project.review_checks,
508
511
  gateChecks: project.gate_checks,
@@ -656,6 +659,7 @@ function validateConfigSemantics(config, options) {
656
659
  const githubRepos = new Set();
657
660
  const issuePrefixes = new Map();
658
661
  const linearTeamIds = new Map();
662
+ const linearProjectIds = new Map();
659
663
  for (const repository of config.repositories) {
660
664
  if (githubRepos.has(repository.githubRepo)) {
661
665
  throw new Error(`Duplicate repository github_repo: ${repository.githubRepo}`);
@@ -667,19 +671,26 @@ function validateConfigSemantics(config, options) {
667
671
  throw new Error(`Duplicate project id: ${project.id}`);
668
672
  }
669
673
  projectIds.add(project.id);
674
+ for (const linearProjectId of project.linearProjectIds) {
675
+ const owner = linearProjectIds.get(linearProjectId);
676
+ if (owner && owner !== project.id) {
677
+ throw new Error(`Linear project id "${linearProjectId}" is configured for both ${owner} and ${project.id}`);
678
+ }
679
+ linearProjectIds.set(linearProjectId, project.id);
680
+ }
670
681
  for (const prefix of project.issueKeyPrefixes) {
671
682
  const owner = issuePrefixes.get(prefix);
672
- if (owner && owner !== project.id) {
673
- throw new Error(`Issue key prefix "${prefix}" is configured for both ${owner} and ${project.id}`);
683
+ if (owner && owner.id !== project.id && !hasDisjointProjectRouting(owner, project)) {
684
+ throw new Error(`Issue key prefix "${prefix}" is configured for both ${owner.id} and ${project.id}`);
674
685
  }
675
- issuePrefixes.set(prefix, project.id);
686
+ issuePrefixes.set(prefix, project);
676
687
  }
677
688
  for (const teamId of project.linearTeamIds) {
678
689
  const owner = linearTeamIds.get(teamId);
679
- if (owner && owner !== project.id) {
680
- throw new Error(`Linear team id "${teamId}" is configured for both ${owner} and ${project.id}`);
690
+ if (owner && owner.id !== project.id && !hasDisjointProjectRouting(owner, project)) {
691
+ throw new Error(`Linear team id "${teamId}" is configured for both ${owner.id} and ${project.id}`);
681
692
  }
682
- linearTeamIds.set(teamId, project.id);
693
+ linearTeamIds.set(teamId, project);
683
694
  }
684
695
  }
685
696
  if (config.operatorApi.enabled &&
@@ -689,3 +700,10 @@ function validateConfigSemantics(config, options) {
689
700
  throw new Error("operator_api.enabled requires operator_api.bearer_token_env when server.bind is not 127.0.0.1");
690
701
  }
691
702
  }
703
+ function hasDisjointProjectRouting(left, right) {
704
+ if (left.linearProjectIds.length === 0 || right.linearProjectIds.length === 0) {
705
+ return false;
706
+ }
707
+ const rightProjectIds = new Set(right.linearProjectIds);
708
+ return left.linearProjectIds.every((linearProjectId) => !rightProjectIds.has(linearProjectId));
709
+ }
@@ -88,6 +88,10 @@ const LINEAR_ISSUE_SELECTION = `
88
88
  }
89
89
  }
90
90
  }
91
+ project {
92
+ id
93
+ name
94
+ }
91
95
  `;
92
96
  export class LinearGraphqlClient {
93
97
  options;
@@ -388,6 +392,8 @@ export class LinearGraphqlClient {
388
392
  ...(issue.state?.type ? { stateType: issue.state.type } : {}),
389
393
  ...(issue.team?.id ? { teamId: issue.team.id } : {}),
390
394
  ...(issue.team?.key ? { teamKey: issue.team.key } : {}),
395
+ ...(issue.project?.id ? { projectId: issue.project.id } : {}),
396
+ ...(issue.project?.name ? { projectName: issue.project.name } : {}),
391
397
  ...(issue.delegate?.id ? { delegateId: issue.delegate.id } : {}),
392
398
  ...(issue.delegate?.name ? { delegateName: issue.delegate.name } : {}),
393
399
  workflowStates: (issue.team?.states?.nodes ?? []).map((state) => ({
@@ -42,6 +42,16 @@ export function buildAlreadyRunningThought(runType) {
42
42
  body: `PatchRelay is already working on the ${lowerRunTypeLabel(runType)} workflow.`,
43
43
  };
44
44
  }
45
+ export function buildBlockedDelegationActivity(blockedByKeys = []) {
46
+ const blockers = blockedByKeys.filter((key) => key.trim().length > 0);
47
+ const blockerText = blockers.length > 0
48
+ ? ` Waiting on ${blockers.join(", ")}.`
49
+ : " Waiting for blocker issues to complete.";
50
+ return {
51
+ type: "response",
52
+ body: `PatchRelay accepted this delegation and will not start implementation until the issue is unblocked.${blockerText}`,
53
+ };
54
+ }
45
55
  export function buildPromptDeliveredThought(runType) {
46
56
  return {
47
57
  type: "thought",
@@ -11,12 +11,30 @@ function matchesProject(issue, project) {
11
11
  return false;
12
12
  }
13
13
  export function resolveProject(config, issue) {
14
+ if (issue.projectId) {
15
+ const projectMatches = config.projects.filter((project) => matchesLinearProject(issue, project));
16
+ if (projectMatches.length === 1) {
17
+ return projectMatches[0];
18
+ }
19
+ if (projectMatches.length > 1) {
20
+ return undefined;
21
+ }
22
+ }
14
23
  const matches = config.projects.filter((project) => matchesProject(issue, project));
15
24
  if (matches.length === 1) {
16
25
  return matches[0];
17
26
  }
18
27
  return undefined;
19
28
  }
29
+ function matchesLinearProject(issue, project) {
30
+ if (!issue.projectId || !project.linearProjectIds.includes(issue.projectId)) {
31
+ return false;
32
+ }
33
+ if (project.linearTeamIds.length === 0 || !issue.teamId) {
34
+ return true;
35
+ }
36
+ return project.linearTeamIds.includes(issue.teamId);
37
+ }
20
38
  export function triggerEventAllowed(project, triggerEvent) {
21
39
  return project.triggerEvents.includes(triggerEvent);
22
40
  }
@@ -1,6 +1,6 @@
1
1
  import { buildAgentSessionPlanForIssue, } from "../agent-session-plan.js";
2
2
  import { buildAgentSessionExternalUrls } from "../agent-session-presentation.js";
3
- import { buildAlreadyRunningThought, buildDelegationThought, buildPromptDeliveredThought, buildStopConfirmationActivity, } from "../linear-session-reporting.js";
3
+ import { buildAlreadyRunningThought, buildBlockedDelegationActivity, buildDelegationThought, buildPromptDeliveredThought, buildStopConfirmationActivity, } from "../linear-session-reporting.js";
4
4
  import { triggerEventAllowed } from "../project-resolution.js";
5
5
  export class AgentSessionHandler {
6
6
  config;
@@ -47,6 +47,12 @@ export class AgentSessionHandler {
47
47
  await this.publishAgentActivity(linear, normalized.agentSession.id, buildAlreadyRunningThought(activeRun.runType));
48
48
  return;
49
49
  }
50
+ if ((trackedIssue?.blockedByCount ?? 0) > 0) {
51
+ const latestIssue = this.db.issues.getIssue(project.id, normalized.issue.id);
52
+ await this.syncAgentSession(linear, normalized.agentSession.id, latestIssue ?? trackedIssue, params.peekPendingSessionWakeRunType);
53
+ await this.publishAgentActivity(linear, normalized.agentSession.id, buildBlockedDelegationActivity(trackedIssue?.blockedByKeys));
54
+ return;
55
+ }
50
56
  if (!trackedIssue?.blockedByCount) {
51
57
  await this.publishAgentActivity(linear, normalized.agentSession.id, {
52
58
  type: "elicitation",
@@ -113,6 +113,8 @@ export function mergeIssueMetadata(issue, liveIssue) {
113
113
  ...(issue.attachments && issue.attachments.length > 0 ? {} : liveIssue.attachments ? { attachments: liveIssue.attachments } : {}),
114
114
  ...(issue.teamId ? {} : liveIssue.teamId ? { teamId: liveIssue.teamId } : {}),
115
115
  ...(issue.teamKey ? {} : liveIssue.teamKey ? { teamKey: liveIssue.teamKey } : {}),
116
+ ...(issue.projectId ? {} : liveIssue.projectId ? { projectId: liveIssue.projectId } : {}),
117
+ ...(issue.projectName ? {} : liveIssue.projectName ? { projectName: liveIssue.projectName } : {}),
116
118
  ...(issue.stateId ? {} : liveIssue.stateId ? { stateId: liveIssue.stateId } : {}),
117
119
  ...(issue.stateName ? {} : liveIssue.stateName ? { stateName: liveIssue.stateName } : {}),
118
120
  ...(issue.stateType ? {} : liveIssue.stateType ? { stateType: liveIssue.stateType } : {}),
package/dist/webhooks.js CHANGED
@@ -200,8 +200,11 @@ function extractIssueMetadata(payload) {
200
200
  const title = getString(issueRecord, "title");
201
201
  const url = getString(issueRecord, "url") ?? payload.url;
202
202
  const delegateRecord = asRecord(issueRecord.delegate);
203
+ const projectRecord = asRecord(issueRecord.project);
203
204
  const teamId = getString(issueRecord, "teamId") ?? getString(teamRecord ?? {}, "id");
204
205
  const teamKey = getString(teamRecord ?? {}, "key");
206
+ const projectId = getString(issueRecord, "projectId") ?? getString(projectRecord ?? {}, "id");
207
+ const projectName = getString(projectRecord ?? {}, "name");
205
208
  const stateRecord = asRecord(issueRecord.state);
206
209
  const stateId = getString(issueRecord, "stateId") ?? getString(stateRecord ?? {}, "id");
207
210
  const stateName = getString(stateRecord ?? {}, "name");
@@ -227,6 +230,8 @@ function extractIssueMetadata(payload) {
227
230
  ...(url ? { url } : {}),
228
231
  ...(teamId ? { teamId } : {}),
229
232
  ...(teamKey ? { teamKey } : {}),
233
+ ...(projectId ? { projectId } : {}),
234
+ ...(projectName ? { projectName } : {}),
230
235
  ...(stateId ? { stateId } : {}),
231
236
  ...(stateName ? { stateName } : {}),
232
237
  ...(stateType ? { stateType } : {}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.54.4",
3
+ "version": "0.55.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {