@dfsramos/foreman 0.0.0-stage → 0.2.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/LICENSE +21 -0
  3. package/README.md +385 -2
  4. package/dist/agent/adapters/agy.js +96 -0
  5. package/dist/agent/adapters/claude.js +152 -0
  6. package/dist/agent/adapters/codex.js +107 -0
  7. package/dist/agent/adapters/opencode.js +91 -0
  8. package/dist/agent/config.js +169 -0
  9. package/dist/agent/process.js +230 -0
  10. package/dist/agent/prompt.js +184 -0
  11. package/dist/agent/registry.js +57 -0
  12. package/dist/agent/types.js +12 -0
  13. package/dist/cli-args.js +70 -0
  14. package/dist/cli.js +184 -0
  15. package/dist/config.js +102 -0
  16. package/dist/daemon.js +109 -0
  17. package/dist/db.js +467 -0
  18. package/dist/git/taskstore.js +224 -0
  19. package/dist/git/worktree.js +248 -0
  20. package/dist/imports/connections.js +57 -0
  21. package/dist/imports/destination.js +2 -0
  22. package/dist/imports/linear.js +40 -0
  23. package/dist/imports/registry.js +4 -0
  24. package/dist/imports/types.js +1 -0
  25. package/dist/kaneo/client.js +47 -0
  26. package/dist/kaneo/issues.js +138 -0
  27. package/dist/kaneo/workspace.js +24 -0
  28. package/dist/linear/browse.js +55 -0
  29. package/dist/linear/client.js +48 -0
  30. package/dist/linear/issues.js +99 -0
  31. package/dist/linear/workspace.js +30 -0
  32. package/dist/paths.js +12 -0
  33. package/dist/pidfile.js +35 -0
  34. package/dist/projects.js +211 -0
  35. package/dist/review/decide.js +186 -0
  36. package/dist/review/loop.js +114 -0
  37. package/dist/runner.js +1281 -0
  38. package/dist/server/app.js +344 -0
  39. package/dist/server/imports.js +182 -0
  40. package/dist/server/tasks.js +77 -0
  41. package/dist/sources/branch.js +17 -0
  42. package/dist/sources/git-import.js +60 -0
  43. package/dist/sources/git.js +430 -0
  44. package/dist/sources/kaneo.js +156 -0
  45. package/dist/sources/lease.js +38 -0
  46. package/dist/sources/linear.js +186 -0
  47. package/dist/sources/options.js +26 -0
  48. package/dist/sources/registry.js +6 -0
  49. package/dist/sources/taskfile.js +190 -0
  50. package/dist/sources/types.js +1 -0
  51. package/package.json +63 -4
  52. package/public/agents.js +435 -0
  53. package/public/app.js +1200 -0
  54. package/public/imports.js +481 -0
  55. package/public/index.html +19 -0
  56. package/public/projects.js +481 -0
  57. package/public/style.css +1614 -0
  58. package/public/tasks.js +253 -0
  59. package/public/ui.js +165 -0
