feat: support imports from azure devops

Signed-off-by: Andrei Ivanovici <andrei.ivanovici@gmail.com>
This commit is contained in:
Andrei Ivanovici
2024-03-20 10:29:05 +02:00
committed by Andrei Ivanovici
parent dd041b8b22
commit 3daad61452
8 changed files with 976 additions and 152 deletions
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/plugin-catalog-import': patch
---
Integrated Azure Devops as a catalog import source. This enables Backstage to create Pull Requests to Azure Devops repositories as it does with GitHub repositories
NO BREAKING CHANGES
@@ -0,0 +1,81 @@
/*
* 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 { AzureIntegration } from '@backstage/integration';
import { ScmAuthApi } from '@backstage/integration-react';
import { ConfigApi } from '@backstage/core-plugin-api';
import { getBranchName, getCatalogFilename } from '../components/helpers';
import { createAzurePullRequest } from './AzureRepoApiClient';
import parseGitUrl from 'git-url-parse';
export interface AzureRepoParts {
tenantUrl: string;
repoName: string;
project: string;
}
export function parseAzureUrl(
repoUrl: string,
integration: AzureIntegration,
): AzureRepoParts {
const { organization, owner, name } = parseGitUrl(repoUrl);
const tenantUrl = `https://${integration.config.host}/${organization}`;
return { tenantUrl, repoName: name, project: owner };
}
export async function submitAzurePrToRepo(
integration: AzureIntegration,
options: {
title: string;
body: string;
fileContent: string;
repositoryUrl: string;
},
scmAuthApi: ScmAuthApi,
configApi: ConfigApi,
) {
const { repositoryUrl, fileContent, title, body } = options;
const branchName = getBranchName(configApi);
const fileName = getCatalogFilename(configApi);
const { token } = await scmAuthApi.getCredentials({
url: repositoryUrl,
additionalScope: {
repoWrite: true,
},
});
const { tenantUrl, repoName, project } = parseAzureUrl(
repositoryUrl,
integration,
);
const result = await createAzurePullRequest({
token,
fileContent,
title,
description: body,
project,
repository: repoName,
branchName,
tenantUrl,
fileName,
});
const catalogLocation = `${result.repository.webUrl}?path=/${fileName}`;
const prLocation = `${result.repository.webUrl}/pullrequest/${result.pullRequestId}`;
return {
link: prLocation!,
location: catalogLocation,
};
}
@@ -0,0 +1,356 @@
/*
* 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 { rest } from 'msw';
import {
AzurePrOptions,
AzurePrResult,
AzureRef,
AzureRefUpdate,
AzureRepo,
createAzurePullRequest,
CreatePrOptions,
NewBranchOptions,
RepoApiClient,
} from './AzureRepoApiClient';
import { setupServer } from 'msw/node';
import { setupRequestMockHandlers } from '@backstage/test-utils';
function mockRepoEndpoint() {
return rest.get(
'https://dev.azure.com/acme/project/_apis/git/repositories/:path',
(req, res, ctx) => {
const { path } = req.params;
if (path === 'success') {
return res(
ctx.json({
id: '01',
name: 'success',
defaultBranch: 'refs/heads/master',
}),
);
}
if (path === 'not-found') {
return res(
ctx.status(404),
ctx.json({
message: 'repository not found',
}),
);
}
return res(ctx.status(200));
},
);
}
function mockRefsEndpoint() {
return rest.get(
'https://dev.azure.com/acme/project/_apis/git/repositories/:repo/refs',
(req, res, ctx) => {
const filter = req.url.searchParams.get('filter');
const { repo } = req.params;
if (repo !== 'success') {
return res(
ctx.status(404),
ctx.json({
message: 'repository not found',
}),
);
}
if (filter === 'heads/main') {
const result = {
value: [
{
name: 'refs/heads/main',
objectId: '0000000000000000000000000000000000000000',
},
],
};
return res(ctx.json(result));
}
return res(ctx.json({ value: [] }));
},
);
}
function mockPushEndpoint() {
return rest.post(
'https://dev.azure.com/acme/project/_apis/git/repositories/:repo/pushes',
(req, res, ctx) => {
const { repo } = req.params;
if (repo === 'success') {
return res(
ctx.json({
refUpdates: [
{
repositoryId: '01',
name: 'refs/heads/backstage-integration',
oldObjectId: '0000000000000000000000000000000000000000',
newObjectId: '0000000000000000000000000000000000000001',
},
],
} satisfies { refUpdates: AzureRefUpdate[] }),
);
}
if (repo === 'error') {
return res(
ctx.status(500),
ctx.json({
message: 'internal error',
}),
);
}
return res(
ctx.status(500),
ctx.json({
message: 'Unexpected call',
}),
);
},
);
}
function mockPrEndpoint() {
return rest.post(
'https://dev.azure.com/acme/project/_apis/git/repositories/:repo/pullrequests',
(req, res, ctx) => {
const { repo } = req.params;
if (repo === 'success') {
return res(
ctx.json({
pullRequestId: 'PR01',
repository: {
name: 'success',
webUrl: 'https://example.com',
},
} satisfies AzurePrResult),
);
}
return res(
ctx.status(500),
ctx.json({
message: 'internal error',
}),
);
},
);
}
describe('RepoApiClient', () => {
const server = setupServer();
setupRequestMockHandlers(server);
const sut = new RepoApiClient({
token: 'token',
project: 'project',
tenantUrl: 'https://dev.azure.com/acme',
});
beforeEach(() => {
server.use(
mockPrEndpoint(),
mockRefsEndpoint(),
mockPushEndpoint(),
mockRepoEndpoint(),
);
});
describe('getRepository', () => {
it('should get an existing repository', async () => {
await expect(sut.getRepository('success')).resolves.toEqual({
id: '01',
name: 'success',
defaultBranch: 'refs/heads/master',
});
});
it('should throw when the repository repository does not exist', async () => {
await expect(sut.getRepository('not-found')).rejects.toThrow(
new Error('repository not found'),
);
});
});
describe('getDefaultBranch', () => {
it('should return when correct branch', async () => {
const foundRef = await sut.getDefaultBranch({
name: 'success',
defaultBranch: 'refs/heads/main',
id: '01',
});
expect(foundRef).toEqual({
name: 'refs/heads/main',
objectId: '0000000000000000000000000000000000000000',
});
});
it('should throw when the repository does not exist', async () => {
const promise = sut.getDefaultBranch({
name: 'fail',
defaultBranch: 'refs/heads/main',
id: '01',
});
await expect(promise).rejects.toThrow(new Error('repository not found'));
});
it('should throw when the default branch does not exist', async () => {
const promise = sut.getDefaultBranch({
name: 'success',
defaultBranch: 'refs/heads/missing_branch',
id: '01',
});
await expect(promise).rejects.toThrow(
new Error(`The requested ref 'heads/missing_branch' was not found`),
);
});
});
describe('pushNewBranch', () => {
let sourceBranch: AzureRef;
let options: NewBranchOptions;
let expectedResult: AzureRefUpdate;
beforeEach(() => {
sourceBranch = {
name: 'refs/heads/main',
objectId: '0000000000000000000000000000000000000000',
};
options = {
title: 'title',
branchName: 'backstage-integration',
fileName: 'catalog-info.yaml',
fileContent: 'This is a test',
};
expectedResult = {
repositoryId: '01',
name: 'refs/heads/backstage-integration',
oldObjectId: '0000000000000000000000000000000000000000',
newObjectId: '0000000000000000000000000000000000000001',
};
});
it('should create a new branch', async () => {
await expect(
sut.pushNewBranch('success', sourceBranch, options),
).resolves.toEqual(expectedResult);
});
it('should throw when api call fails', async () => {
await expect(
sut.pushNewBranch('error', sourceBranch, options),
).rejects.toThrow(new Error('internal error'));
});
});
describe('createPullRequest', () => {
const sourceBranchName = 'refs/heads/main';
const targetBranchName = 'refs/heads/backstage-integration';
const options: CreatePrOptions = {
title: 'Title',
description: 'Description',
};
it('should create a new Pull request', async () => {
await expect(
sut.createPullRequest(
'success',
sourceBranchName,
targetBranchName,
options,
),
).resolves.toEqual({
pullRequestId: 'PR01',
repository: {
name: 'success',
webUrl: 'https://example.com',
},
} satisfies AzurePrResult);
});
it('should throw when api call fails', async () => {
await expect(
sut.createPullRequest(
'error',
sourceBranchName,
targetBranchName,
options,
),
).rejects.toThrow(new Error('internal error'));
});
});
});
describe('createAzurePullRequest', () => {
let client: RepoApiClient;
let clientMock: Record<keyof RepoApiClient, jest.Mock>;
beforeEach(() => {
clientMock = {
createPullRequest: jest.fn(),
pushNewBranch: jest.fn(),
getRepository: jest.fn(),
getDefaultBranch: jest.fn(),
};
client = clientMock as any as RepoApiClient;
});
it('should create a new Pull request', async () => {
const options: AzurePrOptions = {
tenantUrl: 'https://dev.azure.com/acme',
repository: 'test',
project: 'project',
fileName: 'catalog-info.yaml',
title: 'Test Title',
fileContent: 'content',
branchName: 'backstage-integration',
description: 'Test Description',
token: 'token',
};
const repo: AzureRepo = {
name: options.repository,
defaultBranch: 'ref/heads/main',
id: '01',
};
const defaultBranch: AzureRef = {
name: 'ref/heads/main',
objectId: '000000000000000000000000000000000000000',
};
const expectedResult: AzurePrResult = {
pullRequestId: 'PR01',
repository: {
name: options.repository,
webUrl: 'https://dev.azure.com/acme/project',
},
};
clientMock.getRepository.mockResolvedValue(repo);
clientMock.getDefaultBranch.mockResolvedValue(defaultBranch);
clientMock.pushNewBranch.mockResolvedValue({
name: options.branchName,
oldObjectId: '000000000000000000000000000000000000000',
newObjectId: '000000000000000000000000000000000000001',
repositoryId: '01',
} satisfies AzureRefUpdate);
clientMock.createPullRequest.mockResolvedValue(expectedResult);
await expect(createAzurePullRequest(options, client)).resolves.toEqual(
expectedResult,
);
expect(clientMock.getRepository).toHaveBeenCalledWith(options.repository);
expect(clientMock.getDefaultBranch).toHaveBeenCalledWith(repo);
expect(clientMock.pushNewBranch).toHaveBeenCalledWith(
repo.name,
defaultBranch,
options,
);
expect(clientMock.createPullRequest).toHaveBeenCalledWith(
repo.name,
options.branchName,
defaultBranch.name,
options,
);
});
});
@@ -0,0 +1,269 @@
/*
* 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.
*/
export interface AzurePrOptions {
tenantUrl: string;
title: string;
project: string;
branchName: string;
repository: string;
token: string;
fileContent: string;
fileName: string;
description: string;
}
export interface CreateAzurePr {
sourceRefName: string;
targetRefName: string;
title: string;
description: string;
}
export interface AzureRepo {
id: string;
name: string;
defaultBranch: string;
}
export interface AzureRef {
name: string;
objectId: string;
}
interface RefQueryResult {
value: AzureRef[];
}
export interface AzureCommit {
comment: string;
changes: {
changeType: string;
item: {
path: string;
};
newContent: {
content: string;
contentType: 'rawtext';
};
}[];
}
export interface AzureRefUpdate {
repositoryId: string;
name: string;
oldObjectId: string;
newObjectId: string;
}
export interface AzurePushResult {
refUpdates: AzureRefUpdate[];
}
export interface AzurePush {
refUpdates: {
name: string;
oldObjectId: string;
}[];
commits: AzureCommit[];
}
export interface AzurePrResult {
pullRequestId: string;
repository: {
name: string;
webUrl: string;
};
}
const apiVersions = {
V7_2p_1: '7.2-preview.1',
V7_2p_2: '7.2-preview.2',
};
export interface RepoApiClientOptions {
project: string;
tenantUrl: string;
token: string;
}
export interface NewBranchOptions {
fileContent: string;
fileName: string;
title: string;
branchName: string;
}
export interface CreatePrOptions {
description: string;
title: string;
}
export class RepoApiClient {
private createEndpoint = (
path: string,
version: string,
queryParams: Record<string, string> | undefined = undefined,
) => {
const url = new URL(
`${this._options.tenantUrl}/${this._options.project}/_apis/git/repositories`,
);
url.pathname += path;
url.searchParams.set('api-version', version);
Object.entries(queryParams ?? {}).forEach(([key, value]) =>
url.searchParams.set(key, value),
);
return url.toString();
};
constructor(private _options: RepoApiClientOptions) {}
private async get<T>(
path: string,
version: string,
queryParams: Record<string, string> | undefined = undefined,
): Promise<T> {
const endpoint = this.createEndpoint(path, version, queryParams);
const result = await fetch(endpoint, {
headers: {
Authorization: `Bearer ${this._options.token}`,
},
});
if (!result.ok) {
return result.json().then(it => Promise.reject(new Error(it.message)));
}
return await result.json();
}
private async post<T>(
path: string,
version: string,
payload: unknown,
): Promise<T> {
const endpoint = this.createEndpoint(path, version);
const result = await fetch(endpoint, {
method: 'POST',
headers: {
Authorization: `Bearer ${this._options.token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
if (!result.ok) {
return result.json().then(it => Promise.reject(new Error(it.message)));
}
return await result.json();
}
async getRepository(repositoryName: string): Promise<AzureRepo> {
return this.get(`/${repositoryName}`, apiVersions.V7_2p_1);
}
async getDefaultBranch(repo: AzureRepo): Promise<AzureRef> {
const filter = repo.defaultBranch.replace('refs/', '');
const result: RefQueryResult = await this.get(
`/${repo.name}/refs`,
apiVersions.V7_2p_2,
{ filter },
);
if (!result.value?.length) {
return Promise.reject(
new Error(`The requested ref '${filter}' was not found`),
);
}
return result.value[0];
}
async pushNewBranch(
repoName: string,
sourceBranch: AzureRef,
options: NewBranchOptions,
): Promise<AzureRefUpdate> {
const push: AzurePush = {
refUpdates: [
{
name: `refs/heads/${options.branchName}`,
oldObjectId: sourceBranch.objectId,
},
],
commits: [
{
comment: options.title,
changes: [
{
changeType: 'add',
item: {
path: `/${options.fileName}`,
},
newContent: {
content: options.fileContent,
contentType: 'rawtext',
},
},
],
},
],
};
const result = await this.post<AzurePushResult>(
`/${repoName}/pushes`,
apiVersions.V7_2p_2,
push,
);
return result.refUpdates[0];
}
async createPullRequest(
repoName: string,
sourceName: string,
targetName: string,
options: CreatePrOptions,
): Promise<AzurePrResult> {
const payload: CreateAzurePr = {
title: options.title,
description: options.description,
sourceRefName: sourceName,
targetRefName: targetName,
};
return await this.post<AzurePrResult>(
`/${repoName}/pullrequests`,
apiVersions.V7_2p_2,
payload,
);
}
}
export async function createAzurePullRequest(
options: AzurePrOptions,
client: RepoApiClient | undefined = undefined,
): Promise<AzurePrResult> {
const actualClient = client ?? new RepoApiClient(options);
const repo = await actualClient.getRepository(options.repository);
const defaultBranch = await actualClient.getDefaultBranch(repo);
const refUpdate = await actualClient.pushNewBranch(
repo.name,
defaultBranch,
options,
);
return actualClient.createPullRequest(
repo.name,
refUpdate.name,
defaultBranch.name,
options,
);
}
@@ -46,6 +46,12 @@ jest.mock('@octokit/rest', () => {
return { Octokit };
});
jest.mock('./AzureRepoApiClient', () => {
return {
createAzurePullRequest: jest.fn(),
};
});
import { ConfigReader, UrlPatternDiscovery } from '@backstage/core-app-api';
import { ScmIntegrations } from '@backstage/integration';
import { ScmAuthApi } from '@backstage/integration-react';
@@ -55,6 +61,11 @@ import { Octokit } from '@octokit/rest';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { CatalogImportClient } from './CatalogImportClient';
import {
AzurePrOptions,
AzurePrResult,
createAzurePullRequest,
} from './AzureRepoApiClient';
describe('CatalogImportClient', () => {
const server = setupServer();
@@ -269,7 +280,7 @@ describe('CatalogImportClient', () => {
});
});
it('should reject for integrations that are not github ones', async () => {
it('should reject for integrations that are not github or azure', async () => {
await expect(
catalogImportClient.analyzeUrl(
'https://registered-but-not-github.com/backstage/backstage',
@@ -619,6 +630,61 @@ describe('CatalogImportClient', () => {
base: 'main',
});
});
it('should create AzureDevops pull request', async () => {
catalogApi.validateEntity.mockResolvedValueOnce({
valid: true,
});
const azureMock = createAzurePullRequest as jest.Mock;
azureMock.mockResolvedValueOnce({
repository: {
name: 'backstage',
webUrl: 'https://dev.azure.com/spotify/backstage/_git/backstage',
},
pullRequestId: '01',
} satisfies AzurePrResult);
const expectedPrOptions: AzurePrOptions = {
title: 'A title/message',
description: 'A body',
repository: 'backstage',
fileName: 'catalog-info.yaml',
project: 'backstage',
tenantUrl: 'https://dev.azure.com/spotify',
branchName: 'backstage-integration',
token: 'token',
fileContent: `
{
"apiVersion": "backstage.io/v1alpha1",
"kind": "Component",
"metadata": {
"name": "valid-name",
"annotations": {
"github.com/project-slug": "backstage/example-repo"
}
},
"spec": {
"type": "other",
"lifecycle": "unknown",
"owner": "backstage"
}
}
`,
};
await expect(
catalogImportClient.submitPullRequest({
repositoryUrl:
'https://dev.azure.com/spotify/backstage/_git/backstage',
fileContent: expectedPrOptions.fileContent,
title: expectedPrOptions.title,
body: expectedPrOptions.description,
}),
).resolves.toEqual({
link: 'https://dev.azure.com/spotify/backstage/_git/backstage/pullrequest/01',
location:
'https://dev.azure.com/spotify/backstage/_git/backstage?path=/catalog-info.yaml',
});
expect(azureMock).toHaveBeenCalledWith(expectedPrOptions);
});
it('Submit Pull Request with invalid component name', async () => {
const ErrorMessage =
'Policy check failed for component:default/invalid name; caused by Error: "metadata.name" is not valid; expected a string that is sequences of [a-zA-Z0-9] separated by any of [-_.], at most 63 characters in total but found "invalid name". To learn more about catalog file format, visit: https://github.com/backstage/backstage/blob/master/docs/architecture-decisions/adr002-default-catalog-file-format.md';
@@ -21,18 +21,19 @@ import {
IdentityApi,
} from '@backstage/core-plugin-api';
import {
GithubIntegrationConfig,
AzureIntegration,
GithubIntegration,
ScmIntegrationRegistry,
} from '@backstage/integration';
import { ScmAuthApi } from '@backstage/integration-react';
import { Octokit } from '@octokit/rest';
import { Base64 } from 'js-base64';
import { AnalyzeResult, CatalogImportApi } from './CatalogImportApi';
import YAML from 'yaml';
import { getGithubIntegrationConfig } from './GitHub';
import { getBranchName, getCatalogFilename } from '../components/helpers';
import { GitHubOptions, submitGitHubPrToRepo } from './GitHub';
import { getCatalogFilename } from '../components/helpers';
import { AnalyzeLocationResponse } from '@backstage/plugin-catalog-common';
import { CompoundEntityRef } from '@backstage/catalog-model';
import parseGitUrl from 'git-url-parse';
import { submitAzurePrToRepo } from './AzureDevops';
/**
* The default implementation of the {@link CatalogImportApi}.
@@ -89,15 +90,17 @@ export class CatalogImportClient implements CatalogImportApi {
],
};
}
const ghConfig = getGithubIntegrationConfig(this.scmIntegrationsApi, url);
if (!ghConfig) {
const other = this.scmIntegrationsApi.byUrl(url);
const supportedIntegrations = ['github', 'azure'];
const foundIntegration = this.scmIntegrationsApi.byUrl(url);
const iSupported = supportedIntegrations.find(
it => it === foundIntegration?.type,
);
if (!iSupported) {
const catalogFilename = getCatalogFilename(this.configApi);
if (other) {
if (foundIntegration) {
throw new Error(
`The ${other.title} integration only supports full URLs to ${catalogFilename} files. Did you try to pass in the URL of a directory instead?`,
`The ${foundIntegration.title} integration only supports full URLs to ${catalogFilename} files. Did you try to pass in the URL of a directory instead?`,
);
}
throw new Error(
@@ -144,7 +147,7 @@ export class CatalogImportClient implements CatalogImportApi {
return {
type: 'repository',
integrationType: 'github',
integrationType: foundIntegration!.type,
url: url,
generatedEntities: analyzation.generateEntities.map(x => x.entity),
};
@@ -185,21 +188,41 @@ the component will become available.\n\nFor more information, read an \
if (!validationResponse.valid) {
throw new Error(validationResponse.errors[0].message);
}
const ghConfig = getGithubIntegrationConfig(
this.scmIntegrationsApi,
repositoryUrl,
);
if (ghConfig) {
return await this.submitGitHubPrToRepo({
...ghConfig,
repositoryUrl,
fileContent,
title,
body,
});
const provider = this.scmIntegrationsApi.byUrl(repositoryUrl);
switch (provider?.type) {
case 'github': {
const { config } = provider as GithubIntegration;
const { name, owner } = parseGitUrl(repositoryUrl);
const options2: GitHubOptions = {
githubIntegrationConfig: config,
repo: name,
owner: owner,
repositoryUrl,
fileContent,
title,
body,
};
return submitGitHubPrToRepo(options2, this.scmAuthApi, this.configApi);
}
case 'azure': {
return submitAzurePrToRepo(
provider as AzureIntegration,
{
repositoryUrl,
fileContent,
title,
body,
},
this.scmAuthApi,
this.configApi,
);
}
default: {
throw new Error('unimplemented!');
}
}
throw new Error('unimplemented!');
}
// TODO: this could be part of the catalog api
@@ -238,125 +261,4 @@ the component will become available.\n\nFor more information, read an \
const payload = await response.json();
return payload;
}
// TODO: extract this function and implement for non-github
private async submitGitHubPrToRepo(options: {
owner: string;
repo: string;
title: string;
body: string;
fileContent: string;
repositoryUrl: string;
githubIntegrationConfig: GithubIntegrationConfig;
}): Promise<{ link: string; location: string }> {
const {
owner,
repo,
title,
body,
fileContent,
repositoryUrl,
githubIntegrationConfig,
} = options;
const { token } = await this.scmAuthApi.getCredentials({
url: repositoryUrl,
additionalScope: {
repoWrite: true,
},
});
const octo = new Octokit({
auth: token,
baseUrl: githubIntegrationConfig.apiBaseUrl,
});
const branchName = getBranchName(this.configApi);
const fileName = getCatalogFilename(this.configApi);
const repoData = await octo.repos
.get({
owner,
repo,
})
.catch(e => {
throw new Error(formatHttpErrorMessage("Couldn't fetch repo data", e));
});
const parentRef = await octo.git
.getRef({
owner,
repo,
ref: `heads/${repoData.data.default_branch}`,
})
.catch(e => {
throw new Error(
formatHttpErrorMessage("Couldn't fetch default branch data", e),
);
});
await octo.git
.createRef({
owner,
repo,
ref: `refs/heads/${branchName}`,
sha: parentRef.data.object.sha,
})
.catch(e => {
throw new Error(
formatHttpErrorMessage(
`Couldn't create a new branch with name '${branchName}'`,
e,
),
);
});
await octo.repos
.createOrUpdateFileContents({
owner,
repo,
path: fileName,
message: title,
content: Base64.encode(fileContent),
branch: branchName,
})
.catch(e => {
throw new Error(
formatHttpErrorMessage(
`Couldn't create a commit with ${fileName} file added`,
e,
),
);
});
const pullRequestResponse = await octo.pulls
.create({
owner,
repo,
title,
head: branchName,
body,
base: repoData.data.default_branch,
})
.catch(e => {
throw new Error(
formatHttpErrorMessage(
`Couldn't create a pull request for ${branchName} branch`,
e,
),
);
});
return {
link: pullRequestResponse.data.html_url,
location: `https://${githubIntegrationConfig.host}/${owner}/${repo}/blob/${repoData.data.default_branch}/${fileName}`,
};
}
}
function formatHttpErrorMessage(
message: string,
error: { status: number; message: string },
) {
return `${message}, received http response status code ${error.status}: ${error.message}`;
}
+134 -1
View File
@@ -14,9 +14,26 @@
* limitations under the License.
*/
import { ScmIntegrationRegistry } from '@backstage/integration';
import {
GithubIntegrationConfig,
ScmIntegrationRegistry,
} from '@backstage/integration';
import parseGitUrl from 'git-url-parse';
import { ScmAuthApi } from '@backstage/integration-react';
import { Octokit } from '@octokit/rest';
import { getBranchName, getCatalogFilename } from '../components/helpers';
import { ConfigApi } from '@backstage/core-plugin-api';
import { Base64 } from 'js-base64';
export interface GitHubOptions {
owner: string;
repo: string;
title: string;
body: string;
fileContent: string;
repositoryUrl: string;
githubIntegrationConfig: GithubIntegrationConfig;
}
export const getGithubIntegrationConfig = (
scmIntegrationsApi: ScmIntegrationRegistry,
location: string,
@@ -33,3 +50,119 @@ export const getGithubIntegrationConfig = (
githubIntegrationConfig: integration.config,
};
};
export async function submitGitHubPrToRepo(
options: GitHubOptions,
scmAuthApi: ScmAuthApi,
configApi: ConfigApi,
): Promise<{ link: string; location: string }> {
const {
owner,
repo,
title,
body,
fileContent,
repositoryUrl,
githubIntegrationConfig,
} = options;
const { token } = await scmAuthApi.getCredentials({
url: repositoryUrl,
additionalScope: {
repoWrite: true,
},
});
const octo = new Octokit({
auth: token,
baseUrl: githubIntegrationConfig.apiBaseUrl,
});
const branchName = getBranchName(configApi);
const fileName = getCatalogFilename(configApi);
const repoData = await octo.repos
.get({
owner,
repo,
})
.catch(e => {
throw new Error(formatHttpErrorMessage("Couldn't fetch repo data", e));
});
const parentRef = await octo.git
.getRef({
owner,
repo,
ref: `heads/${repoData.data.default_branch}`,
})
.catch(e => {
throw new Error(
formatHttpErrorMessage("Couldn't fetch default branch data", e),
);
});
await octo.git
.createRef({
owner,
repo,
ref: `refs/heads/${branchName}`,
sha: parentRef.data.object.sha,
})
.catch(e => {
throw new Error(
formatHttpErrorMessage(
`Couldn't create a new branch with name '${branchName}'`,
e,
),
);
});
await octo.repos
.createOrUpdateFileContents({
owner,
repo,
path: fileName,
message: title,
content: Base64.encode(fileContent),
branch: branchName,
})
.catch(e => {
throw new Error(
formatHttpErrorMessage(
`Couldn't create a commit with ${fileName} file added`,
e,
),
);
});
const pullRequestResponse = await octo.pulls
.create({
owner,
repo,
title,
head: branchName,
body,
base: repoData.data.default_branch,
})
.catch(e => {
throw new Error(
formatHttpErrorMessage(
`Couldn't create a pull request for ${branchName} branch`,
e,
),
);
});
return {
link: pullRequestResponse.data.html_url,
location: `https://${githubIntegrationConfig.host}/${owner}/${repo}/blob/${repoData.data.default_branch}/${fileName}`,
};
}
function formatHttpErrorMessage(
message: string,
error: { status: number; message: string },
) {
return `${message}, received http response status code ${error.status}: ${error.message}`;
}
@@ -29,7 +29,8 @@ import { useCatalogFilename } from '../../hooks';
*/
export interface ImportInfoCardProps {
exampleLocationUrl?: string;
exampleRepositoryUrl?: string;
exampleGitRepositoryUrl?: string;
exampleAzureRepositoryUrl?: string;
}
/**
@@ -40,7 +41,8 @@ export interface ImportInfoCardProps {
export const ImportInfoCard = (props: ImportInfoCardProps) => {
const {
exampleLocationUrl = 'https://github.com/backstage/backstage/blob/master/catalog-info.yaml',
exampleRepositoryUrl = 'https://github.com/backstage/backstage',
exampleGitRepositoryUrl = 'https://github.com/backstage/backstage',
exampleAzureRepositoryUrl = 'https://dev.azure.com/spotify/backstage/_git/backstage',
} = props;
const configApi = useApi(configApiRef);
@@ -77,10 +79,18 @@ export const ImportInfoCard = (props: ImportInfoCardProps) => {
<>
<Typography component="h4" variant="h6">
Link to a repository{' '}
<Chip label="GitHub only" variant="outlined" size="small" />
<Chip
label="GitHub and AzureDevops"
variant="outlined"
size="small"
/>
</Typography>
<Typography variant="subtitle2" color="textSecondary" paragraph>
GitHub Example: <code>{exampleGitRepositoryUrl}</code>
</Typography>
<Typography variant="subtitle2" color="textSecondary" paragraph>
Example: <code>{exampleRepositoryUrl}</code>
Azure Example: <code>{exampleAzureRepositoryUrl}</code>
</Typography>
<Typography variant="body2" paragraph>
The wizard discovers all <code>{catalogFilename}</code> files in the