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 = {
|
||||
repoOwner: string;
|
||||
repo: string;
|
||||
repoOwner?: string;
|
||||
repo?: string;
|
||||
}
|
||||
|
||||
export function getRepoOwnerAndRepo(repoFullName: string): TRepoOwnerAndRepo {
|
||||
export default function getRepoOwnerAndRepo(repoFullName: string): TRepoOwnerAndRepo {
|
||||
if (!repoFullName) return {};
|
||||
|
||||
const [repoOwner, repo] = repoFullName.split('/');
|
||||
|
||||
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);
|
||||
|
||||
for (const parameterKey in params.parameters) {
|
||||
const parameterValue = params.parameters[parameterKey];
|
||||
$.step.parameters[parameterKey] = parameterValue;
|
||||
}
|
||||
|
||||
const fetchedData = await command.run($);
|
||||
|
||||
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,
|
||||
status: actionOutput.error ? 'failure' : 'success',
|
||||
dataIn: computedParameters,
|
||||
dataOut: actionOutput.error ? null : actionOutput.data.raw,
|
||||
dataOut: actionOutput.error ? null : actionOutput.data?.raw,
|
||||
errorDetails: actionOutput.error ? actionOutput.error : null,
|
||||
});
|
||||
|
||||
|
@@ -38,7 +38,7 @@ export const processTrigger = async (options: ProcessTriggerOptions) => {
|
||||
stepId: $.step.id,
|
||||
status: error ? 'failure' : 'success',
|
||||
dataIn: $.step.parameters,
|
||||
dataOut: !error ? triggerDataItem.raw : null,
|
||||
dataOut: !error ? triggerDataItem?.raw : null,
|
||||
errorDetails: error,
|
||||
});
|
||||
|
||||
|
@@ -9,12 +9,13 @@ interface ControlledAutocompleteProps extends AutocompleteProps<IFieldDropdownOp
|
||||
name: string;
|
||||
required?: boolean;
|
||||
description?: string;
|
||||
dependsOn?: string[];
|
||||
}
|
||||
|
||||
const getOption = (options: readonly IFieldDropdownOption[], value: string) => options.find(option => option.value === value) || null;
|
||||
|
||||
function ControlledAutocomplete(props: ControlledAutocompleteProps): React.ReactElement {
|
||||
const { control } = useFormContext();
|
||||
const { control, watch, setValue, resetField } = useFormContext();
|
||||
|
||||
const {
|
||||
required = false,
|
||||
@@ -25,9 +26,26 @@ function ControlledAutocomplete(props: ControlledAutocompleteProps): React.React
|
||||
onChange,
|
||||
description,
|
||||
options = [],
|
||||
dependsOn = [],
|
||||
...autocompleteProps
|
||||
} = 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 (
|
||||
<Controller
|
||||
rules={{ required }}
|
||||
|
@@ -44,6 +44,7 @@ export default function InputCreator(props: InputCreatorProps): React.ReactEleme
|
||||
clickToCopy,
|
||||
variables,
|
||||
type,
|
||||
dependsOn,
|
||||
} = schema;
|
||||
|
||||
const { data, loading } = useDynamicData(stepId, schema);
|
||||
@@ -55,6 +56,7 @@ export default function InputCreator(props: InputCreatorProps): React.ReactEleme
|
||||
return (
|
||||
<ControlledAutocomplete
|
||||
name={computedName}
|
||||
dependsOn={dependsOn}
|
||||
fullWidth
|
||||
disablePortal
|
||||
disableClearable={required}
|
||||
|
@@ -20,7 +20,7 @@ function computeArguments(args: IFieldDropdownSource["arguments"], getValues: Us
|
||||
const sanitizedFieldPath = value.replace(/{|}/g, '');
|
||||
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);
|
||||
|
||||
|
Reference in New Issue
Block a user