Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add GitHubPullRequestWorkflow #634

Merged
merged 5 commits into from
Sep 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/smooth-dolls-add.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@jpmorganchase/mosaic-cli': patch
'@jpmorganchase/mosaic-schemas': patch
'@jpmorganchase/mosaic-site': patch
'@jpmorganchase/mosaic-types': patch
'@jpmorganchase/mosaic-workflows': patch
---

Feat: add GithubPullRequestWorkflow

This workflow requires a [fine-grained github personal access token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) so that the github rest API can be used to create Pull Requests.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"packages/*"
],
"engines": {
"node": ">=16.10.0 || >=18.0.0"
"node": ">=18.0.0 || >=20.0.0"
},
"bin": {
"mosaic": "yarn workspace @jpmorganchase/mosaic-cli"
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
}
},
"engines": {
"node": ">=16.10.0 || >=18.0.0"
"node": ">=18.0.0 || >=20.0.0"
},
"bin": {
"mosaic": "./dist/index.mjs"
Expand Down
5 changes: 3 additions & 2 deletions packages/cli/src/plugins/mosaicWorkflowsPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ async function mosaicWorkflows(fastify: FastifyInstance, _options) {
);
}

const channel = md5(`${user.sid.toLowerCase()} - ${name.toLowerCase()}`);
const userId = user.id || user.sid;
const channel = md5(`${userId.toLowerCase()} - ${name.toLowerCase()}`);

const sendWorkflowProgressMessage: SendSourceWorkflowMessage = (info, status) =>
connection.socket.send(JSON.stringify({ status, message: info, channel }));
Expand All @@ -68,7 +69,7 @@ async function mosaicWorkflows(fastify: FastifyInstance, _options) {
}
} catch (e) {
console.error(e);
connection.socket.send(JSON.stringify({ status: 'ERROR', message: 'e.message' }));
connection.socket.send(JSON.stringify({ status: 'ERROR', message: e.message }));
}
}
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ export const PersistDialog = ({ meta, persistUrl }: PersistDialogProps) => {
};

const handleCompleteMessage = message => {
setPrHref(message.message?.links?.self[0]?.href);
setPrHref(message.message?.links?.self[0]?.href || message);
setIsRaising(false);
};

