@uxf/scripts 11.123.0 → 11.124.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +4 -5
- package/src/cli-args.js +43 -0
- package/src/cli-args.test.js +61 -0
- package/src/{GitLab.js → gitlab.js} +43 -21
- package/src/gitlab.test.js +207 -0
- package/src/{GoogleChat.js → google-chat.js} +10 -12
- package/src/google-chat.test.js +95 -0
- package/src/http.js +86 -0
- package/src/http.test.js +155 -0
- package/src/sitemap.js +80 -0
- package/src/sitemap.test.js +64 -0
- package/src/{Slack.js → slack.js} +9 -8
- package/src/slack.test.js +81 -0
- package/src/uxf-i18n-namespaces-gen/dependency-tree.js +102 -0
- package/src/uxf-i18n-namespaces-gen/dependency-tree.test.js +46 -0
- package/src/uxf-i18n-namespaces-gen/index.js +62 -44
- package/src/uxf-i18n-namespaces-gen/index.test.js +32 -1
- package/src/uxf-merge-requests-notifier/cli.js +9 -12
- package/src/uxf-merge-requests-notifier/index.js +44 -10
- package/src/uxf-merge-requests-notifier/index.test.js +103 -0
- package/src/uxf-push-notifier/cli.js +14 -13
- package/src/uxf-push-notifier/index.js +32 -23
- package/src/uxf-release/index.js +3 -3
- package/src/uxf-sitemap-check/index.js +6 -2
- package/src/uxf-sitemap-check/index.test.js +2 -2
- package/src/uxf-sitemap-meta-export/index.js +3 -3
- package/src/Logger.js +0 -12
- package/src/Sitemap.js +0 -60
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ Most binaries are already available in the UXF Docker images, so CI jobs can cal
|
|
|
19
19
|
yarn add -D @uxf/scripts
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
-
Requires **Node.js >= 24** (`engines.node`). No peer dependencies. Runtime dependencies (`axios`, `cheerio`, `dayjs`, `fast-glob`, `got`, `
|
|
22
|
+
Requires **Node.js >= 24** (`engines.node`). No peer dependencies. Runtime dependencies (`axios`, `cheerio`, `dayjs`, `fast-glob`, `got`, `rev-dep`, `robots-txt-parser`, `semver`, `yaml`, `yargs`) are bundled.
|
|
23
23
|
|
|
24
24
|
## Commands
|
|
25
25
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uxf/scripts",
|
|
3
|
-
"version": "11.
|
|
3
|
+
"version": "11.124.0",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -30,12 +30,11 @@
|
|
|
30
30
|
"node": ">=24"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"axios": "^1.19.0",
|
|
34
33
|
"cheerio": "1.2.0",
|
|
35
34
|
"dayjs": "^1.11.21",
|
|
36
35
|
"fast-glob": "3.3.3",
|
|
37
36
|
"got": "15.1.0",
|
|
38
|
-
"
|
|
37
|
+
"rev-dep": "3.0.0",
|
|
39
38
|
"robots-txt-parser": "2.0.3",
|
|
40
39
|
"semver": "^7.8.5",
|
|
41
40
|
"yaml": "2.9.0",
|
|
@@ -46,7 +45,7 @@
|
|
|
46
45
|
"@types/react": "18.3.31",
|
|
47
46
|
"@types/react-dom": "18.3.7",
|
|
48
47
|
"@types/semver": "^7.7.1",
|
|
49
|
-
"@uxf/core": "11.
|
|
50
|
-
"@uxf/ui": "11.
|
|
48
|
+
"@uxf/core": "11.124.0",
|
|
49
|
+
"@uxf/ui": "11.124.0"
|
|
51
50
|
}
|
|
52
51
|
}
|
package/src/cli-args.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drobná náhrada yargsu pro notifiery, které musí běžet v CI bez `npm install`
|
|
3
|
+
* — viz `docs/decisions/package-manager.md`. Ostatní skripty v balíčku yargs
|
|
4
|
+
* používají dál; tenhle helper řeší jen `--help` a jednu hodnotovou option.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @param {string[]} args
|
|
9
|
+
* @param {string[]} names např. `["-h", "--help"]`
|
|
10
|
+
*/
|
|
11
|
+
function hasFlag(args, names) {
|
|
12
|
+
return args.some((arg) => names.includes(arg));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Přečte hodnotu option ve tvaru `--name value`, `--name=value`, `-n value` i `-n=value`.
|
|
17
|
+
*
|
|
18
|
+
* @param {string[]} args
|
|
19
|
+
* @param {string[]} names např. `["-g", "--google-chat-webhook-url"]`
|
|
20
|
+
* @returns {string | undefined}
|
|
21
|
+
*/
|
|
22
|
+
function readOption(args, names) {
|
|
23
|
+
for (let index = 0; index < args.length; index++) {
|
|
24
|
+
const arg = args[index];
|
|
25
|
+
|
|
26
|
+
if (names.includes(arg)) {
|
|
27
|
+
return args[index + 1];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const matched = names.find((name) => arg.startsWith(`${name}=`));
|
|
31
|
+
|
|
32
|
+
if (matched) {
|
|
33
|
+
return arg.slice(matched.length + 1);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
module.exports = {
|
|
41
|
+
hasFlag,
|
|
42
|
+
readOption,
|
|
43
|
+
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
const { hasFlag, readOption } = require("./cli-args");
|
|
2
|
+
|
|
3
|
+
describe("hasFlag", () => {
|
|
4
|
+
it("finds the long form", () => {
|
|
5
|
+
expect(hasFlag(["--help"], ["-h", "--help"])).toBe(true);
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
it("finds the short form", () => {
|
|
9
|
+
expect(hasFlag(["-h"], ["-h", "--help"])).toBe(true);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("finds the flag among other arguments", () => {
|
|
13
|
+
expect(hasFlag(["-g", "https://x", "--help"], ["-h", "--help"])).toBe(true);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("returns false when absent", () => {
|
|
17
|
+
expect(hasFlag(["-g", "https://x"], ["-h", "--help"])).toBe(false);
|
|
18
|
+
expect(hasFlag([], ["-h", "--help"])).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("does not match a flag that is only a prefix", () => {
|
|
22
|
+
expect(hasFlag(["--help-me"], ["-h", "--help"])).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("readOption", () => {
|
|
27
|
+
const NAMES = ["-g", "--google-chat-webhook-url"];
|
|
28
|
+
|
|
29
|
+
it("reads `--name value`", () => {
|
|
30
|
+
expect(readOption(["--google-chat-webhook-url", "https://x"], NAMES)).toBe("https://x");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("reads `--name=value` — the form used in .gitlab-ci.yml", () => {
|
|
34
|
+
expect(readOption(["--google-chat-webhook-url=https://x"], NAMES)).toBe("https://x");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("reads `-n value`", () => {
|
|
38
|
+
expect(readOption(["-g", "https://x"], NAMES)).toBe("https://x");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("reads `-n=value`", () => {
|
|
42
|
+
expect(readOption(["-g=https://x"], NAMES)).toBe("https://x");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("keeps `=` inside the value", () => {
|
|
46
|
+
expect(readOption(["--google-chat-webhook-url=https://x?key=abc"], NAMES)).toBe("https://x?key=abc");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("finds the option among other arguments", () => {
|
|
50
|
+
expect(readOption(["--verbose", "-g", "https://x", "--other"], NAMES)).toBe("https://x");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("returns undefined when the option is missing", () => {
|
|
54
|
+
expect(readOption(["--verbose"], NAMES)).toBeUndefined();
|
|
55
|
+
expect(readOption([], NAMES)).toBeUndefined();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("returns undefined when the option is last and has no value", () => {
|
|
59
|
+
expect(readOption(["-g"], NAMES)).toBeUndefined();
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -1,24 +1,41 @@
|
|
|
1
1
|
const { env } = require("process");
|
|
2
|
-
const {
|
|
3
|
-
const dayjs = require("dayjs");
|
|
2
|
+
const { request } = require("./http");
|
|
4
3
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
4
|
+
/**
|
|
5
|
+
* @param {string} url
|
|
6
|
+
* @param {{method?: string, headers?: Record<string, string>, params?: Record<string, any>, body?: any}} options
|
|
7
|
+
*/
|
|
8
|
+
function gitlabRequest(url, options = {}) {
|
|
9
|
+
return request(url, {
|
|
10
|
+
...options,
|
|
11
|
+
baseUrl: `${env.CI_SERVER_URL}/api/v4`,
|
|
12
|
+
headers: { Authorization: `Bearer ${env.GITLAB_TOKEN}`, ...options.headers },
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Lokální čas, stejně jako `dayjs().format(...)`, které tohle nahradilo. */
|
|
17
|
+
function formatTagTimestamp(date = new Date()) {
|
|
18
|
+
const pad = (value) => String(value).padStart(2, "0");
|
|
19
|
+
|
|
20
|
+
return [
|
|
21
|
+
date.getFullYear(),
|
|
22
|
+
pad(date.getMonth() + 1),
|
|
23
|
+
pad(date.getDate()),
|
|
24
|
+
pad(date.getHours()),
|
|
25
|
+
pad(date.getMinutes()),
|
|
26
|
+
].join("-");
|
|
27
|
+
}
|
|
11
28
|
|
|
12
29
|
/**
|
|
13
30
|
*
|
|
14
31
|
* @param {string} url
|
|
15
|
-
* @param {
|
|
32
|
+
* @param {{params?: Record<string, any>}} config
|
|
16
33
|
*/
|
|
17
34
|
async function getAll(url, config) {
|
|
18
35
|
let nextPage = "1";
|
|
19
36
|
const data = [];
|
|
20
37
|
do {
|
|
21
|
-
const response = await
|
|
38
|
+
const response = await gitlabRequest(url, {
|
|
22
39
|
...config,
|
|
23
40
|
params: {
|
|
24
41
|
...config.params,
|
|
@@ -26,7 +43,7 @@ async function getAll(url, config) {
|
|
|
26
43
|
page: nextPage,
|
|
27
44
|
},
|
|
28
45
|
});
|
|
29
|
-
nextPage = response.headers
|
|
46
|
+
nextPage = response.headers.get("x-next-page");
|
|
30
47
|
data.push(...response.data);
|
|
31
48
|
} while (nextPage);
|
|
32
49
|
|
|
@@ -38,15 +55,15 @@ async function loadCommits(from, ref_name = "master") {
|
|
|
38
55
|
|
|
39
56
|
let nextPage = "1";
|
|
40
57
|
do {
|
|
41
|
-
const response = await
|
|
58
|
+
const response = await gitlabRequest(`/projects/${env.CI_PROJECT_ID}/repository/commits`, {
|
|
42
59
|
params: {
|
|
43
60
|
ref_name,
|
|
44
|
-
since: from ?
|
|
61
|
+
since: from ? new Date(from).toISOString() : undefined,
|
|
45
62
|
per_page: "100",
|
|
46
63
|
page: nextPage,
|
|
47
64
|
},
|
|
48
65
|
});
|
|
49
|
-
nextPage = response.headers
|
|
66
|
+
nextPage = response.headers.get("x-next-page");
|
|
50
67
|
commits.push(...response.data);
|
|
51
68
|
} while (nextPage);
|
|
52
69
|
|
|
@@ -61,7 +78,7 @@ async function findMigrationFiles(commits) {
|
|
|
61
78
|
await Promise.all(
|
|
62
79
|
commits.map(async (commit) => {
|
|
63
80
|
try {
|
|
64
|
-
const { data: diffs } = await
|
|
81
|
+
const { data: diffs } = await gitlabRequest(
|
|
65
82
|
`/projects/${env.CI_PROJECT_ID}/repository/commits/${commit.id}/diff`,
|
|
66
83
|
);
|
|
67
84
|
diffs.forEach(({ new_path }) => {
|
|
@@ -81,7 +98,7 @@ async function findMigrationFiles(commits) {
|
|
|
81
98
|
}
|
|
82
99
|
|
|
83
100
|
async function getLastTag(tagPrefix = "release-", ref_name = "master") {
|
|
84
|
-
const { data: commits } = await
|
|
101
|
+
const { data: commits } = await gitlabRequest(`/projects/${env.CI_PROJECT_ID}/repository/commits`, {
|
|
85
102
|
params: {
|
|
86
103
|
ref_name,
|
|
87
104
|
per_page: "100",
|
|
@@ -90,9 +107,11 @@ async function getLastTag(tagPrefix = "release-", ref_name = "master") {
|
|
|
90
107
|
|
|
91
108
|
const commitIds = commits.map((commit) => commit.id);
|
|
92
109
|
|
|
93
|
-
|
|
110
|
+
// Pozor: `per_page` tu záměrně chybí. V axios verzi bylo omylem mimo `params`,
|
|
111
|
+
// takže se na GitLab nikdy neposlalo a platí default (20 tagů). Doplnit ho by
|
|
112
|
+
// změnilo, který tag se najde, což je zásah do release flow — řešit zvlášť.
|
|
113
|
+
const { data: tags } = await gitlabRequest(`/projects/${env.CI_PROJECT_ID}/repository/tags`, {
|
|
94
114
|
params: { search: `^${tagPrefix}` },
|
|
95
|
-
per_page: "100",
|
|
96
115
|
});
|
|
97
116
|
|
|
98
117
|
const tag = tags.find((tag) => commitIds.includes(tag.commit.id)) ?? null;
|
|
@@ -107,20 +126,23 @@ async function getLastTag(tagPrefix = "release-", ref_name = "master") {
|
|
|
107
126
|
}
|
|
108
127
|
|
|
109
128
|
async function createRelease(description, tagPrefix = "release-", dryRun = false) {
|
|
110
|
-
const tag = tagPrefix +
|
|
129
|
+
const tag = tagPrefix + formatTagTimestamp();
|
|
111
130
|
|
|
112
131
|
if (dryRun) {
|
|
113
132
|
console.log(`\n🎉🎉🎉 Release "${tag}" published (skipped in dry run)\n\n${description}`);
|
|
114
133
|
return;
|
|
115
134
|
}
|
|
116
135
|
|
|
117
|
-
await
|
|
136
|
+
await gitlabRequest(`/projects/${env.CI_PROJECT_ID}/releases`, {
|
|
137
|
+
method: "POST",
|
|
138
|
+
body: { description, tag_name: tag, ref: "master" },
|
|
139
|
+
});
|
|
118
140
|
|
|
119
141
|
console.log(`\n🎉🎉🎉 Release "${tag}" published\n\n${description}`);
|
|
120
142
|
}
|
|
121
143
|
|
|
122
144
|
function getSingleMergeRequestChanges(projectId, mr_iid) {
|
|
123
|
-
return
|
|
145
|
+
return gitlabRequest(`/projects/${projectId}/merge_requests/${mr_iid}/changes`).then((r) => r.data);
|
|
124
146
|
}
|
|
125
147
|
|
|
126
148
|
/**
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
const GitLab = require("./gitlab");
|
|
2
|
+
|
|
3
|
+
const originalFetch = globalThis.fetch;
|
|
4
|
+
const { CI_SERVER_URL, GITLAB_TOKEN, CI_PROJECT_ID } = process.env;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {Array<{body: any, nextPage?: string, status?: number}>} pages
|
|
8
|
+
* odpovědi v pořadí, v jakém je fetch vrátí
|
|
9
|
+
*/
|
|
10
|
+
function stubFetch(pages) {
|
|
11
|
+
const calls = [];
|
|
12
|
+
let index = 0;
|
|
13
|
+
|
|
14
|
+
globalThis.fetch = async (url, init) => {
|
|
15
|
+
calls.push({ url, init });
|
|
16
|
+
const page = pages[Math.min(index++, pages.length - 1)];
|
|
17
|
+
const status = page.status ?? 200;
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
ok: status >= 200 && status < 300,
|
|
21
|
+
status,
|
|
22
|
+
headers: {
|
|
23
|
+
get: (name) => {
|
|
24
|
+
if (name === "x-next-page") {
|
|
25
|
+
return page.nextPage ?? "";
|
|
26
|
+
}
|
|
27
|
+
return name.toLowerCase() === "content-type" ? "application/json" : null;
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
text: async () => JSON.stringify(page.body),
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
return calls;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
process.env.CI_SERVER_URL = "https://git.test";
|
|
39
|
+
process.env.GITLAB_TOKEN = "test-token";
|
|
40
|
+
process.env.CI_PROJECT_ID = "42";
|
|
41
|
+
jest.spyOn(console, "log").mockImplementation(() => {});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
globalThis.fetch = originalFetch;
|
|
46
|
+
process.env.CI_SERVER_URL = CI_SERVER_URL;
|
|
47
|
+
process.env.GITLAB_TOKEN = GITLAB_TOKEN;
|
|
48
|
+
process.env.CI_PROJECT_ID = CI_PROJECT_ID;
|
|
49
|
+
jest.restoreAllMocks();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("authentication and base url", () => {
|
|
53
|
+
it("prefixes the API path and sends the bearer token", async () => {
|
|
54
|
+
const calls = stubFetch([{ body: [] }]);
|
|
55
|
+
|
|
56
|
+
await GitLab.getAllProjects();
|
|
57
|
+
|
|
58
|
+
expect(calls[0].url).toContain("https://git.test/api/v4/projects");
|
|
59
|
+
expect(calls[0].init.headers.Authorization).toBe("Bearer test-token");
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("getAll pagination", () => {
|
|
64
|
+
it("follows x-next-page until it is empty and concatenates the pages", async () => {
|
|
65
|
+
const calls = stubFetch([
|
|
66
|
+
{ body: [{ id: 1 }], nextPage: "2" },
|
|
67
|
+
{ body: [{ id: 2 }], nextPage: "3" },
|
|
68
|
+
{ body: [{ id: 3 }], nextPage: "" },
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
const projects = await GitLab.getAllProjects();
|
|
72
|
+
|
|
73
|
+
expect(projects).toEqual([{ id: 1 }, { id: 2 }, { id: 3 }]);
|
|
74
|
+
expect(calls).toHaveLength(3);
|
|
75
|
+
expect(calls[0].url).toContain("page=1");
|
|
76
|
+
expect(calls[1].url).toContain("page=2");
|
|
77
|
+
expect(calls[2].url).toContain("page=3");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("stops after a single page when there is no next page", async () => {
|
|
81
|
+
const calls = stubFetch([{ body: [{ id: 1 }] }]);
|
|
82
|
+
|
|
83
|
+
await GitLab.getAllProjects();
|
|
84
|
+
|
|
85
|
+
expect(calls).toHaveLength(1);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("always asks for 100 items per page", async () => {
|
|
89
|
+
const calls = stubFetch([{ body: [] }]);
|
|
90
|
+
|
|
91
|
+
await GitLab.getAllProjects();
|
|
92
|
+
|
|
93
|
+
expect(calls[0].url).toContain("per_page=100");
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("getAllMergeRequests", () => {
|
|
98
|
+
it("excludes work in progress by default", async () => {
|
|
99
|
+
const calls = stubFetch([{ body: [] }]);
|
|
100
|
+
|
|
101
|
+
await GitLab.getAllMergeRequests(false);
|
|
102
|
+
|
|
103
|
+
expect(calls[0].url).toContain("wip=no");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("omits the wip filter when work in progress is included", async () => {
|
|
107
|
+
const calls = stubFetch([{ body: [] }]);
|
|
108
|
+
|
|
109
|
+
await GitLab.getAllMergeRequests(true);
|
|
110
|
+
|
|
111
|
+
expect(calls[0].url).not.toContain("wip=");
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("loadCommits", () => {
|
|
116
|
+
it("converts the `from` timestamp to ISO for the `since` parameter", async () => {
|
|
117
|
+
const calls = stubFetch([{ body: [] }]);
|
|
118
|
+
|
|
119
|
+
await GitLab.loadCommits("2026-01-02T03:04:05.000Z");
|
|
120
|
+
|
|
121
|
+
expect(decodeURIComponent(calls[0].url)).toContain("since=2026-01-02T03:04:05.000Z");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("omits `since` when there is no starting point", async () => {
|
|
125
|
+
const calls = stubFetch([{ body: [] }]);
|
|
126
|
+
|
|
127
|
+
await GitLab.loadCommits(null);
|
|
128
|
+
|
|
129
|
+
expect(calls[0].url).not.toContain("since=");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("drops the boundary commit so the last release is not counted twice", async () => {
|
|
133
|
+
const from = "2026-01-02T03:04:05.000Z";
|
|
134
|
+
stubFetch([
|
|
135
|
+
{
|
|
136
|
+
body: [
|
|
137
|
+
{ id: "a", created_at: from },
|
|
138
|
+
{ id: "b", created_at: "2026-01-03T00:00:00.000Z" },
|
|
139
|
+
],
|
|
140
|
+
},
|
|
141
|
+
]);
|
|
142
|
+
|
|
143
|
+
const commits = await GitLab.loadCommits(from);
|
|
144
|
+
|
|
145
|
+
expect(commits.map((c) => c.id)).toEqual(["b"]);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("uses the requested ref", async () => {
|
|
149
|
+
const calls = stubFetch([{ body: [] }]);
|
|
150
|
+
|
|
151
|
+
await GitLab.loadCommits(null, "develop");
|
|
152
|
+
|
|
153
|
+
expect(calls[0].url).toContain("ref_name=develop");
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("getSingleMergeRequestChanges", () => {
|
|
158
|
+
it("returns the response body", async () => {
|
|
159
|
+
stubFetch([{ body: { changes_count: "7" } }]);
|
|
160
|
+
|
|
161
|
+
await expect(GitLab.getSingleMergeRequestChanges(1, 2)).resolves.toEqual({ changes_count: "7" });
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe("createRelease", () => {
|
|
166
|
+
it("builds a tag from the prefix and a local timestamp", async () => {
|
|
167
|
+
const calls = stubFetch([{ body: {} }]);
|
|
168
|
+
|
|
169
|
+
await GitLab.createRelease("poznámky", "release-");
|
|
170
|
+
|
|
171
|
+
expect(calls[0].init.method).toBe("POST");
|
|
172
|
+
expect(JSON.parse(calls[0].init.body)).toEqual({
|
|
173
|
+
description: "poznámky",
|
|
174
|
+
tag_name: expect.stringMatching(/^release-\d{4}-\d{2}-\d{2}-\d{2}-\d{2}$/),
|
|
175
|
+
ref: "master",
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("sends nothing in dry run mode", async () => {
|
|
180
|
+
const calls = stubFetch([{ body: {} }]);
|
|
181
|
+
|
|
182
|
+
await GitLab.createRelease("poznámky", "release-", true);
|
|
183
|
+
|
|
184
|
+
expect(calls).toHaveLength(0);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe("findMigrationFiles", () => {
|
|
189
|
+
it("collects api migration files and ignores everything else", async () => {
|
|
190
|
+
stubFetch([
|
|
191
|
+
{
|
|
192
|
+
body: [{ new_path: "api/migrations/Version20260101120000.php" }, { new_path: "web/src/app.tsx" }],
|
|
193
|
+
},
|
|
194
|
+
]);
|
|
195
|
+
|
|
196
|
+
await expect(GitLab.findMigrationFiles([{ id: "abc" }])).resolves.toEqual([
|
|
197
|
+
"api/migrations/Version20260101120000.php",
|
|
198
|
+
]);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("survives a failing commit diff instead of rejecting the whole run", async () => {
|
|
202
|
+
jest.spyOn(console, "error").mockImplementation(() => {});
|
|
203
|
+
stubFetch([{ body: { message: "nope" }, status: 500 }]);
|
|
204
|
+
|
|
205
|
+
await expect(GitLab.findMigrationFiles([{ id: "abc" }])).resolves.toEqual([]);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
const { env } = require("process");
|
|
2
|
-
const {
|
|
3
|
-
|
|
4
|
-
const axios = create({});
|
|
2
|
+
const { request } = require("./http");
|
|
5
3
|
|
|
6
4
|
/**
|
|
7
5
|
* @see https://developers.google.com/chat/api/reference/rest/v1/cards-v1
|
|
@@ -16,7 +14,7 @@ async function chatPostMessage(data, config = {}) {
|
|
|
16
14
|
|
|
17
15
|
if (webhookUrl && !config.dryRun) {
|
|
18
16
|
try {
|
|
19
|
-
await
|
|
17
|
+
await request(webhookUrl, { method: "POST", body: { ...data } });
|
|
20
18
|
} catch (error) {
|
|
21
19
|
process.stderr.write("GOOGLE CHAT: chat.postMessage - error");
|
|
22
20
|
console.error(error);
|
|
@@ -30,16 +28,16 @@ async function chatPostMessage(data, config = {}) {
|
|
|
30
28
|
*
|
|
31
29
|
* @param cardsV2
|
|
32
30
|
* @param {{webhookUrl?: string}} config
|
|
33
|
-
* @returns {Promise<
|
|
31
|
+
* @returns {Promise<any>}
|
|
34
32
|
*/
|
|
35
33
|
async function messageCardsV2(cardsV2, config = {}) {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
34
|
+
const response = await request(config.webhookUrl ?? env.GOOGLE_WEBHOOK_URL, {
|
|
35
|
+
method: "POST",
|
|
36
|
+
headers: { "Content-Type": "application/json; charset=UTF-8" },
|
|
37
|
+
body: { cardsV2 },
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
return response.data;
|
|
43
41
|
}
|
|
44
42
|
|
|
45
43
|
module.exports = {
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
const GoogleChat = require("./google-chat");
|
|
2
|
+
|
|
3
|
+
const originalFetch = globalThis.fetch;
|
|
4
|
+
const { GOOGLE_WEBHOOK_URL } = process.env;
|
|
5
|
+
|
|
6
|
+
function stubFetch({ status = 200, payload = {} } = {}) {
|
|
7
|
+
const calls = [];
|
|
8
|
+
globalThis.fetch = async (url, init) => {
|
|
9
|
+
calls.push({ url, init });
|
|
10
|
+
return {
|
|
11
|
+
ok: status >= 200 && status < 300,
|
|
12
|
+
status,
|
|
13
|
+
headers: { get: () => "application/json" },
|
|
14
|
+
text: async () => JSON.stringify(payload),
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
return calls;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
process.env.GOOGLE_WEBHOOK_URL = "https://chat.test/hook";
|
|
22
|
+
jest.spyOn(process.stdout, "write").mockImplementation(() => true);
|
|
23
|
+
jest.spyOn(process.stderr, "write").mockImplementation(() => true);
|
|
24
|
+
jest.spyOn(console, "error").mockImplementation(() => {});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
globalThis.fetch = originalFetch;
|
|
29
|
+
process.env.GOOGLE_WEBHOOK_URL = GOOGLE_WEBHOOK_URL;
|
|
30
|
+
jest.restoreAllMocks();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("chatPostMessage", () => {
|
|
34
|
+
it("posts the payload to the webhook from the environment", async () => {
|
|
35
|
+
const calls = stubFetch();
|
|
36
|
+
|
|
37
|
+
await GoogleChat.chatPostMessage({ text: "ahoj" });
|
|
38
|
+
|
|
39
|
+
expect(calls[0].url).toBe("https://chat.test/hook");
|
|
40
|
+
expect(calls[0].init.method).toBe("POST");
|
|
41
|
+
expect(JSON.parse(calls[0].init.body)).toEqual({ text: "ahoj" });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("prefers an explicitly configured webhook url", async () => {
|
|
45
|
+
const calls = stubFetch();
|
|
46
|
+
|
|
47
|
+
await GoogleChat.chatPostMessage({ text: "ahoj" }, { webhookUrl: "https://other.test/hook" });
|
|
48
|
+
|
|
49
|
+
expect(calls[0].url).toBe("https://other.test/hook");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("skips in dry run mode", async () => {
|
|
53
|
+
const calls = stubFetch();
|
|
54
|
+
|
|
55
|
+
await GoogleChat.chatPostMessage({ text: "ahoj" }, { dryRun: true });
|
|
56
|
+
|
|
57
|
+
expect(calls).toHaveLength(0);
|
|
58
|
+
expect(process.stdout.write).toHaveBeenCalledWith("GOOGLE CHAT: chat.postMessage - skipped");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("skips when no webhook url is configured", async () => {
|
|
62
|
+
delete process.env.GOOGLE_WEBHOOK_URL;
|
|
63
|
+
const calls = stubFetch();
|
|
64
|
+
|
|
65
|
+
await GoogleChat.chatPostMessage({ text: "ahoj" });
|
|
66
|
+
|
|
67
|
+
expect(calls).toHaveLength(0);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Notifiery posílají desítky zpráv za sebou; jedna neúspěšná nesmí shodit celý běh.
|
|
71
|
+
it("swallows a failed request instead of rejecting", async () => {
|
|
72
|
+
stubFetch({ status: 500 });
|
|
73
|
+
|
|
74
|
+
await expect(GoogleChat.chatPostMessage({ text: "ahoj" })).resolves.toBeUndefined();
|
|
75
|
+
expect(process.stderr.write).toHaveBeenCalledWith("GOOGLE CHAT: chat.postMessage - error");
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("messageCardsV2", () => {
|
|
80
|
+
it("wraps the cards and sends the charset content type", async () => {
|
|
81
|
+
const calls = stubFetch();
|
|
82
|
+
|
|
83
|
+
await GoogleChat.messageCardsV2([{ cardId: "mr-1" }]);
|
|
84
|
+
|
|
85
|
+
expect(JSON.parse(calls[0].init.body)).toEqual({ cardsV2: [{ cardId: "mr-1" }] });
|
|
86
|
+
expect(calls[0].init.headers["Content-Type"]).toBe("application/json; charset=UTF-8");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Na rozdíl od chatPostMessage tady chyba probublá — volající ji má řešit.
|
|
90
|
+
it("rejects on a failed request", async () => {
|
|
91
|
+
stubFetch({ status: 500 });
|
|
92
|
+
|
|
93
|
+
await expect(GoogleChat.messageCardsV2([])).rejects.toThrow("Request failed with status code 500");
|
|
94
|
+
});
|
|
95
|
+
});
|