@allurereport/ci 3.18.0 → 3.19.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.
@@ -1,2 +1,16 @@
1
1
  import { type CiDescriptor } from "@allurereport/core-api";
2
- export declare const gitlab: CiDescriptor;
2
+ export type GitlabCiDescriptor = CiDescriptor & {
3
+ projectId: string;
4
+ projectPath: string;
5
+ projectDirectory: string;
6
+ pipelineSource: string;
7
+ ciJobName: string;
8
+ currentJobId: string;
9
+ currentJobUrl: string;
10
+ mergeRequestProjectId: string;
11
+ restApiUrl: string;
12
+ graphqlApiUrl: string;
13
+ ref: string;
14
+ jobArtifactsUrlBase: string;
15
+ };
16
+ export declare const gitlab: GitlabCiDescriptor;
@@ -1,15 +1,18 @@
1
1
  import { CiType, GitProvider } from "@allurereport/core-api";
2
2
  import { getEnv } from "../utils.js";
3
+ const GITLAB_DEFAULT_SERVER_URL = "https://gitlab.com";
4
+ const stripTrailingSlashes = (value) => value.replace(/\/+$/, "");
3
5
  const getMergeRequestProjectUrl = () => getEnv("CI_MERGE_REQUEST_PROJECT_URL") || getEnv("CI_PROJECT_URL");