Expand Down
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
}
},
"engines": {
"node": ">=16.10.0 || >=18.0.0"
"node": ">=18.0.0 || >=20.0.0"
},
"files": [
"dist"
Expand Down
2 changes: 1 addition & 1 deletion packages/fromHttpRequest/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
}
},
"engines": {
"node": ">=16.10.0 || >=18.0.0"
"node": ">=18.0.0 || >=20.0.0"
},
"files": [
"dist"
Expand Down
2 changes: 1 addition & 1 deletion packages/plugins/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
}
},
"engines": {
"node": ">=16.10.0 || >=18.0.0"
"node": ">=18.0.0 || >=20.0.0"
},
"files": [
"dist"
Expand Down
2 changes: 1 addition & 1 deletion packages/schemas/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
}
},
"engines": {
"node": ">=16.10.0 || >=18.0.0"
"node": ">=18.0.0 || >=20.0.0"
},
"repository": {
"type": "git",
Expand Down
2 changes: 1 addition & 1 deletion packages/schemas/src/SourceWorkflowSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export const sourceWorkflowSchema = z.object({
/**
* Workflow config options
*/
options: z.unknown(),
options: z.record(z.string(), z.unknown()),
/**
* action to run when workflow is triggered
*/
Expand Down
2 changes: 1 addition & 1 deletion packages/serialisers/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
}
},
"engines": {
"node": ">=16.10.0 || >=18.0.0"
"node": ">=18.0.0 || >=20.0.0"
},
"files": [
"dist"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ describe('GIVEN loadLocalFile', () => {
test('THEN it throws and error when the local file does not exist', async () => {
// assert
await expect(loadLocalFile('some/non-existent/mynamespace/mydir')).rejects.toThrow(
/ENOENT: no such file or directory, stat 'some\/non-existent\/mynamespace\/mydir'/
"ENOENT: no such file or directory, stat 'some/non-existent/mynamespace/mydir'"
);
});
test('THEN it loads the index file if a directory is requested', async () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/source-figma/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
}
},
"engines": {
"node": ">= 16.0.0"
"node": ">=18.0.0 || >=20.0.0"
},
"files": [
"dist"
Expand Down
2 changes: 1 addition & 1 deletion packages/source-git-repo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
}
},
"engines": {
"node": ">=16.10.0 || >=18.0.0"
"node": ">=18.0.0 || >=20.0.0"
},
"files": [
"dist"
Expand Down
2 changes: 1 addition & 1 deletion packages/source-http/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
}
},
"engines": {
"node": ">=16.10.0 || >=18.0.0"
"node": ">=18.0.0 || >=20.0.0"
},
"files": [
"dist"
Expand Down
2 changes: 1 addition & 1 deletion packages/source-local-folder/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
}
},
"engines": {
"node": ">=16.10.0 || >=18.0.0"
"node": ">=18.0.0 || >=20.0.0"
},
"files": [
"dist"
Expand Down
2 changes: 1 addition & 1 deletion packages/source-readme/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
}
},
"engines": {
"node": ">= 16.0.0"
"node": ">=18.0.0 || >=20.0.0"
},
"files": [
"dist"
Expand Down
2 changes: 1 addition & 1 deletion packages/source-storybook/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
}
},
"engines": {
"node": ">= 16.0.0"
"node": ">=18.0.0 || >=20.0.0"
},
"files": [
"dist"
Expand Down
2 changes: 1 addition & 1 deletion packages/types/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
}
},
"engines": {
"node": ">=16.10.0 || >=18.0.0"
"node": ">=18.0.0 || >=20.0.0"
},
"repository": {
"type": "git",
Expand Down
2 changes: 1 addition & 1 deletion packages/types/src/SourceWorkflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export type SourceWorkflowAction<TSourceOptions, TOptions> = (
...args: any[]
) => Promise<unknown>;

export type SourceWorkflow<TSourceOptions = unknown, TOptions = unknown> = {
export type SourceWorkflow<TSourceOptions = unknown, TOptions = Record<string, unknown>> = {
name: string;
options?: TOptions;
action: SourceWorkflowAction<TSourceOptions, TOptions>;
Expand Down
4 changes: 3 additions & 1 deletion packages/workflows/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
}
},
"engines": {
"node": ">=16.10.0 || >=18.0.0"
"node": ">=18.0.0 || >=20.0.0"
},
"files": [
"dist"
Expand All @@ -37,6 +37,8 @@
"@jpmorganchase/mosaic-serialisers": "^0.1.0-beta.85",
"@jpmorganchase/mosaic-source-git-repo": "^0.1.0-beta.85",
"@jpmorganchase/mosaic-types": "^0.1.0-beta.85",
"@octokit/core": "^6.1.2",
"undici" : "^6.19.5",
"uuid": "^7.0.3"
}
}
161 changes: 161 additions & 0 deletions packages/workflows/src/GitHubPullRequestWorkflow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { Octokit } from '@octokit/core';
import path from 'node:path';
import fs from 'node:fs';
import { v4 as uuidv4 } from 'uuid';
import { Repo, GitRepoSourceOptions } from '@jpmorganchase/mosaic-source-git-repo';
import { mdx } from '@jpmorganchase/mosaic-serialisers';
import type { SendSourceWorkflowMessage, SourceWorkflow } from '@jpmorganchase/mosaic-types';
import { ProxyAgent, fetch as undiciFetch } from 'undici';

function getErrorMessage(error: unknown) {
if (error instanceof Error) return error.message;
return String(error);
}

interface GitHubPullRequestWorkflowData {
user: { id: string; name: string; email: string };
markdown: string;
}

interface GitHubPullRequestWorkflowOptions {
apiEndpoint: string;
commitMessage: (filePath: string) => string;
titlePrefix: string;
proxyEndpoint?: string;
}

