From 3a63fc376d42b7cf7dad68a7b20d80680a2f96ed Mon Sep 17 00:00:00 2001 From: Ali BARIN Date: Sat, 7 May 2022 23:04:29 +0200 Subject: [PATCH] feat: add new issue trigger in GitHub --- packages/backend/src/apps/github/data.ts | 3 + .../src/apps/github/data/list-labels.ts | 36 ++++++++ packages/backend/src/apps/github/info.json | 92 +++++++++++++++++++ packages/backend/src/apps/github/triggers.ts | 3 + .../src/apps/github/triggers/new-issue.ts | 88 ++++++++++++++++++ .../apps/github/triggers/new-notification.ts | 5 +- packages/backend/src/apps/github/utils.ts | 3 +- .../ControlledAutocomplete/index.tsx | 4 +- .../web/src/components/FlowStep/index.tsx | 4 +- 9 files changed, 230 insertions(+), 8 deletions(-) create mode 100644 packages/backend/src/apps/github/data/list-labels.ts create mode 100644 packages/backend/src/apps/github/triggers/new-issue.ts diff --git a/packages/backend/src/apps/github/data.ts b/packages/backend/src/apps/github/data.ts index 239cb148..12bd3afe 100644 --- a/packages/backend/src/apps/github/data.ts +++ b/packages/backend/src/apps/github/data.ts @@ -1,13 +1,16 @@ import { IJSONObject } from '@automatisch/types'; import ListRepos from './data/list-repos'; import ListBranches from './data/list-branches'; +import ListLabels from './data/list-labels'; export default class Data { listRepos: ListRepos; listBranches: ListBranches; + listLabels: ListLabels; constructor(connectionData: IJSONObject, parameters: IJSONObject) { this.listRepos = new ListRepos(connectionData); this.listBranches = new ListBranches(connectionData, parameters); + this.listLabels = new ListLabels(connectionData, parameters); } } diff --git a/packages/backend/src/apps/github/data/list-labels.ts b/packages/backend/src/apps/github/data/list-labels.ts new file mode 100644 index 00000000..bc1ff192 --- /dev/null +++ b/packages/backend/src/apps/github/data/list-labels.ts @@ -0,0 +1,36 @@ +import { Octokit } from 'octokit'; +import type { IJSONObject } from '@automatisch/types'; + +import { assignOwnerAndRepo } from '../utils'; + +export default class ListLabels { + client?: Octokit; + repoOwner?: string; + repo?: string; + + constructor(connectionData: IJSONObject, parameters?: IJSONObject) { + if (connectionData.accessToken) { + this.client = new Octokit({ + auth: connectionData.accessToken as string, + }); + } + + assignOwnerAndRepo(this, parameters?.repo as string); + } + + get options() { + return { + owner: this.repoOwner, + repo: this.repo, + }; + } + + async run() { + const labels = await this.client.paginate(this.client.rest.issues.listLabelsForRepo, this.options); + + return labels.map((label) => ({ + value: label.name, + name: label.name, + })); + } +} diff --git a/packages/backend/src/apps/github/info.json b/packages/backend/src/apps/github/info.json index a4376103..1099ccf7 100644 --- a/packages/backend/src/apps/github/info.json +++ b/packages/backend/src/apps/github/info.json @@ -649,6 +649,98 @@ "name": "Test trigger" } ] + }, + { + "name": "New issue", + "key": "newIssue", + "description": "Triggers when a new issue is created", + "substeps": [ + { + "key": "chooseAccount", + "name": "Choose account" + }, + { + "key": "chooseTrigger", + "name": "Set up a trigger", + "arguments": [ + { + "label": "Repo", + "key": "repo", + "type": "dropdown", + "required": false, + "variables": false, + "source": { + "type": "query", + "name": "getData", + "arguments": [ + { + "name": "key", + "value": "listRepos" + } + ] + } + }, + { + "label": "Which types of issues should this trigger on?", + "key": "issueType", + "type": "dropdown", + "description": "Defaults to any issue you can see.", + "required": true, + "variables": false, + "value": "all", + "options": [ + { + "label": "Any issue you can see", + "value": "all" + }, + { + "label": "Only issues assigned to you", + "value": "assigned" + }, + { + "label": "Only issues created by you", + "value": "created" + }, + { + "label": "Only issues you're mentioned in", + "value": "mentioned" + }, + { + "label": "Only issues you're subscribed to", + "value": "subscribed" + } + ] + }, + { + "label": "Label", + "key": "label", + "type": "dropdown", + "description": "Only trigger on issues when this label is added.", + "required": false, + "variables": false, + "dependsOn": ["parameters.repo"], + "source": { + "type": "query", + "name": "getData", + "arguments": [ + { + "name": "key", + "value": "listLabels" + }, + { + "name": "parameters.repo", + "value": "{parameters.repo}" + } + ] + } + } + ] + }, + { + "key": "testStep", + "name": "Test trigger" + } + ] } ] } diff --git a/packages/backend/src/apps/github/triggers.ts b/packages/backend/src/apps/github/triggers.ts index a8e9c467..8a49f6e1 100644 --- a/packages/backend/src/apps/github/triggers.ts +++ b/packages/backend/src/apps/github/triggers.ts @@ -11,6 +11,7 @@ import NewCommitComment from './triggers/new-commit-comment'; import NewLabel from './triggers/new-label'; import NewCollaborator from './triggers/new-collaborator'; import NewRelease from './triggers/new-release'; +import NewIssue from './triggers/new-issue'; export default class Triggers { newRepository: NewRepository; @@ -25,6 +26,7 @@ export default class Triggers { newLabel: NewLabel; newCollaborator: NewCollaborator; newRelease: NewRelease; + newIssue: NewIssue; constructor(connectionData: IJSONObject, parameters: IJSONObject) { this.newRepository = new NewRepository(connectionData); @@ -39,5 +41,6 @@ export default class Triggers { this.newLabel = new NewLabel(connectionData, parameters); this.newCollaborator = new NewCollaborator(connectionData, parameters); this.newRelease = new NewRelease(connectionData, parameters); + this.newIssue = new NewIssue(connectionData, parameters); } } diff --git a/packages/backend/src/apps/github/triggers/new-issue.ts b/packages/backend/src/apps/github/triggers/new-issue.ts new file mode 100644 index 00000000..bbcba4bf --- /dev/null +++ b/packages/backend/src/apps/github/triggers/new-issue.ts @@ -0,0 +1,88 @@ +import { Octokit } from 'octokit'; +import { DateTime } from 'luxon'; +import { IJSONObject } from '@automatisch/types'; + +import { assignOwnerAndRepo } from '../utils'; + +export default class NewIssue { + client?: Octokit; + connectionData?: IJSONObject; + repoOwner?: string; + repo?: string; + hasRepo?: boolean; + label?: string; + issueType?: string; + + constructor(connectionData: IJSONObject, parameters: IJSONObject) { + if (connectionData.accessToken) { + this.client = new Octokit({ + auth: connectionData.accessToken as string, + }); + } + + assignOwnerAndRepo(this, parameters?.repo as string); + } + + get options() { + return { + labels: this.label, + } + } + + async listRepoIssues(options = {}, paginate = false) { + const listRepoIssues = this.client.rest.issues.listForRepo; + + const extendedOptions = { + ...this.options, + repo: this.repo, + owner: this.repoOwner, + filter: this.issueType, + ...options, + }; + + if (paginate) { + return await this.client.paginate(listRepoIssues, extendedOptions); + } + + return (await listRepoIssues(extendedOptions)).data; + } + + async listIssues(options = {}, paginate = false) { + const listIssues = this.client.rest.issues.listForAuthenticatedUser; + + const extendedOptions = { + ...this.options, + ...options, + }; + + if (paginate) { + return await this.client.paginate(listIssues, extendedOptions); + } + + return (await listIssues(extendedOptions)).data; + } + + async run(startTime: Date) { + const options = { + since: DateTime.fromJSDate(startTime).toISO(), + }; + + if (this.hasRepo) { + return await this.listRepoIssues(options, true); + } + + return await this.listIssues(options, true); + } + + async testRun() { + const options = { + per_page: 1, + }; + + if (this.hasRepo) { + return await this.listRepoIssues(options, false); + } + + return await this.listIssues(options, false); + } +} diff --git a/packages/backend/src/apps/github/triggers/new-notification.ts b/packages/backend/src/apps/github/triggers/new-notification.ts index 00f496fa..31f9cb3b 100644 --- a/packages/backend/src/apps/github/triggers/new-notification.ts +++ b/packages/backend/src/apps/github/triggers/new-notification.ts @@ -9,6 +9,7 @@ export default class NewNotification { connectionData?: IJSONObject; repoOwner?: string; repo?: string; + hasRepo?: boolean; baseOptions = { all: true, participating: false, @@ -24,10 +25,6 @@ export default class NewNotification { assignOwnerAndRepo(this, parameters?.repo as string); } - get hasRepo() { - return this.repoOwner && this.repo; - } - async listRepoNotifications(options = {}, paginate = false) { const listRepoNotifications = this.client.rest.activity.listRepoNotificationsForAuthenticatedUser; diff --git a/packages/backend/src/apps/github/utils.ts b/packages/backend/src/apps/github/utils.ts index b4de8bc7..6e22ee32 100644 --- a/packages/backend/src/apps/github/utils.ts +++ b/packages/backend/src/apps/github/utils.ts @@ -1,8 +1,9 @@ -export function assignOwnerAndRepo(object: T, repoFullName: string): T { +export function assignOwnerAndRepo(object: T, repoFullName: string): T { if (object && repoFullName) { const [repoOwner, repo] = repoFullName.split('/'); object.repoOwner = repoOwner; object.repo = repo; + object.hasRepo = true; } return object; diff --git a/packages/web/src/components/ControlledAutocomplete/index.tsx b/packages/web/src/components/ControlledAutocomplete/index.tsx index f9255bc6..118575ef 100644 --- a/packages/web/src/components/ControlledAutocomplete/index.tsx +++ b/packages/web/src/components/ControlledAutocomplete/index.tsx @@ -11,7 +11,7 @@ interface ControlledAutocompleteProps extends AutocompleteProps options.find(option => option.value === value); +const getOption = (options: readonly IFieldDropdownOption[], value: string) => options.find(option => option.value === value) || null; function ControlledAutocomplete(props: ControlledAutocompleteProps): React.ReactElement { const { control } = useFormContext(); @@ -45,7 +45,7 @@ function ControlledAutocomplete(props: ControlledAutocompleteProps): React.React value={getOption(options, field.value)} onChange={(event, selectedOption, reason, details) => { const typedSelectedOption = selectedOption as IFieldDropdownOption; - if (Object.prototype.hasOwnProperty.call(typedSelectedOption, 'value')) { + if (typedSelectedOption !== null && Object.prototype.hasOwnProperty.call(typedSelectedOption, 'value')) { controllerOnChange(typedSelectedOption.value); } else { controllerOnChange(typedSelectedOption); diff --git a/packages/web/src/components/FlowStep/index.tsx b/packages/web/src/components/FlowStep/index.tsx index 077e720a..185c22b4 100644 --- a/packages/web/src/components/FlowStep/index.tsx +++ b/packages/web/src/components/FlowStep/index.tsx @@ -69,11 +69,13 @@ function generateValidationSchema(substeps: ISubstep[]) { // if the field depends on another field, add the dependsOn required validation if (dependsOn?.length > 0) { for (const dependsOnKey of dependsOn) { + const missingDependencyValueMessage = `We're having trouble loading '${key}' data as required field '${dependsOnKey}' is missing.`; + // TODO: make `dependsOnKey` agnostic to the field. However, nested validation schema is not supported. // So the fields under the `parameters` key are subject to their siblings only and thus, `parameters.` is removed. substepArgumentValidations[key] = substepArgumentValidations[key].when(`${dependsOnKey.replace('parameters.', '')}`, { is: (value: string) => Boolean(value) === false, - then: (schema) => schema.required(`We're having trouble loading '${key}' data as required field '${dependsOnKey}' is missing.`), + then: (schema) => schema.notOneOf([''], missingDependencyValueMessage).required(missingDependencyValueMessage), }); } }