@open-cr-agent/vcs-gitlab 0.5.0 → 0.6.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.
package/dist/adapter.d.ts CHANGED
@@ -1,15 +1,19 @@
1
1
  import { type FileDiff } from "@open-cr-agent/core";
2
- import { type CodeSource, type History, PlatformReview } from "@open-cr-agent/vcs-platform";
2
+ import { type CodeSource, type History, type PlatformChangeRequest, PlatformReview } from "@open-cr-agent/vcs-platform";
3
3
  import { type GitLabApi, type MergeRequest, type Position } from "./client.js";
4
4
  export interface GitLabAdapterOptions {
5
5
  iid: number;
6
6
  api: GitLabApi;
7
7
  code: CodeSource;
8
8
  history?: History;
9
- snapshot?: MergeRequest;
9
+ snapshot?: MergeRequestSnapshot;
10
+ }
11
+ export interface MergeRequestSnapshot extends PlatformChangeRequest {
12
+ mergeBaseSha: string;
10
13
  }
11
14
  export declare class GitLabAdapter extends PlatformReview {
12
15
  constructor(options: GitLabAdapterOptions);
13
16
  }
17
+ export declare function mergeRequestOf(mr: MergeRequest): MergeRequestSnapshot;
14
18
  export declare function positionOf(file: string, line: number, diffs: readonly FileDiff[], refs: NonNullable<MergeRequest["diff_refs"]>): Position | undefined;
15
19
  //# sourceMappingURL=adapter.d.ts.map
package/dist/adapter.js CHANGED
@@ -1,6 +1,5 @@
1
- import { OcraError } from "@open-cr-agent/core";
2
- import { errorMessage } from "@open-cr-agent/core/internal";
3
- import { PlatformReview, } from "@open-cr-agent/vcs-platform";
1
+ import { errorMessage, OcraError } from "@open-cr-agent/core";
2
+ import { gitlabSuggestion, PlatformReview, } from "@open-cr-agent/vcs-platform";
4
3
  import { DEVELOPER, GitLabApiError, } from "./client.js";
5
4
  // A GitLab merge request as ocra's review conversation (vcs-platform).
