@vornrun/connector-gitlab 0.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/CHANGELOG.md ADDED
@@ -0,0 +1,29 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@vornrun/connector-gitlab`.
4
+
5
+ ## 0.1.0
6
+
7
+ First release.
8
+
9
+ Trigger a workflow from GitLab issues, merge requests and pipeline results, and
10
+ let a workflow step write back. Works against gitlab.com and self-managed
11
+ instances.
12
+
13
+ - **Triggers:** `issueCreated`, `mergeRequestOpened`, `pipelineFinished`.
14
+ - **Actions:** `createIssue`, `commentOnIssue`, `commentOnMergeRequest`,
15
+ `getProject`, `listOpenMergeRequests`, `getIssue`.
16
+ - **Signing in:** borrows the GitLab CLI's login. `glab auth login` is all it
17
+ needs, and no token is stored here — `glab config get token` supplies it on
18
+ demand. A pasted personal access token is used instead when one is given.
19
+
20
+ Every action but the merge request list is a declared request the SDK sends,
21
+ with the response trimmed to camelCase names; the list is hand-written so it can
22
+ answer with a `count` beside its `items`. The three read actions are idempotent
23
+ and carry sample arguments (`gitlab-org/gitlab`) so a live check can call them.
24
+
25
+ The triggers watermark on the field they filter by — `created_at` for issues
26
+ and merge requests, `updated_at` for pipelines — and only finished pipelines
27
+ are delivered, so one that was running when a poll saw it fires once it ends.
28
+ The first poll looks back one minute rather than replaying the project.
29
+ Ships a conformance receipt covering the mock run and the dedupe replay.
package/README.md ADDED
@@ -0,0 +1,138 @@
1
+ # @vornrun/connector-gitlab
2
+
3
+ Trigger Vorn workflows from GitLab issues, merge requests and pipeline results,
4
+ and create or comment on issues and merge requests from a workflow step. Works
5
+ against gitlab.com and self-managed instances.
6
+
7
+ ## Signing in
8
+
9
+ There is no token to paste. This connector borrows the GitLab CLI's login:
10
+
11
+ ```sh
12
+ brew install glab # or see https://gitlab.com/gitlab-org/cli
13
+ glab auth login
14
+ ```
15
+
16
+ `glab config get token --host <host>` supplies the credential on demand, so it
17
+ lives wherever `glab` keeps it — the OS keychain, usually — and nothing is
18
+ stored in the connection. (`glab auth token` does not exist; `config get` is the
19
+ documented way to read what `glab auth login` stored.)
20
+
21
+ If you would rather not depend on `glab`, paste a
22
+ [personal access token](https://docs.gitlab.com/user/profile/personal_access_tokens/)
23
+ into the **Personal access token** field. It is used as-is and `glab` is never
24
+ run. Scope `api` covers everything here; `read_api` is enough for the triggers
25
+ and the read-only actions.
26
+
27
+ The token is sent as `Authorization: Bearer`, which GitLab accepts for both
28
+ personal access tokens and the OAuth tokens `glab auth login --web` stores. A
29
+ borrowed OAuth token lives two hours, so the triggers and the merge request
30
+ list re-read it from `glab` once on a `401` before reporting that you are
31
+ signed out. The other actions are declared requests and send the token the host
32
+ borrowed when it started the connector, which it does afresh on every start.
33
+
34
+ ## Settings
35
+
36
+ | Field | Required | What it does |
37
+ | --- | --- | --- |
38
+ | `baseUrl` | no | Instance URL, default `https://gitlab.com`. Without a trailing slash; `/api/v4` is appended. |
39
+ | `project` | yes | Path such as `gitlab-org/gitlab`, or the numeric id. The triggers poll this project. |
40
+ | `token` | no | Personal access token. Leave empty to borrow `glab`'s login. |
41
+ | `ref` | no | Branch or tag whose pipelines to watch. Blank for every ref. |
42
+
43
+ Actions take their own `project` input, so one connection can act on several
44
+ projects while its triggers watch one.
45
+
46
+ ## Triggers
47
+
48
+ **An issue is created**, **A merge request is opened** and **A pipeline
49
+ finishes**. Each polls the project's list endpoint, oldest first, for
50
+ everything at or after the last watermark, and follows `x-next-page` up to ten
51
+ pages of 100 per poll.
52
+
53
+ Things worth knowing if you are reading `src/connector.ts`:
54
+
55
+ - The watermark is the field the request filters on. The issue and merge
56
+ request triggers ask for `created_after`, so their items carry `created_at`
57
+ as `updatedAt`; the real `updated_at` rides along as `changedAt`. A poll cut
58
+ short by a limit could otherwise move the watermark past an item created
59
+ earlier but touched later, and never ask for it again.
60
+ - The very first poll, before any watermark exists, asks for the minute
61
+ before it rather than the project's whole history.
62
+ - GitLab's time filters are inclusive at second precision while timestamps
63
+ carry milliseconds, so the item sitting on the watermark comes back on the
64
+ next poll. The SDK recognises it by its `iid`; adding a second to the cursor
65
+ would skip whatever else was created in that second.
66
+ - Pipelines are polled with `updated_after`, because `updated_at` moves as a
67
+ pipeline runs. Only `success`, `failed`, `canceled` and `skipped` are
68
+ delivered. A running pipeline is neither delivered nor remembered, so it
69
+ fires once it finishes. A retried pipeline keeps its id and is not delivered
70
+ again — the cost of keying on the id.
71
+ - Lists over 10,000 records omit `x-total`, so the connector never reads it.
72
+
73
+ Status suggestions: issues `opened → todo`, `closed → done`; merge requests
74
+ `opened → in_progress`, `merged`/`closed → done`; pipelines `success → done`,
75
+ `failed → todo`, `canceled`/`skipped → cancelled`.
76
+
77
+ ## Actions
78
+
79
+ | Action | Idempotent | Notes |
80
+ | --- | --- | --- |
81
+ | Create an issue | no | Two identical calls make two issues; GitLab offers no idempotency key |
82
+ | Comment on an issue | no | Posts a note; `internal` hides it from non-members |
83
+ | Comment on a merge request | no | As above, on a merge request |
84
+ | Get a project | yes | `namespace` comes back as `{id, name, path, fullPath, kind}` |
85
+ | List open merge requests | yes | Newest-updated first; `limit` up to 100, optional `targetBranch`. Returns `count` and `items` in the shape the merge request trigger delivers |
86
+ | Get an issue | yes | `author` and `assignees` come back as `{id, username, name}` |
87
+
88
+ Every action but the merge request list is a declared request: the SDK fills
89
+ in the arguments, URL-encodes the project path (`gitlab-org%2Fgitlab`), sends
90
+ the call and keeps the fields named above under camelCase names. The merge
91
+ request list is hand-written, because a declared request cannot count what it
92
+ returns. Issue and merge request numbers are their
93
+ project-scoped `iid`, the number shown in the UI, and are checked to be numbers
94
+ before anything is sent, so a `{{...}}` that resolved to nothing names itself
95
+ rather than returning a 404.
96
+
97
+ ## What this connector cannot do
98
+
99
+ - **No webhooks.** It polls. The default seeded workflows run every 5 minutes.
100
+ - **One project per connection** for the triggers.
101
+ - **No pipeline duration.** The list endpoint carries no `duration` or
102
+ `finished_at`; those need a per-pipeline call this connector does not make.
103
+ - **Nothing beyond the token's scope.** A `403` is reported with GitLab's own
104
+ message; an OAuth token lacking scope answers `insufficient_scope`.
105
+
106
+ Rate limits are honoured by the SDK: a `429` waits out `Retry-After` a bounded
107
+ number of times before the poll gives up and the next scheduled one tries again.
108
+
109
+ ## Checks
110
+
111
+ ```sh
112
+ yarn typecheck && yarn test && yarn build
113
+ yarn workspace @vornrun/connector-gitlab exec vorn-connector check ./dist/index.js --mock --receipt verified.json
114
+ ```
115
+
116
+ `scripts/check.sh` at the repository root runs exactly this for this package.
117
+ `scripts/check-live.sh` runs `vorn-connector check --live` against a real
118
+ instance when `GITLAB_TOKEN` is set, and exits 0 with a note when it is not.
119
+
120
+ ## Built from
121
+
122
+ - [REST API overview: base path, URL encoding, pagination](https://docs.gitlab.com/api/rest/)
123
+ - [REST API authentication: PRIVATE-TOKEN, Bearer, OAuth, 401/403](https://docs.gitlab.com/api/rest/authentication/)
124
+ - [Issues API](https://docs.gitlab.com/api/issues/)
125
+ - [Merge requests API](https://docs.gitlab.com/api/merge_requests/)
126
+ - [Pipelines API](https://docs.gitlab.com/api/pipelines/)
127
+ - [Notes API (issue and merge request comments)](https://docs.gitlab.com/api/notes/)
128
+ - [Projects API](https://docs.gitlab.com/api/projects/)
129
+ - [Personal access tokens](https://docs.gitlab.com/user/profile/personal_access_tokens/)
130
+ - [Access token scopes](https://docs.gitlab.com/security/tokens/access_token_scopes/)
131
+ - [Rate limit response headers](https://docs.gitlab.com/administration/settings/user_and_ip_rate_limits/)
132
+ - [GitLab.com rate limits](https://docs.gitlab.com/user/gitlab_com/)
133
+ - [glab CLI repository](https://gitlab.com/gitlab-org/cli):
134
+ [README and environment variables](https://gitlab.com/gitlab-org/cli/-/blob/main/README.md),
135
+ [authentication](https://gitlab.com/gitlab-org/cli/-/blob/main/docs/source/authentication.md),
136
+ [`glab auth login`](https://gitlab.com/gitlab-org/cli/-/blob/main/docs/source/auth/login.md),
137
+ [`glab auth status`](https://gitlab.com/gitlab-org/cli/-/blob/main/docs/source/auth/status.md),
138
+ [`glab config get`](https://gitlab.com/gitlab-org/cli/-/blob/main/docs/source/config/get.md)
@@ -0,0 +1,214 @@
1
+ import * as _vornrun_connector_sdk from '@vornrun/connector-sdk';
2
+ import { ConnectorConfig, ConnectorItem } from '@vornrun/connector-sdk';
3
+
4
+ /**
5
+ * GitLab's REST API, with the credential borrowed from the GitLab CLI.
6
+ *
7
+ * The connector keeps no token of its own unless someone pastes one. When the
8
+ * `token` field is empty it asks `glab config get token --host <host>` — the
9
+ * documented, machine-readable way to read what `glab auth login` stored —
10
+ * and sends the answer as `Authorization: Bearer`. Bearer rather than
11
+ * `PRIVATE-TOKEN` because `glab auth login --web` stores an OAuth token, which
12
+ * the `PRIVATE-TOKEN` header does not accept; a personal access token is fine
13
+ * either way.
14
+ *
15
+ * A borrowed credential can rotate underneath us — OAuth tokens live two hours
16
+ * — so a 401 re-reads it once and retries before giving up.
17
+ */
18
+
19
+ declare function glabInstallHint(platform?: NodeJS.Platform): string;
20
+ declare class GlabNotFoundError extends Error {
21
+ readonly code = "GLAB_NOT_FOUND";
22
+ constructor();
23
+ }
24
+ declare class GlabSignedOutError extends Error {
25
+ readonly code = "GLAB_SIGNED_OUT";
26
+ constructor(detail?: string);
27
+ }
28
+ type RunGlab = (args: string[]) => Promise<string>;
29
+ /** Run `glab`, translating the two failures a user can actually do something about. */
30
+ declare const runGlab: RunGlab;
31
+ /** The instance URL with nothing after the host (or the relative root), trailing slash gone. */
32
+ declare function normalizeBaseUrl(value: unknown): string;
33
+ /** The `--host` `glab` keeps its login under: the host as typed at `glab auth login`. */
34
+ declare function hostOf(baseUrl: string): string;
35
+ declare function apiUrl(baseUrl: string, path: string, query?: Record<string, string | number | undefined>): string;
36
+ /** A project path is sent URL-encoded (`group%2Fproject`); a numeric id passes through. */
37
+ declare function projectSegment(project: unknown): string;
38
+ interface TokenSourceOptions {
39
+ /** A personal access token from the connection. When set, `glab` is never run. */
40
+ token?: string;
41
+ /** The instance the token must belong to. */
42
+ baseUrl?: string;
43
+ /** Injected in tests, so nothing spawns a process. */
44
+ glab?: RunGlab;
45
+ }
46
+ /**
47
+ * The current GitLab token, cached until something rejects it.
48
+ *
49
+ * Cached because `glab config get token` is a process spawn and a poll makes
50
+ * several calls; invalidated rather than expired because the CLI, not this
51
+ * connector, knows when a token rotates — the only reliable signal is a 401.
52
+ * A pasted token is never invalidated: re-reading it would give the same one.
53
+ */
54
+ declare function createTokenSource(options?: TokenSourceOptions): {
55
+ /** Whether the token came from `glab`, and so could be re-read. */
56
+ borrowed: boolean;
57
+ get(): Promise<string>;
58
+ /** Drop a borrowed token so the next `get()` asks `glab` again. */
59
+ invalidate(): void;
60
+ };
61
+ type TokenSource = ReturnType<typeof createTokenSource>;
62
+ interface GitLabClientOptions {
63
+ config: ConnectorConfig;
64
+ /** The SDK's fetch, which already retries rate limits and gateway hiccups. */
65
+ fetch: typeof fetch;
66
+ tokens?: TokenSource;
67
+ glab?: RunGlab;
68
+ }
69
+ interface ListOptions {
70
+ /** Stop once this many items are in hand; the SDK truncates to it anyway. */
71
+ limit?: number;
72
+ /** Longest chain of pages one call follows. */
73
+ maxPages?: number;
74
+ }
75
+ /**
76
+ * A client bound to the connection's token, which re-authenticates itself once.
77
+ *
78
+ * Every call goes through `get`, so the 401 path is shared rather than
79
+ * repeated per endpoint: read the token again, try once more. A second 401 is
80
+ * a real authorization problem and is reported as one.
81
+ */
82
+ declare function createGitLabClient(options: GitLabClientOptions): {
83
+ baseUrl: string;
84
+ /** One GET, parsed as JSON. Whatever shape comes back is the caller's to judge. */
85
+ getJson<T>(path: string, query?: Record<string, string | number | undefined>): Promise<T>;
86
+ /**
87
+ * Every page of a list endpoint, following `x-next-page` (empty when there
88
+ * is no next page). Never `x-total`: lists over 10,000 records omit it.
89
+ */
90
+ list<T>(path: string, query: Record<string, string | number | undefined>, listOptions?: ListOptions): Promise<T[]>;
91
+ };
92
+ type GitLabClient = ReturnType<typeof createGitLabClient>;
93
+ interface PreflightResult {
94
+ ok: boolean;
95
+ message?: string;
96
+ }
97
+ /**
98
+ * Whether this connector could run right now.
99
+ *
100
+ * Answers the states a user can correct — `glab` missing, `glab` present but
101
+ * signed out — and says what to do about each. Anything else is reported
102
+ * verbatim rather than guessed at. A pasted token is taken at its word: the
103
+ * first request will say if GitLab disagrees.
104
+ */
105
+ declare function gitlabPreflight(options?: TokenSourceOptions): Promise<PreflightResult>;
106
+
107
+ interface GitLabConnectorOptions {
108
+ version?: string;
109
+ /** Injected in tests so nothing spawns `glab`. */
110
+ glab?: RunGlab;
111
+ /** Where preflight reads the token and instance from; defaults to the process environment. */
112
+ env?: NodeJS.ProcessEnv;
113
+ }
114
+ declare function createGitLabConnector(options?: GitLabConnectorOptions): _vornrun_connector_sdk.Connector;
115
+
116
+ /**
117
+ * What GitLab returns, and how each becomes the item Vorn indexes.
118
+ *
119
+ * Kept apart from anything that talks to the network so the mappings — and
120
+ * the one non-obvious choice in them, which timestamp is the watermark — are
121
+ * tested directly against the payloads gitlab.com actually returned.
122
+ */
123
+
124
+ interface GitLabUser {
125
+ id?: number;
126
+ username: string;
127
+ name?: string;
128
+ }
129
+ interface GitLabIssue {
130
+ id: number;
131
+ iid: number;
132
+ project_id: number;
133
+ title: string;
134
+ description?: string | null;
135
+ state: string;
136
+ created_at: string;
137
+ updated_at: string;
138
+ closed_at?: string | null;
139
+ labels?: string[];
140
+ author?: GitLabUser | null;
141
+ assignees?: GitLabUser[];
142
+ web_url: string;
143
+ issue_type?: string;
144
+ confidential?: boolean;
145
+ }
146
+ interface GitLabMergeRequest {
147
+ id: number;
148
+ iid: number;
149
+ project_id: number;
150
+ title: string;
151
+ description?: string | null;
152
+ state: string;
153
+ draft?: boolean;
154
+ created_at: string;
155
+ updated_at: string;
156
+ merged_at?: string | null;
157
+ closed_at?: string | null;
158
+ source_branch: string;
159
+ target_branch: string;
160
+ sha?: string;
161
+ author?: GitLabUser | null;
162
+ labels?: string[];
163
+ web_url: string;
164
+ has_conflicts?: boolean;
165
+ detailed_merge_status?: string;
166
+ }
167
+ interface GitLabPipeline {
168
+ id: number;
169
+ iid?: number;
170
+ project_id: number;
171
+ sha: string;
172
+ ref: string;
173
+ status: string;
174
+ source?: string;
175
+ created_at: string;
176
+ updated_at: string;
177
+ web_url: string;
178
+ name?: string | null;
179
+ }
180
+ /**
181
+ * Pipeline statuses that will not change again, from the documented enum.
182
+ *
183
+ * The rest — `created`, `waiting_for_resource`, `preparing`,
184
+ * `waiting_for_callback`, `pending`, `running`, `canceling`, `manual`,
185
+ * `scheduled` — describe a pipeline still in flight. One of those is neither
186
+ * delivered nor remembered, so it fires once it finishes.
187
+ */
188
+ declare const TERMINAL_PIPELINE_STATUSES: ReadonlySet<string>;
189
+ declare function isFinishedPipeline(pipeline: Pick<GitLabPipeline, 'status'>): boolean;
190
+ /**
191
+ * Map an issue onto the shape Vorn indexes.
192
+ *
193
+ * `updatedAt` is `created_at`, deliberately. The trigger asks GitLab for
194
+ * issues `created_after` its watermark, and the SDK sets that watermark from
195
+ * this field; if it carried `updated_at` instead, a poll cut short by `limit`
196
+ * could move the watermark past an issue created earlier but touched later,
197
+ * and that issue would never be asked for again. The real `updated_at` rides
198
+ * along as `changedAt` (`updatedAt` is a reserved key in `data`).
199
+ */
200
+ declare function issueToItem(issue: GitLabIssue): ConnectorItem;
201
+ /** Map a merge request onto the shape Vorn indexes. Same watermark rule as issues. */
202
+ declare function mergeRequestToItem(mr: GitLabMergeRequest): ConnectorItem;
203
+ /**
204
+ * Map a pipeline onto the shape Vorn indexes.
205
+ *
206
+ * Here `updatedAt` really is `updated_at`: the trigger filters on
207
+ * `updated_after`, and a pipeline's `updated_at` moving as it runs is what
208
+ * makes a finished one reappear after the poll that first saw it running.
209
+ */
210
+ declare function pipelineToItem(pipeline: GitLabPipeline): ConnectorItem;
211
+
212
+ declare const gitlabConnector: _vornrun_connector_sdk.Connector;
213
+
214
+ export { type GitLabClient, type GitLabConnectorOptions, type GitLabIssue, type GitLabMergeRequest, type GitLabPipeline, GlabNotFoundError, GlabSignedOutError, type PreflightResult, type RunGlab, TERMINAL_PIPELINE_STATUSES, type TokenSource, apiUrl, gitlabConnector as connector, createGitLabClient, createGitLabConnector, createTokenSource, gitlabConnector as default, gitlabConnector, gitlabPreflight, glabInstallHint, hostOf, isFinishedPipeline, issueToItem, mergeRequestToItem, normalizeBaseUrl, pipelineToItem, projectSegment, runGlab };
package/dist/index.js ADDED
@@ -0,0 +1,890 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { createRequire } from "module";
5
+
6
+ // src/connector.ts
7
+ import {
8
+ defineConnector
9
+ } from "@vornrun/connector-sdk";
10
+
11
+ // src/client.ts
12
+ import { execFile } from "child_process";
13
+ import { promisify } from "util";
14
+ var execFileAsync = promisify(execFile);
15
+ var GLAB_TIMEOUT_MS = 1e4;
16
+ var API_PATH = "/api/v4";
17
+ var DEFAULT_BASE_URL = "https://gitlab.com";
18
+ var MAX_PAGE_SIZE = 100;
19
+ var MAX_ERROR_BODY = 300;
20
+ function glabInstallHint(platform = process.platform) {
21
+ switch (platform) {
22
+ case "darwin":
23
+ return "Install with Homebrew: `brew install glab`";
24
+ case "win32":
25
+ return "Install with winget: `winget install glab.glab` (or see https://gitlab.com/gitlab-org/cli)";
26
+ default:
27
+ return "Install from https://gitlab.com/gitlab-org/cli (releases carry .deb, .rpm and tarballs)";
28
+ }
29
+ }
30
+ var GlabNotFoundError = class extends Error {
31
+ code = "GLAB_NOT_FOUND";
32
+ constructor() {
33
+ super(`GitLab CLI (glab) not found on PATH. ${glabInstallHint()}`);
34
+ this.name = "GlabNotFoundError";
35
+ }
36
+ };
37
+ var GlabSignedOutError = class extends Error {
38
+ code = "GLAB_SIGNED_OUT";
39
+ constructor(detail) {
40
+ super(`Not signed in to GitLab. Run \`glab auth login\`.${detail ? `
41
+ ${detail}` : ""}`);
42
+ this.name = "GlabSignedOutError";
43
+ }
44
+ };
45
+ var runGlab = async (args) => {
46
+ try {
47
+ const { stdout } = await execFileAsync("glab", args, { timeout: GLAB_TIMEOUT_MS });
48
+ return stdout;
49
+ } catch (error) {
50
+ if (error.code === "ENOENT") throw new GlabNotFoundError();
51
+ const stderr = String(error.stderr ?? "").trim();
52
+ throw new Error(stderr || (error instanceof Error ? error.message : String(error)));
53
+ }
54
+ };
55
+ function normalizeBaseUrl(value) {
56
+ const trimmed = String(value ?? "").trim();
57
+ const base = trimmed === "" ? DEFAULT_BASE_URL : trimmed;
58
+ let url;
59
+ try {
60
+ url = new URL(base);
61
+ } catch {
62
+ throw new Error(`GITLAB_BASE_URL is not a URL: "${base}"`);
63
+ }
64
+ return url.toString().replace(/\/+$/, "");
65
+ }
66
+ function hostOf(baseUrl) {
67
+ return new URL(normalizeBaseUrl(baseUrl)).host;
68
+ }
69
+ function apiUrl(baseUrl, path, query) {
70
+ const url = new URL(`${normalizeBaseUrl(baseUrl)}${API_PATH}${path}`);
71
+ for (const [key, value] of Object.entries(query ?? {})) {
72
+ if (value === void 0 || value === "") continue;
73
+ url.searchParams.set(key, String(value));
74
+ }
75
+ return url.toString();
76
+ }
77
+ function projectSegment(project) {
78
+ const value = String(project ?? "").trim();
79
+ if (!value) throw new Error("GITLAB_PROJECT is required");
80
+ return encodeURIComponent(value);
81
+ }
82
+ function createTokenSource(options = {}) {
83
+ const glab = options.glab ?? runGlab;
84
+ const pasted = String(options.token ?? "").trim();
85
+ const host = hostOf(options.baseUrl ?? DEFAULT_BASE_URL);
86
+ let cached = pasted || void 0;
87
+ async function read() {
88
+ const token = (await glab(["config", "get", "token", "--host", host])).trim();
89
+ if (!token) throw new GlabSignedOutError(`glab has no token for ${host}.`);
90
+ return token;
91
+ }
92
+ return {
93
+ /** Whether the token came from `glab`, and so could be re-read. */
94
+ borrowed: !pasted,
95
+ async get() {
96
+ return cached ??= await read();
97
+ },
98
+ /** Drop a borrowed token so the next `get()` asks `glab` again. */
99
+ invalidate() {
100
+ if (!pasted) cached = void 0;
101
+ }
102
+ };
103
+ }
104
+ async function describeFailure(response) {
105
+ const text2 = await response.text().catch(() => "");
106
+ let detail = text2;
107
+ try {
108
+ const parsed = JSON.parse(text2);
109
+ const message = parsed.message ?? parsed.error;
110
+ if (message !== void 0) detail = typeof message === "string" ? message : JSON.stringify(message);
111
+ } catch {
112
+ }
113
+ const quoted = detail.length > MAX_ERROR_BODY ? `${detail.slice(0, MAX_ERROR_BODY)}\u2026` : detail;
114
+ return `GitLab API ${response.status}${quoted ? `: ${quoted}` : ""}`;
115
+ }
116
+ var MAX_LIST_PAGES = 10;
117
+ function createGitLabClient(options) {
118
+ const baseUrl = normalizeBaseUrl(options.config.baseUrl);
119
+ const tokens = options.tokens ?? createTokenSource({
120
+ ...options.config.token !== void 0 && { token: options.config.token },
121
+ baseUrl,
122
+ ...options.glab && { glab: options.glab }
123
+ });
124
+ async function send(url) {
125
+ const token = await tokens.get();
126
+ return options.fetch(url, { headers: { Authorization: `Bearer ${token}`, Accept: "application/json" } });
127
+ }
128
+ async function get(url) {
129
+ let response = await send(url);
130
+ if (response.status === 401 && tokens.borrowed) {
131
+ tokens.invalidate();
132
+ response = await send(url);
133
+ if (response.status === 401) {
134
+ throw new GlabSignedOutError("The token from `glab config get token` was rejected twice.");
135
+ }
136
+ }
137
+ if (response.status === 401) {
138
+ throw new Error(
139
+ "GitLab rejected the personal access token (401). Check it has not expired and carries the api or read_api scope."
140
+ );
141
+ }
142
+ if (!response.ok) throw new Error(await describeFailure(response));
143
+ return response;
144
+ }
145
+ return {
146
+ baseUrl,
147
+ /** One GET, parsed as JSON. Whatever shape comes back is the caller's to judge. */
148
+ async getJson(path, query) {
149
+ const response = await get(apiUrl(baseUrl, path, query));
150
+ return await response.json();
151
+ },
152
+ /**
153
+ * Every page of a list endpoint, following `x-next-page` (empty when there
154
+ * is no next page). Never `x-total`: lists over 10,000 records omit it.
155
+ */
156
+ async list(path, query, listOptions = {}) {
157
+ const maxPages = listOptions.maxPages ?? MAX_LIST_PAGES;
158
+ const collected = [];
159
+ let page = 1;
160
+ for (let index = 0; index < maxPages; index++) {
161
+ const response = await get(apiUrl(baseUrl, path, { ...query, per_page: MAX_PAGE_SIZE, page }));
162
+ const items = await response.json();
163
+ if (!Array.isArray(items)) {
164
+ throw new Error(`GitLab answered ${path} with something other than a list`);
165
+ }
166
+ collected.push(...items);
167
+ if (listOptions.limit !== void 0 && collected.length >= listOptions.limit) break;
168
+ const next = Number(response.headers.get("x-next-page") ?? "");
169
+ if (!Number.isInteger(next) || next <= page) break;
170
+ page = next;
171
+ }
172
+ return collected;
173
+ }
174
+ };
175
+ }
176
+ async function gitlabPreflight(options = {}) {
177
+ try {
178
+ await createTokenSource(options).get();
179
+ return { ok: true };
180
+ } catch (error) {
181
+ return { ok: false, message: error instanceof Error ? error.message : String(error) };
182
+ }
183
+ }
184
+
185
+ // src/items.ts
186
+ var TERMINAL_PIPELINE_STATUSES = /* @__PURE__ */ new Set([
187
+ "success",
188
+ "failed",
189
+ "canceled",
190
+ "skipped"
191
+ ]);
192
+ function isFinishedPipeline(pipeline) {
193
+ return TERMINAL_PIPELINE_STATUSES.has(pipeline.status);
194
+ }
195
+ function issueToItem(issue) {
196
+ return {
197
+ externalId: String(issue.iid),
198
+ title: issue.title,
199
+ url: issue.web_url,
200
+ description: issue.description ?? "",
201
+ status: issue.state,
202
+ labels: issue.labels ?? [],
203
+ ...issue.assignees?.[0]?.username && { assignee: issue.assignees[0].username },
204
+ updatedAt: issue.created_at,
205
+ data: {
206
+ id: issue.id,
207
+ iid: issue.iid,
208
+ projectId: issue.project_id,
209
+ author: issue.author?.username ?? "",
210
+ assignees: (issue.assignees ?? []).map((user) => user.username),
211
+ createdAt: issue.created_at,
212
+ changedAt: issue.updated_at,
213
+ closedAt: issue.closed_at ?? null,
214
+ issueType: issue.issue_type ?? "issue",
215
+ confidential: issue.confidential === true
216
+ }
217
+ };
218
+ }
219
+ function mergeRequestToItem(mr) {
220
+ return {
221
+ externalId: String(mr.iid),
222
+ title: mr.title,
223
+ url: mr.web_url,
224
+ description: mr.description ?? "",
225
+ status: mr.state,
226
+ labels: mr.labels ?? [],
227
+ updatedAt: mr.created_at,
228
+ data: {
229
+ id: mr.id,
230
+ iid: mr.iid,
231
+ projectId: mr.project_id,
232
+ sourceBranch: mr.source_branch,
233
+ targetBranch: mr.target_branch,
234
+ draft: mr.draft === true,
235
+ sha: mr.sha ?? "",
236
+ author: mr.author?.username ?? "",
237
+ createdAt: mr.created_at,
238
+ changedAt: mr.updated_at,
239
+ mergedAt: mr.merged_at ?? null,
240
+ closedAt: mr.closed_at ?? null,
241
+ hasConflicts: mr.has_conflicts === true,
242
+ detailedMergeStatus: mr.detailed_merge_status ?? ""
243
+ }
244
+ };
245
+ }
246
+ function pipelineToItem(pipeline) {
247
+ return {
248
+ externalId: String(pipeline.id),
249
+ title: `${pipeline.name || pipeline.ref}: ${pipeline.status}`,
250
+ url: pipeline.web_url,
251
+ status: pipeline.status,
252
+ updatedAt: pipeline.updated_at,
253
+ data: {
254
+ id: pipeline.id,
255
+ iid: pipeline.iid ?? null,
256
+ projectId: pipeline.project_id,
257
+ ref: pipeline.ref,
258
+ sha: pipeline.sha,
259
+ source: pipeline.source ?? "",
260
+ name: pipeline.name ?? "",
261
+ createdAt: pipeline.created_at
262
+ }
263
+ };
264
+ }
265
+ var SAMPLE_ISSUE = {
266
+ id: 201377309,
267
+ iid: 627684,
268
+ project_id: 278964,
269
+ title: "Restructure Package Metadata Database documentation and split the offline quick start guide",
270
+ description: "This issue tracks a documentation follow-up agreed during review of https://gitlab.com/gitlab-org/gitlab/-/merge_requests/...",
271
+ state: "opened",
272
+ created_at: "2026-09-04T02:23:26.054Z",
273
+ updated_at: "2026-09-04T03:10:09.633Z",
274
+ closed_at: null,
275
+ labels: ["automation:quick-win-judged", "documentation", "group::composition analysis", "type::maintenance"],
276
+ author: { id: 32685309, username: "azaydan", name: "Ahmad Zaydan" },
277
+ assignees: [],
278
+ web_url: "https://gitlab.com/gitlab-org/gitlab/-/work_items/627684",
279
+ issue_type: "issue",
280
+ confidential: false
281
+ };
282
+ var SAMPLE_MERGE_REQUEST = {
283
+ id: 527853893,
284
+ iid: 253583,
285
+ project_id: 278964,
286
+ title: "Add scope, engine and level properties to perform_search",
287
+ description: "Nothing in GitLab measured whether a search returned any results, so the zero-result rate could not be computed. This adds...",
288
+ state: "opened",
289
+ draft: false,
290
+ created_at: "2026-09-04T03:07:05.722Z",
291
+ updated_at: "2026-09-04T03:12:24.611Z",
292
+ merged_at: null,
293
+ closed_at: null,
294
+ source_branch: "wt/telemetry-zero-result",
295
+ target_branch: "master",
296
+ sha: "ae17207bc7b8ea3696979d9888fd25e0629c8bde",
297
+ author: { id: 9717668, username: "johnmason", name: "John Mason" },
298
+ labels: ["analytics instrumentation", "backend", "feature::addition", "type::feature"],
299
+ web_url: "https://gitlab.com/gitlab-org/gitlab/-/merge_requests/253583",
300
+ has_conflicts: false,
301
+ detailed_merge_status: "not_approved"
302
+ };
303
+ var SAMPLE_PIPELINE = {
304
+ id: 2818962738,
305
+ iid: 6291260,
306
+ project_id: 278964,
307
+ sha: "631b08ab8c69929b88af7f06d64500c3e5f400ae",
308
+ ref: "master",
309
+ status: "success",
310
+ source: "push",
311
+ created_at: "2026-09-04T03:12:16.050Z",
312
+ updated_at: "2026-09-04T03:12:20.492Z",
313
+ web_url: "https://gitlab.com/gitlab-org/gitlab/-/pipelines/2818962738",
314
+ name: "Ruby 3.3.12 master branch"
315
+ };
316
+
317
+ // src/connector.ts
318
+ function text(value) {
319
+ const trimmed = String(value ?? "").trim();
320
+ return trimmed || void 0;
321
+ }
322
+ var AUTH_HEADERS = { Authorization: "Bearer {{config.token}}" };
323
+ var API = "{{config.baseUrl}}/api/v4";
324
+ var FIRST_POLL_LOOKBACK_MS = 6e4;
325
+ function pageSize(limit) {
326
+ if (typeof limit !== "number") return void 0;
327
+ return Math.min(MAX_PAGE_SIZE, Math.max(1, Math.floor(limit)));
328
+ }
329
+ function renames(mapping, path) {
330
+ return Object.entries(mapping).map(([from, to]) => ({
331
+ op: "rename",
332
+ from,
333
+ to,
334
+ ...path !== void 0 && { path }
335
+ }));
336
+ }
337
+ var ISSUE_SHAPE = [
338
+ {
339
+ op: "pick",
340
+ keys: [
341
+ "id",
342
+ "iid",
343
+ "project_id",
344
+ "title",
345
+ "description",
346
+ "state",
347
+ "web_url",
348
+ "labels",
349
+ "author",
350
+ "assignees",
351
+ "created_at",
352
+ "updated_at",
353
+ "closed_at",
354
+ "issue_type",
355
+ "confidential"
356
+ ]
357
+ },
358
+ ...renames({
359
+ project_id: "projectId",
360
+ web_url: "url",
361
+ created_at: "createdAt",
362
+ updated_at: "updatedAt",
363
+ closed_at: "closedAt",
364
+ issue_type: "issueType"
365
+ }),
366
+ { op: "pick", keys: ["id", "username", "name"], path: "author" },
367
+ { op: "pick", keys: ["id", "username", "name"], path: "assignees" }
368
+ ];
369
+ var NOTE_SHAPE = [
370
+ {
371
+ op: "pick",
372
+ keys: ["id", "body", "author", "created_at", "noteable_iid", "noteable_type", "internal"]
373
+ },
374
+ ...renames({ created_at: "createdAt", noteable_iid: "noteableIid", noteable_type: "noteableType" }),
375
+ { op: "pick", keys: ["id", "username", "name"], path: "author" }
376
+ ];
377
+ var NOTE_OUTPUTS = [
378
+ { key: "id", type: "number", description: "The note id" },
379
+ { key: "body", description: "The comment as posted" },
380
+ { key: "author", description: "Who posted it, as {id, username, name}" },
381
+ { key: "createdAt", description: "When it was posted" },
382
+ { key: "noteableIid", type: "number", description: "The iid it was posted on" },
383
+ { key: "noteableType", description: "Issue or MergeRequest" },
384
+ { key: "internal", type: "boolean", description: "Whether only members can see it" }
385
+ ];
386
+ var PROJECT_INPUT = {
387
+ key: "project",
388
+ label: "Project",
389
+ required: true,
390
+ description: "Project path such as group/project, or its numeric id",
391
+ builderHint: "The path is what the URL shows after the host; the id is on the project page. Either is sent URL-encoded as the :id segment."
392
+ };
393
+ var BODY_INPUT = {
394
+ key: "body",
395
+ label: "Comment",
396
+ required: true,
397
+ description: "Markdown, up to 1,000,000 characters"
398
+ };
399
+ var INTERNAL_INPUT = {
400
+ key: "internal",
401
+ label: "Internal note",
402
+ type: "boolean",
403
+ description: "Visible to project members only. Defaults to false.",
404
+ builderHint: "The documented replacement for the deprecated confidential flag on notes."
405
+ };
406
+ function createGitLabConnector(options = {}) {
407
+ const env = options.env ?? process.env;
408
+ const sources = /* @__PURE__ */ new Map();
409
+ function tokensFor(config) {
410
+ const baseUrl = normalizeBaseUrl(config.baseUrl);
411
+ const token = text(config.token);
412
+ const key = `${baseUrl} ${token ?? ""}`;
413
+ let source = sources.get(key);
414
+ if (!source) {
415
+ source = createTokenSource({
416
+ ...token !== void 0 && { token },
417
+ baseUrl,
418
+ ...options.glab && { glab: options.glab }
419
+ });
420
+ sources.set(key, source);
421
+ }
422
+ return source;
423
+ }
424
+ function client(context) {
425
+ return createGitLabClient({
426
+ config: context.config,
427
+ fetch: context.fetch,
428
+ tokens: tokensFor(context.config)
429
+ });
430
+ }
431
+ function sinceOf(context) {
432
+ return context.since ?? new Date(Date.parse(context.now()) - FIRST_POLL_LOOKBACK_MS).toISOString();
433
+ }
434
+ function fetchIssues(context) {
435
+ return client(context).list(
436
+ `/projects/${projectSegment(context.config.project)}/issues`,
437
+ {
438
+ state: "all",
439
+ order_by: "created_at",
440
+ sort: "asc",
441
+ created_after: sinceOf(context)
442
+ },
443
+ { ...context.limit !== void 0 && { limit: context.limit } }
444
+ ).then((issues) => issues.map(issueToItem));
445
+ }
446
+ function fetchMergeRequests(context) {
447
+ return client(context).list(
448
+ `/projects/${projectSegment(context.config.project)}/merge_requests`,
449
+ {
450
+ state: "all",
451
+ order_by: "created_at",
452
+ sort: "asc",
453
+ created_after: sinceOf(context)
454
+ },
455
+ { ...context.limit !== void 0 && { limit: context.limit } }
456
+ ).then((mrs) => mrs.map(mergeRequestToItem));
457
+ }
458
+ async function fetchPipelines(context) {
459
+ const pipelines = await client(context).list(
460
+ `/projects/${projectSegment(context.config.project)}/pipelines`,
461
+ {
462
+ order_by: "updated_at",
463
+ sort: "asc",
464
+ updated_after: sinceOf(context),
465
+ ref: text(context.config.ref)
466
+ }
467
+ // No `limit` here: a page is mostly pipelines still running, and cutting
468
+ // the walk short on the raw count would starve the finished ones behind them.
469
+ );
470
+ return pipelines.filter(isFinishedPipeline).map(pipelineToItem);
471
+ }
472
+ return defineConnector({
473
+ id: "gitlab",
474
+ name: "GitLab",
475
+ ...options.version && { version: options.version },
476
+ description: "Trigger workflows from GitLab issues, merge requests and pipeline results, and create or comment on issues and merge requests from a step.",
477
+ // GitLab's own mark.
478
+ icon: {
479
+ viewBox: "0 0 24 24",
480
+ paths: [
481
+ "m23.6004 9.5927-.0337-.0862L20.3.9814a.851.851 0 0 0-.3362-.405.8748.8748 0 0 0-.9997.0539.8748.8748 0 0 0-.29.4399l-2.2055 6.748H7.5375l-2.2057-6.748a.8573.8573 0 0 0-.29-.4412.8748.8748 0 0 0-.9997-.0537.8585.8585 0 0 0-.3362.4049L.4332 9.5015l-.0325.0862a6.0657 6.0657 0 0 0 2.0119 7.0105l.0113.0087.03.0213 4.976 3.7264 2.462 1.8627 1.4995 1.1321a1.0085 1.0085 0 0 0 1.2197 0l1.4995-1.1321 2.4619-1.8627 5.006-3.7489.0125-.01a6.0682 6.0682 0 0 0 2.0094-7.003z"
482
+ ]
483
+ },
484
+ auth: {
485
+ rung: "cli",
486
+ probe: { command: "glab", args: ["auth", "status"] },
487
+ // `glab auth token` does not exist; `config get token` is the documented
488
+ // way to read what `glab auth login` stored. The host runs it at spawn
489
+ // and hands the answer over as GITLAB_TOKEN, which is why that variable
490
+ // is also the `token` field's env: a host refuses to borrow a name the
491
+ // connector does not openly read. This is the gitlab.com default; the
492
+ // connector's own token source adds `--host` from `baseUrl` when it has
493
+ // to borrow for itself.
494
+ borrow: {
495
+ env: ["GITLAB_TOKEN"],
496
+ tokenArgs: ["glab", "config", "get", "token"],
497
+ tokenEnv: "GITLAB_TOKEN"
498
+ }
499
+ },
500
+ config: [
501
+ {
502
+ key: "baseUrl",
503
+ env: "GITLAB_BASE_URL",
504
+ label: "GitLab URL",
505
+ default: DEFAULT_BASE_URL,
506
+ description: "Instance URL without a trailing slash. Leave the default for gitlab.com.",
507
+ builderHint: "Every request goes to <baseUrl>/api/v4. A self-managed instance under a relative root (https://host/gitlab) works as-is. Declared requests append the path verbatim, so a trailing slash here would double it."
508
+ },
509
+ {
510
+ key: "project",
511
+ env: "GITLAB_PROJECT",
512
+ label: "Project",
513
+ required: true,
514
+ description: "Path such as group/project, or the numeric id. The triggers poll this project.",
515
+ builderHint: "Sent URL-encoded (group%2Fproject) as the :id segment, as the docs require for namespaced paths. Actions take their own project input so one connection can act on several."
516
+ },
517
+ {
518
+ key: "token",
519
+ env: "GITLAB_TOKEN",
520
+ label: "Personal access token",
521
+ secret: true,
522
+ description: "Leave empty to borrow the glab CLI login. Needs scope api, or read_api for the triggers and read-only actions.",
523
+ builderHint: "Created under Edit profile, Access, Personal access tokens, Generate token; prefixed glpat-, 365-day default expiry. Filled, it is sent as-is and glab is never run; empty, the connector asks glab config get token --host <host>."
524
+ },
525
+ {
526
+ key: "ref",
527
+ env: "GITLAB_REF",
528
+ label: "Pipeline ref",
529
+ description: "Branch or tag whose pipelines to watch. Blank for every ref.",
530
+ builderHint: "Sent as the ref filter on GET /projects/:id/pipelines; the other two triggers ignore it."
531
+ }
532
+ ],
533
+ preflight: () => gitlabPreflight({
534
+ ...text(env.GITLAB_TOKEN) !== void 0 && { token: env.GITLAB_TOKEN },
535
+ baseUrl: text(env.GITLAB_BASE_URL) ?? DEFAULT_BASE_URL,
536
+ ...options.glab && { glab: options.glab }
537
+ }),
538
+ triggers: [
539
+ {
540
+ type: "issueCreated",
541
+ label: "An issue is created",
542
+ description: "Fires once for each issue created in the project since the last poll.",
543
+ // The SDK keeps the watermark and the ids sitting on it; the fetch
544
+ // asks GitLab for everything created on or after it.
545
+ dedupe: "timestamp",
546
+ fetch: fetchIssues,
547
+ statusMapping: [
548
+ { upstream: "opened", suggestedLocal: "todo" },
549
+ { upstream: "closed", suggestedLocal: "done" }
550
+ ],
551
+ defaultWorkflow: { name: "GitLab: issues", defaultCronFromMinutes: 5 },
552
+ sample: [issueToItem(SAMPLE_ISSUE)]
553
+ },
554
+ {
555
+ type: "mergeRequestOpened",
556
+ label: "A merge request is opened",
557
+ description: "Fires once for each merge request opened in the project since the last poll.",
558
+ dedupe: "timestamp",
559
+ fetch: fetchMergeRequests,
560
+ statusMapping: [
561
+ { upstream: "opened", suggestedLocal: "in_progress" },
562
+ { upstream: "merged", suggestedLocal: "done" },
563
+ { upstream: "closed", suggestedLocal: "done" }
564
+ ],
565
+ defaultWorkflow: { name: "GitLab: merge requests", defaultCronFromMinutes: 5 },
566
+ sample: [mergeRequestToItem(SAMPLE_MERGE_REQUEST)]
567
+ },
568
+ {
569
+ type: "pipelineFinished",
570
+ label: "A pipeline finishes",
571
+ description: "Fires once for each pipeline that reaches success, failed, canceled or skipped, with the status on the item.",
572
+ dedupe: "timestamp",
573
+ fetch: fetchPipelines,
574
+ // A failed pipeline is work to pick up; Vorn has no `blocked` status
575
+ // to suggest, so it lands as todo.
576
+ statusMapping: [
577
+ { upstream: "success", suggestedLocal: "done" },
578
+ { upstream: "failed", suggestedLocal: "todo" },
579
+ { upstream: "canceled", suggestedLocal: "cancelled" },
580
+ { upstream: "skipped", suggestedLocal: "cancelled" }
581
+ ],
582
+ defaultWorkflow: { name: "GitLab: pipelines", defaultCronFromMinutes: 5 },
583
+ sample: [pipelineToItem(SAMPLE_PIPELINE)]
584
+ }
585
+ ],
586
+ actions: [
587
+ {
588
+ type: "createIssue",
589
+ label: "Create an issue",
590
+ description: "Open a new issue in a project.",
591
+ // Two identical calls make two issues; GitLab offers no idempotency key.
592
+ idempotent: false,
593
+ inputs: [
594
+ PROJECT_INPUT,
595
+ { key: "title", label: "Title", required: true, description: "Issue title" },
596
+ {
597
+ key: "description",
598
+ label: "Description",
599
+ description: "Markdown body, up to 1,048,576 characters"
600
+ },
601
+ {
602
+ key: "labels",
603
+ label: "Labels",
604
+ description: "Comma-separated label names",
605
+ builderHint: "GitLab takes labels as one comma-separated string, so this is passed through as typed."
606
+ }
607
+ ],
608
+ outputs: [
609
+ { key: "id", type: "number", description: "Global issue id" },
610
+ { key: "iid", type: "number", description: "The number shown in the project, as in #12" },
611
+ { key: "url", description: "Where to read it" },
612
+ { key: "title", description: "The title as saved" },
613
+ { key: "state", description: "opened" },
614
+ { key: "createdAt", description: "When it was created" }
615
+ ],
616
+ request: {
617
+ method: "POST",
618
+ url: `${API}/projects/{{args.project}}/issues`,
619
+ headers: AUTH_HEADERS,
620
+ body: {
621
+ title: "{{args.title}}",
622
+ description: "{{args.description}}",
623
+ labels: "{{args.labels}}"
624
+ }
625
+ },
626
+ postReceive: [
627
+ { op: "pick", keys: ["id", "iid", "web_url", "title", "state", "created_at"] },
628
+ ...renames({ web_url: "url", created_at: "createdAt" })
629
+ ]
630
+ },
631
+ {
632
+ type: "commentOnIssue",
633
+ label: "Comment on an issue",
634
+ description: "Post a note on an issue.",
635
+ // Two identical calls make two notes.
636
+ idempotent: false,
637
+ inputs: [
638
+ PROJECT_INPUT,
639
+ {
640
+ key: "iid",
641
+ label: "Issue number",
642
+ type: "number",
643
+ required: true,
644
+ description: "The issue number shown in the project (its iid, not the global id)"
645
+ },
646
+ BODY_INPUT,
647
+ INTERNAL_INPUT
648
+ ],
649
+ outputs: NOTE_OUTPUTS,
650
+ request: {
651
+ method: "POST",
652
+ url: `${API}/projects/{{args.project}}/issues/{{args.iid}}/notes`,
653
+ headers: AUTH_HEADERS,
654
+ body: { body: "{{args.body}}", internal: "{{args.internal}}" }
655
+ },
656
+ postReceive: NOTE_SHAPE
657
+ },
658
+ {
659
+ type: "commentOnMergeRequest",
660
+ label: "Comment on a merge request",
661
+ description: "Post a note on a merge request.",
662
+ idempotent: false,
663
+ inputs: [
664
+ PROJECT_INPUT,
665
+ {
666
+ key: "iid",
667
+ label: "Merge request number",
668
+ type: "number",
669
+ required: true,
670
+ description: "The merge request number shown in the project (its iid)"
671
+ },
672
+ BODY_INPUT,
673
+ INTERNAL_INPUT
674
+ ],
675
+ outputs: NOTE_OUTPUTS,
676
+ request: {
677
+ method: "POST",
678
+ url: `${API}/projects/{{args.project}}/merge_requests/{{args.iid}}/notes`,
679
+ headers: AUTH_HEADERS,
680
+ body: { body: "{{args.body}}", internal: "{{args.internal}}" }
681
+ },
682
+ postReceive: NOTE_SHAPE
683
+ },
684
+ {
685
+ type: "getProject",
686
+ label: "Get a project",
687
+ description: "Read a project by path or id.",
688
+ // Reading changes nothing.
689
+ idempotent: true,
690
+ inputs: [PROJECT_INPUT],
691
+ outputs: [
692
+ { key: "id", type: "number", description: "Project id" },
693
+ { key: "name", description: "Display name" },
694
+ { key: "path", description: "The last segment of the path" },
695
+ { key: "pathWithNamespace", description: "group/project" },
696
+ { key: "description", description: "Project description" },
697
+ { key: "defaultBranch", description: "Usually main or master" },
698
+ { key: "visibility", description: "private, internal or public" },
699
+ { key: "url", description: "Where to open it" },
700
+ { key: "httpUrlToRepo", description: "Clone URL over HTTPS" },
701
+ { key: "sshUrlToRepo", description: "Clone URL over SSH" },
702
+ { key: "createdAt", description: "When the project was created" },
703
+ { key: "lastActivityAt", description: "When something last happened in it" },
704
+ { key: "archived", type: "boolean", description: "Whether it is archived" },
705
+ {
706
+ key: "namespace",
707
+ description: "The owning group or user, as {id, name, path, fullPath, kind}"
708
+ },
709
+ { key: "starCount", type: "number", description: "Stars" },
710
+ { key: "forksCount", type: "number", description: "Forks" },
711
+ { key: "topics", description: "Topic names" }
712
+ ],
713
+ // A public project on gitlab.com, so the live check works with read_api.
714
+ sample: { project: "gitlab-org/gitlab" },
715
+ request: {
716
+ url: `${API}/projects/{{args.project}}`,
717
+ headers: AUTH_HEADERS
718
+ },
719
+ postReceive: [
720
+ {
721
+ op: "pick",
722
+ keys: [
723
+ "id",
724
+ "name",
725
+ "path",
726
+ "path_with_namespace",
727
+ "description",
728
+ "default_branch",
729
+ "visibility",
730
+ "web_url",
731
+ "http_url_to_repo",
732
+ "ssh_url_to_repo",
733
+ "created_at",
734
+ "last_activity_at",
735
+ "archived",
736
+ "namespace",
737
+ "star_count",
738
+ "forks_count",
739
+ "topics"
740
+ ]
741
+ },
742
+ ...renames({
743
+ path_with_namespace: "pathWithNamespace",
744
+ default_branch: "defaultBranch",
745
+ web_url: "url",
746
+ http_url_to_repo: "httpUrlToRepo",
747
+ ssh_url_to_repo: "sshUrlToRepo",
748
+ created_at: "createdAt",
749
+ last_activity_at: "lastActivityAt",
750
+ star_count: "starCount",
751
+ forks_count: "forksCount"
752
+ }),
753
+ { op: "pick", keys: ["id", "name", "path", "full_path", "kind"], path: "namespace" },
754
+ ...renames({ full_path: "fullPath" }, "namespace")
755
+ ]
756
+ },
757
+ {
758
+ type: "listOpenMergeRequests",
759
+ label: "List open merge requests",
760
+ description: "The open merge requests of a project, most recently updated first.",
761
+ idempotent: true,
762
+ inputs: [
763
+ PROJECT_INPUT,
764
+ {
765
+ key: "limit",
766
+ label: "Maximum",
767
+ type: "number",
768
+ description: "How many to return, 1 to 100. Defaults to 20.",
769
+ builderHint: "Sent as per_page; 100 is the documented maximum."
770
+ },
771
+ {
772
+ key: "targetBranch",
773
+ label: "Target branch",
774
+ description: "Only merge requests into this branch. Blank for all."
775
+ }
776
+ ],
777
+ outputs: [
778
+ { key: "count", type: "number", description: "How many merge requests came back" },
779
+ {
780
+ key: "items",
781
+ description: "One entry per merge request, shaped as the merge request trigger delivers it: externalId (the iid), title, url, description, status, labels, updatedAt, and data with id, iid, projectId, sourceBranch, targetBranch, draft, sha, author, createdAt, changedAt, mergedAt, closedAt, hasConflicts, detailedMergeStatus"
782
+ }
783
+ ],
784
+ sample: { project: "gitlab-org/gitlab" },
785
+ // Hand-written rather than declared: a declared request cannot count
786
+ // what it returns, and the spec asks for the trigger's own mapping.
787
+ async run(args, context) {
788
+ const answer = await client(context).getJson(
789
+ `/projects/${projectSegment(args.project)}/merge_requests`,
790
+ {
791
+ state: "opened",
792
+ order_by: "updated_at",
793
+ sort: "desc",
794
+ per_page: pageSize(args.limit),
795
+ target_branch: text(args.targetBranch)
796
+ }
797
+ );
798
+ const mrs = Array.isArray(answer) ? answer : [];
799
+ const items = mrs.map(mergeRequestToItem);
800
+ return { count: items.length, items };
801
+ }
802
+ },
803
+ {
804
+ type: "getIssue",
805
+ label: "Get an issue",
806
+ description: "Read one issue by its number.",
807
+ idempotent: true,
808
+ inputs: [
809
+ PROJECT_INPUT,
810
+ {
811
+ key: "iid",
812
+ label: "Issue number",
813
+ type: "number",
814
+ required: true,
815
+ description: "The issue number shown in the project (its iid)"
816
+ }
817
+ ],
818
+ outputs: [
819
+ { key: "id", type: "number", description: "Global issue id" },
820
+ { key: "iid", type: "number", description: "The number shown in the project" },
821
+ { key: "projectId", type: "number", description: "The project it belongs to" },
822
+ { key: "title", description: "Issue title" },
823
+ { key: "description", description: "Markdown body" },
824
+ { key: "state", description: "opened or closed" },
825
+ { key: "url", description: "Where to read it" },
826
+ { key: "labels", description: "Label names" },
827
+ { key: "author", description: "Who opened it, as {id, username, name}" },
828
+ { key: "assignees", description: "Each as {id, username, name}" },
829
+ { key: "createdAt", description: "When it was opened" },
830
+ { key: "updatedAt", description: "When it last changed" },
831
+ { key: "closedAt", description: "When it was closed, or null" },
832
+ { key: "issueType", description: "issue, incident, test_case or task" },
833
+ { key: "confidential", type: "boolean", description: "Whether only members can see it" }
834
+ ],
835
+ sample: { project: "gitlab-org/gitlab", iid: "1" },
836
+ request: {
837
+ url: `${API}/projects/{{args.project}}/issues/{{args.iid}}`,
838
+ headers: AUTH_HEADERS
839
+ },
840
+ postReceive: ISSUE_SHAPE
841
+ }
842
+ ]
843
+ });
844
+ }
845
+
846
+ // src/entry.ts
847
+ import { realpathSync } from "fs";
848
+ import { fileURLToPath } from "url";
849
+ import { serveConnector } from "@vornrun/connector-sdk";
850
+ function isEntryPoint(moduleUrl, entry = process.argv[1]) {
851
+ if (!entry) return false;
852
+ try {
853
+ return realpathSync(entry) === realpathSync(fileURLToPath(moduleUrl));
854
+ } catch {
855
+ return false;
856
+ }
857
+ }
858
+ function serveIfEntryPoint(connector, moduleUrl, serve = serveConnector) {
859
+ if (!isEntryPoint(moduleUrl)) return false;
860
+ void serve(connector);
861
+ return true;
862
+ }
863
+
864
+ // src/index.ts
865
+ var { version } = createRequire(import.meta.url)("../package.json");
866
+ var gitlabConnector = createGitLabConnector({ version });
867
+ var index_default = gitlabConnector;
868
+ serveIfEntryPoint(gitlabConnector, import.meta.url);
869
+ export {
870
+ GlabNotFoundError,
871
+ GlabSignedOutError,
872
+ TERMINAL_PIPELINE_STATUSES,
873
+ apiUrl,
874
+ gitlabConnector as connector,
875
+ createGitLabClient,
876
+ createGitLabConnector,
877
+ createTokenSource,
878
+ index_default as default,
879
+ gitlabConnector,
880
+ gitlabPreflight,
881
+ glabInstallHint,
882
+ hostOf,
883
+ isFinishedPipeline,
884
+ issueToItem,
885
+ mergeRequestToItem,
886
+ normalizeBaseUrl,
887
+ pipelineToItem,
888
+ projectSegment,
889
+ runGlab
890
+ };
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@vornrun/connector-gitlab",
3
+ "version": "0.1.0",
4
+ "description": "Trigger workflows from GitLab issues, merge requests and pipelines.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/vorn-run/connectors.git",
10
+ "directory": "packages/gitlab"
11
+ },
12
+ "bin": {
13
+ "vorn-connector-gitlab": "dist/index.js"
14
+ },
15
+ "main": "./dist/index.js",
16
+ "files": [
17
+ "dist",
18
+ "README.md",
19
+ "CHANGELOG.md"
20
+ ],
21
+ "scripts": {
22
+ "build": "tsup src/index.ts --format esm --target node22 --clean",
23
+ "typecheck": "tsc --noEmit",
24
+ "test": "vitest run"
25
+ },
26
+ "dependencies": {
27
+ "@vornrun/connector-sdk": "^0.7.0-beta.10"
28
+ },
29
+ "devDependencies": {
30
+ "@types/node": "^22.10.2",
31
+ "@vitest/coverage-v8": "^4.1.10",
32
+ "tsup": "^8.5.1",
33
+ "typescript": "^6.0.3",
34
+ "vitest": "^4.1.10"
35
+ },
36
+ "vorn": {
37
+ "category": "Development",
38
+ "keywords": [
39
+ "gitlab",
40
+ "issues",
41
+ "merge requests",
42
+ "pipelines",
43
+ "ci",
44
+ "code review",
45
+ "repositories"
46
+ ],
47
+ "auth": "Borrows the GitLab CLI's login — `glab auth login` is all it needs — or takes a personal access token if you would rather paste one."
48
+ }
49
+ }