Initial work on jenkins backend

Signed-off-by: Andrew Shirley <andrew.shirley@sainsburys.co.uk>
Signed-off-by: blam <ben@blam.sh>
This commit is contained in:
Andrew Shirley
2021-04-30 12:33:37 +01:00
committed by blam
parent 12a62ea5f6
commit 449b995261
26 changed files with 1338 additions and 384 deletions
+9 -38
View File
@@ -16,7 +16,11 @@ cd packages/app
yarn add @backstage/plugin-jenkins
```
2. Add the `EntityJenkinsContent` extension to the entity page in the app:
2. Add and configure tha backend plugin according to it's instructions
3. Add the `EntityJenkinsContent` extension to the cicd page and `EntityLatestJenkinsRunCard` to the entity page in the app:
Note that if you configured a custom JenkinsInfoProvider in step 2, you may need a custom isJenkinsAvailable.
```tsx
// In packages/app/src/components/catalog/EntityPage.tsx
@@ -32,29 +36,11 @@ const serviceEntityPage = (
</EntityLayout.Route>
```
3. Add proxy configuration to `app-config.yaml`
4. Run app with `yarn start`
5. Add the Jenkins folder annotation to your `catalog-info.yaml`.
```yaml
proxy:
'/jenkins/api':
target: 'http://localhost:8080' # your Jenkins URL
changeOrigin: true
headers:
Authorization: Basic ${JENKINS_BASIC_AUTH_HEADER}
```
4. Add an environment variable which contains the Jenkins credentials (NOTE:
use an API token, not your password). Here `user` is the name of the user
created in Jenkins.
```shell
export JENKINS_BASIC_AUTH_HEADER=$(echo -n user:api-token | base64)
```
5. Run the app with `yarn start`
6. Add the Jenkins folder annotation to your `catalog-info.yaml`, (NOTE:
currently this plugin only supports folders and Git SCM)
Currently, this plugin only supports folders and Git SCM.
Note that if you configured a custom JenkinsInfoProvider in step 2, you may need to use a different annotation scheme here
```yaml
apiVersion: backstage.io/v1alpha1
@@ -75,21 +61,6 @@ spec:
8. Click the component in the catalog. You should now see Jenkins builds, and a
last build result for your master build.
Note: If you are not using environment variables, you can directly type the API
token into `app-config.yaml`.
```yaml
proxy:
'/jenkins/api':
target: 'http://localhost:8080' # your Jenkins URL
changeOrigin: true
headers:
Authorization: Basic YWRtaW46MTFlYzI1NmU0Mzg1MDFjM2Y1Yzc2Yjc1MWE3ZTQ3YWY4Mw==
```
The string starting with `YWR...` is the base64 encoding of the user and their
API token, e.g. `admin:11ec256e438501c3f5c76b751a7e47af83`.
## Features
- View all runs inside a folder
-1
View File
@@ -39,7 +39,6 @@
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"jenkins": "^0.28.0",
"luxon": "^1.25.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
+136 -222
View File
@@ -14,246 +14,160 @@
* limitations under the License.
*/
import { CITableBuildInfo } from '../components/BuildsPage/lib/CITable';
import { createApiRef, DiscoveryApi } from '@backstage/core-plugin-api';
const jenkins = require('jenkins');
import { createApiRef, DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api';
import { EntityName, EntityRef } from '@backstage/catalog-model';
export const jenkinsApiRef = createApiRef<JenkinsApi>({
id: 'plugin.jenkins.service',
id: 'plugin.jenkins.service2',
description: 'Used by the Jenkins plugin to make requests',
});
const DEFAULT_PROXY_PATH = '/jenkins/api';
export interface Build {
// standard Jenkins
timestamp: number;
building: boolean;
duration: number;
result?: string;
fullDisplayName: string;
displayName: string;
url: string;
number: number;
type Options = {
discoveryApi: DiscoveryApi;
/**
* Path to use for requests via the proxy, defaults to /jenkins/api
*/
proxyPath?: string;
};
export class JenkinsApi {
private readonly discoveryApi: DiscoveryApi;
private readonly proxyPath: string;
constructor(options: Options) {
this.discoveryApi = options.discoveryApi;
this.proxyPath = options.proxyPath ?? DEFAULT_PROXY_PATH;
}
private async getClient() {
const proxyUrl = await this.discoveryApi.getBaseUrl('proxy');
return jenkins({ baseUrl: proxyUrl + this.proxyPath, promisify: true });
}
async retry(buildName: string) {
const client = await this.getClient();
// looks like the current SDK only supports triggering a new build
// can't see any support for replay (re-running the specific build with the same SCM info)
return await client.job.build(buildName);
}
async getLastBuild(jobName: string) {
const client = await this.getClient();
const job = await client.job.get(jobName);
const lastBuild = await client.build.get(jobName, job.lastBuild.number);
return lastBuild;
}
extractScmDetailsFromJob(jobDetails: any): any | undefined {
const scmInfo = jobDetails.actions
.filter(
(action: any) =>
action._class === 'jenkins.scm.api.metadata.ObjectMetadataAction',
)
.map((action: any) => {
return {
url: action?.objectUrl,
// https://javadoc.jenkins.io/plugin/scm-api/jenkins/scm/api/metadata/ObjectMetadataAction.html
// branch name for regular builds, pull request title on pull requests
displayName: action?.objectDisplayName,
};
})
.pop();
if (!scmInfo) {
return undefined;
}
const author = jobDetails.actions
.filter(
(action: any) =>
action._class ===
'jenkins.scm.api.metadata.ContributorMetadataAction',
)
.map((action: any) => {
return action.contributorDisplayName;
})
.pop();
if (author) {
scmInfo.author = author;
}
return scmInfo;
}
async getJob(jobName: string) {
const client = await this.getClient();
return client.job.get({
name: jobName,
depth: 1,
});
}
async getFolder(folderName: string) {
const client = await this.getClient();
const folder = await client.job.get({
name: folderName,
// Filter only be the information we need, instead of loading all fields.
// Limit to only show the latest build for each job and only load 50 jobs
// at all.
// Whitespaces are only included for readablity here and stripped out
// before sending to Jenkins
tree: `jobs[
actions[*],
builds[
number,
url,
fullDisplayName,
building,
result,
actions[
*[
*[
*[
*
]
]
]
]
]{0,1},
jobs{0,1},
name
]{0,50}
`.replace(/\s/g, ''),
});
const results = [];
for (const jobDetails of folder.jobs) {
const jobScmInfo = this.extractScmDetailsFromJob(jobDetails);
if (jobDetails?.jobs) {
// skipping folders inside folders for now
} else {
for (const buildDetails of jobDetails.builds) {
const ciTable = this.mapJenkinsBuildToCITable(
buildDetails,
jobScmInfo,
);
results.push(ciTable);
}
}
}
return results;
}
private getTestReport(
jenkinsResult: any,
): {
total: number;
// added by us
source?: {
branchName: string;
displayName: string;
url: string;
commit: {
hash: string;
};
author: string;
};
tests: {
passed: number;
skipped: number;
failed: number;
total: number;
testUrl: string;
} {
return jenkinsResult.actions
.filter(
(action: any) =>
action._class === 'hudson.tasks.junit.TestResultAction',
)
.map((action: any) => {
return {
total: action.totalCount,
passed: action.totalCount - action.failCount - action.skipCount,
skipped: action.skipCount,
failed: action.failCount,
testUrl: `${jenkinsResult.url}${action.urlName}/`,
};
})
.pop();
};
status: string; // == building ? 'running' : result,
}
export interface Project {
// standard Jenkins
lastBuild: Build;
displayName: string;
fullDisplayName: string;
fullName: string;
inQueue: string;
// added by us
status: string; // == inQueue ? 'queued' : lastBuild.building ? 'running' : lastBuild.result,
onRestartClick: () => void; // TODO handle.* ?
}
export interface JenkinsApi {
/**
* Get the projects (jobs which have builds, not folders) including info about their lastBuild.
*
* Deciding what jobs are for an entity can be configured by the backstage _Integrator_ in the plugin-jenkins-backend setup
* and by the _Software Engineer_ using annotations agreed with the _Integrator_.
*
* Typically, a folder job will be identified and the backend plugin will recursively look for projects (jobs with builds) within that folder.
*
* @param entity the entity whose jobs should be retrieved.
* @param filter a filter on jobs. Currently this just takes a branch (and assumes certain structures in jenkins)
*/
getProjects(
entity: EntityRef,
filter: { branch?: string },
): Promise<Project[]>;
/**
* Get a single build.
*
* This takes an entity to support selecting between multiple jenkins instances.
*
* TODO: abstract jobName (so we could support differentiating between the same named job on multiple instances).
* @param entity
* @param jobName
* @param buildNumber
*/
getBuild(
entity: EntityName,
jobName: string,
buildNumber: string,
): Promise<Build>;
retry(entity: EntityName, jobName: string, buildNumber: string): Promise<any>;
}
export class JenkinsApiImpl implements JenkinsApi {
private readonly discoveryApi: DiscoveryApi;
private readonly identityApi: IdentityApi;
constructor(options: {
discoveryApi: DiscoveryApi;
identityApi: IdentityApi;
}) {
this.discoveryApi = options.discoveryApi;
this.identityApi = options.identityApi;
}
mapJenkinsBuildToCITable(
jenkinsResult: any,
jobScmInfo?: any,
): CITableBuildInfo {
const source =
jenkinsResult.actions
.filter(
(action: any) =>
action._class === 'hudson.plugins.git.util.BuildData',
)
.map((action: any) => {
const [first]: any = Object.values(action.buildsByBranchName);
const branch = first.revision.branch[0];
return {
branchName: branch.name,
commit: {
hash: branch.SHA1.substring(0, 8),
},
};
})
.pop() || {};
async getProjects(
entity: EntityName,
filter: { branch?: string },
): Promise<any> {
const url = new URL(
`${await this.discoveryApi.getBaseUrl('jenkins')}/v1/entity/${
entity.namespace
}/${entity.kind}/${entity.name}/projects`,
);
if (jobScmInfo) {
source.url = jobScmInfo?.url;
source.displayName = jobScmInfo?.displayName;
source.author = jobScmInfo?.author;
if (filter.branch) {
url.searchParams.append('branch', filter.branch);
}
const path = new URL(jenkinsResult.url).pathname;
return {
id: path,
buildNumber: jenkinsResult.number,
buildUrl: jenkinsResult.url,
buildName: jenkinsResult.fullDisplayName,
status: jenkinsResult.building ? 'running' : jenkinsResult.result,
onRestartClick: () => {
// TODO: this won't handle non root context path, need a better way to get the job name
const { jobName } = this.extractJobDetailsFromBuildName(path);
return this.retry(jobName);
const idToken = await this.identityApi.getIdToken();
const response = await fetch(url.href, {
method: 'GET',
headers: {
...(idToken && { Authorization: `Bearer ${idToken}` }),
},
source: source,
tests: this.getTestReport(jenkinsResult),
};
});
return (await response.json()).projects;
}
async getBuild(buildName: string) {
const client = await this.getClient();
const { jobName, buildNumber } = this.extractJobDetailsFromBuildName(
buildName,
);
const buildResult = await client.build.get(jobName, buildNumber);
return buildResult;
}
extractJobDetailsFromBuildName(buildName: string) {
const trimmedBuild = buildName.replace(/\/job/g, '').replace(/\/$/, '');
const split = trimmedBuild.split('/');
const buildNumber = parseInt(split[split.length - 1], 10);
const jobName = trimmedBuild.slice(
0,
trimmedBuild.length - buildNumber.toString(10).length - 1,
);
return {
async getBuild(
entity: EntityName,
jobName: string,
buildNumber: string,
): Promise<any> {
const url = `${await this.discoveryApi.getBaseUrl('jenkins')}/v1/entity/${
entity.namespace
}/${entity.kind}/${entity.name}/job/${encodeURIComponent(
jobName,
buildNumber,
};
)}/${buildNumber}`;
const idToken = await this.identityApi.getIdToken();
const response = await fetch(url, {
method: 'GET',
headers: {
...(idToken && { Authorization: `Bearer ${idToken}` }),
},
});
return (await response.json()).build;
}
retry(
// @ts-ignore unused because unimplemented
entity: EntityName,
// @ts-ignore unused because unimplemented
jobName: string,
// @ts-ignore unused because unimplemented
buildNumber: string,
): Promise<any> {
return Promise.resolve(undefined);
}
}
+3 -1
View File
@@ -14,4 +14,6 @@
* limitations under the License.
*/
export { JenkinsApi, jenkinsApiRef } from './JenkinsApi';
export { JenkinsApiImpl, jenkinsApiRef } from './JenkinsApi';
export type { JenkinsApi } from './JenkinsApi';
@@ -30,7 +30,6 @@ import React from 'react';
import { buildRouteRef } from '../../plugin';
import { JenkinsRunStatus } from '../BuildsPage/lib/Status';
import { useBuildWithSteps } from '../useBuildWithSteps';
import { useProjectSlugFromEntity } from '../useProjectSlugFromEntity';
import { Breadcrumbs, Content, Link } from '@backstage/core-components';
import { useRouteRefParams } from '@backstage/core-plugin-api';
@@ -49,17 +48,15 @@ const useStyles = makeStyles(theme => ({
}));
const BuildWithStepsView = () => {
const projectName = useProjectSlugFromEntity();
const { branch, buildNumber } = useRouteRefParams(buildRouteRef);
const { jobName, buildNumber } = useRouteRefParams(buildRouteRef);
const classes = useStyles();
const buildPath = `${projectName}/${encodeURIComponent(
branch,
)}/${buildNumber}`;
const [{ value }] = useBuildWithSteps(buildPath);
const [{ value }] = useBuildWithSteps(jobName, buildNumber);
return (
<div className={classes.root}>
<Breadcrumbs aria-label="breadcrumb">
{/* TODO: don't hardcode this link */}
<Link to="../../..">Projects</Link>
<Typography>Run</Typography>
</Breadcrumbs>
@@ -104,7 +101,7 @@ const BuildWithStepsView = () => {
<Typography noWrap>Jenkins</Typography>
</TableCell>
<TableCell>
<MaterialLink target="_blank" href={value?.buildUrl}>
<MaterialLink target="_blank" href={value?.url}>
View on Jenkins{' '}
<ExternalLinkIcon className={classes.externalLinkIcon} />
</MaterialLink>
@@ -112,10 +109,11 @@ const BuildWithStepsView = () => {
</TableRow>
<TableRow>
<TableCell>
{/* TODO: be SCM agnostic */}
<Typography noWrap>GitHub</Typography>
</TableCell>
<TableCell>
<MaterialLink target="_blank" href={value?.source.url}>
<MaterialLink target="_blank" href={value?.source?.url}>
View on GitHub{' '}
<ExternalLinkIcon className={classes.externalLinkIcon} />
</MaterialLink>
@@ -20,34 +20,9 @@ import JenkinsLogo from '../../../../assets/JenkinsLogo.svg';
import { generatePath, Link as RouterLink } from 'react-router-dom';
import { JenkinsRunStatus } from '../Status';
import { useBuilds } from '../../../useBuilds';
import { useProjectSlugFromEntity } from '../../../useProjectSlugFromEntity';
import { buildRouteRef } from '../../../../plugin';
import { Table, TableColumn } from '@backstage/core-components';
export type CITableBuildInfo = {
id: string;
buildName: string;
buildNumber: number;
buildUrl: string;
source: {
branchName: string;
url: string;
displayName: string;
author?: string;
commit: {
hash: string;
};
};
status: string;
tests?: {
total: number;
passed: number;
skipped: number;
failed: number;
testUrl: string;
};
onRestartClick: () => void;
};
import { Project } from '../../../../api/JenkinsApi';
const FailCount = ({ count }: { count: number }): JSX.Element | null => {
if (count !== 0) {
@@ -107,44 +82,51 @@ const FailSkippedWidget = ({
const generatedColumns: TableColumn[] = [
{
title: 'Build',
field: 'buildName',
field: 'fullName',
highlight: true,
render: (row: Partial<CITableBuildInfo>) => {
if (!row.source?.branchName || !row.buildNumber) {
return <>{row.buildName}</>;
render: (row: Partial<Project>) => {
if (!row.fullName || !row.lastBuild?.number) {
return (
<>
{row.fullName ||
row.fullDisplayName ||
row.displayName ||
'Unknown'}
</>
);
}
return (
<Link
component={RouterLink}
to={generatePath(buildRouteRef.path, {
branch: encodeURIComponent(row.source.branchName),
buildNumber: row.buildNumber.toString(),
jobName: row.fullName,
buildNumber: row.lastBuild?.number.toString(),
})}
>
{row.buildName}
{row.fullName}
</Link>
);
},
},
{
title: 'Source',
field: 'source.branchName',
render: (row: Partial<CITableBuildInfo>) => (
field: 'lastBuild.source.branchName',
render: (row: Partial<Project>) => (
<>
<p>
<Link href={row.source?.url || ''} target="_blank">
{row.source?.branchName}
<Link href={row.lastBuild?.source?.url || ''} target="_blank">
{row.lastBuild?.source?.branchName}
</Link>
</p>
<p>{row.source?.commit?.hash}</p>
<p>{row.lastBuild?.source?.commit?.hash}</p>
</>
),
},
{
title: 'Status',
field: 'status',
render: (row: Partial<CITableBuildInfo>) => {
render: (row: Partial<Project>) => {
return (
<Box display="flex" alignItems="center">
<JenkinsRunStatus status={row.status} />
@@ -155,21 +137,22 @@ const generatedColumns: TableColumn[] = [
{
title: 'Tests',
sorting: false,
render: (row: Partial<CITableBuildInfo>) => {
render: (row: Partial<Project>) => {
return (
<>
<p>
{row.tests && (
<Link href={row.tests.testUrl || ''} target="_blank">
{row.tests.passed} / {row.tests.total} passed
{row.lastBuild?.tests && (
<Link href={row.lastBuild?.tests.testUrl || ''} target="_blank">
{row.lastBuild?.tests.passed} / {row.lastBuild?.tests.total}{' '}
passed
<FailSkippedWidget
skipped={row.tests.skipped}
failed={row.tests.failed}
skipped={row.lastBuild?.tests.skipped}
failed={row.lastBuild?.tests.failed}
/>
</Link>
)}
{!row.tests && 'n/a'}
{!row.lastBuild?.tests && 'n/a'}
</p>
</>
);
@@ -178,7 +161,7 @@ const generatedColumns: TableColumn[] = [
{
title: 'Actions',
sorting: false,
render: (row: Partial<CITableBuildInfo>) => (
render: (row: Partial<Project>) => (
<Tooltip title="Rerun build">
<IconButton onClick={row.onRestartClick}>
<RetryIcon />
@@ -192,8 +175,7 @@ const generatedColumns: TableColumn[] = [
type Props = {
loading: boolean;
retry: () => void;
builds: CITableBuildInfo[];
projectName: string;
projects?: Project[];
page: number;
onChangePage: (page: number) => void;
total: number;
@@ -202,12 +184,11 @@ type Props = {
};
export const CITableView = ({
projectName,
loading,
pageSize,
page,
retry,
builds,
projects,
onChangePage,
onChangePageSize,
total,
@@ -226,14 +207,14 @@ export const CITableView = ({
onClick: () => retry(),
},
]}
data={builds ?? []}
data={projects ?? []}
onChangePage={onChangePage}
onChangeRowsPerPage={onChangePageSize}
title={
<Box display="flex" alignItems="center">
<img src={JenkinsLogo} alt="Jenkins logo" height="50px" />
<Box mr={2} />
<Typography variant="h6">Project: {projectName}</Typography>
<Typography variant="h6">Projects</Typography>
</Box>
}
columns={generatedColumns}
@@ -242,9 +223,7 @@ export const CITableView = ({
};
export const CITable = () => {
const projectName = useProjectSlugFromEntity();
const [tableProps, { setPage, retry, setPageSize }] = useBuilds(projectName);
const [tableProps, { setPage, retry, setPageSize }] = useBuilds();
return (
<CITableView
@@ -14,4 +14,3 @@
* limitations under the License.
*/
export { CITable } from './CITable';
export type { CITableBuildInfo } from './CITable';
@@ -20,6 +20,7 @@ import { LatestRunCard } from './Cards';
import { EntityProvider } from '@backstage/plugin-catalog-react';
import { JenkinsApi, jenkinsApiRef } from '../../api';
import { ApiProvider, ApiRegistry } from '@backstage/core-app-api';
import { Project } from '../../api/JenkinsApi';
describe('<LatestRunCard />', () => {
const entity = {
@@ -33,7 +34,10 @@ describe('<LatestRunCard />', () => {
};
const jenkinsApi: Partial<JenkinsApi> = {
getLastBuild: () => Promise.resolve({ timestamp: 0, result: 'success' }),
getProjects: () =>
Promise.resolve([
{ lastBuild: { timestamp: 0, status: 'success' } },
] as Project[]),
};
it('should show success status of latest build', async () => {
@@ -52,7 +56,7 @@ describe('<LatestRunCard />', () => {
it('should show the appropriate error in case of a connection error', async () => {
const jenkinsApiWithError: Partial<JenkinsApi> = {
getLastBuild: () => Promise.reject(new Error('Unauthorized')),
getProjects: () => Promise.reject(new Error('Unauthorized')),
};
const apis = ApiRegistry.from([[jenkinsApiRef, jenkinsApiWithError]]);
@@ -71,7 +75,7 @@ describe('<LatestRunCard />', () => {
it('should show the appropriate error in case Jenkins project is not found', async () => {
const jenkinsApiWithError: Partial<JenkinsApi> = {
getLastBuild: () =>
getProjects: () =>
Promise.reject({
notFound: true,
message: 'jenkins-project not found',
+11 -12
View File
@@ -19,13 +19,13 @@ import { DateTime, Duration } from 'luxon';
import React from 'react';
import { JenkinsRunStatus } from '../BuildsPage/lib/Status';
import { ErrorType, useBuilds } from '../useBuilds';
import { useProjectSlugFromEntity } from '../useProjectSlugFromEntity';
import {
InfoCard,
InfoCardVariants,
StructuredMetadataTable,
WarningPanel,
} from '@backstage/core-components';
import { Project } from '../../api/JenkinsApi';
const useStyles = makeStyles<Theme>({
externalLinkIcon: {
@@ -39,16 +39,18 @@ const WidgetContent = ({
latestRun,
}: {
loading?: boolean;
latestRun: any;
latestRun?: Project;
branch: string;
}) => {
const classes = useStyles();
if (loading || !latestRun) return <LinearProgress />;
const displayDate = DateTime.fromMillis(latestRun.timestamp).toRelative();
const displayDate = DateTime.fromMillis(
latestRun.lastBuild.timestamp,
).toRelative();
const displayDuration =
(latestRun.building ? 'Running for ' : '') +
(latestRun.lastBuild.building ? 'Running for ' : '') +
DateTime.local()
.minus(Duration.fromMillis(latestRun.duration))
.minus(Duration.fromMillis(latestRun.lastBuild.duration))
.toRelative({ locale: 'en' })
?.replace(' ago', '');
@@ -57,16 +59,14 @@ const WidgetContent = ({
metadata={{
status: (
<>
<JenkinsRunStatus
status={latestRun.building ? 'running' : latestRun.result}
/>
<JenkinsRunStatus status={latestRun.lastBuild.status} />
</>
),
build: latestRun.fullDisplayName,
'latest run': displayDate,
duration: displayDuration,
link: (
<Link href={latestRun.url} target="_blank">
<Link href={latestRun.lastBuild.url} target="_blank">
See more on Jenkins{' '}
<ExternalLinkIcon className={classes.externalLinkIcon} />
</Link>
@@ -100,9 +100,8 @@ export const LatestRunCard = ({
branch: string;
variant?: InfoCardVariants;
}) => {
const projectName = useProjectSlugFromEntity();
const [{ builds, loading, error }] = useBuilds(projectName, branch);
const latestRun = builds ?? {};
const [{ projects, loading, error }] = useBuilds(branch);
const latestRun = projects?.[0];
return (
<InfoCard title={`Latest ${branch} build`} variant={variant}>
{!error ? (
@@ -18,30 +18,34 @@ import { useAsyncRetry } from 'react-use';
import { jenkinsApiRef } from '../api';
import { useAsyncPolling } from './useAsyncPolling';
import { errorApiRef, useApi } from '@backstage/core-plugin-api';
import { useEntity } from '@backstage/plugin-catalog-react';
import { getEntityName } from '@backstage/catalog-model';
const INTERVAL_AMOUNT = 1500;
export function useBuildWithSteps(buildName: string) {
/**
* Hook to expose a specific build.
* @param jobName the full name of the project (job with builds, not a folder). e.g. "department-A/team-1/project-foo/master"
* @param buildNumber the number of the build. e.g. "13"
*/
export function useBuildWithSteps(jobName: string, buildNumber: string) {
const api = useApi(jenkinsApiRef);
const errorApi = useApi(errorApiRef);
const { entity } = useEntity();
const entityName = getEntityName(entity);
const getBuildWithSteps = useCallback(async () => {
try {
const build = await api.getBuild(buildName);
const { jobName } = api.extractJobDetailsFromBuildName(buildName);
const job = await api.getJob(jobName);
const jobInfo = api.extractScmDetailsFromJob(job);
return Promise.resolve(api.mapJenkinsBuildToCITable(build, jobInfo));
return api.getBuild(entityName, jobName, buildNumber);
} catch (e) {
errorApi.post(e);
return Promise.reject(e);
}
}, [buildName, api, errorApi]);
}, [buildNumber, jobName, entityName, api, errorApi]);
const restartBuild = async () => {
try {
await api.retry(buildName);
await api.retry(entityName, jobName, buildNumber);
} catch (e) {
errorApi.post(e);
}
+20 -18
View File
@@ -17,13 +17,23 @@ import { useState } from 'react';
import { useAsyncRetry } from 'react-use';
import { jenkinsApiRef } from '../api';
import { errorApiRef, useApi } from '@backstage/core-plugin-api';
import { useEntity } from '@backstage/plugin-catalog-react';
import { getEntityName } from '@backstage/catalog-model';
export enum ErrorType {
CONNECTION_ERROR,
NOT_FOUND,
}
export function useBuilds(projectName: string, branch?: string) {
/**
* Hook to expose the latest build for all the pipelines/projects for an entity.
* If `branch` is provided, the latest build for only that branch will be given (but still as a list)
*
* TODO: deprecate branch and add a generic filter concept.
*/
export function useBuilds(branch?: string) {
const { entity } = useEntity();
const entityName = getEntityName(entity);
const api = useApi(jenkinsApiRef);
const errorApi = useApi(errorApiRef);
@@ -35,27 +45,21 @@ export function useBuilds(projectName: string, branch?: string) {
errorType: ErrorType;
}>();
const restartBuild = async (buildName: string) => {
const restartBuild = async (jobName: string, buildNumber: string) => {
try {
await api.retry(buildName);
await api.retry(entityName, jobName, buildNumber);
} catch (e) {
errorApi.post(e);
}
};
const { loading, value: builds, retry } = useAsyncRetry(async () => {
const { loading, value: projects, retry } = useAsyncRetry(async () => {
try {
let build;
if (branch) {
build = await api.getLastBuild(`${projectName}/${branch}`);
} else {
build = await api.getFolder(`${projectName}`);
}
const build = await api.getProjects(getEntityName(entity), { branch });
const size = Array.isArray(build) ? build?.[0].build_num! : 1;
setTotal(size);
setTotal(build.length);
return build || [];
return build;
} catch (e) {
const errorType = e.notFound
? ErrorType.NOT_FOUND
@@ -63,24 +67,22 @@ export function useBuilds(projectName: string, branch?: string) {
setError({ message: e.message, errorType });
throw e;
}
}, [api, errorApi, projectName, branch]);
}, [api, errorApi, entity, branch]);
return [
{
page,
pageSize,
loading,
builds,
projectName,
projects,
total,
error,
},
{
builds,
setPage,
setPageSize,
restartBuild,
retry,
retry, // fetch data again
},
] as const;
}
@@ -1,23 +0,0 @@
/*
* Copyright 2020 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 { useEntity } from '@backstage/plugin-catalog-react';
import { JENKINS_ANNOTATION } from '../constants';
export const useProjectSlugFromEntity = () => {
const { entity } = useEntity();
return entity.metadata.annotations?.[JENKINS_ANNOTATION] ?? '';
};
+8 -5
View File
@@ -22,7 +22,9 @@ import {
createRoutableExtension,
createRouteRef,
discoveryApiRef,
identityApiRef,
} from '@backstage/core-plugin-api';
import { JenkinsApiImpl, jenkinsApiRef } from './api';
export const rootRouteRef = createRouteRef({
path: '',
@@ -30,9 +32,9 @@ export const rootRouteRef = createRouteRef({
});
export const buildRouteRef = createRouteRef({
path: 'run/:branch/:buildNumber',
params: ['branch', 'buildNumber'],
title: 'Jenkins run',
path: 'build/:jobName/:buildNumber',
params: ['jobName', 'buildNumber'],
title: 'Jenkins build',
});
export const jenkinsPlugin = createPlugin({
@@ -40,8 +42,9 @@ export const jenkinsPlugin = createPlugin({
apis: [
createApiFactory({
api: jenkinsApiRef,
deps: { discoveryApi: discoveryApiRef },
factory: ({ discoveryApi }) => new JenkinsApi({ discoveryApi }),
deps: { discoveryApi: discoveryApiRef, identityApi: identityApiRef },
factory: ({ discoveryApi, identityApi }) =>
new JenkinsApiImpl({ discoveryApi, identityApi }),
}),
],
routes: {