@uxf/scripts 11.122.5 → 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.
Files changed (36) hide show
  1. package/README.md +1 -38
  2. package/package.json +12 -16
  3. package/src/cli-args.js +43 -0
  4. package/src/cli-args.test.js +61 -0
  5. package/src/{GitLab.js → gitlab.js} +43 -21
  6. package/src/gitlab.test.js +207 -0
  7. package/src/{GoogleChat.js → google-chat.js} +10 -12
  8. package/src/google-chat.test.js +95 -0
  9. package/src/http.js +86 -0
  10. package/src/http.test.js +155 -0
  11. package/src/sitemap.js +80 -0
  12. package/src/sitemap.test.js +64 -0
  13. package/src/{Slack.js → slack.js} +9 -8
  14. package/src/slack.test.js +81 -0
  15. package/src/uxf-i18n-namespaces-gen/dependency-tree.js +102 -0
  16. package/src/uxf-i18n-namespaces-gen/dependency-tree.test.js +46 -0
  17. package/src/uxf-i18n-namespaces-gen/index.js +62 -44
  18. package/src/uxf-i18n-namespaces-gen/index.test.js +32 -1
  19. package/src/uxf-merge-requests-notifier/cli.js +9 -12
  20. package/src/uxf-merge-requests-notifier/index.js +44 -10
  21. package/src/uxf-merge-requests-notifier/index.test.js +103 -0
  22. package/src/uxf-push-notifier/cli.js +14 -13
  23. package/src/uxf-push-notifier/index.js +32 -23
  24. package/src/uxf-release/index.js +3 -3
  25. package/src/uxf-sitemap-check/index.js +6 -2
  26. package/src/uxf-sitemap-check/index.test.js +2 -2
  27. package/src/uxf-sitemap-meta-export/index.js +3 -3
  28. package/bin/uxf-lunch.js +0 -8
  29. package/bin/uxf-unused.js +0 -9
  30. package/src/Logger.js +0 -12
  31. package/src/Sitemap.js +0 -60
  32. package/src/shared/load-page-imports.js +0 -60
  33. package/src/uxf-lunch/cli.js +0 -44
  34. package/src/uxf-lunch/index.js +0 -50
  35. package/src/uxf-unused/cli.js +0 -30
  36. package/src/uxf-unused/index.js +0 -59
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`, `madge`, `robots-txt-parser`, `semver`, `yaml`, `yargs`) are bundled.
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
 
@@ -31,10 +31,8 @@ Requires **Node.js >= 24** (`engines.node`). No peer dependencies. Runtime depen
31
31
  | `uxf-audit` | Print a JSON report of Node / Yarn / Next / React and installed `@uxf/*` versions. |
32
32
  | `uxf-claude-sync` | Download global guidelines from a URL into a `CLAUDE.md` file. |
33
33
  | `uxf-i18n-namespaces-gen` | Generate an `i18n-pages.json` mapping routes to the translation namespaces they use. |
34
- | `uxf-unused` | Find (and optionally delete) unused files in a Next.js project. |
35
34
  | `uxf-sitemap-check` | Crawl a sitemap and check all URLs for HTTP errors, duplicates, and missing pages. |
36
35
  | `uxf-sitemap-meta-export` | Crawl a sitemap and export page meta tags (title, description, OG) to `export.csv`. |
37
- | `uxf-lunch` | Post today's lunch menu to a Slack channel. |
38
36
 
39
37
  Every command accepts `-h` / `--help`.
40
38
 
@@ -221,24 +219,6 @@ Options:
221
219
 
222
220
  Namespaces found on `/_app` are merged into the global `*` entry.
223
221
 
224
- ### uxf-unused
225
-
226
- Finds files in a Next.js project that are not reachable from the pages import graph, prints them, and (with `--removeFiles`) deletes them. Use `--debug` to dump intermediate JSON files (`uxf-unused-*.json`) into the working directory.
227
-
228
- ```
229
- Usage:
230
- uxf-unused [options]
231
-
232
- Options:
233
- -p, --pagesDirectory Glob of page entry points
234
- [default: "src/pages/**/*.(ts|tsx)"]
235
- -f, --allFilesDirectory Glob of all candidate files
236
- [default: "src/**/*.(ts|tsx)"]
237
- -r, --removeFiles Delete the unused files [boolean] [default: false]
238
- -d, --debug Write intermediate JSON files [boolean] [default: false]
239
- -h, --help Show help
240
- ```
241
-
242
222
  ### uxf-sitemap-check
243
223
 
244
224
  Crawls a sitemap and requests every URL, reporting HTTP errors, duplicate sitemap entries, and (optionally) indexable pages missing from the sitemap. Can also follow nested page links and images. Sends a summary to Google Chat when errors are found and exits with code 1.
@@ -286,28 +266,11 @@ Options:
286
266
  -h, --help Show help [boolean]
287
267
  ```
288
268
 
289
- ### uxf-lunch
290
-
291
- Fetches the daily menu (Hotel Radnice) and posts it as a formatted message to a Slack channel.
292
-
293
- ```
294
- Usage:
295
- uxf-lunch [options]
296
-
297
- Environment variables:
298
- SLACK_TOKEN - required
299
-
300
- Options:
301
- --slack-channel Slack channel (required) [string]
302
- -h, --help Show help
303
- ```
304
-
305
269
  ## Gotchas
306
270
 
307
271
  - **CLI-only, no importable API.** `main` references a `index.js` that is not shipped; use the `bin` commands.
