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: added support for labels #53

Merged
merged 14 commits into from
Jan 26, 2023
99 changes: 97 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ on:
issues:
types:
- opened
- reopened
- labeled
workflow_dispatch:
inputs:
Expand All @@ -54,12 +53,49 @@ jobs:
# This is a Personal Access Token and it needs to have the following permissions
# - "read:org": used to read the project's board
# - "write:org": used to assign issues to the project's board
PROJECT_TOKEN: ${{ steps.generate_token.outputs.token }}
PROJECT_TOKEN: ${{ secrets.PROJECT_TOKEN }}
# The number of the project which the issues will be synced to
# You can find this in https://github.com/orgs/@ORGANIZATION/projects/<NUMBER>
project: 4
# Optional, the project field to modify with a new value
# Found more in https://docs.github.com/en/issues/planning-and-tracking-with-projects/understanding-fields/about-single-select-fields
project_field: Status
# Optional unless that project_field was set up. Then this field is required.
# The value to modify in the project field
project_value: To do
# Optional, labels to work with. Read below to see how to configure it.
# If this value is set, the action will be applied only to issues with such label(s).
labels: |
duplicate
bug
invalid
```
You can generate a new token [in your user's token dashboard](https://github.com/settings/tokens/new).

### Warning about labels field
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.
It accepts either:
```yml
labels: my label name
```
or an array of labels using a `pipe`:
```yml
labels: |
some label
another label
third label
```
It **does not** support the following type of arrays:
```yml
# not this one
labels:
- some label
- another one

# also doesn't support this one
labels: ["some label", "another one"]
```

### Using a GitHub app instead of a PAT
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:
- Repository permissions:
Expand Down Expand Up @@ -88,6 +124,65 @@ Because this project is intended to be used with a token we need to do an extra
PROJECT_TOKEN: ${{ steps.generate_token.outputs.token }}
```

## Combining labels and different fields

As the system works different when there are labels available, you can set up steps to work with different cases.
Let's do an example:
- You have 3 cases you want to handle:
- When an new issue is created, assign it to `project 1` and set the `Status` to `To do`.
- When an issue is labeled as `DevOps` or `CI` assign it to `project 2` and set the `Status` to `Needs reviewing`.
- When an issue is labeled as `Needs planning` assign it to `project 1` and set the `Condition` to `Review on next sprint`.

```yml
name: GitHub Issue Sync

on:
issues:
types:
- opened
- labeled
workflow_dispatch:
inputs:
excludeClosed:
description: 'Exclude closed issues in the sync.'
type: boolean
default: true

jobs:
sync:
runs-on: ubuntu-latest
steps:
- name: Sync new issues
uses: paritytech/github-issue-sync@master
with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PROJECT_TOKEN: ${{ secrets.PROJECT_TOKEN }}
project: 1
project_field: Status
project_value: To do
- name: Sync DevOps issues
uses: paritytech/github-issue-sync@master
with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PROJECT_TOKEN: ${{ secrets.PROJECT_TOKEN }}
project: 2
project_field: Status
project_value: Needs reviewing
labels: |
DevOps
CI
- name: Sync issues for the next sprint
uses: paritytech/github-issue-sync@master
with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PROJECT_TOKEN: ${{ secrets.PROJECT_TOKEN }}
project: 1
project_field: Condition
project_value: Review on next sprint
labels: Needs planning
```
With this configuration you will be able to handle all of the aforementioned cases.

## Development
To work on this app, you require
- `Node 18.x`
Expand Down
4 changes: 4 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ inputs:
required: false
description: The value which will be set in the project_field
type: string
labels:
required: false
description: array of labels required to execute the action. See Readme for input format.
type: string
GITHUB_TOKEN:
required: true
type: string
Expand Down
3 changes: 2 additions & 1 deletion src/github/issueKit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@ export class IssueApi implements IIssues {
return issueData.data.state === "open" ? "open" : "closed";
}

