@danypops/tickets 0.2.0 → 0.3.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/README.md CHANGED
@@ -157,15 +157,54 @@ tickets auth status
157
157
  tickets auth logout github
158
158
  ```
159
159
 
160
+ ### GitHub: reuse an already-authenticated `gh` CLI session
161
+
162
+ `tickets auth login --backend github --gh-cli [account]` skips the device
163
+ flow (and the `GITHUB_OAUTH_CLIENT_ID` App registration it needs) entirely
164
+ by reading `gh auth token` instead — never re-implement a vendor CLI's own
165
+ auth, just consume its result via its own documented, stable interface.
166
+ Works whether `gh` stores its token in the OS keyring or a legacy
167
+ plaintext file. Omit `account` for `gh`'s current active account, or name
168
+ one of `gh`'s own multiple authenticated accounts (`gh auth status` lists
169
+ them) — pair with a distinct `--backend` name to register each as its own
170
+ tickets backend:
171
+
172
+ ```bash
173
+ tickets auth login --backend github-personal --gh-cli DanyPops
174
+ tickets auth login --backend github-work --gh-cli work-account
175
+ ```
176
+
160
177
  A stored, still-fresh delegated token always takes precedence over a static
161
178
  config/env token for that backend. Tokens are written to
162
179
  `$XDG_STATE_HOME/tickets/oauth/<backend>.json`, mode `0600`, and are never
163
180
  printed by any command. **Restart the daemon** after logging in so it picks
164
181
  up the new credential — `buildRepositories()` runs once at daemon startup.
165
182
 
183
+ ### Optional: credentials via Enigma
184
+
185
+ If an [Enigma](https://github.com/DanyPops/enigma) vault is running,
186
+ tickets checks it first on every request, ahead of a stored delegated token
187
+ and any static config/env token — a credential Enigma rotates is picked up
188
+ on the very next call, no daemon restart needed. Purely additive: tickets
189
+ works identically with no Enigma running at all.
190
+
191
+ Register tickets as a scoped Enigma client (once), then pass the printed
192
+ token to the daemon via `ENIGMA_CLIENT_TOKEN`:
193
+
194
+ ```bash
195
+ enigma client add tickets --backends github,gitlab,jira
196
+ # -> prints a token once; export it wherever the tickets daemon is started
197
+ export ENIGMA_CLIENT_TOKEN=<printed token>
198
+ ```
199
+
200
+ Without `ENIGMA_CLIENT_TOKEN`, tickets falls back to Enigma's shared
201
+ admin-token file if one exists at `$XDG_STATE_HOME/enigma/token` — fine for
202
+ a single-user machine where every local daemon is equally trusted, but a
203
+ scoped client token is the least-privilege default.
204
+
166
205
  ## The `pi-tickets` extension
167
206
 
168
- `extensions/pi-tickets/` registers a single `tickets` tool for
207
+ Published as `@danypops/pi-tickets`. `../pi-tickets/` (this repo's workspace member) registers a single `tickets` tool for
169
208
  [pi](https://github.com/badlogic/pi) with one action per CLI command (`list`,
170
209
  `get`, `create`, `update`, `search`, `children`, `comments`, `comment_add`,
171
210
  `backends`, `ledger_search`, `ledger_stats`, `focus_set`, `focus_get`,
@@ -189,27 +228,21 @@ shows the current focus at all times, refreshed on session start and after
189
228
  every `tickets` tool call — so a focus the LLM sets via `focus_set` mid-
190
229
  conversation shows up in the footer too, and vice versa.
191
230
 
192
- To use it:
193
-
194
- ```bash
195
- cd extensions/pi-tickets
196
- bun install
197
- ```
198
-
199
- then either symlink (or copy) `extensions/pi-tickets` into
200
- `~/.pi/agent/extensions/pi-tickets`, or add its path to `settings.json`:
231
+ To use it, add it to pi's `settings.json`:
201
232
 
202
233
  ```json
203
- { "extensions": ["/path/to/tickets/extensions/pi-tickets"] }
234
+ { "packages": ["npm:@danypops/pi-tickets"] }
204
235
  ```
205
236
 
237
+ Or, for local development against this monorepo, point at the workspace
238
+ member directory instead: `{ "packages": ["/path/to/tickets/packages/pi-tickets"] }`.
239
+
206
240
  ## Development
207
241
 
208
242
  ```bash
