@danypops/tickets 0.10.5 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/tickets",
3
- "version": "0.10.5",
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",
@@ -136,6 +136,28 @@ const OPERATIONS: readonly OperationSpec[] = [
136
136
  properties: { ref: stringProp, body: stringProp },
137
137
  required: ["ref", "body"],
138
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
+ },
139
161
  {
140
162
  action: "ledger.search",
141
163
  description: "Searches the local pooled-issue ledger (no live backend call).",
@@ -275,13 +297,14 @@ const OPERATIONS: readonly OperationSpec[] = [
275
297
  ];
276
298
 
277
299
  /**
278
- * The five discover.* operations only ever succeed against a backend whose
279
- * repository implements the matching optional capability (Jira today,
280
- * structurally -- never a hardcoded backend name). An operation none of the
281
- * currently configured backends could possibly satisfy is marked
282
- * unavailable so it never appears in the LLM's callable tool list in the
283
- * first place, instead of being offered and then failing with
284
- * NotSupportedError on the first real call.
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.
285
308
  */
286
309
  const DISCOVER_AVAILABILITY: readonly { action: TicketOperation; capability: keyof BackendCapabilities; reason: string }[] = [
287
310
  { action: "discover.fields", capability: "supportsFieldDiscovery", reason: "no configured backend supports field discovery (Jira only)" },
@@ -305,14 +328,31 @@ const DISCOVER_AVAILABILITY: readonly { action: TicketOperation; capability: key
305
328
  capability: "supportsBoardFilterDiscovery",
306
329
  reason: "no configured backend supports board filter discovery (Jira only)",
307
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
+ },
308
346
  ];
309
347
 
310
348
  /**
311
- * Re-syncs the five discover.* operations' availability against the
312
- * service's current backend set -- called once right after the registry is
313
- * built, and again after every live backend refresh (config.ts's
314
- * createBackendRefreshTask), so a Jira credential added or removed at
315
- * runtime flips these tools' visibility without a daemon restart.
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.
316
356
  */
317
357
  export function syncDiscoverAvailability(registry: VehicleRegistry, service: TicketService): void {
318
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
@@ -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 { Comment, CreateInput, Issue, ListFilter, parsePriority, Status, UpdateInput } from "../issue/issue.js";
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
- pull_request?: unknown;
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;
@@ -145,7 +177,7 @@ export class GitHubRepository {
145
177
  request: { signal },
146
178
  }),
147
179
  );
148
- return (raw as GhIssue[]).filter((i) => !i.pull_request).map(toDomain);
180
+ return (raw as GhIssue[]).map((i) => toDomain(i));
149
181
  }
150
182
 
151
183
  async get(key: string): Promise<Issue> {
@@ -153,8 +185,26 @@ export class GitHubRepository {
153
185
  const raw = (await this.call((signal) =>
154
186
  this.client.rest.issues.get({ owner: this.owner, repo: this.repoName(), issue_number, request: { signal } }),
155
187
  )) as GhIssue;
156
- if (raw.pull_request) throw new Error(`github: #${issue_number} is a pull request, not an issue`);
157
- return toDomain(raw);
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[];
158
208
  }
159
209
 
160
210
  async create(input: CreateInput): Promise<Issue> {
@@ -199,7 +249,7 @@ export class GitHubRepository {
199
249
  const result = (await this.call((signal) =>
200
250
  this.client.rest.search.issuesAndPullRequests({ q: `${scope} ${query}`, per_page: limit, request: { signal } }),
201
251
  )) as { items: GhIssue[] };
202
- return result.items.filter((i) => !i.pull_request).map(toDomain);
252
+ return result.items.map((i) => toDomain(i));
203
253
  }
204
254
 
205
255
  // GitHub has no native sub-issue relationship exposed via REST v3.
@@ -207,6 +257,47 @@ export class GitHubRepository {
207
257
  return [];
208
258
  }
209
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
+
210
301
  async listComments(key: string): Promise<Comment[]> {
211
302
  const issue_number = parseIssueNumber(key);
212
303
  const raw = (await this.call((signal) =>
@@ -290,7 +381,54 @@ function priorityFromLabels(labels: (GhLabel | string)[]): ReturnType<typeof par
290
381
  return "none";
291
382
  }
292
383
 
293
- function toDomain(gh: GhIssue): Issue {
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 {
294
432
  return {
295
433
  ref: `github:#${gh.number}`,
296
434
  id: String(gh.number),
@@ -305,6 +443,7 @@ function toDomain(gh: GhIssue): Issue {
305
443
  url: gh.html_url,
306
444
  createdAt: gh.created_at,
307
445
  updatedAt: gh.updated_at,
446
+ pullRequest: pullRequest ?? pullRequestDetailsFromIssue(gh),
308
447
  };
309
448
  }
310
449
 
@@ -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 { Comment, CreateInput, Issue, ListFilter, parsePriority, Status, UpdateInput } from "../issue/issue.js";
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
 
@@ -127,11 +180,20 @@ export class GitLabRepository {
127
180
  }
128
181
 
129
182
  async get(key: string): Promise<Issue> {
183
+ if (isMergeRequestKey(key)) return this.getMergeRequest(parseMrIid(key));
130
184
  const iid = parseIid(key);
131
185
  const raw = await this.call<GlIssue>(() => this.client.Issues.show(iid, { projectId: this.projectId }));
132
186
  return toDomain(raw);
133
187
  }
134
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
+
135
197
  async create(input: CreateInput): Promise<Issue> {
136
198
  this.requireAuth();
137
199
  const assigneeIds = input.assignee ? [await this.resolveUserId(input.assignee)] : undefined;
@@ -185,6 +247,39 @@ export class GitLabRepository {
185
247
  return noteToDomain(raw);
186
248
  }
187
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
+
188
283
  /**
189
284
  * GitLab's assignee write contract takes a numeric user ID, not a username
190
285
  * (`assignee_ids: number[]`, confirmed against @gitbeaker/rest's generated
@@ -228,6 +323,51 @@ function parseIid(key: string): number {
228
323
  return Number(key.replace(/^#/, ""));
229
324
  }
230
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
+
231
371
  function mapStatusToGitLab(status: Status): "opened" | "closed" {
232
372
  return status === "done" || status === "canceled" ? "closed" : "opened";
233
373
  }
@@ -270,6 +410,43 @@ function toDomain(gl: GlIssue): Issue {
270
410
  };
271
411
  }
272
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
+
273
450
  function noteToDomain(n: GlNote): Comment {
274
451
  return {
275
452
  id: String(n.id),
@@ -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 {
@@ -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
+ }
@@ -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/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) => {