@nitra/ci-docs 0.0.2
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/CHANGELOG.md +12 -0
- package/README.md +1 -0
- package/package.json +39 -0
- package/src/cli.mjs +35 -0
- package/src/sync-schema/__fixtures__/new-schema.graphql +10 -0
- package/src/sync-schema/__fixtures__/old-schema.graphql +9 -0
- package/src/sync-schema/main.mjs +290 -0
- package/src/sync-schema/main.test.mjs +280 -0
- package/types/index.d.ts +1 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
Усі помітні зміни цього пакета документуються тут.
|
|
4
|
+
|
|
5
|
+
Формат — [Keep a Changelog](https://keepachangelog.com/uk/1.1.0/), нумерація — [SemVer](https://semver.org/lang/uk/).
|
|
6
|
+
|
|
7
|
+
## [0.0.2] - 2026-05-12
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- CLI `ci-docs` з subcommand `sync-schema` — синкає GraphQL-схему в споживацький `npm/`-пакет (bump + CHANGELOG + копіювання SDL).
|
|
12
|
+
- Аргументи (тільки `--key value`, без env-vars): `--new-schema <path>` (обовʼязковий), `--docs <path>` (default `./docs`), `--schema-name <file>` (default `maya.graphql`), `--db-sha <sha>` (default `unknown`).
|
package/README.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nitra/ci-docs",
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"description": "Nitra CI tooling: sync GraphQL schema to a docs npm package (and more to come).",
|
|
5
|
+
"homepage": "https://github.com/nitra/ci-docs",
|
|
6
|
+
"bugs": {
|
|
7
|
+
"url": "https://github.com/nitra/ci-docs/issues"
|
|
8
|
+
},
|
|
9
|
+
"license": "ISC",
|
|
10
|
+
"author": "v@nitra.ai",
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/nitra/ci-docs.git"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"src",
|
|
17
|
+
"types",
|
|
18
|
+
"CHANGELOG.md"
|
|
19
|
+
],
|
|
20
|
+
"type": "module",
|
|
21
|
+
"types": "./types/index.d.ts",
|
|
22
|
+
"bin": {
|
|
23
|
+
"ci-docs": "./src/cli.mjs"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@graphql-inspector/core": "^6.2.1",
|
|
27
|
+
"graphql": "^16.9.0"
|
|
28
|
+
},
|
|
29
|
+
"engines": {
|
|
30
|
+
"bun": ">=1.3",
|
|
31
|
+
"node": ">=24"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@nitra/cursor": "^1.9.4"
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `@nitra/ci-docs` — CLI-диспатчер.
|
|
4
|
+
*
|
|
5
|
+
* Запуск: `npx -y @nitra/ci-docs <subcommand> [args...]`
|
|
6
|
+
*
|
|
7
|
+
* Доступні subcommands:
|
|
8
|
+
* sync-schema — інтроспект Hasura → bump → CHANGELOG → копіювання SDL
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const SUBCOMMANDS = {
|
|
12
|
+
'sync-schema': () => import('./sync-schema/main.mjs').then(m => m.cli)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const [subcommand, ...rest] = process.argv.slice(2)
|
|
16
|
+
|
|
17
|
+
if (!subcommand || subcommand === '--help' || subcommand === '-h') {
|
|
18
|
+
console.log(`Usage: ci-docs <subcommand> [args...]
|
|
19
|
+
|
|
20
|
+
Subcommands:
|
|
21
|
+
${Object.keys(SUBCOMMANDS).join('\n ')}
|
|
22
|
+
|
|
23
|
+
Run \`ci-docs <subcommand> --help\` for subcommand-specific options.`)
|
|
24
|
+
process.exit(subcommand ? 0 : 2)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const loader = SUBCOMMANDS[subcommand]
|
|
28
|
+
if (!loader) {
|
|
29
|
+
console.error(`Unknown subcommand: ${subcommand}`)
|
|
30
|
+
console.error(`Available: ${Object.keys(SUBCOMMANDS).join(', ')}`)
|
|
31
|
+
process.exit(2)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const run = await loader()
|
|
35
|
+
await run(rest)
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, appendFileSync, mkdirSync } from 'node:fs'
|
|
2
|
+
import { dirname } from 'node:path'
|
|
3
|
+
import { parseArgs } from 'node:util'
|
|
4
|
+
import { buildSchema, buildClientSchema, printSchema, getIntrospectionQuery } from 'graphql'
|
|
5
|
+
import { diff } from '@graphql-inspector/core'
|
|
6
|
+
|
|
7
|
+
const REMOVAL_TYPES = new Set([
|
|
8
|
+
'FIELD_REMOVED',
|
|
9
|
+
'TYPE_REMOVED',
|
|
10
|
+
'ENUM_VALUE_REMOVED',
|
|
11
|
+
'INPUT_FIELD_REMOVED',
|
|
12
|
+
'DIRECTIVE_REMOVED',
|
|
13
|
+
'DIRECTIVE_ARGUMENT_REMOVED',
|
|
14
|
+
'UNION_MEMBER_REMOVED',
|
|
15
|
+
'OBJECT_TYPE_INTERFACE_REMOVED',
|
|
16
|
+
'FIELD_ARGUMENT_REMOVED'
|
|
17
|
+
])
|
|
18
|
+
|
|
19
|
+
const ALLOWED_BUMP_KINDS = new Set(['major', 'minor', 'patch'])
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Класифікує зміни схеми у секції changelog та визначає тип bump.
|
|
23
|
+
* @param {Array<{type?: string, message: string, criticality?: {level: string}}>} changes масив змін від graphql-inspector
|
|
24
|
+
* @returns {{bump: 'minor'|'patch'|null, sections: {added: string[], removed: string[], changed: string[]}}} тип bump та секції changelog
|
|
25
|
+
*/
|
|
26
|
+
export function classifyChanges(changes) {
|
|
27
|
+
const sections = { added: [], removed: [], changed: [] }
|
|
28
|
+
let hasBreaking = false
|
|
29
|
+
let hasAnyChange = false
|
|
30
|
+
|
|
31
|
+
for (const change of changes) {
|
|
32
|
+
hasAnyChange = true
|
|
33
|
+
const level = change.criticality?.level
|
|
34
|
+
if (level === 'BREAKING') hasBreaking = true
|
|
35
|
+
|
|
36
|
+
if (level === 'BREAKING' && REMOVAL_TYPES.has(change.type)) {
|
|
37
|
+
sections.removed.push(change.message)
|
|
38
|
+
} else if (change.type?.endsWith('_ADDED')) {
|
|
39
|
+
sections.added.push(change.message)
|
|
40
|
+
} else {
|
|
41
|
+
sections.changed.push(change.message)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let bump = null
|
|
46
|
+
if (hasAnyChange) bump = hasBreaking ? 'minor' : 'patch'
|
|
47
|
+
return { bump, sections }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Будує markdown-блок changelog для версії.
|
|
52
|
+
* @param {{version: string, date: string, dbSha: string, sections: {added: string[], removed: string[], changed: string[]}, first?: boolean}} params параметри блоку
|
|
53
|
+
* @returns {string} markdown-блок з трейлінговим порожнім рядком
|
|
54
|
+
*/
|
|
55
|
+
export function formatChangelogBlock({ version, date, dbSha, sections, first = false }) {
|
|
56
|
+
const lines = [`## [${version}] - ${date}`, '']
|
|
57
|
+
|
|
58
|
+
const changed = [...sections.changed]
|
|
59
|
+
if (first) {
|
|
60
|
+
changed.unshift('Початкове додавання GraphQL-схеми з Hasura.')
|
|
61
|
+
} else {
|
|
62
|
+
changed.unshift(`Оновлено GraphQL-схему з Hasura (\`db@${dbSha}\`).`)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (sections.added.length > 0) {
|
|
66
|
+
lines.push('### Added', '')
|
|
67
|
+
for (const item of sections.added) lines.push(`- ${item}`)
|
|
68
|
+
lines.push('')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
lines.push('### Changed', '')
|
|
72
|
+
for (const item of changed) lines.push(`- ${item}`)
|
|
73
|
+
lines.push('')
|
|
74
|
+
|
|
75
|
+
if (sections.removed.length > 0) {
|
|
76
|
+
lines.push('### Removed', '')
|
|
77
|
+
for (const item of sections.removed) lines.push(`- ${item}`)
|
|
78
|
+
lines.push('')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return lines.join('\n') + '\n'
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Обчислює розділювач між існуючим текстом і блоком, що додається.
|
|
86
|
+
* @param {string} existing існуючий вміст
|
|
87
|
+
* @returns {string} '', '\n' або '\n\n' — щоб новий блок починався з відступу одного порожнього рядка
|
|
88
|
+
*/
|
|
89
|
+
function separatorFor(existing) {
|
|
90
|
+
if (existing.endsWith('\n\n')) return ''
|
|
91
|
+
if (existing.endsWith('\n')) return '\n'
|
|
92
|
+
return '\n\n'
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Вставляє новий блок changelog перед першим існуючим записом (або після хедера, якщо записів нема).
|
|
97
|
+
* @param {string} existing вміст CHANGELOG.md
|
|
98
|
+
* @param {string} newBlock новий блок версії
|
|
99
|
+
* @returns {string} оновлений вміст
|
|
100
|
+
*/
|
|
101
|
+
export function prependChangelog(existing, newBlock) {
|
|
102
|
+
const firstEntryIdx = existing.indexOf('\n## [')
|
|
103
|
+
if (firstEntryIdx === -1) {
|
|
104
|
+
return existing + separatorFor(existing) + newBlock
|
|
105
|
+
}
|
|
106
|
+
const insertAt = firstEntryIdx + 1
|
|
107
|
+
return existing.slice(0, insertAt) + newBlock + existing.slice(insertAt)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Запускає graphql-inspector diff між двома SDL.
|
|
112
|
+
* @param {string} oldSdl стара GraphQL-схема (SDL)
|
|
113
|
+
* @param {string} newSdl нова GraphQL-схема (SDL)
|
|
114
|
+
* @returns {Promise<Array<{type: string, message: string, criticality: {level: string}}>>} список змін
|
|
115
|
+
*/
|
|
116
|
+
export async function runInspector(oldSdl, newSdl) {
|
|
117
|
+
const oldSchema = buildSchema(oldSdl)
|
|
118
|
+
const newSchema = buildSchema(newSdl)
|
|
119
|
+
return await diff(oldSchema, newSchema)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Читає SDL з файлу або повертає null, якщо файл відсутній/порожній.
|
|
124
|
+
* @param {string} path шлях до SDL
|
|
125
|
+
* @returns {string|null} вміст або null
|
|
126
|
+
*/
|
|
127
|
+
export function readSdl(path) {
|
|
128
|
+
if (!existsSync(path)) return null
|
|
129
|
+
const content = readFileSync(path, 'utf8')
|
|
130
|
+
return content.trim().length === 0 ? null : content
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Збільшує semver-версію за типом bump.
|
|
135
|
+
* @param {string} version поточна semver-версія (наприклад '0.1.2')
|
|
136
|
+
* @param {'major'|'minor'|'patch'} kind тип bump
|
|
137
|
+
* @returns {string} нова версія
|
|
138
|
+
*/
|
|
139
|
+
function incrementSemver(version, kind) {
|
|
140
|
+
const parts = version.split('.').map(s => Number.parseInt(s, 10))
|
|
141
|
+
if (parts.length !== 3 || parts.some(n => Number.isNaN(n))) {
|
|
142
|
+
throw new Error(`Invalid semver: ${version}`)
|
|
143
|
+
}
|
|
144
|
+
let [major, minor, patch] = parts
|
|
145
|
+
if (kind === 'major') {
|
|
146
|
+
major += 1
|
|
147
|
+
minor = 0
|
|
148
|
+
patch = 0
|
|
149
|
+
} else if (kind === 'minor') {
|
|
150
|
+
minor += 1
|
|
151
|
+
patch = 0
|
|
152
|
+
} else {
|
|
153
|
+
patch += 1
|
|
154
|
+
}
|
|
155
|
+
return `${major}.${minor}.${patch}`
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Підіймає версію у package.json напряму (без виклику зовнішнього `npm`).
|
|
160
|
+
* @param {string} npmDir шлях до директорії пакета
|
|
161
|
+
* @param {'major'|'minor'|'patch'} kind тип bump
|
|
162
|
+
* @returns {string} нова версія
|
|
163
|
+
*/
|
|
164
|
+
export function bumpVersion(npmDir, kind) {
|
|
165
|
+
if (!ALLOWED_BUMP_KINDS.has(kind)) {
|
|
166
|
+
throw new Error(`Unsupported bump kind: ${kind}`)
|
|
167
|
+
}
|
|
168
|
+
const pkgPath = `${npmDir}/package.json`
|
|
169
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
|
|
170
|
+
pkg.version = incrementSemver(pkg.version, kind)
|
|
171
|
+
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
|
|
172
|
+
return pkg.version
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Пише пари key=value у файл, на який вказує `GITHUB_OUTPUT`.
|
|
177
|
+
* @param {Record<string, string>} values пари для запису
|
|
178
|
+
* @returns {void}
|
|
179
|
+
*/
|
|
180
|
+
export function writeGithubOutput(values) {
|
|
181
|
+
const outPath = process.env.GITHUB_OUTPUT
|
|
182
|
+
if (!outPath) return
|
|
183
|
+
const lines = Object.entries(values).map(([k, v]) => `${k}=${v}`)
|
|
184
|
+
appendFileSync(outPath, lines.join('\n') + '\n')
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Шле introspection-запит до Hasura і повертає SDL-рядок.
|
|
189
|
+
* @param {string} hasuraUrl URL ендпоінта GraphQL
|
|
190
|
+
* @param {string} [adminSecret] значення `X-Hasura-Admin-Secret` (опціонально для публічних ендпоінтів)
|
|
191
|
+
* @returns {Promise<string>} SDL-схема у вигляді рядка
|
|
192
|
+
*/
|
|
193
|
+
export async function fetchSdl(hasuraUrl, adminSecret) {
|
|
194
|
+
const res = await fetch(hasuraUrl, {
|
|
195
|
+
method: 'POST',
|
|
196
|
+
headers: {
|
|
197
|
+
'Content-Type': 'application/json',
|
|
198
|
+
...(adminSecret ? { 'X-Hasura-Admin-Secret': adminSecret } : {})
|
|
199
|
+
},
|
|
200
|
+
body: JSON.stringify({ query: getIntrospectionQuery() })
|
|
201
|
+
})
|
|
202
|
+
if (!res.ok) throw new Error(`Introspection failed: ${res.status} ${res.statusText}`)
|
|
203
|
+
const { data, errors } = await res.json()
|
|
204
|
+
if (errors?.length) throw new Error(`GraphQL errors: ${JSON.stringify(errors)}`)
|
|
205
|
+
return printSchema(buildClientSchema(data))
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Оркеструє весь flow: diff схем → bump → CHANGELOG → запис SDL.
|
|
210
|
+
* @param {{newSdl: string, docsRoot: string, dbSha: string, date: string, schemaFilename?: string}} params параметри запуску
|
|
211
|
+
* @returns {Promise<{changed: boolean, bump: 'minor'|'patch'|null, version: string|null}>} результат
|
|
212
|
+
*/
|
|
213
|
+
export async function main({ newSdl, docsRoot, dbSha, date, schemaFilename = 'maya.graphql' }) {
|
|
214
|
+
const oldSchemaPath = `${docsRoot}/npm/schema/${schemaFilename}`
|
|
215
|
+
const oldSdl = readSdl(oldSchemaPath)
|
|
216
|
+
|
|
217
|
+
const first = oldSdl === null
|
|
218
|
+
const changes = first ? [] : await runInspector(oldSdl, newSdl)
|
|
219
|
+
const { bump: classifiedBump, sections } = classifyChanges(changes)
|
|
220
|
+
const bump = first ? 'patch' : classifiedBump
|
|
221
|
+
|
|
222
|
+
if (!first && bump === null) {
|
|
223
|
+
return { changed: false, bump: null, version: null }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const npmDir = `${docsRoot}/npm`
|
|
227
|
+
const version = bumpVersion(npmDir, bump)
|
|
228
|
+
|
|
229
|
+
const block = formatChangelogBlock({ version, date, dbSha: dbSha.slice(0, 7), sections, first })
|
|
230
|
+
const changelogPath = `${npmDir}/CHANGELOG.md`
|
|
231
|
+
const existingChangelog = readFileSync(changelogPath, 'utf8')
|
|
232
|
+
writeFileSync(changelogPath, prependChangelog(existingChangelog, block))
|
|
233
|
+
|
|
234
|
+
mkdirSync(dirname(oldSchemaPath), { recursive: true })
|
|
235
|
+
writeFileSync(oldSchemaPath, newSdl)
|
|
236
|
+
|
|
237
|
+
return { changed: true, bump, version }
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* CLI-обгортка для sync-schema. Приймає параметри як `--key value`.
|
|
242
|
+
*
|
|
243
|
+
* Обовʼязковий:
|
|
244
|
+
* --hasura-url <url> URL GraphQL-ендпоінта (введроспект)
|
|
245
|
+
*
|
|
246
|
+
* Необовʼязкові:
|
|
247
|
+
* --hasura-secret <s> значення `X-Hasura-Admin-Secret`
|
|
248
|
+
* --docs <path> корінь docs-репо (default './docs')
|
|
249
|
+
* --schema-name <file> назва файлу в `npm/schema/` (default 'maya.graphql')
|
|
250
|
+
* --db-sha <sha> SHA коміту db (default 'unknown')
|
|
251
|
+
*
|
|
252
|
+
* @param {string[]} [argv] аргументи (без 'node' та script path). Default — process.argv.slice(2).
|
|
253
|
+
* @returns {Promise<{changed: boolean, bump: string|null, version: string|null}>} результат main()
|
|
254
|
+
*/
|
|
255
|
+
export async function cli(argv = process.argv.slice(2)) {
|
|
256
|
+
const { values } = parseArgs({
|
|
257
|
+
args: argv,
|
|
258
|
+
options: {
|
|
259
|
+
'hasura-url': { type: 'string' },
|
|
260
|
+
'hasura-secret': { type: 'string' },
|
|
261
|
+
docs: { type: 'string', default: './docs' },
|
|
262
|
+
'schema-name': { type: 'string', default: 'maya.graphql' },
|
|
263
|
+
'db-sha': { type: 'string', default: 'unknown' }
|
|
264
|
+
},
|
|
265
|
+
allowPositionals: false,
|
|
266
|
+
strict: true
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
const hasuraUrl = values['hasura-url']
|
|
270
|
+
if (!hasuraUrl) {
|
|
271
|
+
throw new Error('--hasura-url is required')
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const newSdl = await fetchSdl(hasuraUrl, values['hasura-secret'])
|
|
275
|
+
|
|
276
|
+
const result = await main({
|
|
277
|
+
newSdl,
|
|
278
|
+
docsRoot: values.docs,
|
|
279
|
+
dbSha: values['db-sha'],
|
|
280
|
+
date: new Date().toISOString().slice(0, 10),
|
|
281
|
+
schemaFilename: values['schema-name']
|
|
282
|
+
})
|
|
283
|
+
console.log(JSON.stringify(result, null, 2))
|
|
284
|
+
writeGithubOutput({
|
|
285
|
+
changed: String(result.changed),
|
|
286
|
+
bump: result.bump ?? '',
|
|
287
|
+
version: result.version ?? ''
|
|
288
|
+
})
|
|
289
|
+
return result
|
|
290
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from 'bun:test'
|
|
2
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { buildSchema, graphqlSync } from 'graphql'
|
|
6
|
+
import { classifyChanges, cli, formatChangelogBlock, main, prependChangelog, runInspector } from './main.mjs'
|
|
7
|
+
|
|
8
|
+
const HEADER = `# Changelog\n\nУсі помітні зміни цього пакета документуються тут.\n\nФормат — [Keep a Changelog](https://keepachangelog.com/uk/1.1.0/), нумерація — [SemVer](https://semver.org/lang/uk/).\n\n`
|
|
9
|
+
const OLD_SDL = readFileSync(join(import.meta.dir, '__fixtures__/old-schema.graphql'), 'utf8')
|
|
10
|
+
const NEW_SDL = readFileSync(join(import.meta.dir, '__fixtures__/new-schema.graphql'), 'utf8')
|
|
11
|
+
|
|
12
|
+
describe('classifyChanges', () => {
|
|
13
|
+
it('повертає bump=null коли немає змін', () => {
|
|
14
|
+
expect(classifyChanges([])).toEqual({ bump: null, sections: { added: [], removed: [], changed: [] } })
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('повертає bump=patch для NON_BREAKING додавань', () => {
|
|
18
|
+
const result = classifyChanges([
|
|
19
|
+
{ type: 'FIELD_ADDED', criticality: { level: 'NON_BREAKING' }, message: "Field 'foo' was added to type 'Bar'" }
|
|
20
|
+
])
|
|
21
|
+
expect(result.bump).toBe('patch')
|
|
22
|
+
expect(result.sections.added).toEqual(["Field 'foo' was added to type 'Bar'"])
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('повертає bump=minor для будь-якого BREAKING', () => {
|
|
26
|
+
const result = classifyChanges([
|
|
27
|
+
{ type: 'FIELD_REMOVED', criticality: { level: 'BREAKING' }, message: "Field 'old' was removed from type 'User'" },
|
|
28
|
+
{ type: 'FIELD_ADDED', criticality: { level: 'NON_BREAKING' }, message: "Field 'new' was added to type 'User'" }
|
|
29
|
+
])
|
|
30
|
+
expect(result.bump).toBe('minor')
|
|
31
|
+
expect(result.sections.removed).toEqual(["Field 'old' was removed from type 'User'"])
|
|
32
|
+
expect(result.sections.added).toEqual(["Field 'new' was added to type 'User'"])
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('повертає bump=patch для DANGEROUS', () => {
|
|
36
|
+
const result = classifyChanges([
|
|
37
|
+
{ type: 'FIELD_ARGUMENT_DEFAULT_CHANGED', criticality: { level: 'DANGEROUS' }, message: "Default for arg 'limit' changed" }
|
|
38
|
+
])
|
|
39
|
+
expect(result.bump).toBe('patch')
|
|
40
|
+
expect(result.sections.changed).toEqual(["Default for arg 'limit' changed"])
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('класифікує BREAKING модифікації у `changed`', () => {
|
|
44
|
+
const result = classifyChanges([
|
|
45
|
+
{ type: 'TYPE_KIND_CHANGED', criticality: { level: 'BREAKING' }, message: "Type 'Foo' changed kind" }
|
|
46
|
+
])
|
|
47
|
+
expect(result.sections.changed).toEqual(["Type 'Foo' changed kind"])
|
|
48
|
+
expect(result.sections.removed).toEqual([])
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe('formatChangelogBlock', () => {
|
|
53
|
+
const baseInput = { version: '0.1.0', date: '2026-05-11', dbSha: 'a1b2c3d', sections: { added: [], removed: [], changed: [] } }
|
|
54
|
+
|
|
55
|
+
it('завжди містить "Changed" з посиланням на db-SHA', () => {
|
|
56
|
+
const out = formatChangelogBlock(baseInput)
|
|
57
|
+
expect(out).toContain('## [0.1.0] - 2026-05-11')
|
|
58
|
+
expect(out).toContain('### Changed')
|
|
59
|
+
expect(out).toContain('Оновлено GraphQL-схему з Hasura (`db@a1b2c3d`)')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('додає секцію Removed для breaking-видалень', () => {
|
|
63
|
+
const out = formatChangelogBlock({ ...baseInput, sections: { added: [], removed: ["Field 'old' removed"], changed: [] } })
|
|
64
|
+
expect(out).toContain('### Removed')
|
|
65
|
+
expect(out).toContain("- Field 'old' removed")
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('додає секцію Added для non-breaking додавань', () => {
|
|
69
|
+
const out = formatChangelogBlock({ ...baseInput, sections: { added: ["Field 'new' added"], removed: [], changed: [] } })
|
|
70
|
+
expect(out).toContain('### Added')
|
|
71
|
+
expect(out).toContain("- Field 'new' added")
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('не друкує порожні секції', () => {
|
|
75
|
+
const out = formatChangelogBlock(baseInput)
|
|
76
|
+
expect(out).not.toContain('### Added')
|
|
77
|
+
expect(out).not.toContain('### Removed')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('first-run шаблон, коли first=true', () => {
|
|
81
|
+
const out = formatChangelogBlock({ ...baseInput, first: true })
|
|
82
|
+
expect(out).toContain('Початкове додавання GraphQL-схеми')
|
|
83
|
+
expect(out).not.toContain('Оновлено GraphQL-схему')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('закінчується одним порожнім рядком (для коректного prepend)', () => {
|
|
87
|
+
const out = formatChangelogBlock(baseInput)
|
|
88
|
+
expect(out.endsWith('\n\n')).toBe(true)
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
describe('runInspector', () => {
|
|
93
|
+
it('повертає порожній масив для ідентичних схем', async () => {
|
|
94
|
+
const sdl = `type Query { hello: String }`
|
|
95
|
+
const changes = await runInspector(sdl, sdl)
|
|
96
|
+
expect(changes).toEqual([])
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('детектить BREAKING при видаленні поля', async () => {
|
|
100
|
+
const oldSdl = `type Query { hello: String, bye: String }`
|
|
101
|
+
const newSdl = `type Query { hello: String }`
|
|
102
|
+
const changes = await runInspector(oldSdl, newSdl)
|
|
103
|
+
const breaking = changes.find(c => c.criticality.level === 'BREAKING')
|
|
104
|
+
expect(breaking).toBeDefined()
|
|
105
|
+
expect(breaking.type).toBe('FIELD_REMOVED')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('детектить NON_BREAKING при додаванні поля', async () => {
|
|
109
|
+
const oldSdl = `type Query { hello: String }`
|
|
110
|
+
const newSdl = `type Query { hello: String, newField: Int }`
|
|
111
|
+
const changes = await runInspector(oldSdl, newSdl)
|
|
112
|
+
const nb = changes.find(c => c.type === 'FIELD_ADDED')
|
|
113
|
+
expect(nb).toBeDefined()
|
|
114
|
+
expect(nb.criticality.level).toBe('NON_BREAKING')
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
describe('prependChangelog', () => {
|
|
119
|
+
it('вставляє новий блок між хедером і першим існуючим записом', () => {
|
|
120
|
+
const existing = HEADER + '## [0.0.2] - 2026-05-11\n\n### Added\n\n- щось.\n'
|
|
121
|
+
const block = '## [0.0.3] - 2026-05-12\n\n### Changed\n\n- нове.\n\n'
|
|
122
|
+
const out = prependChangelog(existing, block)
|
|
123
|
+
expect(out.indexOf('## [0.0.3]')).toBeLessThan(out.indexOf('## [0.0.2]'))
|
|
124
|
+
expect(out.startsWith('# Changelog')).toBe(true)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('додає блок після хедера, якщо записів ще нема', () => {
|
|
128
|
+
const block = '## [0.0.1] - 2026-05-11\n\n### Added\n\n- перший запис.\n\n'
|
|
129
|
+
const out = prependChangelog(HEADER, block)
|
|
130
|
+
expect(out).toBe(HEADER + block)
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
describe('main (e2e via fixtures)', () => {
|
|
135
|
+
let tmp
|
|
136
|
+
|
|
137
|
+
function setupTmpDocs({ withOldSchema = true, schemaFilename = 'maya.graphql' } = {}) {
|
|
138
|
+
tmp = mkdtempSync(join(tmpdir(), 'sync-schema-'))
|
|
139
|
+
const npmDir = join(tmp, 'npm')
|
|
140
|
+
mkdirSync(npmDir, { recursive: true })
|
|
141
|
+
writeFileSync(join(npmDir, 'package.json'), JSON.stringify({ name: '@nitra/efes-docs', version: '0.0.2' }, null, 2))
|
|
142
|
+
writeFileSync(join(npmDir, 'CHANGELOG.md'), HEADER + '## [0.0.2] - 2026-05-10\n\n### Added\n\n- Базовий каркас.\n')
|
|
143
|
+
if (withOldSchema) {
|
|
144
|
+
mkdirSync(join(npmDir, 'schema'), { recursive: true })
|
|
145
|
+
writeFileSync(join(npmDir, 'schema', schemaFilename), OLD_SDL)
|
|
146
|
+
}
|
|
147
|
+
return { docsRoot: tmp, npmDir }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
afterEach(() => {
|
|
151
|
+
if (tmp && existsSync(tmp)) rmSync(tmp, { recursive: true, force: true })
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('NON_BREAKING зміна → bump patch, CHANGELOG.Added', async () => {
|
|
155
|
+
const { docsRoot, npmDir } = setupTmpDocs()
|
|
156
|
+
const result = await main({ newSdl: NEW_SDL, docsRoot, dbSha: 'abc1234567', date: '2026-05-11' })
|
|
157
|
+
console.log(JSON.stringify(result, null, 2))
|
|
158
|
+
|
|
159
|
+
expect(result.changed).toBe(true)
|
|
160
|
+
expect(result.version).toBe('0.0.3')
|
|
161
|
+
|
|
162
|
+
const changelog = readFileSync(join(npmDir, 'CHANGELOG.md'), 'utf8')
|
|
163
|
+
expect(changelog).toContain('## [0.0.3] - 2026-05-11')
|
|
164
|
+
expect(changelog.indexOf('## [0.0.3]')).toBeLessThan(changelog.indexOf('## [0.0.2]'))
|
|
165
|
+
|
|
166
|
+
const schema = readFileSync(join(npmDir, 'schema/maya.graphql'), 'utf8')
|
|
167
|
+
expect(schema).toBe(NEW_SDL)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('однакові схеми → changed=false, нічого не записує', async () => {
|
|
171
|
+
const { docsRoot, npmDir } = setupTmpDocs()
|
|
172
|
+
const result = await main({ newSdl: OLD_SDL, docsRoot, dbSha: 'abc1234567', date: '2026-05-11' })
|
|
173
|
+
console.log(JSON.stringify(result, null, 2))
|
|
174
|
+
|
|
175
|
+
expect(result.changed).toBe(false)
|
|
176
|
+
expect(result.bump).toBeNull()
|
|
177
|
+
|
|
178
|
+
const pkg = JSON.parse(readFileSync(join(npmDir, 'package.json'), 'utf8'))
|
|
179
|
+
expect(pkg.version).toBe('0.0.2')
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('first-run (нема старої схеми) → bump patch, CHANGELOG з "Початкове додавання"', async () => {
|
|
183
|
+
const { docsRoot, npmDir } = setupTmpDocs({ withOldSchema: false })
|
|
184
|
+
const result = await main({ newSdl: NEW_SDL, docsRoot, dbSha: 'abc1234567', date: '2026-05-11' })
|
|
185
|
+
console.log(JSON.stringify(result, null, 2))
|
|
186
|
+
|
|
187
|
+
expect(result.changed).toBe(true)
|
|
188
|
+
expect(result.bump).toBe('patch')
|
|
189
|
+
expect(result.version).toBe('0.0.3')
|
|
190
|
+
|
|
191
|
+
const changelog = readFileSync(join(npmDir, 'CHANGELOG.md'), 'utf8')
|
|
192
|
+
expect(changelog).toContain('Початкове додавання GraphQL-схеми')
|
|
193
|
+
expect(existsSync(join(npmDir, 'schema/maya.graphql'))).toBe(true)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('кастомний schemaFilename (smart.graphql) — записує саме його', async () => {
|
|
197
|
+
const { docsRoot, npmDir } = setupTmpDocs({ schemaFilename: 'smart.graphql' })
|
|
198
|
+
const result = await main({ newSdl: NEW_SDL, docsRoot, dbSha: 'abc1234567', date: '2026-05-11', schemaFilename: 'smart.graphql' })
|
|
199
|
+
|
|
200
|
+
expect(result.changed).toBe(true)
|
|
201
|
+
expect(existsSync(join(npmDir, 'schema/smart.graphql'))).toBe(true)
|
|
202
|
+
expect(existsSync(join(npmDir, 'schema/maya.graphql'))).toBe(false)
|
|
203
|
+
})
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
describe('cli (тільки args)', () => {
|
|
207
|
+
let tmp
|
|
208
|
+
let server
|
|
209
|
+
let receivedHeaders
|
|
210
|
+
|
|
211
|
+
function setupBareDocs() {
|
|
212
|
+
tmp = mkdtempSync(join(tmpdir(), 'sync-schema-cli-'))
|
|
213
|
+
const npmDir = join(tmp, 'npm')
|
|
214
|
+
mkdirSync(npmDir, { recursive: true })
|
|
215
|
+
writeFileSync(join(npmDir, 'package.json'), JSON.stringify({ name: '@nitra/x-docs', version: '0.0.2' }, null, 2))
|
|
216
|
+
writeFileSync(join(npmDir, 'CHANGELOG.md'), HEADER + '## [0.0.2] - 2026-05-10\n\n### Added\n\n- щось.\n')
|
|
217
|
+
return tmp
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function startMockHasura(sdl) {
|
|
221
|
+
const schema = buildSchema(sdl)
|
|
222
|
+
server = Bun.serve({
|
|
223
|
+
port: 0,
|
|
224
|
+
async fetch(req) {
|
|
225
|
+
receivedHeaders = req.headers
|
|
226
|
+
const { query } = await req.json()
|
|
227
|
+
const result = graphqlSync({ schema, source: query })
|
|
228
|
+
return Response.json(result)
|
|
229
|
+
}
|
|
230
|
+
})
|
|
231
|
+
return `http://localhost:${server.port}/v1/graphql`
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
afterEach(() => {
|
|
235
|
+
if (server) {
|
|
236
|
+
server.stop()
|
|
237
|
+
server = undefined
|
|
238
|
+
}
|
|
239
|
+
receivedHeaders = undefined
|
|
240
|
+
if (tmp && existsSync(tmp)) rmSync(tmp, { recursive: true, force: true })
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('інтроспектить Hasura через --hasura-url і синкає схему', async () => {
|
|
244
|
+
const docsRoot = setupBareDocs()
|
|
245
|
+
const url = startMockHasura(NEW_SDL)
|
|
246
|
+
const result = await cli(['--hasura-url', url, '--docs', docsRoot, '--schema-name', 'test.graphql', '--db-sha', 'abcdef1234'])
|
|
247
|
+
|
|
248
|
+
expect(result.changed).toBe(true)
|
|
249
|
+
expect(result.bump).toBe('patch')
|
|
250
|
+
expect(existsSync(join(docsRoot, 'npm', 'schema', 'test.graphql'))).toBe(true)
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
it('передає --hasura-secret як X-Hasura-Admin-Secret заголовок', async () => {
|
|
254
|
+
const docsRoot = setupBareDocs()
|
|
255
|
+
const url = startMockHasura(NEW_SDL)
|
|
256
|
+
await cli(['--hasura-url', url, '--hasura-secret', 'super-secret', '--docs', docsRoot])
|
|
257
|
+
|
|
258
|
+
expect(receivedHeaders.get('x-hasura-admin-secret')).toBe('super-secret')
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('без --hasura-secret заголовок не надсилається', async () => {
|
|
262
|
+
const docsRoot = setupBareDocs()
|
|
263
|
+
const url = startMockHasura(NEW_SDL)
|
|
264
|
+
await cli(['--hasura-url', url, '--docs', docsRoot])
|
|
265
|
+
|
|
266
|
+
expect(receivedHeaders.get('x-hasura-admin-secret')).toBeNull()
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
it('схема за замовчуванням — maya.graphql', async () => {
|
|
270
|
+
const docsRoot = setupBareDocs()
|
|
271
|
+
const url = startMockHasura(NEW_SDL)
|
|
272
|
+
await cli(['--hasura-url', url, '--docs', docsRoot])
|
|
273
|
+
|
|
274
|
+
expect(existsSync(join(docsRoot, 'npm', 'schema', 'maya.graphql'))).toBe(true)
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it('викидає якщо не передано --hasura-url', () => {
|
|
278
|
+
expect(cli(['--docs', '/tmp/nowhere'])).rejects.toThrow('--hasura-url is required')
|
|
279
|
+
})
|
|
280
|
+
})
|
package/types/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {}
|