@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 +48 -15
- package/package.json +9 -6
- package/src/adapters/github.ts +135 -56
- package/src/adapters/gitlab.ts +98 -42
- package/src/adapters/jira.ts +128 -46
- package/src/application/service.ts +12 -1
- package/src/auth/gh-cli.ts +46 -0
- package/src/cli/index.ts +10 -1
- package/src/config/config.ts +80 -19
- package/src/daemon/bootstrap.ts +21 -5
- package/src/daemon/main.ts +1 -1
- package/src/daemon/ops.ts +1 -1
- package/src/daemon/poller.ts +8 -2
- package/src/domain/issue.ts +4 -0
- package/src/index.ts +2 -0
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
|
-
|
|
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
|
-
{ "
|
|
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
|
|
211
|
-
bun test
|
|
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.
|
|
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
|
|
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.
|
|
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": {
|
package/src/adapters/github.ts
CHANGED
|
@@ -1,19 +1,39 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* GitHub adapter — driven implementation of IssueRepository/CommentCapable against
|
|
3
|
-
* the GitHub REST API v3 (
|
|
4
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
92
|
+
private repoName(): string {
|
|
72
93
|
if (!this.repo) throw new Error("github: repo not set — pass repo, or scope via config");
|
|
73
|
-
return
|
|
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
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
93
|
-
const raw = await this.
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
126
|
-
|
|
127
|
-
|
|
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
|
|
137
|
-
const raw = (await this.
|
|
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
|
|
144
|
-
const raw = await this.
|
|
145
|
-
|
|
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
|
|
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
|
|
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.
|
|
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(
|
|
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,
|
package/src/adapters/gitlab.ts
CHANGED
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* GitLab adapter — driven implementation of IssueRepository/CommentCapable against
|
|
3
|
-
* the GitLab REST API v4 (
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
|
|
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
|
|
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 =
|
|
75
|
+
this.projectId = opts.projectId;
|
|
65
76
|
this.readOnly = !opts.token;
|
|
66
|
-
this.
|
|
67
|
-
baseUrl,
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
? {
|
|
73
|
-
: {
|
|
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
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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.
|
|
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
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
113
|
-
if (input.title !== undefined)
|
|
114
|
-
if (input.description !== undefined)
|
|
115
|
-
if (input.status !== undefined)
|
|
116
|
-
if (input.labels !== undefined)
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
124
|
-
|
|
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 =
|
|
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.
|
|
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):
|
|
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" {
|