agent-relay-server 0.62.1 → 0.62.3

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.
@@ -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[];