diff --git a/.changeset/metal-clocks-suffer.md b/.changeset/metal-clocks-suffer.md new file mode 100644 index 0000000000..c459be9fa5 --- /dev/null +++ b/.changeset/metal-clocks-suffer.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-scaffolder-backend-module-gitea': minor +'@backstage/plugin-scaffolder-backend': minor +--- + +Created a gitea module for the scaffolder. This module provides a new action "publish:gitea" able to create a gitea repository owned by an organization. See: https://gitea.com/api/swagger#/organization/createOrgRepo diff --git a/.changeset/shiny-jeans-begin.md b/.changeset/shiny-jeans-begin.md new file mode 100644 index 0000000000..d542747b34 --- /dev/null +++ b/.changeset/shiny-jeans-begin.md @@ -0,0 +1,5 @@ +--- +'@backstage/integration': patch +--- + +Validate if the `baseUrl` is a valid URL diff --git a/.changeset/thick-pillows-punch.md b/.changeset/thick-pillows-punch.md new file mode 100644 index 0000000000..def589d3f8 --- /dev/null +++ b/.changeset/thick-pillows-punch.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-node': patch +--- + +Add gitea as new type to be used from integrations configuration diff --git a/packages/integration/src/gitea/config.ts b/packages/integration/src/gitea/config.ts index 28070d570d..ae1fe5ee87 100644 --- a/packages/integration/src/gitea/config.ts +++ b/packages/integration/src/gitea/config.ts @@ -16,7 +16,7 @@ import { Config } from '@backstage/config'; import { trimEnd } from 'lodash'; -import { isValidHost } from '../helpers'; +import { isValidHost, isValidUrl } from '../helpers'; /** * The configuration for a single Gitea integration. @@ -62,6 +62,10 @@ export function readGiteaConfig(config: Config): GiteaIntegrationConfig { throw new Error( `Invalid Gitea integration config, '${host}' is not a valid host`, ); + } else if (baseUrl && !isValidUrl(baseUrl)) { + throw new Error( + `Invalid Gitea integration config, '${baseUrl}' is not a valid baseUrl`, + ); } if (baseUrl) { diff --git a/packages/integration/src/gitea/core.test.ts b/packages/integration/src/gitea/core.test.ts index fe6381d52e..ac29c469b6 100644 --- a/packages/integration/src/gitea/core.test.ts +++ b/packages/integration/src/gitea/core.test.ts @@ -30,6 +30,22 @@ describe('gitea core', () => { const worker = setupServer(); setupRequestMockHandlers(worker); + describe('getGiteaFileCatalogInfoCntentsUrl', () => { + it('can create an url from arguments', () => { + const config: GiteaIntegrationConfig = { + host: 'gitea.com', + }; + expect( + getGiteaFileContentsUrl( + config, + 'https://gitea.com/org1/repo1/src/branch/main/catalog-info.yaml', + ), + ).toEqual( + 'https://gitea.com/api/v1/repos/org1/repo1/contents/catalog-info.yaml?ref=main', + ); + }); + }); + describe('getGiteaFileContentsUrl', () => { it('can create an url from arguments', () => { const config: GiteaIntegrationConfig = { @@ -94,24 +110,10 @@ describe('gitea core', () => { }); }); - describe('getGerritRequestOptions', () => { - it('adds token header when only a password is specified', () => { - const authRequest: GiteaIntegrationConfig = { - host: 'gerrit.com', - password: 'P', - }; - const anonymousRequest: GiteaIntegrationConfig = { - host: 'gerrit.com', - }; - expect( - (getGiteaRequestOptions(authRequest).headers as any).Authorization, - ).toEqual('token P'); - expect(getGiteaRequestOptions(anonymousRequest).headers).toBeUndefined(); - }); - + describe('getGiteaRequestOptions', () => { it('adds basic auth when username and password are specified', () => { const authRequest: GiteaIntegrationConfig = { - host: 'gerrit.com', + host: 'gitea.com', username: 'username', password: 'P', }; diff --git a/plugins/scaffolder-backend-module-gitea/.eslintrc.js b/plugins/scaffolder-backend-module-gitea/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/scaffolder-backend-module-gitea/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/scaffolder-backend-module-gitea/README.md b/plugins/scaffolder-backend-module-gitea/README.md new file mode 100644 index 0000000000..f13602f2d8 --- /dev/null +++ b/plugins/scaffolder-backend-module-gitea/README.md @@ -0,0 +1,110 @@ +# scaffolder-backend-module-gitea + +Welcome to the `publish:gitea` action of the `scaffolder-gitea-backend`. + +## Getting started + +To use this action, you will have to add the package using the following command to be executed at the root of your backstage project: + +```bash +yarn add --cwd packages/backend +@backstage/plugin-scaffolder-backend-module-gitea +``` + +Configure the action (if not yet done): +(you can check the [docs](https://backstage.io/docs/features/software-templates/writing-custom-actions#registering-custom-actions) to see all options): + +Before to create a template, include to your `app-config.yaml` file the +gitea host and credentials under the `integrations:` section + +```yaml +integrations: + gitea: + - host: gitea.com + username: '' + password: '' + - host: localhost:3333 + username: '' + password: '' +``` + +**NOTE**: As backstage will issue HTTPS/TLS requests to the gitea instance, it is needed to configure `gitea` with a valid certificate or at least with a +self-signed certificate `gitea cert --host localhost -ca`. Don't forget to set the env var `NODE_EXTRA_CA_CERTS` to point to the CA file before launching backstage ! + +When done, you can create a template which: + +- Declare the `RepoUrlPicker` within the `spec/parameters` section to select the gitea hosts +- Include a step able to publish by example the newly generated project using the action `action: publish:gitea` + +```yaml +apiVersion: scaffolder.backstage.io/v1beta3 +kind: Template +metadata: + name: quarkus-web-template + title: Quarkus Hello world + description: Create a simple microservice using Quarkus + tags: + - java + - quarkus +spec: + owner: quarkus + type: service + parameters: + - title: Git repository Information + required: + - repoUrl + properties: + repoUrl: + title: Repository Location + type: string + ui:field: RepoUrlPicker + ui:options: + allowedHosts: + - gitea.: + - localhost: + + steps: + - id: template + name: Generating component + action: fetch:template + input: + url: ./skeleton + copyWithoutTemplating: + - .github/workflows/* + values: + component_id: ${{ parameters.component_id }} + namespace: ${{ parameters.component_id }}-dev + description: ${{ parameters.description }} + group_id: ${{ parameters.group_id }} + artifact_id: ${{ parameters.artifact_id }} + java_package_name: ${{ parameters.java_package_name }} + owner: ${{ parameters.owner }} + destination: ${{ (parameters.repoUrl | parseRepoUrl).owner }}/${{ (parameters.repoUrl | parseRepoUrl).repo } + port: 8080 + + - id: publish + name: Publishing to a gitea git repository + action: publish:gitea + input: + description: This is ${{ parameters.component_id }} + repoUrl: ${{ parameters.repoUrl }} + defaultBranch: main + + - id: register + if: ${{ parameters.dryRun !== true }} + name: Register + action: catalog:register + input: + repoContentsUrl: ${{ steps['publish'].output.repoContentsUrl }} + catalogInfoPath: 'main/catalog-info.yaml' + + output: + links: + - title: Source Code Repository + url: ${{ steps.publish.output.remoteUrl }} + - title: Open Component in catalog + icon: catalog + entityRef: ${{ steps.register.output.entityRef }} +``` + +Access the newly gitea repository created using the `repoContentsUrl` ;-) diff --git a/plugins/scaffolder-backend-module-gitea/api-report.md b/plugins/scaffolder-backend-module-gitea/api-report.md new file mode 100644 index 0000000000..373cc6258c --- /dev/null +++ b/plugins/scaffolder-backend-module-gitea/api-report.md @@ -0,0 +1,32 @@ +## API Report File for "@backstage/plugin-scaffolder-backend-module-gitea" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { BackendFeature } from '@backstage/backend-plugin-api'; +import { Config } from '@backstage/config'; +import { JsonObject } from '@backstage/types'; +import { ScmIntegrationRegistry } from '@backstage/integration'; +import { TemplateAction } from '@backstage/plugin-scaffolder-node'; + +// @public +export function createPublishGiteaAction(options: { + integrations: ScmIntegrationRegistry; + config: Config; +}): TemplateAction< + { + repoUrl: string; + description: string; + defaultBranch?: string | undefined; + gitCommitMessage?: string | undefined; + gitAuthorName?: string | undefined; + gitAuthorEmail?: string | undefined; + sourcePath?: string | undefined; + }, + JsonObject +>; + +// @public +const giteaModule: () => BackendFeature; +export default giteaModule; +``` diff --git a/plugins/scaffolder-backend-module-gitea/catalog-info.yaml b/plugins/scaffolder-backend-module-gitea/catalog-info.yaml new file mode 100644 index 0000000000..adfcc754fb --- /dev/null +++ b/plugins/scaffolder-backend-module-gitea/catalog-info.yaml @@ -0,0 +1,9 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: backstage-plugin-scaffolder-backend-module-gitea + title: '@backstage/plugin-scaffolder-backend-module-gitea' +spec: + lifecycle: experimental + type: backstage-backend-plugin-module + owner: maintainers diff --git a/plugins/scaffolder-backend-module-gitea/package.json b/plugins/scaffolder-backend-module-gitea/package.json new file mode 100644 index 0000000000..17f30b226b --- /dev/null +++ b/plugins/scaffolder-backend-module-gitea/package.json @@ -0,0 +1,57 @@ +{ + "name": "@backstage/plugin-scaffolder-backend-module-gitea", + "description": "The gitea module for @backstage/plugin-scaffolder-backend", + "version": "0.0.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/scaffolder-backend-module-gitea" + }, + "backstage": { + "role": "backend-plugin-module" + }, + "exports": { + ".": "./src/index.ts", + "./package.json": "./package.json" + }, + "typesVersions": { + "*": { + "package.json": [ + "package.json" + ] + } + }, + "scripts": { + "start": "backstage-cli package start", + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack" + }, + "dependencies": { + "@backstage/backend-plugin-api": "workspace:^", + "@backstage/config": "workspace:^", + "@backstage/errors": "workspace:^", + "@backstage/integration": "workspace:^", + "@backstage/plugin-scaffolder-node": "workspace:^", + "node-fetch": "^2.6.7", + "yaml": "^2.0.0" + }, + "devDependencies": { + "@backstage/backend-common": "workspace:^", + "@backstage/backend-test-utils": "workspace:^", + "@backstage/cli": "workspace:^", + "msw": "^1.0.0" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/scaffolder-backend-module-gitea/src/actions/gitea.examples.ts b/plugins/scaffolder-backend-module-gitea/src/actions/gitea.examples.ts new file mode 100644 index 0000000000..748a869205 --- /dev/null +++ b/plugins/scaffolder-backend-module-gitea/src/actions/gitea.examples.ts @@ -0,0 +1,158 @@ +/* + * 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 yaml from 'yaml'; + +export const examples: TemplateExample[] = [ + { + description: + 'Initializes a Gitea repository using the content of the workspace and publish it to Gitea with default configuration.', + example: yaml.stringify({ + steps: [ + { + id: 'publish', + action: 'publish:gitea', + name: 'Publish to Gitea', + input: { + repoUrl: 'gitea.com?repo=repo&owner=owner', + }, + }, + ], + }), + }, + { + description: 'Initializes a Gitea repository with a description.', + example: yaml.stringify({ + steps: [ + { + id: 'publish', + action: 'publish:gitea', + name: 'Publish to Gitea', + input: { + repoUrl: 'gitea.com?repo=repo&owner=owner', + description: 'Initialize a gitea repository', + }, + }, + ], + }), + }, + { + description: + 'Initializes a Gitea repository with a default Branch, if not set defaults to main', + example: yaml.stringify({ + steps: [ + { + id: 'publish', + action: 'publish:gitea', + name: 'Publish to Gitea', + input: { + repoUrl: 'gitea.com?repo=repo&owner=owner', + defaultBranch: 'main', + }, + }, + ], + }), + }, + { + description: + 'Initializes a Gitea repository with an initial commit message, if not set defaults to initial commit', + example: yaml.stringify({ + steps: [ + { + id: 'publish', + action: 'publish:gitea', + name: 'Publish to Gitea', + input: { + repoUrl: 'gitea.com?repo=repo&owner=owner', + gitCommitMessage: 'Initial Commit Message', + }, + }, + ], + }), + }, + { + description: + 'Initializes a Gitea repository with a repo Author Name, if not set defaults to Scaffolder', + example: yaml.stringify({ + steps: [ + { + id: 'publish', + action: 'publish:gitea', + name: 'Publish to Gitea', + input: { + repoUrl: 'gitea.com?repo=repo&owner=owner', + gitAuthorName: 'John Doe', + }, + }, + ], + }), + }, + { + description: 'Initializes a Gitea repository with a repo Author Email', + example: yaml.stringify({ + steps: [ + { + id: 'publish', + action: 'publish:gitea', + name: 'Publish to Gitea', + input: { + repoUrl: 'gitea.com?repo=repo&owner=owner', + gitAuthorEmail: 'johndoe@email.com', + }, + }, + ], + }), + }, + { + description: + 'Path within the workspace that will be used as the repository root. If omitted, the entire workspace will be published as the repository', + example: yaml.stringify({ + steps: [ + { + id: 'publish', + action: 'publish:gitea', + name: 'Publish to Gitea', + input: { + repoUrl: 'gitea.com?repo=repo&owner=owner', + sourcePath: 'repository/', + }, + }, + ], + }), + }, + { + description: 'Initializes a Gitea repository with all properties being set', + example: yaml.stringify({ + steps: [ + { + id: 'publish', + action: 'publish:gitea', + name: 'Publish to Gitea', + input: { + repoUrl: 'gitea.com?repo=repo&owner=owner', + description: 'Initialize a gitea repository', + defaultBranch: 'staging', + gitCommitMessage: 'Initial Commit Message', + gitAuthorName: 'John Doe', + gitAuthorEmail: 'johndoe@email.com', + sourcePath: 'repository/', + }, + }, + ], + }), + }, +]; diff --git a/plugins/scaffolder-backend-module-gitea/src/actions/gitea.test.ts b/plugins/scaffolder-backend-module-gitea/src/actions/gitea.test.ts new file mode 100644 index 0000000000..5e1e94b1b4 --- /dev/null +++ b/plugins/scaffolder-backend-module-gitea/src/actions/gitea.test.ts @@ -0,0 +1,160 @@ +/* + * 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 { PassThrough } from 'stream'; +import { ScmIntegrations } from '@backstage/integration'; +import { ConfigReader } from '@backstage/config'; +import { getVoidLogger } from '@backstage/backend-common'; +import { createPublishGiteaAction } from './gitea'; +import { initRepoAndPush } from '@backstage/plugin-scaffolder-node'; +import { rest } from 'msw'; +import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; +import { setupServer } from 'msw/node'; + +jest.mock('@backstage/plugin-scaffolder-node', () => { + return { + ...jest.requireActual('@backstage/plugin-scaffolder-node'), + initRepoAndPush: jest.fn().mockResolvedValue({ + commitHash: '220f19cc36b551763d157f1b5e4a4b446165dbd6', + }), + }; +}); + +describe('publish:gitea', () => { + const config = new ConfigReader({ + integrations: { + gitea: [ + { + host: 'gitea.com', + username: 'gitea_user', + password: 'gitea_password', + }, + ], + }, + }); + + const description = 'for the lols'; + const integrations = ScmIntegrations.fromConfig(config); + const action = createPublishGiteaAction({ integrations, config }); + const mockContext = { + input: { + repoUrl: 'gitea.com?repo=repo&owner=owner', + description, + }, + workspacePath: 'lol', + logger: getVoidLogger(), + logStream: new PassThrough(), + output: jest.fn(), + createTemporaryDirectory: jest.fn(), + }; + + const server = setupServer(); + setupRequestMockHandlers(server); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('should throw an error when the repoUrl is not well formed', async () => { + await expect( + action.handler({ + ...mockContext, + input: { repoUrl: 'gitea.com?owner=o', description }, + }), + ).rejects.toThrow(/missing repo/); + }); + + it('should throw if there is no integration config provided for missing.com host', async () => { + await expect( + action.handler({ + ...mockContext, + input: { repoUrl: 'missing.com?repo=repo', description }, + }), + ).rejects.toThrow(/No matching integration configuration/); + }); + + it('should throw if there is no repositoryId returned', async () => { + server.use( + rest.get('https://gitea.com/api/v1/orgs/org1', (_req, res, ctx) => { + return res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json({ + id: 1, + name: 'org1', + visibility: 'public', + repo_admin_change_team_access: false, + username: 'org1', + }), + ); + }), + rest.get( + 'https://gitea.com/org1/repo/src/branch/main', + (_req, res, ctx) => { + return res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json({}), + ); + }, + ), + rest.post('https://gitea.com/api/v1/orgs/org1/repos', (req, res, ctx) => { + // Basic auth must match the user and password defined part of the config + expect(req.headers.get('Authorization')).toBe( + 'basic Z2l0ZWFfdXNlcjpnaXRlYV9wYXNzd29yZA==', + ); + expect(req.body).toEqual({ + name: 'repo', + description, + }); + return res( + ctx.status(201), + ctx.set('Content-Type', 'application/json'), + ctx.json({}), + ); + }), + ); + + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + repoUrl: 'gitea.com?repo=repo&owner=org1', + }, + }); + + expect(initRepoAndPush).toHaveBeenCalledWith({ + dir: mockContext.workspacePath, + remoteUrl: 'https://gitea.com/org1/repo.git', + defaultBranch: 'main', + auth: { username: 'gitea_user', password: 'gitea_password' }, + logger: mockContext.logger, + commitMessage: expect.stringContaining('initial commit\n\nChange-Id:'), + gitAuthorInfo: { + email: undefined, + name: undefined, + }, + }); + + expect(mockContext.output).toHaveBeenCalledWith( + 'repoContentsUrl', + 'https://gitea.com/org1/repo/src/branch/main/', + ); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); +}); diff --git a/plugins/scaffolder-backend-module-gitea/src/actions/gitea.ts b/plugins/scaffolder-backend-module-gitea/src/actions/gitea.ts new file mode 100644 index 0000000000..e7f62f51da --- /dev/null +++ b/plugins/scaffolder-backend-module-gitea/src/actions/gitea.ts @@ -0,0 +1,346 @@ +/* + * 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 { InputError } from '@backstage/errors'; +import { Config } from '@backstage/config'; +import { + getGiteaRequestOptions, + GiteaIntegrationConfig, + ScmIntegrationRegistry, +} from '@backstage/integration'; +import { + ActionContext, + createTemplateAction, + getRepoSourceDirectory, + initRepoAndPush, + parseRepoUrl, +} from '@backstage/plugin-scaffolder-node'; +import { examples } from './gitea.examples'; +import crypto from 'crypto'; + +const checkGiteaContentUrl = async ( + config: GiteaIntegrationConfig, + options: { + owner?: string; + repo: string; + defaultBranch?: string; + }, +): Promise => { + const { owner, repo, defaultBranch } = options; + let response: Response; + const getOptions: RequestInit = { + method: 'GET', + }; + + try { + response = await fetch( + `${config.baseUrl}/${owner}/${repo}/src/branch/${defaultBranch}`, + getOptions, + ); + } catch (e) { + throw new Error( + `Unable to get the repository: ${owner}/${repo} metadata , ${e}`, + ); + } + return response; +}; + +const checkGiteaOrg = async ( + config: GiteaIntegrationConfig, + options: { + owner: string; + }, +): Promise => { + const { owner } = options; + let response: Response; + // check first if the org = owner exists + const getOptions: RequestInit = { + method: 'GET', + headers: { + ...getGiteaRequestOptions(config).headers, + 'Content-Type': 'application/json', + }, + }; + try { + response = await fetch( + `${config.baseUrl}/api/v1/orgs/${owner}`, + getOptions, + ); + } catch (e) { + throw new Error(`Unable to get the Organization: ${owner}, ${e}`); + } + if (response.status !== 200) { + throw new Error( + `Organization ${owner} do not exist. Please create it first !`, + ); + } +}; + +const createGiteaProject = async ( + config: GiteaIntegrationConfig, + options: { + projectName: string; + owner?: string; + description: string; + }, +): Promise => { + const { projectName, description, owner } = options; + + /* + Several options exist to create a repository using either the user or organisation + User: https://gitea.com/api/swagger#/user/createCurrentUserRepo + Api: URL/api/v1/user/repos + Remark: The user is the username defined part of the backstage integration config for the gitea URL ! + + Org: https://gitea.com/api/swagger#/organization/createOrgRepo + Api: URL/api/v1/orgs/${org_owner}/repos + This is the default scenario that we support currently + */ + let response: Response; + + const postOptions: RequestInit = { + method: 'POST', + body: JSON.stringify({ + name: projectName, + description, + }), + headers: { + ...getGiteaRequestOptions(config).headers, + 'Content-Type': 'application/json', + }, + }; + try { + response = await fetch( + `${config.baseUrl}/api/v1/orgs/${owner}/repos`, + postOptions, + ); + } catch (e) { + throw new Error(`Unable to create repository, ${e}`); + } + if (response.status !== 201) { + throw new Error( + `Unable to create repository, ${response.status} ${ + response.statusText + }, ${await response.text()}`, + ); + } +}; + +const generateCommitMessage = ( + config: Config, + commitSubject?: string, +): string => { + const changeId = crypto.randomBytes(20).toString('hex'); + const msg = `${ + config.getOptionalString('scaffolder.defaultCommitMessage') || commitSubject + }\n\nChange-Id: I${changeId}`; + return msg; +}; + +async function checkAvailabilityGiteaRepository( + maxDuration: number, + integrationConfig: GiteaIntegrationConfig, + options: { + owner?: string; + repo: string; + defaultBranch: string; + ctx: ActionContext; + }, +) { + const startTimestamp = Date.now(); + + const { owner, repo, defaultBranch, ctx } = options; + const sleep = (ms: number | undefined) => new Promise(r => setTimeout(r, ms)); + let response: Response; + + while (Date.now() - startTimestamp < maxDuration) { + if (ctx.signal?.aborted) return; + + response = await checkGiteaContentUrl(integrationConfig, { + owner, + repo, + defaultBranch, + }); + + if (response.status !== 200) { + // Repository is not yet available/accessible ... + await sleep(1000); + } + } +} + +/** + * Creates a new action that initializes a git repository using the content of the workspace. + * and publishes it to a Gitea instance. + * @public + */ +export function createPublishGiteaAction(options: { + integrations: ScmIntegrationRegistry; + config: Config; +}) { + const { integrations, config } = options; + + return createTemplateAction<{ + repoUrl: string; + description: string; + defaultBranch?: string; + gitCommitMessage?: string; + gitAuthorName?: string; + gitAuthorEmail?: string; + sourcePath?: string; + }>({ + id: 'publish:gitea', + description: + 'Initializes a git repository using the content of the workspace, and publishes it to Gitea.', + examples, + schema: { + input: { + type: 'object', + required: ['repoUrl'], + properties: { + repoUrl: { + title: 'Repository Location', + type: 'string', + }, + description: { + title: 'Repository Description', + type: 'string', + }, + defaultBranch: { + title: 'Default Branch', + type: 'string', + description: `Sets the default branch on the repository. The default value is 'main'`, + }, + gitCommitMessage: { + title: 'Git Commit Message', + type: 'string', + description: `Sets the commit message on the repository. The default value is 'initial commit'`, + }, + gitAuthorName: { + title: 'Default Author Name', + type: 'string', + description: `Sets the default author name for the commit. The default value is 'Scaffolder'`, + }, + gitAuthorEmail: { + title: 'Default Author Email', + type: 'string', + description: `Sets the default author email for the commit.`, + }, + sourcePath: { + title: 'Source Path', + type: 'string', + description: `Path within the workspace that will be used as the repository root. If omitted, the entire workspace will be published as the repository.`, + }, + }, + }, + output: { + type: 'object', + properties: { + remoteUrl: { + title: 'A URL to the repository with the provider', + type: 'string', + }, + repoContentsUrl: { + title: 'A URL to the root of the repository', + type: 'string', + }, + commitHash: { + title: 'The git commit hash of the initial commit', + type: 'string', + }, + }, + }, + }, + async handler(ctx) { + const { + repoUrl, + description, + defaultBranch = 'main', + gitAuthorName, + gitAuthorEmail, + gitCommitMessage = 'initial commit', + sourcePath, + } = ctx.input; + + const { repo, host, owner } = parseRepoUrl(repoUrl, integrations); + + const integrationConfig = integrations.gitea.byHost(host); + if (!integrationConfig) { + throw new InputError( + `No matching integration configuration for host ${host}, please check your integrations config`, + ); + } + const { username, password } = integrationConfig.config; + + if (!username || !password) { + throw new Error('Credentials for the gitea ${host} required.'); + } + + // check if the org exists within the gitea server + if (owner) { + await checkGiteaOrg(integrationConfig.config, { owner }); + } + + await createGiteaProject(integrationConfig.config, { + description, + owner: owner, + projectName: repo, + }); + + const auth = { + username: username, + password: password, + }; + const gitAuthorInfo = { + name: gitAuthorName + ? gitAuthorName + : config.getOptionalString('scaffolder.defaultAuthor.name'), + email: gitAuthorEmail + ? gitAuthorEmail + : config.getOptionalString('scaffolder.defaultAuthor.email'), + }; + // The owner to be used should be either the org name or user authenticated with the gitea server + const remoteUrl = `${integrationConfig.config.baseUrl}/${owner}/${repo}.git`; + const commitResult = await initRepoAndPush({ + dir: getRepoSourceDirectory(ctx.workspacePath, sourcePath), + remoteUrl, + auth, + defaultBranch, + logger: ctx.logger, + commitMessage: generateCommitMessage(config, gitCommitMessage), + gitAuthorInfo, + }); + + // Check if the gitea repo URL is available before to exit + const maxDuration = 20000; // 20 seconds + await checkAvailabilityGiteaRepository( + maxDuration, + integrationConfig.config, + { + owner, + repo, + defaultBranch, + ctx, + }, + ); + + const repoContentsUrl = `${integrationConfig.config.baseUrl}/${owner}/${repo}/src/branch/${defaultBranch}/`; + ctx.output('remoteUrl', remoteUrl); + ctx.output('commitHash', commitResult?.commitHash); + ctx.output('repoContentsUrl', repoContentsUrl); + }, + }); +} diff --git a/plugins/scaffolder-backend-module-gitea/src/actions/index.ts b/plugins/scaffolder-backend-module-gitea/src/actions/index.ts new file mode 100644 index 0000000000..ccf7f23fab --- /dev/null +++ b/plugins/scaffolder-backend-module-gitea/src/actions/index.ts @@ -0,0 +1,16 @@ +/* + * 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. + */ +export * from './gitea'; diff --git a/plugins/scaffolder-backend-module-gitea/src/index.ts b/plugins/scaffolder-backend-module-gitea/src/index.ts new file mode 100644 index 0000000000..0b68069178 --- /dev/null +++ b/plugins/scaffolder-backend-module-gitea/src/index.ts @@ -0,0 +1,24 @@ +/* + * 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. + */ + +/** + * A module for the scaffolder backend that lets you interact with gitea + * + * @packageDocumentation + */ + +export * from './actions'; +export { giteaModule as default } from './module'; diff --git a/plugins/scaffolder-backend-module-gitea/src/module.ts b/plugins/scaffolder-backend-module-gitea/src/module.ts new file mode 100644 index 0000000000..658f240471 --- /dev/null +++ b/plugins/scaffolder-backend-module-gitea/src/module.ts @@ -0,0 +1,48 @@ +/* + * Copyright 2024 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 { + createBackendModule, + coreServices, +} from '@backstage/backend-plugin-api'; +import { scaffolderActionsExtensionPoint } from '@backstage/plugin-scaffolder-node/alpha'; +import { createPublishGiteaAction } from './actions'; +import { ScmIntegrations } from '@backstage/integration'; + +/** + * @public + * The Gitea Module for the Scaffolder Backend + */ +export const giteaModule = createBackendModule({ + pluginId: 'scaffolder', + moduleId: 'gitea', + register({ registerInit }) { + registerInit({ + deps: { + scaffolder: scaffolderActionsExtensionPoint, + config: coreServices.rootConfig, + }, + async init({ scaffolder, config }) { + const integrations = ScmIntegrations.fromConfig(config); + scaffolder.addActions( + createPublishGiteaAction({ + integrations, + config, + }), + ); + }, + }); + }, +}); diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index 9026761a7c..858591749c 100644 --- a/plugins/scaffolder-backend/package.json +++ b/plugins/scaffolder-backend/package.json @@ -64,6 +64,7 @@ "@backstage/plugin-scaffolder-backend-module-bitbucket-cloud": "workspace:^", "@backstage/plugin-scaffolder-backend-module-bitbucket-server": "workspace:^", "@backstage/plugin-scaffolder-backend-module-gerrit": "workspace:^", + "@backstage/plugin-scaffolder-backend-module-gitea": "workspace:^", "@backstage/plugin-scaffolder-backend-module-github": "workspace:^", "@backstage/plugin-scaffolder-backend-module-gitlab": "workspace:^", "@backstage/plugin-scaffolder-common": "workspace:^", diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts index f9c15597a8..2ee0a7644f 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts @@ -81,6 +81,8 @@ import { createPublishGitlabMergeRequestAction, } from '@backstage/plugin-scaffolder-backend-module-gitlab'; +import { createPublishGiteaAction } from '@backstage/plugin-scaffolder-backend-module-gitea'; + /** * The options passed to {@link createBuiltinActions} * @public @@ -158,6 +160,10 @@ export const createBuiltinActions = ( integrations, config, }), + createPublishGiteaAction({ + integrations, + config, + }), createPublishGithubAction({ integrations, config, diff --git a/plugins/scaffolder-node/src/actions/util.ts b/plugins/scaffolder-node/src/actions/util.ts index 327cfa9c2f..7f7121ef16 100644 --- a/plugins/scaffolder-node/src/actions/util.ts +++ b/plugins/scaffolder-node/src/actions/util.ts @@ -92,6 +92,10 @@ export const parseRepoUrl = ( } break; } + case 'gitea': { + checkRequiredParams(parsed, 'repo'); + break; + } case 'gerrit': { checkRequiredParams(parsed, 'repo'); break; diff --git a/yarn.lock b/yarn.lock index 3394871837..3291d13beb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8492,6 +8492,24 @@ __metadata: languageName: unknown linkType: soft +"@backstage/plugin-scaffolder-backend-module-gitea@workspace:^, @backstage/plugin-scaffolder-backend-module-gitea@workspace:plugins/scaffolder-backend-module-gitea": + version: 0.0.0-use.local + resolution: "@backstage/plugin-scaffolder-backend-module-gitea@workspace:plugins/scaffolder-backend-module-gitea" + dependencies: + "@backstage/backend-common": "workspace:^" + "@backstage/backend-plugin-api": "workspace:^" + "@backstage/backend-test-utils": "workspace:^" + "@backstage/cli": "workspace:^" + "@backstage/config": "workspace:^" + "@backstage/errors": "workspace:^" + "@backstage/integration": "workspace:^" + "@backstage/plugin-scaffolder-node": "workspace:^" + msw: ^1.0.0 + node-fetch: ^2.6.7 + yaml: ^2.0.0 + languageName: unknown + linkType: soft + "@backstage/plugin-scaffolder-backend-module-github@workspace:^, @backstage/plugin-scaffolder-backend-module-github@workspace:plugins/scaffolder-backend-module-github": version: 0.0.0-use.local resolution: "@backstage/plugin-scaffolder-backend-module-github@workspace:plugins/scaffolder-backend-module-github" @@ -8616,6 +8634,7 @@ __metadata: "@backstage/plugin-scaffolder-backend-module-bitbucket-cloud": "workspace:^" "@backstage/plugin-scaffolder-backend-module-bitbucket-server": "workspace:^" "@backstage/plugin-scaffolder-backend-module-gerrit": "workspace:^" + "@backstage/plugin-scaffolder-backend-module-gitea": "workspace:^" "@backstage/plugin-scaffolder-backend-module-github": "workspace:^" "@backstage/plugin-scaffolder-backend-module-gitlab": "workspace:^" "@backstage/plugin-scaffolder-common": "workspace:^"