209
- bun install
210
- bun run typecheck # tsc --noEmit against src/ and test/
211
- bun test # domain, adapters, application service, auth flows, daemon
212
- cd extensions/pi-tickets && bun install && bun test && bun run typecheck
243
+ bun install # from the repo root -- links both workspace members
244
+ bun run typecheck # both packages
245
+ bun test # both packages
213
246
  ```
214
247
 
215
248
  Tests never hit real GitHub/GitLab/Jira/Atlassian: adapters take an
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/tickets",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Unified CLI, daemon, and TypeScript library for issue tracking across GitHub, GitLab, and Jira.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -20,14 +20,16 @@
20
20
  ],
21
21
  "scripts": {
22
22
  "daemon": "bun run src/daemon/main.ts",
23
- "test": "bun test --path-ignore-patterns 'extensions/**'",
24
- "test:extension": "cd extensions/pi-tickets && bun install && bun test",
25
- "test:all": "npm run test && npm run test:extension",
23
+ "test": "bun test",
26
24
  "typecheck": "tsc --noEmit"
27
25
  },
28
26
  "dependencies": {
29
- "@danypops/daemon-kit": "^0.2.1",
27
+ "@danypops/daemon-kit": "^0.3.0",
28
+ "@danypops/enigma-client": "^0.3.0",
29
+ "@gitbeaker/rest": "^43.8.0",
30
30
  "commander": "^12.1.0",
31
+ "jira.js": "^5.4.0",
32
+ "octokit": "^5.0.5",
31
33
  "yaml": "^2.6.0"
32
34
  },
33
35
  "devDependencies": {
@@ -50,7 +52,8 @@
50
52
  ],
51
53
  "repository": {
52
54
  "type": "git",
53
- "url": "git+https://github.com/DanyPops/tickets.git"
55
+ "url": "git+https://github.com/DanyPops/tickets.git",
56
+ "directory": "packages/tickets"
54
57
  },
55
58
  "homepage": "https://github.com/DanyPops/tickets#readme",
56
59
  "bugs": {
@@ -1,19 +1,39 @@
1
1
  /**
2
2
  * GitHub adapter — driven implementation of IssueRepository/CommentCapable against
3
- * the GitHub REST API v3 (docs: https://docs.github.com/en/rest/issues/issues).
4
- * Token is optional: public repos allow unauthenticated reads at a lower rate limit.
3
+ * the GitHub REST API v3, via octokit (github.com/octokit) rather than a hand-rolled
4
+ * HTTP client: it's GitHub's own official SDK, generated from GitHub's OpenAPI spec,
5
+ * and its typed `assignees: string[]` matches the real write contract exactly (see
6
+ * RESEARCH.md). Token is optional: public repos allow unauthenticated reads at a
7
+ * lower rate limit.
8
+ *
9
+ * IMPORTANT: the `octokit` meta-package bundles @octokit/plugin-retry and
10
+ * @octokit/plugin-throttling ON by default, with default onRateLimit/
11
+ * onSecondaryRateLimit handlers that silently SLEEP for GitHub's advertised
12
+ * Retry-After window (which for an exhausted hourly quota can be tens of
13
+ * minutes) before even attempting a retry -- confirmed for real: a live smoke
14
+ * test against an already-rate-limited endpoint hung with zero output rather
15
+ * than failing fast. That's the opposite of this project's own design (the
16
+ * daemon's ledger exists so live calls can fail fast and the caller decides
17
+ * what to do next, not so octokit can unilaterally decide to block for an
18
+ * indeterminate duration). Both plugins are explicitly disabled below, and
19
+ * every call carries a hard timeout matching the old hand-rolled HttpClient's.
5
20
  */
21
+ import { Octokit } from "octokit";
22
+ import { RequestError } from "@octokit/request-error";
6
23
  import type { Comment, CreateInput, Issue, ListFilter, Status, UpdateInput } from "../domain/issue.js";
7
24
  import { parsePriority } from "../domain/issue.js";
8
- import { AuthRequiredError } from "./errors.js";
9
- import { type FetchLike, HttpClient } from "./http.js";
25
+ import { ApiError, AuthRequiredError, IssueNotFoundError } from "./errors.js";
26
+
27
+ const DEFAULT_TIMEOUT_MS = 30_000;
10
28
 
11
29
  export interface GitHubOptions {
12
30
  owner: string;
13
31
  repo?: string;
14
32
  token?: string;
15
33
  baseUrl?: string;
16
- fetchImpl?: FetchLike;
34
+ timeoutMs?: number;
35
+ /** Injected in tests instead of hitting a real network — see @octokit/types' RequestRequestOptions.fetch. */
36
+ fetchImpl?: typeof fetch;
17
37
  }
18
38
 
19
39
  interface GhUser {
@@ -31,14 +51,14 @@ interface GhIssue {
31
51
  html_url: string;
32
52
  user: GhUser | null;
33
53
  assignee: GhUser | null;
34
- labels: GhLabel[];
54
+ labels: (GhLabel | string)[];
35
55
  created_at: string;
36
56
  updated_at: string;
37
57
  pull_request?: unknown;
38
58
  }
39
59
  interface GhComment {
40
60
  id: number;
41
- body: string;
61
+ body?: string;
42
62
  created_at: string;
43
63
  updated_at: string;
44
64
  user: GhUser | null;
@@ -46,31 +66,32 @@ interface GhComment {
46
66
 
47
67
  export class GitHubRepository {
48
68
  readonly name: string;
49
- private readonly http: HttpClient;
69
+ private readonly client: Octokit;
50
70
  private readonly owner: string;
51
71
  private repo?: string;
52
72
  private readonly readOnly: boolean;
53
73
 
74
+ private readonly timeoutMs: number;
75
+
54
76
  constructor(name: string, opts: GitHubOptions) {
55
77
  if (!opts.owner) throw new Error("github: owner is required");
56
78
  this.name = name;
57
79
  this.owner = opts.owner;
58
80
  this.repo = opts.repo;
59
81
  this.readOnly = !opts.token;
60
- this.http = new HttpClient({
82
+ this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
83
+ this.client = new Octokit({
84
+ auth: opts.token,
61
85
  baseUrl: opts.baseUrl ?? "https://api.github.com",
62
- backend: "github",
63
- fetchImpl: opts.fetchImpl,
64
- headers: {
65
- Accept: "application/vnd.github.v3+json",
66
- ...(opts.token ? { Authorization: `token ${opts.token}` } : {}),
67
- },
86
+ retry: { enabled: false },
87
+ throttle: { onRateLimit: () => false, onSecondaryRateLimit: () => false },
88
+ ...(opts.fetchImpl ? { request: { fetch: opts.fetchImpl } } : {}),
68
89
  });
69
90
  }
70
91
 
71
- private repoPath(): string {
92
+ private repoName(): string {
72
93
  if (!this.repo) throw new Error("github: repo not set — pass repo, or scope via config");
73
- return `/repos/${this.owner}/${this.repo}`;
94
+ return this.repo;
74
95
  }
75
96
 
76
97
  private requireAuth(): void {
@@ -79,52 +100,70 @@ export class GitHubRepository {
79
100
 
80
101
  async list(filter: ListFilter): Promise<Issue[]> {
81
102
  const limit = filter.limit && filter.limit > 0 ? filter.limit : 50;
82
- const params = new URLSearchParams({ per_page: String(limit), state: "all" });
83
- if (filter.status) params.set("state", mapStatusToGitHub(filter.status));
84
- if (filter.assignee) params.set("assignee", filter.assignee);
85
- if (filter.labels?.length) params.set("labels", filter.labels.join(","));
86
-
87
- const raw = (await this.http.get<GhIssue[]>(`${this.repoPath()}/issues?${params}`)) ?? [];
88
- return raw.filter((i) => !i.pull_request).map(toDomain);
103
+ const raw = await this.call((signal) =>
104
+ this.client.rest.issues.listForRepo({
105
+ owner: this.owner,
106
+ repo: this.repoName(),
107
+ per_page: limit,
108
+ state: filter.status ? mapStatusToGitHub(filter.status) : "all",
109
+ assignee: filter.assignee,
110
+ labels: filter.labels?.length ? filter.labels.join(",") : undefined,
111
+ request: { signal },
112
+ }),
113
+ );
114
+ return (raw as GhIssue[]).filter((i) => !i.pull_request).map(toDomain);
89
115
  }
90
116
 
91
117
  async get(key: string): Promise<Issue> {
92
- const number = parseIssueNumber(key);
93
- const raw = await this.http.get<GhIssue>(`${this.repoPath()}/issues/${number}`);
94
- if (!raw) throw new Error(`github: empty response for #${number}`);
95
- if (raw.pull_request) throw new Error(`github: #${number} is a pull request, not an issue`);
118
+ const issue_number = parseIssueNumber(key);
119
+ const raw = (await this.call((signal) =>
120
+ this.client.rest.issues.get({ owner: this.owner, repo: this.repoName(), issue_number, request: { signal } }),
121
+ )) as GhIssue;
122
+ if (raw.pull_request) throw new Error(`github: #${issue_number} is a pull request, not an issue`);
96
123
  return toDomain(raw);
