ui/job-view/pushes/Push.jsx (712 lines of code) (raw):
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import sortBy from 'lodash/sortBy';
import { Col } from 'reactstrap';
import {
sxsTaskName,
thEvents,
thOptionOrder,
thPlatformMap,
} from '../../helpers/constants';
import decompress from '../../helpers/gzip';
import { getGroupMapKey } from '../../helpers/aggregateId';
import {
getAllUrlParams,
getUrlParam,
setUrlParam,
} from '../../helpers/location';
import JobModel from '../../models/job';
import RunnableJobModel from '../../models/runnableJob';
import { getRevisionTitle } from '../../helpers/revision';
import { getPercentComplete } from '../../helpers/display';
import { notify } from '../redux/stores/notifications';
import {
updateJobMap,
recalculateUnclassifiedCounts,
} from '../redux/stores/pushes';
import {
checkRootUrl,
prodFirefoxRootUrl,
} from '../../taskcluster-auth-callback/constants';
import { RevisionList } from '../../shared/RevisionList';
import { Revision } from '../../shared/Revision';
import PushHealthSummary from '../../shared/PushHealthSummary';
import { getTaskRunStr } from '../../helpers/job';
import FuzzyJobFinder from './FuzzyJobFinder';
import PushHeader from './PushHeader';
import PushJobs from './PushJobs';
const watchCycleStates = ['none', 'push', 'job', 'none'];
const platformArray = Object.values(thPlatformMap);
// Bug 1638424 - Transform WPT test paths to look like paths
// from a local checkout
export const transformTestPath = (path) => {
let newPath = path;
// WPT path transformations
if (path.startsWith('/_mozilla')) {
// /_mozilla/<path> => testing/web-platform/mozilla/tests/<path>
const modifiedPath = path.replace('/_mozilla', '');
newPath = `testing/web-platform/mozilla/tests${modifiedPath}`;
} else if (path.startsWith('/')) {
// /<path> => testing/web-platform/tests/<path>
newPath = `testing/web-platform/tests${path}`;
}
return newPath;
};
export const transformedPaths = (manifestsByTask) => {
const newManifestsByTask = {};
Object.keys(manifestsByTask).forEach((taskName) => {
newManifestsByTask[taskName] = manifestsByTask[taskName].map((testPath) =>
transformTestPath(testPath),
);
});
return newManifestsByTask;
};
const fetchGeckoDecisionArtifact = async (project, revision, filePath) => {
let artifactContents = {};
const rootUrl = prodFirefoxRootUrl;
const url = `${checkRootUrl(
rootUrl,
)}/api/index/v1/task/gecko.v2.${project}.revision.${revision}.taskgraph.decision/artifacts/public/${filePath}`;
const response = await fetch(url);
if (url.endsWith('.gz')) {
if ([200, 303, 304].includes(response.status)) {
const blob = await response.blob();
const binData = await blob.arrayBuffer();
artifactContents = await decompress(binData);
}
} else if (url.endsWith('.json')) {
if ([200, 303, 304].includes(response.status)) {
artifactContents = await response.json();
}
}
return artifactContents;
};
class Push extends React.PureComponent {
constructor(props) {
super(props);
const { push } = props;
const collapsedPushes = getUrlParam('collapsedPushes') || '';
this.state = {
fuzzyModal: false,
platforms: [],
jobList: [],
runnableVisible: false,
selectedRunnableJobs: [],
watched: 'none',
jobCounts: { pending: 0, running: 0, completed: 0, fixedByCommit: 0 },
pushGroupState: 'collapsed',
collapsed: collapsedPushes.includes(push.id),
filteredTryPush: false,
pushHealthStatus: null,
};
}
async componentDidMount() {
// if ``nojobs`` is on the query string, then don't load jobs.
// this allows someone to more quickly load ranges of revisions
// when they don't care about the specific jobs and results.
const allParams = getAllUrlParams();
if (!allParams.has('nojobs')) {
await this.fetchJobs();
}
if (allParams.has('test_paths')) {
await this.fetchTestManifests();
}
this.testForFilteredTry();
window.addEventListener(thEvents.applyNewJobs, this.handleApplyNewJobs);
}
componentDidUpdate(prevProps, prevState) {
this.showUpdateNotifications(prevState);
this.testForFilteredTry();
if (
prevProps.router.location.search !== this.props.router.location.search
) {
this.handleUrlChanges();
}
}
componentWillUnmount() {
window.removeEventListener(thEvents.applyNewJobs, this.handleApplyNewJobs);
}
getJobCount(jobList) {
const filteredByCommit = jobList.filter(
(job) => job.failure_classification_id === 2,
);
return jobList.reduce(
(memo, job) =>
job.result !== 'superseded'
? { ...memo, [job.state]: memo[job.state] + 1 }
: memo,
{
running: 0,
pending: 0,
completed: 0,
fixedByCommit: filteredByCommit.length,
},
);
}
getJobGroupInfo(job) {
const {
job_group_name: name,
job_group_symbol: jobGroupSymbol,
platform,
platform_option: platformOption,
tier,
push_id: pushId,
} = job;
const symbol = jobGroupSymbol === '?' ? '' : jobGroupSymbol;
const mapKey = getGroupMapKey(
pushId,
symbol,
tier,
platform,
platformOption,
);
return { name, tier, symbol, mapKey };
}
setSingleRevisionWindowTitle() {
const { allUnclassifiedFailureCount, currentRepo, push } = this.props;
const percentComplete = getPercentComplete(this.state.jobCounts);
const title = `[${allUnclassifiedFailureCount}] ${currentRepo.name}`;
document.title = `${percentComplete}% - ${title}: ${getRevisionTitle(
push.revisions,
)}`;
}
togglePushCollapsed = () => {
const { push } = this.props;
const pushId = `${push.id}`;
const collapsedPushesParam = getUrlParam('collapsedPushes');
const collapsedPushes = collapsedPushesParam
? new Set(collapsedPushesParam.split(','))
: new Set();
this.setState(
(prevState) => ({ collapsed: !prevState.collapsed }),
() => {
if (!this.state.collapsed) {
collapsedPushes.delete(pushId);
} else {
collapsedPushes.add(pushId);
}
setUrlParam(
'collapsedPushes',
collapsedPushes.size ? Array.from(collapsedPushes) : null,
);
},
);
};
testForFilteredTry = () => {
const { currentRepo } = this.props;
const filterParams = ['revision', 'author'];
const urlParams = getAllUrlParams();
const filteredTryPush =
filterParams.some((f) => urlParams.has(f)) && currentRepo.name === 'try';
this.setState({ filteredTryPush });
};
handleUrlChanges = async () => {
const { push } = this.props;
const allParams = getAllUrlParams();
const collapsedPushes = allParams.get('collapsedPushes') || '';
if (allParams.has('test_paths')) {
await this.fetchTestManifests();
} else {
this.setState({ manifestsByTask: {} });
}
this.setState({ collapsed: collapsedPushes.includes(push.id) });
};
handleApplyNewJobs = (event) => {
const { push } = this.props;
const { jobs } = event.detail;
const jobList = jobs[push.id];
if (jobList) {
this.mapPushJobs(jobList);
}
};
toggleSelectedRunnableJob = (signature) => {
const { selectedRunnableJobs } = this.state;
const jobIndex = selectedRunnableJobs.indexOf(signature);
if (jobIndex === -1) {
selectedRunnableJobs.push(signature);
} else {
selectedRunnableJobs.splice(jobIndex, 1);
}
this.setState({ selectedRunnableJobs: [...selectedRunnableJobs] });
return selectedRunnableJobs;
};
fetchTestManifests = async () => {
const { currentRepo, push } = this.props;
const manifestsByTask = await fetchGeckoDecisionArtifact(
currentRepo.name,
push.revision,
'manifests-by-task.json.gz',
);
// Call setState with callback to guarantee the state of manifestsByTask
// to be set since it is read within mapPushJobs and we might have a race
// condition. We are also reading jobList now rather than before fetching
// the artifact because it gives us an empty list
this.setState({ manifestsByTask: transformedPaths(manifestsByTask) }, () =>
this.mapPushJobs(this.state.jobList),
);
};
fetchJobs = async () => {
const { push, notify } = this.props;
const { data, failureStatus } = await JobModel.getList(
{
push_id: push.id,
},
{ fetchAll: true },
);
if (!failureStatus) {
this.mapPushJobs(data);
} else {
notify(failureStatus, 'danger', { sticky: true });
}
};
mapPushJobs = (jobs, skipJobMap) => {
const { updateJobMap, recalculateUnclassifiedCounts, push } = this.props;
const { manifestsByTask = {} } = this.state;
// whether or not we got any jobs for this push, the operation to fetch
// them has completed.
push.jobsLoaded = true;
if (jobs.length > 0) {
const { jobList } = this.state;
const newIds = jobs.map((job) => job.id);
// remove old versions of jobs we just fetched.
const existingJobs = jobList.filter((job) => !newIds.includes(job.id));
// Join both lists and add test_paths and task_run property
const newJobList = [...existingJobs, ...jobs].map((job) => {
if (Object.keys(manifestsByTask).length > 0) {
job.test_paths = manifestsByTask[job.job_type_name] || [];
}
job.task_run = getTaskRunStr(job);
return job;
});
const sideBySideJobs = newJobList.filter((sxsJob) =>
sxsJob.job_type_symbol.includes(sxsTaskName),
);
// If the pageload job has a side-by-side comparison associated
// add job.hasSideBySide containing sxsTaskName ("side-by-side")
newJobList.forEach((job) => {
if (job.job_type_name.includes('browsertime')) {
const matchingSxsJobs = sideBySideJobs.filter(
(sxsJob) =>
sxsJob.job_type_name.includes(
job.job_type_name.split('/opt-')[0],
) && // platform
sxsJob.job_type_name.includes(
job.job_type_name.split('/opt-')[1],
), // testName
);
if (matchingSxsJobs.length > 0) {
job.hasSideBySide = matchingSxsJobs[0].job_type_name;
} else {
job.hasSideBySide = false;
}
}
});
const platforms = this.sortGroupedJobs(
this.groupJobByPlatform(newJobList),
);
const jobCounts = this.getJobCount(newJobList);
this.setState({
platforms,
jobList: newJobList,
jobCounts,
});
if (!skipJobMap) {
updateJobMap(jobs);
}
recalculateUnclassifiedCounts();
}
};
/*
* Convert a flat list of jobs into a structure grouped by platform and job_group.
*/
groupJobByPlatform = (jobList) => {
const platforms = [];
if (jobList.length === 0) {
return platforms;
}
jobList.forEach((job) => {
// search for the right platform
const platformName = thPlatformMap[job.platform] || job.platform;
let platform = platforms.find(
(platform) =>
platformName === platform.name &&
job.platform_option === platform.option,
);
if (platform === undefined) {
platform = {
name: platformName,
option: job.platform_option,
groups: [],
};
platforms.push(platform);
}
const groupInfo = this.getJobGroupInfo(job);
// search for the right group
let group = platform.groups.find(
(group) =>
groupInfo.symbol === group.symbol && groupInfo.tier === group.tier,
);
if (group === undefined) {
group = { ...groupInfo, jobs: [] };
platform.groups.push(group);
}
group.jobs.push(job);
});
return platforms;
};
sortGroupedJobs = (platforms) => {
platforms.forEach((platform) => {
platform.groups.forEach((group) => {
group.jobs = sortBy(group.jobs, (job) =>
// Symbol could be something like 1, 2 or 3. Or A, B, C or R1, R2, R10.
// So this will pad the numeric portion with 0s like R001, R010, etc.
job.job_type_symbol.replace(/([\D]*)([\d]*)/g, (matcher, s1, s2) =>
s2 !== '' ? s1 + `00${s2}`.slice(-3) : matcher,
),
);
});
platform.groups.sort(
(a, b) => a.symbol.length + a.tier - b.symbol.length - b.tier,
);
});
platforms.sort(
(a, b) =>
platformArray.indexOf(a.name) * 100 +
(thOptionOrder[a.option] || 10) -
(platformArray.indexOf(b.name) * 100 + (thOptionOrder[b.option] || 10)),
);
return platforms;
};
expandAllPushGroups = (callback) => {
// This sets the group state once, then unsets it in the callback. This
// has the result of triggering an expand on all the groups, but then
// gives control back to each group to decide to expand or not.
this.setState({ pushGroupState: 'expanded' }, () => {
this.setState({ pushGroupState: 'collapsed' });
callback();
});
};
showUpdateNotifications = (prevState) => {
const { watched, jobCounts } = this.state;
const {
currentRepo,
notificationSupported,
push: { revision, id: pushId },
notify,
} = this.props;
if (
!notificationSupported ||
Notification.permission !== 'granted' ||
watched === 'none'
) {
return;
}
const lastCounts = prevState.jobCounts;
if (jobCounts) {
const lastUncompleted = lastCounts.pending + lastCounts.running;
const nextUncompleted = jobCounts.pending + jobCounts.running;
const lastCompleted = lastCounts.completed;
const nextCompleted = jobCounts.completed;
let message;
if (lastUncompleted > 0 && nextUncompleted === 0) {
message = 'Push completed';
this.setState({ watched: 'none' });
} else if (watched === 'job' && lastCompleted < nextCompleted) {
const completeCount = nextCompleted - lastCompleted;
message = `${completeCount} jobs completed`;
}
if (message) {
const notification = new Notification(message, {
body: `${currentRepo.name} rev ${revision.substring(0, 12)}`,
tag: pushId,
});
notification.onerror = (event) => {
notify(`${event.target.title}: ${event.target.body}`, 'danger');
};
notification.onclick = (event) => {
if (this.container) {
this.container.scrollIntoView();
event.target.close();
}
};
}
}
};
showRunnableJobs = async () => {
const { push, notify, decisionTaskMap, currentRepo } = this.props;
try {
const jobList = await RunnableJobModel.getList(currentRepo, {
decisionTask: decisionTaskMap[push.id],
push_id: push.id,
});
if (jobList.length === 0) {
notify('No new jobs available');
}
this.mapPushJobs(jobList, true);
this.setState({ runnableVisible: jobList.length > 0 });
} catch (error) {
notify(
`Error fetching runnable jobs: Failed to fetch task ID (${error})`,
'danger',
);
}
};
hideRunnableJobs = () => {
const { jobList } = this.state;
const newJobList = jobList.filter((job) => job.state !== 'runnable');
this.setState(
{
runnableVisible: false,
selectedRunnableJobs: [],
jobList: newJobList,
},
() => this.mapPushJobs(newJobList),
);
};
showFuzzyJobs = async () => {
const { push, currentRepo, notify, decisionTaskMap } = this.props;
const createRegExp = (str, opts) =>
new RegExp(str.raw[0].replace(/\s/gm, ''), opts || '');
const excludedJobNames = createRegExp`
(balrog|beetmover|bouncer-locations-firefox|build-docker-image|build-(.+)-nightly|
build-(.+)-upload-symbols|checksums|cron-bouncer|dmd|fetch|google-play-strings|
push-to-release|mar-signing|nightly|packages|release-bouncer|release-early|
release-final|release-secondary|release-snap|release-source|release-update|
repackage-l10n|repo-update|searchfox|sign-and-push|test-(.+)-devedition|
test-linux(32|64)(-asan|-pgo|-qr)?\/(opt|debug)-jittest|test-macosx64-ccov|
test-verify|test-windows10-64-ux|toolchain|upload-generated-sources)`;
try {
notify('Fetching runnable jobs... This could take a while...');
let fuzzyJobList = await RunnableJobModel.getList(currentRepo, {
decisionTask: decisionTaskMap[push.id],
});
fuzzyJobList = [
...new Set(
fuzzyJobList.map((job) => {
const obj = {};
obj.name = job.job_type_name;
obj.symbol = job.job_type_symbol;
obj.groupsymbol = job.job_group_symbol;
return obj;
}),
),
].sort((a, b) => (a.name > b.name ? 1 : -1));
const filteredFuzzyList = fuzzyJobList.filter(
(job) => job.name.search(excludedJobNames) < 0,
);
this.setState({
fuzzyJobList,
filteredFuzzyList,
});
this.toggleFuzzyModal();
} catch (error) {
notify(
`Error fetching runnable jobs: Failed to fetch task ID (${error})`,
'danger',
);
}
};
cycleWatchState = async () => {
const { notify } = this.props;
const { watched } = this.state;
if (!this.props.notificationSupported) {
return;
}
let next = watchCycleStates[watchCycleStates.indexOf(watched) + 1];
if (next !== 'none' && Notification.permission !== 'granted') {
const result = await Notification.requestPermission();
if (result === 'denied') {
notify('Notification permission denied', 'danger');
next = 'none';
}
}
this.setState({ watched: next });
};
toggleFuzzyModal = async () => {
this.setState((prevState) => ({
fuzzyModal: !prevState.fuzzyModal,
jobList: prevState.jobList,
}));
};
pushHealthStatusCallback = (pushHealthStatus) => {
this.setState({ pushHealthStatus });
};
render() {
const {
push,
currentRepo,
duplicateJobsVisible,
filterModel,
notificationSupported,
getAllShownJobs,
groupCountsExpanded,
isOnlyRevision,
pushHealthVisibility,
decisionTaskMap,
bugSummaryMap,
} = this.props;
const {
fuzzyJobList,
fuzzyModal,
filteredFuzzyList,
watched,
runnableVisible,
pushGroupState,
platforms,
jobCounts,
selectedRunnableJobs,
collapsed,
filteredTryPush,
pushHealthStatus,
} = this.state;
const {
id,
push_timestamp: pushTimestamp,
revision,
revisions,
revision_count: revisionCount,
author,
} = push;
const tipRevision = push.revisions[0];
const decisionTask = decisionTaskMap[push.id];
const decisionTaskId = decisionTask ? decisionTask.id : null;
const showPushHealthSummary =
filteredTryPush &&
(pushHealthVisibility === 'All' ||
currentRepo.name === pushHealthVisibility.toLowerCase());
if (isOnlyRevision) {
this.setSingleRevisionWindowTitle();
}
return (
<div
className="push"
data-testid={`push-${push.id}`}
ref={(ref) => {
this.container = ref;
}}
>
<FuzzyJobFinder
isOpen={fuzzyModal}
toggle={this.toggleFuzzyModal}
jobList={fuzzyJobList}
filteredJobList={filteredFuzzyList}
className="fuzzy-modal"
pushId={id}
decisionTaskId={decisionTaskId}
currentRepo={currentRepo}
/>
<PushHeader
push={push}
pushId={id}
pushTimestamp={pushTimestamp}
author={author}
revision={revision}
jobCounts={jobCounts}
watchState={watched}
currentRepo={currentRepo}
filterModel={filterModel}
runnableVisible={runnableVisible}
showRunnableJobs={this.showRunnableJobs}
hideRunnableJobs={this.hideRunnableJobs}
showFuzzyJobs={this.showFuzzyJobs}
cycleWatchState={this.cycleWatchState}
expandAllPushGroups={this.expandAllPushGroups}
collapsed={collapsed}
getAllShownJobs={getAllShownJobs}
selectedRunnableJobs={selectedRunnableJobs}
notificationSupported={notificationSupported}
pushHealthVisibility={pushHealthVisibility}
groupCountsExpanded={groupCountsExpanded}
pushHealthStatusCallback={this.pushHealthStatusCallback}
togglePushCollapsed={this.togglePushCollapsed}
/>
<div className="push-body-divider" />
{!collapsed ? (
<div className="row push clearfix">
{currentRepo && (
<Col className="col-5">
<RevisionList
revision={revision}
revisions={revisions}
revisionCount={revisionCount}
repo={currentRepo}
bugSummaryMap={bugSummaryMap}
widthClass="ml-4 mb-3"
commitShaClass="text-monospace"
>
{showPushHealthSummary && pushHealthStatus && (
<div className="mt-4">
<PushHealthSummary
healthStatus={pushHealthStatus}
revision={revision}
repoName={currentRepo.name}
/>
</div>
)}
</RevisionList>
</Col>
)}
<span className="job-list job-list-pad col-7">
<PushJobs
push={push}
platforms={platforms}
repoName={currentRepo.name}
filterModel={filterModel}
pushGroupState={pushGroupState}
toggleSelectedRunnableJob={this.toggleSelectedRunnableJob}
runnableVisible={runnableVisible}
duplicateJobsVisible={duplicateJobsVisible}
groupCountsExpanded={groupCountsExpanded}
/>
</span>
</div>
) : (
<span className="row push revision-list col-12">
<ul className="list-unstyled">
<Revision
revision={tipRevision}
repo={currentRepo}
key={tipRevision.revision}
commitShaClass="text-monospace"
/>
</ul>
</span>
)}
</div>
);
}
}
Push.propTypes = {
push: PropTypes.shape({}).isRequired,
currentRepo: PropTypes.shape({
name: PropTypes.string,
}).isRequired,
filterModel: PropTypes.shape({}).isRequired,
notificationSupported: PropTypes.bool.isRequired,
getAllShownJobs: PropTypes.func.isRequired,
updateJobMap: PropTypes.func.isRequired,
recalculateUnclassifiedCounts: PropTypes.func.isRequired,
allUnclassifiedFailureCount: PropTypes.number.isRequired,
duplicateJobsVisible: PropTypes.bool.isRequired,
groupCountsExpanded: PropTypes.bool.isRequired,
notify: PropTypes.func.isRequired,
isOnlyRevision: PropTypes.bool.isRequired,
pushHealthVisibility: PropTypes.string.isRequired,
decisionTaskMap: PropTypes.shape({}).isRequired,
bugSummaryMap: PropTypes.shape({}).isRequired,
};
const mapStateToProps = ({
pushes: { allUnclassifiedFailureCount, decisionTaskMap, bugSummaryMap },
router,
}) => ({
allUnclassifiedFailureCount,
decisionTaskMap,
bugSummaryMap,
router,
});
export default connect(mapStateToProps, {
notify,
updateJobMap,
recalculateUnclassifiedCounts,
})(Push);