@@ -0,0 +1,481 @@
1
+ // The Projects tab: each project's tracker, repository, settings and allowed agents, with its own
2
+ // start and stop. Secrets can be set here but are never shown: the server only says whether one is set.
3
+
4
+ import { api, banner, button, fill, fmtTime, h } from './ui.js';
5
+
6
+ const REVIEW_MODE_LABELS = {
7
+ none: 'No review',
8
+ copilot: 'Copilot review',
9
+ other_agent: 'Another agent',
10
+ any_agent: 'Any agent (prefers another)',
11
+ };
12
+ const REVIEW_MODE_HELP =
13
+ 'Who is asked to review a pull request when it opens. No review: a person requests or does it. Copilot: GitHub Copilot is requested. Another agent: an agent other than the author reviews; with no other agent, nothing happens. Any agent: as before, but the author’s agent reviews in a fresh session when no other can.';
14
+
15
+ // The project being added or edited, if any. Kept here so re-rendering the tab does not lose input.
16
+ let form = null;
17
+ // The last save and what its source check found, shown above the list until a form opens or the tab is left.
18
+ let saved = null;
19
+
20
+ export const isEditingProject = () => form !== null;
21
+
22
+ /** Closes the add/edit form, discarding its input; called when the user leaves the tab. */
23
+ export function resetProjectForm() {
24
+ form = null;
25
+ saved = null;
26
+ }
27
+
28
+ function openForm(data, project) {
29
+ saved = null;
30
+ const source = data.sources.find((type) => type.id === (project?.sourceType ?? data.sources[0]?.id));
31
+ form = {
32
+ id: project?.id ?? null,
33
+ title: project ? `Edit ${project.name}` : 'Add a project',
34
+ draft: {
35
+ name: project?.name ?? '',
36
+ priority: project?.priority ?? 'normal',
37
+ sourceType: source?.id ?? '',
38
+ // Secrets start empty: an empty secret on an existing project means "keep the stored one".
39
+ sourceSettings: Object.fromEntries(
40
+ (source?.fields ?? []).map((field) => [
41
+ field.key,
42
+ field.type === 'secret' ? '' : (project?.sourceSettings[field.key] ?? field.default ?? ''),
43
+ ]),
44
+ ),
45
+ repoPath: project?.repoPath ?? '',
46
+ worktreeRoot: project?.worktreeRoot ?? '',
47
+ pollIntervalSeconds: String(project?.pollIntervalSeconds ?? data.defaults.pollIntervalSeconds),
48
+ agentTimeoutMinutes: String(project?.agentTimeoutMinutes ?? data.defaults.agentTimeoutMinutes),
49
+ agents: [...(project?.agents ?? [])],
50
+ autostart: project?.autostart ?? false,
51
+ reviewMaxRounds: String(project?.reviewMaxRounds ?? data.defaults.reviewMaxRounds),
52
+ reviewIgnoreAuthors: (project?.reviewIgnoreAuthors ?? data.defaults.reviewIgnoreAuthors).join(', '),
53
+ reviewMode: project?.reviewMode ?? data.defaults.reviewMode,
54
+ waitForCi: project?.waitForCi ?? data.defaults.waitForCi,
55
+ },
56
+ secretsSet: Object.fromEntries(
57
+ (source?.fields ?? []).filter((field) => field.type === 'secret').map((field) => [field.key, project?.sourceSettings[field.key]?.set === true]),
58
+ ),
59
+ errors: {},
60
+ check: null,
61
+ // Choices the source listed for its fields (teams, projects, states), once loaded.
62
+ options: null,
63
+ lookup: { busy: false, ok: null, message: null },
64
+ };
65
+ }
66
+
67
+ const payload = () => ({ ...form.draft, ...(form.id === null ? {} : { id: form.id }) });
68
+
69
+ /**
70
+ * @param ctx.data the `/api/projects` response currently known
71
+ * @param ctx.status the latest runner status, for each project's live state
72
+ * @param ctx.agents agents, for the allow-list
73
+ * @param ctx.onChange called with a fresh `/api/projects` response after a change
74
+ * @param ctx.action posts a runner action (start/stop) and applies the result
75
+ * @param ctx.rerender rebuilds the tab
76
+ */
77
+ export function projectsTab(ctx) {
78
+ const { data } = ctx;
79
+ return h('div', { class: 'config' }, form ? formCard(ctx) : listView(ctx));
80
+
81
+ function listView() {
82
+ if (data.projects.length === 0) {
83
+ return h(
84
+ 'div',
85
+ { class: 'card stopped-card' },
86
+ h('h1', { class: 'title-l' }, 'No projects yet'),
87
+ h('p', null, 'A project connects a tracker to a repository. Add one to give foreman something to work on.'),
88
+ h('div', { class: 'actions' }, button('Add a project', { variant: 'primary tall', onclick: () => edit(null) })),
89
+ );
90
+ }
91
+ return [
92
+ banner(
93
+ 'run',
94
+ null,
95
+ 'Each project polls its own tracker and works in its own repository. When projects compete for an agent they take turns, weighted by priority: high gets three turns for every one of low, normal gets two.',
96
+ button('Add a project', { variant: 'primary', onclick: () => edit(null) }),
97
+ true,
98
+ ),
99
+ savedBanner(),
100
+ data.projects.map(projectCard),
101
+ ];
102
+ }
103
+
104
+ /** What the last save's source check found, shown until a form opens again. */
105
+ function savedBanner() {
106
+ if (!saved) return null;
107
+ const { name, check } = saved;
108
+ return banner('run', null, h('span', { class: check.ok ? 'text-ok' : 'text-fail' }, check.ok ? '✓ ' : '⚠ ', `Saved ${name}. `, check.message), null, true);
109
+ }
110
+
111
+ function edit(project) {
112
+ openForm(data, project);
113
+ ctx.rerender();
114
+ // With a stored key the choices can be listed straight away.
115
+ if (project && Object.values(form.secretsSet).some(Boolean)) void loadOptions();
116
+ }
117
+
118
+ /** Asks the source what the entered credentials can see, so fields can become lists to choose from. */
119
+ async function loadOptions() {
120
+ const current = form;
121
+ form.lookup = { busy: true, ok: null, message: null };
122
+ ctx.rerender();
123
+ const response = await api('POST', '/api/projects/lookup', payload());
124
+ // The form may have been closed, or another opened, while the tracker answered.
125
+ if (form !== current) return;
126
+ if (response.ok && response.data.ok) {
127
+ form.options = response.data.options;
128
+ form.lookup = { busy: false, ok: true, message: 'Loaded. Choose from the lists above.' };
129
+ } else if (response.data?.errors) {
130
+ form.errors = { ...form.errors, ...response.data.errors };
131
+ form.lookup = { busy: false, ok: false, message: null };
132
+ } else {
133
+ form.lookup = { busy: false, ok: false, message: response.data?.message ?? response.data?.error ?? `Could not load (${response.status})` };
134
+ }
135
+ ctx.rerender();
136
+ }
137
+
138
+ function projectCard(project) {
139
+ const status = ctx.status?.projects.find((candidate) => candidate.id === project.id) ?? project.status;
140
+ const started = status.state === 'idle' || status.state === 'working';
141
+ const countdown = h('span', { class: 'mono' }, ctx.nextPollText(status));
142
+ ctx.countdowns.push({ el: countdown, id: project.id });
143
+ const runs = status.activeRunIds.length;
144
+ const allowed =
145
+ project.agents.length === 0
146
+ ? 'Any agent'
147
+ : project.agents.map((id) => ctx.agents.find((agent) => agent.id === id)?.name ?? id).join(', ');
148
+ const facts = [
149
+ ['Source', project.sourceSummary],
150
+ ['Repository', project.repoPath],
151
+ ['Worktrees', project.worktreeRoot],
152
+ ['Agents', allowed],
153
+ [
154
+ 'Review loop',
155
+ project.reviewMaxRounds > 0
156
+ ? `up to ${project.reviewMaxRounds} ${project.reviewMaxRounds === 1 ? 'round' : 'rounds'} of feedback per issue`
157
+ : 'off: pull requests are not watched',
158
+ ],
159
+ ['Polling', `every ${project.pollIntervalSeconds}s · agent timeout ${project.agentTimeoutMinutes}m${project.autostart ? ' · starts with foreman' : ''}`],
160
+ ];
161
+ return h(
162
+ 'section',
163
+ { class: 'card', 'aria-label': project.name },
164
+ h(
165
+ 'div',
166
+ { class: 'card-head agent-head' },
167
+ h('h2', null, project.name),
168
+ h('span', { class: `badge tone-${project.priority === 'high' ? 'run' : 'muted'}` }, `${project.priority} priority`),
169
+ ctx.stateBadge(status.state, runs > 0 ? `${runs} ${runs === 1 ? 'run' : 'runs'}` : null),
170
+ h('span', { class: 'spacer' }),
171
+ status.state === 'stopped'
172
+ ? button('Start', { variant: 'primary', iconName: 'play', onclick: () => ctx.action(`/api/projects/${project.id}/start`) })
173
+ : started
174
+ ? button('Stop', { iconName: 'square', onclick: () => ctx.action(`/api/projects/${project.id}/stop`) })
175
+ : null,
176
+ button('Edit', { onclick: () => edit(project) }),
177
+ button('Delete', { variant: 'danger', disabled: status.state !== 'stopped', title: status.state === 'stopped' ? '' : 'Stop the project first', onclick: () => remove(project) }),
178
+ ),
179
+ h(
180
+ 'div',
181
+ { class: 'usage-line' },
182
+ status.lastError
183
+ ? h('span', { class: 'text-fail' }, status.lastError)
184
+ : status.state === 'stopped'
185
+ ? h('span', { class: 'faint' }, 'Not polling.')
186
+ : [
187
+ h('span', { class: 'muted' }, status.waiting ? 'Work waiting for a free agent · ' : ''),
188
+ h('span', { class: 'muted' }, 'next poll '),
189
+ countdown,
190
+ status.lastPollAt ? h('span', { class: 'faint' }, ` · last poll ${fmtTime(status.lastPollAt)}`) : null,
191
+ project.reviewMaxRounds > 0
192
+ ? h('span', { class: 'faint' }, ` · ${status.inReview} in review · ${status.awaitingApproval} pending approval`)
193
+ : null,
194
+ ],
195
+ ),
196
+ h('div', { class: 'kv-grid' }, facts.map(([key, value]) => h('div', null, h('div', { class: 'k' }, key), h('div', { class: 'v' }, value)))),
197
+ );
198
+ }
199
+
200
+ async function remove(project) {
201
+ if (!window.confirm(`Delete the project "${project.name}"? Its past runs stay in the history. Nothing is changed in the tracker or the repository.`)) return;
202
+ const response = await api('DELETE', `/api/projects/${project.id}`);
203
+ if (response.ok) {
204
+ saved = null;
205
+ ctx.onChange(response.data);
206
+ } else {
207
+ window.alert(response.data?.error ?? `Delete failed (${response.status})`);
208
+ }
209
+ }
210
+
211
+ function formCard() {
212
+ const source = data.sources.find((type) => type.id === form.draft.sourceType);
213
+ const { draft } = form;
214
+ const statusLine = h('div', { class: 'usage-line' });
215
+ const actions = h('div', { class: 'save-bar' });
216
+
217
+ const field = (label, key, control, help, wide = false) => {
218
+ const error = form.errors[key];
219
+ return h(
220
+ 'div',
221
+ { class: `field ${error ? 'error' : ''} ${wide ? 'wide' : ''}` },
222
+ h('div', { class: 'label-row' }, h('label', { for: control.id }, label)),
223
+ control,
224
+ h('div', { class: 'help' }, error ? `⚠ ${error}` : (help ?? '')),
225
+ );
226
+ };
227
+ const text = (label, key, read, write, { help, placeholder, secret = false, numeric = false, wide = false } = {}) => {
228
+ const input = h('input', {
229
+ id: `project-${key.replace(/\W/g, '-')}`,
230
+ class: 'input',
231
+ type: secret ? 'password' : 'text',
232
+ inputmode: numeric ? 'numeric' : null,
233
+ autocomplete: secret ? 'new-password' : 'off',
234
+ spellcheck: 'false',
235
+ placeholder,
236
+ value: read(),
237
+ });
238
+ input.addEventListener('input', () => {
239
+ write(input.value);
240
+ // The check result describes the settings as they were.
241
+ form.check = null;
242
+ refreshStatus();
243
+ });
244
+ return field(label, key, input, help, wide);
245
+ };
246
+ const select = (label, key, options, read, write, help) => {
247
+ const control = h(
248
+ 'select',
249
+ { id: `project-${key.replace(/\W/g, '-')}`, class: 'input' },
250
+ options.map(([value, name]) => h('option', { value, selected: read() === value }, name)),
251
+ );
252
+ control.addEventListener('change', () => write(control.value));
253
+ return field(label, key, control, help);
254
+ };
255
+
256
+ function refreshStatus() {
257
+ const messages = [];
258
+ if (form.check) {
259
+ messages.push(h('span', { class: form.check.ok ? 'text-ok' : 'text-fail' }, form.check.ok ? '✓ ' : '⚠ ', form.check.message));
260
+ }
261
+ for (const key of ['_', 'sourceType']) {
262
+ if (form.errors[key]) messages.push(h('span', { class: 'text-fail' }, `⚠ ${form.errors[key]}`));
263
+ }
264
+ fill(statusLine, messages);
265
+ statusLine.hidden = messages.length === 0;
266
+ }
267
+
268
+ async function check(event) {
269
+ const control = event.currentTarget;
270
+ control.disabled = true;
271
+ form.check = null;
272
+ const response = await api('POST', '/api/projects/check', payload());
273
+ if (!form) return;
274
+ if (response.ok) form.check = response.data;
275
+ else if (response.data?.errors) form.errors = response.data.errors;
276
+ else form.check = { ok: false, message: response.data?.error ?? `Check failed (${response.status})` };
277
+ ctx.rerender();
278
+ }
279
+
280
+ async function save() {
281
+ const response =
282
+ form.id === null ? await api('POST', '/api/projects', payload()) : await api('PUT', `/api/projects/${form.id}`, payload());
283
+ if (!form) return;
284
+ if (response.ok) {
285
+ saved = { name: draft.name.trim(), check: response.data.check };
286
+ form = null;
287
+ ctx.onChange(response.data);
288
+ } else {
289
+ form.errors = response.data?.errors ?? { _: response.data?.error ?? `Save failed (${response.status})` };
290
+ ctx.rerender();
291
+ }
292
+ }
293
+
294
+ /** A field the source listed choices for: a list narrowed by the field it depends on. */
295
+ const choice = (spec) => {
296
+ const settings = draft.sourceSettings;
297
+ const dependsOn = spec.lookup.dependsOn;
298
+ const parentField = source.fields.find((field) => field.key === dependsOn);
299
+ // An option without `for` applies whatever the parent field holds.
300
+ const available = (key, parentValue) => form.options[key].filter((option) => !option.for || option.for.includes(parentValue));
301
+ const options = available(spec.key, settings[dependsOn]);
302
+ const current = settings[spec.key] ?? '';
303
+ const blank = dependsOn && !settings[dependsOn] ? `Choose a ${parentField?.label.toLowerCase() ?? 'value'} first` : spec.required ? 'Choose…' : 'Any (no filter)';
304
+ const choices = [['', blank], ...options.map((option) => [option.value, option.label])];
305
+ if (current !== '' && !options.some((option) => option.value === current)) choices.push([current, `${current} (not in the list)`]);
306
+ return select(
307
+ spec.label,
308
+ `sourceSettings.${spec.key}`,
309
+ choices,
310
+ () => current,
311
+ (value) => {
312
+ settings[spec.key] = value;
313
+ form.check = null;
314
+ // Fields narrowed by this one lose a value that the new choice does not offer.
315
+ for (const other of source.fields) {
316
+ if (other.lookup?.dependsOn !== spec.key || !form.options[other.key]) continue;
317
+ if (!available(other.key, value).some((option) => option.value === settings[other.key])) settings[other.key] = '';
318
+ }
319
+ ctx.rerender();
320
+ },
321
+ spec.help,
322
+ );
323
+ };
324
+
325
+ const canLookUp = (source?.fields ?? []).some((spec) => spec.lookup);
326
+ const lookupRow = canLookUp
327
+ ? h(
328
+ 'div',
329
+ { class: 'lookup-row' },
330
+ button(form.options ? `Reload from ${source.name}` : `Load teams and projects from ${source.name}`, {
331
+ disabled: form.lookup.busy,
332
+ title: 'Uses the key above to list what it can see. Nothing is changed in the tracker.',
333
+ onclick: () => void loadOptions(),
334
+ }),
335
+ form.lookup.busy
336
+ ? h('span', { class: 'muted' }, 'Loading…')
337
+ : form.lookup.message
338
+ ? h('span', { class: form.lookup.ok ? 'text-ok' : 'text-fail' }, form.lookup.ok ? '✓ ' : '⚠ ', form.lookup.message)
339
+ : !form.options
340
+ ? h('span', { class: 'faint' }, 'Or enter the IDs and state names by hand.')
341
+ : null,
342
+ )
343
+ : null;
344
+
345
+ const sourceFields = (source?.fields ?? []).map((spec) =>
346
+ spec.lookup && form.options?.[spec.key]
347
+ ? choice(spec)
348
+ : text(
349
+ spec.label,
350
+ `sourceSettings.${spec.key}`,
351
+ () => draft.sourceSettings[spec.key] ?? '',
352
+ (value) => (draft.sourceSettings[spec.key] = value),
353
+ {
354
+ help: spec.type === 'secret' && form.secretsSet[spec.key] ? `${spec.help ?? ''} A key is set; leave empty to keep it.`.trim() : spec.help,
355
+ placeholder: spec.type === 'secret' && form.secretsSet[spec.key] ? '•••••••• (unchanged)' : undefined,
356
+ secret: spec.type === 'secret',
357
+ },
358
+ ),
359
+ );
360
+
361
+ const agentBoxes = h(
362
+ 'div',
363
+ { class: 'checks' },
364
+ ctx.agents.map((agent) => {
365
+ const box = h('input', { type: 'checkbox', id: `project-agent-${agent.id}`, checked: draft.agents.includes(agent.id) });
366
+ box.addEventListener('change', () => {
367
+ draft.agents = box.checked ? [...draft.agents, agent.id] : draft.agents.filter((id) => id !== agent.id);
368
+ });
369
+ return h('label', { class: 'switch', for: box.id }, box, agent.name, agent.available && agent.enabled ? null : h('span', { class: 'faint' }, agent.available ? '(off)' : '(not installed)'));
370
+ }),
371
+ );
372
+ const autostart = h('input', { type: 'checkbox', id: 'project-autostart', checked: draft.autostart });
373
+ autostart.addEventListener('change', () => (draft.autostart = autostart.checked));
374
+ const waitForCi = h('input', { type: 'checkbox', id: 'project-wait-for-ci', checked: draft.waitForCi });
375
+ waitForCi.addEventListener('change', () => (draft.waitForCi = waitForCi.checked));
376
+
377
+ const section = (title, sub, body) =>
378
+ h('section', { class: 'card' }, h('div', { class: 'card-head' }, h('h2', null, title), sub ? h('span', { class: 'sub' }, sub) : null), body);
379
+
380
+ fill(
381
+ actions,
382
+ h('span', { class: 'note' }, form.id === null ? 'The project is created stopped.' : 'Changes apply from the project’s next poll.'),
383
+ h('span', { class: 'spacer' }),
384
+ button('Cancel', {
385
+ onclick: () => {
386
+ form = null;
387
+ ctx.rerender();
388
+ },
389
+ }),
390
+ button('Check source', { onclick: check, title: 'Try these source settings against the tracker without saving' }),
391
+ button(form.id === null ? 'Create project' : 'Save changes', { variant: 'primary', onclick: save }),
392
+ );
393
+ refreshStatus();
394
+
395
+ return [
396
+ h('h1', { class: 'title-l' }, form.title),
397
+ section(
398
+ 'Project',
399
+ null,
400
+ h(
401
+ 'div',
402
+ { class: 'fields' },
403
+ text('Name', 'name', () => draft.name, (value) => (draft.name = value)),
404
+ select(
405
+ 'Priority',
406
+ 'priority',
407
+ data.priorities.map((value) => [value, value[0].toUpperCase() + value.slice(1)]),
408
+ () => draft.priority,
409
+ (value) => (draft.priority = value),
410
+ 'Decides how often this project gets an agent when projects compete.',
411
+ ),
412
+ ),
413
+ ),
414
+ section(
415
+ `Source · ${source?.name ?? draft.sourceType}`,
416
+ 'Where this project’s issues come from',
417
+ [h('div', { class: 'fields' }, sourceFields), lookupRow, statusLine],
418
+ ),
419
+ section(
420
+ 'Repository',
421
+ 'One project per repository',
422
+ h(
423
+ 'div',
424
+ { class: 'fields' },
425
+ text('Repository path', 'repoPath', () => draft.repoPath, (value) => (draft.repoPath = value), {
426
+ help: 'An existing local git repository with an origin remote.',
427
+ }),
428
+ text('Worktree root', 'worktreeRoot', () => draft.worktreeRoot, (value) => (draft.worktreeRoot = value), {
429
+ help: 'One folder per issue; failed runs keep theirs. Empty uses a folder in foreman’s data directory.',
430
+ placeholder: `${data.defaults.worktreeRoot}/<project>`,
431
+ }),
432
+ ),
433
+ ),
434
+ section(
435
+ 'Polling and agents',
436
+ null,
437
+ h(
438
+ 'div',
439
+ { class: 'fields' },
440
+ text('Poll interval (seconds)', 'pollIntervalSeconds', () => draft.pollIntervalSeconds, (value) => (draft.pollIntervalSeconds = value), { numeric: true }),
441
+ text('Agent timeout (minutes)', 'agentTimeoutMinutes', () => draft.agentTimeoutMinutes, (value) => (draft.agentTimeoutMinutes = value), {
442
+ numeric: true,
443
+ }),
444
+ field('Allowed agents', 'agents', agentBoxes, 'None selected means any agent may be used.', true),
445
+ h('div', { class: 'field wide' }, h('label', { class: 'switch', for: autostart.id }, autostart, 'Start polling when foreman starts')),
446
+ ),
447
+ ),
448
+ section(
449
+ 'Review loop',
450
+ 'What happens after the pull request is open',
451
+ h(
452
+ 'div',
453
+ { class: 'fields' },
454
+ select(
455
+ 'Review',
456
+ 'reviewMode',
457
+ data.reviewModes.map((value) => [value, REVIEW_MODE_LABELS[value] ?? value]),
458
+ () => draft.reviewMode,
459
+ (value) => (draft.reviewMode = value),
460
+ REVIEW_MODE_HELP,
461
+ ),
462
+ text('Rounds of feedback per issue', 'reviewMaxRounds', () => draft.reviewMaxRounds, (value) => (draft.reviewMaxRounds = value), {
463
+ numeric: true,
464
+ help: 'New review feedback is handed to an agent this many times; after that the issue goes to a person. 0 turns the loop off.',
465
+ }),
466
+ text('Ignore comments from', 'reviewIgnoreAuthors', () => draft.reviewIgnoreAuthors, (value) => (draft.reviewIgnoreAuthors = value), {
467
+ help: 'GitHub accounts, comma-separated, whose comments are not review feedback: deployment bots and the like.',
468
+ }),
469
+ field(
470
+ 'CI',
471
+ 'waitForCi',
472
+ h('label', { class: 'switch', for: waitForCi.id }, waitForCi, 'Wait for green CI'),
473
+ 'Failing checks go back to the agent, approvals wait until the checks pass, and failures the agent cannot fix are rerun at most twice before a person takes over.',
474
+ true,
475
+ ),
476
+ ),
477
+ ),
478
+ actions,
479
+ ];
480
+ }
481
+ }