Skip to content

Commit 195521a

Browse files
Copilotneilime
andcommitted
Support multiple path mappings for report path rewriting
Co-authored-by: neilime <314088+neilime@users.noreply.github.com> Signed-off-by: Emilien Escalle <emilien.escalle@escemi.com>
1 parent 0d0b8b3 commit 195521a

12 files changed

Lines changed: 263 additions & 73 deletions

File tree

.github/linters/.jscpd.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
{
22
"threshold": 5,
3-
"ignore": ["**/node_modules/**"]
3+
"ignore": ["**/node_modules/**", "**/actions/**/coverage/**"]
44
}

.github/workflows/__main-ci.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ jobs:
2323
permissions:
2424
actions: read
2525
contents: read
26+
id-token: write
27+
pull-requests: write
2628
statuses: write
2729
security-events: write
2830

.github/workflows/__pull-request-ci.yml

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,19 @@ on:
55
pull_request:
66
branches: [main]
77

8-
permissions:
9-
actions: read
10-
contents: read
11-
statuses: write
12-
security-events: write
13-
148
concurrency:
159
group: ${{ github.workflow }}-${{ github.ref }}
1610
cancel-in-progress: true
1711

12+
permissions: {}
13+
1814
jobs:
1915
ci:
2016
uses: ./.github/workflows/__shared-ci.yml
17+
permissions:
18+
actions: read
19+
contents: read
20+
id-token: write
21+
pull-requests: write
22+
statuses: write
23+
security-events: write

.github/workflows/__shared-ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ jobs:
2424
needs: linter
2525
permissions:
2626
contents: read
27+
id-token: write
28+
pull-requests: write
29+
security-events: write
2730
uses: ./.github/workflows/__test-action-parse-ci-reports.yml
2831

2932
test-action-repository-owner-is-organization:

.github/workflows/__test-action-parse-ci-reports.yml

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,20 @@ permissions:
77
contents: read
88

99
jobs:
10-
tests:
10+
continuous-integration:
11+
uses: hoverkraft-tech/ci-github-nodejs/.github/workflows/continuous-integration.yml@32a69b7b8fd5f7ab7bf656e7e88aa90ad235cf8d # 0.18.0
12+
permissions:
13+
contents: read
14+
id-token: write
15+
pull-requests: write
16+
security-events: write
17+
with:
18+
working-directory: ./actions/parse-ci-reports
19+
build: null
20+
lint: null
21+
test: '{ "coverage": null }'
22+
23+
integration-tests:
1124
name: Tests for parse-ci-reports action
1225
runs-on: ubuntu-latest
1326
steps:

actions/parse-ci-reports/README.md

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,10 @@ It supports multiple common report standards out of the box.
8484
# Default: `false`
8585
fail-on-error: "false"
8686

87-
# Path mapping to rewrite file paths in reports (format: "from_path:to_path").
87+
# Path mapping(s) to rewrite file paths in reports (format: "from_path:to_path").
8888
# Useful when tests/lints run in a different directory or container.
89-
# Example: "/app/src:./src" or "/var/lib/docker/.../app:."
89+
# Multiple mappings can be provided separated by newlines or commas.
90+
# Examples: "/app/src:./src", "/app/src:./src,/app/tests:./tests"
9091
#
9192
# Default: ``
9293
path-mapping: ""
@@ -106,9 +107,10 @@ It supports multiple common report standards out of the box.
106107
| **`include-passed`** | Whether to include passed tests in the summary. | **false** | `false` |
107108
| **`output-format`** | Output format: comma-separated list of `summary`, `markdown`, `annotations`, or `all` for everything. | **false** | `all` |
108109
| **`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+
| **`path-mapping`** | Path mapping(s) to rewrite file paths in reports (format: `from_path:to_path`). | **false** | `""` |
110111
| | Useful when tests/lints run in a different directory or container. | | |
111-
| | Example: `/app/src:./src` or `/var/lib/docker/.../app:.` | | |
112+
| | Multiple mappings can be provided separated by newlines or commas. | | |
113+
| | Examples: `/app/src:./src`, `/app/src:./src,/app/tests:./tests` | | |
112114

113115
<!-- inputs:end -->
114116
<!-- secrets:start -->
@@ -279,6 +281,37 @@ When running tests in a container or different directory, use path-mapping to en
279281

280282
This ensures GitHub annotations point to the correct files in your repository, even when tests run in `/app` inside the container.
281283

284+
### Multiple Path Mappings
285+
286+
When you have multiple source directories that need rewriting, provide multiple mappings separated by newlines or commas:
287+
288+
```yaml
289+
- name: Parse reports with multiple path mappings
290+
uses: hoverkraft-tech/ci-github-common/actions/parse-ci-reports@e6405b7d4daa7292edb246103f42b333a96d0a9f # copilot/add-report-parser-action
291+
with:
292+
report-paths: "auto:all"
293+
report-name: "CI Results"
294+
path-mapping: |
295+
/app/src:./src
296+
/app/tests:./tests
297+
/app/lib:./lib
298+
output-format: "annotations"
299+
```
300+
301+
Or using comma-separated format:
302+
303+
```yaml
304+
- name: Parse reports with multiple path mappings
305+
uses: hoverkraft-tech/ci-github-common/actions/parse-ci-reports@e6405b7d4daa7292edb246103f42b333a96d0a9f # copilot/add-report-parser-action
306+
with:
307+
report-paths: "auto:all"
308+
report-name: "CI Results"
309+
path-mapping: "/app/src:./src,/app/tests:./tests,/app/lib:./lib"
310+
output-format: "annotations"
311+
```
312+
313+
The first matching mapping is applied to each file path. This is useful when dealing with complex project structures or monorepos where different parts of the codebase run in different directories.
314+
282315
Another example for complex Docker overlay paths:
283316

284317
```yaml

