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 +14 -0
- package/README.md +14 -5
- package/dist/index.js +144 -22
- package/dist/index.js.map +1 -1
- package/dist/mcp-server.cjs +123 -22
- package/dist/mcp-server.cjs.map +1 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
2858
|
+
return {};
|
|
2793
2859
|
}
|
|
2794
|
-
|
|
2795
|
-
|
|
2860
|
+
const token = credentialToToken(gitlabAuth);
|
|
2861
|
+
if (token) {
|
|
2862
|
+
return { token };
|
|
2796
2863
|
}
|
|
2797
|
-
if (gitlabAuth.type === "
|
|
2798
|
-
return gitlabAuth.
|
|
2864
|
+
if (gitlabAuth.type === "oauth" && isExpired(gitlabAuth.expires)) {
|
|
2865
|
+
return { expired: new Date(gitlabAuth.expires) };
|
|
2799
2866
|
}
|
|
2800
|
-
return
|
|
2867
|
+
return {};
|
|
2801
2868
|
} catch (error) {
|
|
2802
|
-
return
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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, {
|
|
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);
|