@danypops/tickets 0.2.0 → 0.2.1
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 +9 -15
- package/package.json +8 -6
- package/src/adapters/github.ts +135 -56
- package/src/adapters/gitlab.ts +98 -42
- package/src/adapters/jira.ts +128 -46
- package/src/auth/enigma-source.ts +65 -0
- package/src/cli/index.ts +0 -0
- package/src/config/config.ts +30 -19
- package/src/daemon/bootstrap.ts +2 -2
- package/src/daemon/main.ts +1 -1
- package/src/domain/issue.ts +4 -0
- package/src/index.ts +2 -0
package/README.md
CHANGED
|
@@ -165,7 +165,7 @@ up the new credential — `buildRepositories()` runs once at daemon startup.
|
|
|
165
165
|
|
|
166
166
|
## The `pi-tickets` extension
|
|
167
167
|
|
|
168
|
-
|
|
168
|
+
Published as `@danypops/pi-tickets`. `../../extensions/pi-tickets/` (this repo's workspace member) registers a single `tickets` tool for
|
|
169
169
|
[pi](https://github.com/badlogic/pi) with one action per CLI command (`list`,
|
|
170
170
|
`get`, `create`, `update`, `search`, `children`, `comments`, `comment_add`,
|
|
171
171
|
`backends`, `ledger_search`, `ledger_stats`, `focus_set`, `focus_get`,
|
|
@@ -189,27 +189,21 @@ shows the current focus at all times, refreshed on session start and after
|
|
|
189
189
|
every `tickets` tool call — so a focus the LLM sets via `focus_set` mid-
|
|
190
190
|
conversation shows up in the footer too, and vice versa.
|
|
191
191
|
|
|
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`:
|
|
192
|
+
To use it, add it to pi's `settings.json`:
|
|
201
193
|
|
|
202
194
|
```json
|
|
203
|
-
{ "
|
|
195
|
+
{ "packages": ["npm:@danypops/pi-tickets"] }
|
|
204
196
|
```
|
|
205
197
|
|
|
198
|
+
Or, for local development against this monorepo, point at the workspace
|
|
199
|
+
member directory instead: `{ "packages": ["/path/to/tickets/extensions/pi-tickets"] }`.
|
|
200
|
+
|
|
206
201
|
## Development
|
|
207
202
|
|
|
208
203
|
```bash
|
|
209
|
-
bun install
|
|
210
|
-
bun run typecheck
|
|
211
|
-
bun test
|
|
212
|
-
cd extensions/pi-tickets && bun install && bun test && bun run typecheck
|
|
204
|
+
bun install # from the repo root -- links both workspace members
|
|
205
|
+
bun run typecheck # both packages
|
|
206
|
+
bun test # both packages
|
|
213
207
|
```
|
|
214
208
|
|
|
215
209
|
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.
|
|
3
|
+
"version": "0.2.1",
|
|
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,15 @@
|
|
|
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
|
+
"@gitbeaker/rest": "^43.8.0",
|
|
30
29
|
"commander": "^12.1.0",
|
|
30
|
+
"jira.js": "^5.4.0",
|
|
31
|
+
"octokit": "^5.0.5",
|
|
31
32
|
"yaml": "^2.6.0"
|
|
32
33
|
},
|
|
33
34
|
"devDependencies": {
|
|
@@ -50,7 +51,8 @@
|
|
|
50
51
|
],
|
|
51
52
|
"repository": {
|
|
52
53
|
"type": "git",
|
|
53
|
-
"url": "git+https://github.com/DanyPops/tickets.git"
|
|
54
|
+
"url": "git+https://github.com/DanyPops/tickets.git",
|
|
55
|
+
"directory": "packages/tickets"
|
|
54
56
|
},
|
|
55
57
|
"homepage": "https://github.com/DanyPops/tickets#readme",
|
|
56
58
|
"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" {
|
package/src/adapters/jira.ts
CHANGED
|
@@ -1,36 +1,52 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Jira adapter — driven implementation of IssueRepository/CommentCapable against
|
|
3
|
-
* Jira Cloud
|
|
2
|
+
* Jira adapter — driven implementation of IssueRepository/CommentCapable against
|
|
3
|
+
* the Jira Cloud REST API v2, via jira.js's Version2Client (github.com/MrRefactoring/jira.js)
|
|
4
|
+
* rather than a hand-rolled HTTP client: a mature, actively-maintained,
|
|
5
|
+
* TypeScript-native client generated directly from Atlassian's own OpenAPI spec.
|
|
4
6
|
* Status changes go through Jira's workflow transitions, not a direct field PUT —
|
|
5
7
|
* Jira statuses are workflow-owned, so we resolve the transition whose name matches
|
|
6
8
|
* the mapped target status and post to it.
|
|
9
|
+
*
|
|
10
|
+
* NOTE: assignee is deliberately NOT handled in create()/update() here — out of
|
|
11
|
+
* scope for this migration per explicit user direction, even though the mature
|
|
12
|
+
* client's `UserDetails.accountId` typing would make the fix straightforward
|
|
13
|
+
* (see RESEARCH.md for the full analysis of the bug this leaves unfixed).
|
|
7
14
|
*/
|
|
15
|
+
import { Version2Client } from "jira.js";
|
|
16
|
+
import type { HttpException } from "jira.js";
|
|
17
|
+
import type { AxiosAdapter } from "axios";
|
|
8
18
|
import type { Comment, CreateInput, Issue, ListFilter, Status, UpdateInput } from "../domain/issue.js";
|
|
9
19
|
import { parsePriority } from "../domain/issue.js";
|
|
10
|
-
import {
|
|
20
|
+
import { ApiError, IssueNotFoundError } from "./errors.js";
|
|
11
21
|
|
|
12
22
|
/**
|
|
13
23
|
* Basic-auth mode (email + API token) hits the tenant's own *.atlassian.net
|
|
14
24
|
* domain directly. OAuth 2.0 (3LO) mode (accessToken + cloudId, see
|
|
15
25
|
* auth/jira-oauth.ts) instead goes through api.atlassian.com/ex/jira/{cloudId}
|
|
16
26
|
* with a Bearer token — Atlassian does not accept 3LO tokens against the
|
|
17
|
-
* tenant domain directly.
|
|
27
|
+
* tenant domain directly; jira.js resolves that gateway routing itself when
|
|
28
|
+
* given `authentication.oauth2`. Exactly one of the two auth modes must be given.
|
|
18
29
|
*/
|
|
19
30
|
export type JiraOptions = JiraBasicAuthOptions | JiraOAuthOptions;
|
|
20
31
|
|
|
32
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
33
|
+
|
|
21
34
|
export interface JiraBasicAuthOptions {
|
|
22
35
|
baseUrl: string;
|
|
23
36
|
email: string;
|
|
24
37
|
token: string;
|
|
25
38
|
project?: string;
|
|
26
|
-
|
|
39
|
+
timeoutMs?: number;
|
|
40
|
+
/** Injected in tests instead of a real network call — see axios's AxiosRequestConfig.adapter. */
|
|
41
|
+
axiosAdapter?: AxiosAdapter;
|
|
27
42
|
}
|
|
28
43
|
|
|
29
44
|
export interface JiraOAuthOptions {
|
|
30
45
|
accessToken: string;
|
|
31
46
|
cloudId: string;
|
|
32
47
|
project?: string;
|
|
33
|
-
|
|
48
|
+
timeoutMs?: number;
|
|
49
|
+
axiosAdapter?: AxiosAdapter;
|
|
34
50
|
}
|
|
35
51
|
|
|
36
52
|
function isOAuthOptions(opts: JiraOptions): opts is JiraOAuthOptions {
|
|
@@ -58,22 +74,26 @@ interface JiraIssue {
|
|
|
58
74
|
self: string;
|
|
59
75
|
fields: JiraIssueFields;
|
|
60
76
|
}
|
|
61
|
-
interface JiraTransition {
|
|
62
|
-
id: string;
|
|
63
|
-
name: string;
|
|
64
|
-
}
|
|
65
77
|
interface JiraComment {
|
|
66
|
-
id
|
|
67
|
-
|
|
68
|
-
created
|
|
69
|
-
updated
|
|
70
|
-
author?: { displayName
|
|
78
|
+
id?: string;
|
|
79
|
+
comment?: string;
|
|
80
|
+
created?: string;
|
|
81
|
+
updated?: string;
|
|
82
|
+
author?: { displayName?: string };
|
|
83
|
+
}
|
|
84
|
+
interface JiraFieldDetails {
|
|
85
|
+
id?: string;
|
|
86
|
+
name?: string;
|
|
87
|
+
custom?: boolean;
|
|
88
|
+
schema?: { type: string; items?: string };
|
|
71
89
|
}
|
|
72
90
|
|
|
73
91
|
export class JiraRepository {
|
|
74
92
|
readonly name: string;
|
|
75
|
-
private readonly
|
|
93
|
+
private readonly client: Version2Client;
|
|
76
94
|
private readonly project?: string;
|
|
95
|
+
/** display name (lowercased) -> { fieldId, schema type/items }, populated lazily from client.issueFields.getFields(). */
|
|
96
|
+
private customFieldCache?: Map<string, { id: string; type: string; items?: string }>;
|
|
77
97
|
|
|
78
98
|
constructor(name: string, opts: JiraOptions) {
|
|
79
99
|
this.name = name;
|
|
@@ -81,23 +101,23 @@ export class JiraRepository {
|
|
|
81
101
|
|
|
82
102
|
if (isOAuthOptions(opts)) {
|
|
83
103
|
if (!opts.accessToken || !opts.cloudId) throw new Error("jira: accessToken and cloudId are required for OAuth mode");
|
|
84
|
-
this.
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
104
|
+
this.client = new Version2Client({
|
|
105
|
+
authentication: { oauth2: { accessToken: opts.accessToken, cloudId: opts.cloudId } },
|
|
106
|
+
// axios's own native, tested timeout handling -- a stalled call fails predictably
|
|
107
|
+
// instead of hanging (see RESEARCH.md for the octokit throttling-plugin hang this
|
|
108
|
+
// migration found and fixed; jira.js/axios has no equivalent auto-retry-and-wait
|
|
109
|
+
// behavior by default, but had no explicit timeout either until now).
|
|
110
|
+
baseRequestConfig: { timeout: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, ...(opts.axiosAdapter ? { adapter: opts.axiosAdapter } : {}) },
|
|
89
111
|
});
|
|
90
112
|
return;
|
|
91
113
|
}
|
|
92
114
|
|
|
93
115
|
if (!opts.baseUrl) throw new Error("jira: baseUrl is required");
|
|
94
116
|
if (!opts.email || !opts.token) throw new Error("jira: email and token are required");
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
fetchImpl: opts.fetchImpl,
|
|
100
|
-
headers: { Authorization: `Basic ${basic}` },
|
|
117
|
+
this.client = new Version2Client({
|
|
118
|
+
host: opts.baseUrl,
|
|
119
|
+
authentication: { basic: { email: opts.email, apiToken: opts.token } },
|
|
120
|
+
baseRequestConfig: { timeout: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, ...(opts.axiosAdapter ? { adapter: opts.axiosAdapter } : {}) },
|
|
101
121
|
});
|
|
102
122
|
}
|
|
103
123
|
|
|
@@ -113,11 +133,25 @@ export class JiraRepository {
|
|
|
113
133
|
}
|
|
114
134
|
|
|
115
135
|
async get(key: string): Promise<Issue> {
|
|
116
|
-
const raw = await this.
|
|
117
|
-
if (!raw) throw new Error(`jira: empty response for ${key}`);
|
|
136
|
+
const raw = await this.call<JiraIssue>(() => this.client.issues.getIssue({ issueIdOrKey: key }), key);
|
|
118
137
|
return toDomain(raw);
|
|
119
138
|
}
|
|
120
139
|
|
|
140
|
+
/** Runs a jira.js call and maps its HttpException onto this project's shared error taxonomy. */
|
|
141
|
+
private async call<T>(fn: () => Promise<unknown>, key?: string): Promise<T> {
|
|
142
|
+
try {
|
|
143
|
+
return (await fn()) as T;
|
|
144
|
+
} catch (err) {
|
|
145
|
+
const status = (err as Partial<HttpException>)?.status;
|
|
146
|
+
if (typeof status === "number") {
|
|
147
|
+
if (status === 404) throw new IssueNotFoundError("jira", key ?? "?");
|
|
148
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
149
|
+
throw new ApiError("jira", "?", key ?? "?", status, redact(message));
|
|
150
|
+
}
|
|
151
|
+
throw err;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
121
155
|
async create(input: CreateInput): Promise<Issue> {
|
|
122
156
|
const project = input.project ?? this.project;
|
|
123
157
|
if (!project) throw new Error("jira: project is required (pass project or set a default)");
|
|
@@ -132,8 +166,8 @@ export class JiraRepository {
|
|
|
132
166
|
if (input.priority && input.priority !== "none") {
|
|
133
167
|
fields.priority = { name: mapPriorityToJira(input.priority) };
|
|
134
168
|
}
|
|
135
|
-
|
|
136
|
-
|
|
169
|
+
if (input.customFields) await this.applyCustomFields(fields, input.customFields);
|
|
170
|
+
const result = await this.call<{ key: string }>(() => this.client.issues.createIssue({ fields } as never));
|
|
137
171
|
return this.get(result.key);
|
|
138
172
|
}
|
|
139
173
|
|
|
@@ -143,8 +177,10 @@ export class JiraRepository {
|
|
|
143
177
|
if (input.description !== undefined) fields.description = input.description;
|
|
144
178
|
if (input.priority !== undefined) fields.priority = { name: mapPriorityToJira(input.priority) };
|
|
145
179
|
if (input.labels !== undefined) fields.labels = input.labels;
|
|
180
|
+
if (input.customFields) await this.applyCustomFields(fields, input.customFields);
|
|
181
|
+
// assignee intentionally not handled here — see file header.
|
|
146
182
|
if (Object.keys(fields).length > 0) {
|
|
147
|
-
await this.
|
|
183
|
+
await this.call(() => this.client.issues.editIssue({ issueIdOrKey: key, fields }), key);
|
|
148
184
|
}
|
|
149
185
|
if (input.status !== undefined) {
|
|
150
186
|
await this.transitionTo(key, input.status, input.resolution);
|
|
@@ -163,28 +199,33 @@ export class JiraRepository {
|
|
|
163
199
|
}
|
|
164
200
|
|
|
165
201
|
async listComments(key: string): Promise<Comment[]> {
|
|
166
|
-
const result = await this.
|
|
202
|
+
const result = await this.call<{ comments?: JiraComment[] }>(
|
|
203
|
+
() => this.client.issueComments.getComments({ issueIdOrKey: key }),
|
|
204
|
+
key,
|
|
205
|
+
);
|
|
167
206
|
return (result?.comments ?? []).map(commentToDomain);
|
|
168
207
|
}
|
|
169
208
|
|
|
170
209
|
async addComment(key: string, body: string): Promise<Comment> {
|
|
171
|
-
const raw = await this.
|
|
172
|
-
|
|
210
|
+
const raw = await this.call<JiraComment>(
|
|
211
|
+
() => this.client.issueComments.addComment({ issueIdOrKey: key, comment: body }),
|
|
212
|
+
key,
|
|
213
|
+
);
|
|
173
214
|
return commentToDomain(raw);
|
|
174
215
|
}
|
|
175
216
|
|
|
176
217
|
private async searchJql(jql: string, limit: number): Promise<Issue[]> {
|
|
177
|
-
const result = await this.
|
|
178
|
-
jql,
|
|
179
|
-
|
|
180
|
-
});
|
|
218
|
+
const result = await this.call<{ issues?: JiraIssue[] }>(() =>
|
|
219
|
+
this.client.issueSearch.searchForIssuesUsingJqlPost({ jql, maxResults: limit }),
|
|
220
|
+
);
|
|
181
221
|
return (result?.issues ?? []).map(toDomain);
|
|
182
222
|
}
|
|
183
223
|
|
|
184
224
|
private async transitionTo(key: string, status: Status, resolution?: string): Promise<void> {
|
|
185
225
|
const target = mapStatusToJira(status);
|
|
186
|
-
const result = await this.
|
|
187
|
-
|
|
226
|
+
const result = await this.call<{ transitions?: { id: string; name: string }[] }>(
|
|
227
|
+
() => this.client.issues.getTransitions({ issueIdOrKey: key }),
|
|
228
|
+
key,
|
|
188
229
|
);
|
|
189
230
|
const transitions = result?.transitions ?? [];
|
|
190
231
|
const match = transitions.find((t) => t.name.toLowerCase() === target.toLowerCase());
|
|
@@ -192,10 +233,51 @@ export class JiraRepository {
|
|
|
192
233
|
const available = transitions.map((t) => t.name).join(", ");
|
|
193
234
|
throw new Error(`jira: no transition matching "${target}" (available: ${available})`);
|
|
194
235
|
}
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
|
|
236
|
+
const fields = resolution ? { resolution: { name: resolution } } : undefined;
|
|
237
|
+
await this.call(() => this.client.issues.doTransition({ issueIdOrKey: key, transition: { id: match.id }, fields }), key);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Resolves each custom field's display name to its `customfield_XXXXX` ID via
|
|
242
|
+
* client.issueFields.getFields() (GET /rest/api/2/field) -- not a hand-maintained
|
|
243
|
+
* map -- and coerces the given string value to the shape that field's own Jira
|
|
244
|
+
* schema type expects (a plain "option"/select field wants `{value}`, an array
|
|
245
|
+
* field splits on commas, everything else passes through as a raw string).
|
|
246
|
+
*/
|
|
247
|
+
private async applyCustomFields(fields: Record<string, unknown>, customFields: Record<string, string>): Promise<void> {
|
|
248
|
+
for (const [displayName, rawValue] of Object.entries(customFields)) {
|
|
249
|
+
const field = await this.resolveCustomField(displayName);
|
|
250
|
+
fields[field.id] = coerceCustomFieldValue(field, rawValue);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private async resolveCustomField(displayName: string): Promise<{ id: string; type: string; items?: string }> {
|
|
255
|
+
if (!this.customFieldCache) {
|
|
256
|
+
const all = await this.call<JiraFieldDetails[]>(() => this.client.issueFields.getFields());
|
|
257
|
+
this.customFieldCache = new Map(
|
|
258
|
+
all
|
|
259
|
+
.filter((f) => f.custom && f.id && f.name)
|
|
260
|
+
.map((f) => [f.name!.toLowerCase(), { id: f.id!, type: f.schema?.type ?? "string", items: f.schema?.items }]),
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
const field = this.customFieldCache.get(displayName.toLowerCase());
|
|
264
|
+
if (!field) throw new Error(`jira: unknown custom field "${displayName}"`);
|
|
265
|
+
return field;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function coerceCustomFieldValue(field: { type: string; items?: string }, rawValue: string): unknown {
|
|
270
|
+
if (field.type === "array") {
|
|
271
|
+
const parts = rawValue.split(",").map((v) => v.trim()).filter(Boolean);
|
|
272
|
+
return field.items === "option" ? parts.map((v) => ({ value: v })) : parts;
|
|
198
273
|
}
|
|
274
|
+
if (field.type === "option") return { value: rawValue };
|
|
275
|
+
if (field.type === "number") return Number(rawValue);
|
|
276
|
+
return rawValue;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function redact(text: string): string {
|
|
280
|
+
return text.replace(/"(token|password|secret|api_key|authorization)"\s*:\s*"[^"]*"/gi, '"$1":"[redacted]"').slice(0, 2000);
|
|
199
281
|
}
|
|
200
282
|
|
|
201
283
|
function jqlQuote(value: string): string {
|
|
@@ -302,8 +384,8 @@ function toDomain(j: JiraIssue): Issue {
|
|
|
302
384
|
|
|
303
385
|
function commentToDomain(c: JiraComment): Comment {
|
|
304
386
|
return {
|
|
305
|
-
id: c.id,
|
|
306
|
-
body: c.
|
|
387
|
+
id: c.id ?? "",
|
|
388
|
+
body: c.comment ?? "",
|
|
307
389
|
author: c.author?.displayName,
|
|
308
390
|
createdAt: c.created,
|
|
309
391
|
updatedAt: c.updated,
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional credential source: a running Enigma vault (github.com/DanyPops/enigma),
|
|
3
|
+
* if one happens to be configured on this machine. Purely additive — Tickets
|
|
4
|
+
* has never imported Enigma's package and never will; this talks to Enigma's
|
|
5
|
+
* loopback HTTP API using only @danypops/daemon-kit, which Tickets already
|
|
6
|
+
* depends on for its own daemon plumbing. Enigma's discovery contract is three
|
|
7
|
+
* stable, documented constants (its state-directory name and its handle/token
|
|
8
|
+
* filenames), not an import of Enigma's own source.
|
|
9
|
+
*
|
|
10
|
+
* Never creates Enigma's handle or token files — those are strictly Enigma's
|
|
11
|
+
* own job on its first boot. A consumer that could mint them would be a real
|
|
12
|
+
* security problem, not a convenience. Absence of either file means "Enigma
|
|
13
|
+
* isn't running or isn't configured for this backend," not an error: every
|
|
14
|
+
* failure path here resolves `undefined` rather than throwing, and the whole
|
|
15
|
+
* attempt is time-bounded so a slow or hung Enigma can never stall Tickets'
|
|
16
|
+
* own startup.
|
|
17
|
+
*/
|
|
18
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
19
|
+
import { readDaemonHandle, resolveDaemonPaths } from "@danypops/daemon-kit/paths";
|
|
20
|
+
import { createVaultClient, type RefreshableAccessToken } from "@danypops/daemon-kit/vault";
|
|
21
|
+
|
|
22
|
+
const ENIGMA_STATE_DIRECTORY_NAME = "enigma";
|
|
23
|
+
const ENIGMA_HANDLE_FILENAME = "handle.json";
|
|
24
|
+
const ENIGMA_TOKEN_FILENAME = "token";
|
|
25
|
+
const ENIGMA_LOOKUP_TIMEOUT_MS = 500;
|
|
26
|
+
|
|
27
|
+
export interface TryEnigmaCredentialEnv {
|
|
28
|
+
env?: Record<string, string | undefined>;
|
|
29
|
+
/** Injectable for tests; production default is the real fetch, bounded by AbortSignal.timeout. */
|
|
30
|
+
fetchImpl?: typeof fetch;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type TryEnigmaCredential = (backend: string, opts?: TryEnigmaCredentialEnv) => Promise<RefreshableAccessToken | undefined>;
|
|
34
|
+
|
|
35
|
+
export const tryEnigmaCredential: TryEnigmaCredential = async (backend, opts = {}) => {
|
|
36
|
+
const env = opts.env ?? process.env;
|
|
37
|
+
const paths = resolveDaemonPaths(
|
|
38
|
+
{ stateDirectoryName: ENIGMA_STATE_DIRECTORY_NAME, handleFilename: ENIGMA_HANDLE_FILENAME, tokenFilename: ENIGMA_TOKEN_FILENAME, databaseFilename: "", systemdUnitName: "" },
|
|
39
|
+
{ env },
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const handle = readDaemonHandle(paths.handle);
|
|
43
|
+
if (!handle) return undefined; // Enigma isn't running -- not an error, just not present
|
|
44
|
+
|
|
45
|
+
if (!existsSync(paths.token)) return undefined; // never ensureAuthToken here -- read-only, never mint Enigma's own token
|
|
46
|
+
let token: string;
|
|
47
|
+
try {
|
|
48
|
+
token = readFileSync(paths.token, "utf8").trim();
|
|
49
|
+
} catch {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
54
|
+
const client = createVaultClient({
|
|
55
|
+
baseUrl: `http://${handle.host}:${handle.port}`,
|
|
56
|
+
authToken: token,
|
|
57
|
+
fetchImpl: (url, init) => fetchImpl(url, { ...init, signal: AbortSignal.timeout(ENIGMA_LOOKUP_TIMEOUT_MS) }),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
return await client.getCredentials(backend);
|
|
62
|
+
} catch {
|
|
63
|
+
return undefined; // unreachable, timed out, or any other transport failure -- fall through silently
|
|
64
|
+
}
|
|
65
|
+
};
|
package/src/cli/index.ts
CHANGED
|
File without changes
|
package/src/config/config.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { GitLabRepository } from "../adapters/gitlab.js";
|
|
|
12
12
|
import { JiraRepository } from "../adapters/jira.js";
|
|
13
13
|
import type { IssueRepository } from "../ports/repository.js";
|
|
14
14
|
import { isTokenFresh, loadToken } from "../auth/token-store.js";
|
|
15
|
+
import { type TryEnigmaCredential, tryEnigmaCredential } from "../auth/enigma-source.js";
|
|
15
16
|
|
|
16
17
|
export interface BackendConfig {
|
|
17
18
|
/** Adapter type: "github" | "gitlab" | "jira". Falls back to the config key when omitted. */
|
|
@@ -55,19 +56,27 @@ function resolveToken(cfg: BackendConfig, env: NodeJS.ProcessEnv, envFallback: s
|
|
|
55
56
|
}
|
|
56
57
|
|
|
57
58
|
/**
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
59
|
+
* Resolution order, highest priority first: (1) a running Enigma vault, if
|
|
60
|
+
* one happens to be configured for this backend — entirely optional, never a
|
|
61
|
+
* hard dependency, and bounded so Tickets never waits long for it (see
|
|
62
|
+
* auth/enigma-source.ts); (2) a locally stored, still-fresh delegated OAuth
|
|
63
|
+
* token (see auth/token-store.ts, populated by `tickets auth login`); (3) a
|
|
64
|
+
* static config/env PAT. (1) is additive to the pre-Enigma precedence this
|
|
65
|
+
* project already followed for GitHub, GitLab, and Jira — see RESEARCH.md
|
|
66
|
+
* for the auth flows each backend actually supports (device flow for
|
|
67
|
+
* GitHub/GitLab, authorization code for Jira, which has no device flow or
|
|
68
|
+
* PKCE).
|
|
64
69
|
*/
|
|
65
|
-
function preferredAuth(
|
|
70
|
+
export async function preferredAuth(
|
|
66
71
|
name: string,
|
|
67
72
|
cfg: BackendConfig,
|
|
68
73
|
env: NodeJS.ProcessEnv,
|
|
69
74
|
envFallback: string,
|
|
70
|
-
|
|
75
|
+
tryEnigma: TryEnigmaCredential = tryEnigmaCredential,
|
|
76
|
+
): Promise<{ token: string | undefined; oauth: boolean; extra?: Record<string, string> }> {
|
|
77
|
+
const fromEnigma = await tryEnigma(name, { env });
|
|
78
|
+
if (fromEnigma) return { token: fromEnigma.accessToken, oauth: true, extra: fromEnigma.extra };
|
|
79
|
+
|
|
71
80
|
const stored = loadToken(name, { env });
|
|
72
81
|
if (stored && isTokenFresh(stored)) {
|
|
73
82
|
return { token: stored.accessToken, oauth: true, extra: stored.extra };
|
|
@@ -80,45 +89,47 @@ function preferredAuth(
|
|
|
80
89
|
* inferrable purely from environment variables when not present in the config file.
|
|
81
90
|
* Config-file entries take precedence over bare env-var inference for the same name.
|
|
82
91
|
*/
|
|
83
|
-
export function buildRepositories(
|
|
92
|
+
export async function buildRepositories(
|
|
84
93
|
config: Config,
|
|
85
94
|
env: NodeJS.ProcessEnv = process.env,
|
|
86
|
-
|
|
95
|
+
tryEnigma: TryEnigmaCredential = tryEnigmaCredential,
|
|
96
|
+
): Promise<Record<string, IssueRepository>> {
|
|
87
97
|
const repos: Record<string, IssueRepository> = {};
|
|
88
98
|
|
|
89
99
|
for (const [name, cfg] of Object.entries(config.backends)) {
|
|
90
100
|
const type = cfg.type ?? name;
|
|
91
|
-
const repo = createRepository(name, type, cfg, env);
|
|
101
|
+
const repo = await createRepository(name, type, cfg, env, tryEnigma);
|
|
92
102
|
if (repo) repos[name] = repo;
|
|
93
103
|
}
|
|
94
104
|
|
|
95
105
|
if (!repos.github && (env.GITHUB_OWNER || env.GITHUB_TOKEN)) {
|
|
96
|
-
const repo = createRepository("github", "github", {}, env);
|
|
106
|
+
const repo = await createRepository("github", "github", {}, env, tryEnigma);
|
|
97
107
|
if (repo) repos.github = repo;
|
|
98
108
|
}
|
|
99
109
|
if (!repos.gitlab && (env.GITLAB_PROJECT || env.GITLAB_TOKEN)) {
|
|
100
|
-
const repo = createRepository("gitlab", "gitlab", {}, env);
|
|
110
|
+
const repo = await createRepository("gitlab", "gitlab", {}, env, tryEnigma);
|
|
101
111
|
if (repo) repos.gitlab = repo;
|
|
102
112
|
}
|
|
103
113
|
if (!repos.jira && env.JIRA_URL) {
|
|
104
|
-
const repo = createRepository("jira", "jira", {}, env);
|
|
114
|
+
const repo = await createRepository("jira", "jira", {}, env, tryEnigma);
|
|
105
115
|
if (repo) repos.jira = repo;
|
|
106
116
|
}
|
|
107
117
|
|
|
108
118
|
return repos;
|
|
109
119
|
}
|
|
110
120
|
|
|
111
|
-
function createRepository(
|
|
121
|
+
async function createRepository(
|
|
112
122
|
name: string,
|
|
113
123
|
type: string,
|
|
114
124
|
cfg: BackendConfig,
|
|
115
125
|
env: NodeJS.ProcessEnv,
|
|
116
|
-
|
|
126
|
+
tryEnigma: TryEnigmaCredential,
|
|
127
|
+
): Promise<IssueRepository | undefined> {
|
|
117
128
|
switch (type) {
|
|
118
129
|
case "github": {
|
|
119
130
|
const owner = cfg.owner ?? env.GITHUB_OWNER;
|
|
120
131
|
if (!owner) return undefined;
|
|
121
|
-
const auth = preferredAuth(name, cfg, env, "GITHUB_TOKEN");
|
|
132
|
+
const auth = await preferredAuth(name, cfg, env, "GITHUB_TOKEN", tryEnigma);
|
|
122
133
|
return new GitHubRepository(name, {
|
|
123
134
|
owner,
|
|
124
135
|
repo: cfg.repo ?? env.GITHUB_REPO,
|
|
@@ -129,7 +140,7 @@ function createRepository(
|
|
|
129
140
|
case "gitlab": {
|
|
130
141
|
const project = cfg.project ?? env.GITLAB_PROJECT;
|
|
131
142
|
if (!project) return undefined;
|
|
132
|
-
const auth = preferredAuth(name, cfg, env, "GITLAB_TOKEN");
|
|
143
|
+
const auth = await preferredAuth(name, cfg, env, "GITLAB_TOKEN", tryEnigma);
|
|
133
144
|
return new GitLabRepository(name, {
|
|
134
145
|
projectId: project,
|
|
135
146
|
token: auth.token,
|
|
@@ -138,7 +149,7 @@ function createRepository(
|
|
|
138
149
|
});
|
|
139
150
|
}
|
|
140
151
|
case "jira": {
|
|
141
|
-
const auth = preferredAuth(name, cfg, env, "JIRA_API_TOKEN");
|
|
152
|
+
const auth = await preferredAuth(name, cfg, env, "JIRA_API_TOKEN", tryEnigma);
|
|
142
153
|
if (auth.oauth && auth.token && auth.extra?.cloudId) {
|
|
143
154
|
return new JiraRepository(name, {
|
|
144
155
|
accessToken: auth.token,
|
package/src/daemon/bootstrap.ts
CHANGED
|
@@ -48,14 +48,14 @@ export interface BootstrappedDaemon {
|
|
|
48
48
|
const DEFAULT_SYNC_INTERVAL_MS = 5 * 60_000;
|
|
49
49
|
const DEFAULT_CHECKPOINT_INTERVAL_MS = 10 * 60_000;
|
|
50
50
|
|
|
51
|
-
export function bootstrap(opts: BootstrapOptions = {}): BootstrappedDaemon {
|
|
51
|
+
export async function bootstrap(opts: BootstrapOptions = {}): Promise<BootstrappedDaemon> {
|
|
52
52
|
const paths = resolveDaemonPaths(TICKETS_DAEMON_NAMES, opts.pathEnv);
|
|
53
53
|
const token = ensureAuthToken(paths.token, "Tickets");
|
|
54
54
|
const db = openSqliteWithPragmas(paths.database, { migrations: [...LEDGER_MIGRATIONS, ...FOCUS_MIGRATIONS] });
|
|
55
55
|
const ledger = new Ledger(db);
|
|
56
56
|
const focusStore = new FocusStore(db);
|
|
57
57
|
const logger = opts.logger ?? createLogger("tickets-daemon", { levelEnvVar: "TICKETS_LOG_LEVEL" });
|
|
58
|
-
const repos = opts.repos ?? buildRepositories(opts.config ?? loadConfig());
|
|
58
|
+
const repos = opts.repos ?? (await buildRepositories(opts.config ?? loadConfig()));
|
|
59
59
|
const service = new TicketService(repos);
|
|
60
60
|
const version = opts.version ?? "0.0.0-dev";
|
|
61
61
|
|
package/src/daemon/main.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { readPackageVersion } from "@danypops/daemon-kit/version";
|
|
|
10
10
|
import { bootstrap } from "./bootstrap.js";
|
|
11
11
|
|
|
12
12
|
const version = readPackageVersion(new URL("../../package.json", import.meta.url), "Tickets");
|
|
13
|
-
const { options } = bootstrap({ version });
|
|
13
|
+
const { options } = await bootstrap({ version });
|
|
14
14
|
|
|
15
15
|
runDaemonProcess({
|
|
16
16
|
...options,
|
package/src/domain/issue.ts
CHANGED
|
@@ -79,6 +79,8 @@ export interface CreateInput {
|
|
|
79
79
|
project?: string;
|
|
80
80
|
issueType?: string;
|
|
81
81
|
parentKey?: string;
|
|
82
|
+
/** Backend-specific custom fields keyed by display name (e.g. Jira's "QE Priority"), resolved to the backend's own field ID by the adapter. Not every backend supports this (only Jira does today). */
|
|
83
|
+
customFields?: Record<string, string>;
|
|
82
84
|
}
|
|
83
85
|
|
|
84
86
|
export interface UpdateInput {
|
|
@@ -89,6 +91,8 @@ export interface UpdateInput {
|
|
|
89
91
|
labels?: string[];
|
|
90
92
|
assignee?: string;
|
|
91
93
|
resolution?: string;
|
|
94
|
+
/** Backend-specific custom fields keyed by display name (e.g. Jira's "QE Priority"), resolved to the backend's own field ID by the adapter. Not every backend supports this (only Jira does today). */
|
|
95
|
+
customFields?: Record<string, string>;
|
|
92
96
|
}
|
|
93
97
|
|
|
94
98
|
export interface ListFilter {
|
package/src/index.ts
CHANGED
|
@@ -14,10 +14,12 @@ export {
|
|
|
14
14
|
configDir,
|
|
15
15
|
} from "./config/config.js";
|
|
16
16
|
export type { TicketOperation, TicketOpInputs, TicketOpOutputs } from "./daemon/ops.js";
|
|
17
|
+
export type { FocusStatus, TicketFocusState } from "./daemon/focus.js";
|
|
17
18
|
export {
|
|
18
19
|
createTicketsClient,
|
|
19
20
|
ensureDaemonRunning,
|
|
20
21
|
ticketsPaths,
|
|
22
|
+
type EnsureDaemonOptions,
|
|
21
23
|
type TicketsRpcClient,
|
|
22
24
|
} from "./client/tickets-client.js";
|
|
23
25
|
|