Merge pull request #624 from automatisch/github-new-issues
feat(github): add new issues trigger
This commit is contained in:
@@ -1,9 +1,11 @@
|
|||||||
type TRepoOwnerAndRepo = {
|
type TRepoOwnerAndRepo = {
|
||||||
repoOwner: string;
|
repoOwner?: string;
|
||||||
repo: string;
|
repo?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRepoOwnerAndRepo(repoFullName: string): TRepoOwnerAndRepo {
|
export default function getRepoOwnerAndRepo(repoFullName: string): TRepoOwnerAndRepo {
|
||||||
|
if (!repoFullName) return {};
|
||||||
|
|
||||||
const [repoOwner, repo] = repoFullName.split('/');
|
const [repoOwner, repo] = repoFullName.split('/');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
36
packages/backend/src/apps/github/common/paginate-all.ts
Normal file
36
packages/backend/src/apps/github/common/paginate-all.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { IGlobalVariable, IJSONObject } from "@automatisch/types";
|
||||||
|
import type { AxiosResponse } from 'axios';
|
||||||
|
import parseLinkHeader from '../../../helpers/parse-header-link';
|
||||||
|
|
||||||
|
type TResponse = {
|
||||||
|
data: IJSONObject[],
|
||||||
|
error?: IJSONObject,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function paginateAll($: IGlobalVariable, request: Promise<AxiosResponse>) {
|
||||||
|
const response = await request;
|
||||||
|
const aggregatedResponse: TResponse = {
|
||||||
|
data: [...response.data],
|
||||||
|
};
|
||||||
|
|
||||||
|
let links = parseLinkHeader(response.headers.link);
|
||||||
|
|
||||||
|
while (links.next) {
|
||||||
|
const nextPageResponse = await $.http.request({
|
||||||
|
...response.config,
|
||||||
|
url: links.next.uri,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (nextPageResponse.integrationError) {
|
||||||
|
aggregatedResponse.error = nextPageResponse.integrationError;
|
||||||
|
|
||||||
|
links = null;
|
||||||
|
} else {
|
||||||
|
aggregatedResponse.data.push(...nextPageResponse.data);
|
||||||
|
|
||||||
|
links = parseLinkHeader(nextPageResponse.headers.link);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return aggregatedResponse;
|
||||||
|
}
|
29
packages/backend/src/apps/github/data/list-labels/index.ts
Normal file
29
packages/backend/src/apps/github/data/list-labels/index.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { IGlobalVariable } from '@automatisch/types';
|
||||||
|
import getRepoOwnerAndRepo from '../../common/get-repo-owner-and-repo';
|
||||||
|
import paginateAll from '../../common/paginate-all';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'List labels',
|
||||||
|
key: 'listLabels',
|
||||||
|
|
||||||
|
async run($: IGlobalVariable) {
|
||||||
|
const {
|
||||||
|
repoOwner,
|
||||||
|
repo,
|
||||||
|
} = getRepoOwnerAndRepo($.step.parameters.repo as string);
|
||||||
|
|
||||||
|
if (!repo) return { data: [] };
|
||||||
|
|
||||||
|
const firstPageRequest = $.http.get(`/repos/${repoOwner}/${repo}/labels`);
|
||||||
|
const response = await paginateAll($, firstPageRequest);
|
||||||
|
|
||||||
|
response.data = response.data.map((repo: { name: string }) => {
|
||||||
|
return {
|
||||||
|
value: repo.name,
|
||||||
|
name: repo.name,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
};
|
21
packages/backend/src/apps/github/data/list-repos/index.ts
Normal file
21
packages/backend/src/apps/github/data/list-repos/index.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { IGlobalVariable } from '@automatisch/types';
|
||||||
|
import paginateAll from '../../common/paginate-all';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'List repos',
|
||||||
|
key: 'listRepos',
|
||||||
|
|
||||||
|
async run($: IGlobalVariable) {
|
||||||
|
const firstPageRequest = $.http.get('/user/repos');
|
||||||
|
const response = await paginateAll($, firstPageRequest);
|
||||||
|
|
||||||
|
response.data = response.data.map((repo: { full_name: string }) => {
|
||||||
|
return {
|
||||||
|
value: repo.full_name,
|
||||||
|
name: repo.full_name,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
};
|
100
packages/backend/src/apps/github/triggers/new-issues/index.ts
Normal file
100
packages/backend/src/apps/github/triggers/new-issues/index.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import defineTrigger from '../../../../helpers/define-trigger';
|
||||||
|
import newIssues from './new-issues';
|
||||||
|
|
||||||
|
export default defineTrigger({
|
||||||
|
name: 'New issue',
|
||||||
|
key: 'newIssues',
|
||||||
|
pollInterval: 15,
|
||||||
|
description: 'Triggers when a new issue is created',
|
||||||
|
substeps: [
|
||||||
|
{
|
||||||
|
key: 'chooseConnection',
|
||||||
|
name: 'Choose connection'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
async run($) {
|
||||||
|
return await newIssues($);
|
||||||
|
},
|
||||||
|
});
|
@@ -0,0 +1,64 @@
|
|||||||
|
import {
|
||||||
|
IGlobalVariable,
|
||||||
|
ITriggerOutput,
|
||||||
|
} from '@automatisch/types';
|
||||||
|
import getRepoOwnerAndRepo from '../../common/get-repo-owner-and-repo';
|
||||||
|
import parseLinkHeader from '../../../../helpers/parse-header-link';
|
||||||
|
|
||||||
|
function getPathname($: IGlobalVariable) {
|
||||||
|
const { repoOwner, repo } = getRepoOwnerAndRepo($.step.parameters.repo as string);
|
||||||
|
|
||||||
|
if (repoOwner && repo) {
|
||||||
|
return `/repos/${repoOwner}/${repo}/issues`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '/issues';
|
||||||
|
}
|
||||||
|
|
||||||
|
const newIssues = async ($: IGlobalVariable) => {
|
||||||
|
const pathname = getPathname($);
|
||||||
|
const params = {
|
||||||
|
labels: $.step.parameters.label,
|
||||||
|
filter: 'all',
|
||||||
|
state: 'all',
|
||||||
|
sort: 'created',
|
||||||
|
direction: 'desc',
|
||||||
|
per_page: 100,
|
||||||
|
};
|
||||||
|
|
||||||
|
const issues: ITriggerOutput = {
|
||||||
|
data: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
let links;
|
||||||
|
do {
|
||||||
|
const response = await $.http.get(pathname, { params });
|
||||||
|
links = parseLinkHeader(response.headers.link);
|
||||||
|
|
||||||
|
if (response.integrationError) {
|
||||||
|
issues.error = response.integrationError;
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.data.length) {
|
||||||
|
for (const issue of response.data) {
|
||||||
|
const issueId = issue.id.toString();
|
||||||
|
|
||||||
|
if (issueId <= $.flow.lastInternalId && !$.execution.testRun) return issues;
|
||||||
|
|
||||||
|
const dataItem = {
|
||||||
|
raw: issue,
|
||||||
|
meta: {
|
||||||
|
internalId: issueId,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
issues.data.push(dataItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} while (links.next && !$.execution.testRun);
|
||||||
|
|
||||||
|
return issues;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default newIssues;
|
@@ -29,6 +29,11 @@ const getData = async (_parent: unknown, params: Params, context: Context) => {
|
|||||||
|
|
||||||
const command = app.data.find((data: IData) => data.key === params.key);
|
const command = app.data.find((data: IData) => data.key === params.key);
|
||||||
|
|
||||||
|
for (const parameterKey in params.parameters) {
|
||||||
|
const parameterValue = params.parameters[parameterKey];
|
||||||
|
$.step.parameters[parameterKey] = parameterValue;
|
||||||
|
}
|
||||||
|
|
||||||
const fetchedData = await command.run($);
|
const fetchedData = await command.run($);
|
||||||
|
|
||||||
if (fetchedData.error) {
|
if (fetchedData.error) {
|
||||||
|
48
packages/backend/src/helpers/parse-header-link.ts
Normal file
48
packages/backend/src/helpers/parse-header-link.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
type TParameters = {
|
||||||
|
[key: string]: string;
|
||||||
|
rel?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TReference = {
|
||||||
|
uri: string;
|
||||||
|
parameters: TParameters;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TRel = 'next' | 'prev' | 'first' | 'last';
|
||||||
|
|
||||||
|
type TParsedLinkHeader = {
|
||||||
|
next?: TReference;
|
||||||
|
prev?: TReference;
|
||||||
|
first?: TReference;
|
||||||
|
last?: TReference;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function parseLinkHeader(link: string): TParsedLinkHeader {
|
||||||
|
const parsed: TParsedLinkHeader = {};
|
||||||
|
|
||||||
|
if (!link) return parsed;
|
||||||
|
|
||||||
|
const items = link.split(',');
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const [rawUriReference, ...rawLinkParameters] = item.split(';') as [string, ...string[]];
|
||||||
|
const trimmedUriReference = rawUriReference.trim();
|
||||||
|
|
||||||
|
const reference = trimmedUriReference.slice(1, -1);
|
||||||
|
const parameters: TParameters = {};
|
||||||
|
|
||||||
|
for (const rawParameter of rawLinkParameters) {
|
||||||
|
const trimmedRawParameter = rawParameter.trim();
|
||||||
|
const [key, value] = trimmedRawParameter.split('=');
|
||||||
|
|
||||||
|
parameters[key.trim()] = value.slice(1, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed[parameters.rel as TRel] = {
|
||||||
|
uri: reference,
|
||||||
|
parameters,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
@@ -47,7 +47,7 @@ export const processAction = async (options: ProcessActionOptions) => {
|
|||||||
stepId: $.step.id,
|
stepId: $.step.id,
|
||||||
status: actionOutput.error ? 'failure' : 'success',
|
status: actionOutput.error ? 'failure' : 'success',
|
||||||
dataIn: computedParameters,
|
dataIn: computedParameters,
|
||||||
dataOut: actionOutput.error ? null : actionOutput.data.raw,
|
dataOut: actionOutput.error ? null : actionOutput.data?.raw,
|
||||||
errorDetails: actionOutput.error ? actionOutput.error : null,
|
errorDetails: actionOutput.error ? actionOutput.error : null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@@ -38,7 +38,7 @@ export const processTrigger = async (options: ProcessTriggerOptions) => {
|
|||||||
stepId: $.step.id,
|
stepId: $.step.id,
|
||||||
status: error ? 'failure' : 'success',
|
status: error ? 'failure' : 'success',
|
||||||
dataIn: $.step.parameters,
|
dataIn: $.step.parameters,
|
||||||
dataOut: !error ? triggerDataItem.raw : null,
|
dataOut: !error ? triggerDataItem?.raw : null,
|
||||||
errorDetails: error,
|
errorDetails: error,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@@ -9,12 +9,13 @@ interface ControlledAutocompleteProps extends AutocompleteProps<IFieldDropdownOp
|
|||||||
name: string;
|
name: string;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
dependsOn?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const getOption = (options: readonly IFieldDropdownOption[], value: string) => options.find(option => option.value === value) || null;
|
const getOption = (options: readonly IFieldDropdownOption[], value: string) => options.find(option => option.value === value) || null;
|
||||||
|
|
||||||
function ControlledAutocomplete(props: ControlledAutocompleteProps): React.ReactElement {
|
function ControlledAutocomplete(props: ControlledAutocompleteProps): React.ReactElement {
|
||||||
const { control } = useFormContext();
|
const { control, watch, setValue, resetField } = useFormContext();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
required = false,
|
required = false,
|
||||||
@@ -25,9 +26,26 @@ function ControlledAutocomplete(props: ControlledAutocompleteProps): React.React
|
|||||||
onChange,
|
onChange,
|
||||||
description,
|
description,
|
||||||
options = [],
|
options = [],
|
||||||
|
dependsOn = [],
|
||||||
...autocompleteProps
|
...autocompleteProps
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
|
let dependsOnValues: unknown[] = [];
|
||||||
|
if (dependsOn?.length) {
|
||||||
|
dependsOnValues = watch(dependsOn);
|
||||||
|
}
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const hasDependencies = dependsOnValues.length;
|
||||||
|
const allDepsSatisfied = dependsOnValues.every(Boolean);
|
||||||
|
|
||||||
|
if (hasDependencies && !allDepsSatisfied) {
|
||||||
|
// Reset the field if any dependency is not satisfied
|
||||||
|
setValue(name, null);
|
||||||
|
resetField(name);
|
||||||
|
}
|
||||||
|
}, dependsOnValues);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Controller
|
<Controller
|
||||||
rules={{ required }}
|
rules={{ required }}
|
||||||
|
@@ -44,6 +44,7 @@ export default function InputCreator(props: InputCreatorProps): React.ReactEleme
|
|||||||
clickToCopy,
|
clickToCopy,
|
||||||
variables,
|
variables,
|
||||||
type,
|
type,
|
||||||
|
dependsOn,
|
||||||
} = schema;
|
} = schema;
|
||||||
|
|
||||||
const { data, loading } = useDynamicData(stepId, schema);
|
const { data, loading } = useDynamicData(stepId, schema);
|
||||||
@@ -55,6 +56,7 @@ export default function InputCreator(props: InputCreatorProps): React.ReactEleme
|
|||||||
return (
|
return (
|
||||||
<ControlledAutocomplete
|
<ControlledAutocomplete
|
||||||
name={computedName}
|
name={computedName}
|
||||||
|
dependsOn={dependsOn}
|
||||||
fullWidth
|
fullWidth
|
||||||
disablePortal
|
disablePortal
|
||||||
disableClearable={required}
|
disableClearable={required}
|
||||||
|
@@ -20,7 +20,7 @@ function computeArguments(args: IFieldDropdownSource["arguments"], getValues: Us
|
|||||||
const sanitizedFieldPath = value.replace(/{|}/g, '');
|
const sanitizedFieldPath = value.replace(/{|}/g, '');
|
||||||
const computedValue = getValues(sanitizedFieldPath);
|
const computedValue = getValues(sanitizedFieldPath);
|
||||||
|
|
||||||
if (!computedValue) throw new Error(`The ${sanitizedFieldPath} field is required.`);
|
if (computedValue === undefined) throw new Error(`The ${sanitizedFieldPath} field is required.`);
|
||||||
|
|
||||||
set(result, name, computedValue);
|
set(result, name, computedValue);
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user