feat(scafolder): scaffolder-backend-module-github implement github:issues:create

Signed-off-by: Danylo Hotvianskyi <danilgotvyansky@gmail.com>
This commit is contained in:
Danylo Hotvianskyi
2025-07-11 17:40:41 +02:00
parent 445cd3bb76
commit c985173cdf
5 changed files with 374 additions and 0 deletions
@@ -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,