Skip to content

Commit 405f103

Browse files
Copilotneilime
andcommitted
Add path-mapping feature for report path rewriting
Co-authored-by: neilime <314088+neilime@users.noreply.github.com>
1 parent 9286d1b commit 405f103

6 files changed

Lines changed: 267 additions & 3 deletions

File tree

actions/parse-ci-reports/README.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,13 @@ It supports multiple common report standards out of the box.
8383
#
8484
# Default: `false`
8585
fail-on-error: "false"
86+
87+
# Path mapping to rewrite file paths in reports (format: "from_path:to_path").
88+
# Useful when tests/lints run in a different directory or container.
89+
# Example: "/app/src:./src" or "/var/lib/docker/.../app:."
90+
#
91+
# Default: ``
92+
path-mapping: ""
8693
```
8794
8895
<!-- usage:end -->
@@ -99,6 +106,9 @@ It supports multiple common report standards out of the box.
99106
| **`include-passed`** | Whether to include passed tests in the summary. | **false** | `false` |
100107
| **`output-format`** | Output format: comma-separated list of `summary`, `markdown`, `annotations`, or `all` for everything. | **false** | `all` |
101108
| **`fail-on-error`** | Whether to fail the action if any test failures are detected. | **false** | `false` |
109+
| **`path-mapping`** | Path mapping to rewrite file paths in reports (format: `from_path:to_path`). | **false** | `""` |
110+
| | Useful when tests/lints run in a different directory or container. | | |
111+
| | Example: `/app/src:./src` or `/var/lib/docker/.../app:.` | | |
102112

103113
<!-- inputs:end -->
104114
<!-- secrets:start -->
@@ -249,6 +259,37 @@ Parse test results, coverage, and linting in one action:
249259
report-name: "CI Results"
250260
```
251261

262+
### Path Rewriting for Containers
263+
264+
When running tests in a container or different directory, use path-mapping to ensure file paths match your repository structure:
265+
266+
```yaml
267+
- name: Run tests in container
268+
run: |
269+
docker run --rm -v ${{ github.workspace }}:/app myimage npm test
270+
271+
- name: Parse test reports
272+
uses: hoverkraft-tech/ci-github-common/actions/parse-ci-reports@e6405b7d4daa7292edb246103f42b333a96d0a9f # copilot/add-report-parser-action
273+
with:
274+
report-paths: "test-results/junit.xml"
275+
report-name: "Test Results"
276+
path-mapping: "/app:."
277+
output-format: "annotations"
278+
```
279+
280+
This ensures GitHub annotations point to the correct files in your repository, even when tests run in `/app` inside the container.
281+
282+
Another example for complex Docker overlay paths:
283+
284+
```yaml
285+
- name: Parse reports with path rewriting
286+
uses: hoverkraft-tech/ci-github-common/actions/parse-ci-reports@e6405b7d4daa7292edb246103f42b333a96d0a9f # copilot/add-report-parser-action
287+
with:
288+
report-paths: "auto:all"
289+
report-name: "CI Results"
290+
path-mapping: "/var/lib/docker/overlay2/abc123/merged/workspace:./src"
291+
```
292+
252293
### Conditional PR Comment
253294

254295
Only comment on PRs if there are failures:

actions/parse-ci-reports/action.yml

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,13 @@ inputs:
3737
description: |
3838
Whether to fail the action if any test failures are detected.
3939
default: "false"
40+
path-mapping:
41+
description: |
42+
Path mapping to rewrite file paths in reports (format: "from_path:to_path").
43+
Useful when tests/lints run in a different directory or container.
44+
Example: "/app/src:./src" or "/var/lib/docker/.../app:."
45+
required: false
46+
default: ""
4047

