claude-teammate 0.1.83 → 0.1.85

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": "claude-teammate",
3
- "version": "0.1.83",
3
+ "version": "0.1.85",
4
4
  "description": "CLI bootstrapper for Claude Teammate.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -6,6 +6,7 @@ import process from "node:process";
6
6
  import { ensurePlaywrightMcpAvailable, verifyClaudeCli } from "../claude.js";
7
7
  import { createForgeRegistry } from "../forge/index.js";
8
8
  import { createJiraClient } from "../jira.js";
9
+ import { listKnownRepos } from "../memory.js";
9
10
  import {
10
11
  REQUIRED_FIELDS,
11
12
  ensureGlobalConfigDir,
@@ -71,7 +72,7 @@ export async function runStartCommand({ projectRoot, entrypointPath, args = [] }
71
72
 
72
73
  process.stdout.write("Checking Jira credentials...\n");
73
74
  await verifyJiraCredentials(values);
74
- await verifyForgeCredentials(values);
75
+ await verifyForgeCredentials(values, configRoot);
75
76
 
76
77
  const logFd = openSync(runtimePaths.logFile, "a");
77
78
  const child = spawn(process.execPath, [entrypointPath, "run-worker", configRoot], {
@@ -176,31 +177,46 @@ async function verifyJiraCredentials(config) {
176
177
  }
177
178
  }
178
179
 
179
- async function verifyForgeCredentials(config) {
180
+ async function verifyForgeCredentials(config, configRoot) {
180
181
  const forge = createForgeRegistry(config);
181
- const providers = forge.listConfiguredClients();
182
+ const provider = forge.getActiveClient();
182
183
 
183
- for (const provider of providers) {
184
- if (!shouldVerifyProviderAtStart(provider.id, config)) {
185
- process.stdout.write(`Skipping ${provider.label} credential preflight.\n`);
186
- continue;
187
- }
184
+ if (!provider) {
185
+ return;
186
+ }
187
+
188
+ const verificationTargets = await getForgeVerificationTargets(provider.id, config, configRoot);
189
+ if (verificationTargets.length === 0) {
190
+ process.stdout.write(`Skipping ${provider.label} credential preflight.\n`);
191
+ return;
192
+ }
188
193
 
189
- process.stdout.write(`Checking ${provider.label} credentials...\n`);
190
- try {
191
- await provider.client.fetchCurrentUser();
192
- } catch (error) {
193
- throw new Error(`${provider.label} credential check failed: ${formatError(error)}`);
194
+ process.stdout.write(`Checking ${provider.label} credentials...\n`);
195
+ try {
196
+ for (const target of verificationTargets) {
197
+ await provider.client.fetchCurrentUser(target);
194
198
  }
199
+ } catch (error) {
200
+ throw new Error(`${provider.label} credential check failed: ${formatError(error)}`);
195
201
  }
196
202
  }
197
203
 
198
- export function shouldVerifyProviderAtStart(providerId, config) {
204
+ export async function getForgeVerificationTargets(providerId, config, configRoot) {
199
205
  if (providerId === "gitlab") {
200
- return Boolean(String(config.GITLAB_BASE_URL || "").trim());
206
+ const configuredBaseUrl = String(config.GITLAB_BASE_URL || "").trim();
207
+ if (configuredBaseUrl) {
208
+ return [configuredBaseUrl];
209
+ }
210
+
211
+ const repos = await listKnownRepos(configRoot);
212
+ return [...new Set(
213
+ repos
214
+ .map((repo) => String(repo?.url || "").trim())
215
+ .filter((repoUrl) => repoUrl.includes("gitlab"))
216
+ )];
201
217
  }
202
218
 
203
- return true;
219
+ return [""];
204
220
  }
205
221
 
206
222
  function formatError(error) {
@@ -125,7 +125,7 @@ export async function runWorkerCommand({ projectRoot }) {
125
125
  const jira = createJiraClient(values);
126
126
  const forgeRegistry = createForgeRegistry(values);
127
127
  const jiraBotUser = await loadJiraBotUser(jira, values, logger);
128
- const forgeBotUsers = await loadForgeBotUsers(forgeRegistry, logger);
128
+ const forgeBotUsers = new Map();
129
129
  const state = {
130
130
  ...previousState,
131
131
  projectRoot,
@@ -239,12 +239,12 @@ export async function runWorkerCommand({ projectRoot }) {
239
239
  }
240
240
  githubFlag.value = true;
241
241
  await runPoll("GitHub", "GitHub", state, async () => {
242
- const repos = await listKnownRepos(projectRoot);
242
+ const repos = filterReposForActiveForge(await listKnownRepos(projectRoot), forgeRegistry);
243
243
  const processedIssues = [];
244
244
  let trackedIssueCount = 0;
245
245
  for (const repo of repos) {
246
246
  const provider = forgeRegistry.getClientForRepo(repo.url);
247
- const botUser = getForgeBotUserForRepo(forgeBotUsers, repo.url);
247
+ const botUser = await getForgeBotUserForRepo(forgeRegistry, forgeBotUsers, repo.url, logger);
248
248
  const localPath = await resolveRepoLocalPath(repo, runtimePaths.reposDir, logger, `${provider.label} repo`);
249
249
  if (localPath === null) {
250
250
  continue;
@@ -283,12 +283,12 @@ export async function runWorkerCommand({ projectRoot }) {
283
283
  prFlag.value = true;
284
284
  const prJira = createJiraClient(values);
285
285
  await runPoll("Pr", "Pull request", state, async () => {
286
- const repos = await listKnownRepos(projectRoot);
286
+ const repos = filterReposForActiveForge(await listKnownRepos(projectRoot), forgeRegistry);
287
287
  const processedPrs = [];
288
288
  let trackedPrCount = 0;
289
289
  for (const repo of repos) {
290
290
  const provider = forgeRegistry.getClientForRepo(repo.url);
291
- const botUser = getForgeBotUserForRepo(forgeBotUsers, repo.url);
291
+ const botUser = await getForgeBotUserForRepo(forgeRegistry, forgeBotUsers, repo.url, logger);
292
292
  const localPath = await resolveRepoLocalPath(repo, runtimePaths.reposDir, logger, "draft PR repo");
293
293
  if (localPath === null) {
294
294
  continue;
@@ -330,7 +330,10 @@ export async function runWorkerCommand({ projectRoot }) {
330
330
  reviewFlag.value = true;
331
331
  await runPoll("Review", "PR review", state, async () => {
332
332
  const processedPrs = [];
333
- const prs = await forgeRegistry.listPrsNeedingReview();
333
+ const repos = filterReposForActiveForge(await listKnownRepos(projectRoot), forgeRegistry);
334
+ const prs = await forgeRegistry.listPrsNeedingReview({
335
+ repoUrls: repos.map((repo) => repo.url)
336
+ });
334
337
  let reviewedPrCount = prs.length;
335
338
  for (const pr of prs) {
336
339
  if (!pr.repoUrl) {
@@ -451,6 +454,7 @@ async function processJiraIssue({ issue, jira, forgeRegistry, botUser, config, p
451
454
  const epicMemoryRecord = await loadEpicMemory(projectRoot, config.JIRA_BASE_URL, detail);
452
455
  let epicMemory = epicMemoryRecord.data;
453
456
  let issueMemory = issueMemoryRecord.data;
457
+ epicMemory.repos = filterReposForActiveForge(epicMemory.repos, forgeRegistry);
454
458
 
455
459
  try {
456
460
  issueMemory.last_error = "";
@@ -477,6 +481,7 @@ async function processJiraIssue({ issue, jira, forgeRegistry, botUser, config, p
477
481
 
478
482
  if (repoUrlsFromComments.length > 0) {
479
483
  epicMemory.repos = mergeRepoUrls(epicMemory.repos, repoUrlsFromComments);
484
+ epicMemory.repos = filterReposForActiveForge(epicMemory.repos, forgeRegistry);
480
485
  if (
481
486
  issueMemory.workflow_state === "awaiting_repo_request" ||
482
487
  issueMemory.workflow_state === "awaiting_repo_response" ||
@@ -1630,30 +1635,27 @@ async function loadJiraBotUser(jira, config, logger) {
1630
1635
  }
1631
1636
  }
1632
1637
 
1633
- async function loadForgeBotUsers(forgeRegistry, logger) {
1634
- const users = new Map();
1635
-
1636
- for (const provider of forgeRegistry.listConfiguredClients()) {
1637
- try {
1638
- users.set(provider.id, await provider.client.fetchCurrentUser());
1639
- } catch (error) {
1640
- await logger.error(`${provider.label} bot identity lookup failed`, { error });
1641
- users.set(provider.id, {
1642
- login: null,
1643
- id: null
1644
- });
1645
- }
1638
+ async function getForgeBotUserForRepo(forgeRegistry, forgeBotUsers, repoUrl, logger) {
1639
+ const repo = parseRepoUrl(repoUrl);
1640
+ const cacheKey = `${repo.provider}:${repo.host}`;
1641
+ if (forgeBotUsers.has(cacheKey)) {
1642
+ return forgeBotUsers.get(cacheKey);
1646
1643
  }
1647
1644
 
1648
- return users;
1649
- }
1650
-
1651
- function getForgeBotUserForRepo(forgeBotUsers, repoUrl) {
1652
- const repo = parseRepoUrl(repoUrl);
1653
- return forgeBotUsers.get(repo.provider) || {
1654
- login: null,
1655
- id: null
1656
- };
1645
+ const provider = forgeRegistry.getClientForRepo(repoUrl);
1646
+ try {
1647
+ const user = await provider.fetchCurrentUser(repoUrl);
1648
+ forgeBotUsers.set(cacheKey, user);
1649
+ return user;
1650
+ } catch (error) {
1651
+ await logger.error(`${provider.label} bot identity lookup failed`, { repo: repoUrl, error });
1652
+ const fallback = {
1653
+ login: null,
1654
+ id: null
1655
+ };
1656
+ forgeBotUsers.set(cacheKey, fallback);
1657
+ return fallback;
1658
+ }
1657
1659
  }
1658
1660
 
1659
1661
  function isToDoStatus(status) {
@@ -2234,6 +2236,21 @@ function mergeRepoUrls(existingRepos, repoUrls) {
2234
2236
  return merged;
2235
2237
  }
2236
2238
 
2239
+ function filterReposForActiveForge(repos, forgeRegistry) {
2240
+ const activeProviderId = forgeRegistry.getActiveProviderId();
2241
+ if (!activeProviderId) {
2242
+ return [];
2243
+ }
2244
+
2245
+ return (Array.isArray(repos) ? repos : []).filter((repo) => {
2246
+ try {
2247
+ return parseRepoUrl(repo?.url || "").provider === activeProviderId;
2248
+ } catch {
2249
+ return false;
2250
+ }
2251
+ });
2252
+ }
2253
+
2237
2254
  function dedupeGitHubIssues(githubIssues) {
2238
2255
  const seen = new Set();
2239
2256
  const deduped = [];
package/src/config.js CHANGED
@@ -34,6 +34,12 @@ export const REQUIRED_FIELDS = [
34
34
  secret: true,
35
35
  required: true
36
36
  },
37
+ {
38
+ key: "FORGE_PROVIDER",
39
+ prompt: "Forge provider (gitlab or github)",
40
+ example: "gitlab",
41
+ required: false
42
+ },
37
43
  {
38
44
  key: "GITHUB_PAT",
39
45
  prompt: "GitHub personal access token",
@@ -94,7 +100,16 @@ export function getMissingFields(values) {
94
100
  .filter((field) => field.required !== false && !values[field.key])
95
101
  .map((field) => field.key);
96
102
 
97
- if (!hasConfiguredForgeToken(values)) {
103
+ const provider = normalizeForgeProvider(values.FORGE_PROVIDER);
104
+ if (provider === "github") {
105
+ if (!String(values.GITHUB_PAT || "").trim()) {
106
+ missing.push("GITHUB_PAT");
107
+ }
108
+ } else if (provider === "gitlab") {
109
+ if (!String(values.GITLAB_TOKEN || "").trim()) {
110
+ missing.push("GITLAB_TOKEN");
111
+ }
112
+ } else if (!hasConfiguredForgeToken(values)) {
98
113
  missing.push("GITHUB_PAT or GITLAB_TOKEN");
99
114
  }
100
115
 
@@ -105,6 +120,11 @@ export function hasConfiguredForgeToken(values) {
105
120
  return Boolean(String(values.GITHUB_PAT || "").trim() || String(values.GITLAB_TOKEN || "").trim());
106
121
  }
107
122
 
123
+ export function normalizeForgeProvider(value) {
124
+ const normalized = String(value || "").trim().toLowerCase();
125
+ return normalized === "gitlab" || normalized === "github" ? normalized : "";
126
+ }
127
+
108
128
  export function maskSecret(value) {
109
129
  if (value.length <= 4) {
110
130
  return "*".repeat(value.length);
@@ -7,8 +7,10 @@ export function createGitLabClient(config) {
7
7
  id: "gitlab",
8
8
  label: "GitLab",
9
9
 
10
- async fetchCurrentUser() {
11
- const payload = await requestGitLab(config, "/api/v4/user");
10
+ async fetchCurrentUser(repoUrl = "") {
11
+ const payload = await requestGitLab(config, "/api/v4/user", {
12
+ baseUrl: resolveGitLabBaseUrl(config, repoUrl)
13
+ });
12
14
  return {
13
15
  login: payload.username ?? null,
14
16
  id: payload.id ?? null
@@ -17,7 +19,7 @@ export function createGitLabClient(config) {
17
19
 
18
20
  async findIssueByJiraKey(repoUrl, jiraKey) {
19
21
  const repo = parseGitLabRepoUrl(repoUrl);
20
- const payload = await requestGitLab(config, projectPath(repo, "/issues"), {
22
+ const payload = await requestGitLabProject(config, repo, "/issues", {
21
23
  searchParams: {
22
24
  state: "all",
23
25
  search: jiraKey,
@@ -38,7 +40,7 @@ export function createGitLabClient(config) {
38
40
 
39
41
  async createIssue(repoUrl, issue) {
40
42
  const repo = parseGitLabRepoUrl(repoUrl);
41
- const payload = await requestGitLab(config, projectPath(repo, "/issues"), {
43
+ const payload = await requestGitLabProject(config, repo, "/issues", {
42
44
  method: "POST",
43
45
  body: {
44
46
  title: issue.title,
@@ -53,8 +55,8 @@ export function createGitLabClient(config) {
53
55
 
54
56
  async fetchIssue(repoUrl, issueNumber) {
55
57
  const repo = parseGitLabRepoUrl(repoUrl);
56
- const payload = await requestGitLab(config, projectPath(repo, `/issues/${issueNumber}`));
57
- const comments = await requestGitLab(config, projectPath(repo, `/issues/${issueNumber}/notes`), {
58
+ const payload = await requestGitLabProject(config, repo, `/issues/${issueNumber}`);
59
+ const comments = await requestGitLabProject(config, repo, `/issues/${issueNumber}/notes`, {
58
60
  searchParams: {
59
61
  sort: "asc",
60
62
  per_page: "100"
@@ -66,7 +68,7 @@ export function createGitLabClient(config) {
66
68
 
67
69
  async listIssues(repoUrl) {
68
70
  const repo = parseGitLabRepoUrl(repoUrl);
69
- const payload = await requestGitLab(config, projectPath(repo, "/issues"), {
71
+ const payload = await requestGitLabProject(config, repo, "/issues", {
70
72
  searchParams: {
71
73
  state: "opened",
72
74
  per_page: "100"
@@ -87,7 +89,7 @@ export function createGitLabClient(config) {
87
89
 
88
90
  async listPullRequests(repoUrl) {
89
91
  const repo = parseGitLabRepoUrl(repoUrl);
90
- const payload = await requestGitLab(config, projectPath(repo, "/merge_requests"), {
92
+ const payload = await requestGitLabProject(config, repo, "/merge_requests", {
91
93
  searchParams: {
92
94
  state: "opened",
93
95
  per_page: "100"
@@ -100,8 +102,8 @@ export function createGitLabClient(config) {
100
102
 
101
103
  async fetchPullRequest(repoUrl, pullNumber) {
102
104
  const repo = parseGitLabRepoUrl(repoUrl);
103
- const payload = await requestGitLab(config, projectPath(repo, `/merge_requests/${pullNumber}`));
104
- const comments = await requestGitLab(config, projectPath(repo, `/merge_requests/${pullNumber}/notes`), {
105
+ const payload = await requestGitLabProject(config, repo, `/merge_requests/${pullNumber}`);
106
+ const comments = await requestGitLabProject(config, repo, `/merge_requests/${pullNumber}/notes`, {
105
107
  searchParams: {
106
108
  sort: "asc",
107
109
  per_page: "100"
@@ -110,7 +112,9 @@ export function createGitLabClient(config) {
110
112
  let headRepoCloneUrl = null;
111
113
  if (payload.source_project_id) {
112
114
  try {
113
- const sourceProject = await requestGitLab(config, `/api/v4/projects/${payload.source_project_id}`);
115
+ const sourceProject = await requestGitLab(config, `/api/v4/projects/${payload.source_project_id}`, {
116
+ baseUrl: `${repo.origin}/`
117
+ });
114
118
  headRepoCloneUrl = sourceProject.http_url_to_repo || null;
115
119
  } catch {
116
120
  headRepoCloneUrl = null;
@@ -122,7 +126,7 @@ export function createGitLabClient(config) {
122
126
 
123
127
  async updateIssue(repoUrl, issueNumber, updates) {
124
128
  const repo = parseGitLabRepoUrl(repoUrl);
125
- const payload = await requestGitLab(config, projectPath(repo, `/issues/${issueNumber}`), {
129
+ const payload = await requestGitLabProject(config, repo, `/issues/${issueNumber}`, {
126
130
  method: "PUT",
127
131
  body: {
128
132
  title: updates.title,
@@ -134,7 +138,7 @@ export function createGitLabClient(config) {
134
138
 
135
139
  async postIssueComment(repoUrl, issueNumber, body) {
136
140
  const repo = parseGitLabRepoUrl(repoUrl);
137
- const payload = await requestGitLab(config, projectPath(repo, issueNotePath(issueNumber)), {
141
+ const payload = await requestGitLabProject(config, repo, issueNotePath(issueNumber), {
138
142
  method: "POST",
139
143
  body: { body }
140
144
  });
@@ -143,7 +147,7 @@ export function createGitLabClient(config) {
143
147
 
144
148
  async createIssueReaction(repoUrl, issueNumber, content = "eyes") {
145
149
  const repo = parseGitLabRepoUrl(repoUrl);
146
- return requestGitLab(config, projectPath(repo, `${issueNotePath(issueNumber)}/award_emoji`), {
150
+ return requestGitLabProject(config, repo, `${issueNotePath(issueNumber)}/award_emoji`, {
147
151
  method: "POST",
148
152
  body: {
149
153
  name: mapReaction(content)
@@ -163,6 +167,7 @@ export function createGitLabClient(config) {
163
167
  for (const candidate of candidates) {
164
168
  try {
165
169
  return await requestGitLab(config, candidate, {
170
+ baseUrl: `${repo.origin}/`,
166
171
  method: "POST",
167
172
  body
168
173
  });
@@ -177,6 +182,7 @@ export function createGitLabClient(config) {
177
182
  async isRepoCollaborator(repoUrl, username) {
178
183
  const repo = parseGitLabRepoUrl(repoUrl);
179
184
  const users = await requestGitLab(config, "/api/v4/users", {
185
+ baseUrl: resolveGitLabBaseUrl(config, repoUrl),
180
186
  searchParams: {
181
187
  username,
182
188
  per_page: "1"
@@ -191,7 +197,7 @@ export function createGitLabClient(config) {
191
197
  }
192
198
 
193
199
  try {
194
- await requestGitLab(config, projectPath(repo, `/members/all/${user.id}`));
200
+ await requestGitLabProject(config, repo, `/members/all/${user.id}`);
195
201
  return true;
196
202
  } catch (error) {
197
203
  if (String(error.message || "").includes("returned 404")) {
@@ -210,7 +216,7 @@ export function createGitLabClient(config) {
210
216
  if (updates.state === "closed") {
211
217
  body.state_event = "close";
212
218
  }
213
- const payload = await requestGitLab(config, projectPath(repo, `/merge_requests/${pullNumber}`), {
219
+ const payload = await requestGitLabProject(config, repo, `/merge_requests/${pullNumber}`, {
214
220
  method: "PUT",
215
221
  body
216
222
  });
@@ -219,9 +225,9 @@ export function createGitLabClient(config) {
219
225
 
220
226
  async markPullRequestReady(repoUrl, pullNumber) {
221
227
  const repo = parseGitLabRepoUrl(repoUrl);
222
- const current = await requestGitLab(config, projectPath(repo, `/merge_requests/${pullNumber}`));
228
+ const current = await requestGitLabProject(config, repo, `/merge_requests/${pullNumber}`);
223
229
  const title = stripDraftPrefix(current.title ?? "");
224
- const payload = await requestGitLab(config, projectPath(repo, `/merge_requests/${pullNumber}`), {
230
+ const payload = await requestGitLabProject(config, repo, `/merge_requests/${pullNumber}`, {
225
231
  method: "PUT",
226
232
  body: { title }
227
233
  });
@@ -230,7 +236,7 @@ export function createGitLabClient(config) {
230
236
 
231
237
  async findPullRequestByHead(repoUrl, headBranch) {
232
238
  const repo = parseGitLabRepoUrl(repoUrl);
233
- const payload = await requestGitLab(config, projectPath(repo, "/merge_requests"), {
239
+ const payload = await requestGitLabProject(config, repo, "/merge_requests", {
234
240
  searchParams: {
235
241
  state: "all",
236
242
  source_branch: headBranch,
@@ -248,38 +254,58 @@ export function createGitLabClient(config) {
248
254
 
249
255
  async getDefaultBranch(repoUrl) {
250
256
  const repo = parseGitLabRepoUrl(repoUrl);
251
- const payload = await requestGitLab(config, projectPath(repo));
257
+ const payload = await requestGitLabProject(config, repo);
252
258
  return payload.default_branch;
253
259
  },
254
260
 
255
- async listPrsNeedingReview() {
256
- const currentUser = await this.fetchCurrentUser();
257
- if (!currentUser.login) {
261
+ async listPrsNeedingReview(options = {}) {
262
+ const repoUrls = [...new Set((options.repoUrls || []).map((value) => String(value || "").trim()).filter(Boolean))];
263
+ if (repoUrls.length === 0 && !String(config.GITLAB_BASE_URL || "").trim()) {
258
264
  return [];
259
265
  }
260
- const payload = await requestGitLab(config, "/api/v4/merge_requests", {
261
- searchParams: {
262
- state: "opened",
263
- reviewer_username: currentUser.login,
264
- scope: "all",
265
- per_page: "100"
266
+ const baseUrls = repoUrls.length > 0
267
+ ? [...new Set(repoUrls.map((repoUrl) => resolveGitLabBaseUrl(config, repoUrl)))]
268
+ : [resolveGitLabBaseUrl(config)];
269
+
270
+ const results = [];
271
+
272
+ for (const baseUrl of baseUrls) {
273
+ if (!baseUrl) {
274
+ continue;
266
275
  }
267
- });
268
- return Array.isArray(payload)
269
- ? payload
270
- .filter((mergeRequest) => !Array.isArray(mergeRequest.labels) || !mergeRequest.labels.includes("AI-reviewed"))
271
- .map((mergeRequest) => ({
272
- repoUrl: String(mergeRequest.web_url || "").replace(/\/-\/merge_requests\/\d+$/u, ""),
273
- number: mergeRequest.iid,
274
- title: mergeRequest.title ?? ""
275
- }))
276
- .filter((mergeRequest) => Boolean(mergeRequest.repoUrl))
277
- : [];
276
+
277
+ const currentUser = await this.fetchCurrentUser(baseUrl);
278
+ if (!currentUser.login) {
279
+ continue;
280
+ }
281
+ const payload = await requestGitLab(config, "/api/v4/merge_requests", {
282
+ baseUrl,
283
+ searchParams: {
284
+ state: "opened",
285
+ reviewer_username: currentUser.login,
286
+ scope: "all",
287
+ per_page: "100"
288
+ }
289
+ });
290
+ if (!Array.isArray(payload)) {
291
+ continue;
292
+ }
293
+ results.push(...payload
294
+ .filter((mergeRequest) => !Array.isArray(mergeRequest.labels) || !mergeRequest.labels.includes("AI-reviewed"))
295
+ .map((mergeRequest) => ({
296
+ repoUrl: String(mergeRequest.web_url || "").replace(/\/-\/merge_requests\/\d+$/u, ""),
297
+ number: mergeRequest.iid,
298
+ title: mergeRequest.title ?? ""
299
+ }))
300
+ .filter((mergeRequest) => Boolean(mergeRequest.repoUrl)));
301
+ }
302
+
303
+ return results;
278
304
  },
279
305
 
280
306
  async addLabelsToPullRequest(repoUrl, prNumber, labels) {
281
307
  const repo = parseGitLabRepoUrl(repoUrl);
282
- return requestGitLab(config, projectPath(repo, `/merge_requests/${prNumber}`), {
308
+ return requestGitLabProject(config, repo, `/merge_requests/${prNumber}`, {
283
309
  method: "PUT",
284
310
  body: {
285
311
  add_labels: labels.join(",")
@@ -290,7 +316,7 @@ export function createGitLabClient(config) {
290
316
  async createPullRequestReview(repoUrl, prNumber, body, suggestions) {
291
317
  const repo = parseGitLabRepoUrl(repoUrl);
292
318
  const reviewBody = buildGitLabReviewBody(body, suggestions);
293
- return requestGitLab(config, projectPath(repo, `/merge_requests/${prNumber}/notes`), {
319
+ return requestGitLabProject(config, repo, `/merge_requests/${prNumber}/notes`, {
294
320
  method: "POST",
295
321
  body: {
296
322
  body: reviewBody
@@ -300,13 +326,13 @@ export function createGitLabClient(config) {
300
326
 
301
327
  async fetchPullRequestDiff(repoUrl, prNumber) {
302
328
  const repo = parseGitLabRepoUrl(repoUrl);
303
- const payload = await requestGitLab(config, projectPath(repo, `/merge_requests/${prNumber}/changes`));
329
+ const payload = await requestGitLabProject(config, repo, `/merge_requests/${prNumber}/changes`);
304
330
  return buildGitLabDiff(payload.changes || []);
305
331
  },
306
332
 
307
333
  async createPullRequest(repoUrl, pullRequest) {
308
334
  const repo = parseGitLabRepoUrl(repoUrl);
309
- const payload = await requestGitLab(config, projectPath(repo, "/merge_requests"), {
335
+ const payload = await requestGitLabProject(config, repo, "/merge_requests", {
310
336
  method: "POST",
311
337
  body: {
312
338
  title: pullRequest.draft === true ? ensureDraftTitle(pullRequest.title) : pullRequest.title,
@@ -339,6 +365,13 @@ function issueNotePath(issueNumber) {
339
365
  return `/issues/${issueNumber}/notes`;
340
366
  }
341
367
 
368
+ function requestGitLabProject(config, repo, suffix = "", init = {}) {
369
+ return requestGitLab(config, projectPath(repo, suffix), {
370
+ ...init,
371
+ baseUrl: `${repo.origin}/`
372
+ });
373
+ }
374
+
342
375
  function buildGitLabReviewBody(body, suggestions = []) {
343
376
  const base = [String(body || "").trim()];
344
377
  if (Array.isArray(suggestions) && suggestions.length > 0) {
@@ -462,7 +495,7 @@ function mapReaction(content) {
462
495
  async function requestGitLab(config, pathOrUrl, init = {}) {
463
496
  const url = pathOrUrl.startsWith("http")
464
497
  ? new URL(pathOrUrl)
465
- : new URL(pathOrUrl, determineGitLabBaseUrl(config));
498
+ : new URL(pathOrUrl, init.baseUrl || determineGitLabBaseUrl(config));
466
499
 
467
500
  if (init.searchParams) {
468
501
  for (const [key, value] of Object.entries(init.searchParams)) {
@@ -503,6 +536,30 @@ function determineGitLabBaseUrl(config) {
503
536
  return "https://gitlab.com/";
504
537
  }
505
538
 
539
+ function resolveGitLabBaseUrl(config, repoUrl = "") {
540
+ const configured = String(config.GITLAB_BASE_URL || "").trim();
541
+ if (configured) {
542
+ return configured.endsWith("/") ? configured : `${configured}/`;
543
+ }
544
+
545
+ const repoValue = String(repoUrl || "").trim();
546
+ if (!repoValue) {
547
+ return "";
548
+ }
549
+
550
+ try {
551
+ const url = new URL(repoValue);
552
+ if (url.pathname === "/" || url.pathname === "") {
553
+ return `${url.origin}/`;
554
+ }
555
+ } catch {
556
+ // Fall through to repo URL parsing below.
557
+ }
558
+
559
+ const repo = parseGitLabRepoUrl(repoValue);
560
+ return `${repo.origin}/`;
561
+ }
562
+
506
563
  function buildGitLabRequestError(status, url, body) {
507
564
  const detail = String(body || "").slice(0, 300);
508
565
  return `GitLab request ${url.pathname}${url.search} failed with ${status}: ${detail}`;
@@ -1,47 +1,76 @@
1
1
  import { createGitHubClient } from "./github.js";
2
2
  import { createGitLabClient } from "./gitlab.js";
3
3
  import { getProviderLabel, parseRepoUrl } from "./repo-host.js";
4
+ import { normalizeForgeProvider } from "../config.js";
4
5
 
5
6
  export function createForgeRegistry(config) {
6
- const clients = new Map();
7
-
8
- if (String(config.GITHUB_PAT || "").trim()) {
9
- clients.set("github", createGitHubClient(config));
10
- }
11
-
12
- if (String(config.GITLAB_TOKEN || "").trim()) {
13
- clients.set("gitlab", createGitLabClient(config));
14
- }
7
+ const providerId = getActiveForgeProvider(config);
8
+ const client = createClient(providerId, config);
15
9
 
16
10
  return {
17
11
  hasClients() {
18
- return clients.size > 0;
12
+ return Boolean(client);
19
13
  },
20
14
 
21
- listConfiguredClients() {
22
- return [...clients.entries()].map(([id, client]) => ({
23
- id,
24
- label: client.label || getProviderLabel(id),
25
- client
26
- }));
15
+ getActiveProviderId() {
16
+ return providerId;
17
+ },
18
+
19
+ getActiveClient() {
20
+ return client
21
+ ? {
22
+ id: providerId,
23
+ label: client.label || getProviderLabel(providerId),
24
+ client
25
+ }
26
+ : null;
27
27
  },
28
28
 
29
29
  getClientForRepo(repoUrl) {
30
- const repo = parseRepoUrl(repoUrl);
31
- const client = clients.get(repo.provider);
32
- if (!client) {
30
+ if (!client || !providerId) {
31
+ const repo = parseRepoUrl(repoUrl);
33
32
  throw new Error(`${getProviderLabel(repo.provider)} token is not configured for ${repo.host}.`);
34
33
  }
34
+
35
+ const repo = parseRepoUrl(repoUrl);
36
+ if (repo.provider !== providerId) {
37
+ throw new Error(`Active forge is ${getProviderLabel(providerId)}, but repo uses ${getProviderLabel(repo.provider)}.`);
38
+ }
35
39
  return client;
36
40
  },
37
41
 
38
- async listPrsNeedingReview() {
39
- const results = [];
40
- for (const client of clients.values()) {
41
- const items = await client.listPrsNeedingReview();
42
- results.push(...items);
42
+ async listPrsNeedingReview(options = {}) {
43
+ if (!client) {
44
+ return [];
43
45
  }
44
- return results;
46
+ return client.listPrsNeedingReview(options);
45
47
  }
46
48
  };
47
49
  }
50
+
51
+ export function getActiveForgeProvider(config) {
52
+ const explicit = normalizeForgeProvider(config.FORGE_PROVIDER);
53
+ if (explicit === "gitlab" && String(config.GITLAB_TOKEN || "").trim()) {
54
+ return "gitlab";
55
+ }
56
+ if (explicit === "github" && String(config.GITHUB_PAT || "").trim()) {
57
+ return "github";
58
+ }
59
+ if (String(config.GITLAB_TOKEN || "").trim()) {
60
+ return "gitlab";
61
+ }
62
+ if (String(config.GITHUB_PAT || "").trim()) {
63
+ return "github";
64
+ }
65
+ return "";
66
+ }
67
+
68
+ function createClient(providerId, config) {
69
+ if (providerId === "gitlab") {
70
+ return createGitLabClient(config);
71
+ }
72
+ if (providerId === "github") {
73
+ return createGitHubClient(config);
74
+ }
75
+ return null;
76
+ }