Skip to content

Commit 70ba322

Browse files
Bullrichrzadp
andcommitted
feat: added custom field support assigment (#51)
Added the ability to add a custom project field when assigning an issue to a project. This commit closes #44 Moved more of the logic to the `synchronizer` and enhanced the tests to include those business logic changes. Created an evaluated new GraphQL query for GitHub's project API. Co-authored-by: Przemek Rzad <[email protected]>
1 parent c491db4 commit 70ba322

File tree

10 files changed

+360
-100
lines changed

10 files changed

+360
-100
lines changed

action.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,16 @@ branding:
77
inputs:
88
project:
99
required: true
10+
description: The number of the project which the issues will be synced to
1011
type: number
12+
project_field:
13+
required: false
14+
description: The name of the project field that will be set to project_value
15+
type: string
16+
project_value:
17+
required: false
18+
description: The value which will be set in the project_field
19+
type: string
1120
GITHUB_TOKEN:
1221
required: true
1322
type: string

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
},
2929
"devDependencies": {
3030
"@octokit/graphql-schema": "^12.41.1",
31-
"@types/jest": "^29.2.5",
31+
"@types/jest": "^29.2.6",
3232
"jest": "^29.3.1",
3333
"jest-mock-extended": "^3.0.1",
3434
"opstooling-js-style": "https://github.com/paritytech/opstooling-js-style#c298d0f732d93712e4397fd53baa3317a3022c8c",

src/github/CoreLogger.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ export class CoreLogger implements ILogger {
77
info(message: string): void {
88
core.info(message);
99
}
10-
warning(message: string): void {
10+
warning(message: string | Error): void {
1111
core.warning(message);
1212
}
1313
error(message: string | Error): void {

src/github/projectKit.ts

Lines changed: 100 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { graphql } from "@octokit/graphql";
22

3-
import { ILogger, IProjectApi, Issue, Repository } from "./types";
3+
import { FieldValues, ILogger, IProjectApi, Issue, Repository } from "./types";
44

55
type NodeData = { id: string; title: string };
6+
type FieldData = { name: string; id: string; options?: { name: string; id: string }[] };
67

78
export const PROJECT_V2_QUERY: string = `
89
query($organization: String!, $number: Int!) {
@@ -25,6 +26,41 @@ mutation($project: ID!, $issue: ID!) {
2526
}
2627
`;
2728

29+
export const PROJECT_FIELD_ID_QUERY: string = `
30+
query($project: ID!) {
31+
node(id: $project) {
32+
... on ProjectV2 {
33+
fields(first: 20) {
34+
nodes {
35+
... on ProjectV2Field {
36+
id
37+
name
38+
}
39+
... on ProjectV2IterationField {
40+
id
41+
name
42+
configuration {
43+
iterations {
44+
startDate
45+
id
46+
}
47+
}
48+
}
49+
... on ProjectV2SingleSelectField {
50+
id
51+
name
52+
options {
53+
id
54+
name
55+
}
56+
}
57+
}
58+
}
59+
}
60+
}
61+
}
62+
`;
63+
2864
export const UPDATE_PROJECT_V2_ITEM_FIELD_VALUE_QUERY: string = `
2965
mutation (
3066
$project: ID!
@@ -65,20 +101,38 @@ export class ProjectKit implements IProjectApi {
65101
private readonly logger: ILogger,
66102
) {}
67103

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-
} */
104+
async changeIssueStateInProject(issueCardId: string, project: NodeData, fields: FieldValues): Promise<void> {
105+
try {
106+
const op = await this.gql(UPDATE_PROJECT_V2_ITEM_FIELD_VALUE_QUERY, {
107+
project: project.id,
108+
item: issueCardId,
109+
targetField: fields.field,
110+
targetFieldValue: fields.value,
111+
});
112+
113+
this.logger.debug("Returned " + JSON.stringify(op));
114+
} catch (e) {
115+
throw new Error("Failed while executing the 'UPDATE_PROJECT_V2_ITEM_FIELD_VALUE_QUERY' query", { cause: e });
116+
}
117+
}
77118

78119
/**
79-
* Fetches the node id from the project id and caches it.
80-
* @returns node_id of the project. This value never changes so caching it per instance is effective
120+
* Get all the project fields in a project, with their node ids and available options
121+
* @returns A collection of all the fields available in the issue item
81122
*/
123+
async getProjectFields(projectId: string): Promise<FieldData[]> {
124+
try {
125+
type returnType = { node: { fields: { nodes: FieldData[] } } };
126+
const projectData = await this.gql<returnType>(PROJECT_FIELD_ID_QUERY, { project: projectId });
127+
128+
this.logger.debug("correct node data: " + JSON.stringify(projectData.node.fields.nodes[0]));
129+
return projectData.node.fields.nodes;
130+
} catch (e) {
131+
this.logger.error("Failed while executing the 'PROJECT_V2_QUERY' query");
132+
throw e;
133+
}
134+
}
135+
82136
async fetchProjectData(): Promise<NodeData> {
83137
if (this.projectNode) {
84138
return this.projectNode;
@@ -95,8 +149,7 @@ export class ProjectKit implements IProjectApi {
95149

96150
return projectData.organization.projectV2;
97151
} catch (e) {
98-
this.logger.error("Failed while executing the 'PROJECT_V2_QUERY' query");
99-
throw e;
152+
throw new Error("Failed while executing the 'PROJECT_V2_QUERY' query", { cause: e });
100153
}
101154
}
102155

@@ -109,39 +162,55 @@ export class ProjectKit implements IProjectApi {
109162
await this.gql(UPDATE_PROJECT_V2_ITEM_FIELD_VALUE_QUERY, { project, item, targetField, targetFieldValue });
110163
}
111164

112-
async assignIssueToProject(issue: Issue, projectId: string): Promise<boolean> {
165+
async assignIssueToProject(issue: Issue, projectId: string): Promise<string> {
113166
try {
114167
const migration = await this.gql<{ addProjectV2ItemById: { item: { id: string } } }>(
115168
ADD_PROJECT_V2_ITEM_BY_ID_QUERY,
116169
{ project: projectId, issue: issue.node_id },
117170
);
118171

119-
return !!migration.addProjectV2ItemById.item.id;
172+
return migration.addProjectV2ItemById.item.id;
120173
} catch (e) {
121-
this.logger.error("Failed while executing 'ADD_PROJECT_V2_ITEM_BY_ID_QUERY' query");
122-
throw e;
174+
throw new Error("Failed while executing 'ADD_PROJECT_V2_ITEM_BY_ID_QUERY' query", { cause: e });
123175
}
124176
}
125177

126-
async addIssueToProject(issue: Issue, project: NodeData): Promise<boolean> {
127-
this.logger.info(`Syncing issue #${issue.number} for ${project.title}`);
178+
async fetchProjectFieldNodeValues(project: NodeData, projectFields?: FieldValues): Promise<FieldValues> {
179+
if (!projectFields) {
180+
throw new Error("'projectsFields' is null!");
181+
}
128182

129-
return await this.assignIssueToProject(issue, project.id);
130-
}
183+
const projectFieldData = await this.getProjectFields(project.id);
131184

132-
async assignIssue(issue: Issue): Promise<boolean> {
133-
const project = await this.fetchProjectData();
185+
const { field, value } = projectFields;
134186

135-
return await this.addIssueToProject(issue, project);
187+
// ? Should we use .localeCompare here?
188+
const customField = projectFieldData.find(({ name }) => name.toUpperCase() === field.toUpperCase());
136189

137-
// TODO: Assign targetField
138-
}
190+
// check that this custom field exists and it has available options to set up
191+
if (!customField) {
192+
throw new Error(`Field ${field} does not exist!`);
193+
} else if (!customField.options) {
194+
throw new Error(`Field ${field} does not have any available options!.` + "Please add options to set values");
195+
}
139196

140-
async assignIssues(issues: Issue[]): Promise<boolean[]> {
141-
const project = await this.fetchProjectData();
197+
this.logger.debug(`Custom field '${field}' was found.`);
142198

143-
const issueAssigment = issues.map((issue) => this.addIssueToProject(issue, project));
199+
// search for the node element with the correct name.
200+
const fieldOption = customField.options.find(({ name }) => name.toUpperCase() === value.toUpperCase());
201+
if (!fieldOption) {
202+
const valuesArray = customField.options.map((options) => options.name);
203+
throw new Error(`Project value '${value}' does not exist. Available values are ${JSON.stringify(valuesArray)}`);
204+
}
144205

145-
return await Promise.all(issueAssigment);
206+
this.logger.debug(`Field options '${value}' was found.`);
207+
208+
return { field: customField.id, value: fieldOption.id };
209+
}
210+
211+
async assignIssue(issue: Issue, project: NodeData): Promise<string> {
212+
this.logger.info(`Syncing issue #${issue.number} for ${project.title}`);
213+
214+
return await this.assignIssueToProject(issue, project.id);
146215
}
147216
}

src/github/types.ts

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,34 @@ export type Repository = { owner: string; repo: string };
22

33
export type Issue = { number: number; node_id: string };
44

5+
/** Key value pair with the name/id of a field and the name/id of its value */
6+
export type FieldValues = { field: string; value: string };
7+
8+
export type NodeData = { id: string; title: string };
9+
510
export interface IProjectApi {
11+
/**
12+
* Fetches the node id from the project id and caches it.
13+
* @returns node_id of the project. This value never changes so caching it per instance is effective
14+
*/
15+
fetchProjectData(): Promise<NodeData>;
616
/**
717
* Assign an issue to a project
818
* @param issue The issue object which has the number and the node_id
19+
* @returns the issueCardId of the created issue. This ID is used in changeIssueStateInProject.
20+
* @see changeIssueStateInProject
921
*/
10-
assignIssue(issue: Issue): Promise<boolean>;
11-
22+
assignIssue(issue: Issue, project: NodeData): Promise<string>;
1223
/**
13-
* Assign several issues to a project
14-
* @param issues The issue object collection which has the number and the node_id
24+
* Fetches the available fields for a project board and filters to find the node ids of the field and value
25+
* If either the field or the value don't exist, it will fail with an exception
26+
* @param project The node data of the project
27+
* @param projectFields The literal names of the fields to be modified
28+
* @returns The id of both the field and the value
1529
*/
16-
assignIssues(issues: Issue[]): Promise<boolean[]>;
30+
fetchProjectFieldNodeValues(project: NodeData, projectFields?: FieldValues): Promise<FieldValues>;
1731
// getProjectIdFromIssue(issueId: number): Promise<number>;
18-
// changeIssueStateInProject(issueId: number, state: "todo" | "in progress" | "blocked" | "done"): Promise<boolean>;
32+
changeIssueStateInProject(issueCardId: string, project: NodeData, fields: FieldValues): Promise<void>;
1933
}
2034

2135
/** Class managing the instance of issues */
@@ -34,7 +48,7 @@ export interface IIssues {
3448

3549
export interface ILogger {
3650
info(message: string): void;
37-
warning(message: string): void;
51+
warning(message: string | Error): void;
3852
error(message: string | Error): void;
3953
/** Only posts messages if the action is ran in debug mode */
4054
debug(message: string): void;

src/main.ts

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,28 @@
1-
import { getInput, info, setFailed } from "@actions/core";
1+
import { debug, error, getInput, info, setFailed } from "@actions/core";
22
import { context, getOctokit } from "@actions/github";
33

44
import { CoreLogger } from "./github/CoreLogger";
55
import { IssueApi } from "./github/issueKit";
66
import { ProjectKit } from "./github/projectKit";
77
import { GitHubContext, Synchronizer } from "./synchronizer";
88

9+
const getProjectFieldValues = (): { field: string; value: string } | undefined => {
10+
const field = getInput("project_field");
11+
const value = getInput("project_value");
12+
13+
if (field && value) {
14+
return { field, value };
15+
} else {
16+
debug("'project_field' and 'project_value' are empty.");
17+
}
18+
};
19+
920
//* * Generates the class that will handle the project logic */
1021
const generateSynchronizer = (): Synchronizer => {
1122
const repoToken = getInput("GITHUB_TOKEN", { required: true });
1223
const orgToken = getInput("PROJECT_TOKEN", { required: true });
1324

1425
const projectNumber = parseInt(getInput("project", { required: true }));
15-
// TODO: Add support for custom project fields (https://docs.github.com/en/issues/planning-and-tracking-with-projects/understanding-fields)
1626

1727
const { repo } = context;
1828

@@ -27,6 +37,7 @@ const generateSynchronizer = (): Synchronizer => {
2737

2838
const synchronizer = generateSynchronizer();
2939

40+
const projectFields = getProjectFieldValues();
3041
const { issue } = context.payload;
3142
const parsedContext: GitHubContext = {
3243
eventName: context.eventName,
@@ -35,9 +46,27 @@ const parsedContext: GitHubContext = {
3546
inputs: context.payload.inputs,
3647
issue: issue ? { number: issue.number, node_id: issue.node_id as string } : undefined,
3748
},
49+
config: { projectField: projectFields },
50+
};
51+
52+
const errorHandler = (e: Error) => {
53+
let er = e;
54+
setFailed(e);
55+
while (er !== null) {
56+
debug(`Stack -> ${er.stack as string}`);
57+
if (er.cause != null) {
58+
debug("Error has a nested error. Displaying.");
59+
er = er.cause as Error;
60+
error(er);
61+
} else {
62+
break;
63+
}
64+
}
3865
};
3966

4067
synchronizer
4168
.synchronizeIssue(parsedContext)
42-
.then(() => info("Finished"))
43-
.catch(setFailed);
69+
.then(() => {
70+
info("Operation finished successfully!");
71+
})
72+
.catch(errorHandler);

0 commit comments

Comments
 (0)