4148
outputs:
4249
markdown:
@@ -69,6 +76,7 @@ runs:
6976
REPORT_NAME: ${{ inputs.report-name }}
7077
INCLUDE_PASSED: ${{ inputs.include-passed }}
7178
OUTPUT_FORMAT: ${{ inputs.output-format }}
79+
PATH_MAPPING: ${{ inputs.path-mapping }}
7280
ACTION_PATH: ${{ github.action_path }}
7381
with:
7482
script: |
@@ -79,7 +87,8 @@ runs:
7987
reportPaths: process.env.REPORT_PATHS,
8088
reportName: process.env.REPORT_NAME,
8189
includePassed: process.env.INCLUDE_PASSED === 'true',
82-
outputFormat: process.env.OUTPUT_FORMAT
90+
outputFormat: process.env.OUTPUT_FORMAT,
91+
pathMapping: process.env.PATH_MAPPING || null
8392
};
8493
8594
await run({ core, glob, inputs });
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/**
2+
* PathRewriter - Utility to rewrite file paths in reports
3+
*
4+
* This is useful when tests/lints run in a different directory or container,
5+
* but the paths in reports need to match the actual repository structure
6+
* for GitHub annotations and file references to work correctly.
7+
*/
8+
export class PathRewriter {
9+
/**
10+
* Create a path rewriter
11+
* @param {string|null} pathMapping - Path mapping in format "from:to" or null to disable
12+
*/
13+
constructor(pathMapping = null) {
14+
this.fromPath = null;
15+
this.toPath = null;
16+
17+
if (pathMapping && pathMapping.trim().length > 0) {
18+
this._parsePathMapping(pathMapping);
19+
}
20+
}
21+
22+
/**
23+
* Parse path mapping string
24+
* @param {string} pathMapping - Path mapping in format "from:to"
25+
* @private
26+
*/
27+
_parsePathMapping(pathMapping) {
28+
const parts = pathMapping.split(":");
29+
if (parts.length !== 2) {
30+
throw new Error(
31+
`Invalid path-mapping format: "${pathMapping}". Expected format: "from_path:to_path"`,
32+
);
33+
}
34+
35+
this.fromPath = parts[0].trim();
36+
this.toPath = parts[1].trim();
37+
38+
if (this.fromPath.length === 0 || this.toPath.length === 0) {
39+
throw new Error(
40+
`Invalid path-mapping format: "${pathMapping}". Paths cannot be empty.`,
41+
);
42+
}
43+
44+
// Normalize paths - remove trailing slashes for consistency
45+
this.fromPath = this.fromPath.replace(/\/+$/, "");
46+
this.toPath = this.toPath.replace(/\/+$/, "");
47+
}
48+
49+
/**
50+
* Check if path rewriting is enabled
51+
* @returns {boolean} True if path mapping is configured
52+
*/
53+
isEnabled() {
54+
return this.fromPath !== null && this.toPath !== null;
55+
}
56+
57+
/**
58+
* Rewrite a file path
59+
* @param {string} path - Original path from report
60+
* @returns {string} Rewritten path or original if no mapping configured
61+
*/
62+
rewritePath(path) {
63+
if (!this.isEnabled() || !path) {
64+
return path;
65+
}
66+
67+
// Handle both absolute and relative paths
68+
let normalizedPath = path;
69+
70+
// If path starts with fromPath, replace it
71+
if (normalizedPath.startsWith(this.fromPath)) {
72+
normalizedPath =
73+
this.toPath + normalizedPath.substring(this.fromPath.length);
74+
}
75+
// Also handle case where fromPath has leading slash but path doesn't
76+
else if (normalizedPath.startsWith(this.fromPath.replace(/^\//, ""))) {
77+
const fromPathNoLeadingSlash = this.fromPath.replace(/^\//, "");
78+
normalizedPath =
79+
this.toPath + normalizedPath.substring(fromPathNoLeadingSlash.length);
80+
}
81+
82+
return normalizedPath;
83+
}
84+
85+
/**
86+
* Get info about the path mapping configuration
87+
* @returns {Object|null} Object with from and to paths, or null if not enabled
88+
*/
89+
getMapping() {
90+
if (!this.isEnabled()) {
91+
return null;
92+
}
93+
94+
return {
95+
from: this.fromPath,
96+
to: this.toPath,
97+
};
98+
}
99+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { describe, it } from "node:test";
2+
import assert from "node:assert";
3+
import { PathRewriter } from "./PathRewriter.js";
4+
5+
describe("PathRewriter", () => {
6+
it("should be disabled by default", () => {
7+
const rewriter = new PathRewriter();
8+
assert.strictEqual(rewriter.isEnabled(), false);
9+
assert.strictEqual(rewriter.rewritePath("/some/path.js"), "/some/path.js");
10+
});
11+
12+
it("should parse path mapping correctly", () => {
13+
const rewriter = new PathRewriter("/app/src:/workspace/src");
14+
assert.strictEqual(rewriter.isEnabled(), true);
15+
16+
const mapping = rewriter.getMapping();
17+
assert.strictEqual(mapping.from, "/app/src");
18+
assert.strictEqual(mapping.to, "/workspace/src");
19+
});
20+
21+
it("should rewrite paths correctly", () => {
22+
const rewriter = new PathRewriter("/app/src:/workspace/src");
23+
24+
assert.strictEqual(
25+
rewriter.rewritePath("/app/src/utils/helper.js"),
26+
"/workspace/src/utils/helper.js",
27+
);
28+
29+
assert.strictEqual(
30+
rewriter.rewritePath("/app/src/index.js"),
31+
"/workspace/src/index.js",
32+
);
33+
});
34+
35+
it("should handle paths without leading slash in fromPath", () => {
36+
const rewriter = new PathRewriter("/app/src:/workspace/src");
37+
38+
assert.strictEqual(
39+
rewriter.rewritePath("app/src/utils/helper.js"),
40+
"/workspace/src/utils/helper.js",
41+
);
42+
});
43+
44+
it("should not rewrite paths that don't match", () => {
45+
const rewriter = new PathRewriter("/app/src:/workspace/src");
46+
47+
assert.strictEqual(
48+
rewriter.rewritePath("/other/path/file.js"),
49+
"/other/path/file.js",
50+
);
51+
});
52+
53+
it("should handle empty or null paths", () => {
54+
const rewriter = new PathRewriter("/app/src:/workspace/src");
55+
56+
assert.strictEqual(rewriter.rewritePath(""), "");
57+
assert.strictEqual(rewriter.rewritePath(null), null);
58+
});
59+
60+
it("should normalize trailing slashes", () => {
61+
const rewriter = new PathRewriter("/app/src/:/workspace/src/");
62+
63+
const mapping = rewriter.getMapping();
64+
assert.strictEqual(mapping.from, "/app/src");
65+
assert.strictEqual(mapping.to, "/workspace/src");
66+
});
67+
68+
it("should throw error on invalid format", () => {
69+
assert.throws(() => {
70+
new PathRewriter("invalid-mapping");
71+
}, /Invalid path-mapping format/);
72+
73+
assert.throws(() => {
74+
new PathRewriter("/from/path");
75+
}, /Invalid path-mapping format/);
76+
77+
assert.throws(() => {
78+
new PathRewriter(":/to/path");
79+
}, /Paths cannot be empty/);
80+
});
81+
82+
it("should handle complex paths", () => {
83+
const rewriter = new PathRewriter(
84+
"/var/lib/docker/overlay2/hash/merged/app:./src",
85+
);
86+
87+
assert.strictEqual(
88+
rewriter.rewritePath(
89+
"/var/lib/docker/overlay2/hash/merged/app/utils/helper.js",
90+
),
91+
"./src/utils/helper.js",
92+
);
93+
});
94+
});

actions/parse-ci-reports/src/ReportParserCore.js

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,17 @@ import { ParserFactory } from "./parsers/ParserFactory.js";
33
import { SummaryFormatter } from "./formatters/SummaryFormatter.js";
44
import { MarkdownFormatter } from "./formatters/MarkdownFormatter.js";
55
import { ReportData } from "./models/ReportData.js";
6+
import { PathRewriter } from "./PathRewriter.js";
67

78
/**
89
* Core report parsing logic shared between CLI and Action
910
*/
1011
export class ReportParserCore {
11-
constructor() {
12+
constructor(pathMapping = null) {
1213
this.parserFactory = new ParserFactory();
1314
this.summaryFormatter = new SummaryFormatter();
1415
this.markdownFormatter = new MarkdownFormatter();
16+
this.pathRewriter = new PathRewriter(pathMapping);
1517
}
1618

1719
/**
@@ -24,12 +26,28 @@ export class ReportParserCore {
2426
parseReports(files, logger = console.log, errorLogger = console.error) {
2527
const aggregatedData = new ReportData();
2628

29+
// Log path rewriting configuration if enabled
30+
if (this.pathRewriter.isEnabled()) {
31+
const mapping = this.pathRewriter.getMapping();
32+
logger(`Path rewriting enabled: "${mapping.from}" → "${mapping.to}"`);
33+
}
34+
2735
for (const file of files) {
2836
try {
2937
logger(`Parsing ${file}...`);
3038
const content = readFileSync(file, "utf-8");
3139
const reportData = this.parserFactory.parse(file, content);
3240

41+
// Apply path rewriting to test results
42+
for (const test of reportData.tests) {
43+
test.file = this.pathRewriter.rewritePath(test.file);
44+
}
45+
46+
// Apply path rewriting to lint issues
47+
for (const issue of reportData.lintIssues) {
48+
issue.file = this.pathRewriter.rewritePath(issue.file);
49+
}
50+
3351
// Merge data
3452
aggregatedData.tests.push(...reportData.tests);
3553
aggregatedData.lintIssues.push(...reportData.lintIssues);

actions/parse-ci-reports/src/index.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,14 @@ async function run({ core, glob: globModule, inputs }) {
55
const { ReportParserCore } = await import("./ReportParserCore.js");
66
const { ReportPathResolver } = await import("./ReportPathResolver.js");
77

8-
const parserCore = new ReportParserCore();
8+
const parserCore = new ReportParserCore(inputs.pathMapping);
99
const pathResolver = new ReportPathResolver();
1010

1111
core.info(`Report Name: ${inputs.reportName}`);
1212
core.info(`Output Format: ${inputs.outputFormat}`);
13+
if (inputs.pathMapping) {
14+
core.info(`Path Mapping: ${inputs.pathMapping}`);
15+
}
1316

1417
// Parse output formats (comma-separated or "all")
1518
const outputFormats = parserCore.parseOutputFormats(inputs.outputFormat);

0 commit comments

Comments
 (0)