Skip to content

Commit c491db4

Browse files
committed
fix: updated GraphQL queries to V2 (#50)
Updated GraphQL queries to support version 2 as `ProjectNext` has been deprecated. Moved all the queries to be stored in a exported variables so they can be accessed from outside. Created unit tests which validates the schemas using `@octokit/graphql-schema` which contains the latest schemas. Modified dependabot to keep the `@octokit/graphql-schema` dependency at its latest. This commit closes #46.
1 parent 5f45f18 commit c491db4

File tree

10 files changed

+173
-122
lines changed

10 files changed

+173
-122
lines changed

.github/dependabot.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,9 @@ updates:
1010
# Ignore version upgrades.
1111
# Security updates are nevertheless. unaffected by this setting and will continue to work.
1212
update-types: ["version-update:semver-patch", "version-update:semver-minor", "version-update:semver-major"]
13+
- package-ecosystem: "npm"
14+
directory: "/"
15+
schedule:
16+
interval: "monthly"
17+
allow:
18+
- dependency-name: "@octokit/graphql-schema"

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,4 @@ yarn-error.log
1414

1515
.idea
1616
.DS_Store
17+
examples

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"@octokit/rest": "^19.0.5"
2828
},
2929
"devDependencies": {
30+
"@octokit/graphql-schema": "^12.41.1",
3031
"@types/jest": "^29.2.5",
3132
"jest": "^29.3.1",
3233
"jest-mock-extended": "^3.0.1",

src/github/issueKit.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export class IssueApi implements IIssues {
1212
return issueData.data.state === "open" ? "open" : "closed";
1313
}
1414

15-
async getAllIssuesId(excludeClosed: boolean): Promise<Issue[]> {
15+
async getAllIssues(excludeClosed: boolean): Promise<Issue[]> {
1616
const allIssues = await this.octokit.rest.issues.listForRepo({
1717
...this.repoData,
1818
state: excludeClosed ? "open" : "all",

src/github/projectKit.ts

Lines changed: 96 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -2,32 +2,60 @@ import { graphql } from "@octokit/graphql";
22

33
import { ILogger, IProjectApi, Issue, Repository } from "./types";
44

5-
interface ProjectData {
6-
organization: {
7-
projectNext: {
8-
id: string;
9-
fields: {
10-
nodes: {
11-
id: string;
12-
name: string;
13-
settings?: string | null;
14-
}[];
15-
};
16-
};
17-
};
5+
type NodeData = { id: string; title: string };
6+
7+
export const PROJECT_V2_QUERY: string = `
8+
query($organization: String!, $number: Int!) {
9+
organization(login: $organization){
10+
projectV2(number: $number) {
11+
id
12+
title
13+
}
14+
}
1815
}
16+
`;
1917

20-
interface CreatedProjectItemForIssue {
21-
addProjectNextItem: { projectNextItem: { id: string } };
18+
export const ADD_PROJECT_V2_ITEM_BY_ID_QUERY: string = `
19+
mutation($project: ID!, $issue: ID!) {
20+
addProjectV2ItemById(input: {projectId: $project, contentId: $issue}) {
21+
item {
22+
id
23+
}
24+
}
25+
}
26+
`;
27+
28+
export const UPDATE_PROJECT_V2_ITEM_FIELD_VALUE_QUERY: string = `
29+
mutation (
30+
$project: ID!
31+
$item: ID!
32+
$targetField: ID!
33+
$targetFieldValue: String!
34+
) {
35+
updateProjectV2ItemFieldValue(
36+
input: {
37+
projectId: $project
38+
itemId: $item
39+
fieldId: $targetField
40+
value: {
41+
singleSelectOptionId: $targetFieldValue
42+
}
43+
}
44+
) {
45+
projectV2Item {
46+
id
47+
}
48+
}
2249
}
50+
`;
2351

2452
/**
2553
* Instance that manages the GitHub's project api
2654
* ? Octokit.js doesn't support Project v2 API yet so we need to use graphQL
2755
* Used this blog post as a reference for the queries: https://www.cloudwithchris.com/blog/automate-adding-gh-issues-projects-beta/
2856
*/
2957
export class ProjectKit implements IProjectApi {
30-
private projectNodeId: string | null = null;
58+
private projectNode: NodeData | null = null;
3159

3260
/** Requires an instance with a PAT with the 'write:org' permission enabled */
3361
constructor(
@@ -37,122 +65,83 @@ export class ProjectKit implements IProjectApi {
3765
private readonly logger: ILogger,
3866
) {}
3967

40-
/* changeIssueStateInProject(issueCardId: number, state: "todo" | "in progress" | "blocked" | "done"): Promise<void> {
41-
return this.gql(
42-
`
43-
mutation (
44-
$project: ID!
45-
$item: ID!
46-
$targetField: ID!
47-
$targetFieldValue: String!
48-
) {
49-
updateProjectNextItemField(input: {
50-
projectId: $project
51-
itemId: $item
52-
fieldId: $targetField
53-
value: $targetFieldValue
54-
}) {
55-
projectNextItem {
56-
id
57-
}
58-
}
59-
}
60-
`,
61-
{ project: this.projectNumber, item: issueCardId, targetField: "Status", targetFieldValue: state },
62-
);
63-
} */
68+
/*
69+
changeIssueStateInProject(issueCardId: number, state: "todo" | "in progress" | "blocked" | "done"): Promise<void> {
70+
return this.gql(UPDATE_STATE_IN_PROJECT_QUERY, {
71+
project: this.projectNumber,
72+
item: issueCardId,
73+
targetField: "Status",
74+
targetFieldValue: state,
75+
});
76+
} */
6477

6578
/**
6679
* Fetches the node id from the project id and caches it.
6780
* @returns node_id of the project. This value never changes so caching it per instance is effective
6881
*/
69-
async fetchProjectId(): Promise<string> {
70-
if (this.projectNodeId) {
71-
return this.projectNodeId;
82+
async fetchProjectData(): Promise<NodeData> {
83+
if (this.projectNode) {
84+
return this.projectNode;
7285
}
7386

74-
// Source: https://docs.github.com/en/graphql/reference/objects#projectnext
75-
const projectData = await this.gql<ProjectData>(
76-
`
77-
query($organization: String!, $number: Int!) {
78-
organization(login: $organization){
79-
projectNext(number: $number) {
80-
id
81-
fields(first: 20) {
82-
nodes {
83-
id
84-
name
85-
settings
86-
}
87-
}
88-
}
89-
}
90-
}
91-
`,
92-
{ organization: this.repoData.owner, number: this.projectNumber },
93-
);
87+
try {
88+
// Source: https://docs.github.com/en/issues/planning-and-tracking-with-projects/automating-your-project/using-the-api-to-manage-projects#using-variables
89+
const projectData = await this.gql<{ organization: { projectV2: NodeData } }>(PROJECT_V2_QUERY, {
90+
organization: this.repoData.owner,
91+
number: this.projectNumber,
92+
});
9493

95-
this.projectNodeId = projectData.organization.projectNext.id;
94+
this.projectNode = projectData.organization.projectV2;
9695

97-
return projectData.organization.projectNext.id;
96+
return projectData.organization.projectV2;
97+
} catch (e) {
98+
this.logger.error("Failed while executing the 'PROJECT_V2_QUERY' query");
99+
throw e;
100+
}
98101
}
99102

100-
// step three
101103
async updateProjectNextItemField(
102104
project: string,
103105
item: string,
104106
targetField: string,
105107
targetFieldValue: string,
106108
): Promise<void> {
107-
await this.gql(
108-
`
109-
mutation (
110-
$project: ID!
111-
$item: ID!
112-
$targetField: ID!
113-
$targetFieldValue: String!
114-
) {
115-
updateProjectNextItemField(input: {
116-
projectId: $project
117-
itemId: $item
118-
fieldId: $targetField
119-
value: $targetFieldValue
120-
}) {
121-
projectNextItem {
122-
id
123-
}
124-
}
125-
}
126-
`,
127-
{ project, item, targetField, targetFieldValue },
128-
);
109+
await this.gql(UPDATE_PROJECT_V2_ITEM_FIELD_VALUE_QUERY, { project, item, targetField, targetFieldValue });
129110
}
130111

131112
async assignIssueToProject(issue: Issue, projectId: string): Promise<boolean> {
132-
const migration = await this.gql<CreatedProjectItemForIssue>(
133-
`
134-
mutation($project: ID!, $issue: ID!) {
135-
addProjectNextItem(input: {projectId: $project, contentId: $issue}) {
136-
projectNextItem {
137-
id
138-
}
139-
}
140-
}
141-
`,
142-
{ project: projectId, issue: issue.node_id },
143-
);
144-
145-
// TODO: Check what is this ID
146-
return !!migration.addProjectNextItem.projectNextItem.id;
113+
try {
114+
const migration = await this.gql<{ addProjectV2ItemById: { item: { id: string } } }>(
115+
ADD_PROJECT_V2_ITEM_BY_ID_QUERY,
116+
{ project: projectId, issue: issue.node_id },
117+
);
118+
119+
return !!migration.addProjectV2ItemById.item.id;
120+
} catch (e) {
121+
this.logger.error("Failed while executing 'ADD_PROJECT_V2_ITEM_BY_ID_QUERY' query");
122+
throw e;
123+
}
147124
}
148125

149-
async assignIssue(issue: Issue): Promise<boolean> {
150-
const projectId = await this.fetchProjectId();
126+
async addIssueToProject(issue: Issue, project: NodeData): Promise<boolean> {
127+
this.logger.info(`Syncing issue #${issue.number} for ${project.title}`);
151128

152-
this.logger.info(`Syncing issue #${issue.number} for ${this.projectNumber}`);
129+
return await this.assignIssueToProject(issue, project.id);
130+
}
153131

154-
return await this.assignIssueToProject(issue, projectId);
132+
async assignIssue(issue: Issue): Promise<boolean> {
133+
const project = await this.fetchProjectData();
134+
135+
return await this.addIssueToProject(issue, project);
155136

156137
// TODO: Assign targetField
157138
}
139+
140+
async assignIssues(issues: Issue[]): Promise<boolean[]> {
141+
const project = await this.fetchProjectData();
142+
143+
const issueAssigment = issues.map((issue) => this.addIssueToProject(issue, project));
144+
145+
return await Promise.all(issueAssigment);
146+
}
158147
}

src/github/types.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,15 @@ export type Issue = { number: number; node_id: string };
55
export interface IProjectApi {
66
/**
77
* Assign an issue to a project
8-
* @param issueNodeId The issue node_id (which differs from the issue id)
9-
* @param projectId The project id (found in github.com/repo/projects/<ID>)
8+
* @param issue The issue object which has the number and the node_id
109
*/
1110
assignIssue(issue: Issue): Promise<boolean>;
11+
12+
/**
13+
* Assign several issues to a project
14+
* @param issues The issue object collection which has the number and the node_id
15+
*/
16+
assignIssues(issues: Issue[]): Promise<boolean[]>;
1217
// getProjectIdFromIssue(issueId: number): Promise<number>;
1318
// changeIssueStateInProject(issueId: number, state: "todo" | "in progress" | "blocked" | "done"): Promise<boolean>;
1419
}
@@ -24,7 +29,7 @@ export interface IIssues {
2429
* Returns the node_id for all the issues available in the repository
2530
* @param includeClosed exclude issues which are closed from the data agregation.
2631
*/
27-
getAllIssuesId(excludeClosed: boolean): Promise<Issue[]>;
32+
getAllIssues(excludeClosed: boolean): Promise<Issue[]>;
2833
}
2934

3035
export interface ILogger {

src/synchronizer.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,14 @@ export class Synchronizer {
4040
}
4141

4242
async updateAllIssues(excludeClosed: boolean = false): Promise<boolean> {
43-
const issuesIds = await this.issueKit.getAllIssuesId(excludeClosed);
44-
const updatePromises = issuesIds.map((nodeId) => this.projectKit.assignIssue(nodeId));
45-
const syncs = await Promise.all(updatePromises);
46-
return syncs.every((s) => s);
43+
const issues = await this.issueKit.getAllIssues(excludeClosed);
44+
if (issues?.length === 0) {
45+
this.logger.notice("No issues found");
46+
return false;
47+
}
48+
this.logger.info(`Updating ${issues.length} issues`);
49+
const issueAssigment = await this.projectKit.assignIssues(issues);
50+
return issueAssigment.every((s) => s);
4751
}
4852

4953
async updateOneIssue(issue: Issue): Promise<boolean> {

src/test/queries.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { validate } from "@octokit/graphql-schema";
2+
3+
import {
4+
ADD_PROJECT_V2_ITEM_BY_ID_QUERY,
5+
PROJECT_V2_QUERY,
6+
UPDATE_PROJECT_V2_ITEM_FIELD_VALUE_QUERY,
7+
} from "src/github/projectKit";
8+
9+
describe("Schemas", () => {
10+
test("PROJECT_V2_QUERY", () => {
11+
expect(validate(PROJECT_V2_QUERY)).toEqual([]);
12+
});
13+
14+
test("ADD_PROJECT_V2_ITEM_BY_ID_QUERY", () => {
15+
expect(validate(ADD_PROJECT_V2_ITEM_BY_ID_QUERY)).toEqual([]);
16+
});
17+
18+
test("UPDATE_PROJECT_V2_ITEM_FIELD_VALUE_QUERY", () => {
19+
expect(validate(UPDATE_PROJECT_V2_ITEM_FIELD_VALUE_QUERY)).toEqual([]);
20+
});
21+
});

src/test/synchronizer.test.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,14 @@ describe("Synchronizer tests", () => {
3232
});
3333

3434
test("should log when all issues will be synced", async () => {
35-
issueKit.getAllIssuesId.mockReturnValue(Promise.resolve([]));
35+
issueKit.getAllIssues.mockReturnValue(Promise.resolve([]));
3636
await synchronizer.synchronizeIssue({ eventName: "workflow_dispatch", payload: {} });
3737

3838
expect(logger.notice).toBeCalledWith("Closed issues will be synced.");
3939
});
4040

4141
test("should log when only open issues will be synced", async () => {
42-
issueKit.getAllIssuesId.mockReturnValue(Promise.resolve([]));
42+
issueKit.getAllIssues.mockReturnValue(Promise.resolve([]));
4343
await synchronizer.synchronizeIssue({
4444
eventName: "workflow_dispatch",
4545
payload: { inputs: { excludeClosed: "true" } },
@@ -59,16 +59,15 @@ describe("Synchronizer tests", () => {
5959
projectKit.assignIssue.calledWith({ node_id: "1234321", number: issueNumber });
6060
});
6161

62-
test("should call project.assignIssue over an iteration", async () => {
62+
test("should call project.assignIssues with an iteration", async () => {
6363
const issues: Issue[] = [
6464
{ number: 123, node_id: "asd_dsa" },
6565
{ number: 987, node_id: "poi_lkj" },
6666
];
67-
issueKit.getAllIssuesId.mockReturnValue(Promise.resolve(issues));
67+
issueKit.getAllIssues.mockReturnValue(Promise.resolve(issues));
68+
projectKit.assignIssues.mockReturnValue(Promise.resolve([true, true]));
6869
await synchronizer.synchronizeIssue({ eventName: "workflow_dispatch", payload: {} });
6970

70-
issues.forEach((i) => {
71-
projectKit.assignIssue.calledWith(i);
72-
});
71+
projectKit.assignIssues.calledWith(issues);
7372
});
7473
});

0 commit comments

Comments
 (0)