glab-agent 0.2.11 → 0.2.13

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "glab-agent",
3
- "version": "0.2.11",
3
+ "version": "0.2.13",
4
4
  "type": "module",
5
5
  "description": "Multi-agent GitLab To-Do watcher with YAML-defined agents, skills, and GitLab registry.",
6
6
  "license": "MIT",
@@ -24,6 +24,10 @@ export interface AgentRunContext {
24
24
  signal?: AbortSignal;
25
25
  /** Hint when issue labels don't match trigger config. Passed to agent prompt. */
26
26
  labelMismatchHint?: string;
27
+ /** Path to the target project's repo (cached bare clone). Used by runners for git operations. */
28
+ targetRepoPath?: string;
29
+ /** Override gitlabProjectId for the prompt (target project, not agents repo) */
30
+ gitlabProjectId?: number;
27
31
  }
28
32
 
29
33
  export interface SpawnedRunResult {
@@ -230,7 +234,8 @@ export function buildBasePrompt(ctx: AgentPromptContext): string {
230
234
  "",
231
235
  "## 当前环境",
232
236
  `- 分支: ${ctx.branch}`,
233
- "- 工作目录: 项目的独立工作副本",
237
+ "- 工作目录: issue 对应项目的独立工作副本",
238
+ "- 如果任务涉及其他仓库,可以用 git clone 拉取(GITLAB_TOKEN 已配置,支持认证)",
234
239
  ctx.labelMismatchHint ? `\n## 注意\n${ctx.labelMismatchHint}` : "",
235
240
  "",
236
241
  "## 最终输出",
@@ -67,7 +67,7 @@ export class ClaudeRunner implements AgentRunner {
67
67
  issueIid: issue.iid,
68
68
  issueTitle: issue.title,
69
69
  gitlabHost: this.gitlabHost,
70
- gitlabProjectId: this.gitlabProjectId,
70
+ gitlabProjectId: context?.gitlabProjectId ?? this.gitlabProjectId,
71
71
  worktreePath: worktree.worktreePath,
72
72
  branch: worktree.branch,
73
73
  provider: "claude",
@@ -151,7 +151,7 @@ export class ClaudeRunner implements AgentRunner {
151
151
  issueIid: issue.iid,
152
152
  issueTitle: issue.title,
153
153
  gitlabHost: this.gitlabHost,
154
- gitlabProjectId: this.gitlabProjectId,
154
+ gitlabProjectId: context?.gitlabProjectId ?? this.gitlabProjectId,
155
155
  worktreePath: worktree.worktreePath,
156
156
  branch: worktree.branch,
157
157
  provider: "claude",
@@ -67,7 +67,7 @@ export class CodexRunner implements AgentRunner {
67
67
  issueIid: issue.iid,
68
68
  issueTitle: issue.title,
69
69
  gitlabHost: this.gitlabHost,
70
- gitlabProjectId: this.gitlabProjectId,
70
+ gitlabProjectId: context?.gitlabProjectId ?? this.gitlabProjectId,
71
71
  worktreePath: worktree.worktreePath,
72
72
  branch: worktree.branch,
73
73
  provider: "codex",
@@ -154,7 +154,7 @@ export class CodexRunner implements AgentRunner {
154
154
  issueIid: issue.iid,
155
155
  issueTitle: issue.title,
156
156
  gitlabHost: this.gitlabHost,
157
- gitlabProjectId: this.gitlabProjectId,
157
+ gitlabProjectId: context?.gitlabProjectId ?? this.gitlabProjectId,
158
158
  worktreePath: worktree.worktreePath,
159
159
  branch: worktree.branch,
160
160
  provider: "codex",
@@ -101,7 +101,7 @@ export interface GitlabClient {
101
101
  getWikiPage(projectId: number | string, slug: string): Promise<WikiPage>;
102
102
  createWikiPage(projectId: number | string, title: string, content: string): Promise<WikiPage>;
103
103
  updateWikiPage(projectId: number | string, slug: string, title: string, content: string): Promise<WikiPage>;
104
- getProject(projectIdOrPath: number | string): Promise<{ id: number } | undefined>;
104
+ getProject(projectIdOrPath: number | string): Promise<{ id: number; httpUrlToRepo?: string; pathWithNamespace?: string } | undefined>;
105
105
  createProject(name: string, options?: { visibility?: string; initializeWithReadme?: boolean }): Promise<{ id: number }>;
106
106
  getRepositoryFile(projectId: number | string, filePath: string, ref?: string): Promise<{ content: string } | undefined>;
107
107
  createOrUpdateRepositoryFile(projectId: number | string, filePath: string, content: string, commitMessage: string, options?: { create?: boolean }): Promise<void>;
@@ -723,13 +723,18 @@ export class GitlabGlabClient implements GitlabClient {
723
723
  };
724
724
  }
725
725
 
726
- async getProject(projectIdOrPath: number | string): Promise<{ id: number } | undefined> {
726
+ async getProject(projectIdOrPath: number | string): Promise<{ id: number; httpUrlToRepo?: string; pathWithNamespace?: string } | undefined> {
727
727
  try {
728
728
  const encoded = typeof projectIdOrPath === "string" ? encodeURIComponent(projectIdOrPath) : projectIdOrPath;
729
729
  const payload = await this.readJson(`projects/${encoded}`);
730
730
  const p = (payload ?? {}) as Payload;
731
731
  const id = Number(p.id);
732
- return Number.isNaN(id) ? undefined : { id };
732
+ if (Number.isNaN(id)) return undefined;
733
+ return {
734
+ id,
735
+ httpUrlToRepo: typeof p.http_url_to_repo === "string" ? p.http_url_to_repo as string : undefined,
736
+ pathWithNamespace: typeof p.path_with_namespace === "string" ? p.path_with_namespace as string : undefined,
737
+ };
733
738
  } catch {
734
739
  return undefined;
735
740
  }
@@ -122,6 +122,21 @@ export class RepoCache {
122
122
  return true;
123
123
  }
124
124
 
125
+ /**
126
+ * Build a git clone URL with embedded token for authentication.
127
+ * e.g., https://oauth2:TOKEN@gitlab.example.com/group/project.git
128
+ */
129
+ static authenticatedUrl(httpUrl: string, token: string): string {
130
+ try {
131
+ const url = new URL(httpUrl);
132
+ url.username = "oauth2";
133
+ url.password = token;
134
+ return url.toString();
135
+ } catch {
136
+ return httpUrl;
137
+ }
138
+ }
139
+
125
140
  /**
126
141
  * Get the cache directory path for a given repo URL.
127
142
  * Uses SHA-256 hash of the URL for the directory name.
@@ -1,5 +1,6 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { readFileSync } from "node:fs";
3
+ import { mkdir } from "node:fs/promises";
3
4
  import path from "node:path";
4
5
 
5
6
  import { AgentRunnerError, type AgentRunner, type SpawnedRun } from "./agent-runner.js";
@@ -33,6 +34,7 @@ import { rotateIfNeeded } from "./log-rotate.js";
33
34
  import { validateToken } from "./token-check.js";
34
35
  import { WorktreeManager } from "./worktree-manager.js";
35
36
  import type { WorktreeInfo } from "./worktree-manager.js";
37
+ import { RepoCache } from "./repo-cache.js";
36
38
  import { injectSkillFiles } from "./skill-inject.js";
37
39
  import { startHealthServer } from "./health-server.js";
38
40
  import type { HealthStatus } from "./health-server.js";
@@ -103,6 +105,8 @@ export interface WatcherDependencies {
103
105
  issueCloseCheckIntervalMs?: number;
104
106
  /** Feishu app client for targeted personal notifications */
105
107
  feishuClient?: import("./feishu-client.js").FeishuClient;
108
+ /** Cache for bare clones of target project repos */
109
+ repoCache?: RepoCache;
106
110
  }
107
111
 
108
112
  export interface WatcherCycleResult {
@@ -338,6 +342,52 @@ async function replaceReactionOnTodo(
338
342
  }
339
343
  }
340
344
 
345
+ /**
346
+ * Resolve the target project's repo and create a worktree for the given issue/MR.
347
+ * Uses RepoCache to clone/fetch the target project, then creates a worktree from it.
348
+ * Falls back to the agents repo worktree if the target project can't be resolved.
349
+ */
350
+ async function resolveTargetWorktree(
351
+ projectId: number,
352
+ iid: number,
353
+ title: string,
354
+ config: LocalAgentConfig,
355
+ dependencies: WatcherDependencies,
356
+ logger: Logger | typeof console
357
+ ): Promise<{ worktree: WorktreeInfo }> {
358
+ if (dependencies.repoCache) {
359
+ try {
360
+ const project = await dependencies.gitlabClient.getProject(projectId);
361
+ if (project?.httpUrlToRepo) {
362
+ const authedUrl = RepoCache.authenticatedUrl(project.httpUrlToRepo, config.gitlabToken);
363
+ const cachedRepoPath = await dependencies.repoCache.ensureRepo(authedUrl);
364
+
365
+ const agentName = config.agentDefinition?.name;
366
+ const worktreeRoot = path.join(config.agentRepoPath, ".glab-agent", "worktrees", agentName ?? "agent");
367
+
368
+ await mkdir(worktreeRoot, { recursive: true });
369
+
370
+ // Use a WorktreeManager pointing at the cached bare repo
371
+ const targetWtm = new WorktreeManager({
372
+ repoPath: cachedRepoPath,
373
+ worktreeRoot,
374
+ agentName,
375
+ });
376
+ const worktree = await targetWtm.ensureWorktree(iid, title);
377
+
378
+ (logger as Logger | undefined)?.info?.(`Target repo resolved: ${project.pathWithNamespace ?? projectId} → ${cachedRepoPath}`);
379
+ return { worktree };
380
+ }
381
+ } catch (err) {
382
+ (logger as Logger | undefined)?.warn?.(`Failed to resolve target repo for project ${projectId}, falling back to agents repo: ${String(err).slice(0, 150)}`);
383
+ }
384
+ }
385
+
386
+ // Fallback: use agents repo worktree (original behavior)
387
+ const worktree = await dependencies.worktreeManager.ensureWorktree(iid, title);
388
+ return { worktree };
389
+ }
390
+
341
391
  export async function runWatcherCycle(
342
392
  config: LocalAgentConfig,
343
393
  dependencies: WatcherDependencies
@@ -346,6 +396,13 @@ export async function runWatcherCycle(
346
396
  const logger = dependencies.logger ?? console;
347
397
  const state = await dependencies.stateStore.load();
348
398
 
399
+ // When Feishu app client is configured, suppress group webhook notifications —
400
+ // personal Feishu cards already cover the full lifecycle (accepted/running/completed/failed).
401
+ // Mutate a local copy so the original config is untouched.
402
+ if (dependencies.feishuClient && config.webhookUrl) {
403
+ config = { ...config, webhookUrl: undefined };
404
+ }
405
+
349
406
  if (state.activeRun && isPidAlive(state.activeRun.pid) && state.activeRun.pid !== pid) {
350
407
  logger.info(`Watcher is busy with issue #${state.activeRun.issueIid}.`);
351
408
  await updateAgentUserStatus(dependencies, "busy", `#${state.activeRun.issueIid}`);
@@ -429,7 +486,9 @@ export async function runWatcherCycle(
429
486
  webUrl: ""
430
487
  };
431
488
 
432
- const worktree = await dependencies.worktreeManager.ensureWorktree(targetIid, `mr-${targetIid}`);
489
+ const { worktree } = await resolveTargetWorktree(
490
+ candidate.projectId, targetIid, `mr-${targetIid}`, config, dependencies, logger
491
+ );
433
492
  state.activeRun = {
434
493
  pid,
435
494
  todoId: candidate.id,
@@ -456,7 +515,7 @@ export async function runWatcherCycle(
456
515
  logger.info(`Starting agent for MR !${targetIid} branch=${worktree.branch}`);
457
516
 
458
517
  try {
459
- const result = await dependencies.agentRunner.run(issue, worktree, { todoId: candidate.id });
518
+ const result = await dependencies.agentRunner.run(issue, worktree, { todoId: candidate.id, gitlabProjectId: candidate.projectId });
460
519
  logger.info(`Agent completed MR !${targetIid}. Summary: ${result.summary.slice(0, 200)}`);
461
520
  } catch (error) {
462
521
  const summary = error instanceof AgentRunnerError ? error.summary : String(error);
@@ -486,7 +545,9 @@ export async function runWatcherCycle(
486
545
  logger.info(`Issue #${issue.iid}: label mismatch, passing hint to agent`);
487
546
  }
488
547
 
489
- const worktree = await dependencies.worktreeManager.ensureWorktree(issue.iid, issue.title);
548
+ const { worktree } = await resolveTargetWorktree(
549
+ issue.projectId, issue.iid, issue.title, config, dependencies, logger
550
+ );
490
551
 
491
552
  state.activeRun = {
492
553
  pid,
@@ -566,7 +627,7 @@ export async function runWatcherCycle(
566
627
 
567
628
  try {
568
629
  const raceResult = await Promise.race([
569
- dependencies.agentRunner.run(issue, worktree, { todoId: candidate.id, signal: abortController.signal, labelMismatchHint }).then(r => ({ type: "completed" as const, result: r })),
630
+ dependencies.agentRunner.run(issue, worktree, { todoId: candidate.id, signal: abortController.signal, labelMismatchHint, gitlabProjectId: issue.projectId }).then(r => ({ type: "completed" as const, result: r })),
570
631
  closeDetection.then(() => ({ type: "closed" as const }))
571
632
  ]);
572
633
 
@@ -712,6 +773,11 @@ export async function runConcurrentWatcherCycle(
712
773
  const state = await dependencies.stateStore.load();
713
774
  const concurrency = config.agentDefinition?.concurrency ?? 1;
714
775
 
776
+ // Suppress group webhook when Feishu personal cards are active
777
+ if (dependencies.feishuClient && config.webhookUrl) {
778
+ config = { ...config, webhookUrl: undefined };
779
+ }
780
+
715
781
  // 1. Collect finished tasks
716
782
  for (const [issueIid, spawned] of spawnedRuns) {
717
783
  const result = await spawned.run.poll();
@@ -860,7 +926,10 @@ export async function runConcurrentWatcherCycle(
860
926
 
861
927
  // Prepare and spawn
862
928
  const worktreeTitle = isIssueTodo ? issue.title : `mr-${targetIid}`;
863
- const worktree = await dependencies.worktreeManager.ensureWorktree(targetIid, worktreeTitle);
929
+ const { worktree } = await resolveTargetWorktree(
930
+ isIssueTodo ? issue.projectId : candidate.projectId,
931
+ targetIid, worktreeTitle, config, dependencies, logger
932
+ );
864
933
 
865
934
  const skills = config.agentDefinition?.skills ?? [];
866
935
  if (skills.length > 0) {
@@ -870,7 +939,7 @@ export async function runConcurrentWatcherCycle(
870
939
  } catch (err) { logger.warn(`Failed to inject skill files: ${String(err)}`); }
871
940
  }
872
941
 
873
- const spawnedRun = await dependencies.agentRunner.spawn(issue, worktree, { todoId: candidate.id, labelMismatchHint });
942
+ const spawnedRun = await dependencies.agentRunner.spawn(issue, worktree, { todoId: candidate.id, labelMismatchHint, gitlabProjectId: isIssueTodo ? issue.projectId : candidate.projectId });
874
943
  const now = new Date().toISOString();
875
944
 
876
945
  spawnedRuns.set(issue.iid, {
@@ -1602,6 +1671,9 @@ function createDependencies(config: LocalAgentConfig, logger?: Logger): WatcherD
1602
1671
  agentName: config.agentDefinition?.name
1603
1672
  }),
1604
1673
  agentRunner,
1674
+ repoCache: new RepoCache({
1675
+ cacheDir: path.join(config.agentRepoPath, ".glab-agent", "repo-cache")
1676
+ }),
1605
1677
  logger
1606
1678
  };
1607
1679
  }
@@ -1805,9 +1877,8 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
1805
1877
  config.botName = botUser.name;
1806
1878
  logger.info(`Token validated. Bot identity: @${botUser.username} (${botUser.name})`);
1807
1879
 
1808
- // Update GitLab user bio with agent profile (P1: Agent = 员工)
1880
+ // Update GitLab user profile (P1: Agent = 员工)
1809
1881
  await updateAgentUserBio(config, dependencies);
1810
- await publishAgentProfileWiki(config, dependencies);
1811
1882
  await publishProfileReadme(config, dependencies);
1812
1883
  await updateAgentUserStatus(dependencies, "idle", undefined, config);
1813
1884
  } catch (error) {