97
124
  }
98
125
 
99
126
  async create(input: CreateInput): Promise<Issue> {
100
127
  this.requireAuth();
101
- const body: Record<string, unknown> = { title: input.title, body: input.description ?? "" };
102
- if (input.labels?.length) body.labels = input.labels;
103
- if (input.assignee) body.assignees = [input.assignee];
104
- const raw = await this.http.post<GhIssue>(`${this.repoPath()}/issues`, body);
105
- if (!raw) throw new Error("github: create returned no body");
128
+ const raw = (await this.call((signal) =>
129
+ this.client.rest.issues.create({
130
+ owner: this.owner,
131
+ repo: this.repoName(),
132
+ title: input.title,
133
+ body: input.description ?? "",
134
+ labels: input.labels?.length ? input.labels : undefined,
135
+ assignees: input.assignee ? [input.assignee] : undefined,
136
+ request: { signal },
137
+ }),
138
+ )) as GhIssue;
106
139
  return toDomain(raw);
107
140
  }
108
141
 
109
142
  async update(key: string, input: UpdateInput): Promise<Issue> {
110
143
  this.requireAuth();
111
- const number = parseIssueNumber(key);
112
- const body: Record<string, unknown> = {};
113
- if (input.title !== undefined) body.title = input.title;
114
- if (input.description !== undefined) body.body = input.description;
115
- if (input.status !== undefined) body.state = mapStatusToGitHub(input.status);
116
- if (input.labels !== undefined) body.labels = input.labels;
117
- if (input.assignee !== undefined) body.assignees = input.assignee ? [input.assignee] : [];
118
- const raw = await this.http.patch<GhIssue>(`${this.repoPath()}/issues/${number}`, body);
119
- if (!raw) throw new Error("github: update returned no body");
144
+ const issue_number = parseIssueNumber(key);
145
+ const raw = (await this.call((signal) =>
146
+ this.client.rest.issues.update({
147
+ owner: this.owner,
148
+ repo: this.repoName(),
149
+ issue_number,
150
+ title: input.title,
151
+ body: input.description,
152
+ state: input.status !== undefined ? mapStatusToGitHub(input.status) : undefined,
153
+ labels: input.labels,
154
+ assignees: input.assignee !== undefined ? (input.assignee ? [input.assignee] : []) : undefined,
155
+ request: { signal },
156
+ }),
157
+ )) as GhIssue;
120
158
  return toDomain(raw);
121
159
  }
