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.
- package/docs/openapi.json +1 -1
- package/package.json +2 -2
- package/public/assets/display-DzNvOu1j.js.map +1 -1
- package/scripts/orchestrator-spawn-smoke.ts +87 -67
- package/src/branch-landed.ts +7 -1
- package/src/db/delivery.ts +68 -18
- package/src/db/message-reads.ts +131 -9
- package/src/db/messages.ts +6 -7
- package/src/db/schema.ts +2 -1
- package/src/routes/commands.ts +3 -0
- package/src/services/issue-lifecycle.ts +100 -22
|
@@ -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:
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
318
|
+
source: LandIssueRefSource;
|
|
241
319
|
closed: CloseIssueLifecycleResult[];
|
|
242
320
|
reopened: ReopenIssueLifecycleResult[];
|
|
243
321
|
skipped: string[];
|