glab-agent 0.1.0 → 0.2.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/README.md +21 -8
- package/package.json +1 -1
- package/src/local-agent/agent-config.ts +31 -1
- package/src/local-agent/agent-runner.ts +59 -4
- package/src/local-agent/claude-runner.ts +68 -0
- package/src/local-agent/cli.ts +903 -64
- package/src/local-agent/codex-runner.ts +63 -0
- package/src/local-agent/gitlab-glab-client.ts +71 -0
- package/src/local-agent/notifier.ts +19 -101
- package/src/local-agent/report.ts +31 -0
- package/src/local-agent/watcher.ts +164 -49
- package/src/local-agent/wiki-sync.ts +0 -290
package/README.md
CHANGED
|
@@ -15,25 +15,38 @@ GitLab-native AI agent orchestration platform. Define coding agents in YAML, man
|
|
|
15
15
|
|
|
16
16
|
## Quick Start
|
|
17
17
|
|
|
18
|
+
### Prerequisites
|
|
19
|
+
|
|
20
|
+
- **Node.js >= 20** — `node --version`
|
|
21
|
+
- **glab CLI** — [Install](https://gitlab.com/gitlab-org/cli#installation), then `glab auth login`
|
|
22
|
+
- **Claude CLI** or **Codex CLI** — the AI provider your agent will use
|
|
23
|
+
- **GitLab bot account** (recommended) — a dedicated user account so agent actions appear separately from yours
|
|
24
|
+
|
|
25
|
+
### Setup
|
|
26
|
+
|
|
18
27
|
```bash
|
|
19
|
-
# Install
|
|
28
|
+
# 1. Install
|
|
20
29
|
npm install -g glab-agent
|
|
21
30
|
|
|
22
|
-
# Initialize in your project
|
|
31
|
+
# 2. Initialize in your project
|
|
23
32
|
cd your-project
|
|
24
33
|
glab-agent init my-bot --provider claude
|
|
25
34
|
|
|
26
|
-
#
|
|
35
|
+
# 3. Create a GitLab Personal Access Token for the bot account
|
|
36
|
+
# → Settings > Access Tokens > scopes: api, read_user, read_repository, write_repository
|
|
27
37
|
echo "GITLAB_TOKEN=glpat-xxx" >> .glab-agent/.env
|
|
28
38
|
|
|
29
|
-
#
|
|
30
|
-
glab-agent
|
|
39
|
+
# 4. Verify dependencies
|
|
40
|
+
glab-agent doctor
|
|
41
|
+
|
|
42
|
+
# 5. Test with a single poll cycle (synchronous, you see output immediately)
|
|
43
|
+
glab-agent run-once my-bot
|
|
31
44
|
|
|
32
|
-
#
|
|
33
|
-
glab-agent
|
|
45
|
+
# 6. Start background watcher
|
|
46
|
+
glab-agent start my-bot
|
|
34
47
|
```
|
|
35
48
|
|
|
36
|
-
|
|
49
|
+
Go to any GitLab issue and type `@your-bot-username` — the agent will pick it up, write code, run tests, and create a merge request. Use `glab-agent watch my-bot` to see what it's doing.
|
|
37
50
|
|
|
38
51
|
## How It Works
|
|
39
52
|
|
package/package.json
CHANGED
|
@@ -33,6 +33,24 @@ export interface AgentNotifications {
|
|
|
33
33
|
webhook_url?: string;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
export interface AgentLabelConfig {
|
|
37
|
+
backlog: string;
|
|
38
|
+
todo: string;
|
|
39
|
+
in_progress: string;
|
|
40
|
+
in_review: string;
|
|
41
|
+
done: string;
|
|
42
|
+
error: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const DEFAULT_LABELS: AgentLabelConfig = {
|
|
46
|
+
backlog: "Backlog",
|
|
47
|
+
todo: "Todo",
|
|
48
|
+
in_progress: "In Progress",
|
|
49
|
+
in_review: "In Review",
|
|
50
|
+
done: "Done",
|
|
51
|
+
error: "Error",
|
|
52
|
+
};
|
|
53
|
+
|
|
36
54
|
export interface AgentDefinition {
|
|
37
55
|
name: string;
|
|
38
56
|
display_name: string;
|
|
@@ -52,6 +70,7 @@ export interface AgentDefinition {
|
|
|
52
70
|
timeout_seconds?: number;
|
|
53
71
|
model?: string;
|
|
54
72
|
notifications: AgentNotifications;
|
|
73
|
+
labels: AgentLabelConfig;
|
|
55
74
|
}
|
|
56
75
|
|
|
57
76
|
// ── Loader ───────────────────────────────────────────────────────────────────
|
|
@@ -146,6 +165,16 @@ export function parseAgentDefinition(raw: unknown, fileName: string): AgentDefin
|
|
|
146
165
|
throw new Error(`Invalid provider "${provider}" in ${fileName}. Must be "codex" or "claude".`);
|
|
147
166
|
}
|
|
148
167
|
|
|
168
|
+
const rawLabels = (obj.labels ?? {}) as RawValue;
|
|
169
|
+
const labels: AgentLabelConfig = {
|
|
170
|
+
backlog: asString(rawLabels.backlog, DEFAULT_LABELS.backlog),
|
|
171
|
+
todo: asString(rawLabels.todo, DEFAULT_LABELS.todo),
|
|
172
|
+
in_progress: asString(rawLabels.in_progress, DEFAULT_LABELS.in_progress),
|
|
173
|
+
in_review: asString(rawLabels.in_review, DEFAULT_LABELS.in_review),
|
|
174
|
+
done: asString(rawLabels.done, DEFAULT_LABELS.done),
|
|
175
|
+
error: asString(rawLabels.error, DEFAULT_LABELS.error),
|
|
176
|
+
};
|
|
177
|
+
|
|
149
178
|
return {
|
|
150
179
|
name,
|
|
151
180
|
display_name: asString(obj.display_name, name),
|
|
@@ -163,7 +192,8 @@ export function parseAgentDefinition(raw: unknown, fileName: string): AgentDefin
|
|
|
163
192
|
? obj.timeout_seconds
|
|
164
193
|
: undefined,
|
|
165
194
|
model: typeof obj.model === "string" ? obj.model : undefined,
|
|
166
|
-
notifications: parseNotifications(obj.notifications)
|
|
195
|
+
notifications: parseNotifications(obj.notifications),
|
|
196
|
+
labels,
|
|
167
197
|
};
|
|
168
198
|
}
|
|
169
199
|
|
|
@@ -9,6 +9,8 @@ import type { GitlabIssue } from "./gitlab-glab-client.js";
|
|
|
9
9
|
import type { AgentProvider } from "./agent-provider.js";
|
|
10
10
|
import type { WorktreeInfo } from "./worktree-manager.js";
|
|
11
11
|
import type { Logger } from "./logger.js";
|
|
12
|
+
import { DEFAULT_LABELS } from "./agent-config.js";
|
|
13
|
+
import type { AgentLabelConfig } from "./agent-config.js";
|
|
12
14
|
|
|
13
15
|
const execFileAsync = promisify(execFile);
|
|
14
16
|
|
|
@@ -24,6 +26,8 @@ export interface AgentRunContext {
|
|
|
24
26
|
|
|
25
27
|
export interface AgentRunner {
|
|
26
28
|
run(issue: GitlabIssue, worktree: WorktreeInfo, context?: AgentRunContext): Promise<AgentRunResult>;
|
|
29
|
+
/** Run a lightweight contextual reply — no worktree, read-only. Optional: falls back to full_work if absent. */
|
|
30
|
+
runContextual?(issue: GitlabIssue, todoBody: string, context?: AgentRunContext): Promise<AgentRunResult>;
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
export interface AgentRunnerOptions {
|
|
@@ -67,12 +71,53 @@ export interface AgentPromptContext {
|
|
|
67
71
|
preamble?: string;
|
|
68
72
|
append?: string;
|
|
69
73
|
templatePath?: string;
|
|
74
|
+
labels?: AgentLabelConfig;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface ContextualPromptContext {
|
|
78
|
+
issueIid: number;
|
|
79
|
+
issueTitle: string;
|
|
80
|
+
gitlabHost: string;
|
|
81
|
+
gitlabProjectId: number;
|
|
82
|
+
todoBody: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Build a lightweight read-only prompt for contextual replies (NT-4).
|
|
87
|
+
* The agent answers questions and posts a note but does NOT modify code.
|
|
88
|
+
*/
|
|
89
|
+
export function buildContextualPrompt(ctx: ContextualPromptContext): string {
|
|
90
|
+
const p = ctx.gitlabProjectId;
|
|
91
|
+
return [
|
|
92
|
+
"你是项目团队成员(AI agent),收到了一个 @mention 需要回复。",
|
|
93
|
+
"",
|
|
94
|
+
"## 任务",
|
|
95
|
+
`Issue #${ctx.issueIid}: ${truncate(ctx.issueTitle, 200)}`,
|
|
96
|
+
`@mention 内容: ${ctx.todoBody || "(空)"}`,
|
|
97
|
+
"",
|
|
98
|
+
"## GitLab 环境",
|
|
99
|
+
`- Host: ${ctx.gitlabHost} | Project ID: ${p}`,
|
|
100
|
+
"- GITLAB_TOKEN 和 GITLAB_HOST 已配置,可直接使用 glab 命令。",
|
|
101
|
+
"",
|
|
102
|
+
"## 指令",
|
|
103
|
+
`1. 读取 issue 详情: glab api projects/${p}/issues/${ctx.issueIid}`,
|
|
104
|
+
` 读评论: glab api projects/${p}/issues/${ctx.issueIid}/notes`,
|
|
105
|
+
"2. 阅读项目代码和文档来回答问题。",
|
|
106
|
+
`3. 在 issue 上发评论回复: glab api --method POST projects/${p}/issues/${ctx.issueIid}/notes --raw-field "body=<回复>"`,
|
|
107
|
+
"4. **不要修改任何代码文件**。如果需要代码修改,在回复中建议创建新 issue。",
|
|
108
|
+
"5. 像团队成员一样沟通,自然、简洁、有判断力。",
|
|
109
|
+
"6. 不要暴露内部实现细节(worktree 路径、执行器名称等)。",
|
|
110
|
+
"",
|
|
111
|
+
"## 最终输出",
|
|
112
|
+
"标准输出不超过 3 行的简短总结(不是 GitLab 评论)。"
|
|
113
|
+
].join("\n");
|
|
70
114
|
}
|
|
71
115
|
|
|
72
116
|
export function buildBasePrompt(ctx: AgentPromptContext): string {
|
|
73
117
|
const p = ctx.gitlabProjectId;
|
|
74
118
|
const iid = ctx.issueIid;
|
|
75
119
|
const todoId = ctx.todoId;
|
|
120
|
+
const labels = ctx.labels ?? DEFAULT_LABELS;
|
|
76
121
|
return [
|
|
77
122
|
"你是这个项目的团队成员(AI agent),被分配处理一个 GitLab 任务。",
|
|
78
123
|
"你在项目的独立工作分支中,可以读代码、读文档、运行命令、操作 GitLab。",
|
|
@@ -97,7 +142,9 @@ export function buildBasePrompt(ctx: AgentPromptContext): string {
|
|
|
97
142
|
`- 读评论: glab api projects/${p}/issues/${iid}/notes`,
|
|
98
143
|
`- 读关联 issue: glab api projects/${p}/issues/${iid}/links`,
|
|
99
144
|
"- 阅读项目代码和文档,理解架构和设计原则。",
|
|
100
|
-
|
|
145
|
+
`- 查阅项目 Wiki(如有): glab api projects/${p}/wikis`,
|
|
146
|
+
" - Wiki 是团队共享的知识库,包含踩坑记录、设计决策、常用模式等。",
|
|
147
|
+
` - 读具体页面: glab api "projects/${p}/wikis/<slug>"(slug 含 / 时需 URL 编码为 %2F)`,
|
|
101
148
|
"",
|
|
102
149
|
"### 2. 判断并行动",
|
|
103
150
|
"- **明确的技术任务**(bug fix、功能实现、重构、文档)→ 实现它",
|
|
@@ -105,9 +152,9 @@ export function buildBasePrompt(ctx: AgentPromptContext): string {
|
|
|
105
152
|
"- **信息不足** → 在 issue 上提问,不要猜测需求",
|
|
106
153
|
"",
|
|
107
154
|
"### 3. 管理看板状态",
|
|
108
|
-
`- 开始工作时: glab api --method PUT projects/${p}/issues/${iid} --raw-field "labels
|
|
109
|
-
`- 创建 MR 后: glab api --method PUT projects/${p}/issues/${iid} --raw-field "labels
|
|
110
|
-
`- 遇到无法解决的问题: glab api --method PUT projects/${p}/issues/${iid} --raw-field "labels
|
|
155
|
+
`- 开始工作时: glab api --method PUT projects/${p}/issues/${iid} --raw-field "labels=${labels.in_progress}"`,
|
|
156
|
+
`- 创建 MR 后: glab api --method PUT projects/${p}/issues/${iid} --raw-field "labels=${labels.in_review}"`,
|
|
157
|
+
`- 遇到无法解决的问题: glab api --method PUT projects/${p}/issues/${iid} --raw-field "labels=${labels.error}"`,
|
|
111
158
|
todoId ? `- 处理完成后: glab api --method POST todos/${todoId}/mark_as_done` : "",
|
|
112
159
|
"",
|
|
113
160
|
"### 4. 代码交付(仅当决定实现时)",
|
|
@@ -125,6 +172,14 @@ export function buildBasePrompt(ctx: AgentPromptContext): string {
|
|
|
125
172
|
"- **不要**暴露内部实现细节(worktree 路径、执行器名称、PID 等)。",
|
|
126
173
|
"- **不要**输出内部思考过程。",
|
|
127
174
|
"",
|
|
175
|
+
"### 6. 知识沉淀",
|
|
176
|
+
"- 如果在工作过程中发现了新的踩坑经验、设计决策或可复用模式,写入项目 Wiki。",
|
|
177
|
+
` - 新建页面: glab api --method POST projects/${p}/wikis --raw-field "title=<标题>" --raw-field "content=<Markdown 内容>"`,
|
|
178
|
+
` - 更新已有页面: glab api --method PUT "projects/${p}/wikis/<slug>" --raw-field "title=<原标题>" --raw-field "content=<新内容>"(必须传 title,否则目录前缀会丢失)`,
|
|
179
|
+
"- 推荐分类: gotchas/(踩坑)、patterns/(模式)、decisions/(决策)、runbooks/(操作手册)。",
|
|
180
|
+
"- 只记录**非显而易见的**经验。不要记录代码里能直接看到的信息。",
|
|
181
|
+
"- Wiki 标题用 `分类/标题` 格式,如 `gotchas/并发写入文件需要锁`。",
|
|
182
|
+
"",
|
|
128
183
|
"## 当前环境",
|
|
129
184
|
`- 分支: ${ctx.branch}`,
|
|
130
185
|
"- 工作目录: 项目的独立工作副本",
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
type AgentRunner,
|
|
13
13
|
type AgentRunnerOptions,
|
|
14
14
|
buildAgentPrompt,
|
|
15
|
+
buildContextualPrompt,
|
|
15
16
|
createOutputPath,
|
|
16
17
|
isSmokeIssue,
|
|
17
18
|
runSmokeTestChecks
|
|
@@ -67,6 +68,7 @@ export class ClaudeRunner implements AgentRunner {
|
|
|
67
68
|
todoId: context?.todoId,
|
|
68
69
|
preamble: this.agentDefinition?.prompt.preamble,
|
|
69
70
|
append: this.agentDefinition?.prompt.append,
|
|
71
|
+
labels: this.agentDefinition?.labels,
|
|
70
72
|
// skills injected via .claude/skills/*.md files, not in prompt
|
|
71
73
|
});
|
|
72
74
|
const claudeCommand = resolveClaudeCommand(this.env);
|
|
@@ -129,6 +131,72 @@ export class ClaudeRunner implements AgentRunner {
|
|
|
129
131
|
);
|
|
130
132
|
}
|
|
131
133
|
}
|
|
134
|
+
|
|
135
|
+
async runContextual(issue: GitlabIssue, todoBody: string, context?: AgentRunContext): Promise<AgentRunResult> {
|
|
136
|
+
const signal = context?.signal;
|
|
137
|
+
const outputPath = await createOutputPath("claude-contextual", issue.iid);
|
|
138
|
+
|
|
139
|
+
const prompt = buildContextualPrompt({
|
|
140
|
+
issueIid: issue.iid,
|
|
141
|
+
issueTitle: issue.title,
|
|
142
|
+
gitlabHost: this.gitlabHost,
|
|
143
|
+
gitlabProjectId: this.gitlabProjectId,
|
|
144
|
+
todoBody,
|
|
145
|
+
});
|
|
146
|
+
const claudeCommand = resolveClaudeCommand(this.env);
|
|
147
|
+
|
|
148
|
+
this.logger.info(`Contextual reply: ${claudeCommand} -p ... cwd=${this.repoPath || process.cwd()}`);
|
|
149
|
+
this.logger.info(`Prompt (${prompt.length} chars): contextual issue #${issue.iid} "${issue.title}"`);
|
|
150
|
+
|
|
151
|
+
const timeoutMs = this.timeoutSeconds ? this.timeoutSeconds * 1000 : 0;
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
const args = ["-p", "--output-format", "text", "--permission-mode", "bypassPermissions"];
|
|
155
|
+
if (this.agentDefinition?.model) {
|
|
156
|
+
args.push("--model", this.agentDefinition.model);
|
|
157
|
+
}
|
|
158
|
+
args.push(prompt);
|
|
159
|
+
|
|
160
|
+
const { stdout } = await this.execFileImpl(
|
|
161
|
+
claudeCommand,
|
|
162
|
+
args,
|
|
163
|
+
{
|
|
164
|
+
cwd: this.repoPath || process.cwd(),
|
|
165
|
+
encoding: "utf8",
|
|
166
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
167
|
+
env: this.env,
|
|
168
|
+
...(timeoutMs > 0 ? { timeout: timeoutMs, killSignal: "SIGTERM" } : {}),
|
|
169
|
+
...(signal ? { signal } : {})
|
|
170
|
+
}
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
const summary = truncate(compactText(stdout || "Contextual reply sent."), 600);
|
|
174
|
+
this.logger.info(`Contextual reply finished. Output: ${outputPath}`);
|
|
175
|
+
await writeFile(outputPath, summary, "utf8");
|
|
176
|
+
|
|
177
|
+
return { summary, outputPath };
|
|
178
|
+
} catch (error) {
|
|
179
|
+
const execError = error as { stdout?: string; stderr?: string; message?: string; killed?: boolean };
|
|
180
|
+
const isTimeout = execError.killed === true && timeoutMs > 0;
|
|
181
|
+
const summary = isTimeout
|
|
182
|
+
? `Timed out after ${this.timeoutSeconds}s`
|
|
183
|
+
: truncate(
|
|
184
|
+
compactText(execError.stdout || execError.stderr || execError.message || "Contextual reply failed."),
|
|
185
|
+
600
|
|
186
|
+
);
|
|
187
|
+
if (isTimeout) {
|
|
188
|
+
this.logger.error(`Contextual reply timed out after ${this.timeoutSeconds}s.`);
|
|
189
|
+
} else {
|
|
190
|
+
this.logger.error(`Contextual reply failed. stderr: ${execError.stderr?.slice(0, 300) ?? "(none)"}`);
|
|
191
|
+
}
|
|
192
|
+
await writeFile(outputPath, summary, "utf8");
|
|
193
|
+
throw new AgentRunnerError(
|
|
194
|
+
isTimeout ? `Claude Code timed out after ${this.timeoutSeconds}s.` : "Contextual reply failed.",
|
|
195
|
+
summary,
|
|
196
|
+
outputPath
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
132
200
|
}
|
|
133
201
|
|
|
134
202
|
export function resolveClaudeCommand(env: NodeJS.ProcessEnv = process.env): string {
|