122
160
 
123
161
  async search(query: string, limit = 50): Promise<Issue[]> {
124
162
  const scope = this.repo ? `repo:${this.owner}/${this.repo}` : `org:${this.owner}`;
125
- const q = encodeURIComponent(`${scope} ${query}`);
126
- const result = await this.http.get<{ items: GhIssue[] }>(`/search/issues?q=${q}&per_page=${limit}`);
127
- return (result?.items ?? []).filter((i) => !i.pull_request).map(toDomain);
163
+ const result = (await this.call((signal) =>
164
+ this.client.rest.search.issuesAndPullRequests({ q: `${scope} ${query}`, per_page: limit, request: { signal } }),
165
+ )) as { items: GhIssue[] };
166
+ return result.items.filter((i) => !i.pull_request).map(toDomain);
128
167
  }
129
168
 
130
169
  // GitHub has no native sub-issue relationship exposed via REST v3.
@@ -133,24 +172,60 @@ export class GitHubRepository {
133
172
  }
134
173
 
135
174
  async listComments(key: string): Promise<Comment[]> {
136
- const number = parseIssueNumber(key);
137
- const raw = (await this.http.get<GhComment[]>(`${this.repoPath()}/issues/${number}/comments`)) ?? [];
175
+ const issue_number = parseIssueNumber(key);
176
+ const raw = (await this.call((signal) =>
177
+ this.client.rest.issues.listComments({ owner: this.owner, repo: this.repoName(), issue_number, request: { signal } }),
178
+ )) as GhComment[];
138
179
  return raw.map(commentToDomain);
139
180
  }
