Skip to content

Commit d3d5632

Browse files
committed
first commit
0 parents  commit d3d5632

7 files changed

Lines changed: 646 additions & 0 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
node_modules

.nvmrc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
v18.18.2

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2021 Francesco Pira
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# GitHub GraphQL API client in Node.js
2+
3+
A thin wrapper to commit on a GitHub repo through their GraphQL APIs. Useful to create signed commits in CI/CD environments.
4+
5+
Offered as node module and CLI tool.
6+
7+
## Why
8+
9+
- Commit changes to a GitHub repository without cloning it locally
10+
- By using the GitHub GraphQL API, we can commit multiple changes at once
11+
- By using GitHub APIs, we can implicitly sign commits via web-flow signing, like vscode.dev does
12+
13+
## Use cases
14+
15+
- Automate the process of committing file additions, changes, or deletions to a GitHub repository without cloning it locally
16+
- Integrate with existing CI/CD pipelines perform signed commits on behalf of the pipeline, without hard-to-setup GPG config
17+
- Avoid storing private SSH keys in CI/CD environments (only the GITHUB_TOKEN is needed and can be easily saved as secret string passed as environment variable at pipeline runtime)
18+
- you name it...
19+
20+
## ⚠️ Before you start
21+
22+
- Changed (or new) files must exist locally
23+
- for practial reasons, those files must have the same file name and file path as the ones in the repository you are replacing with your commit (or the same file name and file path you want them to have in the repository)
24+
- Deleted files may not exist locally, and their path may just be provided as argument
25+
- GraphQL APIs are not meant to be used to push a lot of code! If that is your case, please consider using a local clone and `git`.
26+
27+
## Requirements
28+
29+
- Node.js (18+)
30+
- A GitHub token with the `repo` scope.
31+
- The token must be set in the environment variable called `GITHUB_TOKEN`.
32+
33+
Note: in GitHub Actions the `GITHUB_TOKEN` is automatically generated per each run and is available as an environment variable. More info [here](https://docs.github.com/en/actions/security-guides/automatic-token-authentication).
34+
35+
## Installation
36+
37+
```sh
38+
npm install
39+
```
40+
41+
## Usage examples
42+
43+
```sh
44+
export GITHUB_TOKEN='your_github_token_here'
45+
node github.js commit \
46+
--owner yourname \
47+
--repo some_repo_of_yours \
48+
--branch main \
49+
--added .gitignore \
50+
--commitMessage 'added .gitignore'
51+
```
52+
53+
```sh
54+
export GITHUB_TOKEN='your_github_token_here'
55+
node github.js commit \
56+
--owner yourname \
57+
--repo some_repo_of_yours \
58+
--branch main \
59+
--deleted .gitignore \
60+
--commitMessage 'remove .gitignore'
61+
```
62+
63+
```sh
64+
export GITHUB_TOKEN='your_github_token_here'
65+
node github.js commit \
66+
--owner yourname \
67+
--repo some_repo_of_yours \
68+
--branch main \
69+
--changed 'some_dir/some_file.txt' \
70+
--changed 'some_other_dir/some_other_file.txt' \
71+
--deleted 'some_dir/delete_me.txt' \
72+
--commitMessage 'stuff'
73+
```
74+
75+
## License
76+
77+
MIT

github.js

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
const fs = require("fs");
2+
const { createClient, cacheExchange, fetchExchange } = require("@urql/core");
3+
const yargs = require("yargs");
4+
const CURRENT_VERSION = require("./package.json").version;
5+
6+
const GITHUB_GRAPHQL_URL =
7+
process.env.GITHUB_GRAPHQL_URL || "https://api.github.com/graphql";
8+
9+
const githubToken = process.env.GITHUB_TOKEN;
10+
if (!githubToken) {
11+
throw new Error("ERROR: GITHUB_TOKEN environment variable not set.");
12+
}
13+
14+
const client = createClient({
15+
url: GITHUB_GRAPHQL_URL,
16+
exchanges: [cacheExchange, fetchExchange],
17+
fetchOptions: {
18+
headers: {
19+
Authorization: `Bearer ${githubToken}`,
20+
},
21+
},
22+
});
23+
24+
function arrayHasElements(array) {
25+
return (array && Array.isArray(array) && array.length > 0)
26+
}
27+
28+
function extractChangedOrDeletedFiles(changedFiles, deletedFiles) {
29+
const changedFilesExist = arrayHasElements(changedFiles);
30+
const deletedFilesExist = arrayHasElements(deletedFiles);
31+
32+
if (!changedFilesExist && !deletedFilesExist) {
33+
throw new Error("No files specified as changed or deleted. Quitting.");
34+
}
35+
36+
return {
37+
changedFiles: changedFilesExist ? changedFiles : [],
38+
deletedFiles: deletedFilesExist ? deletedFiles : [],
39+
}
40+
}
41+
42+
async function fetchBranchData(repoOwner, repoName, branchName) {
43+
const query = `
44+
query($owner: String!, $repo: String!, $branch: String!) {
45+
repository(owner: $owner, name: $repo) {
46+
ref(qualifiedName: $branch) {
47+
name,
48+
target {
49+
oid
50+
}
51+
}
52+
}
53+
}
54+
`;
55+
56+
const variables = {
57+
owner: repoOwner,
58+
repo: repoName,
59+
branch: branchName,
60+
};
61+
62+
try {
63+
const response = await client.query(query, variables);
64+
return response
65+
} catch (error) {
66+
console.error(`Error while trying to fetching data from API: ${error.message}`);
67+
throw error;
68+
}
69+
}
70+
71+
async function checkIfBranchExists(repoOwner, repoName, branchName) {
72+
const response = await fetchBranchData(repoOwner, repoName, branchName);
73+
// NB. if response.data.repository.ref is null the remote branch does not exist!
74+
return response?.data?.repository?.ref || null;
75+
}
76+
77+
async function getShaOfParentCommit(repoOwner, repoName, branchName) {
78+
const response = await fetchBranchData(repoOwner, repoName, branchName);
79+
return response?.data?.repository?.ref?.target?.oid || null;
80+
}
81+
82+
async function createCommitOnBranch(
83+
repoOwner, // Owner of the repository
84+
repoName, // Name of the repository
85+
branchName, // Name of the branch to commit to
86+
changedFiles, // Array of paths of new or modified files
87+
deletedFiles, // Array of paths of tracked deleted files
88+
commitMessage, // Mandatory commit message
89+
commitDescription = null // Optional commit description
90+
) {
91+
92+
const parentCommit = await getShaOfParentCommit(repoOwner, repoName, branchName);
93+
if (!parentCommit) {
94+
throw new Error("Could not get SHA of parent commit. Does the branch exist? Aborting.");
95+
}
96+
97+
const graphqlRequest = {
98+
query: `mutation ($input: CreateCommitOnBranchInput!) { createCommitOnBranch(input: $input) { commit { url } } }`,
99+
variables: {
100+
input: {
101+
branch: {
102+
repositoryNameWithOwner: repoOwner + "/" + repoName,
103+
branchName: branchName,
104+
},
105+
message: {
106+
headline: commitMessage,
107+
},
108+
fileChanges: {},
109+
expectedHeadOid: parentCommit,
110+
},
111+
},
112+
};
113+
114+
({ changedFiles, deletedFiles } = extractChangedOrDeletedFiles(changedFiles, deletedFiles));
115+
console.log("Changed files:", JSON.stringify(changedFiles, null, 2));
116+
console.log("Deleted files:", JSON.stringify(deletedFiles, null, 2));
117+
118+
if (!commitMessage) { throw new Error("No commit message provided. Aborting."); }
119+
120+
if (commitDescription) {
121+
graphqlRequest.variables.input.message.body = commitDescription;
122+
}
123+
124+
const changedFilesGraphqlArray = changedFiles.map((file_path) => {
125+
const contents = fs.readFileSync(file_path, { encoding: "base64" });
126+
return { path: file_path, contents: contents };
127+
});
128+
if (changedFilesGraphqlArray && changedFilesGraphqlArray.length > 0) {
129+
graphqlRequest.variables.input.fileChanges.additions = changedFilesGraphqlArray;
130+
}
131+
132+
const deletedFilesGraphqlArray = deletedFiles.map((file_path) => {
133+
return { path: file_path };
134+
});
135+
if (deletedFilesGraphqlArray && deletedFilesGraphqlArray.length > 0) {
136+
graphqlRequest.variables.input.fileChanges.deletions = deletedFilesGraphqlArray;
137+
}
138+
139+
try {
140+
const response = await client
141+
.mutation(graphqlRequest.query, graphqlRequest.variables)
142+
.toPromise();
143+
return response;
144+
} catch (error) {
145+
console.error(`Error while performing commit action via GraphQL API: ${error.message}`);
146+
throw error;
147+
}
148+
}
149+
150+
module.exports = createCommitOnBranch;
151+
152+
yargs
153+
.command(
154+
"commit",
155+
"Create a commit on a branch",
156+
(yargs) => {
157+
yargs
158+
.option("owner", {
159+
describe: "Owner of the repository",
160+
demandOption: true,
161+
type: "string",
162+
})
163+
.option("repo", {
164+
describe: "Name of the repository",
165+
demandOption: true,
166+
type: "string",
167+
})
168+
.option("branch", {
169+
describe: "Name of the branch to commit to",
170+
demandOption: true,
171+
type: "string",
172+
})
173+
.option("changed", {
174+
describe: "Paths of new or modified files",
175+
alias: "added",
176+
type: "array",
177+
})
178+
.option("deleted", {
179+
describe: "Paths of tracked deleted files",
180+
type: "array",
181+
})
182+
.option("commitMessage", {
183+
describe: "Mandatory commit message",
184+
demandOption: true,
185+
type: "string",
186+
})
187+
.option("commitDescription", {
188+
describe: "Optional commit description",
189+
type: "string",
190+
})
191+
.check((argv) => {
192+
if (!argv.changed && !argv.deleted) {
193+
throw new Error("Missing required argument: either specify changed or deleted files.");
194+
}
195+
extractChangedOrDeletedFiles(argv.changed, argv.deleted);
196+
return true;
197+
})
198+
;
199+
},
200+
(argv) => {
201+
const {
202+
owner,
203+
repo,
204+
branch,
205+
changed,
206+
deleted,
207+
commitMessage,
208+
commitDescription,
209+
} = argv;
210+
211+
createCommitOnBranch(
212+
owner,
213+
repo,
214+
branch,
215+
changed,
216+
deleted,
217+
commitMessage,
218+
commitDescription
219+
)
220+
.then((response) => {
221+
console.log(JSON.stringify(response, null, 2));
222+
})
223+
.catch((error) => {
224+
console.error("Failed to create commit:", error.message);
225+
});
226+
}
227+
)
228+
.command(
229+
"branch",
230+
"Check if a branch exists",
231+
(yargs) => {
232+
yargs
233+
.option("owner", {
234+
describe: "Owner of the repository",
235+
demandOption: true,
236+
type: "string",
237+
})
238+
.option("repo", {
239+
describe: "Name of the repository",
240+
demandOption: true,
241+
type: "string",
242+
})
243+
.option("branch", {
244+
describe: "Name of the branch to check for existence",
245+
demandOption: true,
246+
type: "string",
247+
});
248+
},
249+
(argv) => {
250+
const { owner, repo, branch } = argv;
251+
checkIfBranchExists(owner, repo, branch)
252+
.then((response) => {
253+
const n = response ? "a" : "no";
254+
//console.log(JSON.stringify(response, null, 2));
255+
console.log(
256+
`Repository ${owner}/${repo} has ${n} branch named '${branch}'`
257+
);
258+
})
259+
.catch((error) => {
260+
console.error("Failed to check if branch exists:", error.message);
261+
});
262+
}
263+
)
264+
.demandCommand()
265+
.version(CURRENT_VERSION)
266+
.help().argv;

0 commit comments

Comments
 (0)