govgate 0.2.0 → 0.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.
Files changed (4) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +143 -143
  3. package/dist/index.js +572 -0
  4. package/package.json +42 -41
package/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 HatStack
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 HatStack
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,143 +1,143 @@
1
- # govgate
2
-
3
- Pipeline-step CLI for the MT Testing Tool: parses standard test output (JUnit XML),
4
- maps tests to test cases, reports a run, and **gates the release** via its exit code.
5
- No SDK in application code — only the pipeline YAML changes.
6
-
7
- ```
8
- npx govgate report test-results/junit.xml --env uat
9
- ```
10
-
11
- Works identically in GitHub Actions and Azure DevOps. .NET is covered without a NuGet
12
- package: `dotnet test --logger "junit"` (JunitXml.TestLogger) and point the CLI at the
13
- output. Jest/Vitest/Playwright/pytest/NUnit/xUnit all emit JUnit XML natively or via a
14
- standard reporter.
15
-
16
- ## Commands
17
-
18
- ### `govgate report <files-or-globs...>`
19
-
20
- | Flag | Default | Meaning |
21
- |---|---|---|
22
- | `--url <url>` | `MT_TESTING_TOOL_URL` | Tool deployment URL |
23
- | `--api-key <key>` | `MT_TESTING_TOOL_API_KEY` | API key (env var strongly preferred) |
24
- | `--config <path>` | `.mt-testing.json` searched upward | Config file |
25
- | `--suite <slug>` | config `suite` | Target suite |
26
- | `--env <slug>` | config `defaultEnvironment` | Environment for the run |
27
- | `--name <name>` | derived from CI context | Run name |
28
- | `--build-version <v>` | short commit SHA from CI | Build version |
29
- | `--external-url <url>` | CI build URL | "Build ↗" link on the run page |
30
- | `--format junit` | `junit` | Input format (v1: junit only) |
31
- | `--run-id <uuid>` | — | Append to an existing run (multi-job) |
32
- | `--no-complete` | completes | Leave run in progress; skip the gate |
33
- | `--fail-on <list>` | `fail` | Gate: `fail` or `fail,blocked` |
34
- | `--min-executed <pct>` | — | Gate: minimum executed percentage |
35
- | `--actor <label>` | `govgate` | Tested-by label |
36
- | `--dry-run` | — | Parse + map + print; post nothing |
37
-
38
- ### `govgate complete --run-id <uuid> [--fail-on ...] [--min-executed ...]`
39
-
40
- Completes a run left open by `--no-complete` and evaluates the gate over **all** results
41
- in the run (including those posted by other jobs).
42
-
43
- ## Exit codes
44
-
45
- | Code | Meaning |
46
- |---|---|
47
- | 0 | Success / gate passed |
48
- | 1 | **Gate violated** — fail the pipeline step |
49
- | 2 | Usage or configuration error |
50
- | 3 | API / network error |
51
-
52
- Unmapped tests and unknown case numbers are loud warnings, never non-zero exits.
53
-
54
- ## Mapping tests to cases
55
-
56
- Resolution per test (results are unioned):
57
-
58
- 1. **Tags**: any `@mt-<caseNumber>` in the test name or classname —
59
- `it("completes checkout @mt-2", ...)`.
60
- 2. **Method-name tokens** (v0.2.0+): for identifier-style names (no whitespace — i.e.
61
- what .NET JUnit loggers emit), an underscore-delimited `Mt<n>` token maps the test:
62
- `Mt5_DuplicateEmail_Rejected`, `Profiles_Mt12_NotFound`, `Delete_Resolves_MT28`.
63
- Embedded substrings (`GMT5_…`, `Format2_…`) do not match.
64
- 3. **Config patterns** in `.mt-testing.json`:
65
-
66
- ```jsonc
67
- {
68
- "suite": "checkout-regression",
69
- "defaultEnvironment": "dev",
70
- "mappings": {
71
- "4": ["checkout.spec.ts::pays with saved card"],
72
- "7": ["auth/*.spec.ts::*expired session*", "Login flow > redirects*"],
73
- "12": ["*newsletter*"]
74
- }
75
- }
76
- ```
77
-
78
- Pattern grammar: `*` is the only wildcard; matching is case-insensitive against the
79
- bare test name, or against the full `file::name` id when the pattern contains `::`.
80
-
81
- Aggregation per case is worst-status-wins: any failing mapped test → `fail`; otherwise
82
- any passing → `pass`; all skipped → the case is not posted (stays pending — a skipped
83
- test is a non-observation). Suite cases with no mapped tests stay pending and are
84
- listed as "uncovered" in the summary.
85
-
86
- Use the `map-tests` Claude Code skill (in the
87
- [Maersk-Training/mt-testing-tool](https://github.com/Maersk-Training/mt-testing-tool)
88
- repo under `skills/`) to generate the mapping semi-automatically, and `--dry-run` to
89
- validate it.
90
-
91
- ## .NET specifics (learned the hard way)
92
-
93
- - **Emit JUnit**: `dotnet add package JunitXml.TestLogger`, then
94
- `dotnet test --logger "junit;LogFilePath=test-results/junit.xml"`.
95
- - **The logger writes method names, not `DisplayName`s** — `@mt` tags in DisplayNames
96
- never reach the XML. Use `Mt<n>` method tokens (above) or `mappings` patterns, and
97
- keep method names unique across the project. Verify with `--dry-run`.
98
- - **Two-tier pattern**: `[Trait("Category","Unit")]` (mocked; build-pipeline gate) vs
99
- `[Trait("Category","Smoke")]` (HTTP against the deployed env; release pipeline,
100
- after deploy). Filter with `--filter "Category=Unit"` /
101
- `--TestCaseFilter:"Category=Smoke"`.
102
- - **Classic Release has no source checkout**: `dotnet publish` the test project into
103
- the build artifact (`drop/tests/`) and run it with the ".NET Core" task using
104
- Command `custom` + `vstest` — the `run` command does NOT execute arbitrary commands.
105
- - The full ADO runbook (variable groups, stage scoping, exact task configs) lives in
106
- `skills/onboard-repo/reference.md` in the
107
- [Maersk-Training/mt-testing-tool](https://github.com/Maersk-Training/mt-testing-tool)
108
- repo — or run the `onboard-repo` skill.
109
-
110
- ## Multi-job pipelines
111
-
112
- ```bash
113
- # job A — creates the run, leaves it open
114
- govgate report unit-results.xml --no-complete # emits MT_RUN_ID (GH output / ADO variable)
115
-
116
- # job B — appends to the same run
117
- govgate report e2e-results.xml --run-id $MT_RUN_ID --no-complete
118
-
119
- # final job — completes + gates over everything
120
- govgate complete --run-id $MT_RUN_ID --fail-on fail --min-executed 80
121
- ```
122
-
123
- See `docs/examples/github-actions-ci-report.yml` and
124
- `docs/examples/azure-devops-ci-report.yml` in the
125
- [Maersk-Training/mt-testing-tool](https://github.com/Maersk-Training/mt-testing-tool)
126
- repo for full pipelines.
127
-
128
- ## Registry
129
-
130
- Published **publicly on npmjs** as `govgate` — it contains no secrets or org-specific
131
- logic, so any pipeline can `npx govgate …` with **zero registry config and zero
132
- tokens**. (This deliberately replaced the earlier private GitHub Packages setup, which
133
- taxed every consuming repo with `.npmrc` + `read:packages` token wiring.)
134
-
135
- Releasing: push a `v<version>` tag (or run the *Publish* workflow manually). The
136
- workflow authenticates to npm via **Trusted Publishing (OIDC)** — there is **no
137
- `NPM_TOKEN` secret**; GitHub Actions mints a short-lived identity per run. One-time
138
- setup: configure this repo + `publish-cli.yml` as a trusted publisher on the npm
139
- package's *Settings → Trusted Publishing* page.
140
-
141
- **No registry at all** (local/dev): run straight from a checkout —
142
- `node <path-to>/govGate/dist/index.js report …` — or
143
- `npm exec --package=<path-to>.tgz -- govgate …` after `npm pack`.
1
+ # govgate
2
+
3
+ Pipeline-step CLI for the MT Testing Tool: parses standard test output (JUnit XML),
4
+ maps tests to test cases, reports a run, and **gates the release** via its exit code.
5
+ No SDK in application code — only the pipeline YAML changes.
6
+
7
+ ```
8
+ npx govgate report test-results/junit.xml --env uat
9
+ ```
10
+
11
+ Works identically in GitHub Actions and Azure DevOps. .NET is covered without a NuGet
12
+ package: `dotnet test --logger "junit"` (JunitXml.TestLogger) and point the CLI at the
13
+ output. Jest/Vitest/Playwright/pytest/NUnit/xUnit all emit JUnit XML natively or via a
14
+ standard reporter.
15
+
16
+ ## Commands
17
+
18
+ ### `govgate report <files-or-globs...>`
19
+
20
+ | Flag | Default | Meaning |
21
+ |---|---|---|
22
+ | `--url <url>` | `MT_TESTING_TOOL_URL` | Tool deployment URL |
23
+ | `--api-key <key>` | `MT_TESTING_TOOL_API_KEY` | API key (env var strongly preferred) |
24
+ | `--config <path>` | `.mt-testing.json` searched upward | Config file |
25
+ | `--suite <slug>` | config `suite` | Target suite |
26
+ | `--env <slug>` | config `defaultEnvironment` | Environment for the run |
27
+ | `--name <name>` | derived from CI context | Run name |
28
+ | `--build-version <v>` | short commit SHA from CI | Build version |
29
+ | `--external-url <url>` | CI build URL | "Build ↗" link on the run page |
30
+ | `--format junit` | `junit` | Input format (v1: junit only) |
31
+ | `--run-id <uuid>` | — | Append to an existing run (multi-job) |
32
+ | `--no-complete` | completes | Leave run in progress; skip the gate |
33
+ | `--fail-on <list>` | `fail` | Gate: `fail` or `fail,blocked` |
34
+ | `--min-executed <pct>` | — | Gate: minimum executed percentage |
35
+ | `--actor <label>` | `govgate` | Tested-by label |
36
+ | `--dry-run` | — | Parse + map + print; post nothing |
37
+
38
+ ### `govgate complete --run-id <uuid> [--fail-on ...] [--min-executed ...]`
39
+
40
+ Completes a run left open by `--no-complete` and evaluates the gate over **all** results
41
+ in the run (including those posted by other jobs).
42
+
43
+ ## Exit codes
44
+
45
+ | Code | Meaning |
46
+ |---|---|
47
+ | 0 | Success / gate passed |
48
+ | 1 | **Gate violated** — fail the pipeline step |
49
+ | 2 | Usage or configuration error |
50
+ | 3 | API / network error |
51
+
52
+ Unmapped tests and unknown case numbers are loud warnings, never non-zero exits.
53
+
54
+ ## Mapping tests to cases
55
+
56
+ Resolution per test (results are unioned):
57
+
58
+ 1. **Tags**: any `@mt-<caseNumber>` in the test name or classname —
59
+ `it("completes checkout @mt-2", ...)`.
60
+ 2. **Method-name tokens** (v0.2.0+): for identifier-style names (no whitespace — i.e.
61
+ what .NET JUnit loggers emit), an underscore-delimited `Mt<n>` token maps the test:
62
+ `Mt5_DuplicateEmail_Rejected`, `Profiles_Mt12_NotFound`, `Delete_Resolves_MT28`.
63
+ Embedded substrings (`GMT5_…`, `Format2_…`) do not match.
64
+ 3. **Config patterns** in `.mt-testing.json`:
65
+
66
+ ```jsonc
67
+ {
68
+ "suite": "checkout-regression",
69
+ "defaultEnvironment": "dev",
70
+ "mappings": {
71
+ "4": ["checkout.spec.ts::pays with saved card"],
72
+ "7": ["auth/*.spec.ts::*expired session*", "Login flow > redirects*"],
73
+ "12": ["*newsletter*"]
74
+ }
75
+ }
76
+ ```
77
+
78
+ Pattern grammar: `*` is the only wildcard; matching is case-insensitive against the
79
+ bare test name, or against the full `file::name` id when the pattern contains `::`.
80
+
81
+ Aggregation per case is worst-status-wins: any failing mapped test → `fail`; otherwise
82
+ any passing → `pass`; all skipped → the case is not posted (stays pending — a skipped
83
+ test is a non-observation). Suite cases with no mapped tests stay pending and are
84
+ listed as "uncovered" in the summary.
85
+
86
+ Use the `map-tests` Claude Code skill (in the
87
+ [Maersk-Training/mt-testing-tool](https://github.com/Maersk-Training/mt-testing-tool)
88
+ repo under `skills/`) to generate the mapping semi-automatically, and `--dry-run` to
89
+ validate it.
90
+
91
+ ## .NET specifics (learned the hard way)
92
+
93
+ - **Emit JUnit**: `dotnet add package JunitXml.TestLogger`, then
94
+ `dotnet test --logger "junit;LogFilePath=test-results/junit.xml"`.
95
+ - **The logger writes method names, not `DisplayName`s** — `@mt` tags in DisplayNames
96
+ never reach the XML. Use `Mt<n>` method tokens (above) or `mappings` patterns, and
97
+ keep method names unique across the project. Verify with `--dry-run`.
98
+ - **Two-tier pattern**: `[Trait("Category","Unit")]` (mocked; build-pipeline gate) vs
99
+ `[Trait("Category","Smoke")]` (HTTP against the deployed env; release pipeline,
100
+ after deploy). Filter with `--filter "Category=Unit"` /
101
+ `--TestCaseFilter:"Category=Smoke"`.
102
+ - **Classic Release has no source checkout**: `dotnet publish` the test project into
103
+ the build artifact (`drop/tests/`) and run it with the ".NET Core" task using
104
+ Command `custom` + `vstest` — the `run` command does NOT execute arbitrary commands.
105
+ - The full ADO runbook (variable groups, stage scoping, exact task configs) lives in
106
+ `skills/onboard-repo/reference.md` in the
107
+ [Maersk-Training/mt-testing-tool](https://github.com/Maersk-Training/mt-testing-tool)
108
+ repo — or run the `onboard-repo` skill.
109
+
110
+ ## Multi-job pipelines
111
+
112
+ ```bash
113
+ # job A — creates the run, leaves it open
114
+ govgate report unit-results.xml --no-complete # emits MT_RUN_ID (GH output / ADO variable)
115
+
116
+ # job B — appends to the same run
117
+ govgate report e2e-results.xml --run-id $MT_RUN_ID --no-complete
118
+
119
+ # final job — completes + gates over everything
120
+ govgate complete --run-id $MT_RUN_ID --fail-on fail --min-executed 80
121
+ ```
122
+
123
+ See `docs/examples/github-actions-ci-report.yml` and
124
+ `docs/examples/azure-devops-ci-report.yml` in the
125
+ [Maersk-Training/mt-testing-tool](https://github.com/Maersk-Training/mt-testing-tool)
126
+ repo for full pipelines.
127
+
128
+ ## Registry
129
+
130
+ Published **publicly on npmjs** as `govgate` — it contains no secrets or org-specific
131
+ logic, so any pipeline can `npx govgate …` with **zero registry config and zero
132
+ tokens**. (This deliberately replaced the earlier private GitHub Packages setup, which
133
+ taxed every consuming repo with `.npmrc` + `read:packages` token wiring.)
134
+
135
+ Releasing: push a `v<version>` tag (or run the *Publish* workflow manually). The
136
+ workflow authenticates to npm via **Trusted Publishing (OIDC)** — there is **no
137
+ `NPM_TOKEN` secret**; GitHub Actions mints a short-lived identity per run. One-time
138
+ setup: configure this repo + `publish-cli.yml` as a trusted publisher on the npm
139
+ package's *Settings → Trusted Publishing* page.
140
+
141
+ **No registry at all** (local/dev): run straight from a checkout —
142
+ `node <path-to>/govGate/dist/index.js report …` — or
143
+ `npm exec --package=<path-to>.tgz -- govgate …` after `npm pack`.
package/dist/index.js ADDED
@@ -0,0 +1,572 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { readFileSync as readFileSync2 } from "fs";
5
+ import { Command } from "commander";
6
+ import { glob } from "tinyglobby";
7
+
8
+ // src/api.ts
9
+ var ApiError = class extends Error {
10
+ constructor(message, status) {
11
+ super(message);
12
+ this.status = status;
13
+ }
14
+ status;
15
+ };
16
+ var BATCH_SIZE = 500;
17
+ var ApiClient = class {
18
+ constructor(baseUrl, apiKey) {
19
+ this.baseUrl = baseUrl;
20
+ this.apiKey = apiKey;
21
+ }
22
+ baseUrl;
23
+ apiKey;
24
+ async request(method, path, body) {
25
+ let res;
26
+ try {
27
+ res = await fetch(`${this.baseUrl}${path}`, {
28
+ method,
29
+ headers: {
30
+ Authorization: `Bearer ${this.apiKey}`,
31
+ ...body !== void 0 ? { "Content-Type": "application/json" } : {}
32
+ },
33
+ body: body !== void 0 ? JSON.stringify(body) : void 0
34
+ });
35
+ } catch (e) {
36
+ throw new ApiError(`Could not reach ${this.baseUrl}: ${e.message}`, 0);
37
+ }
38
+ const text2 = await res.text();
39
+ if (!res.ok) {
40
+ let detail = text2;
41
+ try {
42
+ detail = JSON.parse(text2).error ?? text2;
43
+ } catch {
44
+ }
45
+ throw new ApiError(`${method} ${path} -> ${res.status}: ${detail}`, res.status);
46
+ }
47
+ return JSON.parse(text2);
48
+ }
49
+ createRun(input) {
50
+ return this.request("POST", "/api/v1/runs", { ...input, source: "ci" });
51
+ }
52
+ async postResults(runId, results) {
53
+ let updated = 0;
54
+ const unknownCaseNumbers = [];
55
+ for (let i = 0; i < results.length; i += BATCH_SIZE) {
56
+ const batch = results.slice(i, i + BATCH_SIZE);
57
+ const res = await this.request(
58
+ "POST",
59
+ `/api/v1/runs/${runId}/results`,
60
+ { results: batch }
61
+ );
62
+ updated += res.updated;
63
+ unknownCaseNumbers.push(...res.unknownCaseNumbers);
64
+ }
65
+ return { updated, unknownCaseNumbers };
66
+ }
67
+ completeRun(runId) {
68
+ return this.request("PATCH", `/api/v1/runs/${runId}`, { status: "completed" });
69
+ }
70
+ getSummary(runId) {
71
+ return this.request("GET", `/api/v1/runs/${runId}/summary`);
72
+ }
73
+ getSuiteCases(slug) {
74
+ return this.request("GET", `/api/v1/suites/${slug}/cases`);
75
+ }
76
+ };
77
+
78
+ // src/ci-context.ts
79
+ import { appendFileSync } from "fs";
80
+ import { hostname } from "os";
81
+ function detectCiContext(env = process.env) {
82
+ if (env.GITHUB_ACTIONS === "true") {
83
+ const repo = env.GITHUB_REPOSITORY;
84
+ return {
85
+ provider: "github",
86
+ defaultRunName: `CI \u2014 ${env.GITHUB_WORKFLOW ?? "workflow"} #${env.GITHUB_RUN_NUMBER ?? "?"} \u2014 ${env.GITHUB_REF_NAME ?? "?"}`,
87
+ buildVersion: env.GITHUB_SHA?.slice(0, 8),
88
+ externalUrl: repo && env.GITHUB_RUN_ID ? `${env.GITHUB_SERVER_URL ?? "https://github.com"}/${repo}/actions/runs/${env.GITHUB_RUN_ID}` : void 0
89
+ };
90
+ }
91
+ if (env.TF_BUILD === "True") {
92
+ const collection = env.SYSTEM_COLLECTIONURI;
93
+ const project = env.SYSTEM_TEAMPROJECT;
94
+ return {
95
+ provider: "azure-devops",
96
+ defaultRunName: `CI \u2014 ${env.BUILD_DEFINITIONNAME ?? "pipeline"} #${env.BUILD_BUILDNUMBER ?? "?"} \u2014 ${env.BUILD_SOURCEBRANCHNAME ?? "?"}`,
97
+ buildVersion: env.BUILD_SOURCEVERSION?.slice(0, 8),
98
+ externalUrl: collection && project && env.BUILD_BUILDID ? `${collection}${encodeURIComponent(project)}/_build/results?buildId=${env.BUILD_BUILDID}` : void 0
99
+ };
100
+ }
101
+ return {
102
+ provider: "generic",
103
+ defaultRunName: `CI \u2014 ${hostname()} \u2014 ${(/* @__PURE__ */ new Date()).toISOString()}`
104
+ };
105
+ }
106
+ function emitRunIdVariable(runId, ctx, env = process.env) {
107
+ console.log(`MT_RUN_ID=${runId}`);
108
+ if (ctx.provider === "github" && env.GITHUB_OUTPUT) {
109
+ appendFileSync(env.GITHUB_OUTPUT, `run-id=${runId}
110
+ `);
111
+ } else if (ctx.provider === "azure-devops") {
112
+ console.log(`##vso[task.setvariable variable=MT_RUN_ID;isOutput=true]${runId}`);
113
+ }
114
+ }
115
+
116
+ // src/config.ts
117
+ import { readFileSync, existsSync } from "fs";
118
+ import { dirname, join, resolve } from "path";
119
+ var ConfigError = class extends Error {
120
+ };
121
+ function findConfigFile(startDir, explicitPath) {
122
+ if (explicitPath) {
123
+ const p = resolve(startDir, explicitPath);
124
+ if (!existsSync(p)) throw new ConfigError(`Config file not found: ${p}`);
125
+ return p;
126
+ }
127
+ let dir = resolve(startDir);
128
+ for (; ; ) {
129
+ const candidate = join(dir, ".mt-testing.json");
130
+ if (existsSync(candidate)) return candidate;
131
+ const parent = dirname(dir);
132
+ if (parent === dir) return void 0;
133
+ dir = parent;
134
+ }
135
+ }
136
+ function loadFileConfig(path) {
137
+ let data;
138
+ try {
139
+ data = JSON.parse(readFileSync(path, "utf8"));
140
+ } catch (e) {
141
+ throw new ConfigError(`Could not parse ${path}: ${e.message}`);
142
+ }
143
+ if (typeof data !== "object" || data === null) {
144
+ throw new ConfigError(`${path} must contain a JSON object`);
145
+ }
146
+ const cfg = data;
147
+ const out = {};
148
+ if (cfg.suite !== void 0) {
149
+ if (typeof cfg.suite !== "string") throw new ConfigError(`${path}: "suite" must be a string`);
150
+ out.suite = cfg.suite;
151
+ }
152
+ if (cfg.defaultEnvironment !== void 0) {
153
+ if (typeof cfg.defaultEnvironment !== "string")
154
+ throw new ConfigError(`${path}: "defaultEnvironment" must be a string`);
155
+ out.defaultEnvironment = cfg.defaultEnvironment;
156
+ }
157
+ if (cfg.mappings !== void 0) {
158
+ if (typeof cfg.mappings !== "object" || cfg.mappings === null)
159
+ throw new ConfigError(`${path}: "mappings" must be an object`);
160
+ const mappings = {};
161
+ for (const [key, value] of Object.entries(cfg.mappings)) {
162
+ if (!/^\d+$/.test(key))
163
+ throw new ConfigError(`${path}: mapping key "${key}" must be a case number`);
164
+ if (!Array.isArray(value) || value.some((v) => typeof v !== "string"))
165
+ throw new ConfigError(`${path}: mappings["${key}"] must be an array of pattern strings`);
166
+ mappings[key] = value;
167
+ }
168
+ out.mappings = mappings;
169
+ }
170
+ return out;
171
+ }
172
+ function resolveConfig(flags, cwd = process.cwd()) {
173
+ const configPath = findConfigFile(cwd, flags.config);
174
+ const file = configPath ? loadFileConfig(configPath) : {};
175
+ const url = flags.url ?? process.env.MT_TESTING_TOOL_URL;
176
+ const apiKey = flags.apiKey ?? process.env.MT_TESTING_TOOL_API_KEY;
177
+ const suite = flags.suite ?? file.suite;
178
+ const environment = flags.env ?? file.defaultEnvironment;
179
+ if (!url) {
180
+ throw new ConfigError(
181
+ "Tool URL missing. Set MT_TESTING_TOOL_URL or pass --url https://<your-deployment>"
182
+ );
183
+ }
184
+ if (!apiKey) {
185
+ throw new ConfigError(
186
+ "API key missing. Set MT_TESTING_TOOL_API_KEY (create one in the tool: Admin -> API keys, scoped to this application)."
187
+ );
188
+ }
189
+ if (!suite) {
190
+ throw new ConfigError(
191
+ 'Suite missing. Pass --suite <slug> or add { "suite": "<slug>" } to .mt-testing.json in the repo root.'
192
+ );
193
+ }
194
+ return {
195
+ url: url.replace(/\/+$/, ""),
196
+ apiKey,
197
+ suite,
198
+ environment,
199
+ mappings: file.mappings ?? {},
200
+ configPath
201
+ };
202
+ }
203
+
204
+ // src/gate.ts
205
+ function parseFailOn(value) {
206
+ const parts = value.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
207
+ const valid = ["fail", "blocked"];
208
+ for (const p of parts) {
209
+ if (!valid.includes(p)) throw new Error(`Invalid --fail-on value '${p}' (allowed: fail, blocked)`);
210
+ }
211
+ return parts;
212
+ }
213
+ function executedPercent(counts) {
214
+ if (counts.total === 0) return 0;
215
+ return Math.round((counts.total - counts.pending) / counts.total * 1e3) / 10;
216
+ }
217
+ function evaluateGate(counts, options) {
218
+ const reasons = [];
219
+ for (const status of options.failOn) {
220
+ if (counts[status] > 0) {
221
+ reasons.push(`${counts[status]} case(s) with status '${status}'`);
222
+ }
223
+ }
224
+ const pct = executedPercent(counts);
225
+ if (options.minExecuted !== void 0 && pct < options.minExecuted) {
226
+ reasons.push(`executed ${pct}% < required ${options.minExecuted}%`);
227
+ }
228
+ return { passed: reasons.length === 0, reasons, executedPercent: pct };
229
+ }
230
+
231
+ // src/mapping.ts
232
+ var TAG_RE = /@mt-(\d+)\b/g;
233
+ var METHOD_TAG_RE = /(?:^|_)mt[-_]?(\d+)(?=_|$)/gi;
234
+ function patternToRegex(pattern) {
235
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
236
+ return new RegExp(`^${escaped}$`, "i");
237
+ }
238
+ function tagCases(test) {
239
+ const haystack = `${test.name} ${test.classname ?? ""}`;
240
+ const cases = [...haystack.matchAll(TAG_RE)].map((m) => Number(m[1]));
241
+ if (!/\s/.test(test.name)) {
242
+ cases.push(...[...test.name.matchAll(METHOD_TAG_RE)].map((m) => Number(m[1])));
243
+ }
244
+ return cases;
245
+ }
246
+ function resolveCases(test, mappings) {
247
+ const cases = new Set(tagCases(test));
248
+ for (const [num, patterns] of Object.entries(mappings)) {
249
+ for (const pattern of patterns) {
250
+ const regex = patternToRegex(pattern);
251
+ const candidates = pattern.includes("::") ? [test.id] : [test.name, test.id];
252
+ if (candidates.some((c) => regex.test(c))) {
253
+ cases.add(Number(num));
254
+ break;
255
+ }
256
+ }
257
+ }
258
+ return [...cases];
259
+ }
260
+ var NOTE_CAP = 4e3;
261
+ var MESSAGE_CAP = 500;
262
+ function truncate(s, cap) {
263
+ return s.length <= cap ? s : s.slice(0, cap - 12) + "\u2026 [truncated]";
264
+ }
265
+ function buildNotes(tests, status) {
266
+ if (status === "pass") {
267
+ const ids = tests.filter((t) => t.status === "pass").map((t) => t.id);
268
+ const shown = ids.slice(0, 10);
269
+ const more = ids.length - shown.length;
270
+ return truncate(
271
+ `${ids.length} automated test(s) passed:
272
+ ` + shown.map((id) => `- ${id}`).join("\n") + (more > 0 ? `
273
+ \u2026 and ${more} more` : ""),
274
+ NOTE_CAP
275
+ );
276
+ }
277
+ const failing = tests.filter((t) => t.status === "fail");
278
+ return truncate(
279
+ `${failing.length} automated test(s) failed:
280
+ ` + failing.map((t) => `- ${t.id}: ${truncate(t.message ?? "no message", MESSAGE_CAP)}`).join("\n"),
281
+ NOTE_CAP
282
+ );
283
+ }
284
+ function mapTestsToCases(tests, mappings) {
285
+ const byCase = /* @__PURE__ */ new Map();
286
+ const unmappedTests = [];
287
+ for (const test of tests) {
288
+ const cases = resolveCases(test, mappings);
289
+ if (cases.length === 0) {
290
+ unmappedTests.push(test);
291
+ continue;
292
+ }
293
+ for (const num of cases) {
294
+ const list = byCase.get(num) ?? [];
295
+ list.push(test);
296
+ byCase.set(num, list);
297
+ }
298
+ }
299
+ const results = [];
300
+ const skippedOnlyCases = [];
301
+ for (const [caseNumber, caseTests] of [...byCase.entries()].sort((a, b) => a[0] - b[0])) {
302
+ const anyFail = caseTests.some((t) => t.status === "fail");
303
+ const anyPass = caseTests.some((t) => t.status === "pass");
304
+ if (!anyFail && !anyPass) {
305
+ skippedOnlyCases.push(caseNumber);
306
+ continue;
307
+ }
308
+ const status = anyFail ? "fail" : "pass";
309
+ results.push({ caseNumber, status, notes: buildNotes(caseTests, status), tests: caseTests });
310
+ }
311
+ return { results, unmappedTests, skippedOnlyCases };
312
+ }
313
+
314
+ // src/parsers/junit.ts
315
+ import { XMLParser } from "fast-xml-parser";
316
+ function asArray(value) {
317
+ if (value === void 0) return [];
318
+ return Array.isArray(value) ? value : [value];
319
+ }
320
+ function text(node) {
321
+ if (node == null) return "";
322
+ if (typeof node === "string" || typeof node === "number") return String(node);
323
+ const obj = node;
324
+ const parts = [obj["@_message"], obj["@_type"], obj["#text"]].filter((v) => v != null && v !== "").map(String);
325
+ return parts.join(" \u2014 ");
326
+ }
327
+ function parseTestcase(tc) {
328
+ const name = String(tc["@_name"] ?? "(unnamed test)");
329
+ const classname = tc["@_classname"] ? String(tc["@_classname"]) : void 0;
330
+ const file = tc["@_file"] ? String(tc["@_file"]) : void 0;
331
+ let status = "pass";
332
+ let message;
333
+ const failure = asArray(tc.failure)[0];
334
+ const error = asArray(tc.error)[0];
335
+ if (failure !== void 0 || error !== void 0) {
336
+ status = "fail";
337
+ message = text(failure ?? error).trim() || "Test failed (no message in report)";
338
+ } else if (tc.skipped !== void 0) {
339
+ status = "skipped";
340
+ }
341
+ const scope = file ?? classname;
342
+ return {
343
+ id: scope ? `${scope}::${name}` : name,
344
+ name,
345
+ classname,
346
+ file,
347
+ status,
348
+ message,
349
+ timeSec: tc["@_time"] !== void 0 ? Number(tc["@_time"]) : void 0
350
+ };
351
+ }
352
+ function collectSuites(node, out) {
353
+ for (const suite of asArray(node.testsuite)) {
354
+ out.push(suite);
355
+ collectSuites(suite, out);
356
+ }
357
+ }
358
+ function parseJUnitXml(xml) {
359
+ const parser = new XMLParser({
360
+ ignoreAttributes: false,
361
+ parseAttributeValue: false,
362
+ parseTagValue: false
363
+ });
364
+ const doc = parser.parse(xml);
365
+ const suites = [];
366
+ const root = doc.testsuites ?? doc;
367
+ if (doc.testsuites !== void 0) collectSuites(root, suites);
368
+ else if (doc.testsuite !== void 0) collectSuites(doc, suites);
369
+ const tests = [];
370
+ for (const suite of suites) {
371
+ for (const tc of asArray(suite.testcase)) {
372
+ tests.push(parseTestcase(tc));
373
+ }
374
+ }
375
+ return tests;
376
+ }
377
+
378
+ // src/summary.ts
379
+ function printMappingTable(outcome) {
380
+ console.log("\nMapped cases:");
381
+ if (outcome.results.length === 0) {
382
+ console.log(" (none \u2014 no tests matched a case tag or mapping pattern)");
383
+ }
384
+ for (const r of outcome.results) {
385
+ console.log(` #${r.caseNumber} -> ${r.status.toUpperCase()} (${r.tests.length} test(s))`);
386
+ for (const t of r.tests) console.log(` ${t.status.padEnd(7)} ${t.id}`);
387
+ }
388
+ if (outcome.skippedOnlyCases.length) {
389
+ console.log(
390
+ ` Cases with only skipped tests (not reported): ${outcome.skippedOnlyCases.map((n) => `#${n}`).join(", ")}`
391
+ );
392
+ }
393
+ if (outcome.unmappedTests.length) {
394
+ console.log(`
395
+ Unmapped tests (${outcome.unmappedTests.length}) \u2014 add @mt-<case> tags or .mt-testing.json mappings:`);
396
+ for (const t of outcome.unmappedTests.slice(0, 20)) console.log(` - ${t.id}`);
397
+ if (outcome.unmappedTests.length > 20)
398
+ console.log(` \u2026 and ${outcome.unmappedTests.length - 20} more`);
399
+ }
400
+ }
401
+ function printCounts(counts, executedPercent2) {
402
+ console.log(
403
+ `
404
+ Run totals: ${counts.total} cases \u2014 pass ${counts.pass}, fail ${counts.fail}, blocked ${counts.blocked}, na ${counts.na}, pending ${counts.pending} (${executedPercent2}% executed)`
405
+ );
406
+ }
407
+ function printUnknownCases(unknown) {
408
+ if (!unknown.length) return;
409
+ console.error(
410
+ `
411
+ !! WARNING: ${unknown.length} reported case number(s) do not exist in the suite: ${unknown.map((n) => `#${n}`).join(", ")}
412
+ !! Their results were DROPPED. Fix the @mt tags / mappings or re-import the suite.`
413
+ );
414
+ }
415
+ function printGate(verdict) {
416
+ if (verdict.passed) {
417
+ console.log("\nGate: PASSED");
418
+ } else {
419
+ console.error(`
420
+ Gate: FAILED
421
+ ${verdict.reasons.map((r) => ` - ${r}`).join("\n")}`);
422
+ }
423
+ }
424
+ function printRunUrl(baseUrl, runId) {
425
+ console.log(`
426
+ Run: ${baseUrl}/runs/${runId}`);
427
+ }
428
+
429
+ // src/index.ts
430
+ var EXIT_GATE_FAILED = 1;
431
+ var EXIT_USAGE = 2;
432
+ var EXIT_API = 3;
433
+ function fail(code, message) {
434
+ console.error(`Error: ${message}`);
435
+ process.exit(code);
436
+ }
437
+ function handleError(e) {
438
+ if (e instanceof ConfigError) fail(EXIT_USAGE, e.message);
439
+ if (e instanceof ApiError) fail(EXIT_API, e.message);
440
+ fail(EXIT_API, e instanceof Error ? e.message : String(e));
441
+ }
442
+ function gateOptionsFrom(opts) {
443
+ const gate = { failOn: parseFailOn(opts.failOn) };
444
+ if (opts.minExecuted !== void 0) {
445
+ const pct = Number(opts.minExecuted);
446
+ if (!Number.isFinite(pct) || pct < 0 || pct > 100) {
447
+ throw new ConfigError("--min-executed must be a number between 0 and 100");
448
+ }
449
+ gate.minExecuted = pct;
450
+ }
451
+ return gate;
452
+ }
453
+ async function parseFiles(patterns, format) {
454
+ if (format !== "junit") {
455
+ throw new ConfigError(`Unsupported --format '${format}' (v1 supports: junit)`);
456
+ }
457
+ const files = await glob(patterns, { absolute: true });
458
+ if (files.length === 0) {
459
+ throw new ConfigError(`No files matched: ${patterns.join(", ")}`);
460
+ }
461
+ const tests = [];
462
+ for (const file of files) {
463
+ tests.push(...parseJUnitXml(readFileSync2(file, "utf8")));
464
+ }
465
+ if (tests.length === 0) {
466
+ throw new ConfigError(`Parsed ${files.length} file(s) but found no test cases`);
467
+ }
468
+ return tests;
469
+ }
470
+ async function completeAndGate(api, url, runId, gate) {
471
+ await api.completeRun(runId);
472
+ const summary = await api.getSummary(runId);
473
+ printCounts(summary.counts, summary.executedPercent);
474
+ const verdict = evaluateGate(summary.counts, gate);
475
+ printGate(verdict);
476
+ printRunUrl(url, runId);
477
+ if (!verdict.passed) process.exit(EXIT_GATE_FAILED);
478
+ }
479
+ var program = new Command().name("govgate").description("Report CI test results to the MT Testing Tool and gate releases on the outcome.");
480
+ program.command("report").description("Parse test result files (JUnit XML), map tests to cases, and report a run").argument("<files...>", "result files or globs, e.g. test-results/*.xml").option("--url <url>", "tool URL (or MT_TESTING_TOOL_URL)").option("--api-key <key>", "API key (prefer MT_TESTING_TOOL_API_KEY)").option("--config <path>", "path to .mt-testing.json (default: searched upward)").option("--suite <slug>", "suite slug (overrides config)").option("--env <slug>", "environment slug (overrides config)").option("--name <name>", "run name (default: derived from CI context)").option("--build-version <v>", "build version (default: short commit SHA from CI)").option("--external-url <url>", "link to the build/PR (default: from CI context)").option("--format <format>", "input format", "junit").option("--run-id <uuid>", "append results to an existing run instead of creating one").option("--no-complete", "leave the run in progress (multi-job pipelines); skips the gate").option("--fail-on <list>", "statuses that fail the gate: fail | fail,blocked", "fail").option("--min-executed <pct>", "minimum executed percentage required by the gate").option("--actor <label>", "tested-by label on results", "govgate").option("--dry-run", "parse and map only; post nothing").action(async (files, opts) => {
481
+ try {
482
+ const flags = {
483
+ url: opts.url,
484
+ apiKey: opts.apiKey,
485
+ suite: opts.suite,
486
+ env: opts.env,
487
+ config: opts.config
488
+ };
489
+ const tests = await parseFiles(files, opts.format);
490
+ const config = opts.dryRun ? tryResolveConfig(flags) : resolveConfig(flags);
491
+ const mappings = config?.mappings ?? {};
492
+ const outcome = mapTestsToCases(tests, mappings);
493
+ console.log(
494
+ `Parsed ${tests.length} test(s) from ${files.join(", ")} \u2014 ${outcome.results.length} case(s) mapped, ${outcome.unmappedTests.length} unmapped test(s)`
495
+ );
496
+ printMappingTable(outcome);
497
+ if (opts.dryRun) {
498
+ if (config) {
499
+ const api2 = new ApiClient(config.url, config.apiKey);
500
+ const suite = await api2.getSuiteCases(config.suite);
501
+ const known = new Set(suite.cases.map((c) => c.caseNumber));
502
+ const unknown = outcome.results.map((r) => r.caseNumber).filter((n) => !known.has(n));
503
+ printUnknownCases(unknown);
504
+ } else {
505
+ console.log("\n(dry run without credentials \u2014 case numbers not validated against the suite)");
506
+ }
507
+ console.log("\nDry run \u2014 nothing was posted.");
508
+ return;
509
+ }
510
+ const gate = gateOptionsFrom(opts);
511
+ const api = new ApiClient(config.url, config.apiKey);
512
+ const ci = detectCiContext();
513
+ let runId = opts.runId ?? "";
514
+ if (!runId) {
515
+ const run = await api.createRun({
516
+ suiteSlug: config.suite,
517
+ environmentSlug: config.environment,
518
+ name: opts.name ?? ci.defaultRunName,
519
+ buildVersion: opts.buildVersion ?? ci.buildVersion,
520
+ externalUrl: opts.externalUrl ?? ci.externalUrl
521
+ });
522
+ runId = run.runId;
523
+ console.log(
524
+ `
525
+ Created run '${opts.name ?? ci.defaultRunName}' (${run.totalCases} cases, ${run.autoNa} auto-N/A)`
526
+ );
527
+ emitRunIdVariable(runId, ci);
528
+ }
529
+ const { updated, unknownCaseNumbers } = await api.postResults(
530
+ runId,
531
+ outcome.results.map((r) => ({
532
+ caseNumber: r.caseNumber,
533
+ status: r.status,
534
+ notes: r.notes,
535
+ actor: opts.actor
536
+ }))
537
+ );
538
+ console.log(`Posted ${updated} result(s).`);
539
+ printUnknownCases(unknownCaseNumbers);
540
+ if (opts.complete) {
541
+ await completeAndGate(api, config.url, runId, gate);
542
+ } else {
543
+ console.log("\nRun left in progress (--no-complete). Finish with: govgate complete --run-id " + runId);
544
+ printRunUrl(config.url, runId);
545
+ }
546
+ } catch (e) {
547
+ handleError(e);
548
+ }
549
+ });
550
+ function tryResolveConfig(flags) {
551
+ try {
552
+ return resolveConfig(flags);
553
+ } catch {
554
+ return void 0;
555
+ }
556
+ }
557
+ program.command("complete").description("Complete a run and evaluate the gate (final job of a multi-job pipeline)").requiredOption("--run-id <uuid>", "run to complete").option("--url <url>", "tool URL (or MT_TESTING_TOOL_URL)").option("--api-key <key>", "API key (prefer MT_TESTING_TOOL_API_KEY)").option("--config <path>", "path to .mt-testing.json").option("--suite <slug>", "suite slug (only used for config resolution)").option("--fail-on <list>", "statuses that fail the gate: fail | fail,blocked", "fail").option("--min-executed <pct>", "minimum executed percentage required by the gate").action(async (opts) => {
558
+ try {
559
+ const config = resolveConfig({
560
+ url: opts.url,
561
+ apiKey: opts.apiKey,
562
+ suite: opts.suite ?? "unused",
563
+ config: opts.config
564
+ });
565
+ const gate = gateOptionsFrom(opts);
566
+ const api = new ApiClient(config.url, config.apiKey);
567
+ await completeAndGate(api, config.url, opts.runId, gate);
568
+ } catch (e) {
569
+ handleError(e);
570
+ }
571
+ });
572
+ program.parseAsync().catch(handleError);
package/package.json CHANGED
@@ -1,41 +1,42 @@
1
- {
2
- "name": "govgate",
3
- "version": "0.2.0",
4
- "description": "Governance gate for releases: report CI test results (JUnit XML) to your testing tool and gate promotion on the outcome.",
5
- "license": "MIT",
6
- "author": "HatStack",
7
- "repository": {
8
- "type": "git",
9
- "url": "https://github.com/Sejersen92/govGate.git"
10
- },
11
- "homepage": "https://github.com/Sejersen92/govGate#readme",
12
- "bugs": {
13
- "url": "https://github.com/Sejersen92/govGate/issues"
14
- },
15
- "type": "module",
16
- "bin": {
17
- "govgate": "./dist/index.js"
18
- },
19
- "files": [
20
- "dist"
21
- ],
22
- "engines": {
23
- "node": ">=20"
24
- },
25
- "scripts": {
26
- "build": "tsup",
27
- "test": "vitest run",
28
- "typecheck": "tsc --noEmit"
29
- },
30
- "dependencies": {
31
- "commander": "^14.0.2",
32
- "fast-xml-parser": "^5.3.4",
33
- "tinyglobby": "^0.2.15"
34
- },
35
- "devDependencies": {
36
- "@types/node": "^20.19.43",
37
- "tsup": "^8.5.1",
38
- "typescript": "^5.9.3",
39
- "vitest": "^3.2.4"
40
- }
41
- }
1
+ {
2
+ "name": "govgate",
3
+ "version": "0.2.1",
4
+ "description": "Governance gate for releases: report CI test results (JUnit XML) to your testing tool and gate promotion on the outcome.",
5
+ "license": "MIT",
6
+ "author": "HatStack",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/Sejersen92/govGate.git"
10
+ },
11
+ "homepage": "https://github.com/Sejersen92/govGate#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/Sejersen92/govGate/issues"
14
+ },
15
+ "type": "module",
16
+ "bin": {
17
+ "govgate": "./dist/index.js"
18
+ },
19
+ "files": [
20
+ "dist"
21
+ ],
22
+ "engines": {
23
+ "node": ">=20"
24
+ },
25
+ "scripts": {
26
+ "build": "tsup",
27
+ "test": "vitest run",
28
+ "typecheck": "tsc --noEmit",
29
+ "prepack": "npm run build"
30
+ },
31
+ "dependencies": {
32
+ "commander": "^14.0.2",
33
+ "fast-xml-parser": "^5.3.4",
34
+ "tinyglobby": "^0.2.15"
35
+ },
36
+ "devDependencies": {
37
+ "@types/node": "^20.19.43",
38
+ "tsup": "^8.5.1",
39
+ "typescript": "^5.9.3",
40
+ "vitest": "^3.2.4"
41
+ }
42
+ }