opencode-gitlab-plugin 2.5.0 → 2.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,26 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
4
4
 
5
+ ## [2.6.1](https://gitlab.com/vglafirov/opencode-gitlab-plugin/compare/v2.6.0...v2.6.1) (2026-08-17)
6
+
7
+
8
+ ### 🐛 Bug Fixes
9
+
10
+ * **merge-requests:** resolve MWPS 'checking' race in smartMerge/setAutoMerge ([b9a992f](https://gitlab.com/vglafirov/opencode-gitlab-plugin/commit/b9a992f6ebec655fba271623bf5fc963cfe16f09))
11
+ * **work-items:** avoid nullability mismatch on widget inputs ([1ae3cd7](https://gitlab.com/vglafirov/opencode-gitlab-plugin/commit/1ae3cd79627c6cca88955f90af13694472939331))
12
+
13
+ ## [2.6.0](https://gitlab.com/vglafirov/opencode-gitlab-plugin/compare/v2.5.0...v2.6.0) (2026-05-20)
14
+
15
+
16
+ ### ✨ Features
17
+
18
+ * **pagination:** expose pagination info in list MR and issue responses ([1c1722e](https://gitlab.com/vglafirov/opencode-gitlab-plugin/commit/1c1722e62e0e41f00ab5409057bea1e76ec17a8d))
19
+
20
+
21
+ ### ♻️ Code Refactoring
22
+
23
+ * **client:** address review feedback on !23 ([2e94e46](https://gitlab.com/vglafirov/opencode-gitlab-plugin/commit/2e94e46706807bc8797f10884f71fc56ecf967b9)), closes [23#note_3135645102](https://gitlab.com/vglafirov/23/issues/note_3135645102) [23#note_3135645423](https://gitlab.com/vglafirov/23/issues/note_3135645423) [23#note_3135644923](https://gitlab.com/vglafirov/23/issues/note_3135644923)
24
+
5
25
  ## [2.5.0](https://gitlab.com/vglafirov/opencode-gitlab-plugin/compare/v2.4.0...v2.5.0) (2026-05-20)
6
26
 
7
27
 
package/dist/index.js CHANGED
@@ -26,7 +26,13 @@ var GitLabApiClient = class {
26
26
  }
27
27
  return projectId;
28
28
  }
29
- async fetch(method, path2, body) {
29
+ /**
30
+ * Core HTTP request used by all REST helpers.
31
+ *
32
+ * Returns the raw `Response` after validating the status code so callers can
33
+ * inspect headers (e.g. for pagination) before parsing the body.
34
+ */
35
+ async rawFetch(method, path2, body) {
30
36
  const url = `${this.instanceUrl}/api/v4${path2}`;
31
37
  const response = await fetch(url, {
32
38
  method,
@@ -37,12 +43,53 @@ var GitLabApiClient = class {
37
43
  const errorText = await response.text();
38
44
  throw new Error(`GitLab API error ${response.status}: ${errorText}`);
39
45
  }
46
+ return response;
47
+ }
48
+ async fetch(method, path2, body) {
49
+ const response = await this.rawFetch(method, path2, body);
40
50
  const text = await response.text();
41
51
  if (!text) {
42
52
  return {};
43
53
  }
44
54
  return JSON.parse(text);
45
55
  }
56
+ /**
57
+ * Parse a numeric pagination header, guarding against empty strings.
58
+ *
59
+ * GitLab returns `""` (rather than omitting the header) for `X-Next-Page` and
60
+ * `X-Prev-Page` when there is no next/prev page, and bare `parseInt` without
61
+ * a radix on an empty string yields `NaN`. Treat empty/missing as `undefined`.
62
+ */
63
+ parsePaginationHeader(response, name) {
64
+ const raw = response.headers.get(name);
65
+ if (raw == null || raw === "") return void 0;
66
+ const value = parseInt(raw, 10);
67
+ return Number.isNaN(value) ? void 0 : value;
68
+ }
69
+ /**
70
+ * Pagination info extracted from GitLab API response headers
71
+ */
72
+ extractPaginationInfo(response) {
73
+ return {
74
+ total: this.parsePaginationHeader(response, "X-Total"),
75
+ totalPages: this.parsePaginationHeader(response, "X-Total-Pages"),
76
+ page: this.parsePaginationHeader(response, "X-Page"),
77
+ perPage: this.parsePaginationHeader(response, "X-Per-Page"),
78
+ nextPage: this.parsePaginationHeader(response, "X-Next-Page"),
79
+ prevPage: this.parsePaginationHeader(response, "X-Prev-Page")
80
+ };
81
+ }
82
+ /**
83
+ * Fetch with pagination info from response headers.
84
+ * Use this for list endpoints where knowing if more data exists is important.
85
+ */
86
+ async fetchWithPagination(method, path2, body) {
87
+ const response = await this.rawFetch(method, path2, body);
88
+ const text = await response.text();
89
+ const data = text ? JSON.parse(text) : [];
90
+ const pagination = this.extractPaginationInfo(response);
91
+ return { data, pagination };
92
+ }
46
93
  async fetchText(method, path2) {
47
94
  const url = `${this.instanceUrl}/api/v4${path2}`;
48
95
  const response = await fetch(url, {
@@ -294,7 +341,7 @@ var RESOLVE_DISCUSSION_MUTATION = `
294
341
  }
295
342
  }
296
343
  `;
297
- var MergeRequestsClient = class extends GitLabApiClient {
344
+ var MergeRequestsClient = class _MergeRequestsClient extends GitLabApiClient {
298
345
  async getMergeRequest(projectId, mrIid, includeChanges) {
299
346
  const encodedProject = this.encodeProjectId(projectId);
300
347
  let path2 = `/projects/${encodedProject}/merge_requests/${mrIid}`;
@@ -306,6 +353,7 @@ var MergeRequestsClient = class extends GitLabApiClient {
306
353
  async listMergeRequests(options) {
307
354
  const params = new URLSearchParams();
308
355
  params.set("per_page", String(options.limit || 20));
356
+ if (options.page) params.set("page", String(options.page));
309
357
  if (options.state) params.set("state", options.state);
310
358
  if (options.scope) params.set("scope", options.scope);
311
359
  if (options.search) params.set("search", options.search);
@@ -317,7 +365,7 @@ var MergeRequestsClient = class extends GitLabApiClient {
317
365
  } else {
318
366
  path2 = `/merge_requests?${params}`;
319
367
  }
320
- return this.fetch("GET", path2);
368
+ return this.fetchWithPagination("GET", path2);
321
369
  }
322
370
  async getMrChanges(projectId, mrIid) {
323
371
  const encodedProject = this.encodeProjectId(projectId);
@@ -515,20 +563,48 @@ var MergeRequestsClient = class extends GitLabApiClient {
515
563
  * Set auto-merge (MWPS) on a merge request using GraphQL API
516
564
  * Uses the mergeRequestAccept mutation with a merge strategy
517
565
  */
518
- async setAutoMerge(projectId, mrIid, sha, strategy = "MERGE_WHEN_CHECKS_PASS") {
519
- const result = await this.fetchGraphQL(SET_AUTO_MERGE_MUTATION, {
520
- projectPath: projectId,
521
- iid: String(mrIid),
522
- sha,
523
- strategy
524
- });
525
- if (result.mergeRequestAccept.errors.length > 0) {
526
- throw new Error(`Failed to set auto-merge: ${result.mergeRequestAccept.errors.join(", ")}`);
527
- }
528
- if (!result.mergeRequestAccept.mergeRequest) {
529
- throw new Error("Failed to set auto-merge: No merge request returned");
566
+ async setAutoMerge(projectId, mrIid, sha, strategy = "MERGE_WHEN_CHECKS_PASS", retries = 3) {
567
+ let lastError = null;
568
+ for (let attempt = 0; attempt <= retries; attempt++) {
569
+ const result = await this.fetchGraphQL(SET_AUTO_MERGE_MUTATION, {
570
+ projectPath: projectId,
571
+ iid: String(mrIid),
572
+ sha,
573
+ strategy
574
+ });
575
+ if (result.mergeRequestAccept.errors.length > 0) {
576
+ const message = result.mergeRequestAccept.errors.join(", ");
577
+ lastError = new Error(`Failed to set auto-merge: ${message}`);
578
+ if (attempt < retries && _MergeRequestsClient.isTransientMergeError(message)) {
579
+ await _MergeRequestsClient.delay(750 * (attempt + 1));
580
+ continue;
581
+ }
582
+ throw lastError;
583
+ }
584
+ if (!result.mergeRequestAccept.mergeRequest) {
585
+ lastError = new Error("Failed to set auto-merge: No merge request returned");
586
+ if (attempt < retries) {
587
+ await _MergeRequestsClient.delay(750 * (attempt + 1));
588
+ continue;
589
+ }
590
+ throw lastError;
591
+ }
592
+ return result.mergeRequestAccept.mergeRequest;
530
593
  }
531
- return result.mergeRequestAccept.mergeRequest;
594
+ throw lastError ?? new Error("Failed to set auto-merge: exhausted retries");
595
+ }
596
+ /**
597
+ * True for GitLab merge errors that are transient because mergeability is
598
+ * still being (re)computed. Such errors clear on their own within a second
599
+ * or two, so callers should retry rather than treat them as real failures.
600
+ */
601
+ static isTransientMergeError(message) {
602
+ const m = message.toLowerCase();
603
+ return m.includes("being checked") || m.includes("being recomputed") || m.includes("cannot be merged") || m.includes("not mergeable") || m.includes("merge failed") || m.includes("try again");
604
+ }
605
+ /** Small awaitable delay helper (ms). */
606
+ static delay(ms) {
607
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
532
608
  }
533
609
  /**
534
610
  * Approve a merge request
@@ -576,7 +652,13 @@ var MergeRequestsClient = class extends GitLabApiClient {
576
652
  * Returns detailed context about what happened
577
653
  */
578
654
  async smartMerge(projectId, mrIid, sha, strategy = "MERGE_WHEN_CHECKS_PASS") {
579
- const mr = await this.getMergeRequest(projectId, mrIid);
655
+ let mr = await this.getMergeRequest(projectId, mrIid);
656
+ if (mr.detailed_merge_status === "checking") {
657
+ for (let attempt = 0; attempt < 5 && mr.detailed_merge_status === "checking"; attempt++) {
658
+ await _MergeRequestsClient.delay(1e3 * (attempt + 1));
659
+ mr = await this.getMergeRequest(projectId, mrIid);
660
+ }
661
+ }
580
662
  const context = {
581
663
  state: mr.state,
582
664
  detailedMergeStatus: mr.detailed_merge_status,
@@ -615,14 +697,13 @@ var MergeRequestsClient = class extends GitLabApiClient {
615
697
  };
616
698
  }
617
699
  }
618
- const autoMergeStatuses = ["ci_still_running", "not_approved", "checking"];
700
+ const autoMergeStatuses = ["ci_still_running", "not_approved"];
619
701
  if (autoMergeStatuses.includes(mr.detailed_merge_status)) {
620
702
  try {
621
703
  const result = await this.setAutoMerge(projectId, mrIid, sha, strategy);
622
704
  const autoMergeMessages = {
623
705
  ci_still_running: "pipeline passes",
624
- not_approved: "approved",
625
- checking: "checks complete"
706
+ not_approved: "approved"
626
707
  };
627
708
  return {
628
709
  action: "auto_merge_enabled",
@@ -729,6 +810,7 @@ var IssuesClient = class extends GitLabApiClient {
729
810
  async listIssues(options) {
730
811
  const params = new URLSearchParams();
731
812
  params.set("per_page", String(options.limit || 20));
813
+ if (options.page) params.set("page", String(options.page));
732
814
  if (options.state) params.set("state", options.state);
733
815
  if (options.scope) params.set("scope", options.scope);
734
816
  if (options.search) params.set("search", options.search);
@@ -741,7 +823,7 @@ var IssuesClient = class extends GitLabApiClient {
741
823
  } else {
742
824
  path2 = `/issues?${params}`;
743
825
  }
744
- return this.fetch("GET", path2);
826
+ return this.fetchWithPagination("GET", path2);
745
827
  }
746
828
  /**
747
829
  * List notes on an issue using GraphQL API with pagination support
@@ -980,22 +1062,8 @@ var GET_WORK_ITEM_NOTES_QUERY = `
980
1062
  }
981
1063
  `;
982
1064
  var CREATE_WORK_ITEM_MUTATION = `
983
- mutation createWorkItem(
984
- $projectPath: ID!
985
- $title: String!
986
- $workItemTypeId: WorkItemsTypeID!
987
- $description: String
988
- $labelIds: [LabelID!]
989
- $assigneeIds: [UserID!]
990
- ) {
991
- workItemCreate(input: {
992
- projectPath: $projectPath
993
- title: $title
994
- workItemTypeId: $workItemTypeId
995
- descriptionWidget: { description: $description }
996
- labelsWidget: { labelIds: $labelIds }
997
- assigneesWidget: { assigneeIds: $assigneeIds }
998
- }) {
1065
+ mutation createWorkItem($input: WorkItemCreateInput!) {
1066
+ workItemCreate(input: $input) {
999
1067
  workItem {
1000
1068
  ${WORK_ITEM_FIELDS}
1001
1069
  }
@@ -1004,22 +1072,8 @@ var CREATE_WORK_ITEM_MUTATION = `
1004
1072
  }
1005
1073
  `;
1006
1074
  var UPDATE_WORK_ITEM_MUTATION = `
1007
- mutation updateWorkItem(
1008
- $id: WorkItemID!
1009
- $title: String
1010
- $description: String
1011
- $stateEvent: WorkItemStateEvent
1012
- $labelIds: [LabelID!]
1013
- $assigneeIds: [UserID!]
1014
- ) {
1015
- workItemUpdate(input: {
1016
- id: $id
1017
- title: $title
1018
- descriptionWidget: { description: $description }
1019
- stateEvent: $stateEvent
1020
- labelsWidget: { labelIds: $labelIds }
1021
- assigneesWidget: { assigneeIds: $assigneeIds }
1022
- }) {
1075
+ mutation updateWorkItem($input: WorkItemUpdateInput!) {
1076
+ workItemUpdate(input: $input) {
1023
1077
  workItem {
1024
1078
  ${WORK_ITEM_FIELDS}
1025
1079
  }
@@ -1113,14 +1167,21 @@ var WorkItemsClient = class extends GitLabApiClient {
1113
1167
  async createWorkItem(projectId, options) {
1114
1168
  const labelIds = options.labels?.map((l) => toGid("Label", l));
1115
1169
  const assigneeIds = options.assignee_ids?.map((id) => toGid("User", id));
1116
- const result = await this.fetchGraphQL(CREATE_WORK_ITEM_MUTATION, {
1170
+ const input = {
1117
1171
  projectPath: projectId,
1118
1172
  title: options.title,
1119
- workItemTypeId: toGid("WorkItems::Type", options.work_item_type_id),
1120
- description: options.description,
1121
- labelIds: labelIds?.length ? labelIds : void 0,
1122
- assigneeIds: assigneeIds?.length ? assigneeIds : void 0
1123
- });
1173
+ workItemTypeId: toGid("WorkItems::Type", options.work_item_type_id)
1174
+ };
1175
+ if (options.description !== void 0) {
1176
+ input.descriptionWidget = { description: options.description };
1177
+ }
1178
+ if (labelIds?.length) {
1179
+ input.labelsWidget = { labelIds };
1180
+ }
1181
+ if (assigneeIds?.length) {
1182
+ input.assigneesWidget = { assigneeIds };
1183
+ }
1184
+ const result = await this.fetchGraphQL(CREATE_WORK_ITEM_MUTATION, { input });
1124
1185
  if (result.workItemCreate.errors.length > 0) {
1125
1186
  throw new Error(`Failed to create work item: ${result.workItemCreate.errors.join(", ")}`);
1126
1187
  }
@@ -1138,14 +1199,23 @@ var WorkItemsClient = class extends GitLabApiClient {
1138
1199
  const stateEvent = options.state_event ? options.state_event === "close" ? "CLOSE" : "REOPEN" : void 0;
1139
1200
  const labelIds = options.labels?.map((l) => toGid("Label", l));
1140
1201
  const assigneeIds = options.assignee_ids?.map((id) => toGid("User", id));
1141
- const result = await this.fetchGraphQL(UPDATE_WORK_ITEM_MUTATION, {
1142
- id: gid,
1143
- title: options.title,
1144
- description: options.description,
1145
- stateEvent,
1146
- labelIds: labelIds?.length ? labelIds : void 0,
1147
- assigneeIds: assigneeIds?.length ? assigneeIds : void 0
1148
- });
1202
+ const input = { id: gid };
1203
+ if (options.title !== void 0) {
1204
+ input.title = options.title;
1205
+ }
1206
+ if (options.description !== void 0) {
1207
+ input.descriptionWidget = { description: options.description };
1208
+ }
1209
+ if (stateEvent !== void 0) {
1210
+ input.stateEvent = stateEvent;
1211
+ }
1212
+ if (labelIds?.length) {
1213
+ input.labelsWidget = { addLabelIds: labelIds };
1214
+ }
1215
+ if (assigneeIds?.length) {
1216
+ input.assigneesWidget = { assigneeIds };
1217
+ }
1218
+ const result = await this.fetchGraphQL(UPDATE_WORK_ITEM_MUTATION, { input });
1149
1219
  if (result.workItemUpdate.errors.length > 0) {
1150
1220
  throw new Error(`Failed to update work item: ${result.workItemUpdate.errors.join(", ")}`);
1151
1221
  }
@@ -2647,26 +2717,31 @@ Returns: title, description, state, author, assignees, reviewers, labels, diff s
2647
2717
  }),
2648
2718
  gitlab_list_merge_requests: tool({
2649
2719
  description: `List merge requests for a project or search globally.
2650
- Can filter by state (opened, closed, merged, all), scope (assigned_to_me, created_by_me), and labels.`,
2720
+ Can filter by state (opened, closed, merged, all), scope (assigned_to_me, created_by_me), and labels.
2721
+
2722
+ IMPORTANT: Response includes pagination info. If nextPage exists, there are more results.
2723
+ Always paginate until you get an empty response or no nextPage to ensure you have all data.`,
2651
2724
  args: {
2652
2725
  project_id: z.string().optional().describe("The project ID or path. If not provided, searches globally."),
2653
2726
  state: z.enum(["opened", "closed", "merged", "all"]).optional().describe("Filter by MR state (default: opened)"),
2654
2727
  scope: z.enum(["assigned_to_me", "created_by_me", "all"]).optional().describe("Filter by scope"),
2655
2728
  search: z.string().optional().describe("Search MRs by title or description"),
2656
2729
  labels: z.string().optional().describe("Comma-separated list of labels to filter by"),
2657
- limit: z.number().optional().describe("Maximum number of results (default: 20)")
2730
+ limit: z.number().optional().describe("Maximum number of results per page (default: 20)"),
2731
+ page: z.number().optional().describe("Page number for pagination (default: 1)")
2658
2732
  },
2659
2733
  execute: async (args, _ctx) => {
2660
2734
  const client = getGitLabClient();
2661
- const mrs = await client.listMergeRequests({
2735
+ const result = await client.listMergeRequests({
2662
2736
  projectId: args.project_id,
2663
2737
  state: args.state,
2664
2738
  scope: args.scope,
2665
2739
  search: args.search,
2666
2740
  labels: args.labels,
2667
- limit: args.limit
2741
+ limit: args.limit,
2742
+ page: args.page
2668
2743
  });
2669
- return JSON.stringify(mrs, null, 2);
2744
+ return JSON.stringify(result, null, 2);
2670
2745
  }
2671
2746
  }),
2672
2747
  gitlab_get_mr_changes: tool({
@@ -2998,7 +3073,10 @@ Returns: title, description, state, author, assignees, labels, milestone, weight
2998
3073
  }),
2999
3074
  gitlab_list_issues: tool2({
3000
3075
  description: `List issues for a project or search globally.
3001
- Can filter by state, labels, assignee, milestone.`,
3076
+ Can filter by state, labels, assignee, milestone.
3077
+
3078
+ IMPORTANT: Response includes pagination info. If nextPage exists, there are more results.
3079
+ Always paginate until you get an empty response or no nextPage to ensure you have all data.`,
3002
3080
  args: {
3003
3081
  project_id: z2.string().optional().describe("The project ID or path. If not provided, searches globally."),
3004
3082
  state: z2.enum(["opened", "closed", "all"]).optional().describe("Filter by issue state (default: opened)"),
@@ -3006,20 +3084,22 @@ Can filter by state, labels, assignee, milestone.`,
3006
3084
  search: z2.string().optional().describe("Search issues by title or description"),
3007
3085
  labels: z2.string().optional().describe("Comma-separated list of labels to filter by"),
3008
3086
  milestone: z2.string().optional().describe("Filter by milestone title"),
3009
- limit: z2.number().optional().describe("Maximum number of results (default: 20)")
3087
+ limit: z2.number().optional().describe("Maximum number of results per page (default: 20)"),
3088
+ page: z2.number().optional().describe("Page number for pagination (default: 1)")
3010
3089
  },
3011
3090
  execute: async (args, _ctx) => {
3012
3091
  const client = getGitLabClient();
3013
- const issues = await client.listIssues({
3092
+ const result = await client.listIssues({
3014
3093
  projectId: args.project_id,
3015
3094
  state: args.state,
3016
3095
  scope: args.scope,
3017
3096
  search: args.search,
3018
3097
  labels: args.labels,
3019
3098
  milestone: args.milestone,
3020
- limit: args.limit
3099
+ limit: args.limit,
3100
+ page: args.page
3021
3101
  });
3022
- return JSON.stringify(issues, null, 2);
3102
+ return JSON.stringify(result, null, 2);
3023
3103
  }
3024
3104
  })
3025
3105
  };