opencode-gitlab-plugin 3.1.0 → 3.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
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
+ ## [3.2.1](https://gitlab.com/vglafirov/opencode-gitlab-plugin/compare/v3.2.0...v3.2.1) (2026-10-25)
6
+
7
+
8
+ ### 🐛 Bug Fixes
9
+
10
+ * **auth:** read GitLab credentials from the OpenCode v2 store ([a039ea3](https://gitlab.com/vglafirov/opencode-gitlab-plugin/commit/a039ea3345db77973791d36f77716b9cd6e7bdc3))
11
+
12
+ ## [3.2.0](https://gitlab.com/vglafirov/opencode-gitlab-plugin/compare/v3.1.0...v3.2.0) (2026-10-25)
13
+
14
+
15
+ ### ✨ Features
16
+
17
+ * **notes:** support confidential/internal notes in gitlab_create_note ([90cedca](https://gitlab.com/vglafirov/opencode-gitlab-plugin/commit/90cedcac2ab128a2f2377a2951776e47a0d395ba))
18
+
5
19
  ## [3.1.0](https://gitlab.com/vglafirov/opencode-gitlab-plugin/compare/v3.0.1...v3.1.0) (2026-10-13)
6
20
 
7
21
 
package/README.md CHANGED
@@ -477,11 +477,11 @@ The plugin provides **64 tools** organized into the following categories:
477
477
 
478
478
  ### Notes Tools (3 tools)
479
479
 
480
- | Tool | Description |
481
- | -------------------- | --------------------------------------------------------------------------------------------------- |
482
- | `gitlab_list_notes` | List all notes/comments on any resource (MRs, issues, epics, snippets) with cursor-based pagination |
483
- | `gitlab_get_note` | Get a single note by ID (issues and epics only) |
484
- | `gitlab_create_note` | Add a simple comment to any resource (for thread replies, use `gitlab_create_discussion`) |
480
+ | Tool | Description |
481
+ | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
482
+ | `gitlab_list_notes` | List all notes/comments on any resource (MRs, issues, epics, snippets) with cursor-based pagination |
483
+ | `gitlab_get_note` | Get a single note by ID (issues and epics only) |
484
+ | `gitlab_create_note` | Add a simple comment to any resource (for thread replies, use `gitlab_create_discussion`). Pass `internal: true` to post a confidential/internal note on merge requests, issues, and epics |
485
485
 
486
486
  ### Audit Event Tools (3 tools)
487
487
 
@@ -534,6 +534,15 @@ await plugin.tool.gitlab_create_note.execute({
534
534
  body: 'I will start working on this today.',
535
535
  });
536
536
 
537
+ // Add a confidential/internal note (visible only to project members; merge requests, issues, and epics)
538
+ await plugin.tool.gitlab_create_note.execute({
539
+ resource_type: 'issue',
540
+ project_id: 'my-group/my-project',
541
+ iid: issue.iid,
542
+ body: 'Internal triage note - not visible to the reporter.',
543
+ internal: true,
544
+ });
545
+
537
546
  // List all issues with specific labels
538
547
  const issues = await plugin.tool.gitlab_list_issues.execute({
539
548
  project_id: 'my-group/my-project',
package/dist/index.js CHANGED
@@ -511,9 +511,22 @@ var MergeRequestsClient = class _MergeRequestsClient extends GitLabApiClient {
511
511
  requestBody
512
512
  );
513
513
  }
514
- async createMrNote(projectId, mrIid, body, discussionId) {
514
+ /**
515
+ * Create a note on a merge request.
516
+ *
517
+ * When `discussionId` is set the note is a reply on that discussion thread, which the
518
+ * GitLab Notes API does not allow to be internal. Passing `internal=true` together with a
519
+ * `discussionId` therefore throws (fail-closed) rather than silently dropping the
520
+ * confidentiality flag. Post a standalone internal note (omit `discussionId`) instead.
521
+ */
522
+ async createMrNote(projectId, mrIid, body, discussionId, internal) {
515
523
  const encodedProject = this.encodeProjectId(projectId);
516
524
  if (discussionId) {
525
+ if (internal) {
526
+ throw new Error(
527
+ "internal=true is not supported when replying to a discussion. The GitLab Notes API does not accept an internal flag on discussion replies, so honouring it silently would drop a confidentiality flag. Post a standalone internal note (omit discussionId) instead."
528
+ );
529
+ }
517
530
  return this.fetch(
518
531
  "POST",
519
532
  `/projects/${encodedProject}/merge_requests/${mrIid}/discussions/${discussionId}/notes`,
@@ -523,7 +536,7 @@ var MergeRequestsClient = class _MergeRequestsClient extends GitLabApiClient {
523
536
  return this.fetch(
524
537
  "POST",
525
538
  `/projects/${encodedProject}/merge_requests/${mrIid}/notes`,
526
- { body }
539
+ internal ? { body, internal: true } : { body }
527
540
  );
528
541
  }
529
542
  async updateMrNote(projectId, mrIid, noteId, body) {
@@ -989,9 +1002,22 @@ var IssuesClient = class extends GitLabApiClient {
989
1002
  `/projects/${encodedProject}/issues/${issueIid}/discussions/${discussionId}`
990
1003
  );
991
1004
  }
992
- async createIssueNote(projectId, issueIid, body, discussionId) {
1005
+ /**
1006
+ * Create a note on an issue.
1007
+ *
1008
+ * When `discussionId` is set the note is a reply on that discussion thread, which the
1009
+ * GitLab Notes API does not allow to be internal. Passing `internal=true` together with a
1010
+ * `discussionId` therefore throws (fail-closed) rather than silently dropping the
1011
+ * confidentiality flag. Post a standalone internal note (omit `discussionId`) instead.
1012
+ */
1013
+ async createIssueNote(projectId, issueIid, body, discussionId, internal) {
993
1014
  const encodedProject = this.encodeProjectId(projectId);
994
1015
  if (discussionId) {
1016
+ if (internal) {
1017
+ throw new Error(
1018
+ "internal=true is not supported when replying to a discussion. The GitLab Notes API does not accept an internal flag on discussion replies, so honouring it silently would drop a confidentiality flag. Post a standalone internal note (omit discussionId) instead."
1019
+ );
1020
+ }
995
1021
  return this.fetch(
996
1022
  "POST",
997
1023
  `/projects/${encodedProject}/issues/${issueIid}/discussions/${discussionId}/notes`,
@@ -1001,7 +1027,7 @@ var IssuesClient = class extends GitLabApiClient {
1001
1027
  return this.fetch(
1002
1028
  "POST",
1003
1029
  `/projects/${encodedProject}/issues/${issueIid}/notes`,
1004
- { body }
1030
+ internal ? { body, internal: true } : { body }
1005
1031
  );
1006
1032
  }
1007
1033
  async resolveIssueDiscussion(projectId, issueIid, discussionId) {
@@ -2332,9 +2358,22 @@ var EpicsClient = class extends GitLabApiClient {
2332
2358
  `/groups/${encodedGroup}/epics/${epicIid}/discussions/${discussionId}`
2333
2359
  );
2334
2360
  }
2335
- async createEpicNote(groupId, epicIid, body, discussionId) {
2361
+ /**
2362
+ * Create a note on an epic.
2363
+ *
2364
+ * When `discussionId` is set the note is a reply on that discussion thread, which the
2365
+ * GitLab Notes API does not allow to be internal. Passing `internal=true` together with a
2366
+ * `discussionId` therefore throws (fail-closed) rather than silently dropping the
2367
+ * confidentiality flag. Post a standalone internal note (omit `discussionId`) instead.
2368
+ */
2369
+ async createEpicNote(groupId, epicIid, body, discussionId, internal) {
2336
2370
  const encodedGroup = encodeURIComponent(groupId);
2337
2371
  if (discussionId) {
2372
+ if (internal) {
2373
+ throw new Error(
2374
+ "internal=true is not supported when replying to a discussion. The GitLab Notes API does not accept an internal flag on discussion replies, so honouring it silently would drop a confidentiality flag. Post a standalone internal note (omit discussionId) instead."
2375
+ );
2376
+ }
2338
2377
  return this.fetch(
2339
2378
  "POST",
2340
2379
  `/groups/${encodedGroup}/epics/${epicIid}/discussions/${discussionId}/notes`,
@@ -2344,7 +2383,7 @@ var EpicsClient = class extends GitLabApiClient {
2344
2383
  return this.fetch(
2345
2384
  "POST",
2346
2385
  `/groups/${encodedGroup}/epics/${epicIid}/notes`,
2347
- { body }
2386
+ internal ? { body, internal: true } : { body }
2348
2387
  );
2349
2388
  }
2350
2389
  async getEpicNote(groupId, epicIid, noteId) {
@@ -2780,36 +2819,70 @@ applyMixins(UnifiedGitLabClient, [
2780
2819
  ]);
2781
2820
 
2782
2821
  // src/utils.ts
2822
+ var integrationToken;
2823
+ function setIntegrationToken(token) {
2824
+ integrationToken = token || void 0;
2825
+ }
2826
+ function credentialToToken(value) {
2827
+ if (!value || typeof value !== "object") {
2828
+ return void 0;
2829
+ }
2830
+ const credential = value;
2831
+ if (credential["type"] === "oauth") {
2832
+ const access = credential["access"];
2833
+ if (typeof access !== "string" || !access) {
2834
+ return void 0;
2835
+ }
2836
+ return isExpired(credential["expires"]) ? void 0 : access;
2837
+ }
2838
+ if (typeof credential["key"] === "string" && credential["key"]) {
2839
+ return credential["key"];
2840
+ }
2841
+ if (typeof credential["token"] === "string" && credential["token"]) {
2842
+ return credential["token"];
2843
+ }
2844
+ return void 0;
2845
+ }
2846
+ function isExpired(expires) {
2847
+ return typeof expires === "number" && expires > 0 && expires <= Date.now();
2848
+ }
2783
2849
  function readTokenFromAuthStorage() {
2784
2850
  try {
2785
2851
  const authPath = path.join(os.homedir(), ".local", "share", "opencode", "auth.json");
2786
2852
  if (!fs.existsSync(authPath)) {
2787
- return void 0;
2853
+ return {};
2788
2854
  }
2789
2855
  const authData = JSON.parse(fs.readFileSync(authPath, "utf-8"));
2790
2856
  const gitlabAuth = authData?.gitlab;
2791
2857
  if (!gitlabAuth) {
2792
- return void 0;
2858
+ return {};
2793
2859
  }
2794
- if (gitlabAuth.type === "oauth" && gitlabAuth.access) {
2795
- return gitlabAuth.access;
2860
+ const token = credentialToToken(gitlabAuth);
2861
+ if (token) {
2862
+ return { token };
2796
2863
  }
2797
- if (gitlabAuth.type === "api" && gitlabAuth.key) {
2798
- return gitlabAuth.key;
2864
+ if (gitlabAuth.type === "oauth" && isExpired(gitlabAuth.expires)) {
2865
+ return { expired: new Date(gitlabAuth.expires) };
2799
2866
  }
2800
- return gitlabAuth.token || void 0;
2867
+ return {};
2801
2868
  } catch (error) {
2802
- return void 0;
2869
+ return {};
2803
2870
  }
2804
2871
  }
2805
2872
  function getGitLabClient() {
2806
- let token = process.env["GITLAB_TOKEN"];
2873
+ let token = process.env["GITLAB_TOKEN"] || integrationToken;
2874
+ let expired;
2807
2875
  if (!token) {
2808
- token = readTokenFromAuthStorage();
2876
+ ({ token, expired } = readTokenFromAuthStorage());
2809
2877
  }
2810
2878
  if (!token) {
2879
+ if (expired) {
2880
+ throw new Error(
2881
+ `GitLab OAuth token in ~/.local/share/opencode/auth.json expired on ${expired.toISOString()}. Re-authenticate GitLab in OpenCode or set the GITLAB_TOKEN environment variable.`
2882
+ );
2883
+ }
2811
2884
  throw new Error(
2812
- "GitLab API token not found. Set GITLAB_TOKEN environment variable or configure it in OpenCode auth storage (~/.local/share/opencode/auth.json)."
2885
+ "GitLab API token not found. Set GITLAB_TOKEN environment variable or connect GitLab in OpenCode."
2813
2886
  );
2814
2887
  }
2815
2888
  const instanceUrl = process.env["GITLAB_INSTANCE_URL"] || "https://gitlab.com";
@@ -4902,9 +4975,14 @@ Creates a standalone comment (not part of a thread).
4902
4975
  For replying to existing discussion threads, use gitlab_create_discussion
4903
4976
  with discussion_id parameter instead.
4904
4977
 
4978
+ Set internal=true to post a confidential/internal note visible only to project members
4979
+ (supported for merge requests, issues, and epics; not supported for snippets, where passing
4980
+ internal=true raises an error rather than silently posting a public note).
4981
+
4905
4982
  Examples:
4906
4983
  - MR comment: resource_type="merge_request", project_id="group/project", iid=123, body="LGTM!"
4907
4984
  - Issue comment: resource_type="issue", project_id="group/project", iid=456, body="Working on this"
4985
+ - Internal issue note: resource_type="issue", project_id="group/project", iid=456, body="Internal only", internal=true
4908
4986
  - Epic comment: resource_type="epic", group_id="my-group", iid=1, body="Planning complete"
4909
4987
  - Snippet comment: resource_type="snippet", project_id="group/project", snippet_id=789, body="Nice code!"`,
4910
4988
  args: {
@@ -4913,27 +4991,53 @@ Examples:
4913
4991
  project_id: z14.string().optional().describe("Project ID or path. Required for merge_request, issue, snippet"),
4914
4992
  group_id: z14.string().optional().describe("Group ID or path. Required for epic"),
4915
4993
  iid: z14.number().optional().describe("Internal ID of the resource (for merge_request, issue, epic)"),
4916
- snippet_id: z14.number().optional().describe("Snippet ID (required for snippet)")
4994
+ snippet_id: z14.number().optional().describe("Snippet ID (required for snippet)"),
4995
+ internal: z14.boolean().optional().describe(
4996
+ "Post as a confidential/internal note visible only to project members (merge requests, issues, and epics; not supported for snippets, where passing true raises an error). Defaults to false (public note)."
4997
+ )
4917
4998
  },
4918
4999
  execute: async (args, _ctx) => {
4919
5000
  validateNoteParams(args.resource_type, args, { allowedTypes: NOTE_RESOURCE_TYPES });
5001
+ if (args.internal && args.resource_type === "snippet") {
5002
+ throw new Error(
5003
+ `internal=true is not supported for snippet notes. Refusing to post a note that was intended to be confidential as a public note.`
5004
+ );
5005
+ }
4920
5006
  const client = getGitLabClient();
4921
5007
  switch (args.resource_type) {
4922
5008
  case "merge_request":
4923
5009
  return JSON.stringify(
4924
- await client.createMrNote(args.project_id, args.iid, args.body),
5010
+ await client.createMrNote(
5011
+ args.project_id,
5012
+ args.iid,
5013
+ args.body,
5014
+ void 0,
5015
+ args.internal
5016
+ ),
4925
5017
  null,
4926
5018
  2
4927
5019
  );
4928
5020
  case "issue":
4929
5021
  return JSON.stringify(
4930
- await client.createIssueNote(args.project_id, args.iid, args.body),
5022
+ await client.createIssueNote(
5023
+ args.project_id,
5024
+ args.iid,
5025
+ args.body,
5026
+ void 0,
5027
+ args.internal
5028
+ ),
4931
5029
  null,
4932
5030
  2
4933
5031
  );
4934
5032
  case "epic":
4935
5033
  return JSON.stringify(
4936
- await client.createEpicNote(args.group_id, args.iid, args.body),
5034
+ await client.createEpicNote(
5035
+ args.group_id,
5036
+ args.iid,
5037
+ args.body,
5038
+ void 0,
5039
+ args.internal
5040
+ ),
4937
5041
  null,
4938
5042
  2
4939
5043
  );
@@ -6000,6 +6104,7 @@ function adaptTool(name, definition, options) {
6000
6104
  input: toInputSchema(definition.args),
6001
6105
  async execute(input, context) {
6002
6106
  const parsed = validator.parse(input ?? {});
6107
+ await options.beforeExecute?.(context);
6003
6108
  const legacyContext = toLegacyContext(context, options.directory, options.worktree);
6004
6109
  const result = await definition.execute(
6005
6110
  parsed,
@@ -6015,6 +6120,19 @@ function adaptTools(tools, options) {
6015
6120
 
6016
6121
  // src/v2/plugin.ts
6017
6122
  var PLUGIN_ID = "gitlab";
6123
+ var GITLAB_INTEGRATION_ID = "gitlab";
6124
+ async function refreshIntegrationToken(ctx) {
6125
+ const integration = ctx.integration;
6126
+ if (!integration?.connection) {
6127
+ return;
6128
+ }
6129
+ try {
6130
+ const connection = await integration.connection.active(GITLAB_INTEGRATION_ID);
6131
+ const resolved = connection ? await integration.connection.resolve(connection) : void 0;
6132
+ setIntegrationToken(credentialToToken(resolved));
6133
+ } catch {
6134
+ }
6135
+ }
6018
6136
  var LAZY_MCP_HINT = `## GitLab Tools via lazy-mcp
6019
6137
  GitLab API tools (MRs, issues, pipelines, security, search, repos, users, todos, work items, discussions, notes, audit, git, award emoji) are served via the "gitlab" MCP server through lazy-mcp. Use lazy-mcp_list_commands with server "gitlab" to discover and call them.
6020
6138
  DAP tools (agents, flows, memory, skills) are on the "gitlab-dap" server instead.`;
@@ -6029,7 +6147,11 @@ var gitlabPluginV2 = Plugin.define({
6029
6147
  }
6030
6148
  const directory = ctx.location.directory;
6031
6149
  const worktree = ctx.location.project.directory ?? directory;
6032
- const tools = adaptTools(allTools, { directory, worktree });
6150
+ const tools = adaptTools(allTools, {
6151
+ directory,
6152
+ worktree,
6153
+ beforeExecute: () => refreshIntegrationToken(ctx)
6154
+ });
6033
6155
  await ctx.tool.transform((editor) => {
6034
6156
  for (const definition of tools) {
6035
6157
  editor.add(definition);