140
181
 
141
182
  async addComment(key: string, body: string): Promise<Comment> {
142
183
  this.requireAuth();
143
- const number = parseIssueNumber(key);
144
- const raw = await this.http.post<GhComment>(`${this.repoPath()}/issues/${number}/comments`, { body });
145
- if (!raw) throw new Error("github: add comment returned no body");
184
+ const issue_number = parseIssueNumber(key);
185
+ const raw = (await this.call((signal) =>
186
+ this.client.rest.issues.createComment({ owner: this.owner, repo: this.repoName(), issue_number, body, request: { signal } }),
187
+ )) as GhComment;
146
188
  return commentToDomain(raw);
147
189
  }
190
+
191
+ /**
192
+ * Runs an octokit call, unwraps `.data`, and maps RequestError onto this
193
+ * project's shared error taxonomy. Uses a plain AbortController + setTimeout
194
+ * (not AbortSignal.timeout()) specifically so the timer can be cleared the
195
+ * moment the call settles, matching the old hand-rolled HttpClient's
196
+ * finally-block discipline -- AbortSignal.timeout() has no way to cancel
197
+ * early once created, and confirmed for real that letting it linger shows
198
+ * up as measurable delay (each call leaves a live timer sitting in the
199
+ * event loop until it eventually fires). With retry/throttling disabled
200
+ * above, a stalled connection or rate-limit response now fails predictably
201
+ * within `timeoutMs` instead of hanging.
202
+ */
203
+ private async call<T>(fn: (signal: AbortSignal) => Promise<{ data: T }>): Promise<T> {
204
+ const controller = new AbortController();
205
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
206
+ try {
207
+ const res = await fn(controller.signal);
208
+ return res.data;
209
+ } catch (err) {
210
+ if (err instanceof RequestError) {
211
+ if (err.status === 404) throw new IssueNotFoundError("github", err.request.url);
212
+ throw new ApiError("github", err.request.method, err.request.url, err.status, redact(err.message));
213
+ }
214
+ throw err;
215
+ } finally {
216
+ clearTimeout(timer);
217
+ }
218
+ }
148
219
  }
149
220
 