actions/parse-ci-reports/action.yml

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,15 @@ inputs:
3939
default: "false"
4040
path-mapping:
4141
description: |
42-
Path mapping to rewrite file paths in reports (format: "from_path:to_path").
42+
Path mapping(s) to rewrite file paths in reports (format: "from_path:to_path").
4343
Useful when tests/lints run in a different directory or container.
44-
Example: "/app/src:./src" or "/var/lib/docker/.../app:."
44+
Multiple mappings can be provided separated by newlines or commas.
45+
Examples:
46+
- Single mapping: "/app/src:./src"
47+
- Multiple mappings: "/app/src:./src,/app/tests:./tests"
48+
- Multi-line: |
49+
/app/src:./src
50+
/app/tests:./tests
4551
required: false
4652
default: ""
4753

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
*
2+
!.gitignore

actions/parse-ci-reports/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
"version": "1.0.0",
44
"description": "Parse CI reports (tests, linting, coverage) into GitHub summaries and Markdown",
55
"scripts": {
6-
"test": "node --test src/**/*.test.js",
7-
"test:watch": "node --test --watch src/**/*.test.js",
6+
"test": "node --test src/models/*.test.js src/parsers/*.test.js src/*.test.js",
7+
"test:watch": "node --test --watch src/models/*.test.js src/parsers/*.test.js src/*.test.js",
88
"test:ci": "node --experimental-test-coverage --test-reporter=spec --test-reporter-destination=stdout --test-reporter=lcov --test-reporter-destination=coverage/lcov.info --test"
99
},
1010
"keywords": [

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

Lines changed: 93 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -5,95 +5,145 @@
55
* but the paths in reports need to match the actual repository structure
66
* for GitHub annotations and file references to work correctly.
77
*/
8+
const SLASH_CHAR_CODE = "/".charCodeAt(0);
9+
10+
const trimTrailingSlashes = (value) => {
11+
if (!value) {
12+
return value;
13+
}
14+
15+
let end = value.length;
16+
while (end > 0 && value.charCodeAt(end - 1) === SLASH_CHAR_CODE) {
17+
end -= 1;
18+
}
19+
20+
return value.slice(0, end);
21+
};
22+
23+
const removeSingleLeadingSlash = (value) => {
24+
if (!value || value.charCodeAt(0) !== SLASH_CHAR_CODE) {
25+
return value;
26+
}
27+
28+
return value.slice(1);
29+
};
30+
831
export class PathRewriter {
932
/**
1033
* Create a path rewriter
11-
* @param {string|null} pathMapping - Path mapping in format "from:to" or null to disable
34+
* @param {string|null} pathMapping - Path mapping(s) in format "from:to" or multiple mappings separated by newlines/commas
1235
*/
1336
constructor(pathMapping = null) {
14-
this.fromPath = null;
15-
this.toPath = null;
37+
this.mappings = [];
1638

1739
if (pathMapping && pathMapping.trim().length > 0) {
18-
this._parsePathMapping(pathMapping);
40+
this._parsePathMappings(pathMapping);
1941
}
2042
}
2143

2244
/**
23-
* Parse path mapping string
24-
* @param {string} pathMapping - Path mapping in format "from:to"
45+
* Parse path mapping string(s)
46+
* @param {string} pathMapping - Path mapping(s) in format "from:to", can be multiple separated by newlines or commas
2547
* @private
2648
*/
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-
}
49+
_parsePathMappings(pathMapping) {
50+
// Split by newlines or commas, filter empty strings
51+
const lines = pathMapping
52+
.split(/[\n,]/)
53+
.map((line) => line.trim())
54+
.filter((line) => line.length > 0);
3455

35-
this.fromPath = parts[0].trim();
36-
this.toPath = parts[1].trim();
56+
for (const line of lines) {
57+
const parts = line.split(":");
58+
if (parts.length !== 2) {
59+
throw new Error(
60+
`Invalid path-mapping format: "${line}". Expected format: "from_path:to_path"`,
61+
);
62+
}
3763

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-
}
64+
const fromPath = parts[0].trim();
65+
const toPath = parts[1].trim();
66+
67+
if (fromPath.length === 0 || toPath.length === 0) {
68+
throw new Error(
69+
`Invalid path-mapping format: "${line}". Paths cannot be empty.`,
70+
);
71+
}
4372

44-
// Normalize paths - remove trailing slashes for consistency
45-
this.fromPath = this.fromPath.replace(/\/+$/, "");
46-
this.toPath = this.toPath.replace(/\/+$/, "");
73+
// Normalize paths - remove trailing slashes for consistency
74+
this.mappings.push({
75+
from: trimTrailingSlashes(fromPath),
76+
to: trimTrailingSlashes(toPath),
77+
});
78+
}
4779
}
4880

