@danypops/tickets 0.10.5 → 0.12.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/package.json +1 -1
- package/src/agent-tools/tickets-vehicle.ts +71 -15
- package/src/cli/index.ts +34 -0
- package/src/github/github.ts +190 -18
- package/src/gitlab/gitlab.ts +232 -10
- package/src/issue/issue.ts +80 -0
- package/src/issue/repository.ts +36 -0
- package/src/issue/service.ts +32 -0
- package/src/jira/jira.ts +26 -0
- package/src/rpc/ops.ts +12 -0
- package/src/rpc/server.ts +3 -0
package/package.json
CHANGED
|
@@ -34,6 +34,7 @@ const LIMITS = { defaultTimeoutMs: 10_000, maxTimeoutMs: 30_000, maxRequestBytes
|
|
|
34
34
|
|
|
35
35
|
const stringProp: LooseObjectProperty = { type: "string" };
|
|
36
36
|
const numberProp: LooseObjectProperty = { type: "number" };
|
|
37
|
+
const booleanProp: LooseObjectProperty = { type: "boolean" };
|
|
37
38
|
|
|
38
39
|
interface OperationSpec {
|
|
39
40
|
readonly action: TicketOperation;
|
|
@@ -71,7 +72,8 @@ const OPERATIONS: readonly OperationSpec[] = [
|
|
|
71
72
|
},
|
|
72
73
|
{
|
|
73
74
|
action: "issue.list",
|
|
74
|
-
description:
|
|
75
|
+
description:
|
|
76
|
+
"Lists issues from one backend, optionally filtered. reportedByMe/assignedToMe/reviewRequestedOfMe/qaContactIsMe filter to the caller's own tickets using each backend's own identity (no username needed); set more than one to OR them together. reviewRequestedOfMe is GitHub/GitLab-only (PR/MR reviewer requests); qaContactIsMe is Jira-only (its discovered 'QA Contact' field) -- an unsupported flag on a given backend throws rather than being silently ignored.",
|
|
75
77
|
effect: "read",
|
|
76
78
|
properties: {
|
|
77
79
|
backend: stringProp,
|
|
@@ -80,11 +82,25 @@ const OPERATIONS: readonly OperationSpec[] = [
|
|
|
80
82
|
assignee: stringProp,
|
|
81
83
|
labels: stringArrayProp,
|
|
82
84
|
limit: numberProp,
|
|
85
|
+
reportedByMe: booleanProp,
|
|
86
|
+
assignedToMe: booleanProp,
|
|
87
|
+
reviewRequestedOfMe: booleanProp,
|
|
88
|
+
qaContactIsMe: booleanProp,
|
|
83
89
|
},
|
|
84
90
|
required: ["backend"],
|
|
85
|
-
mapInput: ({ backend, project, status, assignee, labels, limit }) => ({
|
|
91
|
+
mapInput: ({ backend, project, status, assignee, labels, limit, reportedByMe, assignedToMe, reviewRequestedOfMe, qaContactIsMe }) => ({
|
|
86
92
|
backend,
|
|
87
|
-
filter: definedEntriesOnly({
|
|
93
|
+
filter: definedEntriesOnly({
|
|
94
|
+
project,
|
|
95
|
+
status,
|
|
96
|
+
assignee,
|
|
97
|
+
labels,
|
|
98
|
+
limit,
|
|
99
|
+
reportedByMe,
|
|
100
|
+
assignedToMe,
|
|
101
|
+
reviewRequestedOfMe,
|
|
102
|
+
qaContactIsMe,
|
|
103
|
+
}),
|
|
88
104
|
}),
|
|
89
105
|
},
|
|
90
106
|
{
|
|
@@ -136,6 +152,28 @@ const OPERATIONS: readonly OperationSpec[] = [
|
|
|
136
152
|
properties: { ref: stringProp, body: stringProp },
|
|
137
153
|
required: ["ref", "body"],
|
|
138
154
|
},
|
|
155
|
+
{
|
|
156
|
+
action: "issue.approve",
|
|
157
|
+
description: "Approves a pull request / merge request on a live backend (GitHub, GitLab) -- a real, externally visible write.",
|
|
158
|
+
effect: "external-write",
|
|
159
|
+
properties: { ref: stringProp, body: stringProp },
|
|
160
|
+
required: ["ref"],
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
action: "issue.request_changes",
|
|
164
|
+
description:
|
|
165
|
+
"Requests changes on a pull request on a live backend -- a real, externally visible write. GitHub only: GitLab has no REST endpoint for this.",
|
|
166
|
+
effect: "external-write",
|
|
167
|
+
properties: { ref: stringProp, body: stringProp },
|
|
168
|
+
required: ["ref", "body"],
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
action: "issue.merge",
|
|
172
|
+
description: "Merges a pull request / merge request on a live backend (GitHub, GitLab) -- a real, externally visible write.",
|
|
173
|
+
effect: "external-write",
|
|
174
|
+
properties: { ref: stringProp, method: stringProp },
|
|
175
|
+
required: ["ref"],
|
|
176
|
+
},
|
|
139
177
|
{
|
|
140
178
|
action: "ledger.search",
|
|
141
179
|
description: "Searches the local pooled-issue ledger (no live backend call).",
|
|
@@ -275,13 +313,14 @@ const OPERATIONS: readonly OperationSpec[] = [
|
|
|
275
313
|
];
|
|
276
314
|
|
|
277
315
|
/**
|
|
278
|
-
* The five discover.* operations
|
|
279
|
-
* repository implements the matching optional
|
|
280
|
-
* structurally -- never a hardcoded backend name
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
316
|
+
* The five discover.* operations, plus the three pull-request-review operations below,
|
|
317
|
+
* only ever succeed against a backend whose repository implements the matching optional
|
|
318
|
+
* capability (structurally -- never a hardcoded backend name: discover.* is Jira-only
|
|
319
|
+
* today, issue.approve/issue.merge need PullRequestReviewable (GitHub, GitLab both),
|
|
320
|
+
* issue.request_changes needs PullRequestChangesRequestable (GitHub only)). An operation
|
|
321
|
+
* none of the currently configured backends could possibly satisfy is marked unavailable
|
|
322
|
+
* so it never appears in the LLM's callable tool list in the first place, instead of being
|
|
323
|
+
* offered and then failing with NotSupportedError on the first real call.
|
|
285
324
|
*/
|
|
286
325
|
const DISCOVER_AVAILABILITY: readonly { action: TicketOperation; capability: keyof BackendCapabilities; reason: string }[] = [
|
|
287
326
|
{ action: "discover.fields", capability: "supportsFieldDiscovery", reason: "no configured backend supports field discovery (Jira only)" },
|
|
@@ -305,14 +344,31 @@ const DISCOVER_AVAILABILITY: readonly { action: TicketOperation; capability: key
|
|
|
305
344
|
capability: "supportsBoardFilterDiscovery",
|
|
306
345
|
reason: "no configured backend supports board filter discovery (Jira only)",
|
|
307
346
|
},
|
|
347
|
+
{
|
|
348
|
+
action: "issue.approve",
|
|
349
|
+
capability: "supportsPullRequestReview",
|
|
350
|
+
reason: "no configured backend supports pull request review (GitHub, GitLab)",
|
|
351
|
+
},
|
|
352
|
+
{
|
|
353
|
+
action: "issue.merge",
|
|
354
|
+
capability: "supportsPullRequestReview",
|
|
355
|
+
reason: "no configured backend supports pull request review (GitHub, GitLab)",
|
|
356
|
+
},
|
|
357
|
+
{
|
|
358
|
+
action: "issue.request_changes",
|
|
359
|
+
capability: "supportsPullRequestChangesRequest",
|
|
360
|
+
reason: "no configured backend supports requesting changes on a pull request (GitHub only)",
|
|
361
|
+
},
|
|
308
362
|
];
|
|
309
363
|
|
|
310
364
|
/**
|
|
311
|
-
* Re-syncs the five discover.* operations
|
|
312
|
-
*
|
|
313
|
-
* built, and again after every live
|
|
314
|
-
* createBackendRefreshTask), so a Jira credential added or
|
|
315
|
-
* runtime flips these tools' visibility without a
|
|
365
|
+
* Re-syncs every capability-gated operation's availability (the five discover.* operations
|
|
366
|
+
* plus issue.approve/issue.request_changes/issue.merge) against the service's current
|
|
367
|
+
* backend set -- called once right after the registry is built, and again after every live
|
|
368
|
+
* backend refresh (config.ts's createBackendRefreshTask), so a Jira credential added or a
|
|
369
|
+
* GitHub/GitLab backend added or removed at runtime flips these tools' visibility without a
|
|
370
|
+
* daemon restart. Name kept from before pull-request support existed -- still exported and
|
|
371
|
+
* called by that name from bootstrap.ts and existing tests.
|
|
316
372
|
*/
|
|
317
373
|
export function syncDiscoverAvailability(registry: VehicleRegistry, service: TicketService): void {
|
|
318
374
|
const capabilities = service.backendCapabilities();
|
package/src/cli/index.ts
CHANGED
|
@@ -47,6 +47,10 @@ program
|
|
|
47
47
|
.option("--assignee <user>", "filter by assignee")
|
|
48
48
|
.option("--label <label...>", "filter by label(s)")
|
|
49
49
|
.option("--limit <n>", "max results", (v) => Number.parseInt(v, 10))
|
|
50
|
+
.option("--reported-by-me", "only issues reported/authored by you (Jira reporter / GitHub PR author / GitLab issue author)")
|
|
51
|
+
.option("--assigned-to-me", "only issues assigned to you")
|
|
52
|
+
.option("--review-requested-of-me", "only PRs/MRs where you're requested as a reviewer (GitHub/GitLab only)")
|
|
53
|
+
.option("--qa-contact-is-me", "only issues where you're the QA Contact (Jira only)")
|
|
50
54
|
.action(async (opts) => {
|
|
51
55
|
const filter: ListFilter = {
|
|
52
56
|
project: opts.project,
|
|
@@ -54,6 +58,10 @@ program
|
|
|
54
58
|
assignee: opts.assignee,
|
|
55
59
|
labels: opts.label,
|
|
56
60
|
limit: opts.limit,
|
|
61
|
+
reportedByMe: opts.reportedByMe,
|
|
62
|
+
assignedToMe: opts.assignedToMe,
|
|
63
|
+
reviewRequestedOfMe: opts.reviewRequestedOfMe,
|
|
64
|
+
qaContactIsMe: opts.qaContactIsMe,
|
|
57
65
|
};
|
|
58
66
|
await withClient((client) => client.call("issue.list", { backend: opts.backend, filter }));
|
|
59
67
|
});
|
|
@@ -124,6 +132,32 @@ program
|
|
|
124
132
|
await withClient((client) => client.call("issue.children", { ref }));
|
|
125
133
|
});
|
|
126
134
|
|
|
135
|
+
program
|
|
136
|
+
.command("approve <ref>")
|
|
137
|
+
.description("approve a pull request / merge request (GitHub, GitLab)")
|
|
138
|
+
.option("--body <text>", "optional review comment (GitHub only -- GitLab's approve endpoint has no comment body)")
|
|
139
|
+
.action(async (ref: string, opts) => {
|
|
140
|
+
await withClient((client) => client.call("issue.approve", { ref, body: opts.body }));
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
program
|
|
144
|
+
.command("request-changes <ref> <body>")
|
|
145
|
+
.description("request changes on a pull request -- GitHub only, GitLab has no such REST endpoint")
|
|
146
|
+
.action(async (ref: string, body: string) => {
|
|
147
|
+
await withClient((client) => client.call("issue.request_changes", { ref, body }));
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
program
|
|
151
|
+
.command("merge <ref>")
|
|
152
|
+
.description("merge a pull request / merge request (GitHub, GitLab)")
|
|
153
|
+
.option(
|
|
154
|
+
"--method <method>",
|
|
155
|
+
"merge | squash | rebase (GitLab: only squash is distinct from a plain merge; rebase falls back to a plain merge)",
|
|
156
|
+
)
|
|
157
|
+
.action(async (ref: string, opts) => {
|
|
158
|
+
await withClient((client) => client.call("issue.merge", { ref, method: opts.method }));
|
|
159
|
+
});
|
|
160
|
+
|
|
127
161
|
const comment = program.command("comment").description("comment operations");
|
|
128
162
|
|
|
129
163
|
comment
|
package/src/github/github.ts
CHANGED
|
@@ -22,7 +22,18 @@
|
|
|
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";
|
|
26
37
|
import type { BackendConfigurationReadiness } from "../issue/repository.js";
|
|
27
38
|
import { classifyBackendTransportFailure } from "../issue/transport-error.js";
|
|
28
39
|
|
|
@@ -56,7 +67,10 @@ interface GhIssue {
|
|
|
56
67
|
labels: (GhLabel | string)[];
|
|
57
68
|
created_at: string;
|
|
58
69
|
updated_at: string;
|
|
59
|
-
|
|
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;
|
|
60
74
|
}
|
|
61
75
|
interface GhComment {
|
|
62
76
|
id: number;
|
|
@@ -65,6 +79,24 @@ interface GhComment {
|
|
|
65
79
|
updated_at: string;
|
|
66
80
|
user: GhUser | null;
|
|
67
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
|
+
}
|
|
68
100
|
|
|
69
101
|
export class GitHubRepository {
|
|
70
102
|
readonly name: string;
|
|
@@ -133,19 +165,34 @@ export class GitHubRepository {
|
|
|
133
165
|
}
|
|
134
166
|
|
|
135
167
|
async list(filter: ListFilter): Promise<Issue[]> {
|
|
168
|
+
if (filter.qaContactIsMe) throw new Error('github: "qaContactIsMe" is a Jira-only concept, not supported on the github backend');
|
|
136
169
|
const limit = filter.limit && filter.limit > 0 ? filter.limit : 50;
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
170
|
+
const meQualifiers = buildMeQualifiers(filter);
|
|
171
|
+
if (meQualifiers.length === 0) {
|
|
172
|
+
const raw = await this.call((signal) =>
|
|
173
|
+
this.client.rest.issues.listForRepo({
|
|
174
|
+
owner: this.owner,
|
|
175
|
+
repo: this.repoName(),
|
|
176
|
+
per_page: limit,
|
|
177
|
+
state: filter.status ? mapStatusToGitHub(filter.status) : "all",
|
|
178
|
+
assignee: filter.assignee,
|
|
179
|
+
labels: filter.labels?.length ? filter.labels.join(",") : undefined,
|
|
180
|
+
request: { signal },
|
|
181
|
+
}),
|
|
182
|
+
);
|
|
183
|
+
return (raw as GhIssue[]).map((i) => toDomain(i));
|
|
184
|
+
}
|
|
185
|
+
// Any "me" flag routes through the Search API instead of listForRepo: it's the only GitHub
|
|
186
|
+
// surface with a review-requested filter at all (confirmed against GitHub's own REST docs --
|
|
187
|
+
// listForRepo has no such parameter), and it happens to support author/assignee "me" qualifiers
|
|
188
|
+
// too, so one call covers every combination of the three flags. Response items are Issue-shaped
|
|
189
|
+
// (same pull_request: {merged_at} stub as listForRepo's own entries -- confirmed against
|
|
190
|
+
// @octokit/openapi-types' issue-search-result-item schema), so toDomain() applies unchanged.
|
|
191
|
+
const q = buildMeSearchQuery(this.owner, this.repoName(), filter, meQualifiers);
|
|
192
|
+
const result = (await this.call((signal) =>
|
|
193
|
+
this.client.rest.search.issuesAndPullRequests({ q, per_page: limit, request: { signal } }),
|
|
194
|
+
)) as { items: GhIssue[] };
|
|
195
|
+
return result.items.map((i) => toDomain(i));
|
|
149
196
|
}
|
|
150
197
|
|
|
151
198
|
async get(key: string): Promise<Issue> {
|
|
@@ -153,8 +200,26 @@ export class GitHubRepository {
|
|
|
153
200
|
const raw = (await this.call((signal) =>
|
|
154
201
|
this.client.rest.issues.get({ owner: this.owner, repo: this.repoName(), issue_number, request: { signal } }),
|
|
155
202
|
)) as GhIssue;
|
|
156
|
-
if (raw.pull_request)
|
|
157
|
-
|
|
203
|
+
if (!raw.pull_request) return toDomain(raw);
|
|
204
|
+
// A PR's full shape (base/head/mergeable/diffStat/requestedReviewers) is not on the Issues
|
|
205
|
+
// API's own "issue" schema at all -- only reachable via the dedicated Pulls API, and only
|
|
206
|
+
// fetched here (the single-item path), never from list()/search(), per this project's own
|
|
207
|
+
// N+1-avoidance discipline. See the research Doc's correction for why this differs from
|
|
208
|
+
// list()'s zero-extra-call population below.
|
|
209
|
+
const [pull, reviews] = await Promise.all([this.fetchPullRequest(issue_number), this.fetchReviews(issue_number)]);
|
|
210
|
+
return toDomain(raw, pullRequestDetailsFromFull(pull, reviews));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private async fetchPullRequest(pull_number: number): Promise<GhPullRequestFull> {
|
|
214
|
+
return (await this.call((signal) =>
|
|
215
|
+
this.client.rest.pulls.get({ owner: this.owner, repo: this.repoName(), pull_number, request: { signal } }),
|
|
216
|
+
)) as GhPullRequestFull;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private async fetchReviews(pull_number: number): Promise<GhReview[]> {
|
|
220
|
+
return (await this.call((signal) =>
|
|
221
|
+
this.client.rest.pulls.listReviews({ owner: this.owner, repo: this.repoName(), pull_number, request: { signal } }),
|
|
222
|
+
)) as GhReview[];
|
|
158
223
|
}
|
|
159
224
|
|
|
160
225
|
async create(input: CreateInput): Promise<Issue> {
|
|
@@ -199,7 +264,7 @@ export class GitHubRepository {
|
|
|
199
264
|
const result = (await this.call((signal) =>
|
|
200
265
|
this.client.rest.search.issuesAndPullRequests({ q: `${scope} ${query}`, per_page: limit, request: { signal } }),
|
|
201
266
|
)) as { items: GhIssue[] };
|
|
202
|
-
return result.items.
|
|
267
|
+
return result.items.map((i) => toDomain(i));
|
|
203
268
|
}
|
|
204
269
|
|
|
205
270
|
// GitHub has no native sub-issue relationship exposed via REST v3.
|
|
@@ -207,6 +272,47 @@ export class GitHubRepository {
|
|
|
207
272
|
return [];
|
|
208
273
|
}
|
|
209
274
|
|
|
275
|
+
async approvePullRequest(key: string, body?: string): Promise<Issue> {
|
|
276
|
+
this.requireAuth();
|
|
277
|
+
const pull_number = parseIssueNumber(key);
|
|
278
|
+
await this.call((signal) =>
|
|
279
|
+
this.client.rest.pulls.createReview({
|
|
280
|
+
owner: this.owner,
|
|
281
|
+
repo: this.repoName(),
|
|
282
|
+
pull_number,
|
|
283
|
+
event: "APPROVE",
|
|
284
|
+
body,
|
|
285
|
+
request: { signal },
|
|
286
|
+
}),
|
|
287
|
+
);
|
|
288
|
+
return this.get(key);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async requestPullRequestChanges(key: string, body: string): Promise<Issue> {
|
|
292
|
+
this.requireAuth();
|
|
293
|
+
const pull_number = parseIssueNumber(key);
|
|
294
|
+
await this.call((signal) =>
|
|
295
|
+
this.client.rest.pulls.createReview({
|
|
296
|
+
owner: this.owner,
|
|
297
|
+
repo: this.repoName(),
|
|
298
|
+
pull_number,
|
|
299
|
+
event: "REQUEST_CHANGES",
|
|
300
|
+
body,
|
|
301
|
+
request: { signal },
|
|
302
|
+
}),
|
|
303
|
+
);
|
|
304
|
+
return this.get(key);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async mergePullRequest(key: string, method?: "merge" | "squash" | "rebase"): Promise<Issue> {
|
|
308
|
+
this.requireAuth();
|
|
309
|
+
const pull_number = parseIssueNumber(key);
|
|
310
|
+
await this.call((signal) =>
|
|
311
|
+
this.client.rest.pulls.merge({ owner: this.owner, repo: this.repoName(), pull_number, merge_method: method, request: { signal } }),
|
|
312
|
+
);
|
|
313
|
+
return this.get(key);
|
|
314
|
+
}
|
|
315
|
+
|
|
210
316
|
async listComments(key: string): Promise<Comment[]> {
|
|
211
317
|
const issue_number = parseIssueNumber(key);
|
|
212
318
|
const raw = (await this.call((signal) =>
|
|
@@ -271,6 +377,24 @@ function mapStatusToGitHub(status: Status): "open" | "closed" {
|
|
|
271
377
|
return status === "done" || status === "canceled" ? "closed" : "open";
|
|
272
378
|
}
|
|
273
379
|
|
|
380
|
+
/** ListFilter.{reportedByMe,assignedToMe,reviewRequestedOfMe} -> GitHub Search API qualifiers -- see ListFilter's own doc comment. */
|
|
381
|
+
function buildMeQualifiers(filter: ListFilter): string[] {
|
|
382
|
+
const qualifiers: string[] = [];
|
|
383
|
+
if (filter.reportedByMe) qualifiers.push("author:@me");
|
|
384
|
+
if (filter.assignedToMe) qualifiers.push("assignee:@me");
|
|
385
|
+
if (filter.reviewRequestedOfMe) qualifiers.push("user-review-requested:@me");
|
|
386
|
+
return qualifiers;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Composes a full Search API `q` string: repo scope + status/labels (AND, matching list()'s own semantics) + the "me" qualifiers (OR'd together). */
|
|
390
|
+
function buildMeSearchQuery(owner: string, repo: string, filter: ListFilter, meQualifiers: readonly string[]): string {
|
|
391
|
+
const parts = [`repo:${owner}/${repo}`];
|
|
392
|
+
if (filter.status) parts.push(`is:${mapStatusToGitHub(filter.status)}`);
|
|
393
|
+
for (const label of filter.labels ?? []) parts.push(`label:"${label.replace(/"/g, '\\"')}"`);
|
|
394
|
+
parts.push(meQualifiers.length === 1 ? meQualifiers[0]! : `(${meQualifiers.join(" OR ")})`);
|
|
395
|
+
return parts.join(" ");
|
|
396
|
+
}
|
|
397
|
+
|
|
274
398
|
function mapStatusFromGitHub(state: string): Status {
|
|
275
399
|
return state.toLowerCase() === "closed" ? "done" : "todo";
|
|
276
400
|
}
|
|
@@ -290,7 +414,54 @@ function priorityFromLabels(labels: (GhLabel | string)[]): ReturnType<typeof par
|
|
|
290
414
|
return "none";
|
|
291
415
|
}
|
|
292
416
|
|
|
293
|
-
|
|
417
|
+
/** Normalizes GitHub's loose mergeable_state string (not a closed enum in its own OpenAPI schema) into this project's own MergeableState. */
|
|
418
|
+
function mapMergeableState(gh: GhPullRequestFull): MergeableState {
|
|
419
|
+
if (gh.mergeable === null) return "checking";
|
|
420
|
+
if (!gh.mergeable) return "conflicting";
|
|
421
|
+
return gh.mergeable_state.toLowerCase() === "unknown" ? "unknown" : "mergeable";
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function mapReviewState(state: string): PullRequestReviewer["state"] {
|
|
425
|
+
switch (state.toUpperCase()) {
|
|
426
|
+
case "APPROVED":
|
|
427
|
+
return "approved";
|
|
428
|
+
case "CHANGES_REQUESTED":
|
|
429
|
+
return "changes_requested";
|
|
430
|
+
case "COMMENTED":
|
|
431
|
+
return "commented";
|
|
432
|
+
case "PENDING":
|
|
433
|
+
return "pending";
|
|
434
|
+
default:
|
|
435
|
+
return "unreviewed";
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** get()-only enrichment -- see the research Doc's correction for why this needs a dedicated pulls.get() call, unreachable from list()/search(). */
|
|
440
|
+
function pullRequestDetailsFromFull(pull: GhPullRequestFull, reviews: GhReview[]): PullRequestDetails {
|
|
441
|
+
return {
|
|
442
|
+
baseBranch: pull.base.ref,
|
|
443
|
+
headBranch: pull.head.ref,
|
|
444
|
+
baseSha: pull.base.sha,
|
|
445
|
+
headSha: pull.head.sha,
|
|
446
|
+
draft: pull.draft,
|
|
447
|
+
merged: pull.merged,
|
|
448
|
+
mergedAt: pull.merged_at ?? undefined,
|
|
449
|
+
requestedReviewers: pull.requested_reviewers?.length ? pull.requested_reviewers.map((r) => r.login) : undefined,
|
|
450
|
+
mergeableState: mapMergeableState(pull),
|
|
451
|
+
diffStat: { filesChanged: pull.changed_files, additions: pull.additions, deletions: pull.deletions },
|
|
452
|
+
reviewers: reviews.length
|
|
453
|
+
? reviews.filter((r) => r.user).map((r) => ({ username: r.user!.login, state: mapReviewState(r.state) }))
|
|
454
|
+
: undefined,
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** 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. */
|
|
459
|
+
function pullRequestDetailsFromIssue(gh: GhIssue): PullRequestDetails | undefined {
|
|
460
|
+
if (!gh.pull_request) return undefined;
|
|
461
|
+
return { draft: gh.draft, merged: gh.pull_request.merged_at !== null, mergedAt: gh.pull_request.merged_at ?? undefined };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function toDomain(gh: GhIssue, pullRequest?: PullRequestDetails): Issue {
|
|
294
465
|
return {
|
|
295
466
|
ref: `github:#${gh.number}`,
|
|
296
467
|
id: String(gh.number),
|
|
@@ -305,6 +476,7 @@ function toDomain(gh: GhIssue): Issue {
|
|
|
305
476
|
url: gh.html_url,
|
|
306
477
|
createdAt: gh.created_at,
|
|
307
478
|
updatedAt: gh.updated_at,
|
|
479
|
+
pullRequest: pullRequest ?? pullRequestDetailsFromIssue(gh),
|
|
308
480
|
};
|
|
309
481
|
}
|
|
310
482
|
|
package/src/gitlab/gitlab.ts
CHANGED
|
@@ -15,7 +15,18 @@ 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";
|
|
19
30
|
import type { BackendConfigurationReadiness } from "../issue/repository.js";
|
|
20
31
|
import { classifyBackendTransportFailure } from "../issue/transport-error.js";
|
|
21
32
|
|
|
@@ -60,6 +71,48 @@ interface GlNote {
|
|
|
60
71
|
author: GlUser | null;
|
|
61
72
|
}
|
|
62
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
|
+
|
|
63
116
|
const DEFAULT_URL = "https://gitlab.com";
|
|
64
117
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
65
118
|
|
|
@@ -113,25 +166,71 @@ export class GitLabRepository {
|
|
|
113
166
|
}
|
|
114
167
|
|
|
115
168
|
async list(filter: ListFilter): Promise<Issue[]> {
|
|
169
|
+
if (filter.qaContactIsMe) throw new Error('gitlab: "qaContactIsMe" is a Jira-only concept, not supported on the gitlab backend');
|
|
170
|
+
if (filter.reviewRequestedOfMe) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
'gitlab: "reviewRequestedOfMe" is a merge-request-only concept (scope=reviews_for_me) that list()\'s Issues-only scope can\'t express -- see get()\'s "!<iid>" convention for merge requests',
|
|
173
|
+
);
|
|
174
|
+
}
|
|
116
175
|
const limit = filter.limit && filter.limit > 0 ? filter.limit : 50;
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
176
|
+
const scopes = meScopes(filter);
|
|
177
|
+
if (scopes.length <= 1) {
|
|
178
|
+
const raw = await this.call<GlIssue[]>(() =>
|
|
179
|
+
this.client.Issues.all({
|
|
180
|
+
projectId: this.projectId,
|
|
181
|
+
perPage: limit,
|
|
182
|
+
state: filter.status ? mapStatusToGitLab(filter.status) : undefined,
|
|
183
|
+
assigneeUsername: filter.assignee ? [filter.assignee] : undefined,
|
|
184
|
+
labels: filter.labels?.length ? filter.labels.join(",") : undefined,
|
|
185
|
+
...(scopes[0] ? { scope: scopes[0] } : {}),
|
|
186
|
+
}),
|
|
187
|
+
);
|
|
188
|
+
return raw.map(toDomain);
|
|
189
|
+
}
|
|
190
|
+
// Both reportedByMe and assignedToMe: GitLab's own `scope` param takes exactly one value per
|
|
191
|
+
// call (no server-side OR across scopes), so the OR-of-roles group ListFilter's own doc comment
|
|
192
|
+
// describes is executed here as two calls, deduped client-side by ref -- the one place in this
|
|
193
|
+
// adapter an OR of "me" flags genuinely costs more than one request (Jira/GitHub both express
|
|
194
|
+
// it in a single native query).
|
|
195
|
+
const results = await Promise.all(
|
|
196
|
+
scopes.map((scope) =>
|
|
197
|
+
this.call<GlIssue[]>(() =>
|
|
198
|
+
this.client.Issues.all({
|
|
199
|
+
projectId: this.projectId,
|
|
200
|
+
perPage: limit,
|
|
201
|
+
state: filter.status ? mapStatusToGitLab(filter.status) : undefined,
|
|
202
|
+
labels: filter.labels?.length ? filter.labels.join(",") : undefined,
|
|
203
|
+
scope,
|
|
204
|
+
}),
|
|
205
|
+
),
|
|
206
|
+
),
|
|
125
207
|
);
|
|
126
|
-
|
|
208
|
+
const seen = new Set<string>();
|
|
209
|
+
const merged: Issue[] = [];
|
|
210
|
+
for (const raw of results.flat()) {
|
|
211
|
+
const issue = toDomain(raw);
|
|
212
|
+
if (seen.has(issue.ref)) continue;
|
|
213
|
+
seen.add(issue.ref);
|
|
214
|
+
merged.push(issue);
|
|
215
|
+
}
|
|
216
|
+
return merged.slice(0, limit);
|
|
127
217
|
}
|
|
128
218
|
|
|
129
219
|
async get(key: string): Promise<Issue> {
|
|
220
|
+
if (isMergeRequestKey(key)) return this.getMergeRequest(parseMrIid(key));
|
|
130
221
|
const iid = parseIid(key);
|
|
131
222
|
const raw = await this.call<GlIssue>(() => this.client.Issues.show(iid, { projectId: this.projectId }));
|
|
132
223
|
return toDomain(raw);
|
|
133
224
|
}
|
|
134
225
|
|
|
226
|
+
private async getMergeRequest(iid: number): Promise<Issue> {
|
|
227
|
+
const [raw, reviewers] = await Promise.all([
|
|
228
|
+
this.call<GlMergeRequestExpanded>(() => this.client.MergeRequests.show(this.projectId, iid)),
|
|
229
|
+
this.call<GlMergeRequestReviewerEntry[]>(() => this.client.MergeRequests.showReviewers(this.projectId, iid)),
|
|
230
|
+
]);
|
|
231
|
+
return mrToDomain(raw, reviewers);
|
|
232
|
+
}
|
|
233
|
+
|
|
135
234
|
async create(input: CreateInput): Promise<Issue> {
|
|
136
235
|
this.requireAuth();
|
|
137
236
|
const assigneeIds = input.assignee ? [await this.resolveUserId(input.assignee)] : undefined;
|
|
@@ -185,6 +284,39 @@ export class GitLabRepository {
|
|
|
185
284
|
return noteToDomain(raw);
|
|
186
285
|
}
|
|
187
286
|
|
|
287
|
+
/**
|
|
288
|
+
* GitLab's approve endpoint (unlike GitHub's createReview) returns only an approval-state
|
|
289
|
+
* summary, not the full MR -- so this re-fetches the same way get() does, `body` is accepted
|
|
290
|
+
* for interface parity with GitHub but ignored: GitLab's approve endpoint has no comment-body
|
|
291
|
+
* parameter at all (confirmed against @gitbeaker/core's ApproveMergeRequestOptions -- just
|
|
292
|
+
* sha/approvalPassword).
|
|
293
|
+
*/
|
|
294
|
+
async approvePullRequest(key: string): Promise<Issue> {
|
|
295
|
+
this.requireAuth();
|
|
296
|
+
const iid = parseMrIid(key);
|
|
297
|
+
await this.call(() => this.client.MergeRequestApprovals.approve(this.projectId, iid));
|
|
298
|
+
return this.getMergeRequest(iid);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* GitLab's merge endpoint returns the full expanded MR directly (per the research Doc) --
|
|
303
|
+
* no extra show() call needed for the MR object itself, unlike approve() above. Reviewer
|
|
304
|
+
* state is still a separate call every time on both backends (see PullRequestReviewer's own
|
|
305
|
+
* doc comment), so that part isn't free. GitLab's accept endpoint only has a boolean `squash`
|
|
306
|
+
* option, not a 3-way merge/squash/rebase choice like GitHub's -- "rebase" has no GitLab merge
|
|
307
|
+
* equivalent (GitLab's own rebase is a distinct pre-merge branch operation), so it falls back
|
|
308
|
+
* to a plain merge rather than rejecting the call.
|
|
309
|
+
*/
|
|
310
|
+
async mergePullRequest(key: string, method?: "merge" | "squash" | "rebase"): Promise<Issue> {
|
|
311
|
+
this.requireAuth();
|
|
312
|
+
const iid = parseMrIid(key);
|
|
313
|
+
const [raw, reviewers] = await Promise.all([
|
|
314
|
+
this.call<GlMergeRequestExpanded>(() => this.client.MergeRequests.merge(this.projectId, iid, { squash: method === "squash" })),
|
|
315
|
+
this.call<GlMergeRequestReviewerEntry[]>(() => this.client.MergeRequests.showReviewers(this.projectId, iid)),
|
|
316
|
+
]);
|
|
317
|
+
return mrToDomain(raw, reviewers);
|
|
318
|
+
}
|
|
319
|
+
|
|
188
320
|
/**
|
|
189
321
|
* GitLab's assignee write contract takes a numeric user ID, not a username
|
|
190
322
|
* (`assignee_ids: number[]`, confirmed against @gitbeaker/rest's generated
|
|
@@ -228,10 +360,63 @@ function parseIid(key: string): number {
|
|
|
228
360
|
return Number(key.replace(/^#/, ""));
|
|
229
361
|
}
|
|
230
362
|
|
|
363
|
+
/** GitLab's own merge-request reference convention, mirrored by its UI: "!5", vs. an issue's "#5". */
|
|
364
|
+
function isMergeRequestKey(key: string): boolean {
|
|
365
|
+
return key.trim().startsWith("!");
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function parseMrIid(key: string): number {
|
|
369
|
+
return Number(key.replace(/^!/, "").replace(/^#/, ""));
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Primarily merge_status, not detailed_merge_status -- see MergeableState's own doc comment for the cross-backend normalization this feeds. */
|
|
373
|
+
function mapMergeableState(mergeStatus: string): MergeableState {
|
|
374
|
+
switch (mergeStatus) {
|
|
375
|
+
case "can_be_merged":
|
|
376
|
+
return "mergeable";
|
|
377
|
+
case "cannot_be_merged":
|
|
378
|
+
case "cannot_be_merged_recheck":
|
|
379
|
+
return "conflicting";
|
|
380
|
+
case "checking":
|
|
381
|
+
return "checking";
|
|
382
|
+
default:
|
|
383
|
+
return "unknown"; // "unchecked"
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** 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". */
|
|
388
|
+
function mapReviewerState(state: string): PullRequestReviewer["state"] {
|
|
389
|
+
switch (state) {
|
|
390
|
+
case "approved":
|
|
391
|
+
return "approved";
|
|
392
|
+
case "requested_changes":
|
|
393
|
+
return "changes_requested";
|
|
394
|
+
case "reviewed":
|
|
395
|
+
return "commented";
|
|
396
|
+
case "review_started":
|
|
397
|
+
return "pending";
|
|
398
|
+
default:
|
|
399
|
+
return "unreviewed";
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** "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). */
|
|
404
|
+
function parseChangesCount(changesCount: string): number {
|
|
405
|
+
return Number.parseInt(changesCount, 10) || 0;
|
|
406
|
+
}
|
|
407
|
+
|
|
231
408
|
function mapStatusToGitLab(status: Status): "opened" | "closed" {
|
|
232
409
|
return status === "done" || status === "canceled" ? "closed" : "opened";
|
|
233
410
|
}
|
|
234
411
|
|
|
412
|
+
/** ListFilter.{reportedByMe,assignedToMe} -> GitLab Issues API `scope` values -- see ListFilter's own doc comment. */
|
|
413
|
+
function meScopes(filter: ListFilter): ("created_by_me" | "assigned_to_me")[] {
|
|
414
|
+
const scopes: ("created_by_me" | "assigned_to_me")[] = [];
|
|
415
|
+
if (filter.reportedByMe) scopes.push("created_by_me");
|
|
416
|
+
if (filter.assignedToMe) scopes.push("assigned_to_me");
|
|
417
|
+
return scopes;
|
|
418
|
+
}
|
|
419
|
+
|
|
235
420
|
function mapStatusEventToGitLab(status: Status): "close" | "reopen" {
|
|
236
421
|
return status === "done" || status === "canceled" ? "close" : "reopen";
|
|
237
422
|
}
|
|
@@ -270,6 +455,43 @@ function toDomain(gl: GlIssue): Issue {
|
|
|
270
455
|
};
|
|
271
456
|
}
|
|
272
457
|
|
|
458
|
+
function mrPullRequestDetails(mr: GlMergeRequest | GlMergeRequestExpanded, reviewers: GlMergeRequestReviewerEntry[]): PullRequestDetails {
|
|
459
|
+
const expanded = "changes_count" in mr ? mr : undefined;
|
|
460
|
+
return {
|
|
461
|
+
baseBranch: mr.target_branch,
|
|
462
|
+
headBranch: mr.source_branch,
|
|
463
|
+
headSha: mr.sha,
|
|
464
|
+
baseSha: expanded?.diff_refs.base_sha,
|
|
465
|
+
draft: mr.draft,
|
|
466
|
+
merged: mr.state === "merged",
|
|
467
|
+
mergedAt: mr.merged_at ?? undefined,
|
|
468
|
+
requestedReviewers: mr.reviewers?.length ? mr.reviewers.map((r) => r.username) : undefined,
|
|
469
|
+
mergeableState: mapMergeableState(mr.merge_status),
|
|
470
|
+
diffStat: expanded ? { filesChanged: parseChangesCount(expanded.changes_count) } : undefined,
|
|
471
|
+
reviewers: reviewers.length ? reviewers.map((r) => ({ username: r.user.username, state: mapReviewerState(r.state) })) : undefined,
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function mrToDomain(mr: GlMergeRequestExpanded, reviewers: GlMergeRequestReviewerEntry[]): Issue {
|
|
476
|
+
return {
|
|
477
|
+
ref: `gitlab:!${mr.iid}`,
|
|
478
|
+
id: String(mr.iid),
|
|
479
|
+
key: `!${mr.iid}`,
|
|
480
|
+
title: mr.title,
|
|
481
|
+
description: mr.description ?? undefined,
|
|
482
|
+
status: mapStatusFromGitLab(mr.state === "merged" ? "closed" : mr.state),
|
|
483
|
+
rawStatus: mr.state,
|
|
484
|
+
priority: priorityFromLabels(mr.labels ?? []),
|
|
485
|
+
labels: mr.labels?.length ? mr.labels : undefined,
|
|
486
|
+
assignee: mr.assignee?.username,
|
|
487
|
+
reporter: mr.author?.username,
|
|
488
|
+
url: mr.web_url,
|
|
489
|
+
createdAt: mr.created_at,
|
|
490
|
+
updatedAt: mr.updated_at,
|
|
491
|
+
pullRequest: mrPullRequestDetails(mr, reviewers),
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
|
|
273
495
|
function noteToDomain(n: GlNote): Comment {
|
|
274
496
|
return {
|
|
275
497
|
id: String(n.id),
|
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 {
|
|
@@ -122,6 +178,30 @@ export interface ListFilter {
|
|
|
122
178
|
assignee?: string;
|
|
123
179
|
query?: string;
|
|
124
180
|
limit?: number;
|
|
181
|
+
/**
|
|
182
|
+
* "Mine" filtering -- deliberately a small set of named, orthogonal boolean flags rather than
|
|
183
|
+
* either a hardcoded single `mine` concept or a generic cross-backend query language (see the
|
|
184
|
+
* research Doc "Tickets 'mine' filtering: grounded API research" for why both alternatives were
|
|
185
|
+
* rejected). Every backend maps each flag to its own native "current user" mechanism (Jira's JQL
|
|
186
|
+
* currentUser(), GitHub's Search API @me qualifiers, GitLab's scope=*_me) -- never a resolved
|
|
187
|
+
* literal username. Setting more than one of these ORs them together ("assignee OR reporter"),
|
|
188
|
+
* AND'd with every other ListFilter field exactly like today's plain fields. A backend that has
|
|
189
|
+
* no native equivalent for a given flag throws rather than silently ignoring it (e.g.
|
|
190
|
+
* reviewRequestedOfMe on Jira, qaContactIsMe on GitHub/GitLab) -- a silently-dropped role would
|
|
191
|
+
* make an incomplete result look complete.
|
|
192
|
+
*/
|
|
193
|
+
/** Jira: `reporter = currentUser()`. GitHub: `author:@me` (Search API). GitLab: `scope=created_by_me` (Issues API). */
|
|
194
|
+
reportedByMe?: boolean;
|
|
195
|
+
/** Jira: `assignee = currentUser()`. GitHub: `assignee:@me` (Search API). GitLab: `scope=assigned_to_me` (Issues API). */
|
|
196
|
+
assignedToMe?: boolean;
|
|
197
|
+
/**
|
|
198
|
+
* GitHub: `user-review-requested:@me` (Search API, PRs only). GitLab: `scope=reviews_for_me` --
|
|
199
|
+
* a merge-request-only concept list()'s Issues-only scope can't express, so GitLab throws. Jira
|
|
200
|
+
* has no reviewer concept on plain issues, so Jira throws too.
|
|
201
|
+
*/
|
|
202
|
+
reviewRequestedOfMe?: boolean;
|
|
203
|
+
/** Jira only, via the discovered "QA Contact" custom field (auto-discovers if never run). GitHub/GitLab throw -- no equivalent concept. */
|
|
204
|
+
qaContactIsMe?: boolean;
|
|
125
205
|
}
|
|
126
206
|
|
|
127
207
|
/** "backend:key" ref parsing, split on the first colon only (keys may contain colons). */
|
package/src/issue/repository.ts
CHANGED
|
@@ -142,3 +142,39 @@ export interface SyncScopeExpandable {
|
|
|
142
142
|
export function hasSyncScopeExpansion(repo: IssueRepository): repo is IssueRepository & SyncScopeExpandable {
|
|
143
143
|
return typeof (repo as Partial<SyncScopeExpandable>).buildSyncQuery === "function";
|
|
144
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
|
@@ -13,6 +13,8 @@ import {
|
|
|
13
13
|
hasComments,
|
|
14
14
|
hasConfigurationReadiness,
|
|
15
15
|
hasFieldDiscovery,
|
|
16
|
+
hasPullRequestChangesRequest,
|
|
17
|
+
hasPullRequestReview,
|
|
16
18
|
hasRawQuery,
|
|
17
19
|
hasStatusDiscovery,
|
|
18
20
|
hasSyncScopeExpansion,
|
|
@@ -30,6 +32,10 @@ export interface BackendCapabilities {
|
|
|
30
32
|
readonly supportsTemplateDiscovery: boolean;
|
|
31
33
|
readonly supportsBoardQuickFilterDiscovery: boolean;
|
|
32
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;
|
|
33
39
|
}
|
|
34
40
|
|
|
35
41
|
export class UnknownBackendError extends Error {
|
|
@@ -79,6 +85,8 @@ export class TicketService {
|
|
|
79
85
|
supportsTemplateDiscovery: hasTemplateDiscovery(repo),
|
|
80
86
|
supportsBoardQuickFilterDiscovery: hasBoardQuickFilterDiscovery(repo),
|
|
81
87
|
supportsBoardFilterDiscovery: hasBoardFilterDiscovery(repo),
|
|
88
|
+
supportsPullRequestReview: hasPullRequestReview(repo),
|
|
89
|
+
supportsPullRequestChangesRequest: hasPullRequestChangesRequest(repo),
|
|
82
90
|
}));
|
|
83
91
|
}
|
|
84
92
|
|
|
@@ -203,4 +211,28 @@ export class TicketService {
|
|
|
203
211
|
if (!hasBoardFilterDiscovery(repo)) throw new NotSupportedError(backend, "board filter discovery");
|
|
204
212
|
return repo.discoverBoardFilterJql(boardId);
|
|
205
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
|
+
}
|
|
206
238
|
}
|
package/src/jira/jira.ts
CHANGED
|
@@ -204,6 +204,11 @@ export class JiraRepository {
|
|
|
204
204
|
* the background), not just the single legacy `project` config field.
|
|
205
205
|
*/
|
|
206
206
|
async list(filter: ListFilter): Promise<Issue[]> {
|
|
207
|
+
if (filter.reviewRequestedOfMe) {
|
|
208
|
+
throw new Error(
|
|
209
|
+
'jira: "reviewRequestedOfMe" has no meaning on Jira issues (no reviewer concept) -- omit this filter for the jira backend',
|
|
210
|
+
);
|
|
211
|
+
}
|
|
207
212
|
const projects = filter.project ? [filter.project] : this.defaultProjects();
|
|
208
213
|
const clauses: string[] = [];
|
|
209
214
|
const scope = projectClause(projects);
|
|
@@ -211,9 +216,30 @@ export class JiraRepository {
|
|
|
211
216
|
if (filter.status) clauses.push(`status = ${jqlQuote(mapStatusToJira(filter.status))}`);
|
|
212
217
|
if (filter.assignee) clauses.push(`assignee = ${jqlQuote(filter.assignee)}`);
|
|
213
218
|
for (const label of filter.labels ?? []) clauses.push(`labels = ${jqlQuote(label)}`);
|
|
219
|
+
const meClauses = await this.buildMeClauses(filter);
|
|
220
|
+
if (meClauses.length > 0) clauses.push(meClauses.length === 1 ? meClauses[0]! : `(${meClauses.join(" OR ")})`);
|
|
214
221
|
return this.searchJql(buildJql(clauses, "AND"), filter.limit ?? 50);
|
|
215
222
|
}
|
|
216
223
|
|
|
224
|
+
/**
|
|
225
|
+
* Builds the OR'd group of "me" role clauses for list()'s ListFilter.{reportedByMe,assignedToMe,
|
|
226
|
+
* qaContactIsMe} flags -- see ListFilter's own doc comment for why this is a small set of named
|
|
227
|
+
* flags rather than a hardcoded single "mine" concept or a generic query language. "QA Contact"
|
|
228
|
+
* resolves through the same discoverFields()-backed manifest applyCustomFields() already uses
|
|
229
|
+
* (auto-discovering on first use), so an instance without that field throws a clear "unknown
|
|
230
|
+
* custom field" error rather than silently omitting the clause.
|
|
231
|
+
*/
|
|
232
|
+
private async buildMeClauses(filter: ListFilter): Promise<string[]> {
|
|
233
|
+
const clauses: string[] = [];
|
|
234
|
+
if (filter.assignedToMe) clauses.push("assignee = currentUser()");
|
|
235
|
+
if (filter.reportedByMe) clauses.push("reporter = currentUser()");
|
|
236
|
+
if (filter.qaContactIsMe) {
|
|
237
|
+
await this.resolveCustomField("QA Contact");
|
|
238
|
+
clauses.push('"QA Contact" = currentUser()');
|
|
239
|
+
}
|
|
240
|
+
return clauses;
|
|
241
|
+
}
|
|
242
|
+
|
|
217
243
|
async get(key: string): Promise<Issue> {
|
|
218
244
|
const [raw, remoteLinks] = await Promise.all([
|
|
219
245
|
this.call<JiraIssue>(() => this.client.issues.getIssue({ issueIdOrKey: key }), key),
|
package/src/rpc/ops.ts
CHANGED
|
@@ -21,6 +21,9 @@ export type TicketOperation =
|
|
|
21
21
|
| "issue.children"
|
|
22
22
|
| "issue.comments"
|
|
23
23
|
| "issue.comment_add"
|
|
24
|
+
| "issue.approve"
|
|
25
|
+
| "issue.request_changes"
|
|
26
|
+
| "issue.merge"
|
|
24
27
|
| "ledger.search"
|
|
25
28
|
| "ledger.stats"
|
|
26
29
|
| "focus.set"
|
|
@@ -55,6 +58,9 @@ export interface TicketOpInputs extends Record<TicketOperation, unknown> {
|
|
|
55
58
|
"issue.children": { ref: string };
|
|
56
59
|
"issue.comments": { ref: string };
|
|
57
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" };
|
|
58
64
|
"ledger.search": { query: string; limit?: number; backend?: string };
|
|
59
65
|
"ledger.stats": Record<string, never>;
|
|
60
66
|
"focus.set": { ref: string };
|
|
@@ -93,6 +99,9 @@ export interface TicketOpOutputs extends Record<TicketOperation, unknown> {
|
|
|
93
99
|
"issue.children": { issues: Issue[] };
|
|
94
100
|
"issue.comments": { comments: Comment[] };
|
|
95
101
|
"issue.comment_add": { comment: Comment };
|
|
102
|
+
"issue.approve": { issue: Issue };
|
|
103
|
+
"issue.request_changes": { issue: Issue };
|
|
104
|
+
"issue.merge": { issue: Issue };
|
|
96
105
|
"ledger.search": { issues: Issue[] };
|
|
97
106
|
"ledger.stats": { backends: { backend: string; count: number }[] };
|
|
98
107
|
"focus.set": { focus: TicketFocusState };
|
|
@@ -128,6 +137,9 @@ export const TICKET_OPERATIONS: TicketOperation[] = [
|
|
|
128
137
|
"issue.children",
|
|
129
138
|
"issue.comments",
|
|
130
139
|
"issue.comment_add",
|
|
140
|
+
"issue.approve",
|
|
141
|
+
"issue.request_changes",
|
|
142
|
+
"issue.merge",
|
|
131
143
|
"ledger.search",
|
|
132
144
|
"ledger.stats",
|
|
133
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) => {
|