feat(scafolder): scaffolder-backend-module-github implement github:issues:create
Signed-off-by: Danylo Hotvianskyi <danilgotvyansky@gmail.com>
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
import { TemplateExample } from '@backstage/plugin-scaffolder-node';
|
||||
import * as yaml from 'yaml';
|
||||
|
||||
export const examples: TemplateExample[] = [
|
||||
{
|
||||
description: 'Create a simple issue',
|
||||
example: yaml.stringify({
|
||||
steps: [
|
||||
{
|
||||
action: 'github:issues:create',
|
||||
name: 'Create issue',
|
||||
input: {
|
||||
repoUrl: 'github.com?repo=repo&owner=owner',
|
||||
title: 'Bug report',
|
||||
body: 'Found a bug that needs to be fixed',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
description: 'Create an issue with labels and assignees',
|
||||
example: yaml.stringify({
|
||||
steps: [
|
||||
{
|
||||
action: 'github:issues:create',
|
||||
name: 'Create issue with metadata',
|
||||
input: {
|
||||
repoUrl: 'github.com?repo=repo&owner=owner',
|
||||
title: 'Feature request',
|
||||
body: 'This is a new feature request',
|
||||
labels: ['enhancement', 'needs-review'],
|
||||
assignees: ['octocat'],
|
||||
milestone: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
description: 'Create an issue with specific token',
|
||||
example: yaml.stringify({
|
||||
steps: [
|
||||
{
|
||||
action: 'github:issues:create',
|
||||
name: 'Create issue with token',
|
||||
input: {
|
||||
repoUrl: 'github.com?repo=repo&owner=owner',
|
||||
title: 'Documentation update',
|
||||
body: 'Update the documentation for the new API',
|
||||
labels: ['documentation'],
|
||||
token: 'gph_YourGitHubToken',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,144 @@
|
||||
import { createGithubIssuesCreateAction } from './githubIssuesCreate';
|
||||
import {
|
||||
ScmIntegrations,
|
||||
DefaultGithubCredentialsProvider,
|
||||
GithubCredentialsProvider,
|
||||
} from '@backstage/integration';
|
||||
import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { TemplateAction } from '@backstage/plugin-scaffolder-node';
|
||||
import { getOctokitOptions } from '../util';
|
||||
|
||||
jest.mock('../util', () => {
|
||||
return {
|
||||
getOctokitOptions: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { Octokit } from 'octokit';
|
||||
|
||||
const octokitMock = Octokit as unknown as jest.Mock;
|
||||
const mockOctokit = {
|
||||
rest: {
|
||||
issues: {
|
||||
create: jest.fn(),
|
||||
},
|
||||
},
|
||||
};
|
||||
jest.mock('octokit', () => ({
|
||||
Octokit: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('github:issues:create', () => {
|
||||
const config = new ConfigReader({
|
||||
integrations: {
|
||||
github: [
|
||||
{ host: 'github.com', token: 'tokenlols' },
|
||||
{ host: 'ghe.github.com' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const getOctokitOptionsMock = getOctokitOptions as jest.Mock;
|
||||
const integrations = ScmIntegrations.fromConfig(config);
|
||||
let githubCredentialsProvider: GithubCredentialsProvider;
|
||||
let action: TemplateAction<any, any, any>;
|
||||
|
||||
const mockContext = createMockActionContext({
|
||||
input: {
|
||||
repoUrl: 'github.com?repo=repo&owner=owner',
|
||||
title: 'Test Issue',
|
||||
body: 'This is a test issue',
|
||||
labels: ['bug', 'test'],
|
||||
assignees: ['octocat'],
|
||||
milestone: 1,
|
||||
},
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
octokitMock.mockImplementation(() => mockOctokit);
|
||||
mockOctokit.rest.issues.create.mockResolvedValue({
|
||||
data: {
|
||||
html_url: 'https://github.com/owner/repo/issues/1',
|
||||
number: 1,
|
||||
},
|
||||
});
|
||||
githubCredentialsProvider =
|
||||
DefaultGithubCredentialsProvider.fromIntegrations(integrations);
|
||||
action = createGithubIssuesCreateAction({
|
||||
integrations,
|
||||
githubCredentialsProvider,
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass context logger to Octokit client', async () => {
|
||||
await action.handler(mockContext);
|
||||
|
||||
expect(octokitMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ log: mockContext.logger }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should call the githubApi for creating issue', async () => {
|
||||
await action.handler(mockContext);
|
||||
expect(mockOctokit.rest.issues.create).toHaveBeenCalledWith({
|
||||
owner: 'owner',
|
||||
repo: 'repo',
|
||||
title: 'Test Issue',
|
||||
body: 'This is a test issue',
|
||||
labels: ['bug', 'test'],
|
||||
assignees: ['octocat'],
|
||||
milestone: 1,
|
||||
});
|
||||
expect(getOctokitOptionsMock.mock.calls[0][0].token).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should call the githubApi for creating issue with token', async () => {
|
||||
await action.handler({
|
||||
...mockContext,
|
||||
input: { ...mockContext.input, token: 'gph_YourGitHubToken' },
|
||||
});
|
||||
expect(mockOctokit.rest.issues.create).toHaveBeenCalledWith({
|
||||
owner: 'owner',
|
||||
repo: 'repo',
|
||||
title: 'Test Issue',
|
||||
body: 'This is a test issue',
|
||||
labels: ['bug', 'test'],
|
||||
assignees: ['octocat'],
|
||||
milestone: 1,
|
||||
});
|
||||
expect(getOctokitOptionsMock.mock.calls[0][0].token).toEqual(
|
||||
'gph_YourGitHubToken',
|
||||
);
|
||||
});
|
||||
|
||||
it('should output issue URL and number', async () => {
|
||||
await action.handler(mockContext);
|
||||
expect(mockContext.output).toHaveBeenCalledWith(
|
||||
'issueUrl',
|
||||
'https://github.com/owner/repo/issues/1',
|
||||
);
|
||||
expect(mockContext.output).toHaveBeenCalledWith('issueNumber', 1);
|
||||
});
|
||||
|
||||
it('should create issue with minimal input', async () => {
|
||||
const minimalContext = createMockActionContext({
|
||||
input: {
|
||||
repoUrl: 'github.com?repo=repo&owner=owner',
|
||||
title: 'Simple Issue',
|
||||
},
|
||||
});
|
||||
|
||||
await action.handler(minimalContext);
|
||||
expect(mockOctokit.rest.issues.create).toHaveBeenCalledWith({
|
||||
owner: 'owner',
|
||||
repo: 'repo',
|
||||
title: 'Simple Issue',
|
||||
body: undefined,
|
||||
labels: undefined,
|
||||
assignees: undefined,
|
||||
milestone: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import {
|
||||
GithubCredentialsProvider,
|
||||
ScmIntegrationRegistry,
|
||||
} from '@backstage/integration';
|
||||
import {
|
||||
createTemplateAction,
|
||||
parseRepoUrl,
|
||||
} from '@backstage/plugin-scaffolder-node';
|
||||
import { assertError, InputError } from '@backstage/errors';
|
||||
import { Octokit } from 'octokit';
|
||||
import { getOctokitOptions } from '../util';
|
||||
import { examples } from './githubIssuesCreate.examples';
|
||||
|
||||
/**
|
||||
* Creates an issue on GitHub
|
||||
* @public
|
||||
*/
|
||||
export function createGithubIssuesCreateAction(options: {
|
||||
integrations: ScmIntegrationRegistry;
|
||||
githubCredentialsProvider?: GithubCredentialsProvider;
|
||||
}) {
|
||||
const { integrations, githubCredentialsProvider } = options;
|
||||
|
||||
return createTemplateAction({
|
||||
id: 'github:issues:create',
|
||||
description: 'Creates an issue on GitHub.',
|
||||
examples,
|
||||
supportsDryRun: true,
|
||||
schema: {
|
||||
input: {
|
||||
repoUrl: z =>
|
||||
z.string({
|
||||
description:
|
||||
'Accepts the format `github.com?repo=reponame&owner=owner` where `reponame` is the repository name and `owner` is an organization or username',
|
||||
}),
|
||||
title: z =>
|
||||
z.string({
|
||||
description: 'The title of the issue',
|
||||
}),
|
||||
body: z =>
|
||||
z
|
||||
.string({
|
||||
description: 'The contents of the issue',
|
||||
})
|
||||
.optional(),
|
||||
assignees: z =>
|
||||
z
|
||||
.array(z.string(), {
|
||||
description:
|
||||
'Logins for Users to assign to this issue. NOTE: Only users with push access can set assignees for new issues. Assignees are silently dropped otherwise.',
|
||||
})
|
||||
.optional(),
|
||||
milestone: z =>
|
||||
z
|
||||
.union([z.string(), z.number()], {
|
||||
description:
|
||||
'The number of the milestone to associate this issue with. NOTE: Only users with push access can set the milestone for new issues. The milestone is silently dropped otherwise.',
|
||||
})
|
||||
.optional(),
|
||||
labels: z =>
|
||||
z
|
||||
.array(z.string(), {
|
||||
description:
|
||||
'Labels to associate with this issue. NOTE: Only users with push access can set labels for new issues. Labels are silently dropped otherwise.',
|
||||
})
|
||||
.optional(),
|
||||
token: z =>
|
||||
z
|
||||
.string({
|
||||
description:
|
||||
'The `GITHUB_TOKEN` to use for authorization to GitHub',
|
||||
})
|
||||
.optional(),
|
||||
},
|
||||
output: {
|
||||
issueUrl: z =>
|
||||
z.string({
|
||||
description: 'The URL of the created issue',
|
||||
}),
|
||||
issueNumber: z =>
|
||||
z.number({
|
||||
description: 'The number of the created issue',
|
||||
}),
|
||||
},
|
||||
},
|
||||
async handler(ctx) {
|
||||
const {
|
||||
repoUrl,
|
||||
title,
|
||||
body,
|
||||
assignees,
|
||||
milestone,
|
||||
labels,
|
||||
token: providedToken,
|
||||
} = ctx.input;
|
||||
|
||||
const { host, owner, repo } = parseRepoUrl(repoUrl, integrations);
|
||||
ctx.logger.info(`Creating issue "${title}" on repo ${repo}`);
|
||||
|
||||
if (!owner) {
|
||||
throw new InputError('Invalid repository owner provided in repoUrl');
|
||||
}
|
||||
|
||||
const octokitOptions = await getOctokitOptions({
|
||||
integrations,
|
||||
credentialsProvider: githubCredentialsProvider,
|
||||
host,
|
||||
owner,
|
||||
repo,
|
||||
token: providedToken,
|
||||
});
|
||||
|
||||
const client = new Octokit({
|
||||
...octokitOptions,
|
||||
log: ctx.logger,
|
||||
});
|
||||
|
||||
if (ctx.isDryRun) {
|
||||
ctx.logger.info(`Performing dry run of creating issue "${title}"`);
|
||||
ctx.output('issueUrl', `https://github.com/${owner}/${repo}/issues/42`);
|
||||
ctx.output('issueNumber', 42);
|
||||
ctx.logger.info(`Dry run complete`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const issue = await ctx.checkpoint({
|
||||
key: `github.issues.create.${owner}.${repo}.${title}`,
|
||||
fn: async () => {
|
||||
const response = await client.rest.issues.create({
|
||||
owner,
|
||||
repo,
|
||||
title,
|
||||
body,
|
||||
assignees,
|
||||
milestone,
|
||||
labels,
|
||||
});
|
||||
|
||||
return {
|
||||
html_url: response.data.html_url,
|
||||
number: response.data.number,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
if (!issue) {
|
||||
throw new Error('Failed to create issue');
|
||||
}
|
||||
|
||||
ctx.output('issueUrl', issue.html_url);
|
||||
ctx.output('issueNumber', issue.number);
|
||||
|
||||
ctx.logger.info(
|
||||
`Successfully created issue #${issue.number}: ${issue.html_url}`,
|
||||
);
|
||||
} catch (e) {
|
||||
assertError(e);
|
||||
ctx.logger.warn(
|
||||
`Failed: creating issue '${title}' on repo: '${repo}', ${e.message}`,
|
||||
);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
export { createGithubActionsDispatchAction } from './githubActionsDispatch';
|
||||
export { createGithubIssuesLabelAction } from './githubIssuesLabel';
|
||||
export { createGithubIssuesCreateAction } from './githubIssuesCreate';
|
||||
export { createGithubRepoCreateAction } from './githubRepoCreate';
|
||||
export { createGithubRepoPushAction } from './githubRepoPush';
|
||||
export { createGithubWebhookAction } from './githubWebhook';
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
createGithubDeployKeyAction,
|
||||
createGithubEnvironmentAction,
|
||||
createGithubIssuesLabelAction,
|
||||
createGithubIssuesCreateAction,
|
||||
createGithubRepoCreateAction,
|
||||
createGithubRepoPushAction,
|
||||
createGithubWebhookAction,
|
||||
@@ -82,6 +83,10 @@ export const githubModule = createBackendModule({
|
||||
integrations,
|
||||
githubCredentialsProvider,
|
||||
}),
|
||||
createGithubIssuesCreateAction({
|
||||
integrations,
|
||||
githubCredentialsProvider,
|
||||
}),
|
||||
createGithubRepoCreateAction({
|
||||
integrations,
|
||||
githubCredentialsProvider,
|
||||
|
||||
Reference in New Issue
Block a user