From c985173cdfccfe0276e090e83145d6e3acf365e6 Mon Sep 17 00:00:00 2001 From: Danylo Hotvianskyi Date: Fri, 11 Jul 2025 17:40:41 +0200 Subject: [PATCH 1/5] feat(scafolder): scaffolder-backend-module-github implement github:issues:create Signed-off-by: Danylo Hotvianskyi --- .../actions/githubIssuesCreate.examples.ts | 58 ++++++ .../src/actions/githubIssuesCreate.test.ts | 144 +++++++++++++++ .../src/actions/githubIssuesCreate.ts | 166 ++++++++++++++++++ .../src/actions/index.ts | 1 + .../src/module.ts | 5 + 5 files changed, 374 insertions(+) create mode 100644 plugins/scaffolder-backend-module-github/src/actions/githubIssuesCreate.examples.ts create mode 100644 plugins/scaffolder-backend-module-github/src/actions/githubIssuesCreate.test.ts create mode 100644 plugins/scaffolder-backend-module-github/src/actions/githubIssuesCreate.ts diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubIssuesCreate.examples.ts b/plugins/scaffolder-backend-module-github/src/actions/githubIssuesCreate.examples.ts new file mode 100644 index 0000000000..17f6c8078b --- /dev/null +++ b/plugins/scaffolder-backend-module-github/src/actions/githubIssuesCreate.examples.ts @@ -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', + }, + }, + ], + }), + }, +]; diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubIssuesCreate.test.ts b/plugins/scaffolder-backend-module-github/src/actions/githubIssuesCreate.test.ts new file mode 100644 index 0000000000..440df36915 --- /dev/null +++ b/plugins/scaffolder-backend-module-github/src/actions/githubIssuesCreate.test.ts @@ -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; + + 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, + }); + }); +}); diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubIssuesCreate.ts b/plugins/scaffolder-backend-module-github/src/actions/githubIssuesCreate.ts new file mode 100644 index 0000000000..757e4ac675 --- /dev/null +++ b/plugins/scaffolder-backend-module-github/src/actions/githubIssuesCreate.ts @@ -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; + } + }, + }); +} diff --git a/plugins/scaffolder-backend-module-github/src/actions/index.ts b/plugins/scaffolder-backend-module-github/src/actions/index.ts index 7cf4109e6b..21732d7908 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/index.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/index.ts @@ -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'; diff --git a/plugins/scaffolder-backend-module-github/src/module.ts b/plugins/scaffolder-backend-module-github/src/module.ts index 5812a22560..67bda58fc4 100644 --- a/plugins/scaffolder-backend-module-github/src/module.ts +++ b/plugins/scaffolder-backend-module-github/src/module.ts @@ -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, From f0f06b43542dec7533ae5c4d9cdb7b9a654139bc Mon Sep 17 00:00:00 2001 From: Danylo Hotvianskyi Date: Fri, 11 Jul 2025 18:12:07 +0200 Subject: [PATCH 2/5] add changeset Signed-off-by: Danylo Hotvianskyi --- .changeset/stupid-pans-flash.md | 35 +++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .changeset/stupid-pans-flash.md diff --git a/.changeset/stupid-pans-flash.md b/.changeset/stupid-pans-flash.md new file mode 100644 index 0000000000..bc857a243a --- /dev/null +++ b/.changeset/stupid-pans-flash.md @@ -0,0 +1,35 @@ +--- +'@backstage/plugin-scaffolder-backend-module-github': minor +--- + +Adding a new scaffolder action `github:issues:create` following the reference of `github:issues:label` with dryRun testing possibility + +It can be used like this + +``` + steps: + - id: create-simple-issue + name: Create Simple Issue + action: github:issues:create + input: + repoUrl: ${{ parameters.repoUrl }} + title: "[${{ parameters.projectName }}] Simple Bug Report" + body: | + ## Bug Description + This is a simple bug report created by the scaffolder template. + + ### Steps to Reproduce + 1. Run the application + 2. Navigate to the main page + 3. Click on the problematic button + + ### Expected Behavior + The button should work correctly. + + ### Actual Behavior + The button does not respond to clicks. + output: + links: + - title: Simple Issue + url: ${{ steps['create-simple-issue'].output.issueUrl }} +``` From 16ee5911ebc5d3051b25652695f241e99df12d89 Mon Sep 17 00:00:00 2001 From: Danylo Hotvianskyi Date: Fri, 11 Jul 2025 18:27:29 +0200 Subject: [PATCH 3/5] Add notices Signed-off-by: Danylo Hotvianskyi --- .../actions/githubIssuesCreate.examples.ts | 15 ++++++++++++++ .../src/actions/githubIssuesCreate.test.ts | 16 +++++++++++++++ .../src/actions/githubIssuesCreate.ts | 20 +++++++++++++++++-- 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubIssuesCreate.examples.ts b/plugins/scaffolder-backend-module-github/src/actions/githubIssuesCreate.examples.ts index 17f6c8078b..7d767f0337 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubIssuesCreate.examples.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubIssuesCreate.examples.ts @@ -1,3 +1,18 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ import { TemplateExample } from '@backstage/plugin-scaffolder-node'; import * as yaml from 'yaml'; diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubIssuesCreate.test.ts b/plugins/scaffolder-backend-module-github/src/actions/githubIssuesCreate.test.ts index 440df36915..474acf14d7 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubIssuesCreate.test.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubIssuesCreate.test.ts @@ -1,3 +1,19 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + import { createGithubIssuesCreateAction } from './githubIssuesCreate'; import { ScmIntegrations, diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubIssuesCreate.ts b/plugins/scaffolder-backend-module-github/src/actions/githubIssuesCreate.ts index 757e4ac675..5c5703e20d 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubIssuesCreate.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubIssuesCreate.ts @@ -1,3 +1,19 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + import { GithubCredentialsProvider, ScmIntegrationRegistry, @@ -109,7 +125,7 @@ export function createGithubIssuesCreateAction(options: { repo, token: providedToken, }); - + const client = new Octokit({ ...octokitOptions, log: ctx.logger, @@ -157,7 +173,7 @@ export function createGithubIssuesCreateAction(options: { } catch (e) { assertError(e); ctx.logger.warn( - `Failed: creating issue '${title}' on repo: '${repo}', ${e.message}`, + `Failed: creating issue '${title}' on repo: '${repo}', ${e.message}`, ); throw e; } From d5f4dd1c9078a0903e8578649e30ad6024ddf629 Mon Sep 17 00:00:00 2001 From: Danylo Hotvianskyi Date: Fri, 11 Jul 2025 18:55:13 +0200 Subject: [PATCH 4/5] Changes in public API Signed-off-by: Danylo Hotvianskyi --- .../report.api.md | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/plugins/scaffolder-backend-module-github/report.api.md b/plugins/scaffolder-backend-module-github/report.api.md index 2941be7702..a2dcc3ec4e 100644 --- a/plugins/scaffolder-backend-module-github/report.api.md +++ b/plugins/scaffolder-backend-module-github/report.api.md @@ -136,6 +136,27 @@ export function createGithubEnvironmentAction(options: { 'v2' >; +// @public +export function createGithubIssuesCreateAction(options: { + integrations: ScmIntegrationRegistry; + githubCredentialsProvider?: GithubCredentialsProvider; +}): TemplateAction< + { + repoUrl: string; + title: string; + body?: string | undefined; + assignees?: string[] | undefined; + milestone?: string | number | undefined; + labels?: string[] | undefined; + token?: string | undefined; + }, + { + issueUrl: string; + issueNumber: number; + }, + 'v2' +>; + // @public export function createGithubIssuesLabelAction(options: { integrations: ScmIntegrationRegistry; From ddf1de03f85b98be124cf7ed95e50fc5fbcfba8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 25 Aug 2025 11:10:14 +0200 Subject: [PATCH 5/5] Update .changeset/stupid-pans-flash.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .changeset/stupid-pans-flash.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/stupid-pans-flash.md b/.changeset/stupid-pans-flash.md index bc857a243a..3e8c2333dd 100644 --- a/.changeset/stupid-pans-flash.md +++ b/.changeset/stupid-pans-flash.md @@ -2,7 +2,7 @@ '@backstage/plugin-scaffolder-backend-module-github': minor --- -Adding a new scaffolder action `github:issues:create` following the reference of `github:issues:label` with dryRun testing possibility +Adding a new scaffolder action `github:issues:create` following the reference of `github:issues:label` with `dryRun` testing possibility It can be used like this