gitlab-labkit 5.6.1 → 5.7.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a1ef319c707c40ea0cca46358bbacdf18d1068b8f2b778ebc1a1486c33cea1ef
4
- data.tar.gz: 5cbead469ab098d29afb9dcf2dd97576976a703db93eda7ea337a54bc94fcde9
3
+ metadata.gz: f2d9d587afe49d99a2ada5a8757230d69024a1085009f1e65c29ac97aa005e98
4
+ data.tar.gz: 35e55c1e1f9aef6836736e6917cffe4813aa37655eebf5534893f9b7b232fb89
5
5
  SHA512:
6
- metadata.gz: 1488c581703cab14db63d485aa2ef3159d8a0cd8449cc34b4aeeb522d97ad2207edb57096f388240cff213ef44aaa925a8219a2464b3ab4054a81b0e8569129a
7
- data.tar.gz: 4c65d98b9b29084a3c5bdc7e0d9dc166a4708f203840a08180421172f21c0356005641557e46dc14f48fb277570f4bdae041d61072c454dbdfa252e4e8baf5fd
6
+ metadata.gz: 83a9378e749aa4c04cf05cca872fffabe8945802b169c996aba579b43b73e582d98dca6c37b31c82098fe3edda41932ddc81813010ab278e2df7569812c7926e
7
+ data.tar.gz: 5b00ebd19184fbfaec05c1cdf40de93b74a70c0a7aa2b167213425debada2ea6708b61874d4ad23a9de40c950724b47da599d37daf0116d323ed8b4db76a6d23
@@ -5,6 +5,7 @@
5
5
  # touched. On failure it prints the output to stderr and exits 2, which feeds the
6
6
  # result back to Claude so it can fix the issue. A clean run exits 0 silently.
7
7
  set -euo pipefail
8
+ IFS=$'\n\t'
8
9
 
9
10
  # no-commit-to-branch guards the branch, not file content, and would fire
10
11
  # spuriously (e.g. on jj's detached HEAD). It is irrelevant to file validation.
data/.copier-answers.yml CHANGED
@@ -3,7 +3,7 @@
3
3
  # See the project for instructions on how to update the project
4
4
  #
5
5
  # Changes here will be overwritten by Copier; NEVER EDIT MANUALLY
6
- _commit: v1.85.0
6
+ _commit: v1.87.1
7
7
  _src_path: https://gitlab.com/gitlab-com/gl-infra/common-template-copier.git
8
8
  ee_licensed: false
9
9
  gitlab_namespace: gitlab-org/ruby/gems
data/.gitlab/CODEOWNERS CHANGED
@@ -1 +1 @@
1
- * @reprazent @mkaeppler @ayufan @hmerscher @sankalp_gl @hardikgala @nindurkar @ashs2
1
+ * @mkaeppler @ayufan @hmerscher @sankalp_gl @hardikgala @nindurkar @ashs2
data/.gitlab-ci.yml CHANGED
@@ -19,13 +19,27 @@ include:
19
19
  # It includes standard checks, gitlab-scanners, validations and release processes
20
20
  # common to all projects using this template library.
21
21
  # see https://gitlab.com/gitlab-com/gl-infra/common-ci-tasks/-/blob/main/templates/standard.md
22
- - component: $CI_SERVER_FQDN/gitlab-com/gl-infra/common-ci-tasks/standard-build@v5.24
22
+ - component: $CI_SERVER_FQDN/gitlab-com/gl-infra/common-ci-tasks/standard-build@v5.46
23
23
 
24
24
  # Runs rspec tests and rubocop on the project
25
25
  # see https://gitlab.com/gitlab-com/gl-infra/common-ci-tasks/-/blob/main/templates/ruby.md
26
- - component: $CI_SERVER_FQDN/gitlab-com/gl-infra/common-ci-tasks/ruby-build@v5.24
26
+ - component: $CI_SERVER_FQDN/gitlab-com/gl-infra/common-ci-tasks/ruby-build@v5.46
27
27
 
