From b7c05854715be73891bd7156f27ea0be5321741f Mon Sep 17 00:00:00 2001 From: Andre Wanlin Date: Sat, 9 Oct 2021 13:14:26 -0500 Subject: [PATCH] Added pull requests to backend Signed-off-by: Andre Wanlin --- .changeset/stale-seahorses-sell.md | 5 ++ plugins/azure-devops-backend/api-report.md | 24 ++++++ .../src/api/AzureDevOpsApi.test.ts | 74 ++++++++++++++++--- .../src/api/AzureDevOpsApi.ts | 62 +++++++++++++++- plugins/azure-devops-backend/src/api/index.ts | 2 +- plugins/azure-devops-backend/src/api/types.ts | 14 ++++ plugins/azure-devops-backend/src/index.ts | 2 +- .../src/service/router.test.ts | 72 +++++++++++++++++- .../src/service/router.ts | 16 ++++ 9 files changed, 252 insertions(+), 19 deletions(-) create mode 100644 .changeset/stale-seahorses-sell.md diff --git a/.changeset/stale-seahorses-sell.md b/.changeset/stale-seahorses-sell.md new file mode 100644 index 0000000000..2fa52b109f --- /dev/null +++ b/.changeset/stale-seahorses-sell.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-azure-devops-backend': patch +--- + +Expands the Azure DevOps backend plugin to provide pull request data to be used by the front end plugin diff --git a/plugins/azure-devops-backend/api-report.md b/plugins/azure-devops-backend/api-report.md index 4ad2f833d7..511127f1e8 100644 --- a/plugins/azure-devops-backend/api-report.md +++ b/plugins/azure-devops-backend/api-report.md @@ -10,6 +10,7 @@ import { Config } from '@backstage/config'; import express from 'express'; import { GitRepository } from 'azure-devops-node-api/interfaces/GitInterfaces'; import { Logger as Logger_2 } from 'winston'; +import { PullRequestStatus } from 'azure-devops-node-api/interfaces/GitInterfaces'; import { WebApi } from 'azure-devops-node-api'; // Warning: (ae-missing-release-tag) "AzureDevOpsApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -29,6 +30,13 @@ export class AzureDevOpsApi { repoName: string, ): Promise; // (undocumented) + getPullRequests( + projectName: string, + repoName: string, + top: number, + status: PullRequestStatus, + ): Promise; + // (undocumented) getRepoBuilds( projectName: string, repoName: string, @@ -41,6 +49,22 @@ export class AzureDevOpsApi { // @public (undocumented) export function createRouter(options: RouterOptions): Promise; +// Warning: (ae-missing-release-tag) "PullRequest" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type PullRequest = { + pullRequestId?: number; + repoName?: string; + title?: string; + createdBy?: string; + creationDate?: Date; + sourceRefName?: string; + targetRefName?: string; + status?: PullRequestStatus; + isDraft?: boolean; + link: string; +}; + // Warning: (ae-missing-release-tag) "RepoBuild" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) diff --git a/plugins/azure-devops-backend/src/api/AzureDevOpsApi.test.ts b/plugins/azure-devops-backend/src/api/AzureDevOpsApi.test.ts index 25022b4c02..3a3662ec56 100644 --- a/plugins/azure-devops-backend/src/api/AzureDevOpsApi.test.ts +++ b/plugins/azure-devops-backend/src/api/AzureDevOpsApi.test.ts @@ -13,17 +13,23 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { repoBuildFromBuild } from './AzureDevOpsApi'; -import { RepoBuild } from './types'; +import { mappedPullRequest, mappedRepoBuild } from './AzureDevOpsApi'; +import { PullRequest, RepoBuild } from './types'; import { Build, BuildResult, BuildStatus, DefinitionReference, } from 'azure-devops-node-api/interfaces/BuildInterfaces'; +import { + GitPullRequest, + PullRequestStatus, +} from 'azure-devops-node-api/interfaces/GitInterfaces'; +import { GitRepository } from 'azure-devops-node-api/interfaces/TfvcInterfaces'; +import { IdentityRef } from 'azure-devops-node-api/interfaces/common/VSSInterfaces'; describe('AzureDevOpsApi', () => { - describe('repoBuildFromBuild', () => { + describe('mappedRepoBuild', () => { it('should return RepoBuild from Build', () => { const inputBuildDefinition: DefinitionReference = { name: 'My Build Definition', @@ -57,11 +63,11 @@ describe('AzureDevOpsApi', () => { source: 'refs/heads/develop (f4f78b31)', }; - expect(repoBuildFromBuild(inputBuild)).toEqual(outputRepoBuild); + expect(mappedRepoBuild(inputBuild)).toEqual(outputRepoBuild); }); }); - describe('repoBuildFromBuild with no Build definition name', () => { + describe('mappedRepoBuild with no Build definition name', () => { it('should return RepoBuild with only Build Number for title', () => { const inputLinks: any = { web: { @@ -91,11 +97,11 @@ describe('AzureDevOpsApi', () => { source: 'refs/heads/develop (f4f78b31)', }; - expect(repoBuildFromBuild(inputBuild)).toEqual(outputRepoBuild); + expect(mappedRepoBuild(inputBuild)).toEqual(outputRepoBuild); }); }); - describe('repoBuildFromBuild with undefined status', () => { + describe('mappedRepoBuild with undefined status', () => { it('should return BuildStatus of None for status', () => { const inputLinks: any = { web: { @@ -125,11 +131,11 @@ describe('AzureDevOpsApi', () => { source: 'refs/heads/develop (f4f78b31)', }; - expect(repoBuildFromBuild(inputBuild)).toEqual(outputRepoBuild); + expect(mappedRepoBuild(inputBuild)).toEqual(outputRepoBuild); }); }); - describe('repoBuildFromBuild with undefined result', () => { + describe('mappedRepoBuild with undefined result', () => { it('should return BuildResult of None for result', () => { const inputLinks: any = { web: { @@ -159,11 +165,11 @@ describe('AzureDevOpsApi', () => { source: 'refs/heads/develop (f4f78b31)', }; - expect(repoBuildFromBuild(inputBuild)).toEqual(outputRepoBuild); + expect(mappedRepoBuild(inputBuild)).toEqual(outputRepoBuild); }); }); - describe('repoBuildFromBuild with undefined link', () => { + describe('mappedRepoBuild with undefined link', () => { it('should return empty string for link', () => { const inputBuild: Build = { id: 1, @@ -187,7 +193,51 @@ describe('AzureDevOpsApi', () => { source: 'refs/heads/develop (f4f78b31)', }; - expect(repoBuildFromBuild(inputBuild)).toEqual(outputRepoBuild); + expect(mappedRepoBuild(inputBuild)).toEqual(outputRepoBuild); + }); + }); + + describe('mappedPullRequest', () => { + it('should return PullRequest from GitPullRequest', () => { + const inputGitRepository: GitRepository = { + name: 'super-feature-repo', + }; + + const inputIdentityRef: IdentityRef = { + displayName: 'Jane Doe', + }; + + const inputPullRequest: GitPullRequest = { + pullRequestId: 7181, + repository: inputGitRepository, + title: 'My Awesome New Feature', + createdBy: inputIdentityRef, + creationDate: new Date('2020-09-12T06:10:23.9325232Z'), + sourceRefName: 'refs/heads/topic/super-awesome-feature', + targetRefName: 'refs/heads/main', + status: PullRequestStatus.Active, + isDraft: false, + }; + + const inputBaseUrl = + 'https://host.com/myOrg/_git/super-feature-repo/pullrequest'; + + const outputPullRequest: PullRequest = { + pullRequestId: 7181, + repoName: 'super-feature-repo', + title: 'My Awesome New Feature', + createdBy: 'Jane Doe', + creationDate: new Date('2020-09-12T06:10:23.9325232Z'), + sourceRefName: 'refs/heads/topic/super-awesome-feature', + targetRefName: 'refs/heads/main', + status: PullRequestStatus.Active, + isDraft: false, + link: 'https://host.com/myOrg/_git/super-feature-repo/pullrequest/7181', + }; + + expect(mappedPullRequest(inputPullRequest, inputBaseUrl)).toEqual( + outputPullRequest, + ); }); }); }); diff --git a/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts b/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts index 3da4dff11d..3d4320da6a 100644 --- a/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts +++ b/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts @@ -16,12 +16,17 @@ import { Logger } from 'winston'; import { WebApi } from 'azure-devops-node-api'; -import { RepoBuild } from './types'; import { Build, BuildResult, BuildStatus, } from 'azure-devops-node-api/interfaces/BuildInterfaces'; +import { + GitPullRequest, + GitPullRequestSearchCriteria, + PullRequestStatus, +} from 'azure-devops-node-api/interfaces/GitInterfaces'; +import { PullRequest, RepoBuild } from './types'; export class AzureDevOpsApi { constructor( @@ -88,14 +93,47 @@ export class AzureDevOpsApi { ); const repoBuilds: RepoBuild[] = buildList.map(build => { - return repoBuildFromBuild(build); + return mappedRepoBuild(build); }); return repoBuilds; } + + async getPullRequests( + projectName: string, + repoName: string, + top: number, + status: PullRequestStatus, + ) { + if (this.logger) { + this.logger.debug( + `Calling Azure DevOps REST API, getting up to ${top} Pull Requests for Repository ${repoName} for Project ${projectName}`, + ); + } + + const gitRepository = await this.getGitRepository(projectName, repoName); + const client = await this.webApi.getGitApi(); + const searchCriteria: GitPullRequestSearchCriteria = { + status: status, + }; + const gitPullRequests = await client.getPullRequests( + gitRepository.id as string, + searchCriteria, + projectName, + undefined, + undefined, + top, + ); + const linkBaseUrl = `${this.webApi.serverUrl}/${projectName}/_git/${repoName}/pullrequest`; + const pullRequests: PullRequest[] = gitPullRequests.map(gitPullRequest => { + return mappedPullRequest(gitPullRequest, linkBaseUrl); + }); + + return pullRequests; + } } -export function repoBuildFromBuild(build: Build) { +export function mappedRepoBuild(build: Build) { return { id: build.id, title: [build.definition?.name, build.buildNumber] @@ -108,3 +146,21 @@ export function repoBuildFromBuild(build: Build) { source: `${build.sourceBranch} (${build.sourceVersion?.substr(0, 8)})`, }; } + +export function mappedPullRequest( + pullRequest: GitPullRequest, + linkBaseUrl: string, +) { + return { + pullRequestId: pullRequest.pullRequestId, + repoName: pullRequest.repository?.name, + title: pullRequest.title, + createdBy: pullRequest.createdBy?.displayName, + creationDate: pullRequest.creationDate, + sourceRefName: pullRequest.sourceRefName, + targetRefName: pullRequest.targetRefName, + status: pullRequest.status, + isDraft: pullRequest.isDraft, + link: `${linkBaseUrl}/${pullRequest.pullRequestId}`, + }; +} diff --git a/plugins/azure-devops-backend/src/api/index.ts b/plugins/azure-devops-backend/src/api/index.ts index 8faf37fe4e..893f0f4f2c 100644 --- a/plugins/azure-devops-backend/src/api/index.ts +++ b/plugins/azure-devops-backend/src/api/index.ts @@ -15,4 +15,4 @@ */ export { AzureDevOpsApi } from './AzureDevOpsApi'; -export type { RepoBuild } from './types'; +export type { RepoBuild, PullRequest } from './types'; diff --git a/plugins/azure-devops-backend/src/api/types.ts b/plugins/azure-devops-backend/src/api/types.ts index b3562f76c6..aaa9d8dcbd 100644 --- a/plugins/azure-devops-backend/src/api/types.ts +++ b/plugins/azure-devops-backend/src/api/types.ts @@ -18,6 +18,7 @@ import { BuildResult, BuildStatus, } from 'azure-devops-node-api/interfaces/BuildInterfaces'; +import { PullRequestStatus } from 'azure-devops-node-api/interfaces/GitInterfaces'; export type RepoBuild = { id?: number; @@ -28,3 +29,16 @@ export type RepoBuild = { queueTime?: Date; source: string; }; + +export type PullRequest = { + pullRequestId?: number; + repoName?: string; + title?: string; + createdBy?: string; + creationDate?: Date; + sourceRefName?: string; + targetRefName?: string; + status?: PullRequestStatus; + isDraft?: boolean; + link: string; +}; diff --git a/plugins/azure-devops-backend/src/index.ts b/plugins/azure-devops-backend/src/index.ts index a7004ec73e..ff4d3973bf 100644 --- a/plugins/azure-devops-backend/src/index.ts +++ b/plugins/azure-devops-backend/src/index.ts @@ -14,5 +14,5 @@ * limitations under the License. */ export { AzureDevOpsApi } from './api'; -export type { RepoBuild } from './api'; +export type { RepoBuild, PullRequest } from './api'; export * from './service/router'; diff --git a/plugins/azure-devops-backend/src/service/router.test.ts b/plugins/azure-devops-backend/src/service/router.test.ts index a66b86f418..e6444bb9d3 100644 --- a/plugins/azure-devops-backend/src/service/router.test.ts +++ b/plugins/azure-devops-backend/src/service/router.test.ts @@ -20,8 +20,11 @@ import express from 'express'; import request from 'supertest'; import { AzureDevOpsApi } from '../api'; import { createRouter } from './router'; -import { RepoBuild } from '../api/types'; -import { GitRepository } from 'azure-devops-node-api/interfaces/GitInterfaces'; +import { PullRequest, RepoBuild } from '../api/types'; +import { + GitRepository, + PullRequestStatus, +} from 'azure-devops-node-api/interfaces/GitInterfaces'; import { Build, BuildResult, @@ -37,6 +40,7 @@ describe('createRouter', () => { getGitRepository: jest.fn(), getBuildList: jest.fn(), getRepoBuilds: jest.fn(), + getPullRequests: jest.fn(), } as any; const router = await createRouter({ azureDevOpsApi, @@ -193,4 +197,68 @@ describe('createRouter', () => { expect(response.body).toEqual(repoBuilds); }); }); + + describe('GET /pull-requests/:projectName/:repoName', () => { + it('fetches a list of pull requests', async () => { + const firstPullRequest: PullRequest = { + pullRequestId: 7181, + repoName: 'super-feature-repo', + title: 'My Awesome New Feature', + createdBy: 'Jane Doe', + creationDate: undefined, + sourceRefName: 'refs/heads/topic/super-awesome-feature', + targetRefName: 'refs/heads/main', + status: PullRequestStatus.Active, + isDraft: false, + link: 'https://host.com/myOrg/_git/super-feature-repo/pullrequest/7181', + }; + + const secondPullRequest: PullRequest = { + pullRequestId: 7182, + repoName: 'super-feature-repo', + title: 'Refactoring My Awesome New Feature', + createdBy: 'Jane Doe', + creationDate: undefined, + sourceRefName: 'refs/heads/topic/refactor-super-awesome-feature', + targetRefName: 'refs/heads/main', + status: PullRequestStatus.Active, + isDraft: false, + link: 'https://host.com/myOrg/_git/super-feature-repo/pullrequest/7182', + }; + + const thirdPullRequest: PullRequest = { + pullRequestId: 7183, + repoName: 'super-feature-repo', + title: 'Bug Fix for My Awesome New Feature', + createdBy: 'Jane Doe', + creationDate: undefined, + sourceRefName: 'refs/heads/topic/fix-super-awesome-feature', + targetRefName: 'refs/heads/main', + status: PullRequestStatus.Active, + isDraft: false, + link: 'https://host.com/myOrg/_git/super-feature-repo/pullrequest/7183', + }; + + const pullRequests: PullRequest[] = [ + firstPullRequest, + secondPullRequest, + thirdPullRequest, + ]; + + azureDevOpsApi.getPullRequests.mockResolvedValueOnce(pullRequests); + + const response = await request(app) + .get('/pull-requests/myProject/myRepo') + .query({ top: '50', status: 1 }); + + expect(azureDevOpsApi.getPullRequests).toHaveBeenCalledWith( + 'myProject', + 'myRepo', + 50, + 1, + ); + expect(response.status).toEqual(200); + expect(response.body).toEqual(pullRequests); + }); + }); }); diff --git a/plugins/azure-devops-backend/src/service/router.ts b/plugins/azure-devops-backend/src/service/router.ts index 1fc86128e6..62ac06afe4 100644 --- a/plugins/azure-devops-backend/src/service/router.ts +++ b/plugins/azure-devops-backend/src/service/router.ts @@ -21,6 +21,7 @@ import { Logger } from 'winston'; import { Config } from '@backstage/config'; import { getPersonalAccessTokenHandler, WebApi } from 'azure-devops-node-api'; import { AzureDevOpsApi } from '../api'; +import { PullRequestStatus } from 'azure-devops-node-api/interfaces/GitInterfaces'; const DEFAULT_TOP: number = 10; @@ -84,6 +85,21 @@ export async function createRouter( res.status(200).json(gitRepository); }); + router.get('/pull-requests/:projectName/:repoName', async (req, res) => { + const { projectName, repoName } = req.params; + const top = req.query.top ? Number(req.query.top) : DEFAULT_TOP; + const status = req.query.status + ? Number(req.query.status) + : PullRequestStatus.Active; + const gitPullRequest = await azureDevOpsApi.getPullRequests( + projectName, + repoName, + top, + status, + ); + res.status(200).json(gitPullRequest); + }); + router.use(errorHandler()); return router; }