ai-agentmanager 1.2.1 → 1.3.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.
- package/CHANGELOG.md +20 -0
- package/README.md +94 -3
- package/docs/plugins.md +4 -1
- package/package.json +1 -1
- package/public/app.js +65 -3
- package/public/index.html +133 -1
- package/public/land.js +149 -0
- package/public/playbooks.js +132 -0
- package/public/review.js +368 -0
- package/public/setup.js +67 -0
- package/public/spend.js +82 -0
- package/public/style.css +104 -0
- package/public/triggers.js +85 -0
- package/src/assistant.js +13 -3
- package/src/budgets.js +166 -0
- package/src/cli.js +323 -14
- package/src/client.js +46 -6
- package/src/engines/claude.js +6 -4
- package/src/git.js +4 -4
- package/src/landing.js +212 -0
- package/src/manager.js +100 -68
- package/src/memory.js +106 -0
- package/src/notify.js +3 -2
- package/src/playbooks.js +178 -0
- package/src/review.js +209 -0
- package/src/server.js +87 -4
- package/src/stats.js +107 -0
- package/src/tracker.js +61 -8
- package/src/triggers.js +258 -0
package/src/tracker.js
CHANGED
|
@@ -72,8 +72,9 @@ export function recentSessionFiles(cwd, sinceMs) {
|
|
|
72
72
|
const URL_TOKEN = /https?:\/\/[^\s"'<>`\\)\]]+/g;
|
|
73
73
|
const PR_PATH = /^https?:\/\/[\w.-]+\/([\w.-]+\/[\w.-]+)\/pull\/(\d+)/;
|
|
74
74
|
const GH_ISSUE_PATH = /^https?:\/\/[\w.-]+\/([\w.-]+\/[\w.-]+)\/issues\/(\d+)(?:[/?#]|$)/;
|
|
75
|
-
//
|
|
76
|
-
|
|
75
|
+
// Issue keys in links: Jira /browse/KEY-1 and ?selectedIssue=KEY-1 (boards, Polaris ideas),
|
|
76
|
+
// /issues/KEY-1, and Linear /issue/KEY-1.
|
|
77
|
+
const JIRA_KEY = /(?:\/browse\/|[?&]selectedIssue=|\/issues?\/)([A-Z][A-Z0-9_]+-\d+)\b/;
|
|
77
78
|
|
|
78
79
|
const trimUrl = (url) => url.replace(/[.,;:!?'"]+$/, '');
|
|
79
80
|
|
|
@@ -95,7 +96,7 @@ export function bugFromRef(ref, source) {
|
|
|
95
96
|
return { key: ref, url: null, source };
|
|
96
97
|
}
|
|
97
98
|
|
|
98
|
-
/** The
|
|
99
|
+
/** The issue key when the text starts with a Jira or Linear link ("https://x.atlassian.net/browse/ABC-1 fix it" → "ABC-1"). */
|
|
99
100
|
export function leadingJiraKey(text) {
|
|
100
101
|
const first = String(text ?? '').trimStart().match(/^<?(https?:\/\/\S+)/);
|
|
101
102
|
return first?.[1].match(JIRA_KEY)?.[1] ?? null;
|
|
@@ -218,17 +219,69 @@ export function scanTranscript(file, cursor, bugPatterns = []) {
|
|
|
218
219
|
return result;
|
|
219
220
|
}
|
|
220
221
|
|
|
222
|
+
/** The gh CLI to run (AGENTMGR_GH_BIN points the tests at a fake one). */
|
|
223
|
+
export const ghBin = () => process.env.AGENTMGR_GH_BIN || 'gh';
|
|
224
|
+
|
|
225
|
+
const PR_FIELDS = 'number,title,url,state,isDraft,headRefOid,reviewDecision,mergeable,mergeStateStatus,statusCheckRollup,createdAt,mergedAt';
|
|
226
|
+
const FAILED = new Set(['FAILURE', 'ERROR', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED', 'STARTUP_FAILURE']);
|
|
227
|
+
const PASSED = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED']);
|
|
228
|
+
|
|
229
|
+
/** gh's statusCheckRollup -> { state: passed|failed|running|none, failed: [names], running, total } */
|
|
230
|
+
export function checkSummary(rollup) {
|
|
231
|
+
const list = Array.isArray(rollup) ? rollup : [];
|
|
232
|
+
const failed = [];
|
|
233
|
+
let running = 0;
|
|
234
|
+
for (const c of list) {
|
|
235
|
+
// Check runs have status/conclusion; commit statuses have state.
|
|
236
|
+
const result = c.conclusion || (c.status && c.status !== 'COMPLETED' ? 'PENDING' : null) || c.state || 'PENDING';
|
|
237
|
+
if (FAILED.has(result)) failed.push(c.name || c.context || 'check');
|
|
238
|
+
else if (!PASSED.has(result)) running += 1;
|
|
239
|
+
}
|
|
240
|
+
const state = !list.length ? 'none' : failed.length ? 'failed' : running ? 'running' : 'passed';
|
|
241
|
+
return { state, failed: failed.slice(0, 10), running, total: list.length };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Where a pull request stands on its way in: merged, closed, draft, fix, behind, ready or waiting. */
|
|
245
|
+
export function landState(pr) {
|
|
246
|
+
if (!pr?.state) return null;
|
|
247
|
+
if (pr.state === 'MERGED') return 'merged';
|
|
248
|
+
if (pr.state === 'CLOSED') return 'closed';
|
|
249
|
+
if (pr.isDraft) return 'draft';
|
|
250
|
+
const checks = pr.checks?.state;
|
|
251
|
+
if (checks === 'failed' || pr.review === 'CHANGES_REQUESTED' || pr.mergeable === 'CONFLICTING' || pr.mergeState === 'DIRTY') return 'fix';
|
|
252
|
+
if (pr.mergeState === 'BEHIND') return 'behind';
|
|
253
|
+
if (checks === 'running') return 'waiting';
|
|
254
|
+
if (pr.mergeState === 'CLEAN' || pr.mergeState === 'HAS_HOOKS' || pr.mergeState === 'UNSTABLE') return 'ready';
|
|
255
|
+
if (pr.review === 'APPROVED' && pr.mergeable === 'MERGEABLE') return 'ready';
|
|
256
|
+
return 'waiting';
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Keep what we use from gh's PR JSON, small enough to store per agent. */
|
|
260
|
+
export function compactPr(p) {
|
|
261
|
+
if (!p) return p;
|
|
262
|
+
const { statusCheckRollup, reviewDecision, mergeStateStatus, headRefOid, mergeable, createdAt, mergedAt, ...rest } = p;
|
|
263
|
+
const out = { ...rest };
|
|
264
|
+
if (createdAt) out.openedAt = createdAt;
|
|
265
|
+
if (mergedAt) out.mergedAt = mergedAt;
|
|
266
|
+
if (statusCheckRollup !== undefined) out.checks = checkSummary(statusCheckRollup);
|
|
267
|
+
if (reviewDecision !== undefined) out.review = reviewDecision || null;
|
|
268
|
+
if (mergeable !== undefined) out.mergeable = mergeable || null;
|
|
269
|
+
if (mergeStateStatus !== undefined) out.mergeState = mergeStateStatus || null;
|
|
270
|
+
if (headRefOid) out.head = headRefOid;
|
|
271
|
+
return out;
|
|
272
|
+
}
|
|
273
|
+
|
|
221
274
|
/** PRs whose head is `branch`, via the gh CLI. Returns [] when gh is missing or unauthenticated. */
|
|
222
275
|
export async function prsForBranch(cwd, branch) {
|
|
223
276
|
if (process.env.AGENTMGR_NO_GH) return [];
|
|
224
277
|
if (!branch || branch === 'HEAD' || ['main', 'master', 'develop', 'trunk'].includes(branch)) return [];
|
|
225
278
|
try {
|
|
226
279
|
const out = await run(
|
|
227
|
-
|
|
228
|
-
['pr', 'list', '--head', branch, '--state', 'all', '--limit', '20', '--json',
|
|
280
|
+
ghBin(),
|
|
281
|
+
['pr', 'list', '--head', branch, '--state', 'all', '--limit', '20', '--json', PR_FIELDS],
|
|
229
282
|
{ cwd, timeout: 30_000 },
|
|
230
283
|
);
|
|
231
|
-
return JSON.parse(out || '[]').map((p) => ({ ...prFromUrl(p.url, 'branch'), ...p }));
|
|
284
|
+
return JSON.parse(out || '[]').map((p) => ({ ...prFromUrl(p.url, 'branch'), ...compactPr(p) }));
|
|
232
285
|
} catch {
|
|
233
286
|
return [];
|
|
234
287
|
}
|
|
@@ -238,8 +291,8 @@ export async function prsForBranch(cwd, branch) {
|
|
|
238
291
|
export async function prDetails(url) {
|
|
239
292
|
if (process.env.AGENTMGR_NO_GH) return null;
|
|
240
293
|
try {
|
|
241
|
-
const out = await run(
|
|
242
|
-
return JSON.parse(out);
|
|
294
|
+
const out = await run(ghBin(), ['pr', 'view', url, '--json', PR_FIELDS], { timeout: 30_000 });
|
|
295
|
+
return compactPr(JSON.parse(out));
|
|
243
296
|
} catch {
|
|
244
297
|
return null;
|
|
245
298
|
}
|
package/src/triggers.js
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// Triggers: start agents and pass on instructions from where the work lives, with no model
|
|
2
|
+
// watching (no tokens spent). Three sources:
|
|
3
|
+
// - GitHub label: open issues with a label (default "agentmgr") in the repos or owners you list
|
|
4
|
+
// each get an agent in that repo. Labeling needs triage access, so it is the go-ahead.
|
|
5
|
+
// - "/agent" comments on an agent's own pull request, by the repo's owners, members or
|
|
6
|
+
// collaborators, are typed into that agent.
|
|
7
|
+
// - Webhook: POST /api/hooks/agents with the secret token creates an agent (Jira automation,
|
|
8
|
+
// CI, scripts). Off until a token is set; reachable from other machines only with remote access.
|
|
9
|
+
import crypto from 'node:crypto';
|
|
10
|
+
import { run } from './git.js';
|
|
11
|
+
import { ghBin } from './tracker.js';
|
|
12
|
+
|
|
13
|
+
export const TRIGGER_DEFAULTS = {
|
|
14
|
+
github: { enabled: false, label: 'agentmgr', repos: [], owners: [], everyMin: 5, comment: false },
|
|
15
|
+
prCommands: { enabled: true, prefix: '/agent' },
|
|
16
|
+
webhook: { enabled: false, token: null, repos: [] },
|
|
17
|
+
maxPerDay: 20,
|
|
18
|
+
};
|
|
19
|
+
const TRUSTED = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']);
|
|
20
|
+
const REPO_RE = /^[\w.-]+\/[\w.-]+$/;
|
|
21
|
+
|
|
22
|
+
const err = (status, message) => Object.assign(new Error(message), { status });
|
|
23
|
+
const list = (v) => (Array.isArray(v) ? v : String(v ?? '').split(/[\s,]+/)).map((x) => String(x).trim()).filter(Boolean);
|
|
24
|
+
|
|
25
|
+
/** Settings from config.json's "triggers", with defaults; the webhook token is never returned by `view`. */
|
|
26
|
+
export function triggerSettings(config = {}) {
|
|
27
|
+
const t = config.triggers ?? {};
|
|
28
|
+
const gh = { ...TRIGGER_DEFAULTS.github, ...t.github };
|
|
29
|
+
const pc = { ...TRIGGER_DEFAULTS.prCommands, ...t.prCommands };
|
|
30
|
+
const wh = { ...TRIGGER_DEFAULTS.webhook, ...t.webhook };
|
|
31
|
+
const every = Number(gh.everyMin);
|
|
32
|
+
const max = Number(t.maxPerDay ?? TRIGGER_DEFAULTS.maxPerDay);
|
|
33
|
+
return {
|
|
34
|
+
github: {
|
|
35
|
+
enabled: Boolean(gh.enabled),
|
|
36
|
+
label: String(gh.label || 'agentmgr').trim(),
|
|
37
|
+
repos: list(gh.repos).filter((r) => REPO_RE.test(r)),
|
|
38
|
+
owners: list(gh.owners).filter((o) => /^[\w.-]+$/.test(o)),
|
|
39
|
+
everyMin: Number.isFinite(every) && every >= 1 ? Math.min(1440, Math.floor(every)) : 5,
|
|
40
|
+
comment: Boolean(gh.comment),
|
|
41
|
+
},
|
|
42
|
+
prCommands: { enabled: pc.enabled !== false, prefix: String(pc.prefix || '/agent').trim() },
|
|
43
|
+
webhook: { enabled: Boolean(wh.enabled && wh.token), token: wh.token || null, repos: list(wh.repos) },
|
|
44
|
+
maxPerDay: Number.isFinite(max) && max >= 0 ? Math.min(500, Math.floor(max)) : TRIGGER_DEFAULTS.maxPerDay,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The request for an agent started from a GitHub issue. */
|
|
49
|
+
export function issueIntent(issue) {
|
|
50
|
+
const body = String(issue.body ?? '').trim();
|
|
51
|
+
return `${issue.url}\n\n${issue.title}${body ? `\n\n${body.slice(0, 6000)}` : ''}\n\nWhen you are done, open a pull request that says "Fixes #${issue.number}".`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A PR comment for the agent, or null when it isn't a command from someone trusted. */
|
|
55
|
+
export function prCommand(comment, prefix = '/agent') {
|
|
56
|
+
const body = String(comment?.body ?? '').trim();
|
|
57
|
+
if (!body.toLowerCase().startsWith(prefix.toLowerCase())) return null;
|
|
58
|
+
if (!TRUSTED.has(comment.authorAssociation)) return null;
|
|
59
|
+
const text = body.slice(prefix.length).trim();
|
|
60
|
+
return text || null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Same length and same bytes, without leaking where they differ. */
|
|
64
|
+
export function tokenMatches(given, expected) {
|
|
65
|
+
if (!given || !expected) return false;
|
|
66
|
+
const a = Buffer.from(String(given));
|
|
67
|
+
const b = Buffer.from(String(expected));
|
|
68
|
+
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export class Triggers {
|
|
72
|
+
constructor({ manager, store, config = {}, notifier = null, log = (m) => console.log(`[agentmgr] ${m}`) }) {
|
|
73
|
+
this.manager = manager;
|
|
74
|
+
this.store = store;
|
|
75
|
+
this.config = config;
|
|
76
|
+
this.notifier = notifier;
|
|
77
|
+
this.log = log;
|
|
78
|
+
store.data.triggers ??= { issues: {}, comments: {}, started: [], lastPoll: null, lastError: null };
|
|
79
|
+
this.schedule();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
get state() {
|
|
83
|
+
return this.store.data.triggers;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
get settings() {
|
|
87
|
+
return triggerSettings(this.config);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
schedule() {
|
|
91
|
+
clearInterval(this.timer);
|
|
92
|
+
this.timer = setInterval(() => this.poll().catch((e) => this.fail(e)), this.settings.github.everyMin * 60_000);
|
|
93
|
+
this.timer.unref?.();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
close() {
|
|
97
|
+
clearInterval(this.timer);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
fail(e) {
|
|
101
|
+
this.state.lastError = { at: new Date().toISOString(), message: e.message.split('\n')[0].slice(0, 300) };
|
|
102
|
+
this.log(`triggers: ${this.state.lastError.message}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Settings for the UI: the token is replaced by whether one is set. */
|
|
106
|
+
view() {
|
|
107
|
+
const s = this.settings;
|
|
108
|
+
const today = this.startedToday();
|
|
109
|
+
return {
|
|
110
|
+
...s,
|
|
111
|
+
webhook: { enabled: s.webhook.enabled, hasToken: Boolean(s.webhook.token), repos: s.webhook.repos },
|
|
112
|
+
startedToday: today.length,
|
|
113
|
+
recent: this.state.started.slice(-20).reverse(),
|
|
114
|
+
lastPoll: this.state.lastPoll,
|
|
115
|
+
lastError: this.state.lastError,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
update(patch = {}) {
|
|
120
|
+
const cur = this.config.triggers ?? {};
|
|
121
|
+
const next = {
|
|
122
|
+
github: { ...cur.github, ...patch.github },
|
|
123
|
+
prCommands: { ...cur.prCommands, ...patch.prCommands },
|
|
124
|
+
webhook: { ...cur.webhook, ...patch.webhook },
|
|
125
|
+
maxPerDay: patch.maxPerDay ?? cur.maxPerDay,
|
|
126
|
+
};
|
|
127
|
+
if (patch.webhook?.newToken) {
|
|
128
|
+
next.webhook.token = crypto.randomBytes(24).toString('base64url');
|
|
129
|
+
delete next.webhook.newToken;
|
|
130
|
+
}
|
|
131
|
+
if (next.github.enabled && !list(next.github.repos).length && !list(next.github.owners).length) {
|
|
132
|
+
throw err(400, 'List the repos (owner/name) or owners to watch, so issues elsewhere on GitHub never start agents');
|
|
133
|
+
}
|
|
134
|
+
const s = triggerSettings({ triggers: next });
|
|
135
|
+
this.config.triggers = { github: s.github, prCommands: s.prCommands, webhook: { enabled: s.webhook.enabled, token: s.webhook.token, repos: s.webhook.repos }, maxPerDay: s.maxPerDay };
|
|
136
|
+
this.schedule();
|
|
137
|
+
return { saved: this.config.triggers, token: patch.webhook?.newToken ? s.webhook.token : undefined };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
startedToday() {
|
|
141
|
+
const since = Date.now() - 86_400_000;
|
|
142
|
+
return this.state.started.filter((x) => Date.parse(x.at) > since);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Create one agent for a trigger, within the daily cap. */
|
|
146
|
+
start({ source, url, intent, repo, name, project, priority }) {
|
|
147
|
+
if (this.startedToday().length >= this.settings.maxPerDay) throw err(429, `Triggers started ${this.settings.maxPerDay} agents in the last day (triggers.maxPerDay)`);
|
|
148
|
+
const [agent] = this.manager.createMany([intent], {
|
|
149
|
+
repo,
|
|
150
|
+
name,
|
|
151
|
+
project,
|
|
152
|
+
priority,
|
|
153
|
+
trigger: { source, url: url ?? null, at: new Date().toISOString() },
|
|
154
|
+
});
|
|
155
|
+
this.state.started = [...this.state.started, { at: new Date().toISOString(), source, url: url ?? null, agent: agent.id, name: agent.name }].slice(-200);
|
|
156
|
+
this.store.save();
|
|
157
|
+
this.notifier?.send?.('assistant', agent, `was started from ${source === 'github' ? 'a GitHub label' : 'a webhook'}${url ? `: ${url}` : ''}`);
|
|
158
|
+
this.log(`triggers: started ${agent.name} from ${source}${url ? ` (${url})` : ''}`);
|
|
159
|
+
return agent;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async gh(args) {
|
|
163
|
+
return run(ghBin(), args, { timeout: 60_000 });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** One pass: labeled issues, then commands on agents' pull requests. */
|
|
167
|
+
async poll() {
|
|
168
|
+
if (process.env.AGENTMGR_NO_GH) return { issues: 0, commands: 0 };
|
|
169
|
+
const s = this.settings;
|
|
170
|
+
let issues = 0;
|
|
171
|
+
let commands = 0;
|
|
172
|
+
if (s.github.enabled && (s.github.repos.length || s.github.owners.length)) issues = await this.pollIssues(s.github);
|
|
173
|
+
if (s.prCommands.enabled) commands = await this.pollPrCommands(s.prCommands.prefix);
|
|
174
|
+
this.state.lastPoll = { at: new Date().toISOString(), issues, commands };
|
|
175
|
+
this.state.lastError = null;
|
|
176
|
+
this.store.save();
|
|
177
|
+
return { issues, commands };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async pollIssues(g) {
|
|
181
|
+
const scopes = [...g.repos.map((r) => ['--repo', r]), ...g.owners.map((o) => ['--owner', o])];
|
|
182
|
+
let started = 0;
|
|
183
|
+
for (const scope of scopes) {
|
|
184
|
+
const out = await this.gh(['search', 'issues', '--label', g.label, '--state', 'open', ...scope, '--limit', '30', '--json', 'number,title,body,url,repository']);
|
|
185
|
+
for (const issue of JSON.parse(out || '[]')) {
|
|
186
|
+
if (this.state.issues[issue.url]) continue;
|
|
187
|
+
const nwo = issue.repository?.nameWithOwner;
|
|
188
|
+
if (!nwo || !REPO_RE.test(nwo)) continue;
|
|
189
|
+
// Mark first: a failure must not start the same issue again on every pass.
|
|
190
|
+
this.state.issues[issue.url] = { at: new Date().toISOString() };
|
|
191
|
+
try {
|
|
192
|
+
// A project already on this repo reuses its clone and settings.
|
|
193
|
+
const project = this.projectFor(nwo);
|
|
194
|
+
const agent = this.start({ source: 'github', url: issue.url, intent: issueIntent(issue), ...(project ? { project: project.shortcut } : { repo: `https://github.com/${nwo}` }), name: `${issue.repository.name}#${issue.number}` });
|
|
195
|
+
this.state.issues[issue.url].agent = agent.id;
|
|
196
|
+
started += 1;
|
|
197
|
+
if (g.comment) {
|
|
198
|
+
await this.gh(['issue', 'comment', issue.url, '--body', `AI Agent Manager started an agent on this (\`${agent.name}\`). It will open a pull request.`]).catch(() => {});
|
|
199
|
+
}
|
|
200
|
+
} catch (e) {
|
|
201
|
+
this.state.issues[issue.url].error = e.message;
|
|
202
|
+
if (e.status === 429) break;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return started;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
projectFor(nwo) {
|
|
210
|
+
const re = new RegExp(`github\\.com[:/]${nwo.replace(/[.]/g, '\\.')}(\\.git)?/?$`, 'i');
|
|
211
|
+
return Object.values(this.store.data.projects ?? {}).find((p) => p.repo && re.test(p.repo) && p.shortcut) ?? null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async pollPrCommands(prefix) {
|
|
215
|
+
let sent = 0;
|
|
216
|
+
for (const agent of this.store.list()) {
|
|
217
|
+
for (const pr of Object.values(agent.prs ?? {})) {
|
|
218
|
+
if (pr.state !== 'OPEN' || pr.source === 'mentioned') continue;
|
|
219
|
+
const out = await this.gh(['pr', 'view', pr.url, '--json', 'comments']).catch(() => null);
|
|
220
|
+
if (!out) continue;
|
|
221
|
+
const comments = JSON.parse(out).comments ?? [];
|
|
222
|
+
const seen = (this.state.comments[pr.url] ??= { ids: [], since: new Date().toISOString() });
|
|
223
|
+
for (const c of comments) {
|
|
224
|
+
const id = c.id ?? c.url ?? `${c.author?.login}:${c.createdAt}`;
|
|
225
|
+
if (seen.ids.includes(id)) continue;
|
|
226
|
+
seen.ids = [...seen.ids, id].slice(-200);
|
|
227
|
+
// Comments from before we started watching this PR are history, not commands.
|
|
228
|
+
if (Date.parse(c.createdAt) < Date.parse(seen.since)) continue;
|
|
229
|
+
const text = prCommand(c, prefix);
|
|
230
|
+
if (!text) continue;
|
|
231
|
+
try {
|
|
232
|
+
this.manager.tell(agent.id, `${c.author?.login ?? 'Someone'} asked on your pull request ${pr.url}:\n${text}\n\nDo it, push, and reply on the pull request when done.`, { source: 'github' });
|
|
233
|
+
sent += 1;
|
|
234
|
+
} catch (e) {
|
|
235
|
+
this.log(`triggers: couldn't pass a comment to ${agent.name}: ${e.message}`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return sent;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** The webhook: { intent, repo?, project?, name?, priority? } with the token. */
|
|
244
|
+
hook(body = {}) {
|
|
245
|
+
const s = this.settings;
|
|
246
|
+
if (!s.webhook.enabled) throw err(404, 'Not found');
|
|
247
|
+
const intent = String(body.intent ?? '').trim();
|
|
248
|
+
if (!intent) throw err(400, 'intent is required');
|
|
249
|
+
const repo = body.repo ? String(body.repo).trim() : undefined;
|
|
250
|
+
const project = body.project ? String(body.project).trim() : undefined;
|
|
251
|
+
if (!repo && !project) throw err(400, 'repo or project is required');
|
|
252
|
+
if (s.webhook.repos.length && repo && !s.webhook.repos.some((r) => repo === r || repo.endsWith(`/${r}`) || repo.endsWith(`/${r}.git`))) {
|
|
253
|
+
throw err(403, 'That repo is not in triggers.webhook.repos');
|
|
254
|
+
}
|
|
255
|
+
const agent = this.start({ source: 'webhook', url: body.url ? String(body.url) : null, intent, repo, project, name: body.name ? String(body.name) : undefined, priority: body.priority });
|
|
256
|
+
return { id: agent.id, name: agent.name };
|
|
257
|
+
}
|
|
258
|
+
}
|