ghagga 2.8.1 → 3.1.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.
Files changed (146) hide show
  1. package/LICENSE +21 -0
  2. package/dist/commands/__tests__/review-tui.test.js +2 -2
  3. package/dist/commands/__tests__/review-tui.test.js.map +1 -1
  4. package/dist/commands/audit.d.ts +23 -0
  5. package/dist/commands/audit.d.ts.map +1 -0
  6. package/dist/commands/audit.js +282 -0
  7. package/dist/commands/audit.js.map +1 -0
  8. package/dist/commands/audit.test.d.ts +10 -0
  9. package/dist/commands/audit.test.d.ts.map +1 -0
  10. package/dist/commands/audit.test.js +110 -0
  11. package/dist/commands/audit.test.js.map +1 -0
  12. package/dist/commands/dismiss.d.ts +14 -0
  13. package/dist/commands/dismiss.d.ts.map +1 -0
  14. package/dist/commands/dismiss.js +98 -0
  15. package/dist/commands/dismiss.js.map +1 -0
  16. package/dist/commands/feedback.d.ts +18 -0
  17. package/dist/commands/feedback.d.ts.map +1 -0
  18. package/dist/commands/feedback.js +122 -0
  19. package/dist/commands/feedback.js.map +1 -0
  20. package/dist/commands/login.js +3 -3
  21. package/dist/commands/login.js.map +1 -1
  22. package/dist/commands/login.test.js +3 -4
  23. package/dist/commands/login.test.js.map +1 -1
  24. package/dist/commands/review-commit-msg.test.js +2 -2
  25. package/dist/commands/review-commit-msg.test.js.map +1 -1
  26. package/dist/commands/review-output.test.js +30 -2
  27. package/dist/commands/review-output.test.js.map +1 -1
  28. package/dist/commands/review.d.ts +16 -0
  29. package/dist/commands/review.d.ts.map +1 -1
  30. package/dist/commands/review.js +239 -3
  31. package/dist/commands/review.js.map +1 -1
  32. package/dist/commands/review.mr-exit.test.d.ts +13 -0
  33. package/dist/commands/review.mr-exit.test.d.ts.map +1 -0
  34. package/dist/commands/review.mr-exit.test.js +190 -0
  35. package/dist/commands/review.mr-exit.test.js.map +1 -0
  36. package/dist/commands/review.postback-disclosure.test.d.ts +22 -0
  37. package/dist/commands/review.postback-disclosure.test.d.ts.map +1 -0
  38. package/dist/commands/review.postback-disclosure.test.js +192 -0
  39. package/dist/commands/review.postback-disclosure.test.js.map +1 -0
  40. package/dist/commands/review.pr-exit.test.d.ts +13 -0
  41. package/dist/commands/review.pr-exit.test.d.ts.map +1 -0
  42. package/dist/commands/review.pr-exit.test.js +162 -0
  43. package/dist/commands/review.pr-exit.test.js.map +1 -0
  44. package/dist/commands/review.pr-jenkins.test.d.ts +16 -0
  45. package/dist/commands/review.pr-jenkins.test.d.ts.map +1 -0
  46. package/dist/commands/review.pr-jenkins.test.js +106 -0
  47. package/dist/commands/review.pr-jenkins.test.js.map +1 -0
  48. package/dist/commands/review.pr-nativeid-fallback.test.d.ts +15 -0
  49. package/dist/commands/review.pr-nativeid-fallback.test.d.ts.map +1 -0
  50. package/dist/commands/review.pr-nativeid-fallback.test.js +151 -0
  51. package/dist/commands/review.pr-nativeid-fallback.test.js.map +1 -0
  52. package/dist/commands/review.test.js +48 -33
  53. package/dist/commands/review.test.js.map +1 -1
  54. package/dist/commands/status.d.ts.map +1 -1
  55. package/dist/commands/status.js +15 -2
  56. package/dist/commands/status.js.map +1 -1
  57. package/dist/commands/status.test.js +31 -10
  58. package/dist/commands/status.test.js.map +1 -1
  59. package/dist/index.d.ts +3 -3
  60. package/dist/index.js +116 -23
  61. package/dist/index.js.map +1 -1
  62. package/dist/lib/cli-github-client-port.d.ts +52 -0
  63. package/dist/lib/cli-github-client-port.d.ts.map +1 -0
  64. package/dist/lib/cli-github-client-port.js +199 -0
  65. package/dist/lib/cli-github-client-port.js.map +1 -0
  66. package/dist/lib/cli-github-client-port.test.d.ts +10 -0
  67. package/dist/lib/cli-github-client-port.test.d.ts.map +1 -0
  68. package/dist/lib/cli-github-client-port.test.js +47 -0
  69. package/dist/lib/cli-github-client-port.test.js.map +1 -0
  70. package/dist/lib/cli-gitlab-client-port.d.ts +62 -0
  71. package/dist/lib/cli-gitlab-client-port.d.ts.map +1 -0
  72. package/dist/lib/cli-gitlab-client-port.js +209 -0
  73. package/dist/lib/cli-gitlab-client-port.js.map +1 -0
  74. package/dist/lib/cli-gitlab-client-port.test.d.ts +9 -0
  75. package/dist/lib/cli-gitlab-client-port.test.d.ts.map +1 -0
  76. package/dist/lib/cli-gitlab-client-port.test.js +278 -0
  77. package/dist/lib/cli-gitlab-client-port.test.js.map +1 -0
  78. package/dist/lib/config.d.ts +12 -0
  79. package/dist/lib/config.d.ts.map +1 -1
  80. package/dist/lib/config.js +14 -2
  81. package/dist/lib/config.js.map +1 -1
  82. package/dist/lib/config.test.js +22 -3
  83. package/dist/lib/config.test.js.map +1 -1
  84. package/dist/lib/forge-postback.d.ts +72 -0
  85. package/dist/lib/forge-postback.d.ts.map +1 -0
  86. package/dist/lib/forge-postback.js +39 -0
  87. package/dist/lib/forge-postback.js.map +1 -0
  88. package/dist/lib/github-api.d.ts.map +1 -1
  89. package/dist/lib/github-api.js +15 -6
  90. package/dist/lib/github-api.js.map +1 -1
  91. package/dist/lib/github-api.test.js +24 -1
  92. package/dist/lib/github-api.test.js.map +1 -1
  93. package/dist/lib/gitlab-api.d.ts +50 -0
  94. package/dist/lib/gitlab-api.d.ts.map +1 -0
  95. package/dist/lib/gitlab-api.js +75 -0
  96. package/dist/lib/gitlab-api.js.map +1 -0
  97. package/dist/lib/gitlab-api.test.d.ts +2 -0
  98. package/dist/lib/gitlab-api.test.d.ts.map +1 -0
  99. package/dist/lib/gitlab-api.test.js +79 -0
  100. package/dist/lib/gitlab-api.test.js.map +1 -0
  101. package/dist/lib/gitlab-e2e.manual.test.d.ts +24 -0
  102. package/dist/lib/gitlab-e2e.manual.test.d.ts.map +1 -0
  103. package/dist/lib/gitlab-e2e.manual.test.js +57 -0
  104. package/dist/lib/gitlab-e2e.manual.test.js.map +1 -0
  105. package/dist/lib/gitlab-token.d.ts +17 -0
  106. package/dist/lib/gitlab-token.d.ts.map +1 -0
  107. package/dist/lib/gitlab-token.js +24 -0
  108. package/dist/lib/gitlab-token.js.map +1 -0
  109. package/dist/lib/gitlab-token.test.d.ts +2 -0
  110. package/dist/lib/gitlab-token.test.d.ts.map +1 -0
  111. package/dist/lib/gitlab-token.test.js +38 -0
  112. package/dist/lib/gitlab-token.test.js.map +1 -0
  113. package/dist/lib/oauth.test.js +0 -1
  114. package/dist/lib/oauth.test.js.map +1 -1
  115. package/dist/lib/pr-postback.d.ts +42 -0
  116. package/dist/lib/pr-postback.d.ts.map +1 -0
  117. package/dist/lib/pr-postback.js +44 -0
  118. package/dist/lib/pr-postback.js.map +1 -0
  119. package/dist/lib/pr-postback.test.d.ts +9 -0
  120. package/dist/lib/pr-postback.test.d.ts.map +1 -0
  121. package/dist/lib/pr-postback.test.js +196 -0
  122. package/dist/lib/pr-postback.test.js.map +1 -0
  123. package/dist/lib/pr-token.d.ts +15 -0
  124. package/dist/lib/pr-token.d.ts.map +1 -0
  125. package/dist/lib/pr-token.js +22 -0
  126. package/dist/lib/pr-token.js.map +1 -0
  127. package/dist/lib/pr-token.test.d.ts +2 -0
  128. package/dist/lib/pr-token.test.d.ts.map +1 -0
  129. package/dist/lib/pr-token.test.js +25 -0
  130. package/dist/lib/pr-token.test.js.map +1 -0
  131. package/dist/lib/providers.d.ts +29 -0
  132. package/dist/lib/providers.d.ts.map +1 -0
  133. package/dist/lib/providers.js +41 -0
  134. package/dist/lib/providers.js.map +1 -0
  135. package/dist/lib/providers.test.d.ts +9 -0
  136. package/dist/lib/providers.test.d.ts.map +1 -0
  137. package/dist/lib/providers.test.js +60 -0
  138. package/dist/lib/providers.test.js.map +1 -0
  139. package/dist/ui/__tests__/format.test.js +2 -2
  140. package/dist/ui/__tests__/format.test.js.map +1 -1
  141. package/dist/ui/__tests__/theme.test.js +3 -2
  142. package/dist/ui/__tests__/theme.test.js.map +1 -1
  143. package/dist/ui/theme.d.ts.map +1 -1
  144. package/dist/ui/theme.js +1 -0
  145. package/dist/ui/theme.js.map +1 -1
  146. package/package.json +18 -18