4
- const isTagPipeline = () => Boolean(getEnv("CI_COMMIT_TAG"));
5
- const getSourceBranch = () => {
6
- if (isTagPipeline()) {
7
- return undefined;
6
+ const getSourceRef = () => getEnv("CI_MERGE_REQUEST_SOURCE_BRANCH_NAME") || getEnv("CI_COMMIT_REF_NAME") || undefined;
7
+ const getServerUrl = () => stripTrailingSlashes(getEnv("CI_SERVER_URL") || GITLAB_DEFAULT_SERVER_URL);
8
+ const getRestApiUrl = () => stripTrailingSlashes(getEnv("CI_API_V4_URL") || `${getServerUrl()}/api/v4`);
9
+ const getGraphqlApiUrl = () => {
10
+ const explicit = getEnv("CI_API_GRAPHQL_URL");
11
+ if (explicit) {
12
+ return stripTrailingSlashes(explicit);
8
13
  }
9
- return (getEnv("CI_MERGE_REQUEST_SOURCE_BRANCH_NAME") ||
10
- getEnv("CI_COMMIT_BRANCH") ||
11
- getEnv("CI_COMMIT_REF_NAME") ||
12
- undefined);
14
+ const restApiUrl = getRestApiUrl();
15
+ return restApiUrl.replace(/\/api\/v4$/, "/api/graphql");
13
16
  };
14
17
  export const gitlab = {
15
18
  type: CiType.Gitlab,
@@ -28,6 +31,9 @@ export const gitlab = {
28
31
  get jobName() {
29
32
  return getEnv("CI_PROJECT_NAME");
30
33
  },
34
+ get ciJobName() {
35
+ return getEnv("CI_JOB_NAME");
36
+ },
31
37
  get jobRunUid() {
32
38
  return getEnv("CI_PIPELINE_ID");
33
39
  },
@@ -38,14 +44,14 @@ export const gitlab = {
38
44
  return getEnv("CI_PIPELINE_ID");
39
45
  },
40
46
  get jobRunBranch() {
41
- return getSourceBranch() || "";
47
+ return getSourceRef() || "";
42
48
  },
43
49
  get pullRequestUrl() {
44
50
  const mergeRequestIID = getEnv("CI_MERGE_REQUEST_IID");
45
51
  if (!mergeRequestIID) {
46
52
  return "";
47
53
  }
48
- const projectUrl = getMergeRequestProjectUrl().replace(/\/+$/, "");
54
+ const projectUrl = stripTrailingSlashes(getMergeRequestProjectUrl());
49
55
  return projectUrl ? `${projectUrl}/-/merge_requests/${mergeRequestIID}` : "";
50
56
  },
51
57
  get pullRequestName() {
@@ -64,7 +70,7 @@ export const gitlab = {
64
70
  : undefined;
65
71
  },
66
72
  get sourceBranch() {
67
- return getSourceBranch();
73
+ return getSourceRef();
68
74
  },
69
75
  get targetBranch() {
70
76
  return getEnv("CI_MERGE_REQUEST_TARGET_BRANCH_NAME") || undefined;
@@ -79,4 +85,38 @@ export const gitlab = {
79
85
  }
80
86
  : undefined;
81
87
  },
88
+ get projectId() {
89
+ return getEnv("CI_PROJECT_ID");
90
+ },
91
+ get projectPath() {
92
+ return getEnv("CI_PROJECT_PATH");
93
+ },
94
+ get projectDirectory() {
95
+ return getEnv("CI_PROJECT_DIR");
96
+ },
97
+ get pipelineSource() {
98
+ return getEnv("CI_PIPELINE_SOURCE");
99
+ },
100
+ get currentJobId() {
101
+ return getEnv("CI_JOB_ID");
102
+ },
103
+ get currentJobUrl() {
104
+ return getEnv("CI_JOB_URL");
105
+ },
106
+ get mergeRequestProjectId() {
107
+ return getEnv("CI_MERGE_REQUEST_PROJECT_ID");
108
+ },
109
+ get restApiUrl() {
110
+ return getRestApiUrl();
111
+ },
112
+ get graphqlApiUrl() {
113
+ return getGraphqlApiUrl();
114
+ },
115
+ get ref() {
116
+ return getSourceRef() || "";
117
+ },
118
+ get jobArtifactsUrlBase() {
119
+ return (`${new URL(getServerUrl()).protocol}//${getEnv("CI_PROJECT_ROOT_NAMESPACE_SLUG")}.${getEnv("CI_PAGES_DOMAIN") || "gitlab.io"}` +
120
+ `/-/${getEnv("CI_PROJECT_NAME")}/-/jobs/${this.currentJobId}/artifacts`);
121
+ },
82
122
  };
@@ -0,0 +1,14 @@
1
+ import { type AxiosResponse } from "axios";
2
+ import { type GitlabCiDescriptor } from "../../detectors/gitlab.js";
3
+ export type GitlabClient = {
4
+ ci: GitlabCiDescriptor;
5
+ query<T>(query: string, variables: Record<string, unknown>): Promise<T>;
6
+ requestJson<T>(method: "GET" | "POST" | "PUT", path: string, body?: unknown): Promise<{
7
+ data: T;
8
+ headers: AxiosResponse["headers"];
9
+ }>;
10
+ downloadArtifact(jobId: string, artifactPath: string): Promise<Uint8Array>;
11
+ };
12
+ export declare const createGitlabClient: (options?: {
13
+ token?: string;
14
+ }) => GitlabClient;
@@ -0,0 +1,133 @@
1
+ import axios from "axios";
2
+ import { gitlab } from "../../detectors/gitlab.js";
3
+ const REQUEST_TIMEOUT_MS = 10000;
4
+ const isDecimalString = (value) => /^[0-9]+$/.test(value);
5
+ const requireMetadata = (ci) => {
6
+ const missing = [];
7
+ if (!ci.detected) {
8
+ missing.push("GitLab CI");
9
+ }
10
+ if (!ci.projectId) {
11
+ missing.push("CI_PROJECT_ID");
12
+ }
13
+ if (!ci.projectPath) {
14
+ missing.push("CI_PROJECT_PATH");
15
+ }
16
+ if (!ci.projectDirectory) {
17
+ missing.push("CI_PROJECT_DIR");
18
+ }
19
+ if (!ci.jobRunUid) {
20
+ missing.push("CI_PIPELINE_ID");
21
+ }
22
+ if (!ci.pipelineSource) {
23
+ missing.push("CI_PIPELINE_SOURCE");
24
+ }
25
+ if (!ci.ciJobName) {
26
+ missing.push("CI_JOB_NAME");
27
+ }
28
+ if (!ci.ref) {
29
+ missing.push("CI_COMMIT_REF_NAME");
30
+ }
31
+ return missing;
32
+ };
33
+ const invalidNumericMetadata = (ci) => {
34
+ const invalid = [];
35
+ if (!isDecimalString(ci.projectId)) {
36
+ invalid.push("CI_PROJECT_ID");
37
+ }
38
+ if (!isDecimalString(ci.jobRunUid)) {
39
+ invalid.push("CI_PIPELINE_ID");
40
+ }
41
+ if (ci.currentJobId && !isDecimalString(ci.currentJobId)) {
42
+ invalid.push("CI_JOB_ID");
43
+ }
44
+ if (ci.mergeRequestProjectId && !isDecimalString(ci.mergeRequestProjectId)) {
45
+ invalid.push("CI_MERGE_REQUEST_PROJECT_ID");
46
+ }
47
+ return invalid;
48
+ };
49
+ const encodeArtifactPathSegment = (segment) => {
50
+ if (segment === ".") {
51
+ return "%2E";
52
+ }
53
+ if (segment === "..") {
54
+ return "%2E%2E";
55
+ }
56
+ return encodeURIComponent(segment);
57
+ };
58
+ const encodeArtifactPath = (artifactPath) => artifactPath.split("/").map(encodeArtifactPathSegment).join("/");
59
+ const buildRestUrl = (restApiUrl, path) => new URL(path.replace(/^\/+/, ""), `${restApiUrl.href.replace(/\/+$/, "")}/`);
60
+ const hasApiPathSuffix = (url, suffix) => url.pathname.replace(/\/+$/, "").endsWith(suffix);
61
+ const assertTrustedApiUrl = (url, apiOrigin) => {
62
+ if (url.origin !== apiOrigin) {
63
+ throw new Error("GitLab API request left trusted origin");
64
+ }
65
+ };
66
+ export const createGitlabClient = (options) => {
67
+ const token = options?.token;
68
+ const ci = gitlab;
69
+ const missingMetadata = requireMetadata(ci);
70
+ if (missingMetadata.length > 0) {
71
+ throw new Error(`missing GitLab metadata: ${missingMetadata.join(", ")}`);
72
+ }
73
+ const invalidNumeric = invalidNumericMetadata(ci);
74
+ if (invalidNumeric.length > 0) {
75
+ throw new Error(`invalid numeric GitLab metadata: ${invalidNumeric.join(", ")}`);
76
+ }
77
+ const restApiUrl = new URL(ci.restApiUrl);
78
+ const graphqlApiUrl = new URL(ci.graphqlApiUrl);
79
+ if (!hasApiPathSuffix(restApiUrl, "/api/v4") || !hasApiPathSuffix(graphqlApiUrl, "/api/graphql")) {
80
+ throw new Error("invalid GitLab API endpoint paths");
81
+ }
82
+ if (restApiUrl.origin !== graphqlApiUrl.origin) {
83
+ throw new Error("GitLab API origins do not match");
84
+ }
85
+ const apiOrigin = restApiUrl.origin;
86
+ const requestOptions = {
87
+ headers: token ? { "private-token": token } : {},
88
+ sensitiveHeaders: ["private-token"],
89
+ timeout: REQUEST_TIMEOUT_MS,
90
+ };
91
+ return {
92
+ ci,
93
+ async query(query, variables) {
94
+ const { data: envelope } = await axios.request({
95
+ ...requestOptions,
96
+ url: graphqlApiUrl.href,
97
+ method: "POST",
98
+ data: { query, variables },
99
+ });
100
+ if (!envelope || typeof envelope !== "object") {
101
+ throw new Error("GitLab GraphQL response was invalid");
102
+ }
103
+ if (envelope.errors) {
104
+ throw new Error("GitLab GraphQL response contained errors");
105
+ }
106
+ if (!Object.hasOwn(envelope, "data")) {
107
+ throw new Error("GitLab GraphQL response omitted data");
108
+ }
109
+ return envelope.data;
110
+ },
111
+ async requestJson(method, path, body) {
112
+ const url = buildRestUrl(restApiUrl, path);
113
+ assertTrustedApiUrl(url, apiOrigin);
114
+ const { data, headers } = await axios.request({
115
+ ...requestOptions,
116
+ url: url.href,
117
+ method,
118
+ data: body,
119
+ });
120
+ return { data, headers };
121
+ },
122
+ async downloadArtifact(jobId, artifactPath) {
123
+ const url = buildRestUrl(restApiUrl, `/projects/${encodeURIComponent(ci.projectId)}/jobs/${encodeURIComponent(jobId)}/artifacts/${encodeArtifactPath(artifactPath)}`);
124
+ const { data } = await axios.request({
125
+ ...requestOptions,
126
+ url: url.href,
127
+ method: "GET",
128
+ responseType: "arraybuffer",
129
+ });
130
+ return data;
131
+ },
132
+ };
133
+ };
@@ -0,0 +1,6 @@
1
+ import type { ReportContext } from "../../reportContext.js";
2
+ export declare const upsertGitlabJobNote: (options: {
3
+ token: string;
4
+ summary: ReportContext;
5
+ reportUrl: string;
6
+ }) => Promise<void>;
@@ -0,0 +1,128 @@
1
+ import { gitlab } from "../../detectors/gitlab.js";
2
+ import { createGitlabClient } from "./client.js";
3
+ import { renderGitlabReportSummary } from "./summary.js";
4
+ const MARKER_VERSION = "v1";
5
+ const MAX_NOTE_SCAN_PAGES = 5;
6
+ const NOTE_PAGE_SIZE = 100;
7
+ const MAX_COMMENT_LENGTH = 60000;
8
+ const isDecimalString = (value) => /^[0-9]+$/.test(value);
9
+ const encodePathSegment = (value) => encodeURIComponent(value);
10
+ const ownershipMarker = ({ jobName, pipelineId, jobId }) => `<!-- allure-gitlab-summary:${MARKER_VERSION}:${jobName}:${pipelineId}:${jobId} -->`;
11
+ const parseNotes = (value) => {
12
+ if (!Array.isArray(value)) {
13
+ throw new Error("invalid notes response");
14
+ }
15
+ return value.map((note) => {
16
+ if (typeof note !== "object" || note === null) {
17
+ throw new Error("invalid notes response");
18
+ }
19
+ const { id, body } = note;
20
+ const textId = typeof id === "number" && Number.isInteger(id) ? String(id) : typeof id === "string" ? id : "";
21
+ if (!textId || typeof body !== "string") {
22
+ throw new Error("invalid notes response");
23
+ }
24
+ return { id: textId, body };
25
+ });
26
+ };
27
+ const markerPattern = new RegExp(`^<!-- allure-gitlab-summary:${MARKER_VERSION}:([A-Za-z0-9+/]+={0,2}):([0-9]+):([0-9]+) -->\\n`);
28
+ const parseMarker = (body) => {
29
+ const match = body.match(markerPattern);
30
+ if (!match) {
31
+ return undefined;
32
+ }
33
+ return {
34
+ jobName: match[1],
35
+ pipelineId: BigInt(match[2]),
36
+ jobId: BigInt(match[3]),
37
+ };
38
+ };
39
+ const compareLogicalRun = (left, right) => {
40
+ if (left.pipelineId > right.pipelineId) {
41
+ return 1;
42
+ }
43
+ if (left.pipelineId < right.pipelineId) {
44
+ return -1;
45
+ }
46
+ if (left.jobId > right.jobId) {
47
+ return 1;
48
+ }
49
+ if (left.jobId < right.jobId) {
50
+ return -1;
51
+ }
52
+ return 0;
53
+ };
54
+ const currentMarker = (client) => ({
55
+ jobName: Buffer.from(client.ci.ciJobName).toString("base64"),
56
+ pipelineId: BigInt(client.ci.jobRunUid),
57
+ jobId: BigInt(client.ci.currentJobId),
58
+ });
59
+ const notePath = (client, iid, page) => {
60
+ const base = `/projects/${encodePathSegment(client.ci.projectId)}/merge_requests/${encodePathSegment(iid)}/notes`;
61
+ return page === undefined ? base : `${base}?per_page=${NOTE_PAGE_SIZE}&page=${page}`;
62
+ };
63
+ const readOwnedNotes = async (client, iid, currentJobName) => {
64
+ const notes = [];
65
+ let page = 1;
66
+ for (let index = 0; index < MAX_NOTE_SCAN_PAGES; index += 1) {
67
+ const { data, headers } = await client.requestJson("GET", notePath(client, iid, page));
68
+ for (const note of parseNotes(data)) {
69
+ const marker = parseMarker(note.body);
70
+ if (marker?.jobName === currentJobName) {
71
+ notes.push({ note, marker });
72
+ }
73
+ }
74
+ const nextPageHeader = headers["x-next-page"];
75
+ if (typeof nextPageHeader !== "string") {
76
+ throw new Error("incomplete note scan");
77
+ }
78
+ const nextPage = nextPageHeader.trim();
79
+ if (!nextPage) {
80
+ return notes;
81
+ }
82
+ const expectedNextPage = String(page + 1);
83
+ if (!isDecimalString(nextPage) || nextPage !== expectedNextPage) {
84
+ throw new Error("incomplete note scan");
85
+ }
86
+ page += 1;
87
+ }
88
+ throw new Error("incomplete note scan");
89
+ };
90
+ const selectNewestOwnedNote = (notes) => notes.reduce((selected, candidate) => {
91
+ if (!selected || compareLogicalRun(candidate.marker, selected.marker) > 0) {
92
+ return candidate;
93
+ }
94
+ return selected;
95
+ }, undefined);
96
+ export const upsertGitlabJobNote = async (options) => {
97
+ const iid = gitlab.pullRequest?.id;
98
+ if (!iid || !isDecimalString(iid)) {
99
+ throw new Error("missing merge request");
100
+ }
101
+ if (gitlab.projectId && gitlab.mergeRequestProjectId && gitlab.mergeRequestProjectId !== gitlab.projectId) {
102
+ throw new Error("cross-project merge request");
103
+ }
104
+ const client = createGitlabClient(options);
105
+ if (!client.ci.currentJobId || !isDecimalString(client.ci.currentJobId)) {
106
+ throw new Error("missing current job");
107
+ }
108
+ const current = currentMarker(client);
109
+ const reportUrl = new URL(options.reportUrl);
110
+ if ((reportUrl.protocol !== "http:" && reportUrl.protocol !== "https:") || reportUrl.username || reportUrl.password) {
111
+ throw new Error("invalid report URL");
112
+ }
113
+ const body = `${ownershipMarker(current)}\n${renderGitlabReportSummary(options.summary, reportUrl)}`;
114
+ if (body.length > MAX_COMMENT_LENGTH) {
115
+ throw new Error("comment too large");
116
+ }
117
+ const ownedNotes = await readOwnedNotes(client, iid, current.jobName);
118
+ const selected = selectNewestOwnedNote(ownedNotes);
119
+ if (selected && compareLogicalRun(selected.marker, current) > 0) {
120
+ throw new Error("newer owned note exists");
121
+ }
122
+ if (selected) {
123
+ await client.requestJson("PUT", `${notePath(client, iid)}/${encodePathSegment(selected.note.id)}`, { body });
124
+ }
125
+ else {
126
+ await client.requestJson("POST", notePath(client, iid), { body });
127
+ }
128
+ };
@@ -0,0 +1,4 @@
1
+ export declare const restoreGitlabHistory: (options: {
2
+ token?: string;
3
+ historyPath: string;
4
+ }) => Promise<void>;
@@ -0,0 +1,111 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { dirname, relative, sep } from "node:path";
3
+ import { createGitlabClient } from "./client.js";
4
+ const previousGitlabJobQuery = `query PreviousGitlabJob($path: ID!, $ref: String!, $source: String!, $job: String!) {
5
+ project(fullPath: $path) {
6
+ pipelines(ref: $ref, source: $source, first: 100) {
7
+ nodes { id source job(name: $job) { id name status retried } }
8
+ }
9
+ }
10
+ }`;
11
+ const isRecord = (value) => typeof value === "object" && value !== null;
12
+ const decodeGitlabId = (value, type) => {
13
+ if (typeof value !== "string") {
14
+ throw new Error("invalid id");
15
+ }
16
+ const typeName = type === "Pipeline" ? "Pipeline" : "Build";
17
+ const match = value.match(new RegExp(`^gid://gitlab/Ci::${typeName}/([0-9]+)$`));
18
+ if (!match) {
19
+ throw new Error("invalid id");
20
+ }
21
+ return { text: match[1], value: BigInt(match[1]) };
22
+ };
23
+ const parseJob = (value) => {
24
+ if (value === null) {
25
+ return null;
26
+ }
27
+ if (!isRecord(value)) {
28
+ throw new Error("invalid job");
29
+ }
30
+ const { id, name, status, retried } = value;
31
+ if (typeof id !== "string" || typeof name !== "string" || typeof status !== "string") {
32
+ throw new Error("invalid job");
33
+ }
34
+ if (retried !== true && retried !== false && retried !== null) {
35
+ throw new Error("invalid job");
36
+ }
37
+ return { id, name, status, retried };
38
+ };
39
+ const parsePipelines = (response) => {
40
+ if (!isRecord(response) || !isRecord(response.project)) {
41
+ throw new Error("invalid project");
42
+ }
43
+ const { pipelines } = response.project;
44
+ if (!isRecord(pipelines) || !Array.isArray(pipelines.nodes)) {
45
+ throw new Error("invalid pipelines");
46
+ }
47
+ return pipelines.nodes.map((node) => {
48
+ if (!isRecord(node) ||
49
+ typeof node.id !== "string" ||
50
+ typeof node.source !== "string" ||
51
+ !Object.hasOwn(node, "job")) {
52
+ throw new Error("invalid pipeline");
53
+ }
54
+ return {
55
+ id: node.id,
56
+ source: node.source,
57
+ job: parseJob(node.job),
58
+ };
59
+ });
60
+ };
61
+ const selectPreviousJobId = (client, nodes) => {
62
+ const currentPipelineId = BigInt(client.ci.jobRunUid);
63
+ let previousPipelineId;
64
+ let selectedJobId;
65
+ for (const node of nodes) {
66
+ const pipelineId = decodeGitlabId(node.id, "Pipeline");
67
+ if (previousPipelineId !== undefined && pipelineId.value >= previousPipelineId) {
68
+ throw new Error("invalid pipeline order");
69
+ }
70
+ previousPipelineId = pipelineId.value;
71
+ if (node.source !== client.ci.pipelineSource) {
72
+ throw new Error("invalid pipeline source");
73
+ }
74
+ const { job } = node;
75
+ const jobId = job ? decodeGitlabId(job.id, "Build") : undefined;
76
+ if (selectedJobId || pipelineId.value >= currentPipelineId || !job || !jobId) {
77
+ continue;
78
+ }
79
+ if (job.name !== client.ci.ciJobName) {
80
+ continue;
81
+ }
82
+ if (job.status !== "SUCCESS" && job.status !== "FAILED") {
83
+ continue;
84
+ }
85
+ if (job.retried === true) {
86
+ continue;
87
+ }
88
+ selectedJobId = jobId.text;
89
+ }
90
+ return selectedJobId;
91
+ };
92
+ const projectRelativePath = (projectDirectory, historyPath) => relative(projectDirectory, historyPath).split(sep).join("/");
93
+ export const restoreGitlabHistory = async (options) => {
94
+ const client = createGitlabClient(options);
95
+ const response = await client.query(previousGitlabJobQuery, {
96
+ path: client.ci.projectPath,
97
+ ref: client.ci.ref,
98
+ source: client.ci.pipelineSource,
99
+ job: client.ci.ciJobName,
100
+ });
101
+ const selectedJobId = selectPreviousJobId(client, parsePipelines(response));
102
+ if (!selectedJobId) {
103
+ throw new Error("no matching prior job in returned pipeline window");
104
+ }
105
+ const bytes = await client.downloadArtifact(selectedJobId, projectRelativePath(client.ci.projectDirectory, options.historyPath));
106
+ if (bytes.byteLength === 0) {
107
+ throw new Error("empty history artifact");
108
+ }
109
+ await mkdir(dirname(options.historyPath), { recursive: true });
110
+ await writeFile(options.historyPath, bytes);
111
+ };
@@ -0,0 +1,4 @@
1
+ export { createGitlabClient } from "./client.js";
2
+ export { upsertGitlabJobNote } from "./comments.js";
3
+ export { restoreGitlabHistory } from "./history.js";
4
+ export type { GitlabClient } from "./client.js";
@@ -0,0 +1,3 @@
1
+ export { createGitlabClient } from "./client.js";
2
+ export { upsertGitlabJobNote } from "./comments.js";
3
+ export { restoreGitlabHistory } from "./history.js";
@@ -0,0 +1,2 @@
1
+ import type { ReportContext } from "../../reportContext.js";
2
+ export declare const renderGitlabReportSummary: (summary: ReportContext, reportUrl: URL) => string;
@@ -0,0 +1,19 @@
1
+ import { renderReportSummaryMarkdown } from "../../reportMarkdown.js";
2
+ const reportHref = (report, reportUrl) => {
3
+ const { href, reportPath } = report;
4
+ if (href && /^[A-Za-z][A-Za-z0-9+.-]*:/.test(href)) {
5
+ return href;
6
+ }
7
+ if (reportPath !== undefined) {
8
+ const path = reportPath.split("/").filter(Boolean).map(encodeURIComponent).join("/");
9
+ return new URL(`${path ? `${path}/` : ""}index.html`, reportUrl).href;
10
+ }
11
+ return href ? new URL(href, reportUrl).href : undefined;
12
+ };
13
+ export const renderGitlabReportSummary = (summary, reportUrl) => {
14
+ const context = {
15
+ ...summary,
16
+ reports: summary.reports.map((report) => ({ ...report, href: reportHref(report, reportUrl) })),
17
+ };
18
+ return renderReportSummaryMarkdown(context, { includeArtifacts: false });
19
+ };
package/dist/index.d.ts CHANGED
@@ -1,5 +1,7 @@
1
+ export * from "./reportContext.js";
2
+ export * from "./reportMarkdown.js";
1
3
  export { applyAllureCiEnv } from "./ciEnv.js";
2
4
  export { detect } from "./detect.js";
3
5
  export { isLocalCiDescriptor } from "./detectors/local.js";
4
- export * from "./reportContext.js";
5
- export * from "./reportMarkdown.js";
6
+ export { restoreGitlabHistory, upsertGitlabJobNote } from "./helpers/gitlab/index.js";
7
+ export type { GitlabCiDescriptor } from "./detectors/gitlab.js";
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
+ export * from "./reportContext.js";
2
+ export * from "./reportMarkdown.js";
1
3
  export { applyAllureCiEnv } from "./ciEnv.js";
2
4
  export { detect } from "./detect.js";
3
5
  export { isLocalCiDescriptor } from "./detectors/local.js";
4
- export * from "./reportContext.js";
5
- export * from "./reportMarkdown.js";
6
+ export { restoreGitlabHistory, upsertGitlabJobNote } from "./helpers/gitlab/index.js";
@@ -27,6 +27,7 @@ const emptyResolutionStats = () => ({
27
27
  });
28
28
  const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
29
29
  const isTestStatus = (value) => typeof value === "string" && TEST_STATUSES.includes(value);
30
+ const getFiniteNumber = (value, fallback = 0) => typeof value === "number" && Number.isFinite(value) ? value : fallback;
30
31
  const normalizePath = (filePath) => filePath.split(sep).join("/");
31
32
  const readOptionalJson = async (filePath, onError) => {
32
33
  if (!existsSync(filePath)) {
@@ -40,12 +41,43 @@ const readOptionalJson = async (filePath, onError) => {
40
41
  return undefined;
41
42
  }
42
43
  };
44
+ const getReportName = (summary) => {
45
+ if (typeof summary.name === "string" && summary.name.trim()) {
46
+ return summary.name;
47
+ }
48
+ return "Allure Report";
49
+ };
50
+ const hasPluginSummarySignal = (value) => typeof value.name === "string" ||
51
+ typeof value.plugin === "string" ||
52
+ typeof value.href === "string" ||
53
+ typeof value.remoteHref === "string" ||
54
+ isRecord(value.stats);
43
55
  const isPluginSummary = (value) => {
44
- if (!isRecord(value) || typeof value.name !== "string" || !isRecord(value.stats)) {
56
+ if (!isRecord(value) || !hasPluginSummarySignal(value)) {
45
57
  return false;
46
58
  }
47
- return typeof value.duration === "number" && isTestStatus(value.status);
59
+ return true;
60
+ };
61
+ const normalizePluginSummaryStats = (summary) => {
62
+ const rawStats = isRecord(summary.stats) ? summary.stats : {};
63
+ const stats = {
64
+ ...rawStats,
65
+ failed: getFiniteNumber(rawStats.failed),
66
+ broken: getFiniteNumber(rawStats.broken),
67
+ passed: getFiniteNumber(rawStats.passed),
68
+ skipped: getFiniteNumber(rawStats.skipped),
69
+ unknown: getFiniteNumber(rawStats.unknown),
70
+ };
71
+ stats.total = getFiniteNumber(rawStats.total, TEST_STATUSES.reduce((acc, status) => acc + getFiniteNumber(stats[status]), 0));
72
+ return stats;
48
73
  };
74
+ const normalizePluginSummary = (summary) => ({
75
+ ...summary,
76
+ name: getReportName(summary),
77
+ stats: normalizePluginSummaryStats(summary),
78
+ status: isTestStatus(summary.status) ? summary.status : "passed",
79
+ duration: getFiniteNumber(summary.duration),
80
+ });
49
81
  const getReportPath = (reportDir, summaryFile) => normalizePath(relative(resolve(reportDir), dirname(resolve(summaryFile))));
50
82
  const readPluginSummary = async (reportDir, filePath, onError) => {
51
83
  const value = await readOptionalJson(filePath, onError);
@@ -57,7 +89,7 @@ const readPluginSummary = async (reportDir, filePath, onError) => {
57
89
  return undefined;
58
90
  }
59
91
  return {
60
- ...value,
92
+ ...normalizePluginSummary(value),
61
93
  summaryFile: filePath,
62
94
  reportPath: getReportPath(reportDir, filePath),
63
95
  };
@@ -204,8 +236,10 @@ const createEnvironmentContext = (registry, summaries) => {
204
236
  });
205
237
  return [...environmentsByName.values()].toSorted((left, right) => left.name.localeCompare(right.name));
206
238
  };
207
- const createReport = (summary) => ({ ...summary });
208
- const sortReports = (reports) => reports.toSorted((left, right) => left.name.localeCompare(right.name));
239
+ const createReport = (summary) => normalizePluginSummary(summary);
240
+ const sortReports = (reports) => reports
241
+ .map((report) => normalizePluginSummary(report))
242
+ .toSorted((left, right) => left.name.localeCompare(right.name));
209
243
  const getResolutionStats = (summaries) => {
210
244
  const stats = emptyResolutionStats();
211
245
  summaries.forEach((summary) => {
@@ -1,6 +1,16 @@
1
- import type { ReportContext } from "./reportContext.js";
1
+ import type { ReportContext, ReportContextFlagStats, ReportContextTestReport, ReportContextResolutionStats, ReportContextStatusStats } from "./reportContext.js";
2
+ export type ReportSummaryMarkdownRow = {
3
+ kind: "total" | "environment" | "report";
4
+ name: string;
5
+ duration: number;
6
+ stats: ReportContextStatusStats;
7
+ flags: ReportContextFlagStats;
8
+ resolutions?: ReportContextResolutionStats;
9
+ report?: ReportContextTestReport;
10
+ };
2
11
  export type RenderReportSummaryMarkdownOptions = {
3
12
  title?: string;
4
13
  includeArtifacts?: boolean;
14
+ getReportFilterHref?: (filter: keyof ReportContextFlagStats, row: ReportSummaryMarkdownRow) => string | undefined;
5
15
  };
6
16
  export declare const renderReportSummaryMarkdown: (context: ReportContext, options?: RenderReportSummaryMarkdownOptions) => string;
@@ -10,6 +10,7 @@ const STATUS_ICON_BASE_URL = "https://allurecharts.qameta.workers.dev/dot";
10
10
  const STATUS_PIE_BASE_URL = "https://allurecharts.qameta.workers.dev/pie";
11
11
  const escapeHtml = (value) => value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
12
12
  const tableCell = (value) => escapeHtml(String(value)).replaceAll("|", "&#124;").replaceAll("\n", "<br>");
13
+ const inlineCode = (value) => `<code>${escapeHtml(value)}</code>`;
13
14
  const isSafeHref = (href) => {
14
15
  try {
15
16
  const url = new URL(href);
@@ -82,11 +83,19 @@ const formatResolutions = (resolutions) => {
82
83
  return lines.join("<br>");
83
84
  };
84
85
  const hasResolutions = (resolutions) => resolutions.issues > 0 || resolutions.muted > 0 || resolutions.accepted > 0;
85
- const formatFlag = (value) => (value > 0 ? String(value) : "0");
86
- const renderTable = (rows, includeResolutions) => {
86
+ const formatFlag = (flag, row, options) => {
87
+ const value = row.flags[flag];
88
+ if (value <= 0) {
89
+ return "0";
90
+ }
91
+ const label = String(value);
92
+ const href = options.getReportFilterHref?.(flag, row);
93
+ return href ? link(label, href) : label;
94
+ };
95
+ const renderTable = (rows, includeResolutions, options) => {
87
96
  const headers = [
88
97
  "&nbsp;&nbsp;&nbsp;&nbsp;",
89
- "Scope",
98
+ "Environment",
90
99
  "Duration",
91
100
  "Stats",
92
101
  ...(includeResolutions ? ["Resolutions"] : []),
@@ -101,14 +110,31 @@ const renderTable = (rows, includeResolutions) => {
101
110
  tableCell(formatDuration(row.duration)),
102
111
  formatStats(row.stats),
103
112
  ...(includeResolutions ? [formatResolutions(row.resolutions)] : []),
104
- tableCell(formatFlag(row.flags.new)),
105
- tableCell(formatFlag(row.flags.flaky)),
106
- tableCell(formatFlag(row.flags.retry)),
113
+ formatFlag("new", row, options),
114
+ formatFlag("flaky", row, options),
115
+ formatFlag("retry", row, options),
107
116
  ].join(" | "));
108
117
  return [`| ${headers.join(" | ")} |`, `| ${divider.join(" | ")} |`, ...body.map((row) => `| ${row} |`)].join("\n");
109
118
  };
110
119
  const reportHref = (report) => report.remoteHref ?? report.href;
111
120
  const reportLabel = (report) => report.plugin ?? report.name;
121
+ const isAwesomeReport = (report) => report?.pluginId?.toLowerCase() === "awesome" || report?.plugin?.toLowerCase() === "awesome";
122
+ const appendReportFilter = (href, filter) => {
123
+ const hashIndex = href.indexOf("#");
124
+ const base = hashIndex === -1 ? href : href.slice(0, hashIndex);
125
+ const hash = hashIndex === -1 ? "" : href.slice(hashIndex);
126
+ const separator = base.includes("?") ? "&" : "?";
127
+ const query = filter === "new" ? "transition=new" : `${filter}=true`;
128
+ return `${base}${separator}${query}${hash}`;
129
+ };
130
+ const createDefaultReportFilterHref = (reports) => {
131
+ const defaultReport = reports.find(isAwesomeReport);
132
+ return (filter, row) => {
133
+ const report = isAwesomeReport(row.report) ? row.report : defaultReport;
134
+ const href = report ? reportHref(report) : undefined;
135
+ return href ? appendReportFilter(href, filter) : undefined;
136
+ };
137
+ };
112
138
  const reportLinkKind = (report) => report.plugin?.toLowerCase() === "testops" ? "testops" : "report";
113
139
  const toReportLink = (report) => {
114
140
  const href = reportHref(report);
@@ -151,43 +177,46 @@ const pluginSummaryToResolutions = (report) => ({
151
177
  muted: report.stats.resolutions?.muted ?? 0,
152
178
  accepted: report.stats.resolutions?.accepted ?? 0,
153
179
  });
154
- const renderFilteredReports = (reports) => {
180
+ const renderFilteredReports = (reports, options) => {
155
181
  const rows = reports.map((report) => ({
182
+ kind: "report",
156
183
  name: report.name,
157
184
  duration: report.duration,
158
185
  stats: pluginSummaryToStatusStats(report),
159
186
  flags: pluginSummaryToFlags(report),
160
187
  resolutions: pluginSummaryToResolutions(report),
188
+ report,
161
189
  }));
162
190
  if (!rows.length) {
163
191
  return undefined;
164
192
  }
165
193
  const includeResolutions = rows.some(({ resolutions }) => resolutions && hasResolutions(resolutions));
166
194
  const links = renderReportLinks(reports);
167
- return ["**Filtered Reports**", renderTable(rows, includeResolutions), ...links].join("\n\n");
195
+ return ["**Filtered Reports**", renderTable(rows, includeResolutions, options), ...links].join("\n\n");
168
196
  };
169
197
  const renderArtifacts = (artifacts) => {
170
198
  if (!artifacts.length) {
171
199
  return undefined;
172
200
  }
173
- const rows = artifacts.map(({ name, path }) => `| ${tableCell(name)} | ${tableCell(path)} |`);
174
- return [
175
- `<details>`,
176
- `<summary>Artifacts used (${artifacts.length})</summary>`,
177
- "",
178
- "| Name | Path |",
179
- "| --- | --- |",
180
- ...rows,
181
- "",
182
- "</details>",
183
- ].join("\n");
201
+ const rows = artifacts.map(({ name, path }) => {
202
+ const normalizedPath = path.replaceAll("\\", "/");
203
+ const isDuplicateName = name === path || normalizedPath.endsWith(`/${name}`);
204
+ const label = isDuplicateName ? inlineCode(path) : `${inlineCode(name)} &mdash; ${inlineCode(path)}`;
205
+ return `- ${label}`;
206
+ });
207
+ return [`<details>`, `<summary>Artifacts used (${artifacts.length})</summary>`, "", ...rows, "", "</details>"].join("\n");
184
208
  };
185
209
  export const renderReportSummaryMarkdown = (context, options = {}) => {
186
210
  const { title = "Allure Report Summary", includeArtifacts = true } = options;
187
211
  const regularReports = context.reports.filter((report) => report.filtered !== true);
188
212
  const filteredReports = context.reports.filter((report) => report.filtered === true);
213
+ const renderOptions = {
214
+ ...options,
215
+ getReportFilterHref: options.getReportFilterHref ?? createDefaultReportFilterHref(regularReports),
216
+ };
189
217
  const aggregateRows = [
190
218
  {
219
+ kind: "total",
191
220
  name: "All tests",
192
221
  duration: context.totals.duration,
193
222
  stats: context.totals.stats,
@@ -195,6 +224,7 @@ export const renderReportSummaryMarkdown = (context, options = {}) => {
195
224
  resolutions: context.totals.resolutions,
196
225
  },
197
226
  ...context.environments.map((environment) => ({
227
+ kind: "environment",
198
228
  name: environment.name,
199
229
  duration: environment.duration,
200
230
  stats: environment.stats,
@@ -204,9 +234,9 @@ export const renderReportSummaryMarkdown = (context, options = {}) => {
204
234
  const includeResolutions = hasResolutions(context.totals.resolutions);
205
235
  const sections = [
206
236
  `# ${escapeHtml(title)}`,
207
- renderTable(aggregateRows, includeResolutions),
237
+ renderTable(aggregateRows, includeResolutions, renderOptions),
208
238
  ...renderReportLinks(regularReports),
209
- renderFilteredReports(filteredReports),
239
+ renderFilteredReports(filteredReports, renderOptions),
210
240
  includeArtifacts ? renderArtifacts(context.artifacts) : undefined,
211
241
  ].filter((section) => Boolean(section));
212
242
  return `${sections.join("\n\n")}\n`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@allurereport/ci",
3
- "version": "3.18.0",
3
+ "version": "3.19.1",
4
4
  "description": "Utilities set to get useful report data from different CI",
5
5
  "keywords": [
6
6
  "allure",
@@ -32,14 +32,15 @@
32
32
  "lint:fix": "oxlint --import-plugin --fix src test features stories"
33
33
  },
34
34
  "dependencies": {
35
- "@allurereport/core-api": "3.18.0",
36
- "@allurereport/plugin-api": "3.18.0",
35
+ "@allurereport/core-api": "3.19.1",
36
+ "@allurereport/plugin-api": "3.19.1",
37
+ "axios": "^1.20.0",
37
38
  "glob": "^13.0.6"
38
39
  },
39
40
  "devDependencies": {
40
41
  "@types/node": "^20",
41
42
  "@vitest/runner": "^2.1.8",
42
- "@vitest/snapshot": "^2.1.8",
43
+ "@vitest/snapshot": "^5.0.1",
43
44
  "allure-js-commons": "^3",
44
45
  "allure-vitest": "^3",
45
46
  "rimraf": "^6",