Skip to content

Commit de8552c

Browse files
authoredNov 4, 2024
Add Issue to GitHub Project v2 call (opensearch-project#31)
* Adding issue to github project v2 call Signed-off-by: Peter Zhu <zhujiaxi@amazon.com> * Probably implement add issue to project v2 Signed-off-by: Peter Zhu <zhujiaxi@amazon.com> * Add more requirements Signed-off-by: Peter Zhu <zhujiaxi@amazon.com> * Improve octokit test Signed-off-by: Peter Zhu <zhujiaxi@amazon.com> * Add more tests Signed-off-by: Peter Zhu <zhujiaxi@amazon.com> * Add more tests Signed-off-by: Peter Zhu <zhujiaxi@amazon.com> * Update test cases Signed-off-by: Peter Zhu <zhujiaxi@amazon.com> * Add more tests Signed-off-by: Peter Zhu <zhujiaxi@amazon.com> * bump version Signed-off-by: Peter Zhu <zhujiaxi@amazon.com> --------- Signed-off-by: Peter Zhu <zhujiaxi@amazon.com>
1 parent 0c8f9bc commit de8552c

9 files changed

+278
-7
lines changed
 

‎README.md

+2
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ For detailed instructions on starting the service with Docker, refer to the proj
8181

8282
Please bump package version with every new commit by updating `package.json` and run `npm install` to update `package-lock.json`. Please do not use `npm version <>` as it will not sign DCO for your commit.
8383

84+
You can run `npm run build` before sending any Pull Request to ensure all the lint / format / test pass beforehand.
85+
8486
## Code of Conduct
8587

8688
This project has adopted [the Open Source Code of Conduct](CODE_OF_CONDUCT.md).

‎package-lock.json

+2-2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "opensearch-automation-app",
3-
"version": "0.1.15",
3+
"version": "0.1.16",
44
"description": "An Automation App that handles all your GitHub Repository Activities",
55
"author": "Peter Zhu",
66
"homepage": "https://github.com/opensearch-project/automation-app",
@@ -13,7 +13,7 @@
1313
"automation-app"
1414
],
1515
"scripts": {
16-
"build": "npm run format-dryrun && npm run clean && npm run lint && npm run compile",
16+
"build": "npm install && npm run format-dryrun && npm run clean && npm run lint && npm run compile",
1717
"postbuild": "npm run test",
1818
"compile": "tsc",
1919
"dev": "npm run clean && npm run compile && probot run ./bin/app.js",
+98
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/*
2+
* Copyright OpenSearch Contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*
5+
* The OpenSearch Contributors require contributions made to
6+
* this file be licensed under the Apache-2.0 license or a
7+
* compatible open source license.
8+
*/
9+
10+
// Name : addIssueToGitHubProjectV2
11+
// Description : add issue to github project v2 based on labels
12+
// Arguments :
13+
// - labels : (array) label name, add any of the listed labels on an issue will add the issue to project listed in `projects` arg
14+
// - projects : (array) the list of `<Organization Name>/<Project Number> for the issues to be added to.
15+
// : Ex: `opensearch-project/206` which is the OpenSearch Roadmap Project
16+
// Requirements : ADDITIONAL_RESOURCE_CONTEXT=true
17+
18+
import { randomBytes } from 'crypto';
19+
import { Probot } from 'probot';
20+
import { Resource } from '../service/resource/resource';
21+
import { validateResourceConfig } from '../utility/verification/verify-resource';
22+
23+
export interface AddIssueToGitHubProjectV2Params {
24+
labels: string[];
25+
projects: string[];
26+
}
27+
28+
export async function validateProjects(app: Probot, resource: Resource, projectsArray: string[]): Promise<Boolean> {
29+
return projectsArray.every((proj) => {
30+
const projOrg = proj.split('/')[0];
31+
const projNum = Number(proj.split('/')[1]);
32+
const project = resource.organizations.get(projOrg)?.projects.get(projNum);
33+
34+
if (!project) {
35+
app.log.error(`Project ${projNum} in organization ${projOrg} is not defined in resource config!`);
36+
return false;
37+
}
38+
39+
return true;
40+
});
41+
}
42+
43+
export default async function addIssueToGitHubProjectV2(
44+
app: Probot,
45+
context: any,
46+
resource: Resource,
47+
{ labels, projects }: AddIssueToGitHubProjectV2Params,
48+
): Promise<Map<string, [string, string]> | null> {
49+
if (!(await validateResourceConfig(app, context, resource))) return null;
50+
if (!(await validateProjects(app, resource, projects))) return null;
51+
52+
// Verify triggered label
53+
const label = context.payload.label.name.trim();
54+
if (!labels.includes(label)) {
55+
app.log.error(`"${label}" is not defined in call paramter "labels": ${labels}.`);
56+
return null;
57+
}
58+
59+
const orgName = context.payload.organization.login;
60+
const repoName = context.payload.repository.name;
61+
const issueNumber = context.payload.issue.number;
62+
const issueNodeId = context.payload.issue.node_id;
63+
const itemIdMap = new Map<string, [string, string]>();
64+
65+
// Add to project
66+
try {
67+
await Promise.all(
68+
projects.map(async (project) => {
69+
app.log.info(`Attempt to add ${orgName}/${repoName}/${issueNumber} to project ${project}`);
70+
const mutationId = await randomBytes(20).toString('hex');
71+
const projectSplit = project.split('/');
72+
const projectNodeId = resource.organizations.get(projectSplit[0])?.projects.get(Number(projectSplit[1]))?.nodeId;
73+
const addToProjectMutation = `
74+
mutation {
75+
addProjectV2ItemById(input: {
76+
clientMutationId: "${mutationId}",
77+
contentId: "${issueNodeId}",
78+
projectId: "${projectNodeId}",
79+
}) {
80+
item {
81+
id
82+
}
83+
}
84+
}
85+
`;
86+
const responseAddToProject = await context.octokit.graphql(addToProjectMutation);
87+
app.log.info(responseAddToProject);
88+
const itemId = responseAddToProject.addProjectV2ItemById.item.id;
89+
itemIdMap.set(project, [itemId, label]);
90+
}),
91+
);
92+
} catch (e) {
93+
app.log.error(`ERROR: ${e}`);
94+
return null;
95+
}
96+
97+
return itemIdMap;
98+
}

‎src/utility/verification/verify-resource.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,17 @@ import { Probot } from 'probot';
1111
import { Resource } from '../../service/resource/resource';
1212

1313
export async function validateResourceConfig(app: Probot, context: any, resource: Resource): Promise<boolean> {
14+
// Verify org and repo between context and loaded resource config
1415
const contextOrgName = context.payload.organization?.login || context.payload.repository?.owner?.login;
1516
const contextRepoName = context.payload.repository?.name;
1617

1718
const org = resource.organizations.get(contextOrgName);
1819
const repo = org?.repositories.get(contextRepoName);
1920

20-
if (!org || !repo) {
21+
if (!contextOrgName || !contextRepoName || !org || !repo) {
2122
app.log.error(`${contextOrgName}/${contextRepoName} is not defined in resource config!`);
2223
return false;
2324
}
25+
2426
return true;
2527
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
/*
2+
* Copyright OpenSearch Contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*
5+
* The OpenSearch Contributors require contributions made to
6+
* this file be licensed under the Apache-2.0 license or a
7+
* compatible open source license.
8+
*/
9+
10+
import addIssueToGitHubProjectV2, { AddIssueToGitHubProjectV2Params } from '../../src/call/add-issue-to-github-project-v2';
11+
import { validateProjects } from '../../src/call/add-issue-to-github-project-v2';
12+
import { Probot, Logger } from 'probot';
13+
14+
// Mock mutationId return
15+
jest.mock('crypto', () => ({
16+
randomBytes: jest.fn(() => {
17+
return {
18+
toString: jest.fn().mockReturnValue('mutation-id'),
19+
};
20+
}),
21+
}));
22+
23+
describe('addIssueToGitHubProjectV2Functions', () => {
24+
let app: Probot;
25+
let context: any;
26+
let resource: any;
27+
let params: AddIssueToGitHubProjectV2Params;
28+
29+
beforeEach(() => {
30+
app = new Probot({ appId: 1, secret: 'test', privateKey: 'test' });
31+
app.log = {
32+
info: jest.fn(),
33+
error: jest.fn(),
34+
} as unknown as Logger;
35+
36+
context = {
37+
payload: {
38+
label: { name: 'Meta' },
39+
organization: { login: 'test-org' },
40+
repository: { name: 'test-repo' },
41+
issue: { number: 111, node_id: 'issue-111-nodeid' },
42+
},
43+
octokit: {
44+
graphql: jest.fn(),
45+
},
46+
};
47+
48+
resource = {
49+
organizations: new Map([
50+
[
51+
'test-org',
52+
{
53+
projects: new Map([[222, { nodeId: 'project-222-nodeid' }]]),
54+
repositories: new Map([['test-repo', 'repo object']]),
55+
},
56+
],
57+
]),
58+
};
59+
60+
params = {
61+
labels: ['Meta', 'RFC'],
62+
projects: ['test-org/222'],
63+
};
64+
});
65+
66+
afterEach(() => {
67+
jest.clearAllMocks();
68+
});
69+
70+
describe('validateProjects', () => {
71+
it('should print error if validateProjects returns false', async () => {
72+
resource.organizations.get('test-org').projects.delete(222);
73+
74+
const result = await validateProjects(app, resource, params.projects);
75+
76+
expect(app.log.error).toHaveBeenCalledWith('Project 222 in organization test-org is not defined in resource config!');
77+
expect(result).toBe(false);
78+
expect(context.octokit.graphql).not.toHaveBeenCalled();
79+
});
80+
});
81+
82+
describe('addIssueToGitHubProjectV2', () => {
83+
it('should print error if context label does not match the ones in resource config', async () => {
84+
context.payload.label.name = 'enhancement';
85+
86+
const result = await addIssueToGitHubProjectV2(app, context, resource, params);
87+
88+
expect(app.log.error).toHaveBeenCalledWith('"enhancement" is not defined in call paramter "labels": Meta,RFC.');
89+
expect(result).toBe(null);
90+
expect(context.octokit.graphql).not.toHaveBeenCalled();
91+
});
92+
93+
it('should add context issue to project when conditions are met', async () => {
94+
const graphQLResponse = {
95+
addProjectV2ItemById: { item: { id: 'new-item-id' } },
96+
};
97+
98+
context.octokit.graphql.mockResolvedValue(graphQLResponse);
99+
100+
const result = await addIssueToGitHubProjectV2(app, context, resource, params);
101+
102+
/* prettier-ignore-start */
103+
const graphQLCallStack = `
104+
mutation {
105+
addProjectV2ItemById(input: {
106+
clientMutationId: "mutation-id",
107+
contentId: "issue-111-nodeid",
108+
projectId: "project-222-nodeid",
109+
}) {
110+
item {
111+
id
112+
}
113+
}
114+
}
115+
`;
116+
/* prettier-ignore-end */
117+
118+
expect(context.octokit.graphql).toHaveBeenCalledWith(graphQLCallStack);
119+
expect(JSON.stringify((result as Map<string, [string, string]>).get('test-org/222'))).toBe('["new-item-id","Meta"]');
120+
expect(app.log.info).toHaveBeenCalledWith(graphQLResponse);
121+
});
122+
123+
it('should print log error when GraphQL call fails', async () => {
124+
context.octokit.graphql.mockRejectedValue(new Error('GraphQL request failed'));
125+
126+
const result = await addIssueToGitHubProjectV2(app, context, resource, params);
127+
128+
expect(context.octokit.graphql).rejects.toThrow('GraphQL request failed');
129+
expect(context.octokit.graphql).toHaveBeenCalled();
130+
expect(result).toBe(null);
131+
expect(app.log.error).toHaveBeenCalledWith('ERROR: Error: GraphQL request failed');
132+
});
133+
});
134+
});

‎test/utility/opensearch/opensearch-client.test.ts

+14
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import { STSClient, AssumeRoleCommand } from '@aws-sdk/client-sts';
1111
import { Client as OpenSearchClient } from '@opensearch-project/opensearch';
1212
import { OpensearchClient } from '../../../src/utility/opensearch/opensearch-client';
13+
import { AwsSigv4Signer } from '@opensearch-project/opensearch/aws-v3';
1314

1415
jest.mock('@aws-sdk/client-sts');
1516
jest.mock('@opensearch-project/opensearch');
@@ -47,6 +48,10 @@ describe('OpensearchClient', () => {
4748
}));
4849
});
4950

