From d864831bc5d9017fa1304584a877cd742cc3beba Mon Sep 17 00:00:00 2001 From: Ali BARIN Date: Fri, 6 May 2022 10:10:01 +0200 Subject: [PATCH] feat: add schedule integration --- package.json | 2 +- packages/backend/src/apps/flickr/info.json | 4 -- packages/backend/src/apps/github/info.json | 12 ----- .../src/apps/schedule/assets/favicon.svg | 1 + packages/backend/src/apps/schedule/index.d.ts | 0 packages/backend/src/apps/schedule/index.ts | 18 ++++++++ packages/backend/src/apps/schedule/info.json | 46 +++++++++++++++++++ .../backend/src/apps/schedule/triggers.ts | 10 ++++ .../src/apps/schedule/triggers/every-hour.ts | 35 ++++++++++++++ packages/backend/src/apps/schedule/utils.ts | 28 +++++++++++ packages/backend/src/apps/twitter/info.json | 1 - .../graphql/mutations/update-flow-status.ts | 18 ++++++-- packages/backend/src/graphql/schema.graphql | 6 +++ packages/backend/src/models/flow.ts | 6 +++ packages/backend/src/models/step.ts | 22 +++++++++ packages/types/index.d.ts | 16 ++++++- .../ControlledAutocomplete/index.tsx | 14 ++---- .../web/src/components/FlowStep/index.tsx | 2 +- .../web/src/components/FlowSubstep/index.tsx | 4 +- .../web/src/components/InputCreator/index.tsx | 18 +++----- .../web/src/graphql/mutations/update-step.ts | 1 + packages/web/src/graphql/queries/get-apps.ts | 4 ++ 22 files changed, 222 insertions(+), 46 deletions(-) create mode 100644 packages/backend/src/apps/schedule/assets/favicon.svg create mode 100644 packages/backend/src/apps/schedule/index.d.ts create mode 100644 packages/backend/src/apps/schedule/index.ts create mode 100644 packages/backend/src/apps/schedule/info.json create mode 100644 packages/backend/src/apps/schedule/triggers.ts create mode 100644 packages/backend/src/apps/schedule/triggers/every-hour.ts create mode 100644 packages/backend/src/apps/schedule/utils.ts diff --git a/package.json b/package.json index 02fb495a..422dac1f 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@automatisch/root", "private": true, "scripts": { - "start": "lerna run --stream --parallel --scope=@*/{web,docs,backend} dev", + "start": "lerna run --stream --parallel --scope=@*/{web,backend} dev", "start:web": "lerna run --stream --scope=@*/web dev", "start:backend": "lerna run --stream --scope=@*/backend dev", "lint": "lerna run --no-bail --stream --parallel --scope=@*/{web,backend,cli} lint", diff --git a/packages/backend/src/apps/flickr/info.json b/packages/backend/src/apps/flickr/info.json index dada4b5d..c8ab8180 100644 --- a/packages/backend/src/apps/flickr/info.json +++ b/packages/backend/src/apps/flickr/info.json @@ -219,7 +219,6 @@ { "name": "New favorite photo", "key": "newFavoritePhoto", - "interval": "15m", "description": "Triggers when you favorite a photo.", "substeps": [ { @@ -235,7 +234,6 @@ { "name": "New photo in album", "key": "newPhotoInAlbum", - "interval": "15m", "description": "Triggers when you add a new photo in an album.", "substeps": [ { @@ -274,7 +272,6 @@ { "name": "New photo", "key": "newPhoto", - "interval": "15m", "description": "Triggers when you add a new photo.", "substeps": [ { @@ -290,7 +287,6 @@ { "name": "New album", "key": "newAlbum", - "interval": "15m", "description": "Triggers when you create a new album.", "substeps": [ { diff --git a/packages/backend/src/apps/github/info.json b/packages/backend/src/apps/github/info.json index a86d89d1..a4376103 100644 --- a/packages/backend/src/apps/github/info.json +++ b/packages/backend/src/apps/github/info.json @@ -219,7 +219,6 @@ { "name": "New repository", "key": "newRepository", - "interval": "15m", "description": "Triggers when a new repository is created", "substeps": [ { @@ -235,7 +234,6 @@ { "name": "New organization", "key": "newOrganization", - "interval": "15m", "description": "Triggers when a new organization is created", "substeps": [ { @@ -251,7 +249,6 @@ { "name": "New branch", "key": "newBranch", - "interval": "15m", "description": "Triggers when a new branch is created", "substeps": [ { @@ -290,7 +287,6 @@ { "name": "New notification", "key": "newNotification", - "interval": "15m", "description": "Triggers when a new notification is created", "substeps": [ { @@ -330,7 +326,6 @@ { "name": "New pull request", "key": "newPullRequest", - "interval": "15m", "description": "Triggers when a new pull request is created", "substeps": [ { @@ -369,7 +364,6 @@ { "name": "New watcher", "key": "newWatcher", - "interval": "15m", "description": "Triggers when a new watcher is added to a repo", "substeps": [ { @@ -408,7 +402,6 @@ { "name": "New milestone", "key": "newMilestone", - "interval": "15m", "description": "Triggers when a new milestone is created", "substeps": [ { @@ -447,7 +440,6 @@ { "name": "New commit comment", "key": "newCommitComment", - "interval": "15m", "description": "Triggers when a new commit comment is created", "substeps": [ { @@ -486,7 +478,6 @@ { "name": "New label", "key": "newLabel", - "interval": "15m", "description": "Triggers when a new label is created", "substeps": [ { @@ -525,7 +516,6 @@ { "name": "New collaborator", "key": "newCollaborator", - "interval": "15m", "description": "Triggers when a new collaborator is added to a repo", "substeps": [ { @@ -564,7 +554,6 @@ { "name": "New release", "key": "newRelease", - "interval": "15m", "description": "Triggers when a new release is created", "substeps": [ { @@ -603,7 +592,6 @@ { "name": "New commit", "key": "newCommit", - "interval": "15m", "description": "Triggers when a new commit is created", "substeps": [ { diff --git a/packages/backend/src/apps/schedule/assets/favicon.svg b/packages/backend/src/apps/schedule/assets/favicon.svg new file mode 100644 index 00000000..359793bd --- /dev/null +++ b/packages/backend/src/apps/schedule/assets/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/backend/src/apps/schedule/index.d.ts b/packages/backend/src/apps/schedule/index.d.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/backend/src/apps/schedule/index.ts b/packages/backend/src/apps/schedule/index.ts new file mode 100644 index 00000000..74f423af --- /dev/null +++ b/packages/backend/src/apps/schedule/index.ts @@ -0,0 +1,18 @@ +import Triggers from './triggers'; +import { + IService, + IApp, + IJSONObject, +} from '@automatisch/types'; + +export default class Schedule implements IService { + triggers: Triggers; + + constructor( + appData: IApp, + connectionData: IJSONObject, + parameters: IJSONObject + ) { + this.triggers = new Triggers(connectionData, parameters); + } +} diff --git a/packages/backend/src/apps/schedule/info.json b/packages/backend/src/apps/schedule/info.json new file mode 100644 index 00000000..d9ef1af1 --- /dev/null +++ b/packages/backend/src/apps/schedule/info.json @@ -0,0 +1,46 @@ +{ + "name": "Schedule", + "key": "schedule", + "iconUrl": "{BASE_URL}/apps/schedule/assets/favicon.svg", + "docUrl": "https://automatisch.io/docs/schedule", + "primaryColor": "0059F7", + "requiresAuthentication": false, + "triggers": [ + { + "name": "Every hour", + "key": "everyHour", + "description": "Triggers every hour.", + "substeps": [ + { + "key": "chooseTrigger", + "name": "Set up a trigger", + "arguments": [ + { + "label": "Trigger on weekends?", + "key": "triggersOnWeekend", + "type": "dropdown", + "description": "Should this flow trigger on Saturday and Sunday?", + "required": true, + "value": true, + "variables": false, + "options": [ + { + "label": "Yes", + "value": true + }, + { + "label": "No", + "value": false + } + ] + } + ] + }, + { + "key": "testStep", + "name": "Test trigger" + } + ] + } + ] +} diff --git a/packages/backend/src/apps/schedule/triggers.ts b/packages/backend/src/apps/schedule/triggers.ts new file mode 100644 index 00000000..f638f88e --- /dev/null +++ b/packages/backend/src/apps/schedule/triggers.ts @@ -0,0 +1,10 @@ +import { IJSONObject } from '@automatisch/types'; +import EveryHour from './triggers/every-hour'; + +export default class Triggers { + everyHour: EveryHour; + + constructor(connectionData: IJSONObject, parameters: IJSONObject) { + this.everyHour = new EveryHour(parameters); + } +} diff --git a/packages/backend/src/apps/schedule/triggers/every-hour.ts b/packages/backend/src/apps/schedule/triggers/every-hour.ts new file mode 100644 index 00000000..caa111e9 --- /dev/null +++ b/packages/backend/src/apps/schedule/triggers/every-hour.ts @@ -0,0 +1,35 @@ +import { DateTime } from 'luxon'; +import type { IJSONObject, IJSONValue, ITrigger } from '@automatisch/types'; +import { cronTimes, getNextCronDateTime, getDateTimeObjectRepresentation } from '../utils'; + +export default class EveryHour implements ITrigger { + triggersOnWeekend?: boolean | string; + + constructor(parameters: IJSONObject) { + if (parameters.triggersOnWeekend) { + this.triggersOnWeekend = parameters.triggersOnWeekend as string; + } + } + + get interval() { + if (this.triggersOnWeekend) { + return cronTimes.everyHour; + } + + return cronTimes.everyHourExcludingWeekends; + } + + async run(startDateTime: Date) { + const dateTime = DateTime.fromJSDate(startDateTime); + const dateTimeObjectRepresentation = getDateTimeObjectRepresentation(dateTime) as IJSONValue; + + return [dateTimeObjectRepresentation] as IJSONValue; + } + + async testRun() { + const nextCronDateTime = getNextCronDateTime(this.interval); + const dateTimeObjectRepresentation = getDateTimeObjectRepresentation(nextCronDateTime) as IJSONValue; + + return [dateTimeObjectRepresentation] as IJSONValue; + } +} diff --git a/packages/backend/src/apps/schedule/utils.ts b/packages/backend/src/apps/schedule/utils.ts new file mode 100644 index 00000000..fff96627 --- /dev/null +++ b/packages/backend/src/apps/schedule/utils.ts @@ -0,0 +1,28 @@ +import { DateTime } from 'luxon'; +import cronParser from 'cron-parser'; + +export const cronTimes = { + everyHour: '0 * * * *', + everyHourExcludingWeekends: '0 * * * 1-5', +}; + +export function getNextCronDateTime(cronString: string) { + const cronDate = cronParser.parseExpression(cronString); + const matchingNextCronDateTime = cronDate.next(); + const matchingNextDateTime = DateTime.fromJSDate(matchingNextCronDateTime.toDate()); + + return matchingNextDateTime; +}; + +export function getDateTimeObjectRepresentation(dateTime: DateTime) { + const defaults = dateTime.toObject(); + + return { + ...defaults, + ISO_date_time: dateTime.toISO(), + pretty_date: dateTime.toLocaleString(DateTime.DATE_MED), + pretty_time: dateTime.toLocaleString(DateTime.TIME_WITH_SECONDS), + pretty_day_of_week: dateTime.toFormat('cccc'), + day_of_week: dateTime.weekday, + }; +} diff --git a/packages/backend/src/apps/twitter/info.json b/packages/backend/src/apps/twitter/info.json index 0c4c2746..b6df76f1 100644 --- a/packages/backend/src/apps/twitter/info.json +++ b/packages/backend/src/apps/twitter/info.json @@ -219,7 +219,6 @@ { "name": "My Tweet", "key": "myTweet", - "interval": "15m", "description": "Will be triggered when you tweet something new.", "substeps": [ { diff --git a/packages/backend/src/graphql/mutations/update-flow-status.ts b/packages/backend/src/graphql/mutations/update-flow-status.ts index be6843b9..e7ac45a4 100644 --- a/packages/backend/src/graphql/mutations/update-flow-status.ts +++ b/packages/backend/src/graphql/mutations/update-flow-status.ts @@ -9,9 +9,7 @@ type Params = { }; const JOB_NAME = 'processorJob'; -const REPEAT_OPTIONS = { - every: 60000, // 1 minute -}; +const EVERY_15_MINUTES_CRON = '*/15 * * * *'; const updateFlowStatus = async ( _parent: unknown, @@ -33,17 +31,27 @@ const updateFlowStatus = async ( active: params.input.active, }); + const triggerStep = await flow.getTriggerStep(); + const trigger = await triggerStep.getTrigger(); + const interval = trigger.interval; + const repeatOptions = { + cron: interval || EVERY_15_MINUTES_CRON, + } + if (flow.active) { await processorQueue.add( JOB_NAME, { flowId: flow.id }, { - repeat: REPEAT_OPTIONS, + repeat: repeatOptions, jobId: flow.id, } ); } else { - await processorQueue.removeRepeatable(JOB_NAME, REPEAT_OPTIONS, flow.id); + const repeatableJobs = await processorQueue.getRepeatableJobs(); + const job = repeatableJobs.find(job => job.id === flow.id); + + await processorQueue.removeRepeatableByKey(job.key); } return flow; diff --git a/packages/backend/src/graphql/schema.graphql b/packages/backend/src/graphql/schema.graphql index a7321bc5..1a44fdaf 100644 --- a/packages/backend/src/graphql/schema.graphql +++ b/packages/backend/src/graphql/schema.graphql @@ -374,6 +374,7 @@ type TriggerSubstepArgument { variables: Boolean source: TriggerSubstepArgumentSource dependsOn: [String] + options: [TriggerSubstepArgumentOption] } type TriggerSubstepArgumentSource { @@ -382,6 +383,11 @@ type TriggerSubstepArgumentSource { arguments: [TriggerSubstepArgumentSourceArgument] } +type TriggerSubstepArgumentOption { + label: String + value: JSONObject +} + type TriggerSubstepArgumentSourceArgument { name: String value: String diff --git a/packages/backend/src/models/flow.ts b/packages/backend/src/models/flow.ts index 5d6dbf59..727f2506 100644 --- a/packages/backend/src/models/flow.ts +++ b/packages/backend/src/models/flow.ts @@ -82,6 +82,12 @@ class Flow extends Base { await super.$afterUpdate(opt, queryContext); Telemetry.flowUpdated(this); } + + async getTriggerStep(): Promise { + return await this.$relatedQuery('steps').findOne({ + type: 'trigger', + }); + } } export default Flow; diff --git a/packages/backend/src/models/step.ts b/packages/backend/src/models/step.ts index 9af2c596..155ba317 100644 --- a/packages/backend/src/models/step.ts +++ b/packages/backend/src/models/step.ts @@ -1,5 +1,6 @@ import { QueryContext, ModelOptions } from 'objection'; import Base from './base'; +import App from './app'; import Flow from './flow'; import Connection from './connection'; import ExecutionStep from './execution-step'; @@ -75,6 +76,27 @@ class Step extends Base { await super.$afterUpdate(opt, queryContext); Telemetry.stepUpdated(this); } + + get isTrigger(): boolean { + return this.type === 'trigger'; + } + + async getTrigger() { + if (!this.isTrigger) return null; + + const { appKey, connection, key, parameters = {} } = this; + + const appData = App.findOneByKey(appKey); + const AppClass = (await import(`../apps/${appKey}`)).default; + const appInstance = new AppClass( + appData, + connection?.formattedData, + parameters, + ); + const command = appInstance.triggers[key]; + + return command; + } } export default Step; diff --git a/packages/types/index.d.ts b/packages/types/index.d.ts index 9ed88d86..c79e2ac2 100644 --- a/packages/types/index.d.ts +++ b/packages/types/index.d.ts @@ -86,6 +86,7 @@ export interface IFieldDropdown { name: string; variables: boolean; dependsOn: string[]; + options: IFieldDropdownOption[]; source: { type: string; name: string; @@ -96,6 +97,11 @@ export interface IFieldDropdown { }; } +export interface IFieldDropdownOption { + label: string; + value: boolean | string; +} + export interface IFieldText { key: string; label: string; @@ -146,7 +152,15 @@ export interface IApp { } export interface IService { - authenticationClient: IAuthentication; + authenticationClient?: IAuthentication; + triggers?: any; + actions?: any; + data?: any; +} + +export interface ITrigger { + run(startTime?: Date): Promise; + testRun(startTime?: Date): Promise; } export interface IAuthentication { diff --git a/packages/web/src/components/ControlledAutocomplete/index.tsx b/packages/web/src/components/ControlledAutocomplete/index.tsx index bf4409c4..f9255bc6 100644 --- a/packages/web/src/components/ControlledAutocomplete/index.tsx +++ b/packages/web/src/components/ControlledAutocomplete/index.tsx @@ -2,20 +2,16 @@ import * as React from 'react'; import FormHelperText from '@mui/material/FormHelperText'; import { Controller, useFormContext } from 'react-hook-form'; import Autocomplete, { AutocompleteProps } from '@mui/material/Autocomplete'; +import type { IFieldDropdownOption } from '@automatisch/types'; -interface ControlledAutocompleteProps extends AutocompleteProps { +interface ControlledAutocompleteProps extends AutocompleteProps { shouldUnregister?: boolean; name: string; required?: boolean; description?: string; } -type Option = { - label: string; - value: string; -} - -const getOption = (options: readonly Option[], value: string) => options.find(option => option.value === value) || null; +const getOption = (options: readonly IFieldDropdownOption[], value: string) => options.find(option => option.value === value); function ControlledAutocomplete(props: ControlledAutocompleteProps): React.ReactElement { const { control } = useFormContext(); @@ -48,8 +44,8 @@ function ControlledAutocomplete(props: ControlledAutocompleteProps): React.React options={options} value={getOption(options, field.value)} onChange={(event, selectedOption, reason, details) => { - const typedSelectedOption = selectedOption as Option; - if (typedSelectedOption?.value) { + const typedSelectedOption = selectedOption as IFieldDropdownOption; + if (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 f0c5888e..077e720a 100644 --- a/packages/web/src/components/FlowStep/index.tsx +++ b/packages/web/src/components/FlowStep/index.tsx @@ -57,7 +57,7 @@ function generateValidationSchema(substeps: ISubstep[]) { // base validation for the field if not exists if (!substepArgumentValidations[key]) { - substepArgumentValidations[key] = yup.string(); + substepArgumentValidations[key] = yup.mixed(); } if (typeof substepArgumentValidations[key] === 'object') { diff --git a/packages/web/src/components/FlowSubstep/index.tsx b/packages/web/src/components/FlowSubstep/index.tsx index bd6d0d58..849d24bd 100644 --- a/packages/web/src/components/FlowSubstep/index.tsx +++ b/packages/web/src/components/FlowSubstep/index.tsx @@ -29,6 +29,9 @@ const validateSubstep = (substep: ISubstep, step: IStep) => { const argValue = step.parameters?.[arg.key]; + // `false` is an exceptional valid value + if (argValue === false) return true; + return Boolean(argValue); }); }; @@ -52,7 +55,6 @@ function FlowSubstep(props: FlowSubstepProps): React.ReactElement { const formContext = useFormContext(); const [validationStatus, setValidationStatus] = React.useState(validateSubstep(substep, formContext.getValues() as IStep)); - const handleChangeOnBlur = React.useCallback((key: string) => { return (value: string) => { const currentValue = step.parameters?.[key]; diff --git a/packages/web/src/components/InputCreator/index.tsx b/packages/web/src/components/InputCreator/index.tsx index bd8e984d..fee1bb22 100644 --- a/packages/web/src/components/InputCreator/index.tsx +++ b/packages/web/src/components/InputCreator/index.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { useLazyQuery } from '@apollo/client'; import MuiTextField from '@mui/material/TextField'; -import type { IField, IFieldDropdown, IJSONObject } from '@automatisch/types'; +import type { IField, IFieldDropdown, IFieldDropdownOption, IJSONObject } from '@automatisch/types'; import useDynamicData from 'hooks/useDynamicData'; import { GET_DATA } from 'graphql/queries/get-data'; @@ -22,13 +22,9 @@ type RawOption = { value: string; }; -type Option = { - label: string; - value: string; -}; - -const optionGenerator = (options: RawOption[]): Option[] => options?.map(({ name, value }) => ({ label: name as string, value: value as string })); -const getOption = (options: Option[], value: string) => options?.find(option => option.value === value); +const computeArguments = (args: IFieldDropdown["source"]["arguments"]): IJSONObject => args.reduce((result, { name, value }) => ({ ...result, [name as string]: value }), {}); +const optionGenerator = (options: RawOption[]): IFieldDropdownOption[] => options?.map(({ name, value }) => ({ label: name as string, value: value })); +const getOption = (options: IFieldDropdownOption[], value: string) => options?.find(option => option.value === value); export default function InputCreator(props: InputCreatorProps): React.ReactElement { const { @@ -55,7 +51,7 @@ export default function InputCreator(props: InputCreatorProps): React.ReactEleme const computedName = namePrefix ? `${namePrefix}.${name}` : name; if (type === 'dropdown') { - const options = optionGenerator(data); + const preparedOptions = schema.options || optionGenerator(data?.getData); return ( } - value={getOption(options, value)} + value={getOption(preparedOptions, value)} onChange={console.log} description={description} loading={loading} diff --git a/packages/web/src/graphql/mutations/update-step.ts b/packages/web/src/graphql/mutations/update-step.ts index 673eb345..906a9461 100644 --- a/packages/web/src/graphql/mutations/update-step.ts +++ b/packages/web/src/graphql/mutations/update-step.ts @@ -8,6 +8,7 @@ export const UPDATE_STEP = gql` key appKey parameters + status connection { id } diff --git a/packages/web/src/graphql/queries/get-apps.ts b/packages/web/src/graphql/queries/get-apps.ts index 5dd30a60..8e91cecf 100644 --- a/packages/web/src/graphql/queries/get-apps.ts +++ b/packages/web/src/graphql/queries/get-apps.ts @@ -64,6 +64,10 @@ export const GET_APPS = gql` description variables dependsOn + options { + label + value + } source { type name