@danypops/tickets 0.10.4 → 0.11.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 +2 -0
- package/package.json +4 -4
- package/src/agent-tools/tickets-vehicle.ts +54 -13
- package/src/cli/index.ts +27 -1
- package/src/github/github.ts +178 -10
- package/src/gitlab/gitlab.ts +208 -5
- package/src/index.ts +1 -1
- package/src/issue/issue.ts +56 -0
- package/src/issue/repository.ts +61 -0
- package/src/issue/service.ts +51 -0
- package/src/issue/transport-error.ts +68 -0
- package/src/jira/jira.ts +22 -2
- package/src/rpc/ops.ts +14 -1
- package/src/rpc/server.ts +3 -0
package/README.md
CHANGED
|
@@ -45,6 +45,8 @@ bun run src/cli/index.ts daemon start
|
|
|
45
45
|
bun run src/cli/index.ts daemon stop # asks it to shut down gracefully
|
|
46
46
|
bun run src/cli/index.ts daemon restart
|
|
47
47
|
|
|
48
|
+
# Reports capabilities plus local read/write readiness and missing setting names.
|
|
49
|
+
# It never probes provider connectivity and never returns credential values.
|
|
48
50
|
bun run src/cli/index.ts backends
|
|
49
51
|
bun run src/cli/index.ts list -b github --status todo
|
|
50
52
|
# get includes fixVersions, issueLinks, externalLinks (Jira "Web Links", e.g.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danypops/tickets",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.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",
|
|
@@ -24,9 +24,9 @@
|
|
|
24
24
|
"typecheck": "tsc --noEmit"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@danypops/vehicle-core": "^0.
|
|
28
|
-
"@danypops/vehicle-server": "^0.18.
|
|
29
|
-
"@danypops/vehicle-client": "^0.7.
|
|
27
|
+
"@danypops/vehicle-core": "^0.13.0",
|
|
28
|
+
"@danypops/vehicle-server": "^0.18.4",
|
|
29
|
+
"@danypops/vehicle-client": "^0.7.1",
|
|
30
30
|
"@danypops/enigma-client": "^0.6.1",
|
|
31
31
|
"@gitbeaker/rest": "^43.8.0",
|
|
32
32
|
"commander": "^12.1.0",
|
|
@@ -63,7 +63,8 @@ function definedEntriesOnly(input: Record<string, unknown>): Record<string, unkn
|
|
|
63
63
|
const OPERATIONS: readonly OperationSpec[] = [
|
|
64
64
|
{
|
|
65
65
|
action: "backends.list",
|
|
66
|
-
description:
|
|
66
|
+
description:
|
|
67
|
+
"Lists configured backends with capabilities and local credential-safe read/write readiness. Connectivity is not probed and is always reported as not_checked.",
|
|
67
68
|
effect: "read",
|
|
68
69
|
properties: {},
|
|
69
70
|
required: [],
|
|
@@ -135,6 +136,28 @@ const OPERATIONS: readonly OperationSpec[] = [
|
|
|
135
136
|
properties: { ref: stringProp, body: stringProp },
|
|
136
137
|
required: ["ref", "body"],
|
|
137
138
|
},
|
|
139
|
+
{
|
|
140
|
+
action: "issue.approve",
|
|
141
|
+
description: "Approves a pull request / merge request on a live backend (GitHub, GitLab) -- a real, externally visible write.",
|
|
142
|
+
effect: "external-write",
|
|
143
|
+
properties: { ref: stringProp, body: stringProp },
|
|
144
|
+
required: ["ref"],
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
action: "issue.request_changes",
|
|
148
|
+
description:
|
|
149
|
+
"Requests changes on a pull request on a live backend -- a real, externally visible write. GitHub only: GitLab has no REST endpoint for this.",
|
|
150
|
+
effect: "external-write",
|
|
151
|
+
properties: { ref: stringProp, body: stringProp },
|
|
152
|
+
required: ["ref", "body"],
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
action: "issue.merge",
|
|
156
|
+
description: "Merges a pull request / merge request on a live backend (GitHub, GitLab) -- a real, externally visible write.",
|
|
157
|
+
effect: "external-write",
|
|
158
|
+
properties: { ref: stringProp, method: stringProp },
|
|
159
|
+
required: ["ref"],
|
|
160
|
+
},
|
|
138
161
|
{
|
|
139
162
|
action: "ledger.search",
|
|
140
163
|
description: "Searches the local pooled-issue ledger (no live backend call).",
|
|
@@ -274,13 +297,14 @@ const OPERATIONS: readonly OperationSpec[] = [
|
|
|
274
297
|
];
|
|
275
298
|
|
|
276
299
|
/**
|
|
277
|
-
* The five discover.* operations
|
|
278
|
-
* repository implements the matching optional
|
|
279
|
-
* structurally -- never a hardcoded backend name
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
*
|
|
300
|
+
* The five discover.* operations, plus the three pull-request-review operations below,
|
|
301
|
+
* only ever succeed against a backend whose repository implements the matching optional
|
|
302
|
+
* capability (structurally -- never a hardcoded backend name: discover.* is Jira-only
|
|
303
|
+
* today, issue.approve/issue.merge need PullRequestReviewable (GitHub, GitLab both),
|
|
304
|
+
* issue.request_changes needs PullRequestChangesRequestable (GitHub only)). An operation
|
|
305
|
+
* none of the currently configured backends could possibly satisfy is marked unavailable
|
|
306
|
+
* so it never appears in the LLM's callable tool list in the first place, instead of being
|
|
307
|
+
* offered and then failing with NotSupportedError on the first real call.
|
|
284
308
|
*/
|
|
285
309
|
const DISCOVER_AVAILABILITY: readonly { action: TicketOperation; capability: keyof BackendCapabilities; reason: string }[] = [
|
|
286
310
|
{ action: "discover.fields", capability: "supportsFieldDiscovery", reason: "no configured backend supports field discovery (Jira only)" },
|
|
@@ -304,14 +328,31 @@ const DISCOVER_AVAILABILITY: readonly { action: TicketOperation; capability: key
|
|
|
304
328
|
capability: "supportsBoardFilterDiscovery",
|
|
305
329
|
reason: "no configured backend supports board filter discovery (Jira only)",
|
|
306
330
|
},
|
|
331
|
+
{
|
|
332
|
+
action: "issue.approve",
|
|
333
|
+
capability: "supportsPullRequestReview",
|
|
334
|
+
reason: "no configured backend supports pull request review (GitHub, GitLab)",
|
|
335
|
+
},
|
|
336
|
+
{
|
|
337
|
+
action: "issue.merge",
|
|
338
|
+
capability: "supportsPullRequestReview",
|
|
339
|
+
reason: "no configured backend supports pull request review (GitHub, GitLab)",
|
|
340
|
+
},
|
|
341
|
+
{
|
|
342
|
+
action: "issue.request_changes",
|
|
343
|
+
capability: "supportsPullRequestChangesRequest",
|
|
344
|
+
reason: "no configured backend supports requesting changes on a pull request (GitHub only)",
|
|
345
|
+
},
|
|
307
346
|
];
|
|
308
347
|
|
|
309
348
|
/**
|
|
310
|
-
* Re-syncs the five discover.* operations
|
|
311
|
-
*
|
|
312
|
-
* built, and again after every live
|
|
313
|
-
* createBackendRefreshTask), so a Jira credential added or
|
|
314
|
-
* runtime flips these tools' visibility without a
|
|
349
|
+
* Re-syncs every capability-gated operation's availability (the five discover.* operations
|
|
350
|
+
* plus issue.approve/issue.request_changes/issue.merge) against the service's current
|
|
351
|
+
* backend set -- called once right after the registry is built, and again after every live
|
|
352
|
+
* backend refresh (config.ts's createBackendRefreshTask), so a Jira credential added or a
|
|
353
|
+
* GitHub/GitLab backend added or removed at runtime flips these tools' visibility without a
|
|
354
|
+
* daemon restart. Name kept from before pull-request support existed -- still exported and
|
|
355
|
+
* called by that name from bootstrap.ts and existing tests.
|
|
315
356
|
*/
|
|
316
357
|
export function syncDiscoverAvailability(registry: VehicleRegistry, service: TicketService): void {
|
|
317
358
|
const capabilities = service.backendCapabilities();
|
package/src/cli/index.ts
CHANGED
|
@@ -124,6 +124,32 @@ program
|
|
|
124
124
|
await withClient((client) => client.call("issue.children", { ref }));
|
|
125
125
|
});
|
|
126
126
|
|
|
127
|
+
program
|
|
128
|
+
.command("approve <ref>")
|
|
129
|
+
.description("approve a pull request / merge request (GitHub, GitLab)")
|
|
130
|
+
.option("--body <text>", "optional review comment (GitHub only -- GitLab's approve endpoint has no comment body)")
|
|
131
|
+
.action(async (ref: string, opts) => {
|
|
132
|
+
await withClient((client) => client.call("issue.approve", { ref, body: opts.body }));
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
program
|
|
136
|
+
.command("request-changes <ref> <body>")
|
|
137
|
+
.description("request changes on a pull request -- GitHub only, GitLab has no such REST endpoint")
|
|
138
|
+
.action(async (ref: string, body: string) => {
|
|
139
|
+
await withClient((client) => client.call("issue.request_changes", { ref, body }));
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
program
|
|
143
|
+
.command("merge <ref>")
|
|
144
|
+
.description("merge a pull request / merge request (GitHub, GitLab)")
|
|
145
|
+
.option(
|
|
146
|
+
"--method <method>",
|
|
147
|
+
"merge | squash | rebase (GitLab: only squash is distinct from a plain merge; rebase falls back to a plain merge)",
|
|
148
|
+
)
|
|
149
|
+
.action(async (ref: string, opts) => {
|
|
150
|
+
await withClient((client) => client.call("issue.merge", { ref, method: opts.method }));
|
|
151
|
+
});
|
|
152
|
+
|
|
127
153
|
const comment = program.command("comment").description("comment operations");
|
|
128
154
|
|
|
129
155
|
comment
|
|
@@ -160,7 +186,7 @@ ledger
|
|
|
160
186
|
|
|
161
187
|
program
|
|
162
188
|
.command("backends")
|
|
163
|
-
.description("list configured
|
|
189
|
+
.description("list configured backends, capabilities, and local read/write readiness (no connectivity probe)")
|
|
164
190
|
.action(async () => {
|
|
165
191
|
await withClient((client) => client.call("backends.list", {}));
|
|
166
192
|
});
|
package/src/github/github.ts
CHANGED
|
@@ -22,7 +22,20 @@
|
|
|
22
22
|
import { RequestError } from "@octokit/request-error";
|
|
23
23
|
import { Octokit } from "octokit";
|
|
24
24
|
import { ApiError, AuthRequiredError, BackendConfigurationError, BackendConnectionError, IssueNotFoundError } from "../issue/errors.js";
|
|
25
|
-
import type {
|
|
25
|
+
import type {
|
|
26
|
+
Comment,
|
|
27
|
+
CreateInput,
|
|
28
|
+
Issue,
|
|
29
|
+
ListFilter,
|
|
30
|
+
MergeableState,
|
|
31
|
+
PullRequestDetails,
|
|
32
|
+
PullRequestReviewer,
|
|
33
|
+
parsePriority,
|
|
34
|
+
Status,
|
|
35
|
+
UpdateInput,
|
|
36
|
+
} from "../issue/issue.js";
|
|
37
|
+
import type { BackendConfigurationReadiness } from "../issue/repository.js";
|
|
38
|
+
import { classifyBackendTransportFailure } from "../issue/transport-error.js";
|
|
26
39
|
|
|
27
40
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
28
41
|
|
|
@@ -54,7 +67,10 @@ interface GhIssue {
|
|
|
54
67
|
labels: (GhLabel | string)[];
|
|
55
68
|
created_at: string;
|
|
56
69
|
updated_at: string;
|
|
57
|
-
|
|
70
|
+
/** The Issues API's own PR stub -- confirmed against @octokit/openapi-types' "issue" schema: only these fields, never base/head/mergeable/diffStat/requestedReviewers. See github.ts's get()/pullRequestDetailsFromIssue for why those need a dedicated pulls.get() call instead. */
|
|
71
|
+
pull_request?: { merged_at: string | null };
|
|
72
|
+
/** A real top-level field on the Issues API's own "issue" schema (not nested under pull_request) -- free at list()/get() time. */
|
|
73
|
+
draft?: boolean;
|
|
58
74
|
}
|
|
59
75
|
interface GhComment {
|
|
60
76
|
id: number;
|
|
@@ -63,6 +79,24 @@ interface GhComment {
|
|
|
63
79
|
updated_at: string;
|
|
64
80
|
user: GhUser | null;
|
|
65
81
|
}
|
|
82
|
+
/** The dedicated Pulls API's full shape (GET /pulls/{pull_number}) -- only reachable via a second call from get(), never from the Issues API list()/get() calls above. */
|
|
83
|
+
interface GhPullRequestFull {
|
|
84
|
+
base: { ref: string; sha: string };
|
|
85
|
+
head: { ref: string; sha: string };
|
|
86
|
+
draft?: boolean;
|
|
87
|
+
merged: boolean;
|
|
88
|
+
merged_at: string | null;
|
|
89
|
+
mergeable: boolean | null;
|
|
90
|
+
mergeable_state: string;
|
|
91
|
+
additions: number;
|
|
92
|
+
deletions: number;
|
|
93
|
+
changed_files: number;
|
|
94
|
+
requested_reviewers?: GhUser[] | null;
|
|
95
|
+
}
|
|
96
|
+
interface GhReview {
|
|
97
|
+
user: GhUser | null;
|
|
98
|
+
state: string;
|
|
99
|
+
}
|
|
66
100
|
|
|
67
101
|
export class GitHubRepository {
|
|
68
102
|
readonly name: string;
|
|
@@ -104,6 +138,32 @@ export class GitHubRepository {
|
|
|
104
138
|
if (this.readOnly) throw new AuthRequiredError("github", "GITHUB_TOKEN");
|
|
105
139
|
}
|
|
106
140
|
|
|
141
|
+
configurationReadiness(): BackendConfigurationReadiness {
|
|
142
|
+
const repositoryMissing = this.repo ? [] : ["GITHUB_REPO"];
|
|
143
|
+
const writeMissing = [...repositoryMissing, ...(this.readOnly ? ["GITHUB_TOKEN"] : [])];
|
|
144
|
+
return {
|
|
145
|
+
backendType: "github",
|
|
146
|
+
connectivity: "not_checked",
|
|
147
|
+
read: this.repo
|
|
148
|
+
? { state: "ready", missingConfiguration: [] }
|
|
149
|
+
: {
|
|
150
|
+
state: "partial",
|
|
151
|
+
missingConfiguration: repositoryMissing,
|
|
152
|
+
recovery:
|
|
153
|
+
"Set GITHUB_REPO (or the backend's repo setting) for repository list/get/comment operations; organization search remains available.",
|
|
154
|
+
},
|
|
155
|
+
write:
|
|
156
|
+
writeMissing.length === 0
|
|
157
|
+
? { state: "ready", missingConfiguration: [] }
|
|
158
|
+
: {
|
|
159
|
+
state: "blocked",
|
|
160
|
+
missingConfiguration: writeMissing,
|
|
161
|
+
recovery:
|
|
162
|
+
"Configure the repository scope and GITHUB_TOKEN (or equivalent backend settings) before using live write operations.",
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
107
167
|
async list(filter: ListFilter): Promise<Issue[]> {
|
|
108
168
|
const limit = filter.limit && filter.limit > 0 ? filter.limit : 50;
|
|
109
169
|
const raw = await this.call((signal) =>
|
|
@@ -117,7 +177,7 @@ export class GitHubRepository {
|
|
|
117
177
|
request: { signal },
|
|
118
178
|
}),
|
|
119
179
|
);
|
|
120
|
-
return (raw as GhIssue[]).
|
|
180
|
+
return (raw as GhIssue[]).map((i) => toDomain(i));
|
|
121
181
|
}
|
|
122
182
|
|
|
123
183
|
async get(key: string): Promise<Issue> {
|
|
@@ -125,8 +185,26 @@ export class GitHubRepository {
|
|
|
125
185
|
const raw = (await this.call((signal) =>
|
|
126
186
|
this.client.rest.issues.get({ owner: this.owner, repo: this.repoName(), issue_number, request: { signal } }),
|
|
127
187
|
)) as GhIssue;
|
|
128
|
-
if (raw.pull_request)
|
|
129
|
-
|
|
188
|
+
if (!raw.pull_request) return toDomain(raw);
|
|
189
|
+
// A PR's full shape (base/head/mergeable/diffStat/requestedReviewers) is not on the Issues
|
|
190
|
+
// API's own "issue" schema at all -- only reachable via the dedicated Pulls API, and only
|
|
191
|
+
// fetched here (the single-item path), never from list()/search(), per this project's own
|
|
192
|
+
// N+1-avoidance discipline. See the research Doc's correction for why this differs from
|
|
193
|
+
// list()'s zero-extra-call population below.
|
|
194
|
+
const [pull, reviews] = await Promise.all([this.fetchPullRequest(issue_number), this.fetchReviews(issue_number)]);
|
|
195
|
+
return toDomain(raw, pullRequestDetailsFromFull(pull, reviews));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private async fetchPullRequest(pull_number: number): Promise<GhPullRequestFull> {
|
|
199
|
+
return (await this.call((signal) =>
|
|
200
|
+
this.client.rest.pulls.get({ owner: this.owner, repo: this.repoName(), pull_number, request: { signal } }),
|
|
201
|
+
)) as GhPullRequestFull;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private async fetchReviews(pull_number: number): Promise<GhReview[]> {
|
|
205
|
+
return (await this.call((signal) =>
|
|
206
|
+
this.client.rest.pulls.listReviews({ owner: this.owner, repo: this.repoName(), pull_number, request: { signal } }),
|
|
207
|
+
)) as GhReview[];
|
|
130
208
|
}
|
|
131
209
|
|
|
132
210
|
async create(input: CreateInput): Promise<Issue> {
|
|
@@ -171,7 +249,7 @@ export class GitHubRepository {
|
|
|
171
249
|
const result = (await this.call((signal) =>
|
|
172
250
|
this.client.rest.search.issuesAndPullRequests({ q: `${scope} ${query}`, per_page: limit, request: { signal } }),
|
|
173
251
|
)) as { items: GhIssue[] };
|
|
174
|
-
return result.items.
|
|
252
|
+
return result.items.map((i) => toDomain(i));
|
|
175
253
|
}
|
|
176
254
|
|
|
177
255
|
// GitHub has no native sub-issue relationship exposed via REST v3.
|
|
@@ -179,6 +257,47 @@ export class GitHubRepository {
|
|
|
179
257
|
return [];
|
|
180
258
|
}
|
|
181
259
|
|
|
260
|
+
async approvePullRequest(key: string, body?: string): Promise<Issue> {
|
|
261
|
+
this.requireAuth();
|
|
262
|
+
const pull_number = parseIssueNumber(key);
|
|
263
|
+
await this.call((signal) =>
|
|
264
|
+
this.client.rest.pulls.createReview({
|
|
265
|
+
owner: this.owner,
|
|
266
|
+
repo: this.repoName(),
|
|
267
|
+
pull_number,
|
|
268
|
+
event: "APPROVE",
|
|
269
|
+
body,
|
|
270
|
+
request: { signal },
|
|
271
|
+
}),
|
|
272
|
+
);
|
|
273
|
+
return this.get(key);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async requestPullRequestChanges(key: string, body: string): Promise<Issue> {
|
|
277
|
+
this.requireAuth();
|
|
278
|
+
const pull_number = parseIssueNumber(key);
|
|
279
|
+
await this.call((signal) =>
|
|
280
|
+
this.client.rest.pulls.createReview({
|
|
281
|
+
owner: this.owner,
|
|
282
|
+
repo: this.repoName(),
|
|
283
|
+
pull_number,
|
|
284
|
+
event: "REQUEST_CHANGES",
|
|
285
|
+
body,
|
|
286
|
+
request: { signal },
|
|
287
|
+
}),
|
|
288
|
+
);
|
|
289
|
+
return this.get(key);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async mergePullRequest(key: string, method?: "merge" | "squash" | "rebase"): Promise<Issue> {
|
|
293
|
+
this.requireAuth();
|
|
294
|
+
const pull_number = parseIssueNumber(key);
|
|
295
|
+
await this.call((signal) =>
|
|
296
|
+
this.client.rest.pulls.merge({ owner: this.owner, repo: this.repoName(), pull_number, merge_method: method, request: { signal } }),
|
|
297
|
+
);
|
|
298
|
+
return this.get(key);
|
|
299
|
+
}
|
|
300
|
+
|
|
182
301
|
async listComments(key: string): Promise<Comment[]> {
|
|
183
302
|
const issue_number = parseIssueNumber(key);
|
|
184
303
|
const raw = (await this.call((signal) =>
|
|
@@ -216,12 +335,13 @@ export class GitHubRepository {
|
|
|
216
335
|
return res.data;
|
|
217
336
|
} catch (err) {
|
|
218
337
|
if (err instanceof BackendConfigurationError) throw err;
|
|
219
|
-
|
|
338
|
+
const transportKind = classifyBackendTransportFailure(err);
|
|
339
|
+
if (transportKind) throw new BackendConnectionError("github", transportKind, err);
|
|
340
|
+
if (err instanceof RequestError && err.response) {
|
|
220
341
|
if (err.status === 404) throw new IssueNotFoundError("github", err.request.url);
|
|
221
342
|
throw new ApiError("github", err.request.method, err.request.url, err.status, redact(err.message));
|
|
222
343
|
}
|
|
223
|
-
|
|
224
|
-
throw new BackendConnectionError("github", "unreachable", err);
|
|
344
|
+
throw err;
|
|
225
345
|
} finally {
|
|
226
346
|
clearTimeout(timer);
|
|
227
347
|
}
|
|
@@ -261,7 +381,54 @@ function priorityFromLabels(labels: (GhLabel | string)[]): ReturnType<typeof par
|
|
|
261
381
|
return "none";
|
|
262
382
|
}
|
|
263
383
|
|
|
264
|
-
|
|
384
|
+
/** Normalizes GitHub's loose mergeable_state string (not a closed enum in its own OpenAPI schema) into this project's own MergeableState. */
|
|
385
|
+
function mapMergeableState(gh: GhPullRequestFull): MergeableState {
|
|
386
|
+
if (gh.mergeable === null) return "checking";
|
|
387
|
+
if (!gh.mergeable) return "conflicting";
|
|
388
|
+
return gh.mergeable_state.toLowerCase() === "unknown" ? "unknown" : "mergeable";
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function mapReviewState(state: string): PullRequestReviewer["state"] {
|
|
392
|
+
switch (state.toUpperCase()) {
|
|
393
|
+
case "APPROVED":
|
|
394
|
+
return "approved";
|
|
395
|
+
case "CHANGES_REQUESTED":
|
|
396
|
+
return "changes_requested";
|
|
397
|
+
case "COMMENTED":
|
|
398
|
+
return "commented";
|
|
399
|
+
case "PENDING":
|
|
400
|
+
return "pending";
|
|
401
|
+
default:
|
|
402
|
+
return "unreviewed";
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** get()-only enrichment -- see the research Doc's correction for why this needs a dedicated pulls.get() call, unreachable from list()/search(). */
|
|
407
|
+
function pullRequestDetailsFromFull(pull: GhPullRequestFull, reviews: GhReview[]): PullRequestDetails {
|
|
408
|
+
return {
|
|
409
|
+
baseBranch: pull.base.ref,
|
|
410
|
+
headBranch: pull.head.ref,
|
|
411
|
+
baseSha: pull.base.sha,
|
|
412
|
+
headSha: pull.head.sha,
|
|
413
|
+
draft: pull.draft,
|
|
414
|
+
merged: pull.merged,
|
|
415
|
+
mergedAt: pull.merged_at ?? undefined,
|
|
416
|
+
requestedReviewers: pull.requested_reviewers?.length ? pull.requested_reviewers.map((r) => r.login) : undefined,
|
|
417
|
+
mergeableState: mapMergeableState(pull),
|
|
418
|
+
diffStat: { filesChanged: pull.changed_files, additions: pull.additions, deletions: pull.deletions },
|
|
419
|
+
reviewers: reviews.length
|
|
420
|
+
? reviews.filter((r) => r.user).map((r) => ({ username: r.user!.login, state: mapReviewState(r.state) }))
|
|
421
|
+
: undefined,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** list()/search()-cheap population -- only what the Issues API's own "issue" schema actually carries for a PR item (see the research Doc's correction): draft and merged/mergedAt, nothing requiring the dedicated Pulls API. */
|
|
426
|
+
function pullRequestDetailsFromIssue(gh: GhIssue): PullRequestDetails | undefined {
|
|
427
|
+
if (!gh.pull_request) return undefined;
|
|
428
|
+
return { draft: gh.draft, merged: gh.pull_request.merged_at !== null, mergedAt: gh.pull_request.merged_at ?? undefined };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function toDomain(gh: GhIssue, pullRequest?: PullRequestDetails): Issue {
|
|
265
432
|
return {
|
|
266
433
|
ref: `github:#${gh.number}`,
|
|
267
434
|
id: String(gh.number),
|
|
@@ -276,6 +443,7 @@ function toDomain(gh: GhIssue): Issue {
|
|
|
276
443
|
url: gh.html_url,
|
|
277
444
|
createdAt: gh.created_at,
|
|
278
445
|
updatedAt: gh.updated_at,
|
|
446
|
+
pullRequest: pullRequest ?? pullRequestDetailsFromIssue(gh),
|
|
279
447
|
};
|
|
280
448
|
}
|
|
281
449
|
|
package/src/gitlab/gitlab.ts
CHANGED
|
@@ -15,7 +15,20 @@ import { isIP } from "node:net";
|
|
|
15
15
|
import { GitbeakerRequestError, type RequesterType, type ResourceOptions } from "@gitbeaker/requester-utils";
|
|
16
16
|
import { Gitlab } from "@gitbeaker/rest";
|
|
17
17
|
import { ApiError, AuthRequiredError, BackendConnectionError, InvalidUrlError, IssueNotFoundError } from "../issue/errors.js";
|
|
18
|
-
import type {
|
|
18
|
+
import type {
|
|
19
|
+
Comment,
|
|
20
|
+
CreateInput,
|
|
21
|
+
Issue,
|
|
22
|
+
ListFilter,
|
|
23
|
+
MergeableState,
|
|
24
|
+
PullRequestDetails,
|
|
25
|
+
PullRequestReviewer,
|
|
26
|
+
parsePriority,
|
|
27
|
+
Status,
|
|
28
|
+
UpdateInput,
|
|
29
|
+
} from "../issue/issue.js";
|
|
30
|
+
import type { BackendConfigurationReadiness } from "../issue/repository.js";
|
|
31
|
+
import { classifyBackendTransportFailure } from "../issue/transport-error.js";
|
|
19
32
|
|
|
20
33
|
export interface GitLabOptions {
|
|
21
34
|
projectId: string;
|
|
@@ -58,6 +71,48 @@ interface GlNote {
|
|
|
58
71
|
author: GlUser | null;
|
|
59
72
|
}
|
|
60
73
|
|
|
74
|
+
/**
|
|
75
|
+
* GitLab's MergeRequests resource is a dedicated endpoint entirely separate from Issues --
|
|
76
|
+
* issues and merge requests have their own independent `iid` sequences within a project (a
|
|
77
|
+
* project can have both a `#5` issue and a `!5` merge request, unrelated to each other), unlike
|
|
78
|
+
* GitHub where a PR *is* an Issue with a `pull_request` stub. list()/search() below stay
|
|
79
|
+
* Issues-only, unchanged: mixing two independently-numbered collections into one list() call
|
|
80
|
+
* would be surprising, not the GitHub-shaped "free extra items" case this adapter otherwise
|
|
81
|
+
* mirrors. Merge requests surface instead via GitLab's own `!<iid>` reference convention
|
|
82
|
+
* (mirrored by the UI itself) as a key prefix get()/approvePullRequest()/mergePullRequest()
|
|
83
|
+
* all recognize -- see parseMrIid().
|
|
84
|
+
*/
|
|
85
|
+
interface GlMergeRequest {
|
|
86
|
+
iid: number;
|
|
87
|
+
title: string;
|
|
88
|
+
description: string | null;
|
|
89
|
+
state: string;
|
|
90
|
+
web_url: string;
|
|
91
|
+
author: GlUser | null;
|
|
92
|
+
assignee: GlUser | null;
|
|
93
|
+
labels: string[];
|
|
94
|
+
created_at: string;
|
|
95
|
+
updated_at: string;
|
|
96
|
+
source_branch: string;
|
|
97
|
+
target_branch: string;
|
|
98
|
+
sha: string;
|
|
99
|
+
draft: boolean;
|
|
100
|
+
merged_at: string | null;
|
|
101
|
+
merge_status: string;
|
|
102
|
+
/** list()-cheap per the research Doc -- the usernames only; per-reviewer *state* always needs the dedicated showReviewers() call below. */
|
|
103
|
+
reviewers: GlUser[] | null;
|
|
104
|
+
}
|
|
105
|
+
/** get()-only shape (ExpandedMergeRequestSchema) -- ordinary list()/show() responses don't carry changes_count/diff_refs. */
|
|
106
|
+
interface GlMergeRequestExpanded extends GlMergeRequest {
|
|
107
|
+
/** A string, not a number -- GitLab caps and reports e.g. "1000+" past its own diff-size limit rather than an exact count. */
|
|
108
|
+
changes_count: string;
|
|
109
|
+
diff_refs: { base_sha: string; head_sha: string };
|
|
110
|
+
}
|
|
111
|
+
interface GlMergeRequestReviewerEntry {
|
|
112
|
+
user: GlUser;
|
|
113
|
+
state: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
61
116
|
const DEFAULT_URL = "https://gitlab.com";
|
|
62
117
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
63
118
|
|
|
@@ -89,6 +144,27 @@ export class GitLabRepository {
|
|
|
89
144
|
if (this.readOnly) throw new AuthRequiredError("gitlab", "GITLAB_TOKEN");
|
|
90
145
|
}
|
|
91
146
|
|
|
147
|
+
configurationReadiness(): BackendConfigurationReadiness {
|
|
148
|
+
return {
|
|
149
|
+
backendType: "gitlab",
|
|
150
|
+
connectivity: "not_checked",
|
|
151
|
+
read: this.readOnly
|
|
152
|
+
? {
|
|
153
|
+
state: "partial",
|
|
154
|
+
missingConfiguration: ["GITLAB_TOKEN"],
|
|
155
|
+
recovery: "Configure GITLAB_TOKEN for private-project reads; unauthenticated reads remain limited to public projects.",
|
|
156
|
+
}
|
|
157
|
+
: { state: "ready", missingConfiguration: [] },
|
|
158
|
+
write: this.readOnly
|
|
159
|
+
? {
|
|
160
|
+
state: "blocked",
|
|
161
|
+
missingConfiguration: ["GITLAB_TOKEN"],
|
|
162
|
+
recovery: "Configure GITLAB_TOKEN (or delegated OAuth) before using live write operations.",
|
|
163
|
+
}
|
|
164
|
+
: { state: "ready", missingConfiguration: [] },
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
92
168
|
async list(filter: ListFilter): Promise<Issue[]> {
|
|
93
169
|
const limit = filter.limit && filter.limit > 0 ? filter.limit : 50;
|
|
94
170
|
const raw = await this.call<GlIssue[]>(() =>
|
|
@@ -104,11 +180,20 @@ export class GitLabRepository {
|
|
|
104
180
|
}
|
|
105
181
|
|
|
106
182
|
async get(key: string): Promise<Issue> {
|
|
183
|
+
if (isMergeRequestKey(key)) return this.getMergeRequest(parseMrIid(key));
|
|
107
184
|
const iid = parseIid(key);
|
|
108
185
|
const raw = await this.call<GlIssue>(() => this.client.Issues.show(iid, { projectId: this.projectId }));
|
|
109
186
|
return toDomain(raw);
|
|
110
187
|
}
|
|
111
188
|
|
|
189
|
+
private async getMergeRequest(iid: number): Promise<Issue> {
|
|
190
|
+
const [raw, reviewers] = await Promise.all([
|
|
191
|
+
this.call<GlMergeRequestExpanded>(() => this.client.MergeRequests.show(this.projectId, iid)),
|
|
192
|
+
this.call<GlMergeRequestReviewerEntry[]>(() => this.client.MergeRequests.showReviewers(this.projectId, iid)),
|
|
193
|
+
]);
|
|
194
|
+
return mrToDomain(raw, reviewers);
|
|
195
|
+
}
|
|
196
|
+
|
|
112
197
|
async create(input: CreateInput): Promise<Issue> {
|
|
113
198
|
this.requireAuth();
|
|
114
199
|
const assigneeIds = input.assignee ? [await this.resolveUserId(input.assignee)] : undefined;
|
|
@@ -162,6 +247,39 @@ export class GitLabRepository {
|
|
|
162
247
|
return noteToDomain(raw);
|
|
163
248
|
}
|
|
164
249
|
|
|
250
|
+
/**
|
|
251
|
+
* GitLab's approve endpoint (unlike GitHub's createReview) returns only an approval-state
|
|
252
|
+
* summary, not the full MR -- so this re-fetches the same way get() does, `body` is accepted
|
|
253
|
+
* for interface parity with GitHub but ignored: GitLab's approve endpoint has no comment-body
|
|
254
|
+
* parameter at all (confirmed against @gitbeaker/core's ApproveMergeRequestOptions -- just
|
|
255
|
+
* sha/approvalPassword).
|
|
256
|
+
*/
|
|
257
|
+
async approvePullRequest(key: string): Promise<Issue> {
|
|
258
|
+
this.requireAuth();
|
|
259
|
+
const iid = parseMrIid(key);
|
|
260
|
+
await this.call(() => this.client.MergeRequestApprovals.approve(this.projectId, iid));
|
|
261
|
+
return this.getMergeRequest(iid);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* GitLab's merge endpoint returns the full expanded MR directly (per the research Doc) --
|
|
266
|
+
* no extra show() call needed for the MR object itself, unlike approve() above. Reviewer
|
|
267
|
+
* state is still a separate call every time on both backends (see PullRequestReviewer's own
|
|
268
|
+
* doc comment), so that part isn't free. GitLab's accept endpoint only has a boolean `squash`
|
|
269
|
+
* option, not a 3-way merge/squash/rebase choice like GitHub's -- "rebase" has no GitLab merge
|
|
270
|
+
* equivalent (GitLab's own rebase is a distinct pre-merge branch operation), so it falls back
|
|
271
|
+
* to a plain merge rather than rejecting the call.
|
|
272
|
+
*/
|
|
273
|
+
async mergePullRequest(key: string, method?: "merge" | "squash" | "rebase"): Promise<Issue> {
|
|
274
|
+
this.requireAuth();
|
|
275
|
+
const iid = parseMrIid(key);
|
|
276
|
+
const [raw, reviewers] = await Promise.all([
|
|
277
|
+
this.call<GlMergeRequestExpanded>(() => this.client.MergeRequests.merge(this.projectId, iid, { squash: method === "squash" })),
|
|
278
|
+
this.call<GlMergeRequestReviewerEntry[]>(() => this.client.MergeRequests.showReviewers(this.projectId, iid)),
|
|
279
|
+
]);
|
|
280
|
+
return mrToDomain(raw, reviewers);
|
|
281
|
+
}
|
|
282
|
+
|
|
165
283
|
/**
|
|
166
284
|
* GitLab's assignee write contract takes a numeric user ID, not a username
|
|
167
285
|
* (`assignee_ids: number[]`, confirmed against @gitbeaker/rest's generated
|
|
@@ -185,11 +303,14 @@ export class GitLabRepository {
|
|
|
185
303
|
if (err instanceof GitbeakerRequestError) {
|
|
186
304
|
const status = err.cause?.response?.status;
|
|
187
305
|
const url = err.cause?.request?.url ?? "";
|
|
188
|
-
if (status
|
|
189
|
-
|
|
190
|
-
|
|
306
|
+
if (status !== undefined) {
|
|
307
|
+
if (status === 404) throw new IssueNotFoundError("gitlab", url);
|
|
308
|
+
throw new ApiError("gitlab", err.cause?.request?.method ?? "?", url, status, redact(err.message));
|
|
309
|
+
}
|
|
191
310
|
}
|
|
192
|
-
|
|
311
|
+
const transportKind = classifyBackendTransportFailure(err);
|
|
312
|
+
if (transportKind) throw new BackendConnectionError("gitlab", transportKind, err);
|
|
313
|
+
throw err;
|
|
193
314
|
}
|
|
194
315
|
}
|
|
195
316
|
}
|
|
@@ -202,6 +323,51 @@ function parseIid(key: string): number {
|
|
|
202
323
|
return Number(key.replace(/^#/, ""));
|
|
203
324
|
}
|
|
204
325
|
|
|
326
|
+
/** GitLab's own merge-request reference convention, mirrored by its UI: "!5", vs. an issue's "#5". */
|
|
327
|
+
function isMergeRequestKey(key: string): boolean {
|
|
328
|
+
return key.trim().startsWith("!");
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function parseMrIid(key: string): number {
|
|
332
|
+
return Number(key.replace(/^!/, "").replace(/^#/, ""));
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Primarily merge_status, not detailed_merge_status -- see MergeableState's own doc comment for the cross-backend normalization this feeds. */
|
|
336
|
+
function mapMergeableState(mergeStatus: string): MergeableState {
|
|
337
|
+
switch (mergeStatus) {
|
|
338
|
+
case "can_be_merged":
|
|
339
|
+
return "mergeable";
|
|
340
|
+
case "cannot_be_merged":
|
|
341
|
+
case "cannot_be_merged_recheck":
|
|
342
|
+
return "conflicting";
|
|
343
|
+
case "checking":
|
|
344
|
+
return "checking";
|
|
345
|
+
default:
|
|
346
|
+
return "unknown"; // "unchecked"
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** GitLab's own showReviewers() state enum, mapped onto this project's cross-backend PullRequestReviewer.state. "reviewed" (a completed, non-approve/non-reject review) is the closest fit to "commented"; "review_started" (in progress) maps to "pending". */
|
|
351
|
+
function mapReviewerState(state: string): PullRequestReviewer["state"] {
|
|
352
|
+
switch (state) {
|
|
353
|
+
case "approved":
|
|
354
|
+
return "approved";
|
|
355
|
+
case "requested_changes":
|
|
356
|
+
return "changes_requested";
|
|
357
|
+
case "reviewed":
|
|
358
|
+
return "commented";
|
|
359
|
+
case "review_started":
|
|
360
|
+
return "pending";
|
|
361
|
+
default:
|
|
362
|
+
return "unreviewed";
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** "5" -> 5; GitLab reports "1000+" past its own diff-size limit -- parsed as a floor, not an exact count (see GlMergeRequestExpanded's own doc comment). */
|
|
367
|
+
function parseChangesCount(changesCount: string): number {
|
|
368
|
+
return Number.parseInt(changesCount, 10) || 0;
|
|
369
|
+
}
|
|
370
|
+
|
|
205
371
|
function mapStatusToGitLab(status: Status): "opened" | "closed" {
|
|
206
372
|
return status === "done" || status === "canceled" ? "closed" : "opened";
|
|
207
373
|
}
|
|
@@ -244,6 +410,43 @@ function toDomain(gl: GlIssue): Issue {
|
|
|
244
410
|
};
|
|
245
411
|
}
|
|
246
412
|
|
|
413
|
+
function mrPullRequestDetails(mr: GlMergeRequest | GlMergeRequestExpanded, reviewers: GlMergeRequestReviewerEntry[]): PullRequestDetails {
|
|
414
|
+
const expanded = "changes_count" in mr ? mr : undefined;
|
|
415
|
+
return {
|
|
416
|
+
baseBranch: mr.target_branch,
|
|
417
|
+
headBranch: mr.source_branch,
|
|
418
|
+
headSha: mr.sha,
|
|
419
|
+
baseSha: expanded?.diff_refs.base_sha,
|
|
420
|
+
draft: mr.draft,
|
|
421
|
+
merged: mr.state === "merged",
|
|
422
|
+
mergedAt: mr.merged_at ?? undefined,
|
|
423
|
+
requestedReviewers: mr.reviewers?.length ? mr.reviewers.map((r) => r.username) : undefined,
|
|
424
|
+
mergeableState: mapMergeableState(mr.merge_status),
|
|
425
|
+
diffStat: expanded ? { filesChanged: parseChangesCount(expanded.changes_count) } : undefined,
|
|
426
|
+
reviewers: reviewers.length ? reviewers.map((r) => ({ username: r.user.username, state: mapReviewerState(r.state) })) : undefined,
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function mrToDomain(mr: GlMergeRequestExpanded, reviewers: GlMergeRequestReviewerEntry[]): Issue {
|
|
431
|
+
return {
|
|
432
|
+
ref: `gitlab:!${mr.iid}`,
|
|
433
|
+
id: String(mr.iid),
|
|
434
|
+
key: `!${mr.iid}`,
|
|
435
|
+
title: mr.title,
|
|
436
|
+
description: mr.description ?? undefined,
|
|
437
|
+
status: mapStatusFromGitLab(mr.state === "merged" ? "closed" : mr.state),
|
|
438
|
+
rawStatus: mr.state,
|
|
439
|
+
priority: priorityFromLabels(mr.labels ?? []),
|
|
440
|
+
labels: mr.labels?.length ? mr.labels : undefined,
|
|
441
|
+
assignee: mr.assignee?.username,
|
|
442
|
+
reporter: mr.author?.username,
|
|
443
|
+
url: mr.web_url,
|
|
444
|
+
createdAt: mr.created_at,
|
|
445
|
+
updatedAt: mr.updated_at,
|
|
446
|
+
pullRequest: mrPullRequestDetails(mr, reviewers),
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
247
450
|
function noteToDomain(n: GlNote): Comment {
|
|
248
451
|
return {
|
|
249
452
|
id: String(n.id),
|
package/src/index.ts
CHANGED
|
@@ -28,7 +28,7 @@ export { type GitLabOptions, GitLabRepository } from "./gitlab/gitlab.js";
|
|
|
28
28
|
export * from "./issue/errors.js";
|
|
29
29
|
export * from "./issue/issue.js";
|
|
30
30
|
export * from "./issue/repository.js";
|
|
31
|
-
export { NotSupportedError, TicketService, UnknownBackendError } from "./issue/service.js";
|
|
31
|
+
export { type BackendCapabilities, NotSupportedError, TicketService, UnknownBackendError } from "./issue/service.js";
|
|
32
32
|
export { type JiraOptions, JiraRepository } from "./jira/jira.js";
|
|
33
33
|
export type { TicketOperation, TicketOpInputs, TicketOpOutputs } from "./rpc/ops.js";
|
|
34
34
|
export type { FocusStatus, TicketFocusState } from "./sqlite/focus.js";
|
package/src/issue/issue.ts
CHANGED
|
@@ -57,6 +57,60 @@ export interface ExternalLink {
|
|
|
57
57
|
type?: string;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Normalized across backends even though availability differs: GitHub only reports this on
|
|
62
|
+
* get() (list/search omit it entirely); GitLab reports merge_status/detailed_merge_status on
|
|
63
|
+
* both list and get, but is still normalized to get()-only here so callers get one predictable
|
|
64
|
+
* contract instead of a per-backend availability difference. See the research Doc "Tickets
|
|
65
|
+
* PR/MR support: grounded GitHub & GitLab API research and domain design" for the source API
|
|
66
|
+
* fields each state is derived from.
|
|
67
|
+
*/
|
|
68
|
+
export type MergeableState = "mergeable" | "conflicting" | "checking" | "unknown";
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* get()-only on both backends. GitLab never totals additions/deletions in its merge request
|
|
72
|
+
* object (only a `changes_count` string like "5" or "1000+") -- getting real added/removed line
|
|
73
|
+
* counts would need a separate Diffs-API round trip this project's N+1-avoidance discipline
|
|
74
|
+
* says to skip, so additions/deletions stay undefined for GitLab.
|
|
75
|
+
*/
|
|
76
|
+
export interface PullRequestDiffStat {
|
|
77
|
+
filesChanged: number;
|
|
78
|
+
additions?: number;
|
|
79
|
+
deletions?: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Per-reviewer review state -- always a dedicated call on both backends (GitHub: listReviews(); GitLab: showReviewers()), never embedded in the list/get response itself. */
|
|
83
|
+
export interface PullRequestReviewer {
|
|
84
|
+
username: string;
|
|
85
|
+
/** Only populated by get() -- both backends require the same dedicated call regardless of path. */
|
|
86
|
+
state?: "approved" | "changes_requested" | "commented" | "pending" | "unreviewed";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The extra fields a GitHub pull request / GitLab merge request carries beyond a plain Issue.
|
|
91
|
+
* A PR/MR is an issue superset via both platforms' own APIs, so this lives as an optional field
|
|
92
|
+
* on Issue (see below) rather than a parallel type hierarchy -- every existing Issue consumer
|
|
93
|
+
* keeps working unchanged for a plain issue, where this is simply undefined.
|
|
94
|
+
*/
|
|
95
|
+
export interface PullRequestDetails {
|
|
96
|
+
/** Undefined at list()/search() time for a backend whose issue-superset listing endpoint doesn't carry branch refs (GitHub) -- populated by get() there. Always present for a backend with a dedicated MR endpoint (GitLab). */
|
|
97
|
+
baseBranch?: string;
|
|
98
|
+
headBranch?: string;
|
|
99
|
+
baseSha?: string;
|
|
100
|
+
headSha?: string;
|
|
101
|
+
draft?: boolean;
|
|
102
|
+
merged?: boolean;
|
|
103
|
+
mergedAt?: string;
|
|
104
|
+
/** list()/search()-cheap on both backends -- populated with zero extra calls. */
|
|
105
|
+
requestedReviewers?: string[];
|
|
106
|
+
/** get()-only -- see MergeableState's own doc comment for why this is normalized across backends. */
|
|
107
|
+
mergeableState?: MergeableState;
|
|
108
|
+
/** get()-only on both backends. */
|
|
109
|
+
diffStat?: PullRequestDiffStat;
|
|
110
|
+
/** get()-only on both backends (a dedicated call every time, on either backend). */
|
|
111
|
+
reviewers?: PullRequestReviewer[];
|
|
112
|
+
}
|
|
113
|
+
|
|
60
114
|
/** The unified representation of a work item, regardless of which platform it lives on. */
|
|
61
115
|
export interface Issue {
|
|
62
116
|
/** "backend:key", e.g. "jira:PROJ-42" or "github:#7". */
|
|
@@ -87,6 +141,8 @@ export interface Issue {
|
|
|
87
141
|
externalLinks?: ExternalLink[];
|
|
88
142
|
/** Custom fields keyed by their backend display name (e.g. Jira's "Target Version"), resolved via that backend's field-discovery manifest. Empty until discovery has run at least once for the backend. */
|
|
89
143
|
customFields?: Record<string, string>;
|
|
144
|
+
/** Present only for a GitHub pull request / GitLab merge request -- undefined for a plain issue. */
|
|
145
|
+
pullRequest?: PullRequestDetails;
|
|
90
146
|
}
|
|
91
147
|
|
|
92
148
|
export interface CreateInput {
|
package/src/issue/repository.ts
CHANGED
|
@@ -5,6 +5,31 @@
|
|
|
5
5
|
import type { Comment, CreateInput, Issue, ListFilter, UpdateInput } from "./issue.js";
|
|
6
6
|
import type { Template } from "./template.js";
|
|
7
7
|
|
|
8
|
+
export type BackendReadinessState = "ready" | "partial" | "blocked" | "unknown";
|
|
9
|
+
|
|
10
|
+
export interface BackendOperationReadiness {
|
|
11
|
+
readonly state: BackendReadinessState;
|
|
12
|
+
/** Names only; never configuration values. */
|
|
13
|
+
readonly missingConfiguration: readonly string[];
|
|
14
|
+
readonly recovery?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Local configuration assessment. Connectivity is intentionally never inferred or probed here. */
|
|
18
|
+
export interface BackendConfigurationReadiness {
|
|
19
|
+
readonly backendType: string;
|
|
20
|
+
readonly connectivity: "not_checked";
|
|
21
|
+
readonly read: BackendOperationReadiness;
|
|
22
|
+
readonly write: BackendOperationReadiness;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ConfigurationInspectable {
|
|
26
|
+
configurationReadiness(): BackendConfigurationReadiness;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function hasConfigurationReadiness(repo: IssueRepository): repo is IssueRepository & ConfigurationInspectable {
|
|
30
|
+
return typeof (repo as Partial<ConfigurationInspectable>).configurationReadiness === "function";
|
|
31
|
+
}
|
|
32
|
+
|
|
8
33
|
export interface IssueRepository {
|
|
9
34
|
/** Backend identifier used in refs, e.g. "github", "gitlab", "jira". */
|
|
10
35
|
readonly name: string;
|
|
@@ -117,3 +142,39 @@ export interface SyncScopeExpandable {
|
|
|
117
142
|
export function hasSyncScopeExpansion(repo: IssueRepository): repo is IssueRepository & SyncScopeExpandable {
|
|
118
143
|
return typeof (repo as Partial<SyncScopeExpandable>).buildSyncQuery === "function";
|
|
119
144
|
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Optional capability -- approve/merge a pull request or merge request. Both GitHub and GitLab
|
|
148
|
+
* support both actions as real REST endpoints (see the research Doc "Tickets PR/MR support:
|
|
149
|
+
* grounded GitHub & GitLab API research and domain design"), so both adapters implement this.
|
|
150
|
+
* Each method returns the refreshed Issue -- neither platform's own review/merge endpoint
|
|
151
|
+
* response is the full PR/MR object, so implementations re-fetch after acting.
|
|
152
|
+
*/
|
|
153
|
+
export interface PullRequestReviewable {
|
|
154
|
+
approvePullRequest(key: string, body?: string): Promise<Issue>;
|
|
155
|
+
mergePullRequest(key: string, method?: "merge" | "squash" | "rebase"): Promise<Issue>;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function hasPullRequestReview(repo: IssueRepository): repo is IssueRepository & PullRequestReviewable {
|
|
159
|
+
return (
|
|
160
|
+
typeof (repo as Partial<PullRequestReviewable>).approvePullRequest === "function" &&
|
|
161
|
+
typeof (repo as Partial<PullRequestReviewable>).mergePullRequest === "function"
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Optional capability -- request changes on a pull request. GitHub-only: confirmed against
|
|
167
|
+
* GitLab's own REST API (both gitbeaker's generated types and GitLab's official docs) that
|
|
168
|
+
* "request changes" has no REST endpoint at all -- it is GraphQL-only on GitLab. Adding a
|
|
169
|
+
* second HTTP client to gitlab.ts just for this one action is out of proportion to what this
|
|
170
|
+
* capability needs, so GitLab's repository simply does not implement this interface;
|
|
171
|
+
* TicketService.requestChanges() throws NotSupportedError via hasPullRequestChangesRequest(),
|
|
172
|
+
* the same pattern every other GitLab-unsupported action already uses (e.g. hasRawQuery).
|
|
173
|
+
*/
|
|
174
|
+
export interface PullRequestChangesRequestable {
|
|
175
|
+
requestPullRequestChanges(key: string, body: string): Promise<Issue>;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function hasPullRequestChangesRequest(repo: IssueRepository): repo is IssueRepository & PullRequestChangesRequestable {
|
|
179
|
+
return typeof (repo as Partial<PullRequestChangesRequestable>).requestPullRequestChanges === "function";
|
|
180
|
+
}
|
package/src/issue/service.ts
CHANGED
|
@@ -7,10 +7,14 @@
|
|
|
7
7
|
import type { Comment, CreateInput, Issue, ListFilter, UpdateInput } from "./issue.js";
|
|
8
8
|
import { parseRef } from "./issue.js";
|
|
9
9
|
import {
|
|
10
|
+
type BackendConfigurationReadiness,
|
|
10
11
|
hasBoardFilterDiscovery,
|
|
11
12
|
hasBoardQuickFilterDiscovery,
|
|
12
13
|
hasComments,
|
|
14
|
+
hasConfigurationReadiness,
|
|
13
15
|
hasFieldDiscovery,
|
|
16
|
+
hasPullRequestChangesRequest,
|
|
17
|
+
hasPullRequestReview,
|
|
14
18
|
hasRawQuery,
|
|
15
19
|
hasStatusDiscovery,
|
|
16
20
|
hasSyncScopeExpansion,
|
|
@@ -21,12 +25,17 @@ import type { Template } from "./template.js";
|
|
|
21
25
|
|
|
22
26
|
export interface BackendCapabilities {
|
|
23
27
|
readonly name: string;
|
|
28
|
+
readonly readiness: BackendConfigurationReadiness;
|
|
24
29
|
readonly supportsRawQuery: boolean;
|
|
25
30
|
readonly supportsFieldDiscovery: boolean;
|
|
26
31
|
readonly supportsStatusDiscovery: boolean;
|
|
27
32
|
readonly supportsTemplateDiscovery: boolean;
|
|
28
33
|
readonly supportsBoardQuickFilterDiscovery: boolean;
|
|
29
34
|
readonly supportsBoardFilterDiscovery: boolean;
|
|
35
|
+
/** approvePullRequest/mergePullRequest -- GitHub and GitLab both support this today. */
|
|
36
|
+
readonly supportsPullRequestReview: boolean;
|
|
37
|
+
/** requestPullRequestChanges -- GitHub only; GitLab has no such REST endpoint. */
|
|
38
|
+
readonly supportsPullRequestChangesRequest: boolean;
|
|
30
39
|
}
|
|
31
40
|
|
|
32
41
|
export class UnknownBackendError extends Error {
|
|
@@ -54,12 +63,30 @@ export class TicketService {
|
|
|
54
63
|
backendCapabilities(): BackendCapabilities[] {
|
|
55
64
|
return Object.values(this.repos).map((repo) => ({
|
|
56
65
|
name: repo.name,
|
|
66
|
+
readiness: hasConfigurationReadiness(repo)
|
|
67
|
+
? repo.configurationReadiness()
|
|
68
|
+
: {
|
|
69
|
+
backendType: repo.name,
|
|
70
|
+
connectivity: "not_checked",
|
|
71
|
+
read: {
|
|
72
|
+
state: "unknown",
|
|
73
|
+
missingConfiguration: [],
|
|
74
|
+
recovery: "This adapter does not expose local configuration readiness.",
|
|
75
|
+
},
|
|
76
|
+
write: {
|
|
77
|
+
state: "unknown",
|
|
78
|
+
missingConfiguration: [],
|
|
79
|
+
recovery: "This adapter does not expose local configuration readiness.",
|
|
80
|
+
},
|
|
81
|
+
},
|
|
57
82
|
supportsRawQuery: hasRawQuery(repo),
|
|
58
83
|
supportsFieldDiscovery: hasFieldDiscovery(repo),
|
|
59
84
|
supportsStatusDiscovery: hasStatusDiscovery(repo),
|
|
60
85
|
supportsTemplateDiscovery: hasTemplateDiscovery(repo),
|
|
61
86
|
supportsBoardQuickFilterDiscovery: hasBoardQuickFilterDiscovery(repo),
|
|
62
87
|
supportsBoardFilterDiscovery: hasBoardFilterDiscovery(repo),
|
|
88
|
+
supportsPullRequestReview: hasPullRequestReview(repo),
|
|
89
|
+
supportsPullRequestChangesRequest: hasPullRequestChangesRequest(repo),
|
|
63
90
|
}));
|
|
64
91
|
}
|
|
65
92
|
|
|
@@ -184,4 +211,28 @@ export class TicketService {
|
|
|
184
211
|
if (!hasBoardFilterDiscovery(repo)) throw new NotSupportedError(backend, "board filter discovery");
|
|
185
212
|
return repo.discoverBoardFilterJql(boardId);
|
|
186
213
|
}
|
|
214
|
+
|
|
215
|
+
/** Approves a pull request / merge request. Both GitHub and GitLab support this today. */
|
|
216
|
+
async approve(ref: string, body?: string): Promise<Issue> {
|
|
217
|
+
const { backend, key } = parseRef(ref);
|
|
218
|
+
const repo = this.repo(backend);
|
|
219
|
+
if (!hasPullRequestReview(repo)) throw new NotSupportedError(backend, "pull request review");
|
|
220
|
+
return repo.approvePullRequest(key, body);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Requests changes on a pull request. GitHub-only -- GitLab has no such REST endpoint (see PullRequestChangesRequestable's own doc comment), so this always throws NotSupportedError there. */
|
|
224
|
+
async requestChanges(ref: string, body: string): Promise<Issue> {
|
|
225
|
+
const { backend, key } = parseRef(ref);
|
|
226
|
+
const repo = this.repo(backend);
|
|
227
|
+
if (!hasPullRequestChangesRequest(repo)) throw new NotSupportedError(backend, "requesting changes on a pull request");
|
|
228
|
+
return repo.requestPullRequestChanges(key, body);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Merges a pull request / merge request. Both GitHub and GitLab support this today. */
|
|
232
|
+
async merge(ref: string, method?: "merge" | "squash" | "rebase"): Promise<Issue> {
|
|
233
|
+
const { backend, key } = parseRef(ref);
|
|
234
|
+
const repo = this.repo(backend);
|
|
235
|
+
if (!hasPullRequestReview(repo)) throw new NotSupportedError(backend, "pull request review");
|
|
236
|
+
return repo.mergePullRequest(key, method);
|
|
237
|
+
}
|
|
187
238
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fail-closed transport classification shared by provider adapters.
|
|
3
|
+
*
|
|
4
|
+
* Only stable error names/codes emitted by the runtimes and HTTP clients we
|
|
5
|
+
* use are accepted. Messages are deliberately ignored: they are unstable,
|
|
6
|
+
* may contain credentials/URLs, and can make an arbitrary programming error
|
|
7
|
+
* look like a network outage.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type BackendTransportFailureKind = "unreachable" | "timeout";
|
|
11
|
+
|
|
12
|
+
const TIMEOUT_CODES = new Set([
|
|
13
|
+
"ABORT_ERR",
|
|
14
|
+
"ECONNABORTED",
|
|
15
|
+
"ERR_CANCELED",
|
|
16
|
+
"ESOCKETTIMEDOUT",
|
|
17
|
+
"ETIMEDOUT",
|
|
18
|
+
"UND_ERR_BODY_TIMEOUT",
|
|
19
|
+
"UND_ERR_CONNECT_TIMEOUT",
|
|
20
|
+
"UND_ERR_HEADERS_TIMEOUT",
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
const UNREACHABLE_CODES = new Set([
|
|
24
|
+
"CERT_HAS_EXPIRED",
|
|
25
|
+
"DEPTH_ZERO_SELF_SIGNED_CERT",
|
|
26
|
+
"EAI_AGAIN",
|
|
27
|
+
"ECONNREFUSED",
|
|
28
|
+
"ECONNRESET",
|
|
29
|
+
"EHOSTUNREACH",
|
|
30
|
+
"ENETUNREACH",
|
|
31
|
+
"ENOTFOUND",
|
|
32
|
+
"EPIPE",
|
|
33
|
+
"ERR_NETWORK",
|
|
34
|
+
"ERR_TLS_CERT_ALTNAME_INVALID",
|
|
35
|
+
"SELF_SIGNED_CERT_IN_CHAIN",
|
|
36
|
+
"UNABLE_TO_GET_ISSUER_CERT",
|
|
37
|
+
"UNABLE_TO_VERIFY_LEAF_SIGNATURE",
|
|
38
|
+
"UND_ERR_SOCKET",
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
const TIMEOUT_NAMES = new Set(["AbortError", "GitbeakerTimeoutError", "TimeoutError"]);
|
|
42
|
+
|
|
43
|
+
function record(value: unknown): Record<string, unknown> | undefined {
|
|
44
|
+
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Returns undefined for every unreviewed/unknown exception. */
|
|
48
|
+
export function classifyBackendTransportFailure(error: unknown): BackendTransportFailureKind | undefined {
|
|
49
|
+
let current: unknown = error;
|
|
50
|
+
const seen = new Set<unknown>();
|
|
51
|
+
|
|
52
|
+
for (let depth = 0; depth < 6; depth++) {
|
|
53
|
+
const candidate = record(current);
|
|
54
|
+
if (!candidate || seen.has(current)) return undefined;
|
|
55
|
+
seen.add(current);
|
|
56
|
+
|
|
57
|
+
const name = typeof candidate.name === "string" ? candidate.name : undefined;
|
|
58
|
+
if (name && TIMEOUT_NAMES.has(name)) return "timeout";
|
|
59
|
+
|
|
60
|
+
const code = typeof candidate.code === "string" ? candidate.code.toUpperCase() : undefined;
|
|
61
|
+
if (code && TIMEOUT_CODES.has(code)) return "timeout";
|
|
62
|
+
if (code && UNREACHABLE_CODES.has(code)) return "unreachable";
|
|
63
|
+
|
|
64
|
+
current = candidate.cause;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
package/src/jira/jira.ts
CHANGED
|
@@ -18,8 +18,10 @@ import type { HttpException, Config as JiraClientConfig } from "jira.js";
|
|
|
18
18
|
import { AgileClient, Version2Client } from "jira.js";
|
|
19
19
|
import { ApiError, BackendConfigurationError, BackendConnectionError, IssueNotFoundError } from "../issue/errors.js";
|
|
20
20
|
import type { Comment, CreateInput, Issue, IssueLink, ListFilter, parsePriority, Status, UpdateInput } from "../issue/issue.js";
|
|
21
|
+
import type { BackendConfigurationReadiness } from "../issue/repository.js";
|
|
21
22
|
import type { Template } from "../issue/template.js";
|
|
22
23
|
import { buildTemplateBody, extractTemplateSections } from "../issue/template.js";
|
|
24
|
+
import { classifyBackendTransportFailure } from "../issue/transport-error.js";
|
|
23
25
|
import * as manifest from "./manifest.js";
|
|
24
26
|
|
|
25
27
|
/**
|
|
@@ -180,6 +182,21 @@ export class JiraRepository {
|
|
|
180
182
|
this.client = new Version2Client(this.clientConfig);
|
|
181
183
|
}
|
|
182
184
|
|
|
185
|
+
configurationReadiness(): BackendConfigurationReadiness {
|
|
186
|
+
return {
|
|
187
|
+
backendType: "jira",
|
|
188
|
+
connectivity: "not_checked",
|
|
189
|
+
read: { state: "ready", missingConfiguration: [] },
|
|
190
|
+
write: this.project
|
|
191
|
+
? { state: "ready", missingConfiguration: [] }
|
|
192
|
+
: {
|
|
193
|
+
state: "partial",
|
|
194
|
+
missingConfiguration: ["JIRA_PROJECT"],
|
|
195
|
+
recovery: "Set JIRA_PROJECT (or pass input.project) for issue creation; updates and comments remain available.",
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
183
200
|
/**
|
|
184
201
|
* An explicit filter.project always wins and narrows to exactly that one
|
|
185
202
|
* project; with none given, defaults to every project this repository
|
|
@@ -219,12 +236,15 @@ export class JiraRepository {
|
|
|
219
236
|
return (await fn()) as T;
|
|
220
237
|
} catch (err) {
|
|
221
238
|
const status = (err as Partial<HttpException>)?.status;
|
|
222
|
-
|
|
239
|
+
const responseStatus = (err as { response?: { status?: unknown } })?.response?.status;
|
|
240
|
+
if (typeof status === "number" && typeof responseStatus === "number") {
|
|
223
241
|
if (status === 404) throw new IssueNotFoundError("jira", key ?? "?");
|
|
224
242
|
const message = err instanceof Error ? err.message : String(err);
|
|
225
243
|
throw new ApiError("jira", "?", key ?? "?", status, redact(message));
|
|
226
244
|
}
|
|
227
|
-
|
|
245
|
+
const transportKind = classifyBackendTransportFailure(err);
|
|
246
|
+
if (transportKind) throw new BackendConnectionError("jira", transportKind, err);
|
|
247
|
+
throw err;
|
|
228
248
|
}
|
|
229
249
|
}
|
|
230
250
|
|
package/src/rpc/ops.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* import from either side without pulling in bun:sqlite or Bun.serve.
|
|
6
6
|
*/
|
|
7
7
|
import type { Comment, CreateInput, Issue, ListFilter, UpdateInput } from "../issue/issue.js";
|
|
8
|
+
import type { BackendCapabilities } from "../issue/service.js";
|
|
8
9
|
import type { Template } from "../issue/template.js";
|
|
9
10
|
import type { TicketFocusState } from "../sqlite/focus.js";
|
|
10
11
|
import type { SavedQuery } from "../sqlite/saved-queries.js";
|
|
@@ -20,6 +21,9 @@ export type TicketOperation =
|
|
|
20
21
|
| "issue.children"
|
|
21
22
|
| "issue.comments"
|
|
22
23
|
| "issue.comment_add"
|
|
24
|
+
| "issue.approve"
|
|
25
|
+
| "issue.request_changes"
|
|
26
|
+
| "issue.merge"
|
|
23
27
|
| "ledger.search"
|
|
24
28
|
| "ledger.stats"
|
|
25
29
|
| "focus.set"
|
|
@@ -54,6 +58,9 @@ export interface TicketOpInputs extends Record<TicketOperation, unknown> {
|
|
|
54
58
|
"issue.children": { ref: string };
|
|
55
59
|
"issue.comments": { ref: string };
|
|
56
60
|
"issue.comment_add": { ref: string; body: string };
|
|
61
|
+
"issue.approve": { ref: string; body?: string };
|
|
62
|
+
"issue.request_changes": { ref: string; body: string };
|
|
63
|
+
"issue.merge": { ref: string; method?: "merge" | "squash" | "rebase" };
|
|
57
64
|
"ledger.search": { query: string; limit?: number; backend?: string };
|
|
58
65
|
"ledger.stats": Record<string, never>;
|
|
59
66
|
"focus.set": { ref: string };
|
|
@@ -83,7 +90,7 @@ export interface TicketOpInputs extends Record<TicketOperation, unknown> {
|
|
|
83
90
|
export type StagePushResult = { issue: Issue } | { comment: Comment };
|
|
84
91
|
|
|
85
92
|
export interface TicketOpOutputs extends Record<TicketOperation, unknown> {
|
|
86
|
-
"backends.list": { backends:
|
|
93
|
+
"backends.list": { backends: BackendCapabilities[] };
|
|
87
94
|
"issue.list": { issues: Issue[] };
|
|
88
95
|
"issue.get": { issue: Issue };
|
|
89
96
|
"issue.create": { issue: Issue };
|
|
@@ -92,6 +99,9 @@ export interface TicketOpOutputs extends Record<TicketOperation, unknown> {
|
|
|
92
99
|
"issue.children": { issues: Issue[] };
|
|
93
100
|
"issue.comments": { comments: Comment[] };
|
|
94
101
|
"issue.comment_add": { comment: Comment };
|
|
102
|
+
"issue.approve": { issue: Issue };
|
|
103
|
+
"issue.request_changes": { issue: Issue };
|
|
104
|
+
"issue.merge": { issue: Issue };
|
|
95
105
|
"ledger.search": { issues: Issue[] };
|
|
96
106
|
"ledger.stats": { backends: { backend: string; count: number }[] };
|
|
97
107
|
"focus.set": { focus: TicketFocusState };
|
|
@@ -127,6 +137,9 @@ export const TICKET_OPERATIONS: TicketOperation[] = [
|
|
|
127
137
|
"issue.children",
|
|
128
138
|
"issue.comments",
|
|
129
139
|
"issue.comment_add",
|
|
140
|
+
"issue.approve",
|
|
141
|
+
"issue.request_changes",
|
|
142
|
+
"issue.merge",
|
|
130
143
|
"ledger.search",
|
|
131
144
|
"ledger.stats",
|
|
132
145
|
"focus.set",
|
package/src/rpc/server.ts
CHANGED
|
@@ -71,6 +71,9 @@ export const TICKET_OP_HANDLERS: { [Op in TicketOperation]: Handler<Op> } = {
|
|
|
71
71
|
"issue.children": async (deps, input) => ({ issues: await deps.service.children(input.ref) }),
|
|
72
72
|
"issue.comments": async (deps, input) => ({ comments: await deps.service.comments(input.ref) }),
|
|
73
73
|
"issue.comment_add": async (deps, input) => ({ comment: await deps.service.addComment(input.ref, input.body) }),
|
|
74
|
+
"issue.approve": async (deps, input) => ({ issue: await deps.service.approve(input.ref, input.body) }),
|
|
75
|
+
"issue.request_changes": async (deps, input) => ({ issue: await deps.service.requestChanges(input.ref, input.body) }),
|
|
76
|
+
"issue.merge": async (deps, input) => ({ issue: await deps.service.merge(input.ref, input.method) }),
|
|
74
77
|
"ledger.search": async (deps, input) => ({ issues: deps.ledger.search(input.query, input.limit, input.backend) }),
|
|
75
78
|
"ledger.stats": async (deps) => ({ backends: deps.ledger.stats() }),
|
|
76
79
|
"focus.set": async (deps, input) => {
|