async getAllIssues(excludeClosed: boolean): Promise<Issue[]> {
async getAllIssues(excludeClosed: boolean, labels?: string[]): Promise<Issue[]> {
const allIssues = await this.octokit.rest.issues.listForRepo({
...this.repoData,
state: excludeClosed ? "open" : "all",
labels: labels?.join(","),
});
return allIssues.data;
}
Expand Down
4 changes: 2 additions & 2 deletions src/github/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export type Repository = { owner: string; repo: string };

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

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

export interface ILogger {
Expand Down
15 changes: 7 additions & 8 deletions src/main.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { debug, error, getInput, info, setFailed } from "@actions/core";
import { debug, error, getInput, getMultilineInput, info, setFailed } from "@actions/core";
import { context, getOctokit } from "@actions/github";

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

const getRequiredLabels = (): string[] => getMultilineInput("labels");

//* * Generates the class that will handle the project logic */
const generateSynchronizer = (): Synchronizer => {
const repoToken = getInput("GITHUB_TOKEN", { required: true });
Expand All @@ -36,17 +38,14 @@ const generateSynchronizer = (): Synchronizer => {
};

const synchronizer = generateSynchronizer();
const labels = getRequiredLabels();

const projectFields = getProjectFieldValues();
const { issue } = context.payload;
const { payload } = context;
const parsedContext: GitHubContext = {
eventName: context.eventName,
payload: {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
inputs: context.payload.inputs,
issue: issue ? { number: issue.number, node_id: issue.node_id as string } : undefined,
},
config: { projectField: projectFields },
payload,
config: { projectField: projectFields, labels },
};

const errorHandler = (e: Error) => {
Expand Down
126 changes: 115 additions & 11 deletions src/synchronizer.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,30 @@
import { FieldValues, IIssues, ILogger, IProjectApi, Issue, NodeData } from "./github/types";

// type IssueEvent = "opened" | "deleted" | "closed" | "reopened" | "labeled" | "unlabeled" | "transfered";
export type IssueEvent = "opened" | "deleted" | "closed" | "reopened" | "labeled" | "unlabeled" | "transfered";

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

type Payload = {
action?: IssueEvent | string;
inputs?: { excludeClosed?: "true" | "false" };
issue?: Issue;
label?: {
id: number;
name: string;
};
};

export type GitHubContext = {
eventName: EventNames;
payload: {
inputs?: { excludeClosed?: "true" | "false" };
issue?: Issue;
};
payload: Payload;
config?: {
projectField?: FieldValues;
labels?: string[];
};
};

const toLowerCase = (array: string[]): string[] => array.map((a) => a.toLowerCase());

export class Synchronizer {
constructor(
private readonly issueKit: IIssues,
Expand All @@ -26,22 +36,112 @@ export class Synchronizer {
if (context.eventName === "workflow_dispatch") {
const excludeClosed = context.payload.inputs?.excludeClosed === "true";
this.logger.notice(excludeClosed ? "Closed issues will NOT be synced." : "Closed issues will be synced.");
return await this.updateAllIssues(excludeClosed, context.config?.projectField);
return await this.updateAllIssues(excludeClosed, context.config?.projectField, context.config?.labels);
} else if (context.eventName === "issues") {
this.logger.debug(`Required labels are: '${JSON.stringify(context.config?.labels)}'`);
this.logger.debug("Payload received: " + JSON.stringify(context.payload));
const { issue } = context.payload;
if (!issue) {
throw new Error("Issue payload object was null");
}
this.logger.debug(`Received issue ${JSON.stringify(issue)}`);
this.logger.info(`Assigning issue #${issue.number} to project`);
return await this.updateOneIssue(issue, context.config?.projectField);
this.logger.debug(`Received event: ${context.eventName}`);
if (this.shouldAssignIssue(context.payload, context.config?.labels)) {
this.logger.info(`Assigning issue #${issue.number} to project`);
return await this.updateOneIssue(issue, context.config?.projectField);
} else {
return this.logger.info("Skipped assigment as it didn't fullfill requirements.");
}
} else {
const failMessage = `Event '${context.eventName}' is not expected. Failing.`;
this.logger.warning(failMessage);
throw new Error(failMessage);
}
}

/**
* Labels can be either an array of objects or an array of string (or maybe both?)
* This functions cleans them and returns all the labels names as a string array
*/
convertLabelArray(labels?: (string | { name?: string })[]): string[] {
if (!labels || labels.length === 0) {
return [];
}
const list: string[] = [];

labels.forEach((label) => {
if (typeof label === "string" || label instanceof String) {
list.push(label as string);
} else if (label.name) {
list.push(label.name);
}
});

return list;
}

/**
* Method which takes all of the (predicted) cases and calculates if the issue should be assigned or skipped
* @param payload object which contains both the event, the issue type and it's information
* @param labels labels required for the action. Can be null or empty
* @returns true if the label should be assigned, false if it should be skipped
*/
shouldAssignIssue(payload: Payload, labels?: string[]): boolean {
const action = payload.action as IssueEvent;

if (action === "labeled") {
const labelName = payload.label?.name;
// Shouldn't happen. Throw and find out what is this kind of event.
if (!labelName) {
throw new Error("No label found in a labeling event!");
}

this.logger.info(`Label ${labelName} was added to the issue.`);

// If this is a labeling event but there are no labels in the config we skip them
if (!labels || labels.length === 0) {
this.logger.notice("No required labels found for event. Skipping assignment.");
return false;
}

if (toLowerCase(labels).indexOf(labelName.toLowerCase()) > -1) {
this.logger.info(`Found matching label '${labelName}' in required labels.`);
return true;
}
this.logger.notice(
`Label '${labelName}' does not match any of the labels '${JSON.stringify(labels)}'. Skipping.`,
);
return false;
} else if (action === "unlabeled") {
this.logger.warning("No support for 'unlabeled' event. Skipping");
return false;
}

// if no labels are required and this is not a labeling event, assign the issue.
if (!labels || labels.length === 0) {
this.logger.info("Matching requirements: not a labeling event and no labels found in the configuration.");
return true;
}
// if the issue in this event has labels and a matching label config, assign it.
const issueLabels = payload.issue?.labels ?? null;
if (labels.length > 0 && issueLabels && issueLabels.length > 0) {
// complex query. Sanitizing everything to a lower case string array first
const parsedLabels = toLowerCase(this.convertLabelArray(issueLabels));
const requiredLabels = toLowerCase(labels);
// checking if an element in one array is included in the second one
const matchingElement = parsedLabels.some((pl) => requiredLabels.includes(pl));
if (matchingElement) {
this.logger.info(
`Found matching element between ${JSON.stringify(parsedLabels)} and ${JSON.stringify(labels)}`,
);
return true;
}
return false;
}

this.logger.debug(`Case ${action} not considered. Accepted with the following payload: ${JSON.stringify(payload)}`);
return true;
}

/**
* Gets the field node data ids to set custom fields
* This method will fail if the field or value are not available.
Expand All @@ -61,8 +161,12 @@ export class Synchronizer {
}
}

private async updateAllIssues(excludeClosed: boolean = false, customField?: FieldValues): Promise<void> | never {
const issues = await this.issueKit.getAllIssues(excludeClosed);
private async updateAllIssues(
excludeClosed: boolean = false,
customField?: FieldValues,
labels?: string[],
): Promise<void> | never {
const issues = await this.issueKit.getAllIssues(excludeClosed, labels);
if (issues?.length === 0) {
return this.logger.notice("No issues found");
}
Expand Down
Loading