@dfsramos/foreman 0.0.0-stage → 0.3.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 +23 -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 +94 -0
  41. package/dist/sources/branch.js +17 -0
  42. package/dist/sources/git-import.js +85 -0
  43. package/dist/sources/git.js +431 -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 +210 -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 +279 -0
  59. package/public/ui.js +165 -0
@@ -0,0 +1,48 @@
1
+ const ENDPOINT = 'https://api.linear.app/graphql';
2
+ const REQUEST_TIMEOUT_MS = 30_000;
3
+ export class LinearError extends Error {
4
+ status;
5
+ constructor(message, status) {
6
+ super(message);
7
+ this.name = 'LinearError';
8
+ this.status = status;
9
+ }
10
+ }
11
+ export function createLinearClient(apiKey, fetchImpl = fetch) {
12
+ return {
13
+ async request(query, variables) {
14
+ let response;
15
+ try {
16
+ response = await fetchImpl(ENDPOINT, {
17
+ method: 'POST',
18
+ // Personal API keys are sent bare; only OAuth tokens take a "Bearer" prefix.
19
+ headers: { 'Content-Type': 'application/json', Authorization: apiKey },
20
+ body: JSON.stringify({ query, variables }),
21
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
22
+ });
23
+ }
24
+ catch (error) {
25
+ const reason = error instanceof Error ? error.message : String(error);
26
+ throw new LinearError(`Linear API unreachable: ${reason}`);
27
+ }
28
+ let body;
29
+ try {
30
+ body = (await response.json());
31
+ }
32
+ catch {
33
+ body = undefined;
34
+ }
35
+ const messages = body?.errors?.map((e) => e.message).join('; ');
36
+ if (!response.ok) {
37
+ throw new LinearError(`Linear API returned HTTP ${response.status}${messages ? `: ${messages}` : ''}`, response.status);
38
+ }
39
+ if (messages) {
40
+ throw new LinearError(`Linear API error: ${messages}`, response.status);
41
+ }
42
+ if (!body || body.data === undefined || body.data === null) {
43
+ throw new LinearError('Linear API returned an empty response', response.status);
44
+ }
45
+ return body.data;
46
+ },
47
+ };
48
+ }
@@ -0,0 +1,99 @@
1
+ import { LinearError } from './client.js';
2
+ const STATES_QUERY = `
3
+ query TeamStates($teamId: String!) {
4
+ team(id: $teamId) { states { nodes { id name } } }
5
+ }`;
6
+ // The label condition is only part of the query when a label is asked for.
7
+ const candidatesQuery = (withLabel) => `
8
+ query Candidates($teamId: ID!, $projectId: ID!, $stateId: ID!, $after: String${withLabel ? ', $label: String!' : ''}) {
9
+ issues(
10
+ first: 100
11
+ after: $after
12
+ filter: {
13
+ team: { id: { eq: $teamId } }
14
+ project: { id: { eq: $projectId } }
15
+ state: { id: { eq: $stateId } }${withLabel ? '\n labels: { some: { name: { eqIgnoreCase: $label } } }' : ''}
16
+ }
17
+ ) {
18
+ nodes { id identifier title description url priority createdAt branchName }
19
+ pageInfo { hasNextPage endCursor }
20
+ }
21
+ }`;
22
+ // Bounds a poll against a runaway cursor; 2000 issues waiting in one state is already far outside normal use.
23
+ const MAX_CANDIDATE_PAGES = 20;
24
+ const MOVE_MUTATION = `
25
+ mutation MoveIssue($id: String!, $stateId: String!) {
26
+ issueUpdate(id: $id, input: { stateId: $stateId }) { success }
27
+ }`;
28
+ const COMMENT_MUTATION = `
29
+ mutation AddComment($issueId: String!, $body: String!) {
30
+ commentCreate(input: { issueId: $issueId, body: $body }) { success }
31
+ }`;
32
+ // "No priority" (0) sorts after "low" (4).
33
+ const priorityRank = (priority) => (priority === 0 ? 5 : priority);
34
+ export function createIssueApi(client) {
35
+ return {
36
+ async resolveStates(teamId, names) {
37
+ const ids = await this.findStateIds(teamId, [names.target, names.inProgress, names.success]);
38
+ return { targetId: ids[names.target], inProgressId: ids[names.inProgress], successId: ids[names.success] };
39
+ },
40
+ async findStateIds(teamId, names) {
41
+ const data = await client.request(STATES_QUERY, { teamId });
42
+ const states = data.team.states.nodes;
43
+ const ids = {};
44
+ const missing = [];
45
+ for (const name of names) {
46
+ const id = states.find((s) => s.name.toLowerCase() === name.trim().toLowerCase())?.id;
47
+ if (id)
48
+ ids[name] = id;
49
+ else
50
+ missing.push(name);
51
+ }
52
+ if (missing.length > 0) {
53
+ const available = states.map((s) => s.name).join(', ');
54
+ const quoted = [...new Set(missing)].map((name) => `"${name}"`).join(', ');
55
+ throw new LinearError(`Workflow state not found: ${quoted}. Available states: ${available}`);
56
+ }
57
+ return ids;
58
+ },
59
+ async fetchCandidates(teamId, projectId, stateId, label) {
60
+ const wanted = label?.trim() ?? '';
61
+ const query = candidatesQuery(wanted !== '');
62
+ // Every page is needed: the pick order is decided here, not by the API.
63
+ const nodes = [];
64
+ let after = null;
65
+ for (let page = 0; page < MAX_CANDIDATE_PAGES; page += 1) {
66
+ const data = await client.request(query, {
67
+ teamId,
68
+ projectId,
69
+ stateId,
70
+ ...(wanted !== '' ? { label: wanted } : {}),
71
+ after,
72
+ });
73
+ nodes.push(...data.issues.nodes);
74
+ if (!data.issues.pageInfo?.hasNextPage || !data.issues.pageInfo.endCursor)
75
+ break;
76
+ after = data.issues.pageInfo.endCursor;
77
+ }
78
+ return nodes.sort((a, b) => priorityRank(a.priority) - priorityRank(b.priority) || a.createdAt.localeCompare(b.createdAt));
79
+ },
80
+ async moveIssue(issueId, stateId) {
81
+ const data = await client.request(MOVE_MUTATION, {
82
+ id: issueId,
83
+ stateId,
84
+ });
85
+ if (!data.issueUpdate.success) {
86
+ throw new LinearError(`Linear did not update issue ${issueId}`);
87
+ }
88
+ },
89
+ async addComment(issueId, body) {
90
+ const data = await client.request(COMMENT_MUTATION, {
91
+ issueId,
92
+ body,
93
+ });
94
+ if (!data.commentCreate.success) {
95
+ throw new LinearError(`Linear did not create a comment on issue ${issueId}`);
96
+ }
97
+ },
98
+ };
99
+ }
@@ -0,0 +1,30 @@
1
+ // Linear limits query complexity, and nesting two lists under teams exceeds it, so the projects
2
+ // are fetched per team.
3
+ const TEAMS_QUERY = `
4
+ query Teams {
5
+ teams(first: 50) {
6
+ nodes { id key name states(first: 50) { nodes { name position } } }
7
+ }
8
+ }`;
9
+ const PROJECTS_QUERY = `
10
+ query TeamProjects($teamId: String!) {
11
+ team(id: $teamId) { projects(first: 100) { nodes { id name } } labels(first: 100) { nodes { name } } }
12
+ issueLabels(first: 100, filter: { team: { null: true } }) { nodes { name } }
13
+ }`;
14
+ /** The teams an API key can see, each with its workflow states and projects. Read-only. */
15
+ export async function fetchWorkspace(client) {
16
+ const data = await client.request(TEAMS_QUERY, {});
17
+ const teams = [];
18
+ for (const team of data.teams.nodes) {
19
+ const projects = await client.request(PROJECTS_QUERY, { teamId: team.id });
20
+ teams.push({
21
+ id: team.id,
22
+ key: team.key,
23
+ name: team.name,
24
+ states: [...team.states.nodes].sort((a, b) => a.position - b.position).map((state) => state.name),
25
+ labels: [...new Set([...projects.team.labels.nodes, ...projects.issueLabels.nodes].map((label) => label.name))].sort((a, b) => a.localeCompare(b)),
26
+ projects: [...projects.team.projects.nodes].sort((a, b) => a.name.localeCompare(b.name)),
27
+ });
28
+ }
29
+ return teams.sort((a, b) => a.name.localeCompare(b.name));
30
+ }
package/dist/paths.js ADDED
@@ -0,0 +1,12 @@
1
+ import path from 'node:path';
2
+ export const PID_FILE = 'foreman.pid';
3
+ export const LOG_FILE = 'foreman.log';
4
+ export const ENV_FILE = '.env';
5
+ /** Where foreman keeps its data when DATA_DIR is not set: the platform's standard place for per-user application data. */
6
+ export function defaultDataDir(platform, env, home) {
7
+ if (platform === 'darwin')
8
+ return path.join(home, 'Library', 'Application Support', 'foreman');
9
+ // The XDG spec says a relative XDG_DATA_HOME is invalid and must be ignored.
10
+ const xdg = env.XDG_DATA_HOME?.trim();
11
+ return path.join(xdg && path.isAbsolute(xdg) ? xdg : path.join(home, '.local', 'share'), 'foreman');
12
+ }
@@ -0,0 +1,35 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { PID_FILE } from './paths.js';
4
+ export function writePidFile(dataDir, pid) {
5
+ fs.writeFileSync(path.join(dataDir, PID_FILE), String(pid));
6
+ }
7
+ export function removePidFile(dataDir) {
8
+ fs.rmSync(path.join(dataDir, PID_FILE), { force: true });
9
+ }
10
+ /** True while a process with this pid exists; one owned by another user (EPERM) exists too. */
11
+ export function isAlive(pid) {
12
+ try {
13
+ process.kill(pid, 0);
14
+ return true;
15
+ }
16
+ catch (error) {
17
+ return error.code === 'EPERM';
18
+ }
19
+ }
20
+ /** The pid of the running foreman, or null when there is no pid file or its process has exited. */
21
+ export function readLivePid(dataDir) {
22
+ let content;
23
+ try {
24
+ content = fs.readFileSync(path.join(dataDir, PID_FILE), 'utf8').trim();
25
+ }
26
+ catch (error) {
27
+ if (error.code === 'ENOENT')
28
+ return null;
29
+ throw error;
30
+ }
31
+ const pid = Number(content);
32
+ if (!/^\d+$/.test(content) || pid <= 0)
33
+ return null;
34
+ return isAlive(pid) ? pid : null;
35
+ }
@@ -0,0 +1,211 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { AGENT_TIMEOUT_RANGE, POLL_INTERVAL_RANGE } from './config.js';
4
+ import { SOURCE_TYPES } from './sources/registry.js';
5
+ export const PRIORITIES = ['low', 'normal', 'high'];
6
+ /** How many turns a project gets, relative to the others, when agent slots are contended. */
7
+ export const PRIORITY_WEIGHTS = { low: 1, normal: 2, high: 3 };
8
+ /**
9
+ * Who is asked to review a pull request once it opens: nobody (a person does it), GitHub Copilot,
10
+ * an agent other than the author's, or any agent, preferring another than the author's.
11
+ */
12
+ export const REVIEW_MODES = ['none', 'copilot', 'other_agent', 'any_agent'];
13
+ export const DEFAULT_REVIEW_ROUNDS = 5;
14
+ /** Bots that comment on pull requests without reviewing them. Review bots are deliberately not listed. */
15
+ export const DEFAULT_IGNORED_AUTHORS = ['vercel', 'linear', 'linear-code', 'github-actions', 'netlify', 'dependabot'];
16
+ const REVIEW_ROUNDS_RANGE = { min: 0, max: 20 };
17
+ const slug = (name) => name
18
+ .toLowerCase()
19
+ .replace(/[^a-z0-9]+/g, '-')
20
+ .replace(/^-+|-+$/g, '') || 'project';
21
+ const wholeNumber = (value, range) => {
22
+ const n = typeof value === 'string' && /^\d+$/.test(value.trim()) ? Number(value) : value;
23
+ return typeof n === 'number' && Number.isInteger(n) && n >= range.min && n <= range.max ? n : null;
24
+ };
25
+ /** Turns console input into a storable project, or says what is wrong with it field by field. */
26
+ export function validateProject(input, options) {
27
+ const { existing, others, dataDir, agentIds, sourceTypes = SOURCE_TYPES, sourceOnly = false } = options;
28
+ const errors = {};
29
+ if (typeof input !== 'object' || input === null || Array.isArray(input)) {
30
+ return { errors: { _: 'Request body must be a JSON object' } };
31
+ }
32
+ const body = input;
33
+ const sourceType = sourceTypes.find((type) => type.id === body.sourceType);
34
+ if (!sourceType)
35
+ errors.sourceType = 'is not a known source';
36
+ const rawSettings = typeof body.sourceSettings === 'object' && body.sourceSettings !== null ? body.sourceSettings : {};
37
+ const sourceSettings = {};
38
+ for (const field of sourceType?.fields ?? []) {
39
+ const raw = rawSettings[field.key];
40
+ let value = typeof raw === 'string' ? raw.trim() : '';
41
+ // A secret left empty while editing means "keep what is stored".
42
+ if (value === '' && field.type === 'secret' && existing?.sourceType === sourceType?.id) {
43
+ value = existing?.sourceSettings[field.key] ?? '';
44
+ }
45
+ // An optional field left empty on purpose stays empty; only a missing or required one takes its default.
46
+ if (value === '' && field.default !== undefined && (raw === undefined || field.required))
47
+ value = field.default;
48
+ if (raw !== undefined && typeof raw !== 'string')
49
+ errors[`sourceSettings.${field.key}`] = 'must be text';
50
+ else if (value === '' && field.required)
51
+ errors[`sourceSettings.${field.key}`] = 'is required';
52
+ sourceSettings[field.key] = value;
53
+ }
54
+ for (const key of Object.keys(rawSettings)) {
55
+ if (sourceType && !sourceType.fields.some((field) => field.key === key)) {
56
+ errors[`sourceSettings.${key}`] = 'is not a setting of this source';
57
+ }
58
+ }
59
+ const name = typeof body.name === 'string' ? body.name.trim() : '';
60
+ const repoPath = typeof body.repoPath === 'string' ? body.repoPath.trim() : '';
61
+ if (!sourceOnly) {
62
+ if (name === '')
63
+ errors.name = 'is required';
64
+ else if (name.length > 80)
65
+ errors.name = 'must be at most 80 characters';
66
+ else if (others.some((other) => other.name.toLowerCase() === name.toLowerCase()))
67
+ errors.name = 'is already used by another project';
68
+ if (repoPath === '')
69
+ errors.repoPath = 'is required';
70
+ else if (!path.isAbsolute(repoPath))
71
+ errors.repoPath = 'must be an absolute path';
72
+ else if (!fs.existsSync(repoPath))
73
+ errors.repoPath = 'does not exist';
74
+ else if (!fs.existsSync(path.join(repoPath, '.git')))
75
+ errors.repoPath = 'is not a git repository';
76
+ else {
77
+ const taken = others.find((other) => path.resolve(other.repoPath) === path.resolve(repoPath));
78
+ if (taken)
79
+ errors.repoPath = `is already used by the project "${taken.name}"`;
80
+ }
81
+ }
82
+ let worktreeRoot = typeof body.worktreeRoot === 'string' ? body.worktreeRoot.trim() : '';
83
+ if (worktreeRoot === '')
84
+ worktreeRoot = path.join(dataDir, 'worktrees', slug(name));
85
+ else if (!path.isAbsolute(worktreeRoot))
86
+ errors.worktreeRoot = 'must be an absolute path';
87
+ const priority = body.priority ?? 'normal';
88
+ if (!PRIORITIES.includes(priority))
89
+ errors.priority = `must be one of: ${PRIORITIES.join(', ')}`;
90
+ const pollIntervalSeconds = wholeNumber(body.pollIntervalSeconds ?? 60, POLL_INTERVAL_RANGE);
91
+ if (pollIntervalSeconds === null) {
92
+ errors.pollIntervalSeconds = `must be a whole number between ${POLL_INTERVAL_RANGE.min} and ${POLL_INTERVAL_RANGE.max}`;
93
+ }
94
+ const agentTimeoutMinutes = wholeNumber(body.agentTimeoutMinutes ?? 30, AGENT_TIMEOUT_RANGE);
95
+ if (agentTimeoutMinutes === null) {
96
+ errors.agentTimeoutMinutes = `must be a whole number between ${AGENT_TIMEOUT_RANGE.min} and ${AGENT_TIMEOUT_RANGE.max}`;
97
+ }
98
+ const agents = body.agents ?? [];
99
+ if (!Array.isArray(agents) || agents.some((id) => typeof id !== 'string'))
100
+ errors.agents = 'must be a list of agent ids';
101
+ else if (agents.some((id) => !agentIds.includes(id)))
102
+ errors.agents = 'contains an unknown agent';
103
+ else if (new Set(agents).size !== agents.length)
104
+ errors.agents = 'must not contain duplicates';
105
+ if (body.autostart !== undefined && typeof body.autostart !== 'boolean')
106
+ errors.autostart = 'must be true or false';
107
+ const reviewMaxRounds = wholeNumber(body.reviewMaxRounds ?? existing?.reviewMaxRounds ?? DEFAULT_REVIEW_ROUNDS, REVIEW_ROUNDS_RANGE);
108
+ if (reviewMaxRounds === null) {
109
+ errors.reviewMaxRounds = `must be a whole number between ${REVIEW_ROUNDS_RANGE.min} and ${REVIEW_ROUNDS_RANGE.max}`;
110
+ }
111
+ const rawIgnored = body.reviewIgnoreAuthors ?? existing?.reviewIgnoreAuthors ?? DEFAULT_IGNORED_AUTHORS;
112
+ const ignoredList = typeof rawIgnored === 'string' ? rawIgnored.split(',') : rawIgnored;
113
+ let reviewIgnoreAuthors = [];
114
+ if (!Array.isArray(ignoredList) || ignoredList.some((author) => typeof author !== 'string')) {
115
+ errors.reviewIgnoreAuthors = 'must be a list of GitHub account names';
116
+ }
117
+ else {
118
+ reviewIgnoreAuthors = [...new Set(ignoredList.map((author) => author.trim().replace(/^@/, '')).filter((author) => author !== ''))];
119
+ }
120
+ const reviewMode = body.reviewMode ?? existing?.reviewMode ?? 'none';
121
+ if (!REVIEW_MODES.includes(reviewMode))
122
+ errors.reviewMode = `must be one of: ${REVIEW_MODES.join(', ')}`;
123
+ const waitForCi = body.waitForCi ?? existing?.waitForCi ?? true;
124
+ if (typeof waitForCi !== 'boolean')
125
+ errors.waitForCi = 'must be true or false';
126
+ if (Object.keys(errors).length > 0)
127
+ return { errors };
128
+ return {
129
+ project: {
130
+ name,
131
+ priority: priority,
132
+ sourceType: sourceType.id,
133
+ sourceSettings,
134
+ repoPath,
135
+ worktreeRoot,
136
+ pollIntervalSeconds: pollIntervalSeconds,
137
+ agentTimeoutMinutes: agentTimeoutMinutes,
138
+ agents: agents,
139
+ autostart: body.autostart === true,
140
+ reviewMaxRounds: reviewMaxRounds,
141
+ reviewIgnoreAuthors,
142
+ reviewMode: reviewMode,
143
+ waitForCi: waitForCi,
144
+ },
145
+ };
146
+ }
147
+ /** A project as the console may see it: every secret replaced by whether it is set. */
148
+ export function maskProject(project, sourceTypes = SOURCE_TYPES) {
149
+ const type = sourceTypes.find((candidate) => candidate.id === project.sourceType);
150
+ const secrets = new Set(type?.fields.filter((field) => field.type === 'secret').map((field) => field.key));
151
+ // Settings without a known field are dropped rather than risk exposing a secret of a removed source type.
152
+ const sourceSettings = {};
153
+ for (const field of type?.fields ?? []) {
154
+ // A setting added after the project was saved shows the default the source applies for it.
155
+ const value = project.sourceSettings[field.key] ?? field.default ?? '';
156
+ sourceSettings[field.key] = secrets.has(field.key) ? { set: value !== '' } : value;
157
+ }
158
+ return { ...project, sourceSettings, sourceSummary: type?.summarise(project.sourceSettings) ?? project.sourceType };
159
+ }
160
+ const LEGACY_OVERRIDE_KEYS = [
161
+ 'targetState',
162
+ 'inProgressState',
163
+ 'successState',
164
+ 'pollIntervalSeconds',
165
+ 'repoPath',
166
+ 'worktreeRoot',
167
+ 'agentTimeoutMinutes',
168
+ ];
169
+ /**
170
+ * Creates the first project from a single-project setup in the environment, with any values that
171
+ * were overridden in the old Config tab applied. Does nothing once a project exists.
172
+ */
173
+ export function migrateLegacy(db, env) {
174
+ // The old override rows are meaningless without the config they overrode; drop them either way.
175
+ const overrides = {};
176
+ for (const key of LEGACY_OVERRIDE_KEYS) {
177
+ const value = db.getSetting(key);
178
+ if (value !== undefined)
179
+ overrides[key] = value;
180
+ db.deleteSetting(key);
181
+ }
182
+ if (!env.legacy || db.listProjects().length > 0)
183
+ return null;
184
+ const legacy = { ...env.legacy, ...overrides };
185
+ const project = db.createProject({
186
+ name: path.basename(legacy.repoPath) || 'Default',
187
+ priority: 'normal',
188
+ sourceType: 'linear',
189
+ sourceSettings: {
190
+ apiKey: legacy.apiKey,
191
+ teamId: legacy.teamId,
192
+ projectId: legacy.projectId,
193
+ targetState: legacy.targetState,
194
+ inProgressState: legacy.inProgressState,
195
+ successState: legacy.successState,
196
+ },
197
+ repoPath: legacy.repoPath,
198
+ worktreeRoot: legacy.worktreeRoot,
199
+ pollIntervalSeconds: Number(legacy.pollIntervalSeconds),
200
+ agentTimeoutMinutes: Number(legacy.agentTimeoutMinutes),
201
+ agents: [],
202
+ autostart: legacy.autostart,
203
+ reviewMaxRounds: DEFAULT_REVIEW_ROUNDS,
204
+ reviewIgnoreAuthors: DEFAULT_IGNORED_AUTHORS,
205
+ reviewMode: 'none',
206
+ waitForCi: true,
207
+ });
208
+ // Every run so far was this setup's.
209
+ db.adoptRuns(project);
210
+ return project;
211
+ }
@@ -0,0 +1,186 @@
1
+ import { isPlainApproval } from '../agent/prompt.js';
2
+ /**
3
+ * After a conflict round, GitHub takes a while to notice the push and go on reporting the old head
4
+ * as conflicting. Until it reports a new head, or this long has passed, the conflict is not raised again.
5
+ */
6
+ export const CONFLICT_SETTLE_MS = 10 * 60_000;
7
+ const timeOf = (iso) => (iso ? new Date(iso).getTime() : 0);
8
+ /** Feedback newer than what has been dealt with. Compared as times: GitHub and foreman format timestamps differently. */
9
+ const newerThan = (feedback, handledUntil) => feedback.filter((item) => timeOf(item.createdAt) > timeOf(handledUntil));
10
+ /** Feedback that counts as review: new, and not from an account the project ignores (deployment bots and the like). */
11
+ export const reviewable = (feedback, handledUntil, ignoreAuthors) => {
12
+ const ignored = new Set(ignoreAuthors.map((author) => author.toLowerCase()));
13
+ return newerThan(feedback, handledUntil).filter((item) => !ignored.has(item.author.toLowerCase().replace(/\[bot\]$/, '')));
14
+ };
15
+ /** The later of `floor` and the newest feedback: everything up to it counts as dealt with. */
16
+ export const latestOf = (feedback, floor) => new Date(Math.max(floor.getTime(), ...feedback.map((item) => timeOf(item.createdAt)))).toISOString();
17
+ /** Whether a pull request's conflict is one to act on now. */
18
+ export function conflictToResolve(pr, progress, now) {
19
+ if (pr.mergeable !== 'CONFLICTING')
20
+ return false;
21
+ // A conflict has no timestamp to mark it as dealt with, so once the issue has been handed to a
22
+ // person at the round limit it is not raised again.
23
+ if (progress.handedOver)
24
+ return false;
25
+ // Same head as when the last conflict round ended: most likely GitHub has not caught up with the push.
26
+ const settling = progress.conflictHead === pr.headSha && now.getTime() - timeOf(progress.conflictAt ?? '') < CONFLICT_SETTLE_MS;
27
+ return !settling;
28
+ }
29
+ /** What a follow-up run is doing, for the pickup comment. */
30
+ export const followUp = (item) => [
31
+ item.feedback.length > 0 ? 'addressing review feedback' : null,
32
+ item.conflict ? 'resolving merge conflicts' : null,
33
+ item.ciFailures?.length ? 'fixing failing checks' : null,
34
+ ]
35
+ .filter(Boolean)
36
+ .join(' and ') || 'addressing review feedback';
37
+ /** Checks pending this long on one head are taken to be stuck. */
38
+ export const CI_STUCK_MS = 2 * 60 * 60_000;
39
+ /** Reruns of failed checks allowed on one head, automatic ones and those the agent asked for together. */
40
+ export const CI_MAX_RERUNS = 2;
41
+ /** GitHub Actions conclusions that say the job did not get to judge the code. */
42
+ const INFRA_STATES = new Set(['CANCELLED', 'TIMED_OUT', 'STARTUP_FAILURE']);
43
+ const failingChecks = (ci) => ci.checks.filter((check) => check.bucket === 'fail' || check.bucket === 'cancel');
44
+ const names = (checks) => checks.map((check) => check.name).join(', ');
45
+ const rerunnable = (checks) => checks.length > 0 && checks.every((check) => check.runId !== null);
46
+ const runIdsOf = (checks) => [...new Set(checks.map((check) => check.runId))];
47
+ const green = (ci) => !ci || ci.state === 'passing' || ci.state === 'none';
48
+ /** The CI fields of `progress` for the pull request's current head; another head starts from nothing. */
49
+ function headCi(progress, pr) {
50
+ const same = progress.ciHead === pr.headSha;
51
+ return {
52
+ reruns: same ? (progress.ciReruns ?? 0) : 0,
53
+ handled: same && progress.ciHandled === true,
54
+ notCode: same ? (progress.ciNotCode ?? null) : null,
55
+ pendingSince: same ? (progress.ciPendingSince ?? null) : null,
56
+ };
57
+ }
58
+ /** Failing checks on the head that no round or hand-over has dealt with yet. */
59
+ function unhandledFailures(progress, pr, ci) {
60
+ if (!ci || ci.state !== 'failing' || progress.handedOver || headCi(progress, pr).handled)
61
+ return [];
62
+ return failingChecks(ci);
63
+ }
64
+ /**
65
+ * How the review progress's CI fields should change for what the checks show now: a new head starts
66
+ * afresh, and the first time its checks are seen pending is noted. Null when nothing changes.
67
+ */
68
+ export function trackCi(progress, pr, ci, now) {
69
+ // No checks: nothing to keep, and a later head without a record starts afresh anyway.
70
+ if (!ci || ci.state === 'none')
71
+ return null;
72
+ const pending = ci.state === 'pending';
73
+ if (progress.ciHead !== pr.headSha) {
74
+ return { ciHead: pr.headSha, ciReruns: 0, ciHandled: false, ciNotCode: null, ciPendingSince: pending ? now.toISOString() : null };
75
+ }
76
+ if (pending && !progress.ciPendingSince)
77
+ return { ciPendingSince: now.toISOString() };
78
+ if (!pending && progress.ciPendingSince)
79
+ return { ciPendingSince: null };
80
+ return null;
81
+ }
82
+ /** The author name foreman gives an agent reviewer's findings; not a GitHub account. */
83
+ export const AGENT_REVIEWER = 'foreman reviewer';
84
+ /** An agent reviewer's findings, waiting for the author's agent, as one item of review feedback. */
85
+ const pendingFindings = (progress, now) => progress.agentFindings
86
+ ? [
87
+ {
88
+ id: `foreman-review-${progress.agentReviewedHead ?? 'unknown'}`,
89
+ kind: 'review',
90
+ author: AGENT_REVIEWER,
91
+ body: progress.agentFindings,
92
+ state: 'CHANGES_REQUESTED',
93
+ createdAt: now.toISOString(),
94
+ },
95
+ ]
96
+ : [];
97
+ const agentReviews = (mode) => mode === 'other_agent' || mode === 'any_agent';
98
+ export function decideReview({ pr, progress, stage, ignoreAuthors, now, mode, ci = null }) {
99
+ // No pull request, or one closed without merging: nothing foreman can decide.
100
+ if (!pr || pr.state === 'CLOSED')
101
+ return { kind: 'none' };
102
+ if (pr.state === 'MERGED')
103
+ return { kind: 'finish', pr };
104
+ const feedback = [...reviewable(pr.feedback, progress.handledUntil, ignoreAuthors), ...pendingFindings(progress, now)];
105
+ const conflict = conflictToResolve(pr, progress, now);
106
+ const head = headCi(progress, pr);
107
+ const failures = unhandledFailures(progress, pr, ci);
108
+ if (failures.length > 0) {
109
+ if (head.notCode) {
110
+ return { kind: 'hand_over', pr, needs: `checks that still fail after a rerun (${names(failures)}); the agent found the cause is not the code: ${head.notCode}` };
111
+ }
112
+ const infra = failures.every((check) => INFRA_STATES.has(check.state));
113
+ if (infra && rerunnable(failures) && head.reruns < CI_MAX_RERUNS) {
114
+ return { kind: 'rerun', pr, runIds: runIdsOf(failures), checks: failures.map((check) => check.name) };
115
+ }
116
+ return { kind: 'round', pr, feedback, conflict, ciFailures: failures };
117
+ }
118
+ if (ci?.state === 'pending' && !progress.handedOver && !head.handled && head.pendingSince && now.getTime() - timeOf(head.pendingSince) >= CI_STUCK_MS) {
119
+ return { kind: 'hand_over', pr, needs: 'checks that have been pending for 2 hours' };
120
+ }
121
+ if (feedback.length > 0 || conflict) {
122
+ if (!conflict && isPlainApproval(feedback)) {
123
+ const handledUntil = latestOf(pr.feedback, now);
124
+ return green(ci) ? { kind: 'approve', pr, moveIssue: stage === 'review', handledUntil } : { kind: 'hold', pr, handledUntil };
125
+ }
126
+ return { kind: 'round', pr, feedback, conflict };
127
+ }
128
+ if (progress.approvalHead === pr.headSha && green(ci)) {
129
+ return { kind: 'approve', pr, moveIssue: stage === 'review', handledUntil: latestOf(pr.feedback, now) };
130
+ }
131
+ const unreviewed = agentReviews(mode) && stage === 'review' && progress.agentReviewedHead !== pr.headSha;
132
+ return unreviewed && green(ci) ? { kind: 'agent_review', pr } : { kind: 'none' };
133
+ }
134
+ export function decideCiFollowUp({ verdict, ci, progress, headBefore, headAfter, }) {
135
+ // A push starts the head afresh, whatever the agent said.
136
+ if (headAfter !== headBefore)
137
+ return { kind: 'none' };
138
+ const failures = failingChecks(ci);
139
+ if (!verdict)
140
+ return { kind: 'hand_over', needs: `failing checks (${names(failures)}); the agent did not say whether the change causes them and pushed nothing` };
141
+ if (verdict.verdict === 'fixed')
142
+ return { kind: 'hand_over', needs: `failing checks (${names(failures)}); the agent said it fixed them but pushed nothing` };
143
+ const reruns = progress.ciHead === headBefore ? (progress.ciReruns ?? 0) : 0;
144
+ if (rerunnable(failures) && reruns < CI_MAX_RERUNS)
145
+ return { kind: 'rerun', runIds: runIdsOf(failures), reason: verdict.reason };
146
+ return { kind: 'hand_over', needs: `failing checks (${names(failures)}) that the agent found are not caused by the code: ${verdict.reason}` };
147
+ }
148
+ /**
149
+ * Chooses the agent to review a pull request from the agents free now, in preference order. Another
150
+ * agent than the author's always comes first; only `any_agent` falls back to the author's own agent.
151
+ * Null means no agent review, or none possible right now.
152
+ */
153
+ export function pickReviewer(mode, authorAgentId, free) {
154
+ if (mode !== 'other_agent' && mode !== 'any_agent')
155
+ return null;
156
+ const other = free.find((agent) => agent.id !== authorAgentId);
157
+ if (other)
158
+ return { agent: other, self: false };
159
+ const author = mode === 'any_agent' ? free.find((agent) => agent.id === authorAgentId) : undefined;
160
+ return author ? { agent: author, self: true } : null;
161
+ }
162
+ export function decideRoundStart({ pr, progress, ignoreAuthors, now, maxRounds, ci = null }) {
163
+ if (!pr || pr.state !== 'OPEN')
164
+ return { kind: 'none' };
165
+ const feedback = [...reviewable(pr.feedback, progress.handledUntil, ignoreAuthors), ...pendingFindings(progress, now)];
166
+ const conflict = conflictToResolve(pr, progress, now);
167
+ const ciFailures = unhandledFailures(progress, pr, ci);
168
+ if (feedback.length === 0 && !conflict && ciFailures.length === 0)
169
+ return { kind: 'none' };
170
+ if (progress.rounds >= maxRounds) {
171
+ const needs = [feedback.length > 0 ? 'new feedback' : null, conflict ? 'merge conflicts' : null, ciFailures.length > 0 ? 'failing checks' : null]
172
+ .filter(Boolean)
173
+ .join(' and ');
174
+ return { kind: 'hand_over', rounds: progress.rounds, needs, handledUntil: latestOf(pr.feedback, now) };
175
+ }
176
+ return { kind: 'start', round: progress.rounds + 1, feedback, conflict, ...(ciFailures.length > 0 ? { ciFailures } : {}) };
177
+ }
178
+ /** What to do when an agent is free to review a pull request, judged on it as it is now. An agent review counts as a round. */
179
+ export function decideAgentReviewStart({ pr, progress, maxRounds, now }) {
180
+ if (!pr || pr.state !== 'OPEN' || progress.agentReviewedHead === pr.headSha)
181
+ return { kind: 'none' };
182
+ if (progress.rounds >= maxRounds) {
183
+ return { kind: 'hand_over', rounds: progress.rounds, needs: 'an agent review of the latest changes', handledUntil: latestOf(pr.feedback, now) };
184
+ }
185
+ return { kind: 'start', round: progress.rounds + 1 };
186
+ }