1
0
mirror of https://github.com/quay/quay.git synced 2025-04-18 10:44:06 +03:00

ui: run builds (PROJQUAY-6297) (#2636)

Adds the ability for users to run builds via the Patternfly UI.
This commit is contained in:
Brandon Caton 2024-02-13 12:00:59 -05:00 committed by GitHub
parent e6bf6392aa
commit 417e66ee76
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 1331 additions and 72 deletions

View File

@ -23,7 +23,7 @@ repos:
entry: web/node_modules/.bin/eslint --fix
language: system
files: ^web/
exclude: ^web/cypress/test/
exclude: ^web/cypress/test/|^web/cypress/fixtures
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.3.0
hooks:

View File

@ -464,7 +464,7 @@ describe('Repository Builds', () => {
}).as('getBuildTriggers');
cy.intercept(
'PUT',
'/api/v1/repository/testorg/testrepo/trigger/gitlab82-9fd5-4005-bc95-d3156855f0d5',
'/api/v1/repository/testorg/testrepo/trigger/disabled-9fd5-4005-bc95-d3156855f0d5',
{
statusCode: 200,
},
@ -473,7 +473,7 @@ describe('Repository Builds', () => {
cy.visit('/repository/testorg/testrepo?tab=builds');
cy.contains(
'tr',
'push to GitLab repository testgitorg/testgitrepo',
'push to GitLab repository testgitorg/disabledrepo',
).within(() => {
cy.get('button[data-testid="build-trigger-actions-kebab"]').click();
cy.contains('Enable Trigger').click();
@ -540,7 +540,12 @@ describe('Repository Builds', () => {
cy.get('[data-testid="Webhook Endpoint URL"').within(() => {
cy.get('input').should(
'have.value',
'https://$token:faketoken@localhost:8080/webhooks/push/trigger/67595ac0-5014-4962-81a0-9a8d336ca851',
`https://$token:faketoken@${Cypress.env(
'REACT_QUAY_APP_API_URL',
).replace(
'http://',
'',
)}/webhooks/push/trigger/67595ac0-5014-4962-81a0-9a8d336ca851`,
);
});
cy.contains('Done').click();
@ -711,6 +716,248 @@ describe('Repository Builds', () => {
cy.contains('Delete Trigger').click();
cy.wait('@deleteTrigger');
});
it('manually runs github trigger', () => {
cy.intercept('GET', '/api/v1/repository/testorg/testrepo/build/?limit=10', {
fixture: 'builds.json',
}).as('getBuilds');
cy.intercept('GET', '/api/v1/repository/testorg/testrepo/trigger/', {
fixture: 'build-triggers.json',
}).as('getBuildTriggers');
cy.intercept(
'POST',
'/api/v1/repository/testorg/testrepo/trigger/githubfe-70b5-4bf9-8eb9-8dccf9874aed/fields/refs',
{fixture: 'build-trigger-refs.json'},
).as('getBuildTriggerRefs');
cy.intercept(
'POST',
'/api/v1/repository/testorg/testrepo/trigger/githubfe-70b5-4bf9-8eb9-8dccf9874aed/start',
{statusCode: 200, body: {id: 'build001'}},
).as('startBuild');
cy.visit('/repository/testorg/testrepo?tab=builds');
const submitBuild = (cy) => {
cy.get('#manually-start-build-modal').within(() => {
cy.contains('button', 'Start Build').should('be.disabled');
cy.contains('Manually Start Build Trigger');
cy.contains('push to GitHub repository testgitorg/testgitrepo');
cy.contains('Branch/Tag:');
cy.get('button[aria-label="Menu toggle"]').click();
cy.contains('master');
cy.contains('development');
cy.contains('1.0.0');
cy.contains('1.0.1').click();
cy.contains('button', 'Start Build').click();
cy.get('@startBuild')
.its('request.body')
.should('deep.equal', {refs: {kind: 'tag', name: '1.0.1'}});
});
cy.contains('Build started successfully with ID build001');
};
// Test with build history button
cy.contains('Start New Build').click();
cy.get('#start-build-modal').within(() => {
cy.contains(
'tr',
'push to GitHub repository testgitorg/testgitrepo',
).within(() => {
cy.contains('^newbranch$');
cy.contains('Run Trigger Now').click();
});
});
submitBuild(cy);
// Test run within row
cy.contains(
'tr',
'push to GitHub repository testgitorg/testgitrepo',
).within(() => {
cy.get('button[data-testid="build-trigger-actions-kebab"]').click();
cy.contains('Run Trigger').click();
});
submitBuild(cy);
});
it('manually runs gitlab trigger', () => {
cy.intercept('GET', '/api/v1/repository/testorg/testrepo/build/?limit=10', {
fixture: 'builds.json',
}).as('getBuilds');
cy.intercept('GET', '/api/v1/repository/testorg/testrepo/trigger/', {
fixture: 'build-triggers.json',
}).as('getBuildTriggers');
cy.intercept(
'POST',
'/api/v1/repository/testorg/testrepo/trigger/gitlab82-9fd5-4005-bc95-d3156855f0d5/fields/refs',
{fixture: 'build-trigger-refs.json'},
).as('getBuildTriggerRefs');
cy.intercept(
'POST',
'/api/v1/repository/testorg/testrepo/trigger/gitlab82-9fd5-4005-bc95-d3156855f0d5/start',
{statusCode: 200, body: {id: 'build001'}},
).as('startBuild');
cy.visit('/repository/testorg/testrepo?tab=builds');
const submitBuild = (cy) => {
cy.get('#manually-start-build-modal').within(() => {
cy.contains('button', 'Start Build').should('be.disabled');
cy.contains('Manually Start Build Trigger');
cy.contains('push to GitLab repository testgitorg/testgitrepo');
cy.contains('Branch/Tag:');
cy.get('button[aria-label="Menu toggle"]').click();
cy.contains('master');
cy.contains('development');
cy.contains('1.0.0');
cy.contains('1.0.1').click();
cy.contains('button', 'Start Build').click();
cy.get('@startBuild')
.its('request.body')
.should('deep.equal', {refs: {kind: 'tag', name: '1.0.1'}});
});
cy.contains('Build started successfully with ID build001');
};
// Test with build history button
cy.contains('Start New Build').click();
cy.get('#start-build-modal').within(() => {
cy.contains(
'tr',
'push to GitLab repository testgitorg/testgitrepo',
).within(() => {
cy.contains('All');
cy.contains('Run Trigger Now').click();
});
});
submitBuild(cy);
// Test run within row
cy.contains(
'tr',
'push to GitLab repository testgitorg/testgitrepo',
).within(() => {
cy.get('button[data-testid="build-trigger-actions-kebab"]').click();
cy.contains('Run Trigger').click();
});
submitBuild(cy);
});
it('manually runs custom trigger', () => {
cy.intercept('GET', '/api/v1/repository/testorg/testrepo/build/?limit=10', {
fixture: 'builds.json',
}).as('getBuilds');
cy.intercept('GET', '/api/v1/repository/testorg/testrepo/trigger/', {
fixture: 'build-triggers.json',
}).as('getBuildTriggers');
cy.intercept(
'POST',
'/api/v1/repository/testorg/testrepo/trigger/custom-git35014-4962-81a0-9a8d336ca851/start',
{statusCode: 200, body: {id: 'build001'}},
).as('startBuild');
cy.visit('/repository/testorg/testrepo?tab=builds');
const submitBuild = (cy) => {
cy.get('#manually-start-build-modal').within(() => {
cy.contains('button', 'Start Build').should('be.disabled');
cy.contains('Manually Start Build Trigger');
cy.contains(
'push to repository https://github.com/testgitorg/testgitrepo',
);
cy.contains('Commit:');
cy.get('#manual-build-commit-input').type('invalidcommit');
cy.contains('Invalid commit pattern');
cy.get('#manual-build-commit-input').clear();
cy.get('#manual-build-commit-input').type(
'adadadf141dd4141a4ecbb3cb21282053a678203',
);
cy.contains('button', 'Start Build').click();
cy.get('@startBuild').its('request.body').should('deep.equal', {
commit_sha: 'adadadf141dd4141a4ecbb3cb21282053a678203',
});
});
cy.contains('Build started successfully with ID build001');
};
// Test with build history button
cy.contains('Start New Build').click();
cy.get('#start-build-modal').within(() => {
cy.contains(
'tr',
'push to repository https://github.com/testgitorg/testgitrepo',
).within(() => {
cy.contains('All');
cy.contains('Run Trigger Now').click();
});
});
submitBuild(cy);
// Test run within row
cy.contains(
'tr',
'push to repository https://github.com/testgitorg/testgitrepo',
).within(() => {
cy.get('button[data-testid="build-trigger-actions-kebab"]').click();
cy.contains('Run Trigger').click();
});
submitBuild(cy);
});
it('runs trigger with dockerfile', () => {
cy.intercept('GET', '/api/v1/repository/testorg/testrepo/build/?limit=10', {
fixture: 'builds.json',
}).as('getBuilds');
cy.intercept('GET', '/api/v1/repository/testorg/testrepo/trigger/', {
fixture: 'build-triggers.json',
}).as('getBuildTriggers');
cy.intercept(
'GET',
'/api/v1/repository/testorg/privaterepo?includeStats=false&includeTags=false',
{statusCode: 200, body: {is_public: false}},
).as('getRepoDetails');
cy.intercept(
'GET',
'/api/v1/organization/testorg/robots?permissions=true&token=false',
{fixture: 'robots.json'},
).as('getRobots');
cy.intercept(
'GET',
'/api/v1/repository/testorg/testrepo/permissions/user/testorg+testrobot/transitive',
{statusCode: 200, body: {permissions: [{role: 'read'}]}},
).as('getTransitivePermissions');
cy.intercept('POST', '/api/v1/filedrop/', {
statusCode: 200,
body: {
url: `${Cypress.env(
'REACT_QUAY_APP_API_URL',
)}/userfiles/a1599e3f-aa56-4f90-8b1a-ec5f9e63ffe7`,
file_id: 'a1599e3f-aa56-4f90-8b1a-ec5f9e63ffe7',
},
}).as('getFileDrop');
cy.intercept('POST', '/api/v1/repository/testorg/testrepo/build/', {
statusCode: 201,
body: {id: 'build001'},
}).as('startBuild');
cy.visit('/repository/testorg/testrepo?tab=builds');
cy.contains('Start New Build').click();
cy.get('#start-build-modal').within(() => {
cy.contains('Upload Dockerfile').click();
cy.fixture('TestDockerfile', null).as('dockerfile');
cy.get('#dockerfile-upload').selectFile('@dockerfile', {
action: 'drag-drop',
});
cy.contains(
'The selected Dockerfile contains a FROM that refers to private repository testorg/privaterepo.',
);
cy.contains(
'A robot account with read access to that repository is required for the build:',
);
cy.get('#repository-creator-dropdown').click();
cy.contains('testorg+testrobot2');
cy.contains('testorg+testrobot').click();
cy.contains('button', 'Start Build').click();
});
cy.contains('Build started with ID build001');
});
});
describe('Repository Builds - Create Custom Git Build Triggers', () => {
@ -996,7 +1243,9 @@ describe('Repository Builds - Create GitHub Build Triggers', () => {
cy.contains('GitHub Repository Push').should(
'have.attr',
'href',
'https://github.com/login/oauth/authorize?client_id=testclientid&redirect_uri=http://localhost:8080/oauth2/github/callback/trigger/testorg/testrepo&scope=repo,user:email',
`https://github.com/login/oauth/authorize?client_id=testclientid&redirect_uri=${Cypress.env(
'REACT_QUAY_APP_API_URL',
)}/oauth2/github/callback/trigger/testorg/testrepo&scope=repo,user:email`,
);
cy.intercept(
@ -1225,7 +1474,9 @@ describe('Repository Builds - Create GitHub Build Triggers', () => {
cy.contains('GitLab Repository Push').should(
'have.attr',
'href',
'https://gitlab.com/oauth/authorize?client_id=testclientid&redirect_uri=http://localhost:8080/oauth2/gitlab/callback/trigger&scope=api%20write_repository%20openid&response_type=code&state=repo:testorg/testrepo',
`https://gitlab.com/oauth/authorize?client_id=testclientid&redirect_uri=${Cypress.env(
'REACT_QUAY_APP_API_URL',
)}/oauth2/gitlab/callback/trigger&scope=api%20write_repository%20openid&response_type=code&state=repo:testorg/testrepo`,
);
cy.intercept(

View File

@ -0,0 +1,4 @@
FROM localhost:8080/testorg/privaterepo
LABEL org=Redhat
LABEL version=1.0
LABEL description="testimage"

View File

@ -79,6 +79,32 @@
"hook_id": 12345496
},
"can_invoke": true,
"enabled": true,
"disabled_reason": "user_toggled"
},
{
"id": "disabled-9fd5-4005-bc95-d3156855f0d5",
"service": "gitlab",
"is_active": true,
"build_source": "testgitorg/disabledrepo",
"repository_url": "https://gitlab.com/testgitorg/disabledrepo",
"config": {
"build_source": "testgitorg/disabledrepo",
"dockerfile_path": "/application/Dockerfile",
"context": "/",
"default_tag_from_ref": false,
"latest_for_default_branch": false,
"tag_templates": [],
"credentials": [
{
"name": "SSH Public Key",
"value": "fakekey"
}
],
"key_id": 12349342,
"hook_id": 12345496
},
"can_invoke": true,
"enabled": false,
"disabled_reason": "user_toggled"
}

View File

@ -0,0 +1,30 @@
{
"robots": [
{
"name": "testorg+testrobot",
"created": "Tue, 29 Nov 2022 13:48:38 -0000",
"last_accessed": null,
"teams": [
{
"name": "chelsea",
"avatar": {
"name": "chelsea",
"hash": "hash41c03ef78bd6daeaa6bb008896607a8413bf8ba6266be80327554b370a9e",
"color": "#e7ba52",
"kind": "team"
}
}
],
"repositories": ["testrepo"],
"description": ""
},
{
"name": "testorg+testrobot2",
"created": "Wed, 30 Nov 2022 21:23:53 -0000",
"last_accessed": null,
"teams": [],
"repositories": [],
"description": ""
}
]
}

View File

@ -0,0 +1,56 @@
import {FileUpload as PFFileUpload, DropEvent} from '@patternfly/react-core';
import {ChangeEvent, useState} from 'react';
import {isNullOrUndefined} from 'src/libs/utils';
export default function FileUpload(props: FileUploadProps) {
const [filename, setFilename] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleFileInputChange = (_, file: File) => {
setFilename(file.name);
};
const handleDataChange = (_event: DropEvent, value: string) => {
props.onValueChange(value);
};
const handleClear = () => {
setFilename('');
props.onValueChange('');
if (!isNullOrUndefined(props.onClear)) {
props.onClear();
}
};
const handleFileReadStarted = (_event: DropEvent, _fileHandle: File) => {
setIsLoading(true);
};
const handleFileReadFinished = (_event: DropEvent, _fileHandle: File) => {
setIsLoading(false);
};
return (
<PFFileUpload
id={isNullOrUndefined(props.id) ? 'upload-file' : props.id}
type="text"
value={props.value}
filename={filename}
filenamePlaceholder="Drag and drop a file or upload one"
onFileInputChange={handleFileInputChange}
onDataChange={handleDataChange}
onReadStarted={handleFileReadStarted}
onReadFinished={handleFileReadFinished}
onClearClick={handleClear}
isLoading={isLoading}
allowEditingUploadedText={false}
browseButtonText="Upload"
/>
);
}
interface FileUploadProps {
id?: string;
value: string | File;
onValueChange: (value: string | File) => void;
onClear?: () => void;
}

View File

@ -1,7 +1,7 @@
export interface SearchState {
query: string;
field: string;
isRegEx: boolean;
isRegEx?: boolean;
}
export interface OrgSearchState extends SearchState {

View File

@ -171,7 +171,6 @@ export function useGitSources(
triggerUuid: string,
gitNamespaceId: string,
) {
console.log(gitNamespaceId);
const {data, isError, error, isLoading} = useQuery(
['sources', org, repo, triggerUuid, gitNamespaceId],
() => fetchSources(org, repo, triggerUuid, gitNamespaceId),
@ -189,11 +188,13 @@ export function useSourceRefs(
org: string,
repo: string,
triggerUuid: string,
sourceRef: string,
sourceRef?: string,
enabled = true,
) {
const {data, isError, error, isLoading} = useQuery(
['sourcerefs', org, repo, triggerUuid, sourceRef],
() => fetchRefs(org, repo, triggerUuid, sourceRef),
{enabled: enabled},
);
return {

View File

@ -1,11 +1,13 @@
import {useQuery} from '@tanstack/react-query';
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
import {isNullOrUndefined} from 'src/libs/utils';
import {
FileDropResponse,
SourceRef,
fetchBuilds,
fetchNamespaces,
fetchRefs,
fetchSources,
fetchSubDirs,
fileDrop,
startBuild,
startDockerfileBuild,
uploadFile,
} from 'src/resources/BuildResource';
export function useBuilds(
@ -31,3 +33,58 @@ export function useBuilds(
isLoading: isLoading,
};
}
export function useStartBuild(
org: string,
repo: string,
triggerUuid: string,
{onSuccess, onError},
) {
const queryClient = useQueryClient();
const {mutate} = useMutation(
async (ref: string | SourceRef) => startBuild(org, repo, triggerUuid, ref),
{
onSuccess: (data) => {
queryClient.invalidateQueries(['repobuilds', org, repo]);
onSuccess(data);
},
onError: onError,
},
);
return {
startBuild: mutate,
};
}
export function useStartDockerfileBuild(
org: string,
repo: string,
{onSuccess, onError},
) {
const queryClient = useQueryClient();
const {mutate} = useMutation(
async ({
dockerfileContent,
robot,
}: {
dockerfileContent: string;
robot: string;
}) => {
const fileDropData: FileDropResponse = await fileDrop();
await uploadFile(fileDropData.url, dockerfileContent);
return await startDockerfileBuild(org, repo, fileDropData.file_id, robot);
},
{
onSuccess: (data) => {
queryClient.invalidateQueries(['repobuilds', org, repo]);
onSuccess(data);
},
onError: onError,
},
);
return {
startBuild: mutate,
};
}

View File

@ -1,13 +1,40 @@
import {useQuery} from '@tanstack/react-query';
import {fetchRepositoryDetails} from 'src/resources/RepositoryResource';
import {isNullOrUndefined} from 'src/libs/utils';
import {
fetchEntityTransitivePermission,
fetchRepositoryDetails,
} from 'src/resources/RepositoryResource';
export function useRepository(org: string, repo: string) {
const {data, error} = useQuery(['repodetails', org, repo], () =>
fetchRepositoryDetails(org, repo),
export function useRepository(org: string, repo?: string) {
const {data, error, isLoading, isError} = useQuery(
['repodetails', org, repo],
() => fetchRepositoryDetails(org, repo),
{enabled: !isNullOrUndefined(repo)},
);
return {
repoDetails: data,
errorLoadingRepoDetails: error,
isLoading: isLoading,
isError: isError,
};
}
export function useTransitivePermissions(
org: string,
repo: string,
entity?: string,
) {
const {data, isLoading, isError, error} = useQuery(
['transitivepermissions', org, repo, entity],
() => fetchEntityTransitivePermission(org, repo, entity),
{enabled: !isNullOrUndefined(entity)},
);
return {
permissions: data,
isLoading: isLoading,
isError: isError,
error: error,
};
}

View File

@ -18,6 +18,7 @@ import {
} from 'src/resources/RobotsResource';
import {updateTeamForRobot} from 'src/resources/TeamResources';
import {useOrganizations} from 'src/hooks/UseOrganizations';
import {isNullOrUndefined} from 'src/libs/utils';
export function useFetchRobotAccounts(
orgName: string,
@ -188,7 +189,7 @@ interface addDefaultPermsParams {
robotDefaultPerm: string;
}
export function useRobotAccounts({name, onSuccess, onError}) {
export function useRobotAccounts({name, onSuccess, onError, enabled}) {
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(20);
const [namespace, setNamespace] = useState(name);
@ -199,6 +200,7 @@ export function useRobotAccounts({name, onSuccess, onError}) {
const {
data: robotAccountsForOrg,
isLoading: loading,
isError,
error,
} = useQuery(
['Namespace', namespace, 'robots'],
@ -211,6 +213,7 @@ export function useRobotAccounts({name, onSuccess, onError}) {
onError: (err) => {
onError(err);
},
enabled: isNullOrUndefined(enabled) ? true : enabled,
},
);
@ -218,6 +221,7 @@ export function useRobotAccounts({name, onSuccess, onError}) {
robotAccountsForOrg: robotAccountsForOrg,
loading: loading,
error,
isError,
setPage,
setPerPage,
page,

View File

@ -0,0 +1,59 @@
export function getRegistryBaseImage(
content: string,
domain: string,
): string | null {
const baseImage = getBaseImage(content);
if (!baseImage) {
return null;
}
if (baseImage.indexOf(`${domain}/`) != 0) {
return null;
}
return baseImage.substring(domain.length + 1);
}
function getBaseImage(content: string): string | null {
const imageAndTag = getBaseImageAndTag(content);
if (!imageAndTag) {
return null;
}
// Note, we have to handle a few different cases here:
// 1) someimage
// 2) someimage:tag
// 3) host:port/someimage
// 4) host:port/someimage:tag
const lastIndex: number = imageAndTag.lastIndexOf(':');
if (lastIndex == -1) {
return imageAndTag;
}
// Otherwise, check if there is a / in the portion after the split point. If so,
// then the latter is part of the path (and not a tag).
const afterColon: string = imageAndTag.substring(lastIndex + 1);
if (afterColon.indexOf('/') != -1) {
return imageAndTag;
}
return imageAndTag.substring(0, lastIndex);
}
function getBaseImageAndTag(content: string): string | null {
let baseImageAndTag: string = null;
const fromIndex: number = content.indexOf('FROM ');
if (fromIndex != -1) {
let newlineIndex: number = content.indexOf('\n', fromIndex);
if (newlineIndex == -1) {
newlineIndex = content.length;
}
baseImageAndTag = content
.substring(fromIndex + 'FROM '.length, newlineIndex)
.trim();
}
return baseImageAndTag;
}

View File

@ -290,7 +290,6 @@ export async function fetchSources(
triggerUuid: string,
gitNamespaceId: string,
) {
console.log(gitNamespaceId);
const response: AxiosResponse<FetchSourcesResponse> = await axios.post(
`/api/v1/repository/${namespace}/${repo}/trigger/${triggerUuid}/sources`,
{
@ -313,13 +312,12 @@ export async function fetchRefs(
namespace: string,
repo: string,
triggerUuid: string,
source: string,
source?: string,
) {
const body = isNullOrUndefined(source) ? {} : {build_source: source};
const response: AxiosResponse<FetchRefsResponse> = await axios.post(
`/api/v1/repository/${namespace}/${repo}/trigger/${triggerUuid}/fields/refs`,
{
build_source: source,
},
body,
);
return response.data?.values;
}
@ -346,3 +344,77 @@ export async function fetchSubDirs(
response.data.contextMap = new Map(Object.entries(response.data.contextMap));
return response.data;
}
export async function startBuild(
org: string,
repo: string,
triggerUuid: string,
ref: string | SourceRef,
) {
const body =
typeof ref === 'string' || ref instanceof String
? {commit_sha: ref}
: {refs: ref};
const response = await axios.post<RepositoryBuild>(
`/api/v1/repository/${org}/${repo}/trigger/${triggerUuid}/start`,
body,
);
return response.data;
}
export async function startDockerfileBuild(
org: string,
repo: string,
fileId: string,
pull_robot?: string,
) {
const body: {file_id: string; pull_robot?: string} = {
file_id: fileId,
};
if (!isNullOrUndefined(pull_robot)) {
body.pull_robot = pull_robot;
}
const response = await axios.post<RepositoryBuild>(
`/api/v1/repository/${org}/${repo}/build/`,
body,
);
return response.data;
}
export interface FileDropResponse {
file_id: string;
url: string;
}
export async function fileDrop() {
const response = await axios.post<FileDropResponse>('/api/v1/filedrop/', {
mimeType: 'application/octet-stream',
});
return response.data;
}
export function uploadFile(url: string, dockerfileContent: string) {
// axios has trouble uploading files with binary content, so we use XMLHttpRequest instead
return new Promise(function (resolve, reject) {
const request = new XMLHttpRequest();
request.open('PUT', url, true);
request.setRequestHeader('Content-Type', 'application/octet-stream');
request.onload = function () {
if (this.status >= 200 && this.status < 300) {
resolve(request.response);
} else {
reject({
status: this.status,
statusText: request.statusText,
});
}
};
request.onerror = function () {
reject({
status: this.status,
statusText: request.statusText,
});
};
request.send(dockerfileContent);
});
}

View File

@ -309,3 +309,30 @@ export async function deleteRepository(ns: string, name: string) {
);
}
}
interface EntityTransitivePermission {
role: string;
}
interface EntityTransitivePermissionResponse {
permissions: EntityTransitivePermission[];
}
export async function fetchEntityTransitivePermission(
org: string,
repo: string,
entity: string,
) {
try {
const response: AxiosResponse<EntityTransitivePermissionResponse> =
await axios.get(
`/api/v1/repository/${org}/${repo}/permissions/user/${entity}/transitive`,
);
return response.data.permissions;
} catch (error) {
// The backend returns a 404 if the entity exists but has no permissions
if (error instanceof AxiosError && error.response?.status == 404) {
return [];
}
}
}

View File

@ -31,28 +31,26 @@ function Message({service}: {service: string}) {
case 'custom-git':
return (
<Alert variant="info" title="Note:">
<p>
In order to use this trigger, the following first requires action:
<List>
<ListItem style={{marginTop: '0'}}>
You must give the following public key read access to the git
repository.
</ListItem>
<ListItem style={{marginTop: '0'}}>
You must set your repository to POST to the following URL to
trigger a build.
</ListItem>
</List>
For more information, refer to the{' '}
<a
href="https://docs.projectquay.io/use_quay.html#setting-up-custom-git-trigger"
target="_blank"
rel="noreferrer"
>
Custom Git Triggers documentation
</a>
.
</p>
In order to use this trigger, the following first requires action:
<List>
<ListItem style={{marginTop: '0'}}>
You must give the following public key read access to the git
repository.
</ListItem>
<ListItem style={{marginTop: '0'}}>
You must set your repository to POST to the following URL to
trigger a build.
</ListItem>
</List>
For more information, refer to the{' '}
<a
href="https://docs.projectquay.io/use_quay.html#setting-up-custom-git-trigger"
target="_blank"
rel="noreferrer"
>
Custom Git Triggers documentation
</a>
.
</Alert>
);
}

View File

@ -1,4 +1,12 @@
import {Title, ToggleGroup, ToggleGroupItem} from '@patternfly/react-core';
import {
Button,
Title,
ToggleGroup,
ToggleGroupItem,
Toolbar,
ToolbarContent,
ToolbarItem,
} from '@patternfly/react-core';
import {
BanIcon,
CheckCircleIcon,
@ -17,7 +25,6 @@ import {LoadingPage} from 'src/components/LoadingPage';
import Conditional from 'src/components/empty/Conditional';
import RequestError from 'src/components/errors/RequestError';
import {useBuilds} from 'src/hooks/UseBuilds';
import {useQuayConfig} from 'src/hooks/UseQuayConfig';
import {
formatDate,
humanizeTimeForExpiry,
@ -26,8 +33,13 @@ import {
import {
RepositoryBuild,
RepositoryBuildPhase,
RepositoryBuildTrigger,
} from 'src/resources/BuildResource';
import BuildTriggerDescription from './BuildTriggerDescription';
import {RepositoryDetails} from 'src/resources/RepositoryResource';
import {useQuayConfig} from 'src/hooks/UseQuayConfig';
import StartBuildModal from './BuildHistoryStartBuildModal';
import ManuallyStartTrigger from './BuildHistoryManuallyStartTriggerModal';
const ONE_DAY_IN_SECONDS = 24 * 3600;
const RESOURCE_URLS = {
@ -54,10 +66,17 @@ enum Filters {
}
export default function BuildHistory(props: BuildHistoryProps) {
const config = useQuayConfig();
const [buildsSinceInSeconds, setBuildsSinceInSeconds] = useState<number>();
const [dateStartedFilter, setDateStartedFilter] = useState<Filters>(
Filters.RECENT_BUILDS,
);
const [isRunBuildModalOpen, setIsRunBuildModalOpen] =
useState<boolean>(false);
const [isManuallyStartTriggerOpen, setIsManuallyStartTriggerOpen] =
useState<boolean>(false);
const [manualTrigger, setManualTrigger] =
useState<RepositoryBuildTrigger>(null);
const {builds, isError, error, isLoading} = useBuilds(
props.org,
props.repo,
@ -94,9 +113,26 @@ export default function BuildHistory(props: BuildHistoryProps) {
return (
<>
<Title headingLevel="h2" style={{paddingLeft: '1em', paddingTop: '1em'}}>
Build History
</Title>
<Toolbar>
<ToolbarContent style={{paddingLeft: '1em', paddingTop: '1em'}}>
<ToolbarItem>
<Title headingLevel="h2">Build History</Title>
</ToolbarItem>
<Conditional
if={
props.repoDetails.can_write &&
config?.features.BUILD_SUPPORT &&
config?.config?.REGISTRY_STATE !== 'readonly'
}
>
<ToolbarItem align={{default: 'alignRight'}}>
<Button onClick={() => setIsRunBuildModalOpen(true)}>
Start New Build
</Button>
</ToolbarItem>
</Conditional>
</ToolbarContent>
</Toolbar>
<ToggleGroup
aria-label="filter build list by date"
style={{padding: '1em'}}
@ -163,6 +199,29 @@ export default function BuildHistory(props: BuildHistoryProps) {
</Tbody>
</Table>
</Conditional>
<StartBuildModal
org={props.org}
repo={props.repo}
isOpen={isRunBuildModalOpen}
onClose={() => setIsRunBuildModalOpen(false)}
triggers={props.triggers}
onSelectTrigger={(trigger) => {
setIsRunBuildModalOpen(false);
setManualTrigger(trigger);
setIsManuallyStartTriggerOpen(true);
}}
/>
<Conditional
if={isManuallyStartTriggerOpen && !isNullOrUndefined(manualTrigger)}
>
<ManuallyStartTrigger
org={props.org}
repo={props.repo}
trigger={manualTrigger}
isOpen={isManuallyStartTriggerOpen}
onClose={() => setIsManuallyStartTriggerOpen(false)}
/>
</Conditional>
</>
);
}
@ -451,4 +510,6 @@ function getLongDescription(message: string) {
interface BuildHistoryProps {
org: string;
repo: string;
repoDetails: RepositoryDetails;
triggers: RepositoryBuildTrigger[];
}

View File

@ -0,0 +1,157 @@
import {
Alert,
Button,
HelperText,
HelperTextItem,
Modal,
ModalVariant,
Spinner,
TextInput,
Title,
} from '@patternfly/react-core';
import {RepositoryBuildTrigger, SourceRef} from 'src/resources/BuildResource';
import BuildTriggerDescription from './BuildTriggerDescription';
import Conditional from 'src/components/empty/Conditional';
import {useState} from 'react';
import {useStartBuild} from 'src/hooks/UseBuilds';
import {useAlerts} from 'src/hooks/UseAlerts';
import {AlertVariant} from 'src/atoms/AlertState';
import {useSourceRefs} from 'src/hooks/UseBuildTriggers';
import TypeAheadSelect from 'src/components/TypeAheadSelect';
import {isNullOrUndefined} from 'src/libs/utils';
export default function ManuallyStartTrigger(props: ManuallyStartTriggerProps) {
const {trigger, org, repo} = props;
const [commit, setCommit] = useState<string>('');
const [ref, setRef] = useState<SourceRef>({name: '', kind: null});
const [isValid, setIsValid] = useState<boolean>(false);
const {addAlert} = useAlerts();
const {refs, isLoading, isError, error} = useSourceRefs(
org,
repo,
trigger?.id,
null,
trigger.service !== 'custom-git',
);
const {startBuild} = useStartBuild(org, repo, trigger?.id, {
onSuccess: (data) => {
addAlert({
title: `Build started successfully with ID ${data.id}`,
variant: AlertVariant.Success,
});
props.onClose();
},
onError: (error) => {
addAlert({
title: 'Failed to start build',
variant: AlertVariant.Failure,
message: error.message,
});
},
});
const onChange = (value: string) => {
setIsValid(/^[A-Fa-f0-9]{7,}$/.test(value));
setCommit(value);
};
const onRefChange = (value: string) => {
const foundRef = refs?.find((ref) => ref.name === value);
if (isNullOrUndefined(foundRef)) {
setIsValid(false);
setRef({name: value, kind: null});
} else {
setIsValid(true);
setRef(foundRef);
}
};
return (
<Modal
id="manually-start-build-modal"
isOpen={props.isOpen}
aria-label="Manually Start Build"
onClose={() => props.onClose()}
variant={ModalVariant.medium}
actions={[
<Button
key="start-build"
isDisabled={!isValid}
onClick={() =>
startBuild(trigger?.service === 'custom-git' ? commit : ref)
}
>
Start Build
</Button>,
<Button key="cancel" variant="primary" onClick={() => props.onClose()}>
Cancel
</Button>,
]}
style={{
overflowX: 'visible',
overflowY: 'visible',
}}
>
<Conditional if={trigger?.service === 'custom-git'}>
<Title headingLevel="h4">Manually Start Build Trigger</Title>
<BuildTriggerDescription trigger={trigger} />
<br />
Commit:
<TextInput
id="manual-build-commit-input"
value={commit}
onChange={(_, value) => onChange(value)}
type="text"
pattern="^([A-Fa-f0-9]{7,})$"
placeholder="1c002dd"
/>
<Conditional if={!isValid && commit !== ''}>
<HelperText id="helper-text">
<HelperTextItem variant="error">
Invalid commit pattern
</HelperTextItem>
</HelperText>
</Conditional>
</Conditional>
<Conditional if={trigger?.service !== 'custom-git'}>
<Conditional if={isLoading}>
<Spinner />
</Conditional>
<Conditional if={isError}>
<Alert variant="danger" title={error?.toString()} />
</Conditional>
<Conditional if={!isLoading && !isError}>
<Title headingLevel="h4">Manually Start Build Trigger</Title>
<BuildTriggerDescription trigger={trigger} />
<br />
Branch/Tag:{' '}
<TypeAheadSelect
value={ref.name}
onChange={(value) => onRefChange(value)}
initialSelectOptions={refs?.map((ref, index) => ({
key: `${ref.kind}/${ref.name}`,
onClick: () => setRef(ref),
id: `ref-option-${index}`,
value: ref.name,
}))}
/>
<Conditional if={!isValid && ref?.name !== ''}>
<HelperText id="helper-text">
<HelperTextItem variant="error">
Branch/Tag not found
</HelperTextItem>
</HelperText>
</Conditional>
</Conditional>
</Conditional>
</Modal>
);
}
interface ManuallyStartTriggerProps {
org: string;
repo: string;
isOpen: boolean;
onClose: () => void;
trigger: RepositoryBuildTrigger;
}

View File

@ -0,0 +1,123 @@
import {
Modal,
ModalVariant,
Button,
Title,
Tabs,
Tab,
TabTitleText,
Tooltip,
} from '@patternfly/react-core';
import {Table, Tbody, Td, Th, Thead, Tr} from '@patternfly/react-table';
import {useState} from 'react';
import {RepositoryBuildTrigger} from 'src/resources/BuildResource';
import BuildTriggerDescription from './BuildTriggerDescription';
import Conditional from 'src/components/empty/Conditional';
import DockerfileUploadBuild from './BuildHistoryStartBuildModalUploadDockerfile';
export default function StartBuildModal(props: StartNewBuildModalProps) {
const [activeTabKey, setActiveTabKey] = useState<string | number>(0);
const activteTriggers = props.triggers.filter((trigger) => trigger.is_active);
return (
<Modal
id="start-build-modal"
aria-label="Start a new build modal"
isOpen={props.isOpen}
onClose={() => props.onClose()}
variant={ModalVariant.medium}
style={{
overflowX: 'visible',
overflowY: 'visible',
}}
>
<Title headingLevel="h4">Start Repository Build</Title>
<Tabs
activeKey={activeTabKey}
onSelect={(_, eventKey) => setActiveTabKey(eventKey)}
aria-label="Start a new build tabs"
role="region"
>
<Tab
eventKey={0}
title={<TabTitleText>Invoke Build Trigger</TabTitleText>}
aria-label="invoke build trigger tab"
>
<p>
Manually running a build trigger provides the means for invoking a
build trigger as-if called from the underlying service for the
latest commit to a particular branch or tag.
</p>
<Table variant="compact">
<Thead>
<Tr>
<Th>Trigger Description</Th>
<Th>Branches/Tags</Th>
<Th></Th>
</Tr>
</Thead>
<Tbody>
<Conditional if={activteTriggers?.length === 0}>
<Tr>
<Td colSpan={3}>
<p>No build triggers available for this repository.</p>
</Td>
</Tr>
</Conditional>
{activteTriggers.map((trigger) => (
<Tr key={trigger.id}>
<Td>
<BuildTriggerDescription trigger={trigger} />
</Td>
<Td>{trigger?.config?.branchtag_regex || 'All'}</Td>
<Td>
<Conditional if={trigger.can_invoke && trigger.enabled}>
<a onClick={() => props.onSelectTrigger(trigger)}>
Run Trigger Now
</a>
</Conditional>
<Conditional if={!trigger.can_invoke}>
<Tooltip content="You do not have permission to run this trigger">
<span>No permission to run</span>
</Tooltip>
</Conditional>
<Conditional if={!trigger.enabled}>
<span>Trigger Disabled</span>
</Conditional>
</Td>
</Tr>
))}
</Tbody>
</Table>
<br />
<Button
key="cancel"
variant="primary"
onClick={() => props.onClose()}
>
Cancel
</Button>
</Tab>
<Tab
eventKey={1}
title={<TabTitleText>Upload Dockerfile</TabTitleText>}
aria-label="upload dockerfile tab"
>
<DockerfileUploadBuild
org={props.org}
repo={props.repo}
onClose={props.onClose}
/>
</Tab>
</Tabs>
</Modal>
);
}
interface StartNewBuildModalProps {
org: string;
repo: string;
isOpen: boolean;
onClose: () => void;
triggers: RepositoryBuildTrigger[];
onSelectTrigger: (trigger: RepositoryBuildTrigger) => void;
}

View File

@ -0,0 +1,270 @@
import {
Alert,
Button,
Divider,
SelectGroup,
SelectOption,
Spinner,
AlertVariant as PFAlertVariant,
HelperText,
HelperTextItem,
} from '@patternfly/react-core';
import {DesktopIcon} from '@patternfly/react-icons';
import React from 'react';
import {useState} from 'react';
import {AlertVariant} from 'src/atoms/AlertState';
import EntitySearch from 'src/components/EntitySearch';
import FileUpload from 'src/components/FileUpload';
import Conditional from 'src/components/empty/Conditional';
import CreateRobotAccountModal from 'src/components/modals/CreateRobotAccountModal';
import {useAlerts} from 'src/hooks/UseAlerts';
import {useStartDockerfileBuild} from 'src/hooks/UseBuilds';
import {useQuayConfig} from 'src/hooks/UseQuayConfig';
import {useRepository, useTransitivePermissions} from 'src/hooks/UseRepository';
import {useFetchTeams} from 'src/hooks/UseTeams';
import {useRobotAccounts} from 'src/hooks/useRobotAccounts';
import {getRegistryBaseImage} from 'src/libs/dockerfileParser';
import {isNullOrUndefined} from 'src/libs/utils';
import {RepositoryBuild} from 'src/resources/BuildResource';
import {Entity} from 'src/resources/UserResource';
import {RepoPermissionDropdownItems} from 'src/routes/RepositoriesList/RobotAccountsList';
export default function DockerfileUploadBuild(
props: DockerfileUploadBuildProps,
) {
const [rejected, setRejected] = useState<boolean>(false);
const [value, setValue] = useState('');
const [privateRepo, setPrivatRepo] = useState<string>();
const [selectedRobot, setSelectedRobot] = useState<string>(null);
const [isCreateRobotModalOpen, setIsCreateRobotModalOpen] = useState(false);
const {addAlert} = useAlerts();
const {teams} = useFetchTeams(props.org);
const [org, repo] = privateRepo?.split('/') ?? [null, null];
const {repoDetails} = useRepository(org, repo);
const {
permissions,
isLoading: isLoadingTransitivePermissions,
isError: isErrorLoadingTransitivePermissions,
error: errorLoadingTransitivePermissions,
} = useTransitivePermissions(props.org, props.repo, selectedRobot);
const {
robotAccountsForOrg: robots,
loading,
isError,
error,
} = useRobotAccounts({
name: props.org,
onSuccess: () => null,
onError: () => null,
enabled: !isNullOrUndefined(privateRepo),
});
const {startBuild} = useStartDockerfileBuild(props.org, props.repo, {
onSuccess: (data: RepositoryBuild) => {
addAlert({
variant: AlertVariant.Success,
title: `Build started with ID ${data.id}`,
});
props.onClose();
},
onError: (error) => {
addAlert({
variant: AlertVariant.Failure,
title: `Failed to start build`,
});
},
});
const config = useQuayConfig();
if (isErrorLoadingTransitivePermissions) {
return (
<Alert
variant={PFAlertVariant.danger}
title="Failed to verify robot account permissions"
/>
);
}
if (isError) {
return (
<Alert
variant={PFAlertVariant.danger}
title="Failed to load robot accounts for private base image"
/>
);
}
const onFileUpload = (value: string) => {
if (value.includes('FROM')) {
const baseImage = getRegistryBaseImage(
value,
config?.config?.SERVER_HOSTNAME,
);
if (!isNullOrUndefined(baseImage)) {
setPrivatRepo(baseImage);
}
setValue(value);
setRejected(false);
} else {
setRejected(true);
setValue('');
}
};
return (
<>
<FileUpload
id="dockerfile-upload"
value={value}
onValueChange={onFileUpload}
onClear={() => {
setValue('');
setPrivatRepo(null);
setSelectedRobot(null);
setRejected(false);
}}
/>
<Conditional if={rejected}>
<HelperText>
<HelperTextItem variant={rejected ? 'error' : 'default'}>
Invalid Dockerfile format
</HelperTextItem>
</HelperText>
</Conditional>
<p>Please select a Dockerfile</p>
<Conditional
if={
!isNullOrUndefined(privateRepo) &&
!isNullOrUndefined(repoDetails) &&
!repoDetails.is_public
}
>
<br />
<Alert
variant={PFAlertVariant.warning}
title={
<>
<p>
The selected Dockerfile contains a <code>FROM</code> that refers
to private repository <strong>{privateRepo}</strong>.
</p>
<p>
A robot account with read access to that repository is required
for the build:
</p>
</>
}
/>
<br />
<EntitySearch
id="repository-creator-dropdown"
org={props.org}
includeTeams={false}
onSelect={(e: Entity) => {
setSelectedRobot(e.name);
}}
onClear={() => setSelectedRobot(null)}
value={selectedRobot}
defaultOptions={
<React.Fragment key="creator">
<SelectGroup label="Robot accounts" key="robot-account-grp">
{loading ? (
<Spinner />
) : (
robots?.map(({name}) => (
<SelectOption
data-testid={`${name}-robot-accnt`}
key={name}
value={name}
onClick={() => {
setSelectedRobot(name);
}}
>
{name}
</SelectOption>
))
)}
</SelectGroup>
<Divider component="li" key={7} />
<SelectOption
data-testid="create-new-robot-accnt-btn"
key="create-robot-account"
component="button"
onClick={() =>
setIsCreateRobotModalOpen(!isCreateRobotModalOpen)
}
isFocused
>
<DesktopIcon /> &nbsp; Create robot account
</SelectOption>
</React.Fragment>
}
placeholderText="Add a registered user, robot to team"
/>
<Conditional
if={
!isNullOrUndefined(selectedRobot) &&
permissions?.length === 0 &&
!isLoadingTransitivePermissions
}
>
<br />
<Alert
variant={PFAlertVariant.warning}
title={
<>
Robot account <strong>{selectedRobot}</strong> does not have
read permission on repository <strong>{privateRepo}</strong>
</>
}
/>
</Conditional>
</Conditional>
<br />
<br />
<Button
isDisabled={
isNullOrUndefined(value) ||
value === '' ||
(isNullOrUndefined(selectedRobot) &&
!isNullOrUndefined(privateRepo) &&
!isNullOrUndefined(repoDetails) &&
!repoDetails.is_public)
}
onClick={() =>
startBuild({dockerfileContent: value, robot: selectedRobot})
}
>
Start Build
</Button>{' '}
<Button onClick={() => props.onClose()}>Close</Button>
<Conditional if={isCreateRobotModalOpen}>
<CreateRobotAccountModal
isModalOpen={isCreateRobotModalOpen}
handleModalToggle={() =>
setIsCreateRobotModalOpen(!isCreateRobotModalOpen)
}
orgName={props.org}
teams={teams}
RepoPermissionDropdownItems={RepoPermissionDropdownItems}
setEntity={(entity: Entity) => setSelectedRobot(entity.name)}
showSuccessAlert={(message) =>
addAlert({
variant: AlertVariant.Success,
title: message,
})
}
showErrorAlert={(message) =>
addAlert({
variant: AlertVariant.Failure,
title: message,
})
}
/>
</Conditional>
</>
);
}
interface DockerfileUploadBuildProps {
org: string;
repo: string;
onClose: () => void;
}

View File

@ -11,6 +11,10 @@ import BuildTriggerToggleModal from './BuildTriggerToggleModal';
import BuildTriggerViewCredentialsModal from './BuildTriggerViewCredentialsModal';
import {RepositoryBuildTrigger} from 'src/resources/BuildResource';
import BuildTriggerDeleteModal from './BuildTriggerDeleteModal';
import {triggerAsyncId} from 'async_hooks';
import {isNullOrUndefined} from 'src/libs/utils';
import Conditional from 'src/components/empty/Conditional';
import ManuallyStartTrigger from './BuildHistoryManuallyStartTriggerModal';
export default function BuildTriggerActions(props: BuildTriggerActionsProps) {
const [isOpen, setIsOpen] = useState(false);
@ -20,6 +24,8 @@ export default function BuildTriggerActions(props: BuildTriggerActionsProps) {
useState(false);
const [isDeleteTriggerModalOpen, setIsDeleteTriggerModalOpen] =
useState(false);
const [isManuallyStartTriggerOpen, setIsManuallyStartTriggerOpen] =
useState(false);
const dropdownItems = [
<DropdownItem
@ -31,6 +37,16 @@ export default function BuildTriggerActions(props: BuildTriggerActionsProps) {
>
View Credentials
</DropdownItem>,
<DropdownItem
key="run-trigger-action"
onClick={() => {
setIsOpen(false);
setIsManuallyStartTriggerOpen(true);
}}
isDisabled={!props.trigger?.enabled}
>
Run Trigger Now
</DropdownItem>,
<DropdownItem
key="toggle-trigger-action"
onClick={() => {
@ -95,6 +111,17 @@ export default function BuildTriggerActions(props: BuildTriggerActionsProps) {
isOpen={isDeleteTriggerModalOpen}
onClose={() => setIsDeleteTriggerModalOpen(false)}
/>
<Conditional
if={isManuallyStartTriggerOpen && !isNullOrUndefined(props.trigger)}
>
<ManuallyStartTrigger
org={props.org}
repo={props.repo}
trigger={props.trigger}
isOpen={isManuallyStartTriggerOpen}
onClose={() => setIsManuallyStartTriggerOpen(false)}
/>
</Conditional>
</>
);
}

View File

@ -7,9 +7,6 @@ import {
ToolbarItem,
} from '@patternfly/react-core';
import {Table, Tbody, Td, Th, Thead, Tr} from '@patternfly/react-table';
import {LoadingPage} from 'src/components/LoadingPage';
import RequestError from 'src/components/errors/RequestError';
import {useBuildTriggers} from 'src/hooks/UseBuildTriggers';
import BuildTriggerDescription from './BuildTriggerDescription';
import Conditional from 'src/components/empty/Conditional';
import {isNullOrUndefined} from 'src/libs/utils';
@ -23,34 +20,23 @@ import InactiveTrigger from './BuildTriggersInactiveTriggerRow';
import CreateBuildTriggerDropdown from './BuildCreateTriggerDropdown';
import SetupBuildTriggerModal from './BuildTriggerSetupModal';
import {RepositoryDetails} from 'src/resources/RepositoryResource';
import {RepositoryBuildTrigger} from 'src/resources/BuildResource';
export default function BuildTriggers(props: BuildTriggersProps) {
const config = useQuayConfig();
const [isSetupTriggerOpen, setIsSetupTriggerOpen] = useState(
!isNullOrUndefined(props.setupTriggerUuid),
);
const {triggers, isLoading, isError, error} = useBuildTriggers(
props.org,
props.repo,
);
const [triggerToggleOptions, setTriggerToggleOptions] = useState<{
trigger_uuid: string;
enabled: boolean;
isOpen: boolean;
}>({trigger_uuid: null, enabled: null, isOpen: false});
if (isLoading) {
return <LoadingPage />;
}
if (isError) {
return <RequestError message={error as string} />;
}
const activeTriggers = triggers.filter(
const activeTriggers = props.triggers.filter(
(trigger) => trigger.is_active === true,
);
const inActiveTriggers = triggers.filter(
const inActiveTriggers = props.triggers.filter(
(trigger) => trigger.is_active == false,
);
@ -59,7 +45,7 @@ export default function BuildTriggers(props: BuildTriggersProps) {
<Toolbar>
<ToolbarContent style={{paddingLeft: '1em', paddingTop: '1em'}}>
<ToolbarItem>
<Title headingLevel="h2">Build triggers</Title>
<Title headingLevel="h2">Build Triggers</Title>
</ToolbarItem>
<ToolbarItem align={{default: 'alignRight'}}>
<CreateBuildTriggerDropdown
@ -69,7 +55,7 @@ export default function BuildTriggers(props: BuildTriggersProps) {
</ToolbarItem>
</ToolbarContent>
</Toolbar>
<Conditional if={triggers.length > 0}>
<Conditional if={props.triggers.length > 0}>
<Table aria-label="Repository build triggers table" variant="compact">
<Thead>
<Tr>
@ -203,7 +189,7 @@ export default function BuildTriggers(props: BuildTriggersProps) {
))}
</Table>
</Conditional>
<Conditional if={triggers.length === 0}>
<Conditional if={props.triggers.length === 0}>
<p style={{padding: '1em'}}>
No build triggers defined. Build triggers invoke builds whenever the
triggered condition is met (source control push, webhook, etc)
@ -240,4 +226,5 @@ interface BuildTriggersProps {
repo: string;
setupTriggerUuid?: string;
repoDetails: RepositoryDetails;
triggers: RepositoryBuildTrigger[];
}

View File

@ -2,18 +2,40 @@ import {Card, PageSection} from '@patternfly/react-core';
import BuildHistory from './BuildHistory';
import BuildTriggers from './BuildTriggers';
import {RepositoryDetails} from 'src/resources/RepositoryResource';
import {useBuildTriggers} from 'src/hooks/UseBuildTriggers';
import {LoadingPage} from 'src/components/LoadingPage';
import RequestError from 'src/components/errors/RequestError';
export default function Builds(props: BuildsProps) {
const {triggers, isLoading, isError, error} = useBuildTriggers(
props.org,
props.repo,
);
if (isLoading) {
return <LoadingPage />;
}
if (isError) {
return <RequestError message={error.toString()} />;
}
return (
<PageSection>
<Card>
<BuildHistory org={props.org} repo={props.repo} />
<BuildHistory
org={props.org}
repo={props.repo}
repoDetails={props.repoDetails}
triggers={triggers}
/>
</Card>
<br />
<Card>
<BuildTriggers
org={props.org}
repo={props.repo}
triggers={triggers}
setupTriggerUuid={props.setupTriggerUuid}
repoDetails={props.repoDetails}
/>