export async function createPullRequest(
sendWorkflowProgressMessage: SendSourceWorkflowMessage,
sourceOptions: GitRepoSourceOptions,
{ apiEndpoint, commitMessage, titlePrefix, proxyEndpoint }: GitHubPullRequestWorkflowOptions,
filePath: string,
{ user, markdown }: GitHubPullRequestWorkflowData
) {
const {
credentials,
remote,
branch: sourceBranch,
repo: repoUrl,
subfolder,
prefixDir
} = sourceOptions;

if (!repoUrl || !markdown) {
return false;
}

let repoInstance: Repo | null = new Repo(credentials, remote, sourceBranch, repoUrl);
await repoInstance.init();
const userId = user.id.toLowerCase();

sendWorkflowProgressMessage('GitHub clone complete', 'IN_PROGRESS');

const branchName = `${userId}-${uuidv4()}`;
await repoInstance.createWorktree(userId, branchName);
sendWorkflowProgressMessage('Created git worktree', 'IN_PROGRESS');

/**
* strip out the namespace from the file path.
* We are interested in the file on disk not in the VFS
*/
const pathOnDisk = path.posix.join(
repoInstance.dir,
subfolder,
filePath.replace(new RegExp(`${prefixDir}/`), '')
);

const rawPage = await fs.promises.readFile(pathOnDisk);
const { content, ...metadata } = await mdx.deserialise(pathOnDisk, rawPage);
const updatedPage = { ...metadata, content: markdown };
sendWorkflowProgressMessage('Updated page content', 'IN_PROGRESS');
await fs.promises.writeFile(pathOnDisk, await mdx.serialise(pathOnDisk, updatedPage));
sendWorkflowProgressMessage('Saved page', 'IN_PROGRESS');

/** create a new fetcher with proxy agent configured if required. */
const fetcher: typeof undiciFetch = proxyEndpoint
? (url, opts) => {
return undiciFetch(url, {
...opts,
dispatcher: new ProxyAgent({
uri: proxyEndpoint,
keepAliveTimeout: 10,
keepAliveMaxTimeout: 10
})
});
}
: undiciFetch;

const token = credentials.split(':')[1];

// get a new client
const githubClient = new Octokit({
auth: token,
request: {
fetch: fetcher,
timeout: 5000
}
});

sendWorkflowProgressMessage('Creating Pull Request', 'IN_PROGRESS');

let prResult = null;

try {
await repoInstance.configureGitUser(user.name, user.email);
await repoInstance.addChanges();
await repoInstance.commitChanges(user.name, user.email, commitMessage(filePath));
await repoInstance.pushBranch(branchName);

const result = await githubClient.request(`POST ${apiEndpoint}`, {
owner: repoInstance.projectName,
repo: repoInstance.repoName,
title: `${titlePrefix} - Content update - ${filePath}`,
body: commitMessage(filePath),
head: branchName,
base: sourceBranch,
headers: {
'X-GitHub-Api-Version': '2022-11-28'
}
});

/**
* https://docs.github.com/en/rest/pulls/pulls?apiVersion=2022-11-28#create-a-pull-request
* Status 201 = created
*/
if (result.status === 201) {
prResult = result.data.url;
} else {
throw new Error(`${result.data.status} - ${result.data.message}`);
}
} catch (e: unknown) {
console.group('[Mosaic] Pull Request Error');
console.log('fullPath', filePath);
console.log('Head', branchName);
console.log('Base', sourceBranch);
console.error(e);
console.groupEnd();

prResult = {
error: `Error creating Pull Request: ${getErrorMessage(e)} `,
source: `${repoInstance.name}`
};
} finally {
await repoInstance.removeWorktree(userId);
repoInstance = null;
}

sendWorkflowProgressMessage(prResult, 'COMPLETE');
return prResult;
}

const workflow: SourceWorkflow = {
name: 'save',
options: {
titlePrefix: 'Mosaic Docs',
apiEndpoint: 'https://api.github.com/repos/{owner}/{repo}/pulls',
commitMessage: (filePath: string) => `docs: updated content ${filePath} (UIE-7026)`
},
action: createPullRequest
};

export default workflow;
Loading
Loading