@@ -0,0 +1,47 @@
1
+ /**
2
+ * resolveGitHubRepoId — numeric repo id resolution for RepoRef.nativeId.
3
+ *
4
+ * The CLI `--pr` flow resolves the IMMUTABLE numeric GitHub repo id once before
5
+ * the post-back (mirroring the GitLab `--mr` flow's resolveGitLabProjectId). We
6
+ * mock ONLY `fetch` (the network seam) so this exercises the genuine GET
7
+ * /repos/{owner}/{repo} → .id call + its error handling.
8
+ */
9
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
10
+ import { resolveGitHubRepoId } from './cli-github-client-port.js';
11
+ const mockFetch = vi.fn();
12
+ vi.stubGlobal('fetch', mockFetch);
13
+ function jsonResponse(status, body) {
14
+ return {
15
+ ok: status >= 200 && status < 300,
16
+ status,
17
+ statusText: status === 200 ? 'OK' : 'ERR',
18
+ text: () => Promise.resolve(JSON.stringify(body)),
19
+ json: () => Promise.resolve(body),
20
+ };
21
+ }
22
+ describe('resolveGitHubRepoId (numeric repo id resolution → RepoRef.nativeId)', () => {
23
+ beforeEach(() => mockFetch.mockReset());
24
+ it('GET /repos/{owner}/{repo} → numeric id as string', async () => {
25
+ mockFetch.mockResolvedValueOnce(jsonResponse(200, { id: 555000111, full_name: 'acme/widgets' }));
26
+ const id = await resolveGitHubRepoId('acme', 'widgets', 'ghp');
27
+ // Stringified to match RepoRef.nativeId's string type (consistent with GitLab).
28
+ expect(id).toBe('555000111');
29
+ const [url] = mockFetch.mock.calls[0];
30
+ expect(url).toBe('https://api.github.com/repos/acme/widgets');
31
+ });
32
+ it('passes an AbortSignal (timeout) so CI cannot hang', async () => {
33
+ mockFetch.mockResolvedValueOnce(jsonResponse(200, { id: 1 }));
34
+ await resolveGitHubRepoId('acme', 'widgets', 'ghp');
35
+ const [, init] = mockFetch.mock.calls[0];
36
+ expect(init.signal).toBeInstanceOf(AbortSignal);
37
+ });
38
+ it('throws on a non-ok response (e.g. 404)', async () => {
39
+ mockFetch.mockResolvedValueOnce(jsonResponse(404, { message: 'Not Found' }));
40
+ await expect(resolveGitHubRepoId('nope', 'nope', 'ghp')).rejects.toThrow(/resolving repo id/);
41
+ });
42
+ it('throws when the response has no numeric "id"', async () => {
43
+ mockFetch.mockResolvedValueOnce(jsonResponse(200, { full_name: 'acme/widgets' }));
44
+ await expect(resolveGitHubRepoId('acme', 'widgets', 'ghp')).rejects.toThrow(/no numeric "id"/);
45
+ });
46
+ });
47
+ //# sourceMappingURL=cli-github-client-port.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli-github-client-port.test.js","sourceRoot":"","sources":["../../src/lib/cli-github-client-port.test.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,EAAE,MAAM,QAAQ,CAAC;AAC9D,OAAO,EAAE,mBAAmB,EAAE,MAAM,6BAA6B,CAAC;AAElE,MAAM,SAAS,GAAG,EAAE,CAAC,EAAE,EAAE,CAAC;AAC1B,EAAE,CAAC,UAAU,CAAC,OAAO,EAAE,SAAS,CAAC,CAAC;AAElC,SAAS,YAAY,CAAC,MAAc,EAAE,IAAa;IACjD,OAAO;QACL,EAAE,EAAE,MAAM,IAAI,GAAG,IAAI,MAAM,GAAG,GAAG;QACjC,MAAM;QACN,UAAU,EAAE,MAAM,KAAK,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,KAAK;QACzC,IAAI,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,OAAO,CAAC,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC;QACjD,IAAI,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,OAAO,CAAC,IAAI,CAAC;KACtB,CAAC;AAChB,CAAC;AAED,QAAQ,CAAC,qEAAqE,EAAE,GAAG,EAAE;IACnF,UAAU,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,SAAS,EAAE,CAAC,CAAC;IAExC,EAAE,CAAC,kDAAkD,EAAE,KAAK,IAAI,EAAE;QAChE,SAAS,CAAC,qBAAqB,CAC7B,YAAY,CAAC,GAAG,EAAE,EAAE,EAAE,EAAE,SAAS,EAAE,SAAS,EAAE,cAAc,EAAE,CAAC,CAChE,CAAC;QACF,MAAM,EAAE,GAAG,MAAM,mBAAmB,CAAC,MAAM,EAAE,SAAS,EAAE,KAAK,CAAC,CAAC;QAC/D,gFAAgF;QAChF,MAAM,CAAC,EAAE,CAAC,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC;QAC7B,MAAM,CAAC,GAAG,CAAC,GAAG,SAAS,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAA0B,CAAC;QAC/D,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,2CAA2C,CAAC,CAAC;IAChE,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,mDAAmD,EAAE,KAAK,IAAI,EAAE;QACjE,SAAS,CAAC,qBAAqB,CAAC,YAAY,CAAC,GAAG,EAAE,EAAE,EAAE,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC;QAC9D,MAAM,mBAAmB,CAAC,MAAM,EAAE,SAAS,EAAE,KAAK,CAAC,CAAC;QACpD,MAAM,CAAC,EAAE,IAAI,CAAC,GAAG,SAAS,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAA0B,CAAC;QAClE,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,cAAc,CAAC,WAAW,CAAC,CAAC;IAClD,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,wCAAwC,EAAE,KAAK,IAAI,EAAE;QACtD,SAAS,CAAC,qBAAqB,CAAC,YAAY,CAAC,GAAG,EAAE,EAAE,OAAO,EAAE,WAAW,EAAE,CAAC,CAAC,CAAC;QAC7E,MAAM,MAAM,CAAC,mBAAmB,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,CAAC,CAAC,CAAC,OAAO,CAAC,OAAO,CAAC,mBAAmB,CAAC,CAAC;IAChG,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,8CAA8C,EAAE,KAAK,IAAI,EAAE;QAC5D,SAAS,CAAC,qBAAqB,CAAC,YAAY,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,cAAc,EAAE,CAAC,CAAC,CAAC;QAClF,MAAM,MAAM,CAAC,mBAAmB,CAAC,MAAM,EAAE,SAAS,EAAE,KAAK,CAAC,CAAC,CAAC,OAAO,CAAC,OAAO,CAAC,iBAAiB,CAAC,CAAC;IACjG,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"}
@@ -0,0 +1,62 @@
1
+ /**
2
+ * CLI-side implementation of the forge `GitLabClientPort` + numeric-project-id
3
+ * resolution for the `ghagga review --mr N` MR summary post-back.
4
+ *
5
+ * The `GitLabForgeAdapter` (packages/forge) depends on an injected
6
+ * {@link GitLabClientPort} (dependency inversion — forge MUST NOT import any
7
+ * concrete HTTP client). The CLI provides the real native-fetch implementation
8
+ * here, on the same `AbortSignal.timeout` + error-class pattern as
9
+ * `cli-github-client-port.ts`.
10
+ *
11
+ * AUTH: we send ONLY the `PRIVATE-TOKEN: <pat>` header — GitLab's recommended
12
+ * header for Personal/Project/Group Access Tokens (the kind `ghagga` uses). We do
13
+ * NOT also send `Authorization: Bearer`, which is for OAuth2 access tokens; mixing
14
+ * both is unnecessary and the PAT header is the canonical PAT auth path.
15
+ *
16
+ * HOST: the API base is derived from the resolved GitLab host
17
+ * (`https://<host>/api/v4`) so self-managed instances work, with a `GITLAB_HOST`
18
+ * env override for the rare case the API host differs from the git remote host.
19
+ *
20
+ * SCOPE (P4 — MR summary post-back): the four note members the adapter's
21
+ * `upsertSummaryComment` folds over (list/create/delete/update) plus
22
+ * `publishInline` (create). All four route through the NUMERIC project id (the
23
+ * canonical R-GITLAB identity). `resolveGitLabProjectId` is the pre-construction
24
+ * step that turns the mutable group/project path into that numeric id.
25
+ */
26
+ import type { GitLabClientPort } from 'ghagga-forge';
27
+ /**
28
+ * Resolve the GitLab API base (`https://<host>/api/v4`) for a remote host.
29
+ *
30
+ * Precedence:
31
+ * 1. `GITLAB_API_BASE` — a FULL API base override. Required for a self-managed
32
+ * GitLab served under a SUBPATH (e.g. `https://example.com/gitlab`), where a
33
+ * host-only value cannot express the `/gitlab` prefix. Used verbatim (only a
34
+ * trailing slash is trimmed); supply the complete `.../api/v4` URL.
35
+ * 2. `GITLAB_HOST` — a host-only override (for when the API host differs from
36
+ * the git remote host, e.g. a reverse proxy on a different hostname).
37
+ * 3. the host parsed from the git remote.
38
+ * For (2)/(3) a bare host is expected; a stray scheme/trailing slash is tolerated.
39
+ */
40
+ export declare function resolveGitLabApiBase(host: string, env?: NodeJS.ProcessEnv): string;
41
+ /**
42
+ * Resolve the GitLab NUMERIC project id from a group/project path (R-GITLAB).
43
+ *
44
+ * `GET /projects/:url-encoded-path` → `.id`. The path is mutable, so the numeric
45
+ * id is the canonical `RepoRef.nativeId`. This runs ONCE before constructing the
46
+ * adapter; it is NOT part of the {@link GitLabClientPort} (the adapter only ever
47
+ * speaks numeric ids).
48
+ *
49
+ * @param apiBase the resolved API base (`https://<host>/api/v4`) — see
50
+ * {@link resolveGitLabApiBase}. Defaults to gitlab.com for back-compat callers.
51
+ * @returns the numeric project id as a STRING (RepoRef.nativeId is a string).
52
+ */
53
+ export declare function resolveGitLabProjectId(path: string, token: string, apiBase?: string): Promise<string>;
54
+ /**
55
+ * Build a {@link GitLabClientPort} backed by native fetch for the CLI `--mr`
56
+ * post-back. All members route through the numeric project id.
57
+ *
58
+ * @param apiBase the resolved API base (`https://<host>/api/v4`) — see
59
+ * {@link resolveGitLabApiBase}. Defaults to gitlab.com for back-compat callers.
60
+ */
61
+ export declare function createCliGitLabClientPort(apiBase?: string): GitLabClientPort;
62
+ //# sourceMappingURL=cli-gitlab-client-port.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli-gitlab-client-port.d.ts","sourceRoot":"","sources":["../../src/lib/cli-gitlab-client-port.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,OAAO,KAAK,EAAE,gBAAgB,EAAc,MAAM,cAAc,CAAC;AAUjE;;;;;;;;;;;;GAYG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,MAAM,CAU/F;AAwBD;;;;;;;;;;;GAWG;AACH,wBAAsB,sBAAsB,CAC1C,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,EACb,OAAO,SAA8B,GACpC,OAAO,CAAC,MAAM,CAAC,CAgBjB;AAED;;;;;;GAMG;AACH,wBAAgB,yBAAyB,CAAC,OAAO,SAA8B,GAAG,gBAAgB,CA2GjG"}
@@ -0,0 +1,209 @@
1
+ /**
2
+ * CLI-side implementation of the forge `GitLabClientPort` + numeric-project-id
3
+ * resolution for the `ghagga review --mr N` MR summary post-back.
4
+ *
5
+ * The `GitLabForgeAdapter` (packages/forge) depends on an injected
6
+ * {@link GitLabClientPort} (dependency inversion — forge MUST NOT import any
7
+ * concrete HTTP client). The CLI provides the real native-fetch implementation
8
+ * here, on the same `AbortSignal.timeout` + error-class pattern as
9
+ * `cli-github-client-port.ts`.
10
+ *
11
+ * AUTH: we send ONLY the `PRIVATE-TOKEN: <pat>` header — GitLab's recommended
12
+ * header for Personal/Project/Group Access Tokens (the kind `ghagga` uses). We do
13
+ * NOT also send `Authorization: Bearer`, which is for OAuth2 access tokens; mixing
14
+ * both is unnecessary and the PAT header is the canonical PAT auth path.
15
+ *
16
+ * HOST: the API base is derived from the resolved GitLab host
17
+ * (`https://<host>/api/v4`) so self-managed instances work, with a `GITLAB_HOST`
18
+ * env override for the rare case the API host differs from the git remote host.
19
+ *
20
+ * SCOPE (P4 — MR summary post-back): the four note members the adapter's
21
+ * `upsertSummaryComment` folds over (list/create/delete/update) plus
22
+ * `publishInline` (create). All four route through the NUMERIC project id (the
23
+ * canonical R-GITLAB identity). `resolveGitLabProjectId` is the pre-construction
24
+ * step that turns the mutable group/project path into that numeric id.
25
+ */
26
+ import { GitLabApiError } from './gitlab-api.js';
27
+ /**
28
+ * Per-request timeout for the CLI's GitLab fetch calls. Mirrors the GitHub CLI
29
+ * port's `AbortSignal.timeout(10_000)` so a slow/hung GitLab can never hang a CI
30
+ * job indefinitely.
31
+ */
32
+ const REQUEST_TIMEOUT_MS = 10_000;
33
+ /**
34
+ * Resolve the GitLab API base (`https://<host>/api/v4`) for a remote host.
35
+ *
36
+ * Precedence:
37
+ * 1. `GITLAB_API_BASE` — a FULL API base override. Required for a self-managed
38
+ * GitLab served under a SUBPATH (e.g. `https://example.com/gitlab`), where a
39
+ * host-only value cannot express the `/gitlab` prefix. Used verbatim (only a
40
+ * trailing slash is trimmed); supply the complete `.../api/v4` URL.
41
+ * 2. `GITLAB_HOST` — a host-only override (for when the API host differs from
42
+ * the git remote host, e.g. a reverse proxy on a different hostname).
43
+ * 3. the host parsed from the git remote.
44
+ * For (2)/(3) a bare host is expected; a stray scheme/trailing slash is tolerated.
45
+ */
46
+ export function resolveGitLabApiBase(host, env = process.env) {
47
+ const apiBaseOverride = env.GITLAB_API_BASE?.trim();
48
+ if (apiBaseOverride) {
49
+ return apiBaseOverride.replace(/\/+$/, '');
50
+ }
51
+ const hostOverride = env.GITLAB_HOST?.trim();
52
+ const chosen = hostOverride || host;
53
+ // Tolerate an override that includes a scheme and/or trailing slash.
54
+ const bare = chosen.replace(/^https?:\/\//i, '').replace(/\/+$/, '');
55
+ return `https://${bare}/api/v4`;
56
+ }
57
+ /** Auth headers — PRIVATE-TOKEN only (GitLab's recommended header for PATs). */
58
+ function apiHeaders(token) {
59
+ return {
60
+ 'PRIVATE-TOKEN': token,
61
+ 'Content-Type': 'application/json',
62
+ };
63
+ }
64
+ async function failOn(res, action) {
65
+ const body = await res.text();
66
+ throw new GitLabApiError(`GitLab API error ${action}: ${res.status} ${res.statusText}`, res.status, body);
67
+ }
68
+ /** Encode a path segment that itself contains slashes (group/subgroup/project). */
69
+ function encodeProjectPath(path) {
70
+ return encodeURIComponent(path);
71
+ }
72
+ /**
73
+ * Resolve the GitLab NUMERIC project id from a group/project path (R-GITLAB).
74
+ *
75
+ * `GET /projects/:url-encoded-path` → `.id`. The path is mutable, so the numeric
76
+ * id is the canonical `RepoRef.nativeId`. This runs ONCE before constructing the
77
+ * adapter; it is NOT part of the {@link GitLabClientPort} (the adapter only ever
78
+ * speaks numeric ids).
79
+ *
80
+ * @param apiBase the resolved API base (`https://<host>/api/v4`) — see
81
+ * {@link resolveGitLabApiBase}. Defaults to gitlab.com for back-compat callers.
82
+ * @returns the numeric project id as a STRING (RepoRef.nativeId is a string).
83
+ */
84
+ export async function resolveGitLabProjectId(path, token, apiBase = 'https://gitlab.com/api/v4') {
85
+ const url = `${apiBase}/projects/${encodeProjectPath(path)}`;
86
+ const res = await fetch(url, {
87
+ headers: apiHeaders(token),
88
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
89
+ });
90
+ if (!res.ok)
91
+ await failOn(res, 'resolving project id');
92
+ const data = (await res.json());
93
+ if (typeof data.id !== 'number') {
94
+ throw new GitLabApiError(`GitLab API error resolving project id: response had no numeric "id" for path "${path}"`, res.status, JSON.stringify(data));
95
+ }
96
+ return String(data.id);
97
+ }
98
+ /**
99
+ * Build a {@link GitLabClientPort} backed by native fetch for the CLI `--mr`
100
+ * post-back. All members route through the numeric project id.
101
+ *
102
+ * @param apiBase the resolved API base (`https://<host>/api/v4`) — see
103
+ * {@link resolveGitLabApiBase}. Defaults to gitlab.com for back-compat callers.
104
+ */
105
+ export function createCliGitLabClientPort(apiBase = 'https://gitlab.com/api/v4') {
106
+ return {
107
+ async listMrNotes(projectId, mrIid, token) {
108
+ // Paginate until exhausted (a page with < 100 items is the last page),
109
+ // like the GitHub port. MAX_PAGES is a safety upper bound (50 × 100 = 5000
110
+ // notes) so a pathological MR can't loop forever; if hit we log rather
111
+ // than silently truncate, since a stale marker beyond the bound would
112
+ // yield a DUPLICATE note. (Was 5 pages / 500 items — backlog #6.)
113
+ const baseUrl = `${apiBase}/projects/${projectId}/merge_requests/${mrIid}/notes`;
114
+ const MAX_PAGES = 50;
115
+ const out = [];
116
+ for (let page = 1; page <= MAX_PAGES; page++) {
117
+ const url = `${baseUrl}?per_page=100&page=${page}&sort=asc&order_by=created_at`;
118
+ const res = await fetch(url, {
119
+ headers: apiHeaders(token),
120
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
121
+ });
122
+ if (!res.ok)
123
+ await failOn(res, 'listing MR notes');
124
+ const notes = (await res.json());
125
+ for (const note of notes) {
126
+ out.push({ id: note.id, body: note.body });
127
+ }
128
+ if (notes.length < 100)
129
+ break;
130
+ if (page === MAX_PAGES) {
131
+ console.warn(`[ghagga] listMrNotes hit MAX_PAGES (${MAX_PAGES}) for project ${projectId} MR !${mrIid}; note listing may be truncated`);
132
+ }
133
+ }
134
+ return out;
135
+ },
136
+ async createMrNote(projectId, mrIid, body, token) {
137
+ const url = `${apiBase}/projects/${projectId}/merge_requests/${mrIid}/notes`;
138
+ const res = await fetch(url, {
139
+ method: 'POST',
140
+ headers: apiHeaders(token),
141
+ body: JSON.stringify({ body }),
142
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
143
+ });
144
+ if (!res.ok)
145
+ await failOn(res, 'creating MR note');
146
+ const data = (await res.json());
147
+ return { id: data.id };
148
+ },
149
+ async deleteMrNote(projectId, mrIid, noteId, token) {
150
+ const url = `${apiBase}/projects/${projectId}/merge_requests/${mrIid}/notes/${noteId}`;
151
+ const res = await fetch(url, {
152
+ method: 'DELETE',
153
+ headers: apiHeaders(token),
154
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
155
+ });
156
+ // The adapter's upsert tolerates delete failures (best-effort), but we
157
+ // still surface a non-2xx (except 404) so `deleted[]` only reflects
158
+ // genuinely-removed ids.
159
+ if (!res.ok && res.status !== 404)
160
+ await failOn(res, 'deleting MR note');
161
+ },
162
+ async createMrDiscussion(projectId, mrIid, body, position, token) {
163
+ // True diff-anchored discussion: POST /merge_requests/:iid/discussions with
164
+ // position[position_type]=text + the three SHAs + old/new path + line(s).
165
+ const url = `${apiBase}/projects/${projectId}/merge_requests/${mrIid}/discussions`;
166
+ const payload = {
167
+ body,
168
+ position: {
169
+ position_type: 'text',
170
+ base_sha: position.baseSha,
171
+ head_sha: position.headSha,
172
+ start_sha: position.startSha,
173
+ old_path: position.oldPath,
174
+ new_path: position.newPath,
175
+ ...(position.oldLine != null ? { old_line: position.oldLine } : {}),
176
+ ...(position.newLine != null ? { new_line: position.newLine } : {}),
177
+ },
178
+ };
179
+ const res = await fetch(url, {
180
+ method: 'POST',
181
+ headers: apiHeaders(token),
182
+ body: JSON.stringify(payload),
183
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
184
+ });
185
+ if (!res.ok)
186
+ await failOn(res, 'creating MR discussion');
187
+ // The discussion response nests its notes; box the FIRST note's id so the
188
+ // result matches the createMrNote contract ({ id: number }).
189
+ const data = (await res.json());
190
+ const firstNoteId = data.notes?.[0]?.id;
191
+ if (typeof firstNoteId !== 'number') {
192
+ throw new GitLabApiError('GitLab API error creating MR discussion: response had no numeric notes[0].id', res.status, JSON.stringify(data));
193
+ }
194
+ return { id: firstNoteId };
195
+ },
196
+ async updateMrNote(projectId, mrIid, noteId, body, token) {
197
+ const url = `${apiBase}/projects/${projectId}/merge_requests/${mrIid}/notes/${noteId}`;
198
+ const res = await fetch(url, {
199
+ method: 'PUT',
200
+ headers: apiHeaders(token),
201
+ body: JSON.stringify({ body }),
202
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
203
+ });
204
+ if (!res.ok)
205
+ await failOn(res, 'updating MR note');
206
+ },
207
+ };
208
+ }
209
+ //# sourceMappingURL=cli-gitlab-client-port.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli-gitlab-client-port.js","sourceRoot":"","sources":["../../src/lib/cli-gitlab-client-port.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,9 @@
1
+ /**
2
+ * R-GITLAB / R-LEAK-PUBLISH: the `--mr` post-back routes through the REAL
3
+ * GitLabForgeAdapter folding over the CLI's own GitLabClientPort. We mock ONLY
4
+ * `fetch` (the network seam), so this exercises the genuine adapter
5
+ * find→delete→repost idempotency AND the genuine CLI port REST calls — a
6
+ * faithful simulation against a mocked GitLab.
7
+ */
8
+ export {};
9
+ //# sourceMappingURL=cli-gitlab-client-port.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli-gitlab-client-port.test.d.ts","sourceRoot":"","sources":["../../src/lib/cli-gitlab-client-port.test.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG"}
@@ -0,0 +1,278 @@
1
+ /**
2
+ * R-GITLAB / R-LEAK-PUBLISH: the `--mr` post-back routes through the REAL
3
+ * GitLabForgeAdapter folding over the CLI's own GitLabClientPort. We mock ONLY
4
+ * `fetch` (the network seam), so this exercises the genuine adapter
5
+ * find→delete→repost idempotency AND the genuine CLI port REST calls — a
6
+ * faithful simulation against a mocked GitLab.
7
+ */
8
+ import { GitLabForgeAdapter } from 'ghagga-forge';
9
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
10
+ import { createCliGitLabClientPort, resolveGitLabApiBase, resolveGitLabProjectId, } from './cli-gitlab-client-port.js';
11
+ import { postSummaryComment } from './pr-postback.js';
12
+ const mockFetch = vi.fn();
13
+ vi.stubGlobal('fetch', mockFetch);
14
+ function jsonResponse(status, body) {
15
+ return {
16
+ ok: status >= 200 && status < 300,
17
+ status,
18
+ statusText: status === 200 ? 'OK' : status === 201 ? 'Created' : 'ERR',
19
+ text: () => Promise.resolve(JSON.stringify(body)),
20
+ json: () => Promise.resolve(body),
21
+ };
22
+ }
23
+ const MARKER = { html: '<!-- ghagga-review -->' };
24
+ const PROJECT_ID = '12345';
25
+ const REF = {
26
+ repo: { kind: 'gitlab', nativeId: PROJECT_ID, path: 'acme/widgets' },
27
+ iid: 7,
28
+ };
29
+ function buildAdapter(token = 'glpat') {
30
+ return new GitLabForgeAdapter({
31
+ client: createCliGitLabClientPort(),
32
+ token,
33
+ projectId: PROJECT_ID,
34
+ });
35
+ }
36
+ describe('resolveGitLabProjectId (numeric project id resolution, R-GITLAB)', () => {
37
+ beforeEach(() => mockFetch.mockReset());
38
+ it('GET /projects/:url-encoded-path → numeric id as string', async () => {
39
+ mockFetch.mockResolvedValueOnce(jsonResponse(200, { id: 12345, path_with_namespace: 'acme/widgets' }));
40
+ const id = await resolveGitLabProjectId('acme/widgets', 'glpat');
41
+ expect(id).toBe('12345');
42
+ const [url] = mockFetch.mock.calls[0];
43
+ // path is URL-encoded (nested-group safe).
44
+ expect(url).toBe('https://gitlab.com/api/v4/projects/acme%2Fwidgets');
45
+ });
46
+ it('URL-encodes nested-group paths', async () => {
47
+ mockFetch.mockResolvedValueOnce(jsonResponse(200, { id: 99 }));
48
+ await resolveGitLabProjectId('group/subgroup/project', 'glpat');
49
+ const [url] = mockFetch.mock.calls[0];
50
+ expect(url).toContain('projects/group%2Fsubgroup%2Fproject');
51
+ });
52
+ it('throws on a non-ok response', async () => {
53
+ mockFetch.mockResolvedValueOnce(jsonResponse(404, { message: '404 Project Not Found' }));
54
+ await expect(resolveGitLabProjectId('nope/nope', 'glpat')).rejects.toThrow(/resolving project id/);
55
+ });
56
+ });
57
+ describe('postSummaryComment via CLI GitLab port + GitLabForgeAdapter', () => {
58
+ beforeEach(() => mockFetch.mockReset());
59
+ it('creates a fresh note when none exists (no stale to delete)', async () => {
60
+ // listMrNotes: empty
61
+ mockFetch.mockResolvedValueOnce(jsonResponse(200, []));
62
+ // createMrNote → { id }
63
+ mockFetch.mockResolvedValueOnce(jsonResponse(201, { id: 9001 }));
64
+ const adapter = buildAdapter();
65
+ const body = `${MARKER.html}\n## body`;
66
+ const result = await postSummaryComment(adapter, REF, body, MARKER);
67
+ expect(result.createdNativeId).toBe(9001);
68
+ expect(result.deletedNativeIds).toEqual([]);
69
+ expect(mockFetch).toHaveBeenCalledTimes(2);
70
+ const [listUrl] = mockFetch.mock.calls[0];
71
+ expect(listUrl).toContain('/projects/12345/merge_requests/7/notes');
72
+ const [postUrl, postInit] = mockFetch.mock.calls[1];
73
+ expect(postUrl).toBe('https://gitlab.com/api/v4/projects/12345/merge_requests/7/notes');
74
+ expect(postInit.method).toBe('POST');
75
+ expect(JSON.parse(postInit.body)).toEqual({ body });
76
+ });
77
+ it('finds stale by marker → deletes ALL → reposts fresh (idempotent upsert)', async () => {
78
+ mockFetch.mockResolvedValueOnce(jsonResponse(200, [
79
+ { id: 100, body: `old ${MARKER.html}` },
80
+ { id: 200, body: 'someone else' },
81
+ { id: 300, body: `newer ${MARKER.html}` },
82
+ ]));
83
+ // delete latest=300, delete stale=100
84
+ mockFetch.mockResolvedValueOnce(jsonResponse(204, {}));
85
+ mockFetch.mockResolvedValueOnce(jsonResponse(204, {}));
86
+ // create fresh
87
+ mockFetch.mockResolvedValueOnce(jsonResponse(201, { id: 400 }));
88
+ const adapter = buildAdapter();
89
+ const result = await postSummaryComment(adapter, REF, 'body', MARKER);
90
+ expect(result.createdNativeId).toBe(400);
91
+ expect(result.deletedNativeIds).toEqual([300, 100]);
92
+ const del1 = mockFetch.mock.calls[1];
93
+ const del2 = mockFetch.mock.calls[2];
94
+ expect(del1[0]).toContain('/notes/300');
95
+ expect(del1[1].method).toBe('DELETE');
96
+ expect(del2[0]).toContain('/notes/100');
97
+ // foreign note 200 never deleted.
98
+ const allUrls = mockFetch.mock.calls.map((c) => c[0]).join('\n');
99
+ expect(allUrls).not.toContain('/notes/200');
100
+ });
101
+ it('sends ONLY PRIVATE-TOKEN (no Bearer) + an AbortSignal (FIX E)', async () => {
102
+ mockFetch.mockResolvedValueOnce(jsonResponse(200, []));
103
+ mockFetch.mockResolvedValueOnce(jsonResponse(201, { id: 1 }));
104
+ const adapter = buildAdapter('secret-pat');
105
+ await postSummaryComment(adapter, REF, 'body', MARKER);
106
+ const [, postInit] = mockFetch.mock.calls[1];
107
+ const headers = postInit.headers;
108
+ expect(headers['PRIVATE-TOKEN']).toBe('secret-pat');
109
+ expect(headers.Authorization).toBeUndefined();
110
+ for (const call of mockFetch.mock.calls) {
111
+ expect(call[1].signal).toBeInstanceOf(AbortSignal);
112
+ }
113
+ });
114
+ it('a 401 with the static PAT is fatal (no retry loop)', async () => {
115
+ mockFetch.mockResolvedValueOnce(jsonResponse(401, { message: '401 Unauthorized' }));
116
+ const adapter = buildAdapter();
117
+ await expect(postSummaryComment(adapter, REF, 'body', MARKER)).rejects.toThrow();
118
+ expect(mockFetch).toHaveBeenCalledTimes(1);
119
+ });
120
+ });
121
+ describe('CLI GitLab port — publishInline partial failure end-to-end (R-LEAK-PUBLISH)', () => {
122
+ beforeEach(() => mockFetch.mockReset());
123
+ it('5 inline notes, 3 fail (500) → posted 2, failed 3, never aborts', async () => {
124
+ // create calls in order: ok, 500, 500, ok, 500
125
+ mockFetch
126
+ .mockResolvedValueOnce(jsonResponse(201, { id: 11 }))
127
+ .mockResolvedValueOnce(jsonResponse(500, { message: 'boom' }))
128
+ .mockResolvedValueOnce(jsonResponse(500, { message: 'boom' }))
129
+ .mockResolvedValueOnce(jsonResponse(201, { id: 14 }))
130
+ .mockResolvedValueOnce(jsonResponse(500, { message: 'boom' }));
131
+ const adapter = buildAdapter();
132
+ const report = await adapter.publishInline(REF, [
133
+ { path: 'a.ts', line: 1, body: 'c0' },
134
+ { path: 'b.ts', line: 2, body: 'c1' },
135
+ { path: 'c.ts', line: 3, body: 'c2' },
136
+ { path: 'd.ts', line: 4, body: 'c3' },
137
+ { path: 'e.ts', line: 5, body: 'c4' },
138
+ ]);
139
+ expect(report.posted).toEqual([
140
+ { kind: 'gitlab', raw: '11' },
141
+ { kind: 'gitlab', raw: '14' },
142
+ ]);
143
+ expect(report.failed.map((f) => f.index)).toEqual([1, 2, 4]);
144
+ expect(mockFetch).toHaveBeenCalledTimes(5);
145
+ });
146
+ });
147
+ describe('resolveGitLabApiBase (FIX A — derive API base from host + env overrides)', () => {
148
+ it('defaults to https://<host>/api/v4 for the parsed remote host', () => {
149
+ expect(resolveGitLabApiBase('gitlab.com', {})).toBe('https://gitlab.com/api/v4');
150
+ expect(resolveGitLabApiBase('gitlab.example.com', {})).toBe('https://gitlab.example.com/api/v4');
151
+ });
152
+ it('GITLAB_HOST overrides the remote host', () => {
153
+ expect(resolveGitLabApiBase('gitlab.com', { GITLAB_HOST: 'api.internal' })).toBe('https://api.internal/api/v4');
154
+ });
155
+ it('GITLAB_API_BASE is a full override (subpath self-hosted)', () => {
156
+ expect(resolveGitLabApiBase('gitlab.com', {
157
+ GITLAB_API_BASE: 'https://example.com/gitlab/api/v4',
158
+ })).toBe('https://example.com/gitlab/api/v4');
159
+ });
160
+ it('GITLAB_API_BASE wins over GITLAB_HOST + trims trailing slash', () => {
161
+ expect(resolveGitLabApiBase('gitlab.com', {
162
+ GITLAB_HOST: 'ignored.example',
163
+ GITLAB_API_BASE: 'https://example.com/gitlab/api/v4/',
164
+ })).toBe('https://example.com/gitlab/api/v4');
165
+ });
166
+ });
167
+ describe('CLI GitLab port — self-hosted API base (FIX A)', () => {
168
+ beforeEach(() => mockFetch.mockReset());
169
+ function buildSelfHostedAdapter() {
170
+ return new GitLabForgeAdapter({
171
+ client: createCliGitLabClientPort('https://gitlab.example.com/api/v4'),
172
+ token: 'glpat',
173
+ projectId: PROJECT_ID,
174
+ });
175
+ }
176
+ it('routes note calls + project-id resolution against the self-hosted host', async () => {
177
+ // resolveGitLabProjectId against the self-hosted base.
178
+ mockFetch.mockResolvedValueOnce(jsonResponse(200, { id: 12345 }));
179
+ const id = await resolveGitLabProjectId('team/repo', 'glpat', 'https://gitlab.example.com/api/v4');
180
+ expect(id).toBe('12345');
181
+ const [idUrl] = mockFetch.mock.calls[0];
182
+ expect(idUrl).toBe('https://gitlab.example.com/api/v4/projects/team%2Frepo');
183
+ mockFetch.mockReset();
184
+ // upsert against the self-hosted base.
185
+ mockFetch.mockResolvedValueOnce(jsonResponse(200, []));
186
+ mockFetch.mockResolvedValueOnce(jsonResponse(201, { id: 9001 }));
187
+ const adapter = buildSelfHostedAdapter();
188
+ await postSummaryComment(adapter, REF, 'body', MARKER);
189
+ const [postUrl, postInit] = mockFetch.mock.calls[1];
190
+ expect(postUrl).toBe('https://gitlab.example.com/api/v4/projects/12345/merge_requests/7/notes');
191
+ // PRIVATE-TOKEN only.
192
+ const headers = postInit.headers;
193
+ expect(headers['PRIVATE-TOKEN']).toBe('glpat');
194
+ expect(headers.Authorization).toBeUndefined();
195
+ });
196
+ });
197
+ describe('CLI GitLab port — listMrNotes pagination (backlog #6)', () => {
198
+ beforeEach(() => mockFetch.mockReset());
199
+ function makeNotes(count, startId = 0, markerAt) {
200
+ return Array.from({ length: count }, (_, i) => ({
201
+ id: startId + i,
202
+ body: i === markerAt ? `summary ${startId + i} ${MARKER.html}` : `noise ${startId + i}`,
203
+ }));
204
+ }
205
+ it('single page (< 100 notes) → exactly one list call, no extra fetch', async () => {
206
+ mockFetch.mockResolvedValueOnce(jsonResponse(200, makeNotes(20, 0)));
207
+ const port = createCliGitLabClientPort();
208
+ const notes = await port.listMrNotes('12345', 7, 'glpat');
209
+ expect(notes).toHaveLength(20);
210
+ expect(mockFetch).toHaveBeenCalledTimes(1);
211
+ const [url] = mockFetch.mock.calls[0];
212
+ expect(url).toContain('page=1');
213
+ });
214
+ it('multi-page → full page 1 forces page 2 fetch; stale-on-page-2 found + deleted (no duplicate)', async () => {
215
+ // page 1: 100 notes, NO marker → must fetch page 2; page 2 carries the stale marker.
216
+ mockFetch.mockResolvedValueOnce(jsonResponse(200, makeNotes(100, 0)));
217
+ mockFetch.mockResolvedValueOnce(jsonResponse(200, makeNotes(15, 100, 5))); // marker id 105
218
+ // adapter then deletes the found stale (id 105) and reposts fresh.
219
+ mockFetch.mockResolvedValueOnce(jsonResponse(204, {})); // delete 105
220
+ mockFetch.mockResolvedValueOnce(jsonResponse(201, { id: 999 })); // repost
221
+ const adapter = buildAdapter();
222
+ const result = await postSummaryComment(adapter, REF, 'body', MARKER);
223
+ // Stale on page 2 WAS found and deleted — not duplicated.
224
+ expect(result.deletedNativeIds).toEqual([105]);
225
+ expect(result.createdNativeId).toBe(999);
226
+ // page 1 + page 2 list calls happened.
227
+ expect(mockFetch.mock.calls[0][0]).toContain('page=1');
228
+ expect(mockFetch.mock.calls[1][0]).toContain('page=2');
229
+ const delCall = mockFetch.mock.calls[2];
230
+ expect(delCall[0]).toContain('/notes/105');
231
+ expect(delCall[1].method).toBe('DELETE');
232
+ });
233
+ it('safety bound → stops at MAX_PAGES (50) and warns instead of looping forever', async () => {
234
+ for (let page = 0; page < 50; page++) {
235
+ mockFetch.mockResolvedValueOnce(jsonResponse(200, makeNotes(100, page * 100)));
236
+ }
237
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
238
+ const port = createCliGitLabClientPort();
239
+ const notes = await port.listMrNotes('12345', 7, 'glpat');
240
+ expect(notes).toHaveLength(5000);
241
+ expect(mockFetch).toHaveBeenCalledTimes(50);
242
+ expect(mockFetch.mock.calls[49][0]).toContain('page=50');
243
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('MAX_PAGES (50)'));
244
+ warnSpy.mockRestore();
245
+ });
246
+ });
247
+ describe('CLI GitLab port — createMrDiscussion (FIX C true positioning)', () => {
248
+ beforeEach(() => mockFetch.mockReset());
249
+ it('posts a positioned inline comment to /discussions and boxes notes[0].id', async () => {
250
+ mockFetch.mockResolvedValueOnce(jsonResponse(201, { id: 'disc-1', notes: [{ id: 555 }] }));
251
+ const adapter = buildAdapter();
252
+ const report = await adapter.publishInline(REF, [
253
+ {
254
+ path: 'src/x.ts',
255
+ line: 4,
256
+ side: 'new',
257
+ body: 'anchored',
258
+ position: { baseSha: 'B', headSha: 'H', startSha: 'S', newLine: 4 },
259
+ },
260
+ ]);
261
+ expect(report.posted).toEqual([{ kind: 'gitlab', raw: '555' }]);
262
+ const [discUrl, discInit] = mockFetch.mock.calls[0];
263
+ expect(discUrl).toBe('https://gitlab.com/api/v4/projects/12345/merge_requests/7/discussions');
264
+ expect(discInit.method).toBe('POST');
265
+ const payload = JSON.parse(discInit.body);
266
+ expect(payload.body).toBe('anchored');
267
+ expect(payload.position).toEqual({
268
+ position_type: 'text',
269
+ base_sha: 'B',
270
+ head_sha: 'H',
271
+ start_sha: 'S',
272
+ old_path: 'src/x.ts',
273
+ new_path: 'src/x.ts',
274
+ new_line: 4,
275
+ });
276
+ });
277
+ });
278
+ //# sourceMappingURL=cli-gitlab-client-port.test.js.map