@developerz.ai/aitm 0.0.3 → 0.0.5
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/README.md +12 -2
- package/dist/cli/args.d.ts +29 -1
- package/dist/cli/args.js +113 -0
- package/dist/cli/cli.js +23 -2
- package/dist/cli/commands.d.ts +7 -0
- package/dist/cli/commands.js +97 -4
- package/dist/config/config-loader.d.ts +2 -0
- package/dist/config/config-loader.js +49 -7
- package/dist/config/config-writer.js +9 -0
- package/dist/config/profiles.d.ts +30 -0
- package/dist/config/profiles.js +224 -0
- package/dist/config/provider-presets.d.ts +5 -0
- package/dist/config/provider-presets.js +18 -0
- package/dist/config/schema.d.ts +28 -1
- package/dist/config/schema.js +11 -0
- package/dist/credentials/credentials.d.ts +4 -0
- package/dist/credentials/credentials.js +7 -1
- package/dist/github/github-client.d.ts +7 -0
- package/dist/github/github-client.js +85 -0
- package/dist/loop/run-loop-adapter.js +4 -1
- package/dist/loop/take-over-flow.d.ts +14 -0
- package/dist/loop/take-over-flow.js +22 -6
- package/dist/orchestrator/orchestrator.js +16 -4
- package/dist/state/pr-context-store.d.ts +20 -0
- package/dist/state/pr-context-store.js +60 -0
- package/dist/subagents/planner.d.ts +2 -4
- package/dist/subagents/planner.js +11 -10
- package/dist/subagents/reviewer.d.ts +2 -4
- package/dist/subagents/reviewer.js +16 -12
- package/dist/subagents/worker.d.ts +5 -5
- package/dist/subagents/worker.js +22 -9
- package/package.json +2 -2
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { ZodError } from 'zod';
|
|
4
|
+
import { atomicWrite } from "../fs/atomic-write.js";
|
|
5
|
+
import { PROVIDER_PRESETS } from "./provider-presets.js";
|
|
6
|
+
import { ConfigFileSchema } from "./schema.js";
|
|
7
|
+
const GLOBAL_FILE = '.aitm.json';
|
|
8
|
+
export class ProfileManager {
|
|
9
|
+
homeDir;
|
|
10
|
+
constructor(homeDir) {
|
|
11
|
+
this.homeDir = homeDir;
|
|
12
|
+
}
|
|
13
|
+
async list() {
|
|
14
|
+
const validated = await this.readValidated();
|
|
15
|
+
return {
|
|
16
|
+
activeProfile: validated.activeProfile,
|
|
17
|
+
profiles: validated.profiles ?? {},
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
async use(name) {
|
|
21
|
+
const file = await this.readRaw();
|
|
22
|
+
const profiles = asObject(file.profiles);
|
|
23
|
+
if (!(name in profiles)) {
|
|
24
|
+
throw new Error(unknownProfileMessage(name, Object.keys(profiles)));
|
|
25
|
+
}
|
|
26
|
+
file.activeProfile = name;
|
|
27
|
+
await this.persist(file);
|
|
28
|
+
}
|
|
29
|
+
async add(name, input = {}) {
|
|
30
|
+
if (name.trim() === '')
|
|
31
|
+
throw new Error('Profile name must be non-empty.');
|
|
32
|
+
const file = await this.readRaw();
|
|
33
|
+
const profiles = ensureObject(file, 'profiles');
|
|
34
|
+
if (name in profiles) {
|
|
35
|
+
throw new Error(`Profile "${name}" already exists. Use \`aitm profile set ${name} <key> <value>\` to modify it.`);
|
|
36
|
+
}
|
|
37
|
+
const profile = input.preset
|
|
38
|
+
? jsonClone(PROVIDER_PRESETS[input.preset])
|
|
39
|
+
: {};
|
|
40
|
+
if (input.baseURL !== undefined)
|
|
41
|
+
profile.baseURL = input.baseURL;
|
|
42
|
+
if (input.apiKey !== undefined)
|
|
43
|
+
profile.openrouterApiKey = input.apiKey;
|
|
44
|
+
profiles[name] = profile;
|
|
45
|
+
if (file.activeProfile === undefined)
|
|
46
|
+
file.activeProfile = name;
|
|
47
|
+
const validated = await this.persist(file);
|
|
48
|
+
return validated.profiles?.[name] ?? {};
|
|
49
|
+
}
|
|
50
|
+
async set(name, key, value) {
|
|
51
|
+
const file = await this.readRaw();
|
|
52
|
+
const profiles = asObject(file.profiles);
|
|
53
|
+
if (!(name in profiles)) {
|
|
54
|
+
throw new Error(unknownProfileMessage(name, Object.keys(profiles)));
|
|
55
|
+
}
|
|
56
|
+
const profile = asObject(profiles[name]);
|
|
57
|
+
setDotted(profile, splitKey(key), parseValue(value));
|
|
58
|
+
profiles[name] = profile;
|
|
59
|
+
file.profiles = profiles;
|
|
60
|
+
const validated = await this.persist(file);
|
|
61
|
+
return validated.profiles?.[name] ?? {};
|
|
62
|
+
}
|
|
63
|
+
async get(name, key) {
|
|
64
|
+
const { profiles } = await this.list();
|
|
65
|
+
const profile = profiles[name];
|
|
66
|
+
if (profile === undefined) {
|
|
67
|
+
throw new Error(unknownProfileMessage(name, Object.keys(profiles)));
|
|
68
|
+
}
|
|
69
|
+
return getDotted(profile, splitKey(key));
|
|
70
|
+
}
|
|
71
|
+
async remove(name) {
|
|
72
|
+
const file = await this.readRaw();
|
|
73
|
+
const profiles = asObject(file.profiles);
|
|
74
|
+
if (!(name in profiles)) {
|
|
75
|
+
throw new Error(unknownProfileMessage(name, Object.keys(profiles)));
|
|
76
|
+
}
|
|
77
|
+
delete profiles[name];
|
|
78
|
+
if (file.activeProfile === name)
|
|
79
|
+
delete file.activeProfile;
|
|
80
|
+
await this.persist(file);
|
|
81
|
+
}
|
|
82
|
+
async show(name) {
|
|
83
|
+
const { activeProfile, profiles } = await this.list();
|
|
84
|
+
const target = name ?? activeProfile;
|
|
85
|
+
if (target === undefined) {
|
|
86
|
+
throw new Error('No profile specified and no active profile set. Pass a name or run `aitm profile use <name>`.');
|
|
87
|
+
}
|
|
88
|
+
const profile = profiles[target];
|
|
89
|
+
if (profile === undefined) {
|
|
90
|
+
throw new Error(unknownProfileMessage(target, Object.keys(profiles)));
|
|
91
|
+
}
|
|
92
|
+
return { name: target, profile };
|
|
93
|
+
}
|
|
94
|
+
filePath() {
|
|
95
|
+
return join(this.homeDir, GLOBAL_FILE);
|
|
96
|
+
}
|
|
97
|
+
async readRaw() {
|
|
98
|
+
const path = this.filePath();
|
|
99
|
+
let raw;
|
|
100
|
+
try {
|
|
101
|
+
raw = await readFile(path, 'utf8');
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
if (isNotFound(err))
|
|
105
|
+
return {};
|
|
106
|
+
throw err;
|
|
107
|
+
}
|
|
108
|
+
let parsed;
|
|
109
|
+
try {
|
|
110
|
+
parsed = JSON.parse(raw);
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
114
|
+
throw new Error(`${path}: invalid JSON — ${msg}`);
|
|
115
|
+
}
|
|
116
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
117
|
+
throw new Error(`${path}: expected a JSON object at the top level`);
|
|
118
|
+
}
|
|
119
|
+
return parsed;
|
|
120
|
+
}
|
|
121
|
+
async readValidated() {
|
|
122
|
+
return this.validate(await this.readRaw());
|
|
123
|
+
}
|
|
124
|
+
validate(file) {
|
|
125
|
+
try {
|
|
126
|
+
return ConfigFileSchema.parse(file);
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
if (err instanceof ZodError)
|
|
130
|
+
throw new Error(`${this.filePath()}: ${formatZodError(err)}`);
|
|
131
|
+
throw err;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
async persist(file) {
|
|
135
|
+
const validated = this.validate(file);
|
|
136
|
+
await atomicWrite(this.filePath(), `${JSON.stringify(validated, null, 2)}\n`);
|
|
137
|
+
return validated;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
function asObject(value) {
|
|
141
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
142
|
+
? value
|
|
143
|
+
: {};
|
|
144
|
+
}
|
|
145
|
+
function ensureObject(parent, key) {
|
|
146
|
+
const next = asObject(parent[key]);
|
|
147
|
+
parent[key] = next;
|
|
148
|
+
return next;
|
|
149
|
+
}
|
|
150
|
+
function jsonClone(value) {
|
|
151
|
+
return JSON.parse(JSON.stringify(value));
|
|
152
|
+
}
|
|
153
|
+
const ALLOWED_PROFILE_ROOT_KEYS = new Set([
|
|
154
|
+
'openrouterApiKey',
|
|
155
|
+
'baseURL',
|
|
156
|
+
'models',
|
|
157
|
+
]);
|
|
158
|
+
const FORBIDDEN_KEY_SEGMENTS = new Set([
|
|
159
|
+
'__proto__',
|
|
160
|
+
'prototype',
|
|
161
|
+
'constructor',
|
|
162
|
+
]);
|
|
163
|
+
const KEY_SURFACE_HINT = 'Allowed keys: openrouterApiKey, baseURL, models.<tier>.';
|
|
164
|
+
function splitKey(key) {
|
|
165
|
+
const parts = key.split('.');
|
|
166
|
+
if (parts.length === 0 || parts.some((p) => p === '')) {
|
|
167
|
+
throw new Error(`Invalid profile key: "${key}". ${KEY_SURFACE_HINT}`);
|
|
168
|
+
}
|
|
169
|
+
if (parts.some((p) => FORBIDDEN_KEY_SEGMENTS.has(p))) {
|
|
170
|
+
throw new Error(`Invalid profile key: "${key}" — reserved segment. ${KEY_SURFACE_HINT}`);
|
|
171
|
+
}
|
|
172
|
+
const [first, ...rest] = parts;
|
|
173
|
+
if (first === undefined || !ALLOWED_PROFILE_ROOT_KEYS.has(first)) {
|
|
174
|
+
throw new Error(`Invalid profile key: "${key}". ${KEY_SURFACE_HINT}`);
|
|
175
|
+
}
|
|
176
|
+
if (first === 'models' ? rest.length !== 1 : rest.length !== 0) {
|
|
177
|
+
throw new Error(`Invalid profile key: "${key}". ${KEY_SURFACE_HINT}`);
|
|
178
|
+
}
|
|
179
|
+
return [first, ...rest];
|
|
180
|
+
}
|
|
181
|
+
function setDotted(obj, parts, value) {
|
|
182
|
+
const [first, ...rest] = parts;
|
|
183
|
+
if (first === undefined)
|
|
184
|
+
return;
|
|
185
|
+
if (rest.length === 0) {
|
|
186
|
+
obj[first] = value;
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const sub = asObject(obj[first]);
|
|
190
|
+
obj[first] = sub;
|
|
191
|
+
setDotted(sub, rest, value);
|
|
192
|
+
}
|
|
193
|
+
function getDotted(obj, parts) {
|
|
194
|
+
let cur = obj;
|
|
195
|
+
for (const p of parts) {
|
|
196
|
+
if (cur === null || typeof cur !== 'object' || Array.isArray(cur))
|
|
197
|
+
return undefined;
|
|
198
|
+
cur = cur[p];
|
|
199
|
+
}
|
|
200
|
+
return cur;
|
|
201
|
+
}
|
|
202
|
+
function parseValue(v) {
|
|
203
|
+
if (typeof v !== 'string')
|
|
204
|
+
return v;
|
|
205
|
+
try {
|
|
206
|
+
return JSON.parse(v);
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
return v;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
function unknownProfileMessage(name, available) {
|
|
213
|
+
const list = available.length > 0 ? available.slice().sort().join(', ') : '(none)';
|
|
214
|
+
return `Unknown profile "${name}". Available: ${list}. Create it with \`aitm profile add ${name}\`.`;
|
|
215
|
+
}
|
|
216
|
+
function isNotFound(err) {
|
|
217
|
+
return (typeof err === 'object' &&
|
|
218
|
+
err !== null &&
|
|
219
|
+
'code' in err &&
|
|
220
|
+
err.code === 'ENOENT');
|
|
221
|
+
}
|
|
222
|
+
function formatZodError(err) {
|
|
223
|
+
return err.issues.map((i) => `${i.path.join('.') || '<root>'}: ${i.message}`).join('; ');
|
|
224
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { Profile } from './schema.ts';
|
|
2
|
+
export type PresetName = 'openrouter' | 'zai';
|
|
3
|
+
export declare const PROVIDER_PRESETS: Readonly<Record<PresetName, Profile>>;
|
|
4
|
+
export declare const PRESET_NAMES: readonly PresetName[];
|
|
5
|
+
export declare function isPresetName(s: string): s is PresetName;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export const PROVIDER_PRESETS = {
|
|
2
|
+
openrouter: {
|
|
3
|
+
baseURL: 'https://openrouter.ai/api/v1',
|
|
4
|
+
},
|
|
5
|
+
zai: {
|
|
6
|
+
baseURL: 'https://api.z.ai/api/coding/paas/v4',
|
|
7
|
+
models: {
|
|
8
|
+
generic: 'glm-5.2',
|
|
9
|
+
smart: 'glm-5.2',
|
|
10
|
+
coding: 'glm-5.2',
|
|
11
|
+
fast: 'glm-5-turbo',
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
export const PRESET_NAMES = Object.keys(PROVIDER_PRESETS);
|
|
16
|
+
export function isPresetName(s) {
|
|
17
|
+
return Object.hasOwn(PROVIDER_PRESETS, s);
|
|
18
|
+
}
|
package/dist/config/schema.d.ts
CHANGED
|
@@ -18,8 +18,31 @@ export declare const MergeMethodSchema: z.ZodEnum<{
|
|
|
18
18
|
merge: "merge";
|
|
19
19
|
rebase: "rebase";
|
|
20
20
|
}>;
|
|
21
|
+
export declare const ProfileSchema: z.ZodObject<{
|
|
22
|
+
openrouterApiKey: z.ZodOptional<z.ZodString>;
|
|
23
|
+
baseURL: z.ZodOptional<z.ZodURL>;
|
|
24
|
+
models: z.ZodOptional<z.ZodObject<{
|
|
25
|
+
generic: z.ZodOptional<z.ZodString>;
|
|
26
|
+
smart: z.ZodOptional<z.ZodString>;
|
|
27
|
+
coding: z.ZodOptional<z.ZodString>;
|
|
28
|
+
fast: z.ZodOptional<z.ZodString>;
|
|
29
|
+
}, z.core.$loose>>;
|
|
30
|
+
}, z.core.$loose>;
|
|
31
|
+
export type Profile = z.infer<typeof ProfileSchema>;
|
|
21
32
|
export declare const ConfigFileSchema: z.ZodObject<{
|
|
22
33
|
openrouterApiKey: z.ZodOptional<z.ZodString>;
|
|
34
|
+
activeProfile: z.ZodOptional<z.ZodString>;
|
|
35
|
+
profiles: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
36
|
+
openrouterApiKey: z.ZodOptional<z.ZodString>;
|
|
37
|
+
baseURL: z.ZodOptional<z.ZodURL>;
|
|
38
|
+
models: z.ZodOptional<z.ZodObject<{
|
|
39
|
+
generic: z.ZodOptional<z.ZodString>;
|
|
40
|
+
smart: z.ZodOptional<z.ZodString>;
|
|
41
|
+
coding: z.ZodOptional<z.ZodString>;
|
|
42
|
+
fast: z.ZodOptional<z.ZodString>;
|
|
43
|
+
}, z.core.$loose>>;
|
|
44
|
+
}, z.core.$loose>>>;
|
|
45
|
+
baseURL: z.ZodOptional<z.ZodURL>;
|
|
23
46
|
models: z.ZodOptional<z.ZodObject<{
|
|
24
47
|
generic: z.ZodOptional<z.ZodString>;
|
|
25
48
|
smart: z.ZodOptional<z.ZodString>;
|
|
@@ -35,6 +58,7 @@ export declare const ConfigFileSchema: z.ZodObject<{
|
|
|
35
58
|
rebase: "rebase";
|
|
36
59
|
}>>;
|
|
37
60
|
stylePath: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
61
|
+
formatCommand: z.ZodOptional<z.ZodString>;
|
|
38
62
|
logLevel: z.ZodOptional<z.ZodEnum<{
|
|
39
63
|
debug: "debug";
|
|
40
64
|
info: "info";
|
|
@@ -70,13 +94,16 @@ export type CliOverrides = {
|
|
|
70
94
|
};
|
|
71
95
|
export type ResolvedConfig = {
|
|
72
96
|
openrouterApiKey: string;
|
|
73
|
-
apiKeySource: 'project' | 'global' | 'env';
|
|
97
|
+
apiKeySource: 'project' | 'global' | 'env' | 'profile';
|
|
98
|
+
activeProfile?: string | undefined;
|
|
99
|
+
baseURL?: string | undefined;
|
|
74
100
|
models: Required<Pick<CapabilityModels, 'generic' | 'smart' | 'coding' | 'fast'>>;
|
|
75
101
|
maxPrs: number;
|
|
76
102
|
maxSessions: number | null;
|
|
77
103
|
autoMerge: boolean;
|
|
78
104
|
mergeMethod: 'squash' | 'merge' | 'rebase';
|
|
79
105
|
stylePath: string | null;
|
|
106
|
+
formatCommand: string | null;
|
|
80
107
|
logLevel: 'debug' | 'info' | 'warn' | 'error';
|
|
81
108
|
concurrency: number;
|
|
82
109
|
mcpServers: import('../mcp/schema.ts').McpServers;
|
package/dist/config/schema.js
CHANGED
|
@@ -10,15 +10,26 @@ export const CapabilityModelsSchema = z
|
|
|
10
10
|
.passthrough();
|
|
11
11
|
export const LogLevelSchema = z.enum(['debug', 'info', 'warn', 'error']);
|
|
12
12
|
export const MergeMethodSchema = z.enum(['squash', 'merge', 'rebase']);
|
|
13
|
+
export const ProfileSchema = z
|
|
14
|
+
.object({
|
|
15
|
+
openrouterApiKey: z.string().optional(),
|
|
16
|
+
baseURL: z.url().optional(),
|
|
17
|
+
models: CapabilityModelsSchema.optional(),
|
|
18
|
+
})
|
|
19
|
+
.passthrough();
|
|
13
20
|
export const ConfigFileSchema = z
|
|
14
21
|
.object({
|
|
15
22
|
openrouterApiKey: z.string().optional(),
|
|
23
|
+
activeProfile: z.string().optional(),
|
|
24
|
+
profiles: z.record(z.string(), ProfileSchema).optional(),
|
|
25
|
+
baseURL: z.url().optional(),
|
|
16
26
|
models: CapabilityModelsSchema.optional(),
|
|
17
27
|
maxPrs: z.number().int().positive().optional(),
|
|
18
28
|
maxSessions: z.number().int().positive().nullable().optional(),
|
|
19
29
|
autoMerge: z.boolean().optional(),
|
|
20
30
|
mergeMethod: MergeMethodSchema.optional(),
|
|
21
31
|
stylePath: z.string().nullable().optional(),
|
|
32
|
+
formatCommand: z.string().optional(),
|
|
22
33
|
logLevel: LogLevelSchema.optional(),
|
|
23
34
|
concurrency: z.number().int().positive().optional(),
|
|
24
35
|
mcpServers: McpServersSchema.optional(),
|
|
@@ -3,6 +3,10 @@ import type { Capability, ResolvedConfig } from '../config/schema.ts';
|
|
|
3
3
|
export type Role = 'planner' | 'worker' | 'reviewer' | 'orchestrator';
|
|
4
4
|
export declare const ROLE_CAPABILITY: Readonly<Record<Role, Capability>>;
|
|
5
5
|
export type ModelHandles = Record<Role, LanguageModel>;
|
|
6
|
+
export declare function providerSettings(resolved: ResolvedConfig): {
|
|
7
|
+
apiKey: string;
|
|
8
|
+
baseURL?: string;
|
|
9
|
+
};
|
|
6
10
|
export declare class Credentials {
|
|
7
11
|
private readonly resolved;
|
|
8
12
|
private providerInstance;
|
|
@@ -6,6 +6,12 @@ export const ROLE_CAPABILITY = {
|
|
|
6
6
|
reviewer: 'smart',
|
|
7
7
|
orchestrator: 'fast',
|
|
8
8
|
};
|
|
9
|
+
export function providerSettings(resolved) {
|
|
10
|
+
return {
|
|
11
|
+
apiKey: resolved.openrouterApiKey,
|
|
12
|
+
...(resolved.baseURL ? { baseURL: resolved.baseURL } : {}),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
9
15
|
export class Credentials {
|
|
10
16
|
resolved;
|
|
11
17
|
providerInstance;
|
|
@@ -37,7 +43,7 @@ export class Credentials {
|
|
|
37
43
|
provider() {
|
|
38
44
|
if (!this.providerInstance) {
|
|
39
45
|
Credentials.assertApiKeyPresent(this.resolved);
|
|
40
|
-
this.providerInstance = createOpenRouter(
|
|
46
|
+
this.providerInstance = createOpenRouter(providerSettings(this.resolved));
|
|
41
47
|
}
|
|
42
48
|
return this.providerInstance;
|
|
43
49
|
}
|
|
@@ -33,6 +33,13 @@ export declare class GitHubClient {
|
|
|
33
33
|
getPrForBranch(branch: string): Promise<PullRequest | null>;
|
|
34
34
|
createPr(input: CreatePrInput): Promise<PullRequest>;
|
|
35
35
|
waitForChecks(pr: number): Promise<CheckStatus>;
|
|
36
|
+
getFailedCiLogs(pr: number): Promise<Array<{
|
|
37
|
+
check: string;
|
|
38
|
+
logs: string;
|
|
39
|
+
}>>;
|
|
40
|
+
private failedRunIds;
|
|
41
|
+
private failedJobs;
|
|
42
|
+
private jobLogs;
|
|
36
43
|
listUnresolvedThreads(pr: number): Promise<ReviewThread[]>;
|
|
37
44
|
private paginateReviewThreads;
|
|
38
45
|
private paginateThreadComments;
|
|
@@ -121,6 +121,67 @@ export class GitHubClient {
|
|
|
121
121
|
delay = Math.min(delay * 2, CHECKS_MAX_DELAY_MS);
|
|
122
122
|
}
|
|
123
123
|
}
|
|
124
|
+
async getFailedCiLogs(pr) {
|
|
125
|
+
const head = await this.runCmd('gh', ['pr', 'view', String(pr), '--json', 'headRefName,headRefOid'], { cwd: this.cwd });
|
|
126
|
+
if (head.exitCode !== 0)
|
|
127
|
+
return [];
|
|
128
|
+
const parsedHead = safeJson(head.stdout);
|
|
129
|
+
const branch = isRecord(parsedHead) ? parsedHead.headRefName : undefined;
|
|
130
|
+
const sha = isRecord(parsedHead) ? parsedHead.headRefOid : undefined;
|
|
131
|
+
if (typeof branch !== 'string')
|
|
132
|
+
return [];
|
|
133
|
+
const runIds = await this.failedRunIds(branch, typeof sha === 'string' ? sha : undefined);
|
|
134
|
+
if (runIds.length === 0)
|
|
135
|
+
return [];
|
|
136
|
+
const { owner, name } = await this.repoMeta();
|
|
137
|
+
const out = [];
|
|
138
|
+
for (const runId of runIds) {
|
|
139
|
+
for (const job of await this.failedJobs(owner, name, runId)) {
|
|
140
|
+
const logs = await this.jobLogs(owner, name, job.id);
|
|
141
|
+
if (logs.trim())
|
|
142
|
+
out.push({ check: job.name, logs });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
async failedRunIds(branch, sha) {
|
|
148
|
+
const r = await this.runCmd('gh', [
|
|
149
|
+
'run',
|
|
150
|
+
'list',
|
|
151
|
+
'--branch',
|
|
152
|
+
branch,
|
|
153
|
+
'--json',
|
|
154
|
+
'databaseId,headSha,conclusion',
|
|
155
|
+
'--limit',
|
|
156
|
+
'30',
|
|
157
|
+
], { cwd: this.cwd });
|
|
158
|
+
if (r.exitCode !== 0)
|
|
159
|
+
return [];
|
|
160
|
+
const parsed = safeJson(r.stdout);
|
|
161
|
+
const rows = WorkflowRunsSchema.safeParse(parsed);
|
|
162
|
+
if (!rows.success)
|
|
163
|
+
return [];
|
|
164
|
+
const failed = rows.data.filter((run) => FAILED_CONCLUSIONS.has(run.conclusion ?? ''));
|
|
165
|
+
const forSha = sha ? failed.filter((run) => run.headSha === sha) : [];
|
|
166
|
+
return (forSha.length > 0 ? forSha : failed).map((run) => run.databaseId);
|
|
167
|
+
}
|
|
168
|
+
async failedJobs(owner, name, runId) {
|
|
169
|
+
const r = await this.runCmd('gh', ['api', `repos/${owner}/${name}/actions/runs/${runId}/jobs`], { cwd: this.cwd });
|
|
170
|
+
if (r.exitCode !== 0)
|
|
171
|
+
return [];
|
|
172
|
+
const parsed = JobsResponseSchema.safeParse(safeJson(r.stdout));
|
|
173
|
+
if (!parsed.success)
|
|
174
|
+
return [];
|
|
175
|
+
return parsed.data.jobs
|
|
176
|
+
.filter((job) => FAILED_CONCLUSIONS.has(job.conclusion ?? ''))
|
|
177
|
+
.map((job) => ({ id: job.id, name: job.name }));
|
|
178
|
+
}
|
|
179
|
+
async jobLogs(owner, name, jobId) {
|
|
180
|
+
const r = await this.runCmd('gh', ['api', `repos/${owner}/${name}/actions/jobs/${jobId}/logs`], {
|
|
181
|
+
cwd: this.cwd,
|
|
182
|
+
});
|
|
183
|
+
return r.exitCode === 0 ? r.stdout : '';
|
|
184
|
+
}
|
|
124
185
|
async listUnresolvedThreads(pr) {
|
|
125
186
|
const { owner, name } = await this.repoMeta();
|
|
126
187
|
const threads = await this.paginateReviewThreads(owner, name, pr);
|
|
@@ -249,6 +310,30 @@ export class GitHubClient {
|
|
|
249
310
|
return { ok: r.exitCode === 0, scopes };
|
|
250
311
|
}
|
|
251
312
|
}
|
|
313
|
+
const FAILED_CONCLUSIONS = new Set(['failure', 'timed_out', 'startup_failure', 'action_required']);
|
|
314
|
+
const WorkflowRunsSchema = z.array(z.object({
|
|
315
|
+
databaseId: z.number(),
|
|
316
|
+
headSha: z.string().optional(),
|
|
317
|
+
conclusion: z.string().nullable().optional(),
|
|
318
|
+
}));
|
|
319
|
+
const JobsResponseSchema = z.object({
|
|
320
|
+
jobs: z.array(z.object({
|
|
321
|
+
id: z.number(),
|
|
322
|
+
name: z.string(),
|
|
323
|
+
conclusion: z.string().nullable().optional(),
|
|
324
|
+
})),
|
|
325
|
+
});
|
|
326
|
+
function safeJson(s) {
|
|
327
|
+
try {
|
|
328
|
+
return JSON.parse(s);
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
function isRecord(v) {
|
|
335
|
+
return typeof v === 'object' && v !== null;
|
|
336
|
+
}
|
|
252
337
|
function isPrNotFoundStderr(stderr) {
|
|
253
338
|
return /no pull requests? found|could not resolve to a pullrequest|no open pull requests/i.test(stderr);
|
|
254
339
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { resolve as resolvePath } from 'node:path';
|
|
2
|
-
import { bashTool, composeSystemPrompt, editFileTool, globTool, grepTool, multiEditTool, readFileTool, writeFileTool, } from '@developerz.ai/ai-claude-compat';
|
|
2
|
+
import { bashTool, composeSystemPrompt, editFileTool, globTool, grepTool, multiBashTool, multiEditTool, readFileTool, writeFileTool, } from '@developerz.ai/ai-claude-compat';
|
|
3
3
|
import { tool } from 'ai';
|
|
4
4
|
import { execa } from 'execa';
|
|
5
5
|
import { z } from 'zod';
|
|
@@ -20,6 +20,7 @@ export function localEditTools(cwd) {
|
|
|
20
20
|
grep: grepTool({ cwd }),
|
|
21
21
|
glob: globTool({ cwd }),
|
|
22
22
|
bash: bashTool({ cwd }),
|
|
23
|
+
multiBash: multiBashTool({ cwd }),
|
|
23
24
|
};
|
|
24
25
|
}
|
|
25
26
|
export function localReadTools(cwd) {
|
|
@@ -156,6 +157,7 @@ function defaultMakeOrchestrator(ctx) {
|
|
|
156
157
|
baseBranch,
|
|
157
158
|
styleContents: style,
|
|
158
159
|
rollingContext,
|
|
160
|
+
...(input.resolved.formatCommand ? { formatCommand: input.resolved.formatCommand } : {}),
|
|
159
161
|
});
|
|
160
162
|
},
|
|
161
163
|
finalizeCommit: (group, delivery, worktreePath) => orch.finalizeCommit(group, delivery, worktreePath),
|
|
@@ -191,6 +193,7 @@ function resolveWorkerTools(set, cwd) {
|
|
|
191
193
|
grep: set.grep ?? local.grep,
|
|
192
194
|
glob: set.glob ?? local.glob,
|
|
193
195
|
bash: set.bash ?? local.bash,
|
|
196
|
+
multiBash: set.multiBash ?? local.multiBash,
|
|
194
197
|
};
|
|
195
198
|
}
|
|
196
199
|
function resolveReviewerTools(set, cwd, github) {
|
|
@@ -11,6 +11,18 @@ export type TakeOverGithub = {
|
|
|
11
11
|
mergePr(pr: number, method: MergeMethod): Promise<void>;
|
|
12
12
|
replyToThread(threadId: string, body: string): Promise<void>;
|
|
13
13
|
resolveThread(threadId: string): Promise<void>;
|
|
14
|
+
getFailedCiLogs?(pr: number): Promise<Array<{
|
|
15
|
+
check: string;
|
|
16
|
+
logs: string;
|
|
17
|
+
}>>;
|
|
18
|
+
};
|
|
19
|
+
export type PrContextPort = {
|
|
20
|
+
clear(pr: number): Promise<void>;
|
|
21
|
+
saveCiFailures(pr: number, failures: ReadonlyArray<{
|
|
22
|
+
check: string;
|
|
23
|
+
logs: string;
|
|
24
|
+
}>): Promise<string | null>;
|
|
25
|
+
saveComments(pr: number, threads: readonly ReviewThread[]): Promise<string | null>;
|
|
14
26
|
};
|
|
15
27
|
export type TakeOverSubagents = {
|
|
16
28
|
reviewerModel: LanguageModel;
|
|
@@ -18,6 +30,7 @@ export type TakeOverSubagents = {
|
|
|
18
30
|
workerModel: LanguageModel;
|
|
19
31
|
workerTools: WorkerTools;
|
|
20
32
|
styleContents: string;
|
|
33
|
+
formatCommand?: string;
|
|
21
34
|
runReviewerOverride?: (input: {
|
|
22
35
|
pr: number;
|
|
23
36
|
threads: ReviewThread[];
|
|
@@ -38,6 +51,7 @@ export type TakeOverFlowInput = {
|
|
|
38
51
|
baseBranch: string;
|
|
39
52
|
github: TakeOverGithub;
|
|
40
53
|
subagents: TakeOverSubagents;
|
|
54
|
+
prContext?: PrContextPort;
|
|
41
55
|
mergeMethod: MergeMethod;
|
|
42
56
|
maxIterations?: number;
|
|
43
57
|
cooldownMs?: number;
|
|
@@ -19,9 +19,24 @@ export async function runTakeOverFlow(input) {
|
|
|
19
19
|
if (ciStatus === 'success' && threads.length === 0) {
|
|
20
20
|
break;
|
|
21
21
|
}
|
|
22
|
+
let ciLogsDir = null;
|
|
23
|
+
if (input.prContext) {
|
|
24
|
+
await input.prContext.clear(input.pr);
|
|
25
|
+
if ((ciStatus === 'failure' || ciStatus === 'cancelled') && input.github.getFailedCiLogs) {
|
|
26
|
+
const failures = await input.github.getFailedCiLogs(input.pr);
|
|
27
|
+
ciLogsDir = await input.prContext.saveCiFailures(input.pr, failures);
|
|
28
|
+
log?.info('take-over: downloaded ci logs', {
|
|
29
|
+
pr: input.pr,
|
|
30
|
+
checks: failures.length,
|
|
31
|
+
dir: ciLogsDir,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
if (threads.length > 0)
|
|
35
|
+
await input.prContext.saveComments(input.pr, threads);
|
|
36
|
+
}
|
|
22
37
|
let pushedSomething = false;
|
|
23
38
|
if (ciStatus === 'failure' || ciStatus === 'cancelled') {
|
|
24
|
-
const fixed = await runWorkerCiFix(input);
|
|
39
|
+
const fixed = await runWorkerCiFix(input, ciLogsDir);
|
|
25
40
|
if (fixed.kind === 'blocked') {
|
|
26
41
|
return { kind: 'blocked', reason: fixed.reason, iterations: iteration };
|
|
27
42
|
}
|
|
@@ -104,14 +119,14 @@ async function runReviewerThreads(input, threads) {
|
|
|
104
119
|
styleContents: input.subagents.styleContents,
|
|
105
120
|
});
|
|
106
121
|
}
|
|
107
|
-
async function runWorkerCiFix(input) {
|
|
122
|
+
async function runWorkerCiFix(input, ciLogsDir) {
|
|
123
|
+
const readTask = ciLogsDir
|
|
124
|
+
? `Read the downloaded CI failure logs in ${ciLogsDir} (one file per failed check, full untruncated logs) with your shell/read tools, then fix every failure those logs report.`
|
|
125
|
+
: `Read the CI logs (via gh) and fix every failing check on PR #${input.pr}.`;
|
|
108
126
|
const group = {
|
|
109
127
|
id: `takeover-ci-${input.pr}`,
|
|
110
128
|
title: `Fix CI failures on PR #${input.pr}`,
|
|
111
|
-
tasks: [
|
|
112
|
-
`Read the CI logs (via gh) and fix every failing check on PR #${input.pr}.`,
|
|
113
|
-
'Run the project test/lint commands locally to verify, then stage fixes.',
|
|
114
|
-
],
|
|
129
|
+
tasks: [readTask, 'Run the project test/lint commands locally to verify, then stage fixes.'],
|
|
115
130
|
dependsOn: [],
|
|
116
131
|
branch: null,
|
|
117
132
|
pr: input.pr,
|
|
@@ -137,6 +152,7 @@ async function runWorkerCiFix(input) {
|
|
|
137
152
|
baseBranch: input.baseBranch,
|
|
138
153
|
styleContents: input.subagents.styleContents,
|
|
139
154
|
rollingContext: '',
|
|
155
|
+
...(input.subagents.formatCommand ? { formatCommand: input.subagents.formatCommand } : {}),
|
|
140
156
|
});
|
|
141
157
|
}
|
|
142
158
|
function defaultSleep(ms) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { submittedOutput } from '@developerz.ai/ai-claude-compat';
|
|
2
|
+
import { generateText, hasToolCall, stepCountIs, ToolLoopAgent, tool } from 'ai';
|
|
2
3
|
import { ExecaError, execa } from 'execa';
|
|
3
4
|
import { z } from 'zod';
|
|
4
5
|
import { makePlannerTool, makeReviewerTool, makeWorkerTool, } from "./subagent-tools.js";
|
|
@@ -133,15 +134,26 @@ export class Orchestrator {
|
|
|
133
134
|
const result = await generateText({
|
|
134
135
|
model: this.init.credentials.modelFor('orchestrator'),
|
|
135
136
|
prompt: this.buildPrPrompt(group, delivery),
|
|
136
|
-
|
|
137
|
+
tools: {
|
|
138
|
+
submit: tool({
|
|
139
|
+
description: 'Submit the composed pull-request title and body (the PrComposition schema).',
|
|
140
|
+
inputSchema: PrCompositionSchema,
|
|
141
|
+
execute: async (composition) => composition,
|
|
142
|
+
}),
|
|
143
|
+
},
|
|
144
|
+
toolChoice: { type: 'tool', toolName: 'submit' },
|
|
137
145
|
});
|
|
138
|
-
|
|
146
|
+
const out = submittedOutput(result, PrCompositionSchema);
|
|
147
|
+
if (!out) {
|
|
148
|
+
throw new Error('orchestrator did not submit a PR composition');
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
139
151
|
}
|
|
140
152
|
buildPrPrompt(group, delivery) {
|
|
141
153
|
return [
|
|
142
154
|
this.buildSystemPrompt(),
|
|
143
155
|
'',
|
|
144
|
-
'Compose the pull-request title and body for this PR group
|
|
156
|
+
'Compose the pull-request title and body for this PR group, then call the submit tool with it.',
|
|
145
157
|
'- title: conventional-commit style, ≤72 chars',
|
|
146
158
|
'- body: short summary + bulleted file changes + relevant rolling context',
|
|
147
159
|
'',
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ReviewThread } from '../github/schema.ts';
|
|
2
|
+
export type CiFailure = {
|
|
3
|
+
check: string;
|
|
4
|
+
logs: string;
|
|
5
|
+
};
|
|
6
|
+
export type PrContextSummary = {
|
|
7
|
+
prDir: string;
|
|
8
|
+
ciDir: string | null;
|
|
9
|
+
commentsDir: string | null;
|
|
10
|
+
ciCount: number;
|
|
11
|
+
commentCount: number;
|
|
12
|
+
};
|
|
13
|
+
export declare class PrContextStore {
|
|
14
|
+
private readonly stateDir;
|
|
15
|
+
constructor(stateDir: string);
|
|
16
|
+
prDir(pr: number): string;
|
|
17
|
+
clear(pr: number): Promise<void>;
|
|
18
|
+
saveCiFailures(pr: number, failures: readonly CiFailure[]): Promise<string | null>;
|
|
19
|
+
saveComments(pr: number, threads: readonly ReviewThread[]): Promise<string | null>;
|
|
20
|
+
}
|