6
5
  export class GitLabAdapter extends PlatformReview {
@@ -18,13 +17,14 @@ export class GitLabAdapter extends PlatformReview {
18
17
  // it rather than trust it. Parentheses cannot occur in a GitLab username.
19
18
  const UNKNOWN_EDITOR = "(unknown)";
20
19
  class GitLabPlatform {
21
- options;
22
20
  text = { changeRequest: "merge request", authority: "the Developer role or higher" };
21
+ suggestionFence = gitlabSuggestion;
23
22
  mergeRequest;
24
23
  user;
25
24
  editors;
26
25
  // User ids by username, from the notes read, for membership lookups.
27
26
  ids = new Map();
27
+ options;
28
28
  constructor(options) {
29
29
  this.options = options;
30
30
  }
@@ -35,16 +35,8 @@ class GitLabPlatform {
35
35
  return { login: username, is: (other) => other === username };
36
36
  }
37
37
  async changeRequest() {
38
- const mr = await this.mr();
39
- const refs = diffRefs(mr);
40
- return {
41
- id: `${projectPath(mr.web_url)}!${mr.iid}`,
42
- title: mr.title,
43
- description: mr.description ?? "",
44
- baseSha: refs.start_sha,
45
- headSha: refs.head_sha,
46
- ...(mr.author ? { author: mr.author.username } : {}),
47
- };
38
+ const { mergeBaseSha: _, ...changeRequest } = await this.mr();
39
+ return changeRequest;
48
40
  }
49
41
  // Comments on the merge request as a whole: plain notes and the notes of
50
42
  // threads started on it, not notes on lines or system notes.
@@ -110,7 +102,8 @@ class GitLabPlatform {
110
102
  async publishFindings(_report, fresh) {
111
103
  if (fresh.length === 0)
112
104
  return { posted: [], warnings: [] };
113
- const refs = diffRefs(await this.mr());
105
+ const mr = await this.mr();
106
+ const refs = { base_sha: mr.mergeBaseSha, start_sha: mr.baseSha, head_sha: mr.headSha };
114
107
  const diffs = await this.options.code.getDiff();
115
108
  const posted = [];
116
109
  const warnings = [];
@@ -151,15 +144,24 @@ class GitLabPlatform {
151
144
  mr() {
152
145
  this.mergeRequest ??= this.options.snapshot
153
146
  ? Promise.resolve(this.options.snapshot)
154
- : this.options.api.getMergeRequest(this.options.iid);
147
+ : this.options.api.getMergeRequest(this.options.iid).then(mergeRequestOf);
155
148
  return this.mergeRequest;
156
149
  }
157
150
  }
158
- function diffRefs(mr) {
159
- if (!mr.diff_refs) {
151
+ export function mergeRequestOf(mr) {
152
+ const refs = mr.diff_refs;
153
+ if (!refs) {
160
154
  throw new OcraError("VCS_NOT_READY", `merge request !${mr.iid} has no diff yet; try again shortly`);
161
155
  }
162
- return mr.diff_refs;
156
+ return {
157
+ id: `${projectPath(mr.web_url)}!${mr.iid}`,
158
+ title: mr.title,
159
+ description: mr.description ?? "",
160
+ baseSha: refs.start_sha,
161
+ headSha: refs.head_sha,
162
+ mergeBaseSha: refs.base_sha,
163
+ ...(mr.author ? { author: mr.author.username } : {}),
164
+ };
163
165
  }
164
166
  // "https://gitlab.example.com/group/sub/project/-/merge_requests/7" names
165
167
  // the project "group/sub/project".
package/dist/client.d.ts CHANGED
@@ -78,10 +78,9 @@ export interface GitLabApiOptions {
78
78
  }
79
79
  export declare const DEVELOPER = 30;
80
80
  export declare class GitLabApi {
81
- private readonly project;
82
- private readonly options;
83
81
  private readonly baseUrl;
84
- private readonly fetchImpl;
82
+ private readonly api;
83
+ private readonly project;
85
84
  constructor(project: string | number, options: GitLabApiOptions);
86
85
  currentUser(): Promise<{
87
86
  id: number;
@@ -102,7 +101,6 @@ export declare class GitLabApi {
102
101
  private pages;
103
102
  private graphql;
104
103
  private request;
105
- private send;
106
104
  }
107
105
  export {};
108
106
  //# sourceMappingURL=client.d.ts.map
package/dist/client.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { OcraError } from "@open-cr-agent/core";
2
- import { retryDecision } from "@open-cr-agent/vcs-platform";
2
+ import { pageInfoSchema, sendWithRetry, } from "@open-cr-agent/vcs-platform/internal";
3
3
  import { z } from "zod";
4
4
  // Commit ids reach git as arguments; anything else is refused at the boundary.
5
5
  const sha = z.string().regex(/^[0-9a-f]{40}([0-9a-f]{24})?$/, "not a commit id");
@@ -43,6 +43,7 @@ const PER_PAGE = 100;
43
43
  const MAX_NOTE_PAGES = 30;
44
44
  const MAX_DISCUSSION_PAGES = 10;
45
45
  const MAX_EDITOR_PAGES = 30;
46
+ const REQUEST_TIMEOUT_MS = 30_000;
46
47
  // Developer: may push to unprotected branches, the nearest to GitHub's write.
47
48
  export const DEVELOPER = 30;
48
49
  const EDITORS_QUERY = `query($path: ID!, $iid: String!, $after: String) {
@@ -59,7 +60,7 @@ const editorsSchema = z.object({
59
60
  project: z.object({
60
61
  mergeRequest: z.object({
61
62
  notes: z.object({
62
- pageInfo: z.object({ hasNextPage: z.boolean(), endCursor: z.string().nullable() }),
63
+ pageInfo: pageInfoSchema,
63
64
  nodes: z.array(z.object({
64
65
  id: z.string(),
65
66
  lastEditedBy: z.object({ username: z.string() }).nullable().optional(),
@@ -71,19 +72,24 @@ const editorsSchema = z.object({
71
72
  // A thin REST and GraphQL client for one project: every response is
72
73
  // validated before it reaches the adapter, and errors never echo the token.
73
74
  export class GitLabApi {
74
- project;
75
- options;
76
75
  baseUrl;
77
- fetchImpl;
76
+ api;
78
77
  // `project` is the numeric id or the full path ("group/project").
78
+ project;
79
79
  constructor(project, options) {
80
80
  this.project = project;
81
- this.options = options;
82
81
  this.baseUrl = (options.baseUrl ?? "https://gitlab.com/api/v4").replace(/\/+$/, "");
83
- this.fetchImpl = options.fetch ?? fetch;
82
+ this.api = {
83
+ platform: "GitLab",
84
+ headers: { Accept: "application/json", Authorization: `Bearer ${options.token}` },
85
+ fetch: options.fetch ?? fetch,
86
+ sleep: options.sleep,
87
+ timeoutMs: () => REQUEST_TIMEOUT_MS,
88
+ toError: (status, message) => new GitLabApiError(status, message),
89
+ };
84
90
  }
85
91
  async currentUser() {
86
- return user.parse(await this.send("GET", `${this.baseUrl}/user`, "/user"));
92
+ return user.parse(await sendWithRetry(this.api, { method: "GET", url: `${this.baseUrl}/user`, path: "/user" }));
87
93
  }
88
94
  // The project's full path, which GraphQL takes instead of the id.
89
95
  async fullPath() {
@@ -135,9 +141,11 @@ export class GitLabApi {
135
141
  }
136
142
  }
137
143
  async findUser(username) {
138
- const found = z
139
- .array(user)
140
- .parse(await this.send("GET", `${this.baseUrl}/users?username=${encodeURIComponent(username)}`, "/users"));
144
+ const found = z.array(user).parse(await sendWithRetry(this.api, {
145
+ method: "GET",
146
+ url: `${this.baseUrl}/users?username=${encodeURIComponent(username)}`,
147
+ path: "/users",
148
+ }));
141
149
  return found.find((u) => u.username === username)?.id;
142
150
  }
143
151
  // Who last edited each note of the merge request, by note id; REST does
@@ -182,7 +190,14 @@ export class GitLabApi {
182
190
  data: z.unknown().optional(),
183
191
  errors: z.array(z.object({ message: z.string() })).optional(),
184
192
  })
185
- .parse(await this.send("POST", url, "/graphql", { query, variables }, true));
193
+ .parse(await sendWithRetry(this.api, {
194
+ method: "POST",
195
+ url,
196
+ path: "/graphql",
197
+ body: { query, variables },
198
+ // GraphQL queries are safe to repeat, although they are POSTs.
199
+ idempotent: true,
200
+ }));
186
201
  if (result.errors?.length) {
187
202
  throw new GitLabApiError(200, `GitLab GraphQL failed: ${result.errors.map((e) => e.message).join("; ")}`);
188
203
  }
@@ -190,46 +205,8 @@ export class GitLabApi {
190
205
  }
191
206
  request(method, path, body) {
192
207
  const project = encodeURIComponent(String(this.project));
193
- return this.send(method, `${this.baseUrl}/projects/${project}${path}`, path, body);
194
- }
195
- // GraphQL queries are safe to repeat, although they are POSTs; REST POSTs
196
- // create notes and threads, and are not.
197
- async send(method, url, path, body, idempotent = method !== "POST") {
198
- const sleep = this.options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
199
- for (let attempt = 1;; attempt += 1) {
200
- const init = {
201
- method,
202
- headers: {
203
- Accept: "application/json",
204
- Authorization: `Bearer ${this.options.token}`,
205
- "User-Agent": "open-cr-agent",
206
- ...(body === undefined ? {} : { "Content-Type": "application/json" }),
207
- },
208
- signal: AbortSignal.timeout(30_000),
209
- };
210
- if (body !== undefined)
211
- init.body = JSON.stringify(body);
212
- let response;
213
- try {
214
- response = await this.fetchImpl(url, init);
215
- }
216
- catch (error) {
217
- const next = retryDecision(method, idempotent, "network_error", attempt);
218
- if (!next.retry)
219
- throw error;
220
- await sleep(next.waitMs);
221
- continue;
222
- }
223
- if (response.ok)
224
- return response.status === 204 ? undefined : response.json();
225
- const detail = (await response.text().catch(() => "")).slice(0, 500);
226
- const next = retryDecision(method, idempotent, { status: response.status, headers: response.headers, body: detail }, attempt);
227
- if (next.retry) {
228
- await sleep(next.waitMs);
229
- continue;
230
- }
231
- throw new GitLabApiError(response.status, `GitLab ${method} ${path.split("?")[0]} failed with ${response.status}: ${detail}`);
232
- }
208
+ const url = `${this.baseUrl}/projects/${project}${path}`;
209
+ return sendWithRetry(this.api, { method, url, path, body });
233
210
  }
234
211
  }
235
212
  //# sourceMappingURL=client.js.map
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export { GitLabApiError } from "./client.js";
2
2
  export { gitlabPlugin } from "./plugin.js";
3
+ export { type GitLabSettings, resolveGitLabTarget } from "./target.js";
3
4
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export { GitLabApiError } from "./client.js";
2
2
  export { gitlabPlugin } from "./plugin.js";
3
+ export { resolveGitLabTarget } from "./target.js";
3
4
  //# sourceMappingURL=index.js.map
package/dist/plugin.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { changeRequestSchema, codeSourceSchema, commitIdSchema, fetchSchema, historySchema, } from "@open-cr-agent/vcs-platform";
1
2
  import { z } from "zod";
2
3
  import { GitLabAdapter } from "./adapter.js";
3
4
  import { GitLabApi } from "./client.js";
@@ -7,18 +8,10 @@ const optionsSchema = z.object({
7
8
  iid: z.number().int().positive(),
8
9
  token: z.string().min(1),
9
10
  apiUrl: z.string().url().optional(),
10
- fetch: z.custom((value) => typeof value === "function").optional(),
11
- code: z.custom((value) => typeof value === "object" &&
12
- value !== null &&
13
- ["getDiff", "readFile", "searchCode"].every((m) => typeof value[m] === "function"), "code must provide getDiff, readFile and searchCode"),
14
- snapshot: z
15
- .custom((value) => typeof value === "object" && value !== null && "diff_refs" in value)
16
- .optional(),
17
- history: z
18
- .custom((value) => typeof value === "object" &&
19
- value !== null &&
20
- typeof value.filesChangedSince === "function", "history must provide filesChangedSince")
21
- .optional(),
11
+ fetch: fetchSchema.optional(),
12
+ code: codeSourceSchema,
13
+ snapshot: changeRequestSchema.extend({ mergeBaseSha: commitIdSchema }).optional(),
14
+ history: historySchema.optional(),
22
15
  });
23
16
  export const gitlabPlugin = {
24
17
  name: "vcs-gitlab",
@@ -0,0 +1,4 @@
1
+ import type { PlatformTarget, ResolveTargetOptions } from "@open-cr-agent/vcs-platform";
2
+ export type GitLabSettings = Record<string, never>;
3
+ export declare function resolveGitLabTarget(options: ResolveTargetOptions): Promise<PlatformTarget<GitLabSettings>>;
4
+ //# sourceMappingURL=target.d.ts.map
package/dist/target.js ADDED
@@ -0,0 +1,90 @@
1
+ import { OcraError } from "@open-cr-agent/core";
2
+ import { mergeRequestOf } from "./adapter.js";
3
+ import { GitLabApi } from "./client.js";
4
+ const GITLAB_API = "https://gitlab.com/api/v4";
5
+ // A merge request on GitLab. GitLab CI sets CI_API_V4_URL and
6
+ // CI_PROJECT_ID; GITLAB_TOKEN must be a token that may write notes, which
7
+ // CI_JOB_TOKEN may not. The project is --project, else CI_PROJECT_ID, else
8
+ // origin's.
9
+ export async function resolveGitLabTarget(options) {
10
+ const { env, ref } = options;
11
+ const token = env.GITLAB_TOKEN;
12
+ if (!token) {
13
+ throw new OcraError("CONFIG_CREDENTIALS_MISSING", "--mr needs a GitLab token in GITLAB_TOKEN with the api scope and the Developer role: a project access token, or on GitLab.com Free a personal access token of a dedicated account (CI_JOB_TOKEN cannot post comments)");
14
+ }
15
+ const origin = await options.origin();
16
+ const project = ref.repository ?? env.CI_PROJECT_ID ?? gitlabProject(origin);
17
+ const apiUrl = gitlabApi(env.CI_API_V4_URL, remoteHost(origin), options.warn);
18
+ const fetchImpl = options.fetch;
19
+ const api = new GitLabApi(project, {
20
+ token,
21
+ baseUrl: apiUrl,
22
+ ...(fetchImpl ? { fetch: fetchImpl } : {}),
23
+ });
24
+ const iid = ref.number;
25
+ const merge = await api.getMergeRequest(iid);
26
+ const snapshot = mergeRequestOf(merge);
27
+ return {
28
+ platform: "gitlab",
29
+ baseSha: snapshot.baseSha,
30
+ headSha: snapshot.headSha,
31
+ headRefs: [`refs/merge-requests/${iid}/head`],
32
+ webUrl: merge.web_url,
33
+ createVcs: (registry, local) => registry.createVcs("gitlab", {
34
+ project: /^\d+$/.test(project) ? Number(project) : project,
35
+ iid,
36
+ token,
37
+ apiUrl,
38
+ code: local.code,
39
+ snapshot,
40
+ history: local.history,
41
+ ...(fetchImpl ? { fetch: fetchImpl } : {}),
42
+ }),
43
+ };
44
+ }
45
+ // Where GITLAB_TOKEN goes: to CI_API_V4_URL, which GitLab CI sets, or else
46
+ // to gitlab.com, but only when origin is there too or is no remote host. A
47
+ // token for a self-managed instance must not travel to gitlab.com because
48
+ // the address was left out.
49
+ function gitlabApi(configured, origin, warn) {
50
+ if (configured) {
51
+ const api = remoteHost(configured);
52
+ if (api && origin && site(api) !== site(origin)) {
53
+ warn(`CI_API_V4_URL is on ${api} but origin is on ${origin}; GITLAB_TOKEN goes to ${api}`);
54
+ }
55
+ return configured;
56
+ }
57
+ if (origin && site(origin) !== "gitlab.com") {
58
+ throw new OcraError("CONFIG_INVALID", `origin is on ${origin}, not gitlab.com: set CI_API_V4_URL to its API, such as https://${origin}/api/v4, so that GITLAB_TOKEN goes only there`);
59
+ }
60
+ return GITLAB_API;
61
+ }
62
+ // GitLab.com answers on subdomains too, such as altssh.gitlab.com for SSH.
63
+ function site(host) {
64
+ return host === "gitlab.com" || host.endsWith(".gitlab.com") ? "gitlab.com" : host;
65
+ }
66
+ // The host of https://host/…, ssh://user@host:port/… or user@host:path; none
67
+ // for a local path, including C:\ on Windows.
68
+ function remoteHost(url) {
69
+ if (!url)
70
+ return undefined;
71
+ if (/^[\w+.-]+:\/\//.test(url)) {
72
+ try {
73
+ const parsed = new URL(url);
74
+ return parsed.protocol === "file:" ? undefined : parsed.hostname.toLowerCase() || undefined;
75
+ }
76
+ catch {
77
+ return undefined;
78
+ }
79
+ }
80
+ return /^(?:[^@\s/]+@)?([^:/\s\\]+):(?![\\/])/.exec(url)?.[1]?.toLowerCase();
81
+ }
82
+ // The project behind the origin remote: any host, since GitLab runs on many.
83
+ function gitlabProject(url) {
84
+ const match = url && /^(?:[\w+.-]+:\/\/[^/]+\/|[^@\s]+@[^:]+:)(.+?)(?:\.git)?\/?$/.exec(url);
85
+ if (!match?.[1]) {
86
+ throw new OcraError("CONFIG_INVALID", "Cannot tell which GitLab project this is; pass --project <id|path>");
87
+ }
88
+ return match[1];
89
+ }
90
+ //# sourceMappingURL=target.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-cr-agent/vcs-gitlab",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "GitLab merge request adapter for Open-CR-Agent",
5
5
  "keywords": [
6
6
  "code-review",
@@ -26,11 +26,6 @@
26
26
  "types": "./dist/index.d.ts",
27
27
  "default": "./dist/index.js"
28
28
  },
29
- "./internal": {
30
- "@open-cr-agent/source": "./src/internal.ts",
31
- "types": "./dist/internal.d.ts",
32
- "default": "./dist/internal.js"
33
- },
34
29
  "./package.json": "./package.json"
35
30
  },
36
31
  "files": [
@@ -38,8 +33,8 @@
38
33
  "!dist/**/*.map"
39
34
  ],
40
35
  "dependencies": {
41
- "@open-cr-agent/core": "0.5.0",
42
- "@open-cr-agent/vcs-platform": "0.5.0",
36
+ "@open-cr-agent/core": "0.6.0",
37
+ "@open-cr-agent/vcs-platform": "0.6.0",
43
38
  "zod": "^4.6.5"
44
39
  },
45
40
  "publishConfig": {
@@ -1,2 +0,0 @@
1
- export { GitLabApi } from "./client.js";
2
- //# sourceMappingURL=internal.d.ts.map
package/dist/internal.js DELETED
@@ -1,4 +0,0 @@
1
- // "@open-cr-agent/vcs-gitlab/internal": what the CLI uses beyond the public
2
- // entry. Not a contract: any release may change it.
3
- export { GitLabApi } from "./client.js";
4
- //# sourceMappingURL=internal.js.map