4981
/**
5082
* Check if path rewriting is enabled
5183
* @returns {boolean} True if path mapping is configured
5284
*/
5385
isEnabled() {
54-
return this.fromPath !== null && this.toPath !== null;
86+
return this.mappings.length > 0;
5587
}
5688

5789
/**
58-
* Rewrite a file path
90+
* Rewrite a file path using the first matching mapping
5991
* @param {string} path - Original path from report
60-
* @returns {string} Rewritten path or original if no mapping configured
92+
* @returns {string} Rewritten path or original if no mapping matches
6193
*/
6294
rewritePath(path) {
6395
if (!this.isEnabled() || !path) {
6496
return path;
6597
}
6698

67-
// Handle both absolute and relative paths
68-
let normalizedPath = path;
99+
// Try each mapping in order until one matches
100+
for (const mapping of this.mappings) {
101+
const result = this._applyMapping(path, mapping);
102+
if (result !== path) {
103+
// Mapping was applied
104+
return result;
105+
}
106+
}
107+
108+
// No mapping matched, return original
109+
return path;
110+
}
69111

112+
/**
113+
* Apply a single mapping to a path
114+
* @param {string} path - Original path
115+
* @param {Object} mapping - Mapping object with from and to properties
116+
* @returns {string} Rewritten path or original if mapping doesn't match
117+
* @private
118+
*/
119+
_applyMapping(path, mapping) {
70120
// If path starts with fromPath, replace it
71-
if (normalizedPath.startsWith(this.fromPath)) {
72-
normalizedPath =
73-
this.toPath + normalizedPath.substring(this.fromPath.length);
121+
if (path.startsWith(mapping.from)) {
122+
return mapping.to + path.substring(mapping.from.length);
74123
}
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);
124+
125+
if (mapping.from.startsWith("/")) {
126+
const fromPathNoLeadingSlash = removeSingleLeadingSlash(mapping.from);
127+
if (path.startsWith(fromPathNoLeadingSlash)) {
128+
return mapping.to + path.substring(fromPathNoLeadingSlash.length);
129+
}
80130
}
81131

82-
return normalizedPath;
132+
return path;
83133
}
84134

85135
/**
86136
* Get info about the path mapping configuration
87-
* @returns {Object|null} Object with from and to paths, or null if not enabled
137+
* @returns {Array|null} Array of mapping objects with from and to properties, or null if not enabled
88138
*/
89-
getMapping() {
139+
getMappings() {
90140
if (!this.isEnabled()) {
91141
return null;
92142
}
93143

94-
return {
95-
from: this.fromPath,
96-
to: this.toPath,
97-
};
144+
return this.mappings.map((m) => ({
145+
from: m.from,
146+
to: m.to,
147+
}));
98148
}
99149
}

0 commit comments

Comments
 (0)