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.
- package/LICENSE +21 -0
- package/dist/commands/__tests__/review-tui.test.js +2 -2
- package/dist/commands/__tests__/review-tui.test.js.map +1 -1
- package/dist/commands/audit.d.ts +23 -0
- package/dist/commands/audit.d.ts.map +1 -0
- package/dist/commands/audit.js +282 -0
- package/dist/commands/audit.js.map +1 -0
- package/dist/commands/audit.test.d.ts +10 -0
- package/dist/commands/audit.test.d.ts.map +1 -0
- package/dist/commands/audit.test.js +110 -0
- package/dist/commands/audit.test.js.map +1 -0
- package/dist/commands/dismiss.d.ts +14 -0
- package/dist/commands/dismiss.d.ts.map +1 -0
- package/dist/commands/dismiss.js +98 -0
- package/dist/commands/dismiss.js.map +1 -0
- package/dist/commands/feedback.d.ts +18 -0
- package/dist/commands/feedback.d.ts.map +1 -0
- package/dist/commands/feedback.js +122 -0
- package/dist/commands/feedback.js.map +1 -0
- package/dist/commands/login.js +3 -3
- package/dist/commands/login.js.map +1 -1
- package/dist/commands/login.test.js +3 -4
- package/dist/commands/login.test.js.map +1 -1
- package/dist/commands/review-commit-msg.test.js +2 -2
- package/dist/commands/review-commit-msg.test.js.map +1 -1
- package/dist/commands/review-output.test.js +30 -2
- package/dist/commands/review-output.test.js.map +1 -1
- package/dist/commands/review.d.ts +16 -0
- package/dist/commands/review.d.ts.map +1 -1
- package/dist/commands/review.js +239 -3
- package/dist/commands/review.js.map +1 -1
- package/dist/commands/review.mr-exit.test.d.ts +13 -0
- package/dist/commands/review.mr-exit.test.d.ts.map +1 -0
- package/dist/commands/review.mr-exit.test.js +190 -0
- package/dist/commands/review.mr-exit.test.js.map +1 -0
- package/dist/commands/review.postback-disclosure.test.d.ts +22 -0
- package/dist/commands/review.postback-disclosure.test.d.ts.map +1 -0
- package/dist/commands/review.postback-disclosure.test.js +192 -0
- package/dist/commands/review.postback-disclosure.test.js.map +1 -0
- package/dist/commands/review.pr-exit.test.d.ts +13 -0
- package/dist/commands/review.pr-exit.test.d.ts.map +1 -0
- package/dist/commands/review.pr-exit.test.js +162 -0
- package/dist/commands/review.pr-exit.test.js.map +1 -0
- package/dist/commands/review.pr-jenkins.test.d.ts +16 -0
- package/dist/commands/review.pr-jenkins.test.d.ts.map +1 -0
- package/dist/commands/review.pr-jenkins.test.js +106 -0
- package/dist/commands/review.pr-jenkins.test.js.map +1 -0
- package/dist/commands/review.pr-nativeid-fallback.test.d.ts +15 -0
- package/dist/commands/review.pr-nativeid-fallback.test.d.ts.map +1 -0
- package/dist/commands/review.pr-nativeid-fallback.test.js +151 -0
- package/dist/commands/review.pr-nativeid-fallback.test.js.map +1 -0
- package/dist/commands/review.test.js +48 -33
- package/dist/commands/review.test.js.map +1 -1
- package/dist/commands/status.d.ts.map +1 -1
- package/dist/commands/status.js +15 -2
- package/dist/commands/status.js.map +1 -1
- package/dist/commands/status.test.js +31 -10
- package/dist/commands/status.test.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +116 -23
- package/dist/index.js.map +1 -1
- package/dist/lib/cli-github-client-port.d.ts +52 -0
- package/dist/lib/cli-github-client-port.d.ts.map +1 -0
- package/dist/lib/cli-github-client-port.js +199 -0
- package/dist/lib/cli-github-client-port.js.map +1 -0
- package/dist/lib/cli-github-client-port.test.d.ts +10 -0
- package/dist/lib/cli-github-client-port.test.d.ts.map +1 -0
- package/dist/lib/cli-github-client-port.test.js +47 -0
- package/dist/lib/cli-github-client-port.test.js.map +1 -0
- package/dist/lib/cli-gitlab-client-port.d.ts +62 -0
- package/dist/lib/cli-gitlab-client-port.d.ts.map +1 -0
- package/dist/lib/cli-gitlab-client-port.js +209 -0
- package/dist/lib/cli-gitlab-client-port.js.map +1 -0
- package/dist/lib/cli-gitlab-client-port.test.d.ts +9 -0
- package/dist/lib/cli-gitlab-client-port.test.d.ts.map +1 -0
- package/dist/lib/cli-gitlab-client-port.test.js +278 -0
- package/dist/lib/cli-gitlab-client-port.test.js.map +1 -0
- package/dist/lib/config.d.ts +12 -0
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +14 -2
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/config.test.js +22 -3
- package/dist/lib/config.test.js.map +1 -1
- package/dist/lib/forge-postback.d.ts +72 -0
- package/dist/lib/forge-postback.d.ts.map +1 -0
- package/dist/lib/forge-postback.js +39 -0
- package/dist/lib/forge-postback.js.map +1 -0
- package/dist/lib/github-api.d.ts.map +1 -1
- package/dist/lib/github-api.js +15 -6
- package/dist/lib/github-api.js.map +1 -1
- package/dist/lib/github-api.test.js +24 -1
- package/dist/lib/github-api.test.js.map +1 -1
- package/dist/lib/gitlab-api.d.ts +50 -0
- package/dist/lib/gitlab-api.d.ts.map +1 -0
- package/dist/lib/gitlab-api.js +75 -0
- package/dist/lib/gitlab-api.js.map +1 -0
- package/dist/lib/gitlab-api.test.d.ts +2 -0
- package/dist/lib/gitlab-api.test.d.ts.map +1 -0
- package/dist/lib/gitlab-api.test.js +79 -0
- package/dist/lib/gitlab-api.test.js.map +1 -0
- package/dist/lib/gitlab-e2e.manual.test.d.ts +24 -0
- package/dist/lib/gitlab-e2e.manual.test.d.ts.map +1 -0
- package/dist/lib/gitlab-e2e.manual.test.js +57 -0
- package/dist/lib/gitlab-e2e.manual.test.js.map +1 -0
- package/dist/lib/gitlab-token.d.ts +17 -0
- package/dist/lib/gitlab-token.d.ts.map +1 -0
- package/dist/lib/gitlab-token.js +24 -0
- package/dist/lib/gitlab-token.js.map +1 -0
- package/dist/lib/gitlab-token.test.d.ts +2 -0
- package/dist/lib/gitlab-token.test.d.ts.map +1 -0
- package/dist/lib/gitlab-token.test.js +38 -0
- package/dist/lib/gitlab-token.test.js.map +1 -0
- package/dist/lib/oauth.test.js +0 -1
- package/dist/lib/oauth.test.js.map +1 -1
- package/dist/lib/pr-postback.d.ts +42 -0
- package/dist/lib/pr-postback.d.ts.map +1 -0
- package/dist/lib/pr-postback.js +44 -0
- package/dist/lib/pr-postback.js.map +1 -0
- package/dist/lib/pr-postback.test.d.ts +9 -0
- package/dist/lib/pr-postback.test.d.ts.map +1 -0
- package/dist/lib/pr-postback.test.js +196 -0
- package/dist/lib/pr-postback.test.js.map +1 -0
- package/dist/lib/pr-token.d.ts +15 -0
- package/dist/lib/pr-token.d.ts.map +1 -0
- package/dist/lib/pr-token.js +22 -0
- package/dist/lib/pr-token.js.map +1 -0
- package/dist/lib/pr-token.test.d.ts +2 -0
- package/dist/lib/pr-token.test.d.ts.map +1 -0
- package/dist/lib/pr-token.test.js +25 -0
- package/dist/lib/pr-token.test.js.map +1 -0
- package/dist/lib/providers.d.ts +29 -0
- package/dist/lib/providers.d.ts.map +1 -0
- package/dist/lib/providers.js +41 -0
- package/dist/lib/providers.js.map +1 -0
- package/dist/lib/providers.test.d.ts +9 -0
- package/dist/lib/providers.test.d.ts.map +1 -0
- package/dist/lib/providers.test.js +60 -0
- package/dist/lib/providers.test.js.map +1 -0
- package/dist/ui/__tests__/format.test.js +2 -2
- package/dist/ui/__tests__/format.test.js.map +1 -1
- package/dist/ui/__tests__/theme.test.js +3 -2
- package/dist/ui/__tests__/theme.test.js.map +1 -1
- package/dist/ui/theme.d.ts.map +1 -1
- package/dist/ui/theme.js +1 -0
- package/dist/ui/theme.js.map +1 -1
- 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":"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"}
|
|
@@ -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
|