150
- function parseIssueNumber(key: string): string {
221
+ function redact(text: string): string {
222
+ return text.replace(/"(token|password|secret|api_key|authorization)"\s*:\s*"[^"]*"/gi, '"$1":"[redacted]"').slice(0, 2000);
223
+ }
224
+
225
+ function parseIssueNumber(key: string): number {
151
226
  const stripped = key.replace(/^#/, "");
152
227
  const idx = stripped.lastIndexOf("#");
153
- return idx >= 0 ? stripped.slice(idx + 1) : stripped;
228
+ return Number(idx >= 0 ? stripped.slice(idx + 1) : stripped);
154
229
  }
155
230
 
156
231
  function mapStatusToGitHub(status: Status): "open" | "closed" {
@@ -161,9 +236,13 @@ function mapStatusFromGitHub(state: string): Status {
161
236
  return state.toLowerCase() === "closed" ? "done" : "todo";
162
237
  }
163
238
 
164
- function priorityFromLabels(labels: GhLabel[]): ReturnType<typeof parsePriority> {
239
+ function labelName(label: GhLabel | string): string {
240
+ return typeof label === "string" ? label : label.name;
241
+ }
242
+
243
+ function priorityFromLabels(labels: (GhLabel | string)[]): ReturnType<typeof parsePriority> {
165
244
  for (const l of labels) {
166
- const lower = l.name.toLowerCase();
245
+ const lower = labelName(l).toLowerCase();
167
246
  if (lower.includes("urgent") || lower.includes("critical")) return "urgent";
168
247
  if (lower.includes("high")) return "high";
169
248
  if (lower.includes("medium")) return "medium";
@@ -182,7 +261,7 @@ function toDomain(gh: GhIssue): Issue {
182
261
  status: mapStatusFromGitHub(gh.state),
183
262
  rawStatus: gh.state,
184
263
  priority: priorityFromLabels(gh.labels ?? []),
185
- labels: gh.labels?.length ? gh.labels.map((l) => l.name) : undefined,
264
+ labels: gh.labels?.length ? gh.labels.map(labelName) : undefined,
186
265
  assignee: gh.assignee?.login,
187
266
  url: gh.html_url,
188
267
  createdAt: gh.created_at,
@@ -193,7 +272,7 @@ function toDomain(gh: GhIssue): Issue {
193
272
  function commentToDomain(c: GhComment): Comment {
194
273
  return {
195
274
  id: String(c.id),
196
- body: c.body,
275
+ body: c.body ?? "",
197
276
  author: c.user?.login,
198
277
  createdAt: c.created_at,
199
278
  updatedAt: c.updated_at,
@@ -1,14 +1,21 @@
1
1
  /**
2
2
  * GitLab adapter — driven implementation of IssueRepository/CommentCapable against
3
- * the GitLab REST API v4 (docs: https://docs.gitlab.com/api/issues/).
4
- * Self-hosted base URLs are validated to reject SSRF-prone targets (private/loopback
5
- * IPs, non-HTTPS non-localhost) before any request is made.
3
+ * the GitLab REST API v4, via @gitbeaker/rest (github.com/jdalrymple/gitbeaker)
4
+ * rather than a hand-rolled HTTP client: a mature, actively-maintained,
5
+ * TypeScript-native GitLab SDK supporting both gitlab.com and self-managed
6
+ * instances. Its typed `assignee_ids: number[]` (not a username) matches GitLab's
7
+ * real write contract exactly — a class of bug this adapter used to have (see
8
+ * RESEARCH.md): assignee was silently dropped in update() because a hand-rolled
9
+ * body never resolved a username to the numeric ID GitLab's API actually requires.
10
+ * Self-hosted base URLs are still validated to reject SSRF-prone targets before
11
+ * any request is made.
6
12
  */
13
+ import { Gitlab } from "@gitbeaker/rest";
14
+ import { GitbeakerRequestError, type RequesterType, type ResourceOptions } from "@gitbeaker/requester-utils";
7
15
  import { isIP } from "node:net";
8
16
  import type { Comment, CreateInput, Issue, ListFilter, Status, UpdateInput } from "../domain/issue.js";
9
17
  import { parsePriority } from "../domain/issue.js";
10
- import { AuthRequiredError, InvalidUrlError } from "./errors.js";
11
- import { type FetchLike, HttpClient } from "./http.js";
18
+ import { ApiError, AuthRequiredError, InvalidUrlError, IssueNotFoundError } from "./errors.js";
12
19
 
13
20
  export interface GitLabOptions {
14
21
  projectId: string;
@@ -20,10 +27,13 @@ export interface GitLabOptions {
20
27
  */
21
28
  tokenType?: "private" | "oauth";
22
29
  baseUrl?: string;
23
- fetchImpl?: FetchLike;
30
+ timeoutMs?: number;
31
+ /** Injected in tests instead of a real network call — see @gitbeaker/requester-utils' requesterFn. */
32
+ requesterFn?: (resourceOptions: ResourceOptions) => RequesterType;
24
33
  }
25
34
 
26
35
  interface GlUser {
36
+ id: number;
27
37
  username: string;
28
38
  name: string;
29
39
  }
@@ -49,10 +59,11 @@ interface GlNote {
49
59
  }
50
60
 
51
61
  const DEFAULT_URL = "https://gitlab.com";
62
+ const DEFAULT_TIMEOUT_MS = 30_000;
52
63
 
53
64
  export class GitLabRepository {
54
65
  readonly name: string;
55
- private readonly http: HttpClient;
66
+ private readonly client: InstanceType<typeof Gitlab>;
56
67
  private readonly projectId: string;
57
68
  private readonly readOnly: boolean;
58
69
 
@@ -61,17 +72,20 @@ export class GitLabRepository {
61
72
  const baseUrl = opts.baseUrl?.trim() || DEFAULT_URL;
62
73
  validateUrl(baseUrl);
63
74
  this.name = name;
64
- this.projectId = encodeURIComponent(opts.projectId);
75
+ this.projectId = opts.projectId;
65
76
  this.readOnly = !opts.token;
66
- this.http = new HttpClient({
67
- baseUrl,
68
- backend: "gitlab",
69
- fetchImpl: opts.fetchImpl,
70
- headers: opts.token
77
+ this.client = new Gitlab({
78
+ host: baseUrl,
79
+ // gitbeaker generates a fresh AbortSignal.timeout() per request internally when this
80
+ // is set (confirmed by reading its source), so a stalled call fails predictably
81
+ // instead of hanging -- same class of gap the octokit adapter needed a manual fix for.
82
+ queryTimeout: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
83
+ ...(opts.token
71
84
  ? opts.tokenType === "oauth"
72
- ? { Authorization: `Bearer ${opts.token}` }
73
- : { "PRIVATE-TOKEN": opts.token }
74
- : {},
85
+ ? { oauthToken: opts.token }
86
+ : { token: opts.token }
87
+ : {}),
88
+ ...(opts.requesterFn ? { requesterFn: opts.requesterFn } : {}),
75
89
  });
76
90
  }
77
91
 
@@ -81,47 +95,56 @@ export class GitLabRepository {
81
95
 
82
96
  async list(filter: ListFilter): Promise<Issue[]> {
83
97
  const limit = filter.limit && filter.limit > 0 ? filter.limit : 50;
84
- const params = new URLSearchParams({ per_page: String(limit) });
85
- if (filter.status) params.set("state", mapStatusToGitLab(filter.status));
86
- if (filter.assignee) params.set("assignee_username", filter.assignee);
87
- if (filter.labels?.length) params.set("labels", filter.labels.join(","));
88
-
89
- const raw = (await this.http.get<GlIssue[]>(`/api/v4/projects/${this.projectId}/issues?${params}`)) ?? [];
98
+ const raw = await this.call<GlIssue[]>(() =>
99
+ this.client.Issues.all({
100
+ projectId: this.projectId,
101
+ perPage: limit,
102
+ state: filter.status ? mapStatusToGitLab(filter.status) : undefined,
103
+ assigneeUsername: filter.assignee ? [filter.assignee] : undefined,
104
+ labels: filter.labels?.length ? filter.labels.join(",") : undefined,
105
+ }),
106
+ );
90
107
  return raw.map(toDomain);
91
108
  }
92
109
 
93
110
  async get(key: string): Promise<Issue> {
94
111
  const iid = parseIid(key);
95
- const raw = await this.http.get<GlIssue>(`/api/v4/projects/${this.projectId}/issues/${iid}`);
96
- if (!raw) throw new Error(`gitlab: empty response for #${iid}`);
112
+ const raw = await this.call<GlIssue>(() => this.client.Issues.show(iid, { projectId: this.projectId }));
97
113
  return toDomain(raw);
98
114
  }
99
115
 
100
116
  async create(input: CreateInput): Promise<Issue> {
101
117
  this.requireAuth();
102
- const body: Record<string, unknown> = { title: input.title, description: input.description ?? "" };
103
- if (input.labels?.length) body.labels = input.labels.join(",");
104
- const raw = await this.http.post<GlIssue>(`/api/v4/projects/${this.projectId}/issues`, body);
105
- if (!raw) throw new Error("gitlab: create returned no body");
118
+ const assigneeIds = input.assignee ? [await this.resolveUserId(input.assignee)] : undefined;
119
+ const raw = await this.call<GlIssue>(() =>
120
+ this.client.Issues.create(this.projectId, input.title, {
121
+ description: input.description ?? "",
122
+ labels: input.labels?.length ? input.labels.join(",") : undefined,
123
+ assigneeIds,
124
+ }),
125
+ );
106
126
  return toDomain(raw);
107
127
  }
108
128
 
109
129
  async update(key: string, input: UpdateInput): Promise<Issue> {
110
130
  this.requireAuth();
111
131
  const iid = parseIid(key);
112
- const body: Record<string, unknown> = {};
113
- if (input.title !== undefined) body.title = input.title;
114
- if (input.description !== undefined) body.description = input.description;
115
- if (input.status !== undefined) body.state_event = mapStatusEventToGitLab(input.status);
116
- if (input.labels !== undefined) body.labels = input.labels.join(",");
117
- const raw = await this.http.put<GlIssue>(`/api/v4/projects/${this.projectId}/issues/${iid}`, body);
118
- if (!raw) throw new Error("gitlab: update returned no body");
132
+ const options: Record<string, unknown> = {};
133
+ if (input.title !== undefined) options.title = input.title;
134
+ if (input.description !== undefined) options.description = input.description;
135
+ if (input.status !== undefined) options.stateEvent = mapStatusEventToGitLab(input.status);
136
+ if (input.labels !== undefined) options.labels = input.labels.join(",");
137
+ if (input.assignee !== undefined) {
138
+ options.assigneeIds = input.assignee ? [await this.resolveUserId(input.assignee)] : [];
139
+ }
140
+ const raw = await this.call<GlIssue>(() => this.client.Issues.edit(this.projectId, iid, options));
119
141
  return toDomain(raw);
120
142
  }
121
143
 
122
144
  async search(query: string, limit = 50): Promise<Issue[]> {
123
- const params = new URLSearchParams({ search: query, per_page: String(limit) });
124
- const raw = (await this.http.get<GlIssue[]>(`/api/v4/projects/${this.projectId}/issues?${params}`)) ?? [];
145
+ const raw = await this.call<GlIssue[]>(() =>
146
+ this.client.Issues.all({ projectId: this.projectId, search: query, perPage: limit }),
147
+ );
125
148
  return raw.map(toDomain);
126
149
  }
127
150
 
@@ -132,21 +155,54 @@ export class GitLabRepository {
132
155
 
133
156
  async listComments(key: string): Promise<Comment[]> {
134
157
  const iid = parseIid(key);
135
- const raw = (await this.http.get<GlNote[]>(`/api/v4/projects/${this.projectId}/issues/${iid}/notes`)) ?? [];
158
+ const raw = await this.call<GlNote[]>(() => this.client.IssueNotes.all(this.projectId, iid));
136
159
  return raw.map(noteToDomain);
137
160
  }
138
161
 
139
162
  async addComment(key: string, body: string): Promise<Comment> {
140
163
  this.requireAuth();
141
164
  const iid = parseIid(key);
142
- const raw = await this.http.post<GlNote>(`/api/v4/projects/${this.projectId}/issues/${iid}/notes`, { body });
143
- if (!raw) throw new Error("gitlab: add comment returned no body");
165
+ const raw = await this.call<GlNote>(() => this.client.IssueNotes.create(this.projectId, iid, body));
144
166
  return noteToDomain(raw);
145
167
  }
168
+
169
+ /**
170
+ * GitLab's assignee write contract takes a numeric user ID, not a username
171
+ * (`assignee_ids: number[]`, confirmed against @gitbeaker/rest's generated
172
+ * types) — the exact gap the old hand-rolled adapter had (never resolved
173
+ * this, never even attempted to set assignee at all). GitLab's own username
174
+ * filter is an exact match, but we still confirm the returned user's
175
+ * username matches exactly before trusting its id.
176
+ */
177
+ private async resolveUserId(username: string): Promise<number> {
178
+ const users = await this.call<GlUser[]>(() => this.client.Users.all({ username }));
179
+ const match = users.find((u) => u.username === username);
180
+ if (!match) throw new Error(`gitlab: no user found with username "${username}"`);
181
+ return match.id;
182
+ }
183
+
184
+ /** Runs a gitbeaker call and maps GitbeakerRequestError onto this project's shared error taxonomy. */
185
+ private async call<T>(fn: () => Promise<unknown>): Promise<T> {
186
+ try {
187
+ return (await fn()) as T;
188
+ } catch (err) {
189
+ if (err instanceof GitbeakerRequestError) {
190
+ const status = err.cause?.response?.status ?? 500;
191
+ const url = err.cause?.request?.url ?? "";
192
+ if (status === 404) throw new IssueNotFoundError("gitlab", url);
193
+ throw new ApiError("gitlab", err.cause?.request?.method ?? "?", url, status, redact(err.message));
194
+ }
195
+ throw err;
196
+ }
197
+ }
198
+ }
199
+
200
+ function redact(text: string): string {
201
+ return text.replace(/"(token|password|secret|api_key|authorization)"\s*:\s*"[^"]*"/gi, '"$1":"[redacted]"').slice(0, 2000);
146
202
  }
147
203
 
148
- function parseIid(key: string): string {
149
- return key.replace(/^#/, "");
204
+ function parseIid(key: string): number {
205
+ return Number(key.replace(/^#/, ""));
150
206
  }
151
207
 
152
208
  function mapStatusToGitLab(status: Status): "opened" | "closed" {