agent-relay-server 0.62.1 → 0.62.2

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/docs/openapi.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Agent Relay API",
5
- "version": "0.62.1",
5
+ "version": "0.62.2",
6
6
  "description": "Real-time message bus for inter-agent communication. Agent-first: this spec is designed for machine consumption — agents can self-discover the full API surface via GET /api/spec.",
7
7
  "license": {
8
8
  "name": "MIT",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-server",
3
- "version": "0.62.1",
3
+ "version": "0.62.2",
4
4
  "description": "Lightweight HTTP message relay for inter-agent communication across machines",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
@@ -34,6 +34,12 @@ export interface BranchLandedInput {
34
34
  * `undefined` from older orchestrators that don't report it → message stays generic.
35
35
  */
36
36
  pushed?: boolean;
37
+ /**
38
+ * The land came in via a pull request — the subject's trailing `(#NNN)` is the PR
39
+ * number, not an issue. Forwarded to the issue-lifecycle hook so it doesn't mistake
40
+ * the PR number for an issue to close (#482).
41
+ */
42
+ prLand?: boolean;
37
43
  }
38
44
 
39
45
  /**
@@ -172,7 +178,7 @@ export function reconcileLandedWorkspace(ws: WorkspaceRecord, preview: Workspace
172
178
  metadata: { source: "server", maintenanceJobId: "workspace-conflict-scan", workspaceId: ws.id, fromStatus: ws.status, ...(sha ? { landedSha: sha } : {}) },
173
179
  });
174
180
  try {
175
- notifyBranchLanded({ workspace: ws, mergedSha: sha, pushed: true });
181
+ notifyBranchLanded({ workspace: ws, mergedSha: sha, pushed: true, prLand: via === "pr" });
176
182
  } catch {
177
183
  // Notification is best-effort; the merged status + activity event still stand.
178
184
  }
package/src/db/schema.ts CHANGED
@@ -677,7 +677,8 @@ export function initDb(path: string = "agent-relay.db"): Database {
677
677
  );
678
678
  CREATE INDEX IF NOT EXISTS idx_activity_operator ON activity_events(operator_id, created_at);
679
679
  CREATE INDEX IF NOT EXISTS idx_activity_created ON activity_events(created_at);
680
- CREATE INDEX IF NOT EXISTS idx_activity_agent ON activity_events(agent_id, created_at); CREATE INDEX IF NOT EXISTS idx_activity_issue ON activity_events(issue_ref_id, created_at); CREATE INDEX IF NOT EXISTS idx_activity_agent_issue ON activity_events(agent_id, issue_ref_id, created_at);
680
+ CREATE INDEX IF NOT EXISTS idx_activity_agent ON activity_events(agent_id, created_at);
681
+ -- issue_ref_id/issue_action indexes are created in applyMigrations() AFTER the ALTER — never inline here (#483: inline index over a not-yet-migrated column crashes initDb on existing DBs).
681
682
 
682
683
  CREATE TABLE IF NOT EXISTS channels (
683
684
  id TEXT PRIMARY KEY,
@@ -250,6 +250,9 @@ export const patchCommand: Handler = async (req, params) => {
250
250
  subject: prLanded
251
251
  ? cleanString(prLanded.subject, "params.prLanded.subject", { max: 200 })
252
252
  : cleanString(command.result.subject, "result.subject", { max: 200 }),
253
+ // PR-land subjects carry a trailing `(#PR)` GitHub stamps on the squash — not an
254
+ // issue. Tell the lifecycle hook so it won't close the PR number as an issue (#482).
255
+ prLand: Boolean(prLanded),
253
256
  newBranch,
254
257
  pushed: prLanded ? true : typeof command.result.pushed === "boolean" ? command.result.pushed : undefined,
255
258
  });
@@ -22,6 +22,13 @@ export interface LandedIssueLifecycleInput {
22
22
  mergedSha?: string;
23
23
  subject?: string;
24
24
  source?: string;
25
+ /**
26
+ * The land happened via a pull request (the squash/merge subject carries a
27
+ * trailing `(#PR)` that GitHub appends — the PR number, NOT an issue). Bare-backlink
28
+ * parsing is suppressed in this mode so relay never mistakes the PR number for an
29
+ * issue to close; GitHub-native keywords + durable association still apply (#482).
30
+ */
31
+ prLand?: boolean;
25
32
  }
26
33
 
