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