blob: a0e63a90d42647e0519dc85926b3e6168eda7b1c [file] [log] [blame]
/**
* @module modules/commits-table-sk
* @description An element that displays task and commit data for Status.
*
* @property displayCommitSubject - Render truncated commit subjects, rather than authors in the
* table.
* @property filter - One of 'interesting', 'all', 'failure', 'nocomment', 'comments, or
* 'search'. To filter taskSpecs displayed.
* @property search - a regex string with which to filter taskSpecs against for
* display. Only used if filter == 'search'.
*/
import { $, $$ } from 'common-sk/modules/dom';
import { define } from 'elements-sk/define';
import { html, TemplateResult } from 'lit-html';
import { styleMap } from 'lit-html/directives/style-map';
import { classMap } from 'lit-html/directives/class-map';
import { ElementSk } from '../../../infra-sk/modules/ElementSk';
import 'elements-sk/radio-sk';
import 'elements-sk/select-sk';
import 'elements-sk/icon/block-icon-sk';
import 'elements-sk/icon/comment-icon-sk';
import 'elements-sk/icon/help-icon-sk';
import 'elements-sk/icon/redo-icon-sk';
import 'elements-sk/icon/texture-icon-sk';
import 'elements-sk/icon/undo-icon-sk';
import 'elements-sk/styles/select';
import '../details-dialog-sk';
import {
Branch,
Comment,
GetIncrementalCommitsRequest,
GetIncrementalCommitsResponse,
IncrementalUpdate,
LongCommit,
StatusService,
Task,
} from '../rpc/status';
import { DetailsDialogSk } from '../details-dialog-sk/details-dialog-sk';
import { errorMessage } from 'elements-sk/errorMessage';
import { truncateWithEllipses } from '../../../golden/modules/common';
import { GetStatusService } from '../rpc';
import { defaultRepo, repos } from '../settings';
const CONTROL_START_ROW = 1;
const CATEGORY_START_ROW = CONTROL_START_ROW + 1;
const SUBCATEGORY_START_ROW = CATEGORY_START_ROW + 1;
const TASKSPEC_START_ROW = SUBCATEGORY_START_ROW + 1;
const BRANCH_START_COL = 1;
const COMMIT_START_COL = BRANCH_START_COL + 1;
const TASK_START_COL = COMMIT_START_COL + 1;
const REVERT_HIGHLIGHT_CLASS = 'highlight-revert';
const RELAND_HIGHLIGHT_CLASS = 'highlight-reland';
const VALID_TASK_SPEC_CATEGORIES = ['Build', 'Housekeeper', 'Infra', 'Perf', 'Test', 'Upload'];
const TASK_STATUS_SUCCESS = 'SUCCESS';
const TASK_STATUS_FAILURE = 'FAILURE';
const TASK_STATUS_MISHAP = 'MISHAP';
// Makes some maps more self-documenting.
export type CommitHash = string;
export type TaskSpec = string;
export type TaskId = string;
// Commit with added metadata we compute that aid in displaying and associating it with other data.
export interface Commit extends LongCommit {
shortAuthor: string;
shortHash: string;
shortSubject: string;
issue: string;
patchStorage: string;
isRevert: boolean;
isReland: boolean;
ignoreFailure: boolean;
}
// Describes the subcategories and taskspecs within a category.
export class CategorySpec {
taskSpecsBySubCategory: Map<string, Array<TaskSpec>> = new Map();
// Sum of the above taskspec array value lengths.
colspan: number = 0;
}
// Generated data to descrbie a taskspec and the tasks within it.
export class TaskSpecDetails {
name: string = '';
comments: Array<Comment> = [];
category: string = '';
subcategory: string = '';
flaky: boolean = false;
ignoreFailure: boolean = false;
// Metadata about the set of tasks in this spec.
hasSuccess = false;
hasFailure = false;
hasTaskComment = false;
interesting(): boolean {
return this.hasSuccess && this.hasFailure && !this.ignoreFailure;
}
hasFailing(): boolean {
return this.hasFailure;
}
hasFailingNoComment(): boolean {
return this.hasFailure && (!this.comments || this.comments.length == 0);
}
hasComment(): boolean {
return this.hasTaskComment || (this.comments && this.comments.length > 0);
}
}
type Filter = 'Interesting' | 'Failures' | 'All' | 'Nocomment' | 'Comments' | 'Search';
interface FilterInfo {
text: string;
title: string;
}
const FILTER_INFO: Map<Filter, FilterInfo> = new Map([
[
'Interesting',
{
text: 'Interesting',
title: 'Tasks which have both successes and failures within the visible commit window.',
},
],
[
'Failures',
{
text: 'Failures',
title: 'Tasks which have failures within the visible commit window.',
},
],
[
'Comments',
{
text: 'Comments',
title: 'Tasks which have comments.',
},
],
[
'Nocomment',
{
text: 'Failing w/o comment',
title: 'Tasks which have failures within the visible commit window but have no comments.',
},
],
[
'All',
{
text: 'All',
title: 'Display all tasks.',
},
],
[
'Search',
{
text: ' ',
title:
'Enter a search string. Substrings and regular expressions may be used, per the Javascript String match() rules.',
},
],
]);
// An internal class used to keep the fetching and preprocessing of data untangled from the logic
// to render the table itself.
class Data {
// Outputs - Data to be used by the CommitsTableSk class. Derived from calling the
// GetIncrementalCommits API.
commits: Array<Commit> = []; // Commits in reverse chronoligcal order.
commitsByHash: Map<CommitHash, Commit> = new Map();
branchHeads: Array<Branch> = [];
tasks: Map<TaskId, Task> = new Map();
tasksBySpec: Map<TaskSpec, Map<TaskId, Task>> = new Map();
tasksByCommit: Map<CommitHash, Map<TaskSpec, Task>> = new Map();
comments: Map<CommitHash, Map<TaskSpec, Array<Comment>>> = new Map();
revertedMap: Map<CommitHash, Commit> = new Map();
relandedMap: Map<CommitHash, Commit> = new Map();
taskSpecs: Map<TaskSpec, TaskSpecDetails> = new Map();
categories: Map<string, CategorySpec> = new Map();
// Internal state.
private serverPodId: string = '';
private client: StatusService = GetStatusService();
// Used to detect changes of these values between calls, to know when to load from scratch.
private repo: string = '';
private numCommits: number = -1;
update(repo: string, numCommits: number, lastLoaded?: Date) {
const req: GetIncrementalCommitsRequest = {
n: numCommits,
pod: this.serverPodId,
repoPath: repo,
};
if (lastLoaded && repo === this.repo && numCommits === this.numCommits) {
// We incrementally update if this is the same repo and numCommits as the
// previous call, and we have a starting point.
req.from = lastLoaded.toISOString();
}
this.repo = repo;
this.numCommits = numCommits;
return this.client
.getIncrementalCommits(req)
.then((json: GetIncrementalCommitsResponse) => {
if (json.metadata!.startOver) {
this.clearData();
}
this.serverPodId = json.metadata!.pod;
this.extractData(json.update!);
// We clear this derived data, as it may have changed with incremental updates.
this.taskSpecs = new Map();
this.categories = new Map();
this.processCommits();
})
.catch(errorMessage);
}
private clearData() {
this.commits = [];
this.commitsByHash = new Map();
this.branchHeads = [];
this.tasks = new Map();
this.tasksBySpec = new Map();
this.tasksByCommit = new Map();
this.comments = new Map();
this.revertedMap = new Map();
this.relandedMap = new Map();
this.taskSpecs = new Map();
this.categories = new Map();
}
/**
* extractData takes data from GetIncrementalCommits and adds useful structure, mapping commits
* by hash, tasks by Id, commits to tasks, and comments by hash and taskSpec.
* @param update Data from the backend.
*/
private extractData(update: IncrementalUpdate) {
const newCommits = (update.commits || []) as Array<Commit>;
const sliceIdx = this.numCommits - newCommits.length;
const keep = this.commits.slice(0, sliceIdx);
const remove = this.commits.slice(sliceIdx, this.commits.length);
this.commits = newCommits.concat(keep);
this.branchHeads = update.branchHeads || this.branchHeads;
// Map commits by hash.
this.commits.forEach((commit: Commit) => {
this.commitsByHash.set(commit.hash, commit);
});
// Map task Id to Task
for (const task of update.tasks || []) {
this.tasks.set(task.id, task);
}
// Remove too-old tasks.
for (let commit of remove) {
this.tasksByCommit.delete(commit.hash);
for (let [id, task] of this.tasks) {
if (task.revision == commit.hash) {
this.tasks.delete(id);
}
}
}
// Map commits to tasks
for (const [, task] of this.tasks) {
if (task.commits) {
for (let commit of task.commits) {
let tasksForCommit = this.tasksByCommit.get(commit);
if (!tasksForCommit) {
tasksForCommit = new Map();
this.tasksByCommit.set(commit, tasksForCommit);
}
tasksForCommit.set(task.name, task);
}
}
}
// TODO(westont): Remove deleted comments. This is broken at the backend already delete
// comments are only deleted in incremental updates if there is another comment in the
// same category(e.g. A commit comment won't be recognized as deleted unless another
// commit comment exists somewhere, to populate the commit_comments update field.)
// For now this just means deleted comments aren't conveyed to clients until they or the
// backend forces a full update.
// Map comments.
for (let comment of update.comments || []) {
comment.taskSpecName = comment.taskSpecName || '';
comment.commit = comment.commit || '';
const commentsBySpec = lookupOrInsert<string, Map<TaskSpec, Array<Comment>>>(
this.comments,
comment.commit,
Map
);
const comments = lookupOrInsert<TaskSpec, Array<Comment>>(
commentsBySpec,
comment.taskSpecName,
Array
);
comments.push(comment);
// Keep comments sorted by timestamp, if there are multiple.
comments.sort((a: Comment, b: Comment) => Number(a.timestamp) - Number(b.timestamp));
}
}
/**
* processCommits adds metadata to commit objects, maps reverts and relands, and gathers
* taskspecs references by commits.
*/
private processCommits() {
for (let commit of this.commits) {
// Metadata for display/links.
commit.shortAuthor = shortAuthor(commit.author);
commit.shortHash = shortCommit(commit.hash);
commit.shortSubject = shortSubject(commit.subject);
[commit.issue, commit.patchStorage] = findIssueAndReviewTool(commit);
this.mapRevertsAndRelands(commit);
// Check for commit-specific comments with ignoreFailure.
const commitComments = this.comments.get(commit.hash)?.get('');
if (
commitComments &&
commitComments.length &&
commitComments[commitComments.length - 1].ignoreFailure
) {
commit.ignoreFailure = true;
}
const commitTasks = this.tasksByCommit.get(commit.hash) || [];
this.processCommitTasks(commitTasks, commit);
// TODO(westont): Branch tags and time offset tags.
}
}
private processCommitTasks(commitTasks: Map<string, Task> | never[], commit: Commit) {
for (const [taskSpec, task] of commitTasks) {
const details = lookupOrInsert(this.taskSpecs, taskSpec, TaskSpecDetails);
// First time seeing the taskSpec, fill in header data.
if (!details.name) {
this.fillTaskSpecDetails(details, taskSpec);
}
// Aggregate data about this spec's tasks.
details.hasSuccess = details.hasSuccess || task.status == TASK_STATUS_SUCCESS;
// Only count failures we aren't ignoring.
details.hasFailure =
details.hasFailure ||
(!commit.ignoreFailure &&
(task.status == TASK_STATUS_FAILURE || task.status == TASK_STATUS_MISHAP));
details.hasTaskComment =
details.hasTaskComment || (this.comments.get(commit.hash)?.get(taskSpec)?.length || 0) > 0;
// TODO(westont): Track purple tasks.
}
}
private fillTaskSpecDetails(details: TaskSpecDetails, taskSpec: string) {
details.name = taskSpec;
const comments = this.comments.get('')?.get(taskSpec) || [];
details.comments = comments;
const split = taskSpec.split('-');
if (split.length >= 2 && VALID_TASK_SPEC_CATEGORIES.indexOf(split[0]) != -1) {
details.category = split[0];
details.subcategory = split[1];
}
if (comments.length > 0) {
details.flaky = comments[comments.length - 1].flaky;
details.ignoreFailure = comments[comments.length - 1].ignoreFailure;
}
const category = details.category || 'Other';
const categoryDetails = lookupOrInsert(this.categories, category, CategorySpec);
const subcategory = details.subcategory || 'Other';
lookupOrInsert<string, Array<string>>(
categoryDetails.taskSpecsBySubCategory,
subcategory,
Array
).push(taskSpec);
categoryDetails.colspan++;
}
private mapRevertsAndRelands(commit: Commit) {
commit.isRevert = false;
var reverted = findRevertedCommit(this.commitsByHash, commit);
if (reverted) {
commit.isRevert = true;
this.revertedMap.set(reverted.hash, commit);
reverted.ignoreFailure = true;
}
commit.isReland = false;
var relanded = findRelandedCommit(this.commitsByHash, commit);
if (relanded) {
commit.isReland = true;
this.relandedMap.set(relanded.hash, commit);
}
}
}
/**
* RequestLimiter is a helper class that manages keeping a single async call live at once.
* Async calls should only be triggered if a call to beginUpdate returns true. After async calls
* resolve, call endUpdate, if it returns true, one or more calls to beginUpdate occured before
* the initial call resolved, these can honored by retriggering the async call.
*/
class RequestLimiter {
private awaitingResponse: boolean = false;
private updateRequested: boolean = true;
// beginUpdate returns true if a request should be sent.
beginUpdate(): boolean {
if (this.awaitingResponse) {
this.updateRequested = true;
return false;
}
this.awaitingResponse = true;
return true;
}
// endUpdate returns true if multiple beginUpdate calls have occured consecutively (without
// paired finishUpdate calls).
endUpdate(): boolean {
this.awaitingResponse = false;
if (this.updateRequested) {
this.updateRequested = false;
return true;
}
return false;
}
}
export class CommitsTableSk extends ElementSk {
private _displayCommitSubject: boolean = false;
private _filter: Filter = 'Interesting';
private _search: RegExp = new RegExp('');
private lastLoaded?: Date;
private lastColumn: number = 1;
private refreshHandle?: number;
private requestLimiter: RequestLimiter = new RequestLimiter();
private data: Data = new Data();
private static template = (el: CommitsTableSk) => html`<div class="commitsTableContainer">
<div
class="legend"
style=${el.gridLocation(CATEGORY_START_ROW, COMMIT_START_COL, TASKSPEC_START_ROW + 1)}
>
<comment-icon-sk class="tiny"></comment-icon-sk>Comments<br />
<texture-icon-sk class="tiny"></texture-icon-sk>Flaky<br />
<block-icon-sk class="tiny"></block-icon-sk>Ignore Failure<br />
<undo-icon-sk class="tiny fill-red"></undo-icon-sk>Revert<br />
<redo-icon-sk class="tiny fill-green"></redo-icon-sk>Reland<br />
</div>
<div class="tasksTable">${el.fillTableTemplate()}</div>
<div
class="reloadControls"
style=${el.gridLocation(CONTROL_START_ROW, BRANCH_START_COL, TASKSPEC_START_ROW + 1)}
>
<div id="repoContainer">
<div id="repoLabel">Repo:</div>
<select id="repoSelector" @change=${() => el.update()}>
${repos().map((r) => html`<option value=${r}>${r}</option>`)}
</select>
</div>
<div class="refresh">
<input-sk
type="number"
textPrefix="Reload (s):&nbsp"
id="reloadInput"
@change=${() => el.update()}
></input-sk>
<input-sk
type="number"
textPrefix="Commits:&nbsp&nbsp&nbsp"
id="commitsInput"
@change=${() => el.update()}
>
</input-sk>
<div class="lastLoaded">
${el.lastLoaded ? `Loaded ${el.lastLoaded.toLocaleTimeString()}` : '(Not yet loaded)'}
</div>
</div>
</div>
<div
class="controls"
style=${el.gridLocation(
CONTROL_START_ROW,
COMMIT_START_COL,
CONTROL_START_ROW + 1,
// We render this after the table so we know our last column.
el.lastColumn
)}
>
<div class="horizontal">
<div class="commitLabelSelector">
${['Author', 'Subject'].map(
(label, i) => html` <radio-sk
class="tiny"
label=${label}
name="commitLabel"
?checked=${!!i === el.displayCommitSubject}
@change=${el.toggleCommitLabel}
></radio-sk>`
)}
</div>
<div class="horizontal">
${Array.from(FILTER_INFO).map(
([filter, info]) => html` <label class="specFilter" title=${info.title}>
<radio-sk
class="tiny"
label=""
id=${`${filter}Filter`}
name="specFilter"
?checked=${el._filter === filter}
@change=${() => (el.filter = filter)}
>
</radio-sk>
<span>
${info.text}
${filter !== 'Search'
? // For Search, we put the help icon after the search input.
html`<help-icon-sk class="tiny"></help-icon-sk>`
: html``}
</span>
</label>`
)}
<input-sk label="Filter task spec" @change=${el.searchFilter}> </input-sk>
<help-icon-sk class="tiny"></help-icon-sk>
</div>
</div>
</div>
<details-dialog-sk .repo=${el.repo}></details-dialog-sk>
</div>`;
constructor() {
super(CommitsTableSk.template);
}
connectedCallback() {
super.connectedCallback();
document.addEventListener('click', this.onClick);
this._render();
// input-sk value is backed by its <inputs>'s value directly, so set after render.
(<HTMLInputElement>$$('#reloadInput', this)).value = '60';
(<HTMLInputElement>$$('#commitsInput', this)).value = '35';
(<HTMLSelectElement>$$('#repoSelector', this)).value = defaultRepo();
this.update();
}
disconnectedCallback() {
document.removeEventListener('click', this.onClick);
}
get displayCommitSubject() {
return this._displayCommitSubject;
}
set displayCommitSubject(v: boolean) {
this._displayCommitSubject = v;
$('.commit').forEach((el, i) => {
if (v) {
el.innerHTML = this.data.commits[i].shortSubject;
el.setAttribute('title', this.data.commits[i].shortAuthor);
} else {
el.innerHTML = this.data.commits[i].shortAuthor;
el.setAttribute('title', this.data.commits[i].shortSubject);
}
});
}
get filter(): Filter {
return this._filter;
}
set filter(v: Filter) {
this._filter = v;
this.draw();
}
get search(): string {
return this._search.toString();
}
set search(v: string) {
this._search = new RegExp(v, 'i');
this.draw();
}
get repo(): string {
return ($$('#repoSelector', this) as HTMLSelectElement)?.value || defaultRepo();
}
private searchFilter(e: Event) {
this._filter = 'Search'; // Use the private member to avoid double-render
this.search = (<HTMLInputElement>e.target).value;
}
// Arrow notation to allow for reference of same function in removeEventListener.
private onClick = (event: Event) => {
const target = event.target as HTMLElement;
const dialog = $$('details-dialog-sk', this) as DetailsDialogSk;
if (target.classList.contains('task-spec')) {
const spec = target.getAttribute('title') || '';
const comments = this.data.taskSpecs.get(spec)?.comments!;
if (spec !== '' && comments !== undefined) {
dialog.displayTaskSpec(spec, comments);
}
} else if (target.classList.contains('commit')) {
const commit = this.data.commits[Number(target.dataset.commitIndex)]!;
const comments = this.data.comments.get(commit.hash)?.get('') || [];
dialog.displayCommit(commit, comments);
} else if (target.hasAttribute('data-task-id')) {
const task = this.data.tasks.get(target.dataset.taskId!)!;
const comments = this.data.comments.get(task.revision)?.get(task.name) || [];
dialog.displayTask(task, comments, this.data.commitsByHash);
} else {
dialog.close();
}
};
private toggleCommitLabel() {
this.displayCommitSubject = !this.displayCommitSubject;
}
/**
* gridLocation returns a lit StyleMap Part to inline on an element to place it between the
* provided css grid row and column tracks.
*/
private gridLocation(
rowStart: number,
colStart: number,
rowEnd: number = rowStart + 1,
colEnd: number = colStart + 1
) {
// RowStart / ColStart / RowEnd / ColEnd
return styleMap({ gridArea: `${rowStart} / ${colStart} / ${rowEnd} / ${colEnd}` });
}
/**
* includeTaskSpec checks the spec against the filter type currently set for the table and
* returns true if the taskspec should be displayed.
* @param taskSpec The taskSpec name to check against the filter.
*/
private includeTaskSpec(taskSpec: string): boolean {
const specDetails = this.data.taskSpecs.get(taskSpec);
if (!specDetails) {
return true;
}
switch (this._filter) {
case 'All':
return true;
case 'Comments':
return specDetails.hasComment();
case 'Nocomment':
return specDetails.hasFailingNoComment();
case 'Failures':
return specDetails.hasFailing();
case 'Interesting':
return specDetails.interesting();
case 'Search':
return this._search.test(taskSpec);
}
}
/**
* taskSpecIcons returns any needed comment related icons for a task spec.
* @param taskSpec The taskSpec to assess.
*/
private taskSpecIcons(taskSpec: string): Array<TemplateResult> {
const res: Array<TemplateResult> = [];
const task = this.data.taskSpecs.get(taskSpec)!;
if (task.comments.length > 0) {
res.push(html`<comment-icon-sk class="tiny"></comment-icon-sk>`);
}
if (task.flaky) {
res.push(html`<texture-icon-sk class="tiny"></texture-icon-sk>`);
}
if (task.ignoreFailure) {
res.push(html`<block-icon-sk class="tiny"></block-icon-sk>`);
}
return res;
}
/**
* taskIcon returns any needed comment icon for a task.
* @param task The task to assess.
*/
private taskIcon(task: Task): TemplateResult {
return task.commits?.every((c) => {
return !this.data.comments.get(c)?.get(task.name);
})
? html``
: html`<comment-icon-sk class="tiny"></comment-icon-sk>`;
}
/**
* commitIcons returns any needed comment, revert, and reland related icons for a commit.
* @param commit The commit to assess.
*/
private commitIcons(commit: Commit): Array<TemplateResult> {
const res: Array<TemplateResult> = [];
if (this.data.comments.get(commit.hash)?.get('')?.length || 0 > 0) {
res.push(html`<comment-icon-sk class="tiny icon-right"></comment-icon-sk>`);
}
if (commit.ignoreFailure) {
res.push(html`<block-icon-sk class="tiny icon-right"></block-icon-sk>`);
}
const reverted = this.data.revertedMap.get(commit.hash);
if (reverted && reverted.timestamp! > commit.timestamp!) {
res.push(html`<undo-icon-sk
class="tiny icon-right fill-red"
@mouseenter=${() => this.highlightAssociatedCommit(reverted.hash, true)}
@mouseleave=${() => this.highlightAssociatedCommit(reverted.hash, true)}
>
</undo-icon-sk>`);
}
const relanded = this.data.relandedMap.get(commit.hash);
if (relanded && relanded.timestamp! > commit.timestamp!) {
res.push(html`<redo-icon-sk
class="tiny icon-right fill-green"
@mouseenter=${() => this.highlightAssociatedCommit(relanded.hash, false)}
@mouseleave=${() => this.highlightAssociatedCommit(relanded.hash, false)}
>
</redo-icon-sk>`);
}
return res;
}
/**
* highlightAssociatedCommit toggles a class on the relevant commit's div when the mouse enters
* or leaves a revert or reland icon.
* @param hash Hash of the commit reverting/relanding this CL, which is also the commit div's id.
* @param revert Use the revert highlight class instead of reland highlight class.
*/
private highlightAssociatedCommit(hash: string, revert: boolean) {
$$(`.${this.attributeStringFromHash(hash)}`, this)?.classList.toggle(
revert ? REVERT_HIGHLIGHT_CLASS : RELAND_HIGHLIGHT_CLASS
);
}
/**
* attributeStringFromHash pads a hash with the string 'commit-' to avoid confusing JS with
* leading digits, which fail querySelector.
* @param hash The hash being padded.
*/
private attributeStringFromHash(hash: string) {
return `commit-${hash}`;
}
private addTaskHeaders(res: Array<TemplateResult>): Map<TaskSpec, number> {
const taskSpecStartCols: Map<TaskSpec, number> = new Map();
let categoryStartCol = TASK_START_COL;
// We walk category/subcategory/taskspec info 'depth-first' so filtered out taskspecs can
// correctly filter out unnecessary subcategories, etc.
this.data.categories.forEach((categoryDetails: CategorySpec, categoryName: string) => {
let subcategoryStartCol = categoryStartCol;
categoryDetails.taskSpecsBySubCategory.forEach(
(taskSpecs: Array<string>, subcategoryName: string) => {
let taskSpecStartCol = subcategoryStartCol;
taskSpecs
.filter((ts) => this.includeTaskSpec(ts))
.forEach((taskSpec: string) => {
taskSpecStartCols.set(taskSpec, taskSpecStartCol);
res.push(
html`<div
class="category task-spec"
style=${this.gridLocation(TASKSPEC_START_ROW, taskSpecStartCol++)}
title=${taskSpec}
>
${this.taskSpecIcons(taskSpec)}
</div>`
);
});
if (taskSpecStartCol != subcategoryStartCol) {
// Added at least one TaskSpec in this subcategory, so add a Subcategory header.
const subcategoryEndCol = taskSpecStartCol;
res.push(
html`<div
class="category"
style=${this.gridLocation(
SUBCATEGORY_START_ROW,
subcategoryStartCol,
SUBCATEGORY_START_ROW + 1,
subcategoryEndCol
)}
>
${subcategoryName}
</div>`
);
subcategoryStartCol = subcategoryEndCol;
}
}
);
if (subcategoryStartCol != categoryStartCol) {
// Added at least one Subcategory in this category, so add a Category header.
const categoryEndCol = subcategoryStartCol;
res.push(
html`<div
class="category"
style=${this.gridLocation(
CATEGORY_START_ROW,
categoryStartCol,
CATEGORY_START_ROW + 1,
categoryEndCol
)}
>
${categoryName}
</div>`
);
categoryStartCol = categoryEndCol;
}
});
return taskSpecStartCols;
}
private multiCommitTaskSlots(
displayTaskRows: Array<boolean>,
rowStart: number,
task: Task
): Array<TemplateResult> {
let currRow = rowStart;
// Convert the array of bools describing which slots are covered to an array of templates,
// where 'true's are styled, normal task divs that have dashed tops / bottoms when bordering
// 'false's, and 'false's are hidden divs.
// TODO(westont): Consider further optimizing for minimal divs for broken tasks
// (combine the contiguous rows).
return displayTaskRows.map((display, index) => {
let ret: TemplateResult = display
? html` <div
class=${taskClasses(task, ...this.getDashedBorderClasses(displayTaskRows, index))}
style=${this.gridLocation(currRow - rowStart + 1, 1)}
data-task-id=${task.id}
>
${index === 0 ? this.taskIcon(task) : ''}
</div>`
: // On holes we just drop a hidden div.
// TODO(westont): What if the other branch has jobs? Perhaps we should sort out
// styling for an empty template, or reduce z index.
html`<div
class="hidden ${taskClasses(task)}"
style=${this.gridLocation(currRow - rowStart + 1, 1)}
></div>`;
currRow++;
return ret;
});
}
private addTasks(
tasksBySpec: Map<string, Task>,
taskSpecStartCols: Map<string, number>,
rowStart: number,
commitIndex: number,
tasksAddedToTemplate: Set<string>,
res: Array<TemplateResult>
) {
if (tasksBySpec) {
tasksBySpec.forEach((task: Task, name: TaskSpec) => {
if (tasksAddedToTemplate.has(task.id)) {
// We already added this task since it also covered a later commit.
return;
}
const colStart = taskSpecStartCols.get(name);
if (!colStart) {
// This taskSpec wasn't added, must be filtered, skip it.
return;
}
// We mark tasks as added, since the first time we see multi-commit
// tasks we add them in their entirety.
tasksAddedToTemplate.add(task.id);
const displayTaskRows = this.displayTaskRows(task, commitIndex);
if (displayTaskRows.every(Boolean)) {
// The task bubble is contiguous, just draw a single div over that span.
res.push(
html`<div
class=${taskClasses(task, 'grow')}
style=${this.gridLocation(rowStart, colStart, rowStart + displayTaskRows.length)}
title=${taskTitle(task)}
data-task-id=${task.id}
>
${this.taskIcon(task)}
</div>`
);
} else {
// A commit on another branch interrupted the task, draw mutiple divs to represent the
// break. This looks like e.g. [true, false, true] for a task covering two
// commits that have a single branch commit between them.
res.push(
html`<div
class="multicommit-task grow"
style=${this.gridLocation(rowStart, colStart, rowStart + displayTaskRows.length)}
>
${this.multiCommitTaskSlots(displayTaskRows, rowStart, task)}
</div>`
);
}
});
}
}
/**
* fillTableTemplate returns an array of templates (containing headers, commits, tasks, etc),
* each styled with 'grid-area' to place them inside a css-grid element, covering one or more
* cells.
*/
private fillTableTemplate(): Array<TemplateResult> {
// Elements, each styled to cover one or more cells of a css grid element.
// E.g.includes divs for commits and taskspecs that are single - row / column headings, but
// also divs for tasks that may cover multiple commits(rows) and divs for category headings
// that may span multiple columns.
const res: Array<TemplateResult> = [];
// Add headers and get grid column number of each TaskSpec.
const taskSpecStartCols: Map<TaskSpec, number> = this.addTaskHeaders(res);
// We use lastColumn to ensure our controls panel and row underlay covers all columns, always
// at least 1 more than the commits panel, even if we have no tasks displayed.
this.lastColumn = Math.max(taskSpecStartCols.size + TASK_START_COL, TASK_START_COL + 1);
const taskStartRow = TASKSPEC_START_ROW + 1;
const tasksAddedToTemplate: Set<TaskId> = new Set();
// Commits are ordered newest to oldest, so the first commit is visually near the top.
for (const [i, commit] of this.data.commits.entries()) {
const rowStart = taskStartRow + i;
const title = this.displayCommitSubject ? commit.shortAuthor : commit.shortSubject;
const text = !this.displayCommitSubject ? commit.shortAuthor : commit.shortSubject;
res.push(
html`<div
class="commit ${this.attributeStringFromHash(commit.hash)}"
style=${this.gridLocation(rowStart, COMMIT_START_COL)}
title=${title}
data-commit-index=${i}
>
${text}${this.commitIcons(commit)}
</div>`
);
const tasksBySpec = this.data.tasksByCommit.get(commit.hash);
if (tasksBySpec) {
this.addTasks(tasksBySpec, taskSpecStartCols, rowStart, i, tasksAddedToTemplate, res);
}
}
// Add a single div covering the grid, behind everything, that highlights alternate rows.
let row = taskStartRow;
const nextRowDiv = () => html` <div
style=${this.gridLocation(row, 1, ++row, this.lastColumn)}
></div>`;
res.push(html` <div class="rowUnderlay">
${Array(this.data.commits.length).fill(1).map(nextRowDiv)}
</div>`);
return res;
}
/**
* getDashedBorderClasses provides classes for styling the borders against 'gaps' where commits
* on different branches lie between commits covered by a task.
*
* @param displayTaskRows Value returned from displayTaskRows.
* @param index Index in displayTaskRows that we're assessing.
*/
private getDashedBorderClasses(displayTaskRows: Array<boolean>, index: number) {
const ret: Array<string> = [];
if (index > 0 && !displayTaskRows[index - 1]) {
ret.push('dashed-top');
}
if (index < displayTaskRows.length - 1 && !displayTaskRows[index + 1]) {
ret.push('dashed-bottom');
}
return ret;
}
/**
* displayTaskRows returns an array describing which of the next N commits the task covers.
* e.g. for Tasks covering contiguous commits (no commits on other branches), return will
* be [true, ...[true]]. For tasks covering commits with interstitial other-branch commits,
* return will be e.g. [true, false, true, true].
* @param task The task being assessed.
* @param latestCommitIndex: The index of the top/most recent commit covered by the task.
*/
private displayTaskRows(task: Task, latestCommitIndex: number) {
// Only a single commit, or the last shown commit, obviously contiguous.
if (task.commits!.length < 2 || latestCommitIndex >= this.data.commits.length - 1) {
return [true];
}
const thisTaskOverCommits: Array<boolean> = [true];
// Check for parental gaps. Commits may be sorted, but we don't assume that.
let displayCommitsCount = 1;
// We update this as we 'walk backward' through the commits this task covers.
let currentCommitInTask = this.data.commits[latestCommitIndex];
// Follow the ancestory up to the penultimate commit, since we look ahead by 1.
// Earlier here means visually below.
for (
let earlierCommitIndex = latestCommitIndex + 1;
earlierCommitIndex < this.data.commits.length;
earlierCommitIndex++
) {
// Exit if we know we've account for all commits in the task, to avoid an extra 'false' at
// the end of the returned array.
if (displayCommitsCount === task.commits!.length) break;
let earlierCommit = this.data.commits[earlierCommitIndex];
if (currentCommitInTask.parents!.indexOf(earlierCommit.hash) === -1) {
// Branch leaves a gap.
thisTaskOverCommits.push(false);
} else {
// This is expected to be true, since this task covers at least one more commit, and the
// next oldest commit is our current commits parent.
if (task.commits!.indexOf(earlierCommit.hash) !== -1) {
thisTaskOverCommits.push(true);
displayCommitsCount++;
currentCommitInTask = earlierCommit;
}
}
}
return thisTaskOverCommits;
}
private update() {
if (!this.requestLimiter.beginUpdate()) {
// There is already an outstanding request, we'll re-update once that resolves.
return;
}
const refreshSeconds = Number((<HTMLInputElement>$$('#reloadInput', this)).value);
const numCommits = Number((<HTMLInputElement>$$('#commitsInput', this)).value);
window.clearTimeout(this.refreshHandle);
this.refreshHandle = undefined;
this.dispatchEvent(new CustomEvent('begin-task', { bubbles: true }));
this.data.update(this.repo, numCommits, this.lastLoaded).finally(() => {
this.lastLoaded = new Date();
this.draw();
this.dispatchEvent(new CustomEvent('end-task', { bubbles: true }));
// If an additional update was requested, start it, otherwise schedule it.
if (this.requestLimiter.endUpdate()) {
this.update();
} else {
this.refreshHandle = window.setTimeout(() => this.update(), refreshSeconds * 1000);
}
});
}
private draw() {
console.time('render');
this._render();
console.timeEnd('render');
}
}
define('commits-table-sk', CommitsTableSk);
function taskClasses(task: Task, ...classes: Array<string>) {
const map: Record<string, any> = { task: true };
map[`bg-${(task.status || 'PENDING').toLowerCase()}`] = true;
classes.forEach((c) => (map[c] = true));
return classMap(map);
}
function taskTitle(task: Task) {
return `${task.name} @${task.commits!.length > 1 ? '\n' : ' '}${task.commits!.join(',\n')}`;
}
// shortCommit returns the first 7 characters of a commit hash.
function shortCommit(commit: string): string {
return commit.substring(0, 7);
}
// shortAuthor shortens the commit author field by returning the
// parenthesized email address if it exists. If it does not exist, the
// entire author field is used.
function shortAuthor(author: string): string {
const re: RegExp = /.*\((.+)\)/;
const match = re.exec(author);
let res = author;
if (match) {
res = match[1];
}
return res.split('@')[0];
}
// shortSubject truncates a commit subject line to 72 characters if needed.
// If the text was shortened, the last three characters are replaced by
// ellipsis.
function shortSubject(subject: string): string {
return truncateWithEllipses(subject, 72);
}
// findIssueAndReviewTool returns [issue, patchStorage]. patchStorage will
// be either Gerrit or empty, and issue will be the CL number or empty.
// If an issue cannot be determined then an empty string is returned for
// both issue and patchStorage.
function findIssueAndReviewTool(commit: LongCommit): [string, string] {
// See if it is a Gerrit CL.
var gerritRE = /(.|[\r\n])*Reviewed-on:.*\/([0-9]*)/g;
var gerritTokens = gerritRE.exec(commit.body);
if (gerritTokens) {
return [gerritTokens[gerritTokens.length - 1], 'gerrit'];
}
// Could not find a CL number return an empty string.
return ['', ''];
}
// Find and return the commit which was reverted by the given commit.
function findRevertedCommit(commits: Map<string, Commit>, commit: Commit) {
const patt = new RegExp('^This reverts commit ([a-f0-9]+)');
const tokens = patt.exec(commit.body);
if (tokens) {
return commits.get(tokens[tokens.length - 1]);
}
return null;
}
// Find and return the commit which was relanded by the given commit.
function findRelandedCommit(commits: Map<string, Commit>, commit: Commit) {
// Relands can take one of two formats. The first is a "direct" reland.
const patt = new RegExp('^This is a reland of ([a-f0-9]+)');
const tokens = patt.exec(commit.body) as RegExpExecArray;
if (tokens) {
return commits.get(tokens[tokens.length - 1]);
}
// The second is a revert of a revert.
var revert = findRevertedCommit(commits, commit);
if (revert) {
return findRevertedCommit(commits, revert);
}
return null;
}
// Helper to get the value associated with a key, but default construct and
// insert it first if not present. Passing the type as a second arg is
// necessary since types are erased when transcribed to JS.
// Usage:
// const mymap: Map<string, Array<string>> = new Map();
// lookupOrInsert(mymap, 'foo', Array).push('bar')
function lookupOrInsert<K, V>(map: Map<K, V>, key: K, valuetype: { new (): V }): V {
let maybeValue = map.get(key);
if (!maybeValue) {
maybeValue = new valuetype();
map.set(key, maybeValue);
}
return maybeValue;
}