Skip to content

Commit ab74b59

Browse files
committed
feat: added support for labels (#53)
Added support for labeling. The system can now evaluate the new issues based on the existence of labels, allowing us to combine unlabeled and labeled issue behavior. This commit closes #45 Updated `README` to explain how to set up the labeling system and showing an example of how to combine different configurations in one file. Extended unit tests to handle all predicted behaviors.
1 parent 70ba322 commit ab74b59

File tree

7 files changed

+350
-24
lines changed

7 files changed

+350
-24
lines changed

README.md

Lines changed: 97 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ on:
3333
issues:
3434
types:
3535
- opened
36-
- reopened
3736
- labeled
3837
workflow_dispatch:
3938
inputs:
@@ -54,12 +53,49 @@ jobs:
5453
# This is a Personal Access Token and it needs to have the following permissions
5554
# - "read:org": used to read the project's board
5655
# - "write:org": used to assign issues to the project's board
57-
PROJECT_TOKEN: ${{ steps.generate_token.outputs.token }}
56+
PROJECT_TOKEN: ${{ secrets.PROJECT_TOKEN }}
5857
# The number of the project which the issues will be synced to
5958
# You can find this in https://github.com/orgs/@ORGANIZATION/projects/<NUMBER>
6059
project: 4
60+
# Optional, the project field to modify with a new value
61+
# Found more in https://docs.github.com/en/issues/planning-and-tracking-with-projects/understanding-fields/about-single-select-fields
62+
project_field: Status
63+
# Optional unless that project_field was set up. Then this field is required.
64+
# The value to modify in the project field
65+
project_value: To do
66+
# Optional, labels to work with. Read below to see how to configure it.
67+
# If this value is set, the action will be applied only to issues with such label(s).
68+
labels: |
69+
duplicate
70+
bug
71+
invalid
6172
```
6273
You can generate a new token [in your user's token dashboard](https://github.com/settings/tokens/new).
74+
75+
### Warning about labels field
76+
The labels field accepts an array or a single value, [but only with some particular format](https://github.com/actions/toolkit/issues/184#issuecomment-1198653452), so it is important to follow it.
77+
It accepts either:
78+
```yml
79+
labels: my label name
80+
```
81+
or an array of labels using a `pipe`:
82+
```yml
83+
labels: |
84+
some label
85+
another label
86+
third label
87+
```
88+
It **does not** support the following type of arrays:
89+
```yml
90+
# not this one
91+
labels:
92+
- some label
93+
- another one
94+
95+
# also doesn't support this one
96+
labels: ["some label", "another one"]
97+
```
98+
6399
### Using a GitHub app instead of a PAT
64100
In some cases, specially in big organizations, it is more organized to use a GitHub app to authenticate, as it allows us to give it permissions per repository and we can fine-grain them even better. If you wish to do that, you need to create a GitHub app with the following permissions:
65101
- Repository permissions:
@@ -88,6 +124,65 @@ Because this project is intended to be used with a token we need to do an extra
88124
PROJECT_TOKEN: ${{ steps.generate_token.outputs.token }}
89125
```
90126

127+
## Combining labels and different fields
128+
129+
As the system works different when there are labels available, you can set up steps to work with different cases.
130+
Let's do an example:
131+
- You have 3 cases you want to handle:
132+
- When an new issue is created, assign it to `project 1` and set the `Status` to `To do`.
133+
- When an issue is labeled as `DevOps` or `CI` assign it to `project 2` and set the `Status` to `Needs reviewing`.
134+
- When an issue is labeled as `Needs planning` assign it to `project 1` and set the `Condition` to `Review on next sprint`.
135+
136+
```yml
137+
name: GitHub Issue Sync
138+
139+
on:
140+
issues:
141+
types:
142+
- opened
143+
- labeled
144+
workflow_dispatch:
145+
inputs:
146+
excludeClosed:
147+
description: 'Exclude closed issues in the sync.'
148+
type: boolean
149+
default: true
150+
151+
jobs:
152+
sync:
153+
runs-on: ubuntu-latest
154+
steps:
155+
- name: Sync new issues
156+
uses: paritytech/github-issue-sync@master
157+
with:
158+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
159+
PROJECT_TOKEN: ${{ secrets.PROJECT_TOKEN }}
160+
project: 1
161+
project_field: Status
162+
project_value: To do
163+
- name: Sync DevOps issues
164+
uses: paritytech/github-issue-sync@master
165+
with:
166+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
167+
PROJECT_TOKEN: ${{ secrets.PROJECT_TOKEN }}
168+
project: 2
169+
project_field: Status
170+
project_value: Needs reviewing
171+
labels: |
172+
DevOps
173+
CI
174+
- name: Sync issues for the next sprint
175+
uses: paritytech/github-issue-sync@master
176+
with:
177+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
178+
PROJECT_TOKEN: ${{ secrets.PROJECT_TOKEN }}
179+
project: 1
180+
project_field: Condition
181+
project_value: Review on next sprint
182+
labels: Needs planning
183+
```
184+
With this configuration you will be able to handle all of the aforementioned cases.
185+
91186
## Development
92187
To work on this app, you require
93188
- `Node 18.x`

action.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ inputs:
1717
required: false
1818
description: The value which will be set in the project_field
1919
type: string
20+
labels:
21+
required: false
22+
description: array of labels required to execute the action. See Readme for input format.
23+
type: string
2024
GITHUB_TOKEN:
2125
required: true
2226
type: string

src/github/issueKit.ts

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

15-
async getAllIssues(excludeClosed: boolean): Promise<Issue[]> {
15+
async getAllIssues(excludeClosed: boolean, labels?: string[]): Promise<Issue[]> {
1616
const allIssues = await this.octokit.rest.issues.listForRepo({
1717
...this.repoData,
1818
state: excludeClosed ? "open" : "all",
19+
labels: labels?.join(","),
1920
});
2021
return allIssues.data;
2122
}

src/github/types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
export type Repository = { owner: string; repo: string };
22

3-
export type Issue = { number: number; node_id: string };
3+
export type Issue = { number: number; node_id?: string; labels?: (string | { name?: string })[] };
44

55
/** Key value pair with the name/id of a field and the name/id of its value */
66
export type FieldValues = { field: string; value: string };
@@ -43,7 +43,7 @@ export interface IIssues {
4343
* Returns the node_id for all the issues available in the repository
4444
* @param includeClosed exclude issues which are closed from the data agregation.
4545
*/
46-
getAllIssues(excludeClosed: boolean): Promise<Issue[]>;
46+
getAllIssues(excludeClosed: boolean, labels?: string[]): Promise<Issue[]>;
4747
}
4848

4949
export interface ILogger {

src/main.ts

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

44
import { CoreLogger } from "./github/CoreLogger";
@@ -17,6 +17,8 @@ const getProjectFieldValues = (): { field: string; value: string } | undefined =
1717
}
1818
};
1919

20+
const getRequiredLabels = (): string[] => getMultilineInput("labels");
21+
2022
//* * Generates the class that will handle the project logic */
2123
const generateSynchronizer = (): Synchronizer => {
2224
const repoToken = getInput("GITHUB_TOKEN", { required: true });
@@ -36,17 +38,14 @@ const generateSynchronizer = (): Synchronizer => {
3638
};
3739

3840
const synchronizer = generateSynchronizer();
41+
const labels = getRequiredLabels();
3942

4043
const projectFields = getProjectFieldValues();
41-
const { issue } = context.payload;
44+
const { payload } = context;
4245
const parsedContext: GitHubContext = {
4346
eventName: context.eventName,
44-
payload: {
45-
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
46-
inputs: context.payload.inputs,
47-
issue: issue ? { number: issue.number, node_id: issue.node_id as string } : undefined,
48-
},
49-
config: { projectField: projectFields },
47+
payload,
48+
config: { projectField: projectFields, labels },
5049
};
5150

5251
const errorHandler = (e: Error) => {

src/synchronizer.ts

Lines changed: 115 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,30 @@
11
import { FieldValues, IIssues, ILogger, IProjectApi, Issue, NodeData } from "./github/types";
22

3-
// type IssueEvent = "opened" | "deleted" | "closed" | "reopened" | "labeled" | "unlabeled" | "transfered";
3+
export type IssueEvent = "opened" | "deleted" | "closed" | "reopened" | "labeled" | "unlabeled" | "transfered";
44

55
type EventNames = "workflow_dispatch" | "issues" | string;
66

7+
type Payload = {
8+
action?: IssueEvent | string;
9+
inputs?: { excludeClosed?: "true" | "false" };
10+
issue?: Issue;
11+
label?: {
12+
id: number;
13+
name: string;
14+
};
15+
};
16+
717
export type GitHubContext = {
818
eventName: EventNames;
9-
payload: {
10-
inputs?: { excludeClosed?: "true" | "false" };
11-
issue?: Issue;
12-
};
19+
payload: Payload;
1320
config?: {
1421
projectField?: FieldValues;
22+
labels?: string[];
1523
};
1624
};
1725

26+
const toLowerCase = (array: string[]): string[] => array.map((a) => a.toLowerCase());
27+
1828
export class Synchronizer {
1929
constructor(
2030
private readonly issueKit: IIssues,
@@ -26,22 +36,112 @@ export class Synchronizer {
2636
if (context.eventName === "workflow_dispatch") {
2737
const excludeClosed = context.payload.inputs?.excludeClosed === "true";
2838
this.logger.notice(excludeClosed ? "Closed issues will NOT be synced." : "Closed issues will be synced.");
29-
return await this.updateAllIssues(excludeClosed, context.config?.projectField);
39+
return await this.updateAllIssues(excludeClosed, context.config?.projectField, context.config?.labels);
3040
} else if (context.eventName === "issues") {
41+
this.logger.debug(`Required labels are: '${JSON.stringify(context.config?.labels)}'`);
42+
this.logger.debug("Payload received: " + JSON.stringify(context.payload));
3143
const { issue } = context.payload;
3244
if (!issue) {
3345
throw new Error("Issue payload object was null");
3446
}
35-
this.logger.debug(`Received issue ${JSON.stringify(issue)}`);
36-
this.logger.info(`Assigning issue #${issue.number} to project`);
37-
return await this.updateOneIssue(issue, context.config?.projectField);
47+
this.logger.debug(`Received event: ${context.eventName}`);
48+
if (this.shouldAssignIssue(context.payload, context.config?.labels)) {
49+
this.logger.info(`Assigning issue #${issue.number} to project`);
50+
return await this.updateOneIssue(issue, context.config?.projectField);
51+
} else {
52+
return this.logger.info("Skipped assigment as it didn't fullfill requirements.");
53+
}
3854
} else {
3955
const failMessage = `Event '${context.eventName}' is not expected. Failing.`;
4056
this.logger.warning(failMessage);
4157
throw new Error(failMessage);
4258
}
4359
}
4460

61+
/**
62+
* Labels can be either an array of objects or an array of string (or maybe both?)
63+
* This functions cleans them and returns all the labels names as a string array
64+
*/
65+
convertLabelArray(labels?: (string | { name?: string })[]): string[] {
66+
if (!labels || labels.length === 0) {
67+
return [];
68+
}
69+
const list: string[] = [];
70+
71+
labels.forEach((label) => {
72+
if (typeof label === "string" || label instanceof String) {
73+
list.push(label as string);
74+
} else if (label.name) {
75+
list.push(label.name);
76+
}
77+
});
78+
79+
return list;
80+
}
81+
82+
/**
83+
* Method which takes all of the (predicted) cases and calculates if the issue should be assigned or skipped
84+
* @param payload object which contains both the event, the issue type and it's information
85+
* @param labels labels required for the action. Can be null or empty
86+
* @returns true if the label should be assigned, false if it should be skipped
87+
*/
88+
shouldAssignIssue(payload: Payload, labels?: string[]): boolean {
89+
const action = payload.action as IssueEvent;
90+
91+
if (action === "labeled") {
92+
const labelName = payload.label?.name;
93+
// Shouldn't happen. Throw and find out what is this kind of event.
94+
if (!labelName) {
95+
throw new Error("No label found in a labeling event!");
96+
}
97+
98+
this.logger.info(`Label ${labelName} was added to the issue.`);
99+
100+
// If this is a labeling event but there are no labels in the config we skip them
101+
if (!labels || labels.length === 0) {
102+
this.logger.notice("No required labels found for event. Skipping assignment.");
103+
return false;
104+
}
105+
106+
if (toLowerCase(labels).indexOf(labelName.toLowerCase()) > -1) {
107+
this.logger.info(`Found matching label '${labelName}' in required labels.`);
108+
return true;
109+
}
110+
this.logger.notice(
111+
`Label '${labelName}' does not match any of the labels '${JSON.stringify(labels)}'. Skipping.`,
112+
);
113+
return false;
114+
} else if (action === "unlabeled") {
115+
this.logger.warning("No support for 'unlabeled' event. Skipping");
116+
return false;
117+
}
118+
119+
// if no labels are required and this is not a labeling event, assign the issue.
120+
if (!labels || labels.length === 0) {
121+
this.logger.info("Matching requirements: not a labeling event and no labels found in the configuration.");
122+
return true;
123+
}
124+
// if the issue in this event has labels and a matching label config, assign it.
125+
const issueLabels = payload.issue?.labels ?? null;
126+
if (labels.length > 0 && issueLabels && issueLabels.length > 0) {
127+
// complex query. Sanitizing everything to a lower case string array first
128+
const parsedLabels = toLowerCase(this.convertLabelArray(issueLabels));
129+
const requiredLabels = toLowerCase(labels);
130+
// checking if an element in one array is included in the second one
131+
const matchingElement = parsedLabels.some((pl) => requiredLabels.includes(pl));
132+
if (matchingElement) {
133+
this.logger.info(
134+
`Found matching element between ${JSON.stringify(parsedLabels)} and ${JSON.stringify(labels)}`,
135+
);
136+
return true;
137+
}
138+
return false;
139+
}
140+
141+
this.logger.debug(`Case ${action} not considered. Accepted with the following payload: ${JSON.stringify(payload)}`);
142+
return true;
143+
}
144+
45145
/**
46146
* Gets the field node data ids to set custom fields
47147
* This method will fail if the field or value are not available.
@@ -61,8 +161,12 @@ export class Synchronizer {
61161
}
62162
}
63163

64-
private async updateAllIssues(excludeClosed: boolean = false, customField?: FieldValues): Promise<void> | never {
65-
const issues = await this.issueKit.getAllIssues(excludeClosed);
164+
private async updateAllIssues(
165+
excludeClosed: boolean = false,
166+
customField?: FieldValues,
167+
labels?: string[],
168+
): Promise<void> | never {
169+
const issues = await this.issueKit.getAllIssues(excludeClosed, labels);
66170
if (issues?.length === 0) {
67171
return this.logger.notice("No issues found");
68172
}

0 commit comments

Comments
 (0)