Background photo by Lukas Blazek on Unsplash
We recently released an exciting new unique feature at DeckDeckGo.
In addition to being able to deploy online your presentations as Progressive Web Apps, our web open source editor can now push their source codes to GitHub too 🎉.
This new function runs in Firebase Cloud Functions. Because we like to share our discoveries, here are the key elements we learned while developing this integration.
Access Tokens
To interact with GitHub we need a token.
Personal Token
If you are looking to interact with GitHub with your account, your can use a personal access token. Once created, you can set in the configuration of our Firebase functions. Doing so, it will be obfuscated from your code.
#!/bin/sh
firebase functions:config:set github.token="4a686......."
Firebase Auth And GitHub Token
If you are rather interested to interact with GitHub in behave of your users, you might use Firebase UI and the Firebase Authentication.
As far as I discovered, with such combination, it is unfortunately not possible to get the user’s GitHub token in a Firebase Cloud Functions. I tried to hook on the authentication events but did not find any related information in the object triggered.
I might have missed something, in such a case please let me know as soon as possible (!), but if not, to get such information, you have to find it through the signInSuccessWithAuthResult
callback of the Firebase UI configuration.
callbacks: {
signInSuccessWithAuthResult:
(authResult: firebase.auth.UserCredential, _redirectUrl) => {
const token: string =
(userCred.credential as
firebase.auth.OAuthCredential).accessToken;
return true;
},
},
Note that I opened an issue to ask how it was possible to access the token using TypeScript and the cast to OAuthCredential
was provided as answer.
File System
Before going further, you may ask yourself how we are going to be able to execute Git command in the "cloud"? I was actually asking my self the same question, and it turns out that Firebase Functions have access to a temporary folder of their file system.
The only writeable part of the filesystem is the
/tmp
directory, which you can use to store temporary files in a function instance. This is a local disk mount point known as a "tmpfs" volume in which data written to the volume is stored in memory. Note that it will consume memory resources provisioned for the function.
In addition, temporary directories are not share across functions. It means for example that you cannot use such a folder to share data.
The tmp
order has not to be hardcoded. Instead of such, the Node.js OS module can be used to retrieve the temporary folder. It can be more handy to it if for some reason it would change in the future, you never know 😉.
import * as os from "os";
console.log(os.tmpdir()); // -> /tmp
Using it together with the Path module, we can even create a short utility function to resolve files’ paths locally.
import * as path from 'path';
import * as os from 'os';
export function getFilePath(...files: string[]): string {
return path.join(os.tmpdir(), ...files);
}
console.log(getFilePath('yo', 'david.txt'); // -> /tmp/yo/david.txt
Git Commands
In order to clone a repo, or generally speaking to execute any Git commands such as commit, pull or push, I suggest the use the simple-git interface for Node.js developed by Steve King (1.5 millions weekly downloads on npm). It really eases all the work.
npm i simple-git --save
Clone
Concretely, a clone function can be implemented as following:
import * as path from 'path';
import * as os from 'os';
import simpleGit, {SimpleGit} from 'simple-git';
export async function clone(repoUrl: string, repoName: string) {
const localPath: string = path.join(os.tmpdir(), repoName);
await deleteDir(localPath);
const git: SimpleGit = simpleGit();
await git.clone(repoUrl, localPath);
}
// Demo:
(async () => {
await clone('https://github.com/deckgo/deckdeckgo/', 'deckdeckgo');
})();
Even though temporary folder are probably going to be empty, it is probably a safe bet to try to delete the working subdirectory first. That’s why I call the deleteDir
in the above function.
import * as rimraf from 'rimraf';
export function deleteDir(localPath: string): Promise<void> {
return new Promise<void>((resolve) => {
rimraf(localPath, () => {
resolve();
});
});
}
As you can notice, I use rimraf from Isaac Z. Schlueter (37 millions weekly downloads on npmjs).
npm i rimraf --save && npm i @types/rimraf --save-dev
Push
Another interesting example of Git commands is the Push request, as we do have to use the token to authenticate the request.
After searching for a solution to use the token, I notably spent some times reading this Stackoverflow question and answers, I came to the conclusion that the solution which gives the best results, to avoid exposing the token, even though we are executing the interaction in the function, was to use it in the Git URI.
Note that the token is exposed in the potential error messages, that is why I think it is also good to catch properly these too.
In addition to the token, we might need to provide our GitHub account’s username
(such as peterpeterparker for example) and email
. These information can be administrated with the configuration of our functions too.
import * as functions from 'firebase-functions';
import * as path from 'path';
import * as os from 'os';
import simpleGit, {SimpleGit} from 'simple-git';
export async function push(project: string,
branch: string) {
try {
const localPath: string = path.join(os.tmpdir(), repoName);
// Git needs to know where is has to run, that's why we pass
// the pass to the constructor of simple-git
const git: SimpleGit = getSimpleGit(localPath);
// Configure Git with the username and email
const username: string = functions.config().github.username;
const email: string = functions.config().github.email;
await git.addConfig('user.name', username);
await git.addConfig('user.email', email);
// Finally Git push
const token: string = functions.config().github.token;
await git.push(`https://${username}:${token}@github.com/${username}/${project}.git`, branch);
} catch (err) {
throw new Error(`Error pushing.`);
}
}
// Demo:
(async () => {
await push('deckdeckgo', 'my-branch');
})();
GitHub GraphQL API
The last, or new, depends on the point of view, version (v4) of the GitHub API can be use with GraphQL queries. Its documentation makes it relatively easy to search for information but the explorer, and its auto-complete feature, is probably even more handy to compose quickly flexible queries.
Query
I did not use any GraphQL clients (as for example Apollo) to perform the queries. Instead, I developed a utility to perform the HTTPS requests.
import fetch, {Response} from 'node-fetch';
async function queryGitHub(githubToken: string,
query: string): Promise<Response> {
const githubApiV4: string = 'https://api.github.com/graphql';
const rawResponse: Response = await fetch(`${githubApiV4}`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `token ${githubToken}`,
},
body: JSON.stringify({query}),
});
if (!rawResponse || !rawResponse.ok) {
throw new Error('Cannot perform GitHub query.');
}
return rawResponse;
}
As fetch
is not natively available in Node.js, I used node-fetch (16 millions weekly downloads on npm).
npm i node-fetch --save && npm i @types/node-fetch --save-dev
Query: User Information
A relatively basic example of query can be the following. In such function, we try to retrieve the GitHub login
("username") and id
corresponding to the token we are using to authenticate the request, respectively the information of the currently authenticated user.
export interface GitHubUser {
id: string;
login: string;
}
export function getUser(githubToken: string): Promise<GitHubUser> {
return new Promise<GitHubUser>(async (resolve, reject) => {
try {
const query = `
query {
viewer {
id,
login
}
}
`;
const response: Response =
await queryGitHub(githubToken, query);
const result = await response.json();
resolve(result.data.viewer);
} catch (err) {
reject(err);
}
});
}
// Demo:
(async () => {
const token: string = functions.config().github.token;
const user = await getUser(token);
console.log(user); // -> {login: 'peterpeterparker', id: '123'}
})();
Mutation: Pull Request
Creating a Pull Request is not a GraphQL query but a mutation. It needs a bit more information in comparison to previous query, but the logic behind is the same: compose a GraphQL query/mutation, send it through an HTTPS request and get the results 😁.
It is worth to notice that, in order to create a PR, the mutation will need a repositoryId
. This information can be found with the help of another GraphQL query, as for example provided when requesting repository information.
export function createPR(githubToken: string,
repositoryId: string,
branch: string): Promise<void> {
return new Promise<void>(async (resolve, reject) => {
try {
const title: string = 'feat: my title';
const body: string = `# The Pull Request body.
It supports *Markdown*.`;
// We want to provide a PR from a branch to master
const query = `
mutation CreatePullRequest {
createPullRequest(input:{baseRefName:"master",body:"${body}",headRefName:"${branch}",repositoryId:"${repositoryId}",title:"${title}"}) {
pullRequest {
id
}
}
}
`;
const response: Response =
await queryGitHub(githubToken, query);
const result = await response.json();
if (!result || !result.data ||
!result.data.createPullRequest || result.errors) {
resolve(undefined);
return;
}
resolve();
} catch (err) {
reject(err);
}
});
}
// Demo:
(async () => {
const token: string = functions.config().github.token;
await createPR(token, '6789', 'my-branch');
})();
Summary
I learned many new things while developing this feature and, I hope that with the help of this blog post, I was able to share the major learnings.
In addition, we are open source, you can always have a look at our repo’s source code or contribute to our project.
You are also most welcomed to give a try to DeckDeckGo for your next presentations.
I am also looking forward to checkout and give a try to the GitHub repos that will contain the source code of your slides 😉.
To infinity and beyond!
David