28
- - component: $CI_SERVER_FQDN/gitlab-com/gl-infra/common-ci-tasks/danger@v5.24
28
+ - component: $CI_SERVER_FQDN/gitlab-com/gl-infra/common-ci-tasks/danger@v5.46
29
+
30
+ # danger-review reads its token from Vault, which the community fork has no
31
+ # access to, so it fails on merge requests from the fork. Let it fail for those;
32
+ # the second rule is the component's own. See
33
+ # https://gitlab.com/gitlab-org/labkit/-/work_items/171
34
+ danger-review:
35
+ rules:
36
+ - if: $CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_SOURCE_PROJECT_PATH == "gitlab-community/gitlab-org/ruby/gems/labkit-ruby"
37
+ exists:
38
+ - Dangerfile
39
+ allow_failure: true
40
+ - if: $CI_PIPELINE_SOURCE == "merge_request_event"
41
+ exists:
42
+ - Dangerfile
29
43
 
30
44
  # Attach a redis service to the rspec job from common-ci-tasks/ruby-build.
31
45
  # GitLab merges keys when a local job has the same name as an included one,
@@ -1,4 +1,6 @@
1
- import type { Plugin, Hooks } from "@opencode-ai/plugin";
1
+ import type { Hooks, PluginModule } from "@opencode-ai/plugin";
2
+ import type { Plugin } from "@opencode/plugin";
3
+ import { execFile, spawn } from "node:child_process";
2
4
  import { existsSync } from "node:fs";
3
5
  import { isAbsolute, join, resolve } from "node:path";
4
6
 
@@ -22,80 +24,203 @@ import { isAbsolute, join, resolve } from "node:path";
22
24
  *
23
25
  * The script is reused verbatim: it reads Claude Code's PostToolUse payload on
24
26
  * stdin (`{ "tool_input": { "file_path": "..." } }`), so we synthesize that same
25
- * shape here from OpenCode's tool arguments.
27
+ * shape here from OpenCode's tool input.
28
+ *
29
+ * Supports both plugin APIs from one default export, as described in
30
+ * https://opencode.ai/v2/docs/build/plugins/migrate-v1:
31
+ * - OpenCode 2 reads `id` and `setup()` (the `@opencode/plugin` API).
32
+ * - OpenCode 1 (>= 1.18.29) calls `server()` (the `@opencode-ai/plugin` API).
33
+ * Both imports are type-only, so neither OpenCode version needs the other's
34
+ * plugin package installed at runtime. Remove `server()` once V1 is gone.
26
35
  *
27
36
  * Auto-loaded from `.opencode/plugins/`.
28
37
  */
29
38
 
30
- // OpenCode file-mutating tools, analogous to Claude Code's `Edit|Write|MultiEdit`
31
- // matcher. Current OpenCode may use `apply_patch` for file edits.
32
- const FILE_TOOLS = new Set(["apply_patch", "edit", "write"]);
39
+ const ID = "precommit-validate";
33
40
 
34
41
  const HOOK_SCRIPT = ".claude/hooks/precommit-validate.sh";
35
42
 