51+
afterEach(() => {
52+
jest.clearAllMocks();
53+
});
54+
5055
describe('getClient', () => {
5156
it('should return an OpenSearch client with valid credentials', async () => {
5257
const opensearchClient = new OpensearchClient();
@@ -58,6 +63,15 @@ describe('OpensearchClient', () => {
5863
RoleSessionName: 'githubWorkflowRunsMonitorSession',
5964
});
6065
expect(client).toBeInstanceOf(OpenSearchClient);
66+
67+
const getCredentials = (AwsSigv4Signer as jest.Mock).mock.calls[0][0].getCredentials;
68+
const credentials = await getCredentials();
69+
70+
expect(credentials).toEqual({
71+
accessKeyId: 'mockAccessKeyId',
72+
secretAccessKey: 'mockSecretAccessKey',
73+
sessionToken: 'mockSessionToken',
74+
});
6175
});
6276

6377
it('should throw an error if credentials are undefined', async () => {

‎test/utility/probot/octokit.test.ts

+19-2
Original file line numberDiff line numberDiff line change
@@ -8,23 +8,40 @@
88
*/
99

1010
import { octokitAuth } from '../../../src/utility/probot/octokit';
11-
import { Probot, Logger } from 'probot';
11+
import { Probot, ProbotOctokit, Logger } from 'probot';
1212

1313
describe('octokitFunctions', () => {
1414
let app: Probot;
1515
let installationId: number;
16+
let octokitMock: ProbotOctokit
1617

1718
beforeEach(() => {
1819
app = new Probot({ appId: 1, secret: 'test', privateKey: 'test' });
1920
app.log = {
2021
info: jest.fn(),
2122
error: jest.fn(),
2223
} as unknown as Logger;
24+
25+
octokitMock = {} as ProbotOctokit;
26+
app.auth = jest.fn().mockResolvedValue(octokitMock);
27+
28+
installationId = 123;
29+
});
30+
31+
afterEach(() => {
32+
jest.clearAllMocks();
2333
});
2434

2535
describe('octokitAuth', () => {
2636
it('should fail if no installation id', async () => {
27-
await expect(octokitAuth(app, installationId)).rejects.toThrowError('Please provide installation id of your github app!');
37+
await expect(octokitAuth(app, undefined as any)).rejects.toThrowError('Please provide installation id of your github app!');
38+
});
39+
40+
it('should call app.auth if installationId present and return ProbotOctokit', async () => {
41+
const result = await octokitAuth(app, installationId);
42+
43+
expect(app.auth).toHaveBeenCalledWith(installationId);
44+
expect(result).toBe(octokitMock);
2845
});
2946
});
3047
});

‎test/utility/verification/verify-resource.test.ts

+4
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ describe('verifyResourceFunctions', () => {
3636
};
3737
});
3838

39+
afterEach(() => {
40+
jest.clearAllMocks();
41+
});
42+
3943
describe('validateResourceConfig', () => {
4044
it('should fail if no org or repo data in payload', async () => {
4145
const result = await validateResourceConfig(app, context, resource);

0 commit comments

Comments
 (0)
Please sign in to comment.