@uxf/scripts 11.123.0 → 11.125.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 +33 -33
- 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,20 +19,20 @@ 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 (`
|
|
22
|
+
Requires **Node.js >= 24** (`engines.node`). No peer dependencies. Runtime dependencies (`cheerio`, `dayjs`, `fast-glob`, `got`, `rev-dep`, `robots-txt-parser`, `semver`, `yaml`, `yargs`) are bundled.
|
|
23
23
|
|
|
24
24
|
## Commands
|
|
25
25
|
|
|
26
|
-
| Command
|
|
27
|
-
|
|
|
28
|
-
| `uxf-release`
|
|
29
|
-
| `uxf-push-notifier`
|
|
30
|
-
| `uxf-dependencies-check`
|
|
31
|
-
| `uxf-audit`
|
|
32
|
-
| `uxf-claude-sync`
|
|
33
|
-
| `uxf-i18n-namespaces-gen` | Generate an `i18n-pages.json` mapping routes to the translation namespaces they use.
|
|
34
|
-
| `uxf-sitemap-check`
|
|
35
|
-
| `uxf-sitemap-meta-export` | Crawl a sitemap and export page meta tags (title, description, OG) to `export.csv`.
|
|
26
|
+
| Command | Purpose |
|
|
27
|
+
| ------------------------- | ------------------------------------------------------------------------------------------------- |
|
|
28
|
+
| `uxf-release` | Generate release notes from Git commits, create a GitLab release, notify Slack / Google Chat. |
|
|
29
|
+
| `uxf-push-notifier` | Notify Google Chat when a merge request is merged into (or code is pushed to) the default branch. |
|
|
30
|
+
| `uxf-dependencies-check` | Detect version conflicts and missing/mismatched peer deps across `@uxf/*` packages. |
|
|
31
|
+
| `uxf-audit` | Print a JSON report of Node / Yarn / Next / React and installed `@uxf/*` versions. |
|
|
32
|
+
| `uxf-claude-sync` | Download global guidelines from a URL into a `CLAUDE.md` file. |
|
|
33
|
+
| `uxf-i18n-namespaces-gen` | Generate an `i18n-pages.json` mapping routes to the translation namespaces they use. |
|
|
34
|
+
| `uxf-sitemap-check` | Crawl a sitemap and check all URLs for HTTP errors, duplicates, and missing pages. |
|
|
35
|
+
| `uxf-sitemap-meta-export` | Crawl a sitemap and export page meta tags (title, description, OG) to `export.csv`. |
|
|
36
36
|
|
|
37
37
|
Every command accepts `-h` / `--help`.
|
|
38
38
|
|
|
@@ -70,16 +70,16 @@ Set `GITLAB_TOKEN` in the GitLab CI/CD variables, then:
|
|
|
70
70
|
```yaml
|
|
71
71
|
# .gitlab-ci.yml
|
|
72
72
|
stages:
|
|
73
|
-
|
|
73
|
+
- release
|
|
74
74
|
|
|
75
75
|
release:
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
76
|
+
image: node:24
|
|
77
|
+
stage: release
|
|
78
|
+
before_script:
|
|
79
|
+
- yarn global add @uxf/scripts
|
|
80
|
+
script:
|
|
81
|
+
# ... deploy project ...
|
|
82
|
+
- uxf-release
|
|
83
83
|
```
|
|
84
84
|
|
|
85
85
|
### uxf-push-notifier
|
|
@@ -100,17 +100,17 @@ Options:
|
|
|
100
100
|
|
|
101
101
|
```yaml
|
|
102
102
|
stages:
|
|
103
|
-
|
|
103
|
+
- notify
|
|
104
104
|
|
|
105
105
|
notify-push:
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
106
|
+
image: node:24
|
|
107
|
+
stage: notify
|
|
108
|
+
except: [schedules]
|
|
109
|
+
only:
|
|
110
|
+
- develop # default branch
|
|
111
|
+
script:
|
|
112
|
+
- yarn global add @uxf/scripts # already present in UXF Docker images
|
|
113
|
+
- uxf-push-notifier --google-chat-webhook-url=$GOOGLE_CHAT_WEBHOOK
|
|
114
114
|
```
|
|
115
115
|
|
|
116
116
|
### uxf-dependencies-check
|
|
@@ -159,10 +159,10 @@ For genuine declarer conflicts that you've consciously accepted — e.g. two `@u
|
|
|
159
159
|
|
|
160
160
|
```json
|
|
161
161
|
{
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
162
|
+
"name": "@uxf/wysiwyg",
|
|
163
|
+
"uxf": {
|
|
164
|
+
"ignoredConflicts": ["some-shared-dep"]
|
|
165
|
+
}
|
|
166
166
|
}
|
|
167
167
|
```
|
|
168
168
|
|
|
@@ -171,7 +171,7 @@ Ignored conflicts are still printed as informational output but do **not** cause
|
|
|
171
171
|
#### What it checks
|
|
172
172
|
|
|
173
173
|
- Parses the lock file (`yarn.lock` / `package-lock.json` / `pnpm-lock.yaml` / `bun.lock`) to find dependencies with multiple installed versions.
|
|
174
|
-
- For each candidate, resolves the version actually seen by each
|
|
174
|
+
- For each candidate, resolves the version actually seen by each _declarer_ (the project itself and any `@uxf/*` package that directly declares the dep). A conflict is reported only when these declarers land on different versions — transitive-only duplicates from unrelated third-party packages are filtered out.
|
|
175
175
|
- Scans all `@uxf/*` packages in your `dependencies` / `devDependencies`.
|
|
176
176
|
- Reads each package's `peerDependencies` from `node_modules`.
|
|
177
177
|
- Skips `@uxf/*`, `react`, `react-dom`, and `next` (you manage these yourself).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uxf/scripts",
|
|
3
|
-
"version": "11.
|
|
3
|
+
"version": "11.125.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.125.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 = {
|