27
34
  export interface ReopenIssueLifecycleInput {
@@ -44,8 +51,10 @@ export interface IssueLifecycleOptions {
44
51
  log?: LogFn;
45
52
  }
46
53
 
54
+ export type LandIssueRefSource = "association" | "commit" | "backlink";
55
+
47
56
  export type CloseIssueLifecycleResult =
48
- | { issueRef: IssueRef; action: "closed"; source: "association" | "commit"; landKey: string }
57
+ | { issueRef: IssueRef; action: "closed"; source: LandIssueRefSource; landKey: string }
49
58
  | { issueRef: IssueRef; action: "skipped"; reason: "already-closed" | "already-processed"; landKey: string }
50
59
  | { issueRef: IssueRef; action: "failed"; error: string; landKey: string };
51
60
 
@@ -73,6 +82,51 @@ function defaultIssueRepo(): string | undefined {
73
82
  return process.env.AGENT_RELAY_DEFAULT_ISSUE_REPO || process.env.GITHUB_REPOSITORY || undefined;
74
83
  }
75
84
 
85
+ /**
86
+ * `owner/repo` out of any git remote URL form: `git@github.com:owner/repo.git`,
87
+ * `https://github.com/owner/repo.git`, `ssh://git@host/owner/repo`, trailing slash/`.git`
88
+ * optional. Pure — exported for tests.
89
+ */
90
+ export function parseOwnerRepoFromRemote(url: string | undefined): string | undefined {
91
+ if (!url) return undefined;
92
+ const match = url.trim().match(/[:/]([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+?)(?:\.git)?\/?$/);
93
+ return match?.[1];
94
+ }
95
+
96
+ let remoteRepoCache: Map<string, string | undefined> | undefined;
97
+
98
+ function repoFromGitRemote(repoRoot: string | undefined): string | undefined {
99
+ if (!repoRoot) return undefined;
100
+ if (!remoteRepoCache) remoteRepoCache = new Map();
101
+ if (remoteRepoCache.has(repoRoot)) return remoteRepoCache.get(repoRoot);
102
+ let repo: string | undefined;
103
+ try {
104
+ const proc = Bun.spawnSync(["git", "-C", repoRoot, "remote", "get-url", "origin"], {
105
+ stdin: "ignore",
106
+ stdout: "pipe",
107
+ stderr: "ignore",
108
+ });
109
+ if (proc.exitCode === 0) repo = parseOwnerRepoFromRemote(Buffer.from(proc.stdout).toString("utf8"));
110
+ } catch {
111
+ repo = undefined;
112
+ }
113
+ remoteRepoCache.set(repoRoot, repo);
114
+ return repo;
115
+ }
116
+
117
+ export function __resetRemoteRepoCacheForTests(): void {
118
+ remoteRepoCache = undefined;
119
+ }
120
+
121
+ /**
122
+ * The repo a bare `#NNN` in a land's commit subject resolves against. Derived per-workspace
123
+ * from its own git remote so a single relay serving many repos closes the RIGHT repo's issue;
124
+ * env (`AGENT_RELAY_DEFAULT_ISSUE_REPO`/`GITHUB_REPOSITORY`) is an override/fallback.
125
+ */
126
+ function landDefaultRepo(workspace: LifecycleWorkspace): string | undefined {
127
+ return repoFromGitRemote(workspace.repoRoot) ?? defaultIssueRepo();
128
+ }
129
+
76
130
  function cleanText(value: string | undefined): string | undefined {
77
131
  const cleaned = value?.trim();
78
132
  return cleaned || undefined;
@@ -116,33 +170,57 @@ const CLOSING_KEYWORD_RE = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+([^\n\
116
170
  const ISSUE_REF_AT_START_RE = /^(https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/issues\/[0-9]+(?:[/?#][^\s,)]*)?|[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+#[0-9]+|#[0-9]+)/;
117
171
  const REF_SEPARATOR_RE = /^(?:\s*,\s*|\s+(?:and|&)\s+)/i;
118
172
 
173
+ // A run of issue refs ("#1, #2 and owner/repo#3") starting at the head of `segment`.
174
+ // Bare refs resolve against `fallbackDefaultRepo`; unresolvable ones are skipped so a
175
+ // land never fails here.
176
+ function collectRefRun(segment: string, fallbackDefaultRepo: string | undefined, into: IssueRef[]): void {
177
+ let rest = segment;
178
+ while (rest.trim()) {
179
+ rest = rest.trimStart();
180
+ const refMatch = rest.match(ISSUE_REF_AT_START_RE);
181
+ if (!refMatch) break;
182
+ try {
183
+ into.push(resolveIssueRefFromString({ ref: refMatch[1]!, defaultRepo: fallbackDefaultRepo }));
184
+ } catch {
185
+ // Invalid or bare-without-default refs are ignored; the land itself must never fail here.
186
+ }
187
+ rest = rest.slice(refMatch[0].length);
188
+ const separator = rest.match(REF_SEPARATOR_RE);
189
+ if (!separator) break;
190
+ rest = rest.slice(separator[0].length);
191
+ }
192
+ }
193
+
119
194
  export function parseClosingIssueRefs(text: string | undefined, fallbackDefaultRepo = defaultIssueRepo()): IssueRef[] {
120
195
  if (!text) return [];
121
196
  const refs: IssueRef[] = [];
122
- for (const match of text.matchAll(CLOSING_KEYWORD_RE)) {
123
- let rest = match[1] ?? "";
124
- while (rest.trim()) {
125
- rest = rest.trimStart();
126
- const refMatch = rest.match(ISSUE_REF_AT_START_RE);
127
- if (!refMatch) break;
128
- try {
129
- refs.push(resolveIssueRefFromString({ ref: refMatch[1]!, defaultRepo: fallbackDefaultRepo }));
130
- } catch {
131
- // Invalid or bare-without-default refs are ignored; the land itself must never fail here.
132
- }
133
- rest = rest.slice(refMatch[0].length);
134
- const separator = rest.match(REF_SEPARATOR_RE);
135
- if (!separator) break;
136
- rest = rest.slice(separator[0].length);
137
- }
138
- }
197
+ for (const match of text.matchAll(CLOSING_KEYWORD_RE)) collectRefRun(match[1] ?? "", fallbackDefaultRepo, refs);
198
+ return issueRefMap(refs);
199
+ }
200
+
201
+ // Parenthesized backlink groups: the trailing `(#NNN)` convention agents (and a squash merge)
202
+ // stamp on a subject without a closing keyword. Non-issue parens (conventional-commit scopes
203
+ // like `fix(tokens)`, notes like `(see also)`) carry no `#` and fall out.
204
+ const BACKLINK_GROUP_RE = /\(([^()]*#[0-9]+[^()]*)\)/g;
205
+
206
+ export function parseBacklinkIssueRefs(text: string | undefined, fallbackDefaultRepo = defaultIssueRepo()): IssueRef[] {
207
+ if (!text) return [];
208
+ const refs: IssueRef[] = [];
209
+ for (const group of text.matchAll(BACKLINK_GROUP_RE)) collectRefRun(group[1] ?? "", fallbackDefaultRepo, refs);
139
210
  return issueRefMap(refs);
140
211
  }
141
212
 
142
- export function resolveIssueRefsForLand(input: LandedIssueLifecycleInput): { source: "association" | "commit"; refs: IssueRef[] } {
213
+ export function resolveIssueRefsForLand(input: LandedIssueLifecycleInput): { source: LandIssueRefSource; refs: IssueRef[] } {
143
214
  const associated = associatedIssueRefs(input.workspace);
144
215
  if (associated.length > 0) return { source: "association", refs: associated };
145
- return { source: "commit", refs: parseClosingIssueRefs(input.subject) };
216
+ const fallbackDefaultRepo = landDefaultRepo(input.workspace);
217
+ const keyworded = parseClosingIssueRefs(input.subject, fallbackDefaultRepo);
218
+ if (keyworded.length > 0) return { source: "commit", refs: keyworded };
219
+ // Bare `(#NNN)` is closing intent in relay-land mode (no PR), but the SAME shape is the
220
+ // PR number GitHub appends to a squash subject — never close that. Suppress on PR lands;
221
+ // those rely on GitHub-native keywords + durable association instead (#482).
222
+ if (input.prLand) return { source: "commit", refs: [] };
223
+ return { source: "backlink", refs: parseBacklinkIssueRefs(input.subject, fallbackDefaultRepo) };
146
224
  }
147
225
 
148
226
  function closeAuditComment(input: LandedIssueLifecycleInput): string {
@@ -152,7 +230,7 @@ function closeAuditComment(input: LandedIssueLifecycleInput): string {
152
230
 
153
231
  async function closeIssueRefForLand(
154
232
  issueRef: IssueRef,
155
- source: "association" | "commit",
233
+ source: LandIssueRefSource,
156
234
  input: LandedIssueLifecycleInput,
157
235
  opts: IssueLifecycleOptions,
158
236
  ): Promise<CloseIssueLifecycleResult> {
@@ -237,7 +315,7 @@ async function reopenIssuesForRevert(input: LandedIssueLifecycleInput, opts: Iss
237
315
  }
238
316
 
239
317
  export async function handleLandedIssueLifecycle(input: LandedIssueLifecycleInput, opts: IssueLifecycleOptions = {}): Promise<{
240
- source: "association" | "commit";
318
+ source: LandIssueRefSource;
241
319
  closed: CloseIssueLifecycleResult[];
242
320
  reopened: ReopenIssueLifecycleResult[];
243
321
  skipped: string[];