@embrace-ai/infra-api-schema-sync 1.0.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/CHANGELOG.md +14 -0
- package/README.md +83 -0
- package/dist/vitest.config.d.ts +3 -0
- package/dist/vitest.config.d.ts.map +1 -0
- package/dist/vitest.config.js +30 -0
- package/dist/vitest.config.js.map +1 -0
- package/package.json +62 -0
- package/src/dispatch-update.js +322 -0
- package/src/fetch-schema.js +65 -0
- package/src/generate-schema-url.js +165 -0
- package/src/generate-summary.js +61 -0
- package/src/index.js +90 -0
- package/src/validate-schema.js +157 -0
- package/templates/workflows/emit-schema-update.yml +83 -0
- package/templates/workflows/graphql-schema-validate.yml +106 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 1.0.0 (2025-06-19)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* **commands:** remove unnecessary cli commands, update workflows, move bash to dispath-update ([14aa3a8](https://github.com/Embrace-AI/infra-api-schema-sync/commit/14aa3a82e142eb9c135bf6d409ad36d9cad5e20f))
|
|
9
|
+
* **lockfile:** update lockfile ([706bf37](https://github.com/Embrace-AI/infra-api-schema-sync/commit/706bf372d80d49692db9159da6ff25593869064a))
|
|
10
|
+
* **logging:** add globals for console logging in workflows ([fe00324](https://github.com/Embrace-AI/infra-api-schema-sync/commit/fe003249a5859b7769fce977556b71e610cb42d9))
|
|
11
|
+
* **workflow:** move dispatch logic to js, and update consumer workflow ([b0ff4c1](https://github.com/Embrace-AI/infra-api-schema-sync/commit/b0ff4c14096d090a50a93795dc45f69e5f2a6d8e))
|
|
12
|
+
* **workflows:** add issue_count and remove token in env ([662e8ad](https://github.com/Embrace-AI/infra-api-schema-sync/commit/662e8adae80cfc6a4f1ddfa389a28a94a7c04ac7))
|
|
13
|
+
* **workflows:** only emit update on **/*schema.ts change, only show summary when issues are found ([2c2a589](https://github.com/Embrace-AI/infra-api-schema-sync/commit/2c2a589eff7192c833d008ac64e6bad6f5186acb))
|
|
14
|
+
* **workflows:** restore workflow files ([63eb5ad](https://github.com/Embrace-AI/infra-api-schema-sync/commit/63eb5ad7e1a4f2e004f6fad3524d3d30bb0278aa))
|
package/README.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# CHANGEME
|
|
2
|
+
|
|
3
|
+
## Setup
|
|
4
|
+
|
|
5
|
+
### Node
|
|
6
|
+
|
|
7
|
+
This project uses `nvm` ([Node Version Manager](https://github.com/nvm-sh/nvm))
|
|
8
|
+
and `pnpm` ([Package Node Modules](https://pnpm.js.org/)).
|
|
9
|
+
|
|
10
|
+
1. Install `nvm`
|
|
11
|
+
2. `cd` to the project directory and execute the following:
|
|
12
|
+
```
|
|
13
|
+
nvm install
|
|
14
|
+
nvm use
|
|
15
|
+
```
|
|
16
|
+
3. Install `pnpm`
|
|
17
|
+
```
|
|
18
|
+
npm install -g pnpm
|
|
19
|
+
```
|
|
20
|
+
4. Install dependencies
|
|
21
|
+
```
|
|
22
|
+
pnpm install
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### IDE Setup
|
|
26
|
+
|
|
27
|
+
This project uses [EditorConfig](https://editorconfig.org/) for IDE configuration.
|
|
28
|
+
|
|
29
|
+
See `.editorconfig` for settings.
|
|
30
|
+
|
|
31
|
+
Many popular IDEs and editors support this out of the box or with a plugin.
|
|
32
|
+
|
|
33
|
+
## Development
|
|
34
|
+
|
|
35
|
+
### Prettier
|
|
36
|
+
|
|
37
|
+
This project uses [Prettier](https://prettier.io/), so please run it before checking in:
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
pnpm prettier-format
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
See `.prettierrc` for settings.
|
|
44
|
+
|
|
45
|
+
Some IDEs and editors have plugins for running Prettier.
|
|
46
|
+
|
|
47
|
+
### Linting
|
|
48
|
+
|
|
49
|
+
This project uses [ESLint](https://eslint.org/). Check linting before checking in:
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
pnpm lint
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
See `eslint.config.mjs` for settings.
|
|
56
|
+
|
|
57
|
+
Many IDEs and editors support ESLint.
|
|
58
|
+
|
|
59
|
+
### Typechecking
|
|
60
|
+
|
|
61
|
+
In addition, running typecheck is recommended before checking in:
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
pnpm -R typecheck
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Testing
|
|
68
|
+
|
|
69
|
+
This project uses [Vitest](https://vitest.dev/) for testing. Run tests before checking in.
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
pnpm test
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Building
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
pnpm build
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Releasing
|
|
82
|
+
|
|
83
|
+
This project uses Github Actions to release to npmjs.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"vitest.config.d.ts","sourceRoot":"","sources":["../vitest.config.ts"],"names":[],"mappings":";AAEA,wBAyBG"}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const config_1 = require("vitest/config");
|
|
4
|
+
exports.default = (0, config_1.defineConfig)({
|
|
5
|
+
test: {
|
|
6
|
+
exclude: [
|
|
7
|
+
"**/node_modules/**",
|
|
8
|
+
"**/dist/**",
|
|
9
|
+
"**/.{idea,git,cache,output,temp}/**",
|
|
10
|
+
"**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build}.config.*",
|
|
11
|
+
],
|
|
12
|
+
chaiConfig: { truncateThreshold: 200 },
|
|
13
|
+
maxConcurrency: 20,
|
|
14
|
+
testTimeout: 20 * 1000, // 20 seconds
|
|
15
|
+
coverage: {
|
|
16
|
+
enabled: false,
|
|
17
|
+
exclude: [
|
|
18
|
+
"**/.idea/**",
|
|
19
|
+
"**/dist/**",
|
|
20
|
+
"**/node_modules/**",
|
|
21
|
+
"**/test-context.ts",
|
|
22
|
+
],
|
|
23
|
+
reporter: ["text", "html"],
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
resolve: {
|
|
27
|
+
preserveSymlinks: true,
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
//# sourceMappingURL=vitest.config.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"vitest.config.js","sourceRoot":"","sources":["../vitest.config.ts"],"names":[],"mappings":";;AAAA,0CAA6C;AAE7C,kBAAe,IAAA,qBAAY,EAAC;IAC1B,IAAI,EAAE;QACJ,OAAO,EAAE;YACP,oBAAoB;YACpB,YAAY;YACZ,qCAAqC;YACrC,sFAAsF;SACvF;QACD,UAAU,EAAE,EAAE,iBAAiB,EAAE,GAAG,EAAE;QACtC,cAAc,EAAE,EAAE;QAClB,WAAW,EAAE,EAAE,GAAG,IAAI,EAAE,aAAa;QACrC,QAAQ,EAAE;YACR,OAAO,EAAE,KAAK;YACd,OAAO,EAAE;gBACP,aAAa;gBACb,YAAY;gBACZ,oBAAoB;gBACpB,oBAAoB;aACrB;YACD,QAAQ,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC;SAC3B;KACF;IACD,OAAO,EAAE;QACP,gBAAgB,EAAE,IAAI;KACvB;CACF,CAAC,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@embrace-ai/infra-api-schema-sync",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"bin": {
|
|
5
|
+
"api-tool": "./dist/index.js"
|
|
6
|
+
},
|
|
7
|
+
"files": [
|
|
8
|
+
"src/",
|
|
9
|
+
"templates/",
|
|
10
|
+
"dist",
|
|
11
|
+
"CHANGELOG.md",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"description": "API schema synchronization tool for automated client validation and code generation across distributed repositories",
|
|
15
|
+
"author": "Embrace.ai",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "https://github.com/Embrace-AI/infra-api-schema-sync"
|
|
19
|
+
},
|
|
20
|
+
"types": "dist/**/*.d.ts",
|
|
21
|
+
"keywords": [],
|
|
22
|
+
"type": "module",
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@graphql-codegen/cli": "^5.0.7",
|
|
25
|
+
"@graphql-codegen/typescript": "^4.1.6",
|
|
26
|
+
"@graphql-inspector/cli": "^5.0.8",
|
|
27
|
+
"commander": "^14.0.0",
|
|
28
|
+
"fs-extra": "^11.3.0",
|
|
29
|
+
"globals": "^16.2.0",
|
|
30
|
+
"graphql": "^16.11.0",
|
|
31
|
+
"yaml": "^2.8.0"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@eslint/compat": "1.3.0",
|
|
35
|
+
"@graphql-inspector/core": "6.2.1",
|
|
36
|
+
"@trivago/prettier-plugin-sort-imports": "5.2.2",
|
|
37
|
+
"@tsconfig/node20": "20.1.6",
|
|
38
|
+
"@types/node": "22.15.32",
|
|
39
|
+
"@typescript-eslint/eslint-plugin": "8.34.1",
|
|
40
|
+
"@typescript-eslint/parser": "8.34.1",
|
|
41
|
+
"@vitest/coverage-v8": "3.2.4",
|
|
42
|
+
"eslint": "9.29.0",
|
|
43
|
+
"eslint-config-prettier": "10.1.5",
|
|
44
|
+
"eslint-plugin-prefer-arrow": "1.2.3",
|
|
45
|
+
"eslint-plugin-prettier": "5.5.0",
|
|
46
|
+
"eslint-plugin-unused-imports": "4.1.4",
|
|
47
|
+
"prettier": "3.5.3",
|
|
48
|
+
"ts-pattern": "5.7.1",
|
|
49
|
+
"typescript": "5.8.3",
|
|
50
|
+
"vitest": "3.2.4"
|
|
51
|
+
},
|
|
52
|
+
"scripts": {
|
|
53
|
+
"clean": "rm -rf dist",
|
|
54
|
+
"build": "tsc",
|
|
55
|
+
"build:watch": "tsc --watch",
|
|
56
|
+
"rebuild": "pnpm clean && pnpm build",
|
|
57
|
+
"types": "echo no types",
|
|
58
|
+
"typecheck": "tsc --noEmit",
|
|
59
|
+
"lint": "eslint .",
|
|
60
|
+
"test": "vitest run"
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { execSync } from "child_process";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Dispatch schema update events to repositories
|
|
5
|
+
* @param {Object} options - Configuration options
|
|
6
|
+
* @param {string} options.schemaUrl - The schema URL to dispatch
|
|
7
|
+
* @param {string} options.sourceRepo - Source repository name
|
|
8
|
+
* @param {string} options.commit - Git commit SHA
|
|
9
|
+
* @param {string} options.branch - Git branch name
|
|
10
|
+
* @param {string} options.timestamp - ISO timestamp
|
|
11
|
+
* @param {string} options.token - GitHub token for API access
|
|
12
|
+
* @param {string} options.orgName - GitHub organization name
|
|
13
|
+
* @param {string} options.repoFilter - Regex pattern to filter repositories
|
|
14
|
+
* @param {boolean} options.dryRun - If true, only show what would be dispatched
|
|
15
|
+
*/
|
|
16
|
+
export const dispatchUpdate = async (options) => {
|
|
17
|
+
try {
|
|
18
|
+
const {
|
|
19
|
+
schemaUrl,
|
|
20
|
+
sourceRepo,
|
|
21
|
+
commit,
|
|
22
|
+
branch,
|
|
23
|
+
timestamp,
|
|
24
|
+
token,
|
|
25
|
+
orgName,
|
|
26
|
+
repoFilter = ".*",
|
|
27
|
+
dryRun = false,
|
|
28
|
+
} = options;
|
|
29
|
+
|
|
30
|
+
console.log("📡 Starting schema update dispatch...");
|
|
31
|
+
console.log(`🌐 Schema URL: ${schemaUrl}`);
|
|
32
|
+
console.log(`📁 Source Repository: ${sourceRepo}`);
|
|
33
|
+
console.log(`🔍 Organization: ${orgName}`);
|
|
34
|
+
console.log(`🎯 Repository Filter: ${repoFilter}`);
|
|
35
|
+
|
|
36
|
+
if (dryRun) {
|
|
37
|
+
console.log("DRY RUN MODE - No actual dispatches will be sent");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Function to fetch repositories with pagination
|
|
41
|
+
const fetchAllRepos = async () => {
|
|
42
|
+
let page = 1;
|
|
43
|
+
const perPage = 100;
|
|
44
|
+
let allRepos = "";
|
|
45
|
+
|
|
46
|
+
while (true) {
|
|
47
|
+
console.log(`Fetching repos page ${page}...`);
|
|
48
|
+
|
|
49
|
+
// Fetch repositories for the organization
|
|
50
|
+
const response = await fetch(
|
|
51
|
+
`https://api.github.com/orgs/${orgName}/repos?page=${page}&per_page=${perPage}&type=all`,
|
|
52
|
+
{
|
|
53
|
+
headers: {
|
|
54
|
+
Authorization: `Bearer ${token}`,
|
|
55
|
+
Accept: "application/vnd.github.v3+json",
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
if (!response.ok) {
|
|
61
|
+
const errorData = await response.json();
|
|
62
|
+
throw new Error(
|
|
63
|
+
`GitHub API error: ${errorData.message || response.statusText}`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const repos = await response.json();
|
|
68
|
+
|
|
69
|
+
// If no repos on this page, we're done
|
|
70
|
+
if (!repos || repos.length === 0) {
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Extract repository names
|
|
75
|
+
const repoNames = repos.map((repo) => repo.full_name).join("\n");
|
|
76
|
+
allRepos += `${repoNames}\n`;
|
|
77
|
+
|
|
78
|
+
// Check if we got fewer than per_page results (last page)
|
|
79
|
+
if (repos.length < perPage) {
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
page++;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return allRepos;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// Get all repositories
|
|
90
|
+
console.log("🔍 Fetching all organization repositories...");
|
|
91
|
+
|
|
92
|
+
// In dry-run mode with fake token, simulate some repositories
|
|
93
|
+
let allRepos;
|
|
94
|
+
if (dryRun && (token === "fake-token" || token.startsWith("fake"))) {
|
|
95
|
+
console.log("DRY RUN: Using simulated repositories");
|
|
96
|
+
allRepos = `${orgName}/repo1\n${orgName}/repo2\n${orgName}/frontend\n${sourceRepo}\n`;
|
|
97
|
+
} else {
|
|
98
|
+
allRepos = await fetchAllRepos();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Filter repositories if pattern is provided
|
|
102
|
+
let filteredRepos;
|
|
103
|
+
if (repoFilter !== ".*") {
|
|
104
|
+
console.log(`🔍 Filtering repositories with pattern: ${repoFilter}`);
|
|
105
|
+
const repoList = allRepos.split("\n").filter((repo) => repo.trim());
|
|
106
|
+
const regex = new RegExp(repoFilter);
|
|
107
|
+
filteredRepos = repoList.filter((repo) => regex.test(repo)).join("\n");
|
|
108
|
+
} else {
|
|
109
|
+
filteredRepos = allRepos;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Remove empty lines and current repository from the list
|
|
113
|
+
const targetRepos = filteredRepos
|
|
114
|
+
.split("\n")
|
|
115
|
+
.filter((repo) => repo.trim() && repo !== sourceRepo)
|
|
116
|
+
.filter((repo) => repo.length > 0);
|
|
117
|
+
|
|
118
|
+
if (targetRepos.length === 0) {
|
|
119
|
+
console.log("⚠️ No target repositories found to notify");
|
|
120
|
+
return { success: 0, errors: 0, total: 0 };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
console.log(`Found ${targetRepos.length} repositories to notify:`);
|
|
124
|
+
targetRepos.forEach((repo) => console.log(` - ${repo}`));
|
|
125
|
+
console.log("");
|
|
126
|
+
|
|
127
|
+
if (dryRun) {
|
|
128
|
+
console.log("DRY RUN: Would dispatch to the above repositories");
|
|
129
|
+
return {
|
|
130
|
+
success: targetRepos.length,
|
|
131
|
+
errors: 0,
|
|
132
|
+
total: targetRepos.length,
|
|
133
|
+
dryRun: true,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Dispatch to each repository
|
|
138
|
+
let successCount = 0;
|
|
139
|
+
let errorCount = 0;
|
|
140
|
+
|
|
141
|
+
for (const repo of targetRepos) {
|
|
142
|
+
if (repo.trim()) {
|
|
143
|
+
console.log(`📡 Dispatching to repository: ${repo}`);
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
const dispatchResponse = await fetch(
|
|
147
|
+
`https://api.github.com/repos/${repo}/dispatches`,
|
|
148
|
+
{
|
|
149
|
+
method: "POST",
|
|
150
|
+
headers: {
|
|
151
|
+
Authorization: `Bearer ${token}`,
|
|
152
|
+
Accept: "application/vnd.github.v3+json",
|
|
153
|
+
"Content-Type": "application/json",
|
|
154
|
+
},
|
|
155
|
+
body: JSON.stringify({
|
|
156
|
+
event_type: "graphql-schema-updated",
|
|
157
|
+
client_payload: {
|
|
158
|
+
schemaUrl,
|
|
159
|
+
sourceRepo,
|
|
160
|
+
commit,
|
|
161
|
+
branch,
|
|
162
|
+
timestamp,
|
|
163
|
+
},
|
|
164
|
+
}),
|
|
165
|
+
},
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
if (dispatchResponse.status === 204) {
|
|
169
|
+
console.log(" ✅ Success");
|
|
170
|
+
successCount++;
|
|
171
|
+
} else {
|
|
172
|
+
console.log(` ❌ Failed (HTTP ${dispatchResponse.status})`);
|
|
173
|
+
errorCount++;
|
|
174
|
+
}
|
|
175
|
+
} catch (error) {
|
|
176
|
+
console.log(` ❌ Failed: ${error.message}`);
|
|
177
|
+
errorCount++;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
console.log("");
|
|
183
|
+
console.log("📊 Dispatch Summary:");
|
|
184
|
+
console.log(` ✅ Successful: ${successCount}`);
|
|
185
|
+
console.log(` ❌ Failed: ${errorCount}`);
|
|
186
|
+
console.log(` 📋 Total: ${successCount + errorCount}`);
|
|
187
|
+
|
|
188
|
+
if (errorCount > 0) {
|
|
189
|
+
console.log(
|
|
190
|
+
"⚠️ Some dispatches failed. Check repository permissions and workflow file presence.",
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
success: successCount,
|
|
196
|
+
errors: errorCount,
|
|
197
|
+
total: successCount + errorCount,
|
|
198
|
+
};
|
|
199
|
+
} catch (error) {
|
|
200
|
+
console.error(`❌ Failed to dispatch schema updates: ${error.message}`);
|
|
201
|
+
process.exit(1);
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Auto-detect values from environment or git
|
|
207
|
+
*/
|
|
208
|
+
const detectValues = () => {
|
|
209
|
+
const sourceRepo = process.env.GITHUB_REPOSITORY || detectRepositoryFromGit();
|
|
210
|
+
const commit = process.env.GITHUB_SHA || detectCommitFromGit();
|
|
211
|
+
const branch = process.env.GITHUB_REF_NAME || detectBranchFromGit();
|
|
212
|
+
const orgName =
|
|
213
|
+
process.env.GITHUB_REPOSITORY_OWNER || sourceRepo?.split("/")[0];
|
|
214
|
+
const timestamp = new Date().toISOString();
|
|
215
|
+
|
|
216
|
+
return { sourceRepo, commit, branch, orgName, timestamp };
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const detectRepositoryFromGit = () => {
|
|
220
|
+
try {
|
|
221
|
+
const remoteUrl = execSync("git config --get remote.origin.url", {
|
|
222
|
+
encoding: "utf8",
|
|
223
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
224
|
+
}).trim();
|
|
225
|
+
|
|
226
|
+
// Extract org/repo from various URL formats
|
|
227
|
+
const match = remoteUrl.match(/github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?$/);
|
|
228
|
+
return match ? match[1] : null;
|
|
229
|
+
} catch (error) {
|
|
230
|
+
console.warn("Failed to detect repository from git:", error.message);
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const detectCommitFromGit = () => {
|
|
236
|
+
try {
|
|
237
|
+
return execSync("git rev-parse HEAD", {
|
|
238
|
+
encoding: "utf8",
|
|
239
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
240
|
+
}).trim();
|
|
241
|
+
} catch (error) {
|
|
242
|
+
console.warn("Failed to detect commit from git:", error.message);
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
const detectBranchFromGit = () => {
|
|
248
|
+
try {
|
|
249
|
+
return execSync("git rev-parse --abbrev-ref HEAD", {
|
|
250
|
+
encoding: "utf8",
|
|
251
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
252
|
+
}).trim();
|
|
253
|
+
} catch (error) {
|
|
254
|
+
console.warn("Failed to detect branch from git:", error.message);
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* CLI command handler
|
|
261
|
+
*/
|
|
262
|
+
export const dispatchUpdateCommand = async (opts) => {
|
|
263
|
+
const {
|
|
264
|
+
schemaUrl,
|
|
265
|
+
sourceRepo: providedSourceRepo,
|
|
266
|
+
commit: providedCommit,
|
|
267
|
+
branch: providedBranch,
|
|
268
|
+
token,
|
|
269
|
+
orgName: providedOrgName,
|
|
270
|
+
repoFilter,
|
|
271
|
+
dryRun,
|
|
272
|
+
} = opts;
|
|
273
|
+
|
|
274
|
+
// Auto-detect missing values
|
|
275
|
+
const detected = detectValues();
|
|
276
|
+
|
|
277
|
+
const sourceRepo = providedSourceRepo || detected.sourceRepo;
|
|
278
|
+
const commit = providedCommit || detected.commit;
|
|
279
|
+
const branch = providedBranch || detected.branch;
|
|
280
|
+
const orgName = providedOrgName || detected.orgName;
|
|
281
|
+
const timestamp = detected.timestamp;
|
|
282
|
+
|
|
283
|
+
// Validate required parameters
|
|
284
|
+
if (!schemaUrl) {
|
|
285
|
+
throw new Error("Schema URL is required (--schema-url)");
|
|
286
|
+
}
|
|
287
|
+
if (!token) {
|
|
288
|
+
throw new Error("GitHub token is required (--token)");
|
|
289
|
+
}
|
|
290
|
+
if (!sourceRepo) {
|
|
291
|
+
throw new Error(
|
|
292
|
+
"Source repository could not be detected. Please provide --source-repo",
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
if (!orgName) {
|
|
296
|
+
throw new Error(
|
|
297
|
+
"Organization name could not be detected. Please provide --org-name",
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
console.log("🔧 Preparing schema update dispatch...");
|
|
302
|
+
console.log(`📁 Source Repository: ${sourceRepo}`);
|
|
303
|
+
console.log(`🏷️ Commit: ${commit || "not detected"}`);
|
|
304
|
+
console.log(`🌿 Branch: ${branch || "not detected"}`);
|
|
305
|
+
console.log(`🏢 Organization: ${orgName}`);
|
|
306
|
+
console.log(`⏰ Timestamp: ${timestamp}`);
|
|
307
|
+
|
|
308
|
+
return dispatchUpdate({
|
|
309
|
+
schemaUrl,
|
|
310
|
+
sourceRepo,
|
|
311
|
+
commit,
|
|
312
|
+
branch,
|
|
313
|
+
timestamp,
|
|
314
|
+
token,
|
|
315
|
+
orgName,
|
|
316
|
+
repoFilter,
|
|
317
|
+
dryRun,
|
|
318
|
+
});
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
// Default export for CLI
|
|
322
|
+
export default dispatchUpdateCommand;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import fse from "fs-extra";
|
|
3
|
+
import { buildClientSchema, getIntrospectionQuery, printSchema } from "graphql";
|
|
4
|
+
import path from "path";
|
|
5
|
+
|
|
6
|
+
export const fetchSchema = async ({ url, output }) => {
|
|
7
|
+
try {
|
|
8
|
+
console.log(`🔄 Fetching GraphQL schema from ${url}...`);
|
|
9
|
+
|
|
10
|
+
// Prepare introspection query
|
|
11
|
+
const introspectionQuery = getIntrospectionQuery();
|
|
12
|
+
|
|
13
|
+
// Fetch schema using introspection
|
|
14
|
+
const response = await fetch(url, {
|
|
15
|
+
method: "POST",
|
|
16
|
+
headers: {
|
|
17
|
+
"Content-Type": "application/json",
|
|
18
|
+
Accept: "application/json",
|
|
19
|
+
},
|
|
20
|
+
body: JSON.stringify({
|
|
21
|
+
query: introspectionQuery,
|
|
22
|
+
}),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
if (!response.ok) {
|
|
26
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const result = await response.json();
|
|
30
|
+
|
|
31
|
+
if (result.errors) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
`GraphQL errors: ${result.errors.map((e) => e.message).join(", ")}`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (!result.data) {
|
|
38
|
+
throw new Error("No data returned from introspection query");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Build schema from introspection result
|
|
42
|
+
const schema = buildClientSchema(result.data);
|
|
43
|
+
|
|
44
|
+
// Convert schema to SDL (Schema Definition Language)
|
|
45
|
+
const schemaSDL = printSchema(schema);
|
|
46
|
+
|
|
47
|
+
// Ensure output directory exists
|
|
48
|
+
const outputDir = path.dirname(output);
|
|
49
|
+
fse.ensureDirSync(outputDir);
|
|
50
|
+
|
|
51
|
+
// Write schema to file
|
|
52
|
+
fs.writeFileSync(output, schemaSDL, "utf8");
|
|
53
|
+
|
|
54
|
+
console.log(`✅ Schema successfully saved to ${output}`);
|
|
55
|
+
console.log(
|
|
56
|
+
`📊 Schema contains ${Object.keys(schema.getTypeMap()).length} types`,
|
|
57
|
+
);
|
|
58
|
+
} catch (error) {
|
|
59
|
+
console.error(`❌ Failed to fetch schema: ${error.message}`);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// Default export for CLI
|
|
65
|
+
export default fetchSchema;
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { execSync } from "child_process";
|
|
2
|
+
import path from "path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Generate a dynamic GraphQL schema URL based on repository and deployment stage
|
|
6
|
+
* @param {Object} options - Configuration options
|
|
7
|
+
* @param {string} options.stage - Deployment stage (dev, prod, pr-*)
|
|
8
|
+
* @param {string} options.service - Service name (optional, auto-detected if not provided)
|
|
9
|
+
* @param {string} options.repo - Repository name (optional, auto-detected if not provided)
|
|
10
|
+
* @returns {string} The generated schema URL
|
|
11
|
+
*/
|
|
12
|
+
export const buildSchemaUrl = ({ stage = "dev", service, repo }) => {
|
|
13
|
+
// Auto-detect repository name if not provided
|
|
14
|
+
if (!repo) {
|
|
15
|
+
repo = detectRepositoryName();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Auto-detect service name if not provided
|
|
19
|
+
if (!service) {
|
|
20
|
+
service = extractServiceName(repo);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Special case for embrace repository - always use the main API URL
|
|
24
|
+
if (
|
|
25
|
+
service === "embrace" ||
|
|
26
|
+
repo === "embrace" ||
|
|
27
|
+
repo.endsWith("/embrace")
|
|
28
|
+
) {
|
|
29
|
+
return buildEmbraceUrl(stage);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Build URL for other services
|
|
33
|
+
return buildServiceUrl(service, stage);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Build URL for the main embrace service
|
|
38
|
+
* @param {string} stage - Deployment stage
|
|
39
|
+
* @returns {string} The embrace service URL
|
|
40
|
+
*/
|
|
41
|
+
const buildEmbraceUrl = (stage) => {
|
|
42
|
+
switch (stage) {
|
|
43
|
+
case "dev":
|
|
44
|
+
return "https://api.dev.embrace.ai/v2/graphql/schema";
|
|
45
|
+
default:
|
|
46
|
+
if (stage.startsWith("pr-")) {
|
|
47
|
+
return `https://api.${stage}.dev.embrace.ai/v2/graphql/schema`;
|
|
48
|
+
}
|
|
49
|
+
// Default to dev pattern for unknown stages
|
|
50
|
+
return "https://api.dev.embrace.ai/v2/graphql/schema";
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Build URL for microservices
|
|
56
|
+
* @param {string} service - Service name
|
|
57
|
+
* @param {string} stage - Deployment stage
|
|
58
|
+
* @returns {string} The service URL
|
|
59
|
+
*/
|
|
60
|
+
const buildServiceUrl = (service, stage) => {
|
|
61
|
+
switch (stage) {
|
|
62
|
+
case "prod":
|
|
63
|
+
return `https://api.${service}.services.embrace.ai/graphql/schema`;
|
|
64
|
+
case "dev":
|
|
65
|
+
return `https://api.${service}.services.dev.embrace.ai/graphql/schema`;
|
|
66
|
+
default:
|
|
67
|
+
if (stage.startsWith("pr-")) {
|
|
68
|
+
return `https://api.${service}.${stage}.services.dev.embrace.ai/graphql/schema`;
|
|
69
|
+
}
|
|
70
|
+
// Default to dev pattern for unknown stages
|
|
71
|
+
return `https://api.${service}.services.dev.embrace.ai/graphql/schema`;
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Detect the current repository name from git
|
|
77
|
+
* @returns {string} Repository name
|
|
78
|
+
*/
|
|
79
|
+
const detectRepositoryName = () => {
|
|
80
|
+
try {
|
|
81
|
+
// Try to get repository name from git remote
|
|
82
|
+
const remoteUrl = execSync("git config --get remote.origin.url", {
|
|
83
|
+
encoding: "utf8",
|
|
84
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
85
|
+
}).trim();
|
|
86
|
+
|
|
87
|
+
// Extract repository name from various URL formats
|
|
88
|
+
// https://github.com/org/repo.git -> repo
|
|
89
|
+
// git@github.com:org/repo.git -> repo
|
|
90
|
+
// https://github.com/org/repo -> repo
|
|
91
|
+
const match = remoteUrl.match(/[/:]([^/]+?)(?:\.git)?$/);
|
|
92
|
+
if (match) {
|
|
93
|
+
return match[1];
|
|
94
|
+
}
|
|
95
|
+
} catch (error) {
|
|
96
|
+
// Fallback: try to get from current directory name
|
|
97
|
+
console.warn(
|
|
98
|
+
"⚠️ Could not detect repository from git, using directory name: ",
|
|
99
|
+
error.message,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Fallback to current directory name
|
|
104
|
+
return path.basename(process.cwd());
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Extract service name from repository name
|
|
109
|
+
* @param {string} repo - Repository name
|
|
110
|
+
* @returns {string} Service name
|
|
111
|
+
*/
|
|
112
|
+
const extractServiceName = (repo) => {
|
|
113
|
+
// Handle common repository naming patterns
|
|
114
|
+
// Remove common prefixes/suffixes but preserve the core service name
|
|
115
|
+
let serviceName = repo
|
|
116
|
+
.replace(/^(api-|service-|microservice-|ms-)/, "") // Remove API prefixes
|
|
117
|
+
.replace(/(-api|-service|-microservice|-ms)$/, "") // Remove API suffixes
|
|
118
|
+
.replace(/^embrace-/, ""); // Remove embrace prefix
|
|
119
|
+
|
|
120
|
+
// Handle special cases
|
|
121
|
+
if (serviceName === "embrace" || serviceName === "") {
|
|
122
|
+
return "embrace";
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Keep the full service name (e.g., "orgs-core", "integrations-plugins")
|
|
126
|
+
return serviceName;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* CLI command handler
|
|
131
|
+
*/
|
|
132
|
+
export const generateSchemaUrl = (opts) => {
|
|
133
|
+
try {
|
|
134
|
+
const { stage, service, repo } = opts;
|
|
135
|
+
|
|
136
|
+
console.log("🔧 Generating schema URL...");
|
|
137
|
+
|
|
138
|
+
// Detect values if not provided
|
|
139
|
+
const detectedRepo = repo || detectRepositoryName();
|
|
140
|
+
const detectedService = service || extractServiceName(detectedRepo);
|
|
141
|
+
|
|
142
|
+
console.log(`📁 Repository: ${detectedRepo}`);
|
|
143
|
+
console.log(`🏷️ Service: ${detectedService}`);
|
|
144
|
+
console.log(`🚀 Stage: ${stage}`);
|
|
145
|
+
|
|
146
|
+
const url = buildSchemaUrl({
|
|
147
|
+
stage,
|
|
148
|
+
service: detectedService,
|
|
149
|
+
repo: detectedRepo,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
console.log(`🌐 Generated URL: ${url}`);
|
|
153
|
+
|
|
154
|
+
// Output just the URL for easy consumption by scripts
|
|
155
|
+
process.stdout.write(url);
|
|
156
|
+
|
|
157
|
+
return url;
|
|
158
|
+
} catch (error) {
|
|
159
|
+
console.error(`❌ Failed to generate schema URL: ${error.message}`);
|
|
160
|
+
process.exit(1);
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
// Default export for CLI
|
|
165
|
+
export default generateSchemaUrl;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
|
|
3
|
+
export const generateSummaryFromReport = (report) => {
|
|
4
|
+
if (!report || report.length === 0) {
|
|
5
|
+
return "No GraphQL validation errors found.";
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
let output = "## 🚨 GraphQL Schema Validation Issues\n\n";
|
|
9
|
+
|
|
10
|
+
// Group issues by file
|
|
11
|
+
const grouped = report.reduce((acc, issue) => {
|
|
12
|
+
const file = issue.location?.file || "Unknown file";
|
|
13
|
+
if (!acc[file]) {
|
|
14
|
+
acc[file] = [];
|
|
15
|
+
}
|
|
16
|
+
acc[file].push(issue);
|
|
17
|
+
return acc;
|
|
18
|
+
}, {});
|
|
19
|
+
|
|
20
|
+
for (const [file, issues] of Object.entries(grouped)) {
|
|
21
|
+
output += `### \`${file}\`\n`;
|
|
22
|
+
issues.forEach(({ message, location, operation }) => {
|
|
23
|
+
const line = location?.line || "?";
|
|
24
|
+
output += `- ❌ \`${operation || "Unknown Operation"}\`: ${message} (line ${line})\n`;
|
|
25
|
+
});
|
|
26
|
+
output += "\n";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return output;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// CLI function that handles file I/O
|
|
33
|
+
export const generateSummary = (opts) => {
|
|
34
|
+
const reportPath = opts.report;
|
|
35
|
+
const outputPath = opts.output;
|
|
36
|
+
|
|
37
|
+
const raw = fs.readFileSync(reportPath, "utf-8");
|
|
38
|
+
const report = JSON.parse(raw);
|
|
39
|
+
|
|
40
|
+
const summary = generateSummaryFromReport(report);
|
|
41
|
+
|
|
42
|
+
if (outputPath) {
|
|
43
|
+
fs.writeFileSync(outputPath, summary, "utf-8");
|
|
44
|
+
console.log(`✅ Summary written to ${outputPath}`);
|
|
45
|
+
} else {
|
|
46
|
+
console.log(summary);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// Support direct script usage (for backwards compatibility)
|
|
51
|
+
if (process.argv[1] && process.argv[1].endsWith("generate-summary.js")) {
|
|
52
|
+
const reportPath = process.argv[2] || "report.json";
|
|
53
|
+
const raw = fs.readFileSync(reportPath, "utf-8");
|
|
54
|
+
const report = JSON.parse(raw);
|
|
55
|
+
|
|
56
|
+
const summary = generateSummaryFromReport(report);
|
|
57
|
+
console.log(summary);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Default export for CLI
|
|
61
|
+
export default generateSummary;
|
package/src/index.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
|
|
4
|
+
const program = new Command();
|
|
5
|
+
|
|
6
|
+
program
|
|
7
|
+
.name("api-tool")
|
|
8
|
+
.description("CLI for API schema management")
|
|
9
|
+
.version("0.1.0");
|
|
10
|
+
|
|
11
|
+
program
|
|
12
|
+
.command("fetch")
|
|
13
|
+
.description("Fetch GraphQL schema from a URL")
|
|
14
|
+
.requiredOption("--url <url>", "Schema URL")
|
|
15
|
+
.requiredOption("--output <file>", "Output file path")
|
|
16
|
+
.action(async (opts) => {
|
|
17
|
+
const { default: fetchSchema } = await import("./fetch-schema.js");
|
|
18
|
+
return fetchSchema(opts);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
program
|
|
22
|
+
.command("validate")
|
|
23
|
+
.description("Validate queries against schema")
|
|
24
|
+
.requiredOption("--schema <file>", "Schema file path")
|
|
25
|
+
.requiredOption("--queries <glob>", "Glob pattern to GraphQL query files")
|
|
26
|
+
.requiredOption("--output <file>", "JSON report output")
|
|
27
|
+
.action(async (opts) => {
|
|
28
|
+
const { default: validateSchema } = await import("./validate-schema.js");
|
|
29
|
+
return validateSchema(opts);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
program
|
|
33
|
+
.command("summarize")
|
|
34
|
+
.description("Generate markdown from a validation report")
|
|
35
|
+
.requiredOption("--report <file>", "Validation JSON report")
|
|
36
|
+
.requiredOption("--output <file>", "Markdown file path")
|
|
37
|
+
.action(async (opts) => {
|
|
38
|
+
const { default: generateSummary } = await import("./generate-summary.js");
|
|
39
|
+
return generateSummary(opts);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
program
|
|
43
|
+
.command("generate-schema-url")
|
|
44
|
+
.description("Generate dynamic schema URL based on repository and stage")
|
|
45
|
+
.option("--stage <stage>", "Deployment stage (dev, prod, pr-*)", "dev")
|
|
46
|
+
.option(
|
|
47
|
+
"--service <service>",
|
|
48
|
+
"Service name (auto-detected from repo if not provided)",
|
|
49
|
+
)
|
|
50
|
+
.option(
|
|
51
|
+
"--repo <repo>",
|
|
52
|
+
"Repository name (auto-detected from git if not provided)",
|
|
53
|
+
)
|
|
54
|
+
.action(async (opts) => {
|
|
55
|
+
const { default: generateSchemaUrl } = await import(
|
|
56
|
+
"./generate-schema-url.js"
|
|
57
|
+
);
|
|
58
|
+
return generateSchemaUrl(opts);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
program
|
|
62
|
+
.command("dispatch-update")
|
|
63
|
+
.description("Dispatch schema update events to organization repositories")
|
|
64
|
+
.requiredOption("--schema-url <url>", "Schema URL to dispatch")
|
|
65
|
+
.requiredOption("--token <token>", "GitHub token for API access")
|
|
66
|
+
.option(
|
|
67
|
+
"--source-repo <repo>",
|
|
68
|
+
"Source repository (auto-detected if not provided)",
|
|
69
|
+
)
|
|
70
|
+
.option("--commit <sha>", "Git commit SHA (auto-detected if not provided)")
|
|
71
|
+
.option(
|
|
72
|
+
"--branch <branch>",
|
|
73
|
+
"Git branch name (auto-detected if not provided)",
|
|
74
|
+
)
|
|
75
|
+
.option(
|
|
76
|
+
"--org-name <org>",
|
|
77
|
+
"GitHub organization name (auto-detected if not provided)",
|
|
78
|
+
)
|
|
79
|
+
.option(
|
|
80
|
+
"--repo-filter <pattern>",
|
|
81
|
+
"Regex pattern to filter repositories",
|
|
82
|
+
".*",
|
|
83
|
+
)
|
|
84
|
+
.option("--dry-run", "Show what would be dispatched without sending", false)
|
|
85
|
+
.action(async (opts) => {
|
|
86
|
+
const { default: dispatchUpdate } = await import("./dispatch-update.js");
|
|
87
|
+
return dispatchUpdate(opts);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
program.parse();
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { validate } from "@graphql-inspector/core";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import fse from "fs-extra";
|
|
4
|
+
import { buildSchema } from "graphql";
|
|
5
|
+
import path from "path";
|
|
6
|
+
|
|
7
|
+
export const validateSchema = async ({ schema, queries, output }) => {
|
|
8
|
+
try {
|
|
9
|
+
console.log(`🔄 Validating GraphQL queries against schema...`);
|
|
10
|
+
console.log(`📄 Schema: ${schema}`);
|
|
11
|
+
console.log(`🔍 Queries: ${queries}`);
|
|
12
|
+
|
|
13
|
+
// Validate input files exist
|
|
14
|
+
if (!fs.existsSync(schema)) {
|
|
15
|
+
throw new Error(`Schema file not found: ${schema}`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Ensure output directory exists
|
|
19
|
+
const outputDir = path.dirname(output);
|
|
20
|
+
fse.ensureDirSync(outputDir);
|
|
21
|
+
|
|
22
|
+
// Load and parse the schema
|
|
23
|
+
const schemaContent = fs.readFileSync(schema, "utf8");
|
|
24
|
+
const graphqlSchema = buildSchema(schemaContent);
|
|
25
|
+
|
|
26
|
+
// Find and load query files based on the glob pattern
|
|
27
|
+
const queryFiles = findFiles(queries);
|
|
28
|
+
|
|
29
|
+
if (queryFiles.length === 0) {
|
|
30
|
+
console.log(`⚠️ No query files found matching pattern: ${queries}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Create source objects for GraphQL Inspector
|
|
34
|
+
const sources = queryFiles.map((filePath) => ({
|
|
35
|
+
name: filePath,
|
|
36
|
+
body: fs.readFileSync(filePath, "utf8"),
|
|
37
|
+
location: filePath,
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
console.log(
|
|
41
|
+
`🚀 Validating ${sources.length} query file(s) against schema...`,
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
// Run validation using GraphQL Inspector core
|
|
45
|
+
const invalidDocuments = validate(graphqlSchema, sources, {
|
|
46
|
+
strictDeprecated: false, // Don't fail on deprecated usage by default
|
|
47
|
+
strictFragments: true,
|
|
48
|
+
apollo: false,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Transform the results to match expected output format
|
|
52
|
+
const validationReport = [];
|
|
53
|
+
|
|
54
|
+
for (const doc of invalidDocuments) {
|
|
55
|
+
// Add validation errors
|
|
56
|
+
for (const error of doc.errors) {
|
|
57
|
+
validationReport.push({
|
|
58
|
+
message: error.message,
|
|
59
|
+
location: {
|
|
60
|
+
file: doc.source.name,
|
|
61
|
+
line: error.locations?.[0]?.line || 1,
|
|
62
|
+
column: error.locations?.[0]?.column || 1,
|
|
63
|
+
},
|
|
64
|
+
operation: "validation",
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Add deprecated usage warnings
|
|
69
|
+
for (const deprecated of doc.deprecated) {
|
|
70
|
+
validationReport.push({
|
|
71
|
+
message: `Deprecated: ${deprecated.message}`,
|
|
72
|
+
location: {
|
|
73
|
+
file: doc.source.name,
|
|
74
|
+
line: deprecated.locations?.[0]?.line || 1,
|
|
75
|
+
column: deprecated.locations?.[0]?.column || 1,
|
|
76
|
+
},
|
|
77
|
+
operation: "deprecated",
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Write validation report to output file
|
|
83
|
+
fs.writeFileSync(output, JSON.stringify(validationReport, null, 2), "utf8");
|
|
84
|
+
|
|
85
|
+
// Log results
|
|
86
|
+
const errorCount = validationReport.length;
|
|
87
|
+
if (errorCount === 0) {
|
|
88
|
+
console.log(`✅ Validation completed successfully - no issues found`);
|
|
89
|
+
console.log(`📊 Report saved to ${output}`);
|
|
90
|
+
} else {
|
|
91
|
+
console.log(`⚠️ Validation completed with ${errorCount} issue(s) found`);
|
|
92
|
+
console.log(`📊 Report saved to ${output}`);
|
|
93
|
+
|
|
94
|
+
// Show summary of issues
|
|
95
|
+
const fileGroups = validationReport.reduce((acc, issue) => {
|
|
96
|
+
const file = issue.location?.file || "Unknown file";
|
|
97
|
+
acc[file] = (acc[file] || 0) + 1;
|
|
98
|
+
return acc;
|
|
99
|
+
}, {});
|
|
100
|
+
|
|
101
|
+
console.log(`📋 Issues by file:`);
|
|
102
|
+
Object.entries(fileGroups).forEach(([file, count]) => {
|
|
103
|
+
console.log(` ${file}: ${count} issue(s)`);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
} catch (error) {
|
|
107
|
+
console.error(`❌ Validation failed: ${error.message}`);
|
|
108
|
+
process.exit(1);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const findFiles = (pattern) => {
|
|
113
|
+
// Simple glob-like pattern matching for GraphQL files
|
|
114
|
+
// This handles basic patterns like "*.graphql", "**/*.graphql", etc.
|
|
115
|
+
|
|
116
|
+
if (fs.existsSync(pattern) && fs.statSync(pattern).isFile()) {
|
|
117
|
+
// If it's a direct file path, return it
|
|
118
|
+
return [pattern];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Handle glob patterns
|
|
122
|
+
const files = [];
|
|
123
|
+
|
|
124
|
+
if (pattern.includes("*")) {
|
|
125
|
+
// Basic glob support - find all .graphql files in current directory
|
|
126
|
+
const dir = pattern.includes("/") ? path.dirname(pattern) : ".";
|
|
127
|
+
const searchDir = fs.existsSync(dir) ? dir : ".";
|
|
128
|
+
|
|
129
|
+
const searchDirectory = (dirPath, recursive = false) => {
|
|
130
|
+
const entries = fs.readdirSync(dirPath);
|
|
131
|
+
|
|
132
|
+
for (const entry of entries) {
|
|
133
|
+
const fullPath = path.join(dirPath, entry);
|
|
134
|
+
const stat = fs.statSync(fullPath);
|
|
135
|
+
|
|
136
|
+
if (stat.isDirectory() && recursive) {
|
|
137
|
+
searchDirectory(fullPath, true);
|
|
138
|
+
} else if (stat.isFile() && entry.endsWith(".graphql")) {
|
|
139
|
+
files.push(fullPath);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const recursive = pattern.includes("**");
|
|
145
|
+
searchDirectory(searchDir, recursive);
|
|
146
|
+
} else {
|
|
147
|
+
// Direct file or simple pattern
|
|
148
|
+
if (fs.existsSync(pattern)) {
|
|
149
|
+
files.push(pattern);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return files;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
// Default export for CLI
|
|
157
|
+
export default validateSchema;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# .github/workflows/emit-schema-update.yml
|
|
2
|
+
name: Emit GraphQL Schema Update Event
|
|
3
|
+
|
|
4
|
+
on:
|
|
5
|
+
push:
|
|
6
|
+
branches:
|
|
7
|
+
- main
|
|
8
|
+
paths:
|
|
9
|
+
- '**/*schema.ts'
|
|
10
|
+
|
|
11
|
+
env:
|
|
12
|
+
# Configure these environment variables for your setup
|
|
13
|
+
# SCHEMA_URL will be dynamically generated based on repository and stage
|
|
14
|
+
# You can override this by setting a static URL if needed
|
|
15
|
+
# SCHEMA_URL: "https://api.dev.embrace.ai/v2/graphql/schema"
|
|
16
|
+
|
|
17
|
+
# Deployment stage - determines the environment URL pattern
|
|
18
|
+
DEPLOYMENT_STAGE: "dev" # Options: dev, prod, pr-* (e.g., pr-123)
|
|
19
|
+
# Optional: Override service name (auto-detected from repository name if not set)
|
|
20
|
+
# SERVICE_NAME: "your-service-name"
|
|
21
|
+
|
|
22
|
+
# Optional: Filter repositories by name pattern (regex)
|
|
23
|
+
REPO_FILTER: ".*" # Default: all repos. Example: "^(frontend|mobile|web)-.*" for specific patterns
|
|
24
|
+
permissions:
|
|
25
|
+
id-token: write
|
|
26
|
+
contents: read
|
|
27
|
+
|
|
28
|
+
jobs:
|
|
29
|
+
emit-schema-update:
|
|
30
|
+
runs-on: ubuntu-latest
|
|
31
|
+
container:
|
|
32
|
+
image: node:22.16.0@sha256:71bcbb3b215b3fa84b5b167585675072f4c270855e37a599803f1a58141a0716
|
|
33
|
+
options: --privileged --user root
|
|
34
|
+
steps:
|
|
35
|
+
- uses: actions/checkout@v4
|
|
36
|
+
|
|
37
|
+
- name: Setup pnpm
|
|
38
|
+
uses: pnpm/action-setup@v4
|
|
39
|
+
- name: Setup node cache
|
|
40
|
+
uses: actions/setup-node@v4
|
|
41
|
+
with:
|
|
42
|
+
node-version: 22.16.0
|
|
43
|
+
cache: 'pnpm'
|
|
44
|
+
- name: Setup NPM
|
|
45
|
+
env:
|
|
46
|
+
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
47
|
+
run: pnpm config set //registry.npmjs.org/:_authToken "${NPM_TOKEN}"
|
|
48
|
+
- name: Install dependencies
|
|
49
|
+
run: pnpm i --frozen-lockfile
|
|
50
|
+
|
|
51
|
+
- name: Install GraphQL schema sync tool
|
|
52
|
+
run: npm install @embrace-ai/infra-api-schema-sync
|
|
53
|
+
# Generate dynamic schema URL based on repository and stage
|
|
54
|
+
- name: Generate schema URL
|
|
55
|
+
id: schema-url
|
|
56
|
+
run: |
|
|
57
|
+
# Generate URL using the CLI tool
|
|
58
|
+
if [ -n "$SERVICE_NAME" ]; then
|
|
59
|
+
SCHEMA_URL=$(npx api-tool generate-schema-url --stage "$DEPLOYMENT_STAGE" --service "$SERVICE_NAME")
|
|
60
|
+
else
|
|
61
|
+
SCHEMA_URL=$(npx api-tool generate-schema-url --stage "$DEPLOYMENT_STAGE")
|
|
62
|
+
fi
|
|
63
|
+
|
|
64
|
+
# Set as output for use in later steps
|
|
65
|
+
echo "url=$SCHEMA_URL" >> $GITHUB_OUTPUT
|
|
66
|
+
echo "🌐 Generated schema URL: $SCHEMA_URL"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# Fetch all organization repositories and dispatch to each
|
|
70
|
+
- name: Dispatch schema update events to all org repositories
|
|
71
|
+
env:
|
|
72
|
+
# GitHub token with repo scope - defaults to GITHUB_TOKEN but can be overridden
|
|
73
|
+
# Use the dynamically generated schema URL
|
|
74
|
+
SCHEMA_URL: ${{ steps.schema-url.outputs.url }}
|
|
75
|
+
run: |
|
|
76
|
+
npx api-tool dispatch-update \
|
|
77
|
+
--schema-url "$SCHEMA_URL" \
|
|
78
|
+
--token "${{ secrets.GITHUB_TOKEN }}" \
|
|
79
|
+
--source-repo "${{ github.repository }}" \
|
|
80
|
+
--commit "${{ github.sha }}" \
|
|
81
|
+
--branch "${{ github.ref_name }}" \
|
|
82
|
+
--org-name "${{ github.repository_owner }}" \
|
|
83
|
+
--repo-filter "$REPO_FILTER"
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# .github/workflows/graphql-schema-validate.yml
|
|
2
|
+
name: Validate GraphQL Schema Updates
|
|
3
|
+
|
|
4
|
+
on:
|
|
5
|
+
repository_dispatch:
|
|
6
|
+
types: [graphql-schema-updated]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
validate-schema:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v4
|
|
15
|
+
|
|
16
|
+
- name: Setup pnpm
|
|
17
|
+
uses: pnpm/action-setup@v4
|
|
18
|
+
|
|
19
|
+
- name: Setup node cache
|
|
20
|
+
uses: actions/setup-node@v4
|
|
21
|
+
with:
|
|
22
|
+
node-version: 22.16.0
|
|
23
|
+
cache: 'pnpm'
|
|
24
|
+
|
|
25
|
+
- name: Setup NPM
|
|
26
|
+
env:
|
|
27
|
+
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
28
|
+
run: pnpm config set //registry.npmjs.org/:_authToken "${NPM_TOKEN}"
|
|
29
|
+
|
|
30
|
+
- name: Install dependencies
|
|
31
|
+
run: pnpm i --frozen-lockfile
|
|
32
|
+
|
|
33
|
+
- name: Install GraphQL schema sync tool
|
|
34
|
+
run: npm install @embrace-ai/infra-api-schema-sync
|
|
35
|
+
|
|
36
|
+
- name: Configure aws credentials
|
|
37
|
+
uses: aws-actions/configure-aws-credentials@v4.2.1
|
|
38
|
+
with:
|
|
39
|
+
role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_DEV }}
|
|
40
|
+
aws-region: ${{ vars.AWS_REGION }}
|
|
41
|
+
|
|
42
|
+
- name: Display schema update context
|
|
43
|
+
run: |
|
|
44
|
+
echo "Schema update received from: ${{ github.event.client_payload.sourceRepo }}"
|
|
45
|
+
echo "Schema URL: ${{ github.event.client_payload.schemaUrl }}"
|
|
46
|
+
echo "Source commit: ${{ github.event.client_payload.commit }}"
|
|
47
|
+
echo "Source branch: ${{ github.event.client_payload.branch }}"
|
|
48
|
+
echo "Timestamp: ${{ github.event.client_payload.timestamp }}"
|
|
49
|
+
|
|
50
|
+
- name: Fetch updated schema
|
|
51
|
+
run: npx api-tool fetch --url ${{ github.event.client_payload.schemaUrl }} --output new-schema.graphql
|
|
52
|
+
|
|
53
|
+
- name: Validate queries against new schema
|
|
54
|
+
id: validate
|
|
55
|
+
run: npx api-tool validate --schema new-schema.graphql --queries 'src/**/*.graphql' --output report.json
|
|
56
|
+
|
|
57
|
+
- name: Check for issues
|
|
58
|
+
id: check-issues
|
|
59
|
+
run: |
|
|
60
|
+
if [ -s report.json ] && [ "$(jq '.issues | length' report.json)" -gt 0 ]; then
|
|
61
|
+
echo "has_issues=true" >> $GITHUB_OUTPUT
|
|
62
|
+
echo "issue_count=$(jq '.issues | length' report.json)" >> $GITHUB_OUTPUT
|
|
63
|
+
else
|
|
64
|
+
echo "has_issues=false" >> $GITHUB_OUTPUT
|
|
65
|
+
echo "issue_count=0" >> $GITHUB_OUTPUT
|
|
66
|
+
fi
|
|
67
|
+
|
|
68
|
+
- name: Generate summary
|
|
69
|
+
id: gen-summary
|
|
70
|
+
if: steps.check-issues.outputs.has_issues == 'true'
|
|
71
|
+
run: npx api-tool summarize --report report.json --output GRAPHQL_ISSUES.md && echo "summary=$(cat GRAPHQL_ISSUES.md)" >> $GITHUB_OUTPUT
|
|
72
|
+
|
|
73
|
+
- name: Commit and create PR with issues
|
|
74
|
+
uses: peter-evans/create-pull-request@v6
|
|
75
|
+
with:
|
|
76
|
+
commit-message: "chore(api): GraphQL schema update from ${{ github.event.client_payload.sourceRepo }}"
|
|
77
|
+
branch: "graphql/schema-update-${{ github.run_id }}"
|
|
78
|
+
title: "GraphQL Schema Update (${{ steps.check-issues.outputs.issue_count }} issues)"
|
|
79
|
+
body: |
|
|
80
|
+
# GraphQL Schema Update Impact Report
|
|
81
|
+
|
|
82
|
+
**Schema Source:** ${{ github.event.client_payload.sourceRepo }}
|
|
83
|
+
**Schema URL:** ${{ github.event.client_payload.schemaUrl }}
|
|
84
|
+
**Source Commit:** ${{ github.event.client_payload.commit }}
|
|
85
|
+
**Source Branch:** ${{ github.event.client_payload.branch }}
|
|
86
|
+
**Update Time:** ${{ github.event.client_payload.timestamp }}
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
## ${{ steps.check-issues.outputs.has_issues == 'true' && 'Detected Issues' || '🎉 No Issues Found' }}
|
|
90
|
+
${{
|
|
91
|
+
steps.check-issues.outputs.has_issues == 'true'
|
|
92
|
+
? steps.gen-summary.outputs.summary
|
|
93
|
+
: 'The updated schema is compatible with your existing GraphQL queries. No changes are required.'
|
|
94
|
+
}}
|
|
95
|
+
|
|
96
|
+
## Next Steps
|
|
97
|
+
1. Review the compatibility issues above
|
|
98
|
+
2. Update your GraphQL queries to match the new schema
|
|
99
|
+
3. Test your changes thoroughly
|
|
100
|
+
4. Merge this PR once all issues are resolved
|
|
101
|
+
|
|
102
|
+
- name: Create success comment
|
|
103
|
+
if: steps.check-issues.outputs.has_issues == 'false'
|
|
104
|
+
run: |
|
|
105
|
+
echo "✅ No GraphQL compatibility issues found!"
|
|
106
|
+
echo "Schema from ${{ github.event.client_payload.sourceRepo }} is compatible with existing queries."
|