36
- export const PrecommitValidatePlugin: Plugin = async ({ $, client, directory, worktree }) => {
37
- const root = worktree || directory;
38
- const script = join(root, HOOK_SCRIPT);
43
+ // OpenCode file-mutating tools, analogous to Claude Code's `Edit|Write|MultiEdit`
44
+ // matcher. V1 and V2 both have `edit` and `write`; patches go through
45
+ // `apply_patch` in V1 and `patch` in V2.
46
+ const FILE_TOOLS = new Set(["apply_patch", "edit", "patch", "write"]);
39
47
 
40
- const hooks: Hooks = {
41
- "tool.execute.after": async (input, output) => {
42
- if (!FILE_TOOLS.has(input.tool)) return;
43
- // Inert when the shared script is absent (e.g. loaded in another repo).
44
- if (!existsSync(script)) return;
48
+ interface HookResult {
49
+ /** Exit code 2: pre-commit findings to feed back to the model. */
50
+ readonly reports: string[];
51
+ /** Any other failure: environment problems to log for the user. */
52
+ readonly warnings: string[];
53
+ }
45
54
 
46
- const filePaths = new Set<string>();
47
- const filePath: string | undefined = input.args?.filePath;
48
- if (filePath) filePaths.add(filePath);
55
+ const asRecord = (value: unknown): Record<string, unknown> =>
56
+ value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
49
57
 
50
- for (const file of output.metadata?.files ?? []) {
51
- if (typeof file.filePath === "string") filePaths.add(file.filePath);
52
- }
58
+ const asString = (value: unknown): string | undefined => (typeof value === "string" && value ? value : undefined);
53
59
 
60
+ /**
61
+ * Collect the files a tool call touched from its input and result metadata.
62
+ *
63
+ * Input: V2 `edit`/`write` take `path`; V1 takes `filePath`.
64
+ * Metadata: `files` entries carry `file` in V2 and `filePath` in V1. This is
65
+ * the only source for patch tools, whose input is a single `patchText` blob.
66
+ */
67
+ function touchedFiles(input: unknown, metadata: unknown): Set<string> {
68
+ const args = asRecord(input);
69
+ const files = new Set<string>();
70
+
71
+ const direct = asString(args.path) ?? asString(args.filePath);
72
+ if (direct) files.add(direct);
73
+
74
+ const entries = asRecord(metadata).files;
75
+ for (const entry of Array.isArray(entries) ? entries : []) {
76
+ const file = asRecord(entry);
77
+ // A deleted file has nothing left to validate.
78
+ if (file.status === "deleted" || file.type === "delete") continue;
79
+ const path = asString(file.file) ?? asString(file.filePath);
80
+ if (path) files.add(path);
81
+ }
82
+
83
+ return files;
84
+ }
85
+
86
+ /** Run the shared hook script for one file, feeding the payload on stdin. */
87
+ function runHookScript(
88
+ script: string,
89
+ root: string,
90
+ payload: string,
91
+ signal?: AbortSignal,
92
+ ): Promise<{ code: number | null; stdout: string; stderr: string }> {
93
+ return new Promise((done, fail) => {
94
+ const child = spawn(script, [], { cwd: root, signal, stdio: ["pipe", "pipe", "pipe"] });
95
+ let stdout = "";
96
+ let stderr = "";
97
+ child.stdout.setEncoding("utf8").on("data", (chunk: string) => (stdout += chunk));
98
+ child.stderr.setEncoding("utf8").on("data", (chunk: string) => (stderr += chunk));
99
+ // The script exits early (e.g. mise missing) without reading stdin.
100
+ child.stdin.on("error", () => {});
101
+ child.on("error", fail);
102
+ child.on("close", (code) => done({ code, stdout, stderr }));
103
+ child.stdin.end(payload);
104
+ });
105
+ }
106
+
107
+ /** Validate every touched file and sort the outcomes into reports and warnings. */
108
+ async function validate(root: string, filePaths: Iterable<string>, signal?: AbortSignal): Promise<HookResult> {
109
+ const result: HookResult = { reports: [], warnings: [] };
110
+ const script = join(root, HOOK_SCRIPT);
111
+ // Inert when the shared script is absent (e.g. loaded in another repo).
112
+ if (!existsSync(script)) return result;
113
+
114
+ for (const filePath of filePaths) {
115
+ if (signal?.aborted) break;
116
+ const absolute = isAbsolute(filePath) ? filePath : resolve(root, filePath);
117
+
118
+ // Same JSON shape Claude Code's PostToolUse hook delivers on stdin. The
119
+ // script itself decides whether the file lives inside the repo.
120
+ const payload = JSON.stringify({ tool_input: { file_path: absolute } });
121
+
122
+ let run: Awaited<ReturnType<typeof runHookScript>>;
123
+ try {
124
+ run = await runHookScript(script, root, payload, signal);
125
+ } catch (error) {
126
+ if (signal?.aborted) break; // plugin unloaded mid-run
127
+ result.warnings.push(`pre-commit validation hook failed to start: ${String(error)}`);
128
+ continue;
129
+ }
130
+
131
+ if (run.code === 0) continue; // clean / no-op
132
+
133
+ const report = run.stderr.trim() || run.stdout.trim();
134
+
135
+ if (run.code === 2) {
136
+ // Blocking: pre-commit reported issues. Surface them to the model, the
137
+ // OpenCode equivalent of feeding the hook's stderr back to Claude Code.
138
+ if (report) result.reports.push(report);
139
+ continue;
140
+ }
141
+
142
+ // Non-blocking error (e.g. exit 1 = mise not on PATH). Not something the
143
+ // model can act on, so log it for the user instead of polluting the turn.
144
+ result.warnings.push(report || `pre-commit validation hook exited ${run.code}`);
145
+ }
146
+
147
+ return result;
148
+ }
149
+
150
+ const formatReports = (reports: string[]): string =>
151
+ `<pre-commit-validation>\n${reports.join("\n\n")}\n</pre-commit-validation>`;
152
+
153
+ /** The repository root, so the hook script is found from any subdirectory. */
154
+ function gitToplevel(directory: string): Promise<string> {
155
+ return new Promise((done) => {
156
+ execFile("git", ["rev-parse", "--show-toplevel"], { cwd: directory }, (error, stdout) => {
157
+ done(error ? directory : stdout.trim() || directory);
158
+ });
159
+ });
160
+ }
161
+
162
+ /** OpenCode 2 implementation (`@opencode/plugin`). */
163
+ const v2: Plugin.Plugin = {
164
+ id: ID,
165
+ async setup(ctx) {
166
+ const root = await gitToplevel(ctx.location.directory);
167
+ // Hook events carry no abort signal; stop in-flight runs on unload instead.
168
+ const controller = new AbortController();
169
+
170
+ await ctx.tool.hook("execute.after", async (event) => {
171
+ if (event.status !== "completed") return;
172
+ if (!FILE_TOOLS.has(event.tool)) return;
173
+
174
+ const filePaths = touchedFiles(event.input, event.result.metadata);
54
175
  if (filePaths.size === 0) return;
55
176
 
56
- const reports: string[] = [];
177
+ const { reports, warnings } = await validate(root, filePaths, controller.signal);
178
+
179
+ for (const warning of warnings) console.warn(`[${ID}] ${warning}`);
57
180
 
58
- for (const filePath of filePaths) {
59
- const absolute = isAbsolute(filePath) ? filePath : resolve(root, filePath);
181
+ if (reports.length === 0) return;
60
182
 
61
- // Same JSON shape Claude Code's PostToolUse hook delivers on stdin. The
62
- // script itself decides whether the file lives inside the repo.
63
- const payload = JSON.stringify({ tool_input: { file_path: absolute } });
183
+ // Results are read-only: replace the result, appending to its content.
184
+ const text = formatReports(reports);
185
+ const content = event.result.content;
186
+ event.result = {
187
+ ...event.result,
188
+ content:
189
+ typeof content === "string" || content === undefined
190
+ ? content ? `${content}\n\n${text}` : text
191
+ : [...content, { type: "text", text }],
192
+ };
193
+ });
194
+
195
+ return () => controller.abort();
196
+ },
197
+ };
64
198
 
65
- const result = await $`printf '%s' ${payload} | ${script}`
66
- .cwd(root)
67
- .nothrow()
68
- .quiet();
199
+ /** OpenCode 1 implementation (`@opencode-ai/plugin`, >= 1.18.29). */
200
+ const v1: PluginModule = {
201
+ id: ID,
202
+ async server({ client, directory, worktree }) {
203
+ const root = worktree || directory;
69
204
 
70
- if (result.exitCode === 0) continue; // clean / no-op
205
+ const hooks: Hooks = {
206
+ "tool.execute.after": async (input, output) => {
207
+ if (!FILE_TOOLS.has(input.tool)) return;
71
208
 
72
- const report =
73
- result.stderr.toString().trim() || result.stdout.toString().trim();
209
+ const filePaths = touchedFiles(input.args, output.metadata);
210
+ if (filePaths.size === 0) return;
74
211
 
75
- if (result.exitCode === 2) {
76
- // Blocking: pre-commit reported issues. Surface them to the model by
77
- // appending to the tool output, the OpenCode equivalent of feeding the
78
- // hook's stderr back to Claude Code on exit 2.
79
- if (report) reports.push(report);
80
- continue;
212
+ const { reports, warnings } = await validate(root, filePaths);
213
+
214
+ for (const warning of warnings) {
215
+ await client.app.log({ body: { service: ID, level: "warn", message: warning } });
81
216
  }
82
217
 
83
- // Non-blocking error (e.g. exit 1 = mise not on PATH). Not something the
84
- // model can act on, so log it for the user instead of polluting the turn.
85
- await client.app.log({
86
- body: {
87
- service: "precommit-validate",
88
- level: "warn",
89
- message: report || `pre-commit validation hook exited ${result.exitCode}`,
90
- },
91
- });
92
- }
93
-
94
- if (reports.length > 0) {
95
- output.output += `\n\n<pre-commit-validation>\n${reports.join("\n\n")}\n</pre-commit-validation>`;
96
- }
97
- },
98
- };
99
-
100
- return hooks;
218
+ if (reports.length > 0) output.output += `\n\n${formatReports(reports)}`;
219
+ },
220
+ };
221
+
222
+ return hooks;
223
+ },
101
224
  };
225
+
226
+ export default { ...v2, server: v1.server };
@@ -25,7 +25,7 @@ repos:
25
25
  # Documentation available at
26
26
  # https://gitlab.com/gitlab-com/gl-infra/common-ci-tasks/-/blob/main/docs/pre-commit.md
27
27
  - repo: https://gitlab.com/gitlab-com/gl-infra/common-ci-tasks
28
- rev: v5.24 # renovate:managed
28
+ rev: v5.46 # renovate:managed
29
29
 
30
30
  hooks:
31
31
  - id: shellcheck # Run shellcheck for changed Shell files
data/CODEOWNERS CHANGED
@@ -1,4 +1,4 @@
1
1
  # CODEOWNERS is used to lookup assignees for
2
2
  # Renovate Bot dependency change Merge Requests.
3
3
  # https://docs.renovatebot.com/configuration-options/#assigneesfromcodeowners
4
- * @reprazent @ayufan @hmerscher @e_forbes @M_Alvarez @mwoolf @sankalp_gl @hardikgala @nindurkar @ashs2
4
+ * @ayufan @hmerscher @e_forbes @M_Alvarez @mwoolf @sankalp_gl @hardikgala @nindurkar @ashs2
data/CONTRIBUTING.md CHANGED
@@ -6,7 +6,7 @@ Except for the license granted herein to GitLab Inc. and recipients of software
6
6
  distributed by GitLab Inc., you reserve all right, title, and interest in and to
7
7
  your Contributions.
8
8
 
9
- All contributions are subject to the Developer Certificate of Origin and license set out at [docs.gitlab.com/ce/legal/developer_certificate_of_origin](https://docs.gitlab.com/ce/legal/developer_certificate_of_origin).
9
+ All contributions are subject to the Developer Certificate of Origin and license set out at [docs.gitlab.com/legal/developer_certificate_of_origin](https://docs.gitlab.com/legal/developer_certificate_of_origin/).
10
10
 
11
11
  _This notice should stay as the first item in the CONTRIBUTING.md file._
12
12
 
data/lib/labkit/fields.rb CHANGED
@@ -16,6 +16,12 @@ module Labkit
16
16
  # GitLab username.
17
17
  GL_USER_NAME = "gl_user_name"
18
18
 
19
+ # Type of key used for authentication (e.g. "deploy_key", "key").
20
+ GL_KEY_TYPE = "gl_key_type"
21
+
22
+ # Numeric ID of the key used for authentication.
23
+ GL_KEY_ID = "gl_key_id"
24
+
19
25
  # Duo Workflow definition identifier (e.g. "analytics_agent/v1"). Identifies
20
26
  # which Duo Workflow agent originated a request, enabling per-agent
21
27
  # filtering in Kibana and Grafana.
@@ -149,12 +155,19 @@ module Labkit
149
155
  # connection field.
150
156
  GRAPHQL_CONNECTION_NODES = "graphql_connection_nodes"
151
157
 
158
+ # Protocol or subsystem that produced an access log line (e.g. "http",
159
+ # "grpc"). Emitted by the LabKit access-logging middleware so a single
160
+ # service's HTTP and gRPC access logs can be told apart.
161
+ SYSTEM = "system"
162
+
152
163
  # Maps each field constant to its logging field variant version.
153
164
  # A version of 0 means the field is not yet assigned to any variant.
154
165
  VARIANT_VERSION = {
155
166
  CORRELATION_ID => 1,
156
167
  GL_USER_ID => 1,
157
168
  GL_USER_NAME => 1,
169
+ GL_KEY_TYPE => 0,
170
+ GL_KEY_ID => 0,
158
171
  DUO_WORKFLOW_DEFINITION => 0,
159
172
  DUO_WORKFLOW_ID => 0,
160
173
  ERROR_TYPE => 0,
@@ -192,6 +205,7 @@ module Labkit
192
205
  ADDITIONAL_DETAILS => 0,
193
206
  GRAPHQL_COMPLEXITY_RAW => 0,
194
207
  GRAPHQL_CONNECTION_NODES => 0,
208
+ SYSTEM => 0,
195
209
  }.freeze
196
210
 
197
211
  # Get the constant name for a field value
@@ -3,6 +3,7 @@
3
3
  # Consider contributing upstream when updating this file
4
4
 
5
5
  set -euo pipefail
6
+ IFS=$'\n\t'
6
7
 
7
8
  # ---------------------------------------------------------
8
9
  # This script will prepare your development environment
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gitlab-labkit
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.6.1
4
+ version: 5.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Newdigate