308
272
  - **CI-oriented defaults.** `uxf-release`, `uxf-push-notifier`, and `uxf-sitemap-meta-export` expect GitLab CI / environment variables and mostly make sense inside a pipeline.
309
273
  - **`uxf-sitemap-check --check-missing`** only works together with `--with-nested`; used alone it exits with code 1.
310
- - **`uxf-unused --removeFiles` deletes files** on disk — run without it (or with `--debug`) first to review the list.
311
274
  - **`uxf-merge-requests-notifier`** exists in the source tree but is **not** exposed as a `bin` command; it is run internally via the package's `run:mr-notifier` npm script and is not installed for consumers.
312
275
 
313
276
  ## Links
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uxf/scripts",
3
- "version": "11.122.5",
3
+ "version": "11.124.0",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -8,18 +8,15 @@
8
8
  "uxf-claude-sync": "bin/uxf-claude-sync.js",
9
9
  "uxf-dependencies-check": "bin/uxf-dependencies-check.js",
10
10
  "uxf-i18n-namespaces-gen": "bin/uxf-i18n-namespaces-gen.js",
11
- "uxf-lunch": "bin/uxf-lunch.js",
12
11
  "uxf-push-notifier": "bin/uxf-push-notifier.js",
13
12
  "uxf-release": "bin/uxf-release.js",
14
13
  "uxf-sitemap-check": "bin/uxf-sitemap-check.js",
15
- "uxf-sitemap-meta-export": "bin/uxf-sitemap-meta-export.js",
16
- "uxf-unused": "bin/uxf-unused.js"
14
+ "uxf-sitemap-meta-export": "bin/uxf-sitemap-meta-export.js"
17
15
  },
18
16
  "scripts": {
19
17
  "build": "tsc -P tsconfig.build.json && chmod +x ./bin/*",
20
18
  "run:mr-notifier": "GOOGLE_WEBHOOK_URL=<<TODO>> GITLAB_TOKEN=<<TODO>> CI_SERVER_URL=https://gitlab.uxf.cz node ./bin/uxf-merge-requests-notifier.js",
21
- "test": "./node_modules/.bin/eslint ./bin",
22
- "typecheck": "tsc --noEmit -p tsconfig.json"
19
+ "typecheck": "tsc --noEmit"
23
20
  },
24
21
  "publishConfig": {
25
22
  "access": "public"
@@ -33,23 +30,22 @@
33
30
  "node": ">=24"
34
31
  },
35
32
  "dependencies": {
36
- "axios": "^1.13.4",
37
33
  "cheerio": "1.2.0",
38
- "dayjs": "^1.11.19",
34
+ "dayjs": "^1.11.21",
39
35
  "fast-glob": "3.3.3",
40
- "got": "14.6.6",
41
- "madge": "8.0.0",
36
+ "got": "15.1.0",
37
+ "rev-dep": "3.0.0",
42
38
  "robots-txt-parser": "2.0.3",
43
- "semver": "^7.7.3",
44
- "yaml": "2.8.2",
45
- "yargs": "18.0.0"
39
+ "semver": "^7.8.5",
40
+ "yaml": "2.9.0",
41
+ "yargs": "18.1.0"
46
42
  },
47
43
  "devDependencies": {
48
44
  "@types/node": "24",
49
- "@types/react": "18.3.27",
45
+ "@types/react": "18.3.31",
50
46
  "@types/react-dom": "18.3.7",
51
47
  "@types/semver": "^7.7.1",
52
- "@uxf/core": "11.114.0",
53
- "@uxf/ui": "11.122.5"
48
+ "@uxf/core": "11.124.0",
49
+ "@uxf/ui": "11.124.0"
54
50
  }
55
51
  }
@@ -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 { create } = require("axios");
3
- const dayjs = require("dayjs");
2
+ const { request } = require("./http");
4
3
 
5
- const axios = create({
6
- baseURL: `${env.CI_SERVER_URL}/api/v4`,
7
- headers: {
8
- Authorization: `Bearer ${env.GITLAB_TOKEN}`,
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 {axios.AxiosRequestConfig<any>} config
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 axios.get(url, {
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["x-next-page"];
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 axios.get(`/projects/${env.CI_PROJECT_ID}/repository/commits`, {
58
+ const response = await gitlabRequest(`/projects/${env.CI_PROJECT_ID}/repository/commits`, {
42
59
  params: {
43
60
  ref_name,
44
- since: from ? dayjs(from).toISOString() : undefined,
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["x-next-page"];
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 axios.get(
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 axios.get(`/projects/${env.CI_PROJECT_ID}/repository/commits`, {
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
- const { data: tags } = await axios.get(`/projects/${env.CI_PROJECT_ID}/repository/tags`, {
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 + dayjs().format("YYYY-MM-DD-HH-mm");
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 axios.post(`/projects/${env.CI_PROJECT_ID}/releases`, { description, tag_name: tag, ref: "master" });
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 axios.get(`/projects/${projectId}/merge_requests/${mr_iid}/changes`).then((r) => r.data);
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 { create } = require("axios");
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 axios.post(webhookUrl, { ...data });
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<axios.AxiosResponse<any>>}
31
+ * @returns {Promise<any>}
34
32
  */
35
33
  async function messageCardsV2(cardsV2, config = {}) {
36
- return axios
37
- .post(
38
- config.webhookUrl ?? env.GOOGLE_WEBHOOK_URL,
39
- { cardsV2 },
40
- { headers: { "Content-Type": "application/json; charset=UTF-8" } },
41
- )
42
- .then((r) => r.data);
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 = {