Merge pull request #624 from automatisch/github-new-issues

feat(github): add new issues trigger
This commit is contained in:
Ömer Faruk Aydın
2022-10-20 00:04:49 +02:00
committed by GitHub
13 changed files with 332 additions and 7 deletions

View File

@@ -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 {

View 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;
}

View 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;
},
};

View 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;
},
};

View 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($);
},
});

View File

@@ -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;

View File

@@ -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) {

View 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;
}

View File

@@ -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,
}); });

View File

@@ -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,
}); });

View File

@@ -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 }}

View File

@@ -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}

View File

@@ -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);