| /** |
| * @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'. |
| * |
| * @event repo-changed - Occurs when user selects a repo. Event has {detail: '<new repo>'} |
| */ |
| |
| import { html, TemplateResult } from 'lit/html.js'; |
| import { styleMap } from 'lit/directives/style-map.js'; |
| import { classMap } from 'lit/directives/class-map.js'; |
| import { $, $$, DomReady } from '../../../infra-sk/modules/dom'; |
| import { fromObject } from '../../../infra-sk/modules/query'; |
| import { stateReflector } from '../../../infra-sk/modules/stateReflector'; |
| import { HintableObject } from '../../../infra-sk/modules/hintable'; |
| import { define } from '../../../elements-sk/modules/define'; |
| import { errorMessage } from '../../../elements-sk/modules/errorMessage'; |
| import { ElementSk } from '../../../infra-sk/modules/ElementSk'; |
| import { Commit } from '../util'; |
| |
| import '../../../elements-sk/modules/radio-sk'; |
| import '../../../elements-sk/modules/tabs-sk'; |
| import '../../../elements-sk/modules/select-sk'; |
| import '../../../elements-sk/modules/icons/add-icon-sk'; |
| import '../../../elements-sk/modules/icons/autorenew-icon-sk'; |
| import '../../../elements-sk/modules/icons/block-icon-sk'; |
| import '../../../elements-sk/modules/icons/comment-icon-sk'; |
| import '../../../elements-sk/modules/icons/help-icon-sk'; |
| import '../../../elements-sk/modules/icons/redo-icon-sk'; |
| import '../../../elements-sk/modules/icons/texture-icon-sk'; |
| import '../../../elements-sk/modules/icons/undo-icon-sk'; |
| import '../branches-sk'; |
| 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 { GetStatusService } from '../rpc'; |
| import { BranchesSk } from '../branches-sk/branches-sk'; |
| import { defaultRepo, repos, taskSchedulerUrl } from '../settings'; |
| import { truncate } from '../../../infra-sk/modules/string'; |
| |
| 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 COMMIT_START_ROW = TASKSPEC_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 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; |
| |
| // 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 describe 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; |
| |
| // Like the above, but more restrictive - has failure not associated with |
| // a commit with 'ignore failure' comment. |
| hasNonIgnoredFailure = false; |
| |
| hasTaskComment = false; |
| |
| interesting(): boolean { |
| return this.hasSuccess && this.hasNonIgnoredFailure && !this.ignoreFailure; |
| } |
| |
| hasFailing(): boolean { |
| return this.hasFailure; |
| } |
| |
| hasFailingNoComment(): boolean { |
| return this.hasNonIgnoredFailure && (!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.', |
| }, |
| ], |
| ]); |
| // Used to translate tabs-sk event indices to Filters. |
| const FILTER_INDEX = Array.from(FILTER_INFO).map(([filter, _]) => filter); |
| |
| // 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(); |
| |
| private _lastLoaded?: Date; |
| |
| // Used to detect changes of these values between calls, to know when to load from scratch. |
| private repo: string = ''; |
| |
| private numCommits: number = -1; |
| |
| get lastLoaded() { |
| return this._lastLoaded; |
| } |
| |
| update(repo: string, numCommits: number) { |
| const req: GetIncrementalCommitsRequest = { |
| n: numCommits, |
| pod: this.serverPodId, |
| repoPath: repo, |
| }; |
| if (this.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 = this.lastLoaded.toISOString(); |
| } |
| this.repo = repo; |
| this.numCommits = numCommits; |
| // Date.now to allow mocking of time. Take time before the server gets the request to make sure |
| // we don't miss commits, but only set it if the request was successful. |
| const reqTime = new Date(Date.now()); |
| return this.client |
| .getIncrementalCommits(req) |
| .then((json: GetIncrementalCommitsResponse) => { |
| this._lastLoaded = reqTime; |
| 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>).filter( |
| // In a pathological case, a commit that the backend becomes aware of between when the client |
| // calculates 'from' and when the backend gets the client's request, could end up being sent |
| // twice. Dedup it. |
| (commit) => !this.commitsByHash.has(commit.hash) |
| ); |
| 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); |
| |
| if (update.branchHeads && update.branchHeads.length > 0) { |
| this.branchHeads = update.branchHeads; |
| } |
| |
| // Map commits by hash. |
| this.commits.forEach((commit: Commit) => { |
| this.commitsByHash.set(commit.hash, commit); |
| }); |
| // Sorting the array of commits uses commitsByHash, so validate after loading the map. |
| this.sortCommits(); |
| |
| // Map task Id to Task |
| for (const task of update.tasks || []) { |
| this.tasks.set(task.id, task); |
| } |
| |
| // Remove too-old tasks. |
| for (const commit of remove) { |
| this.tasksByCommit.delete(commit.hash); |
| for (const [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 (const 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 (const 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)); |
| } |
| } |
| |
| // Returns true if c is a child of possibleAncestor. |
| private childOf(c: Commit | undefined, possibleAncestor: Commit) { |
| let curr = c; |
| while (curr) { |
| if (curr.parents!.includes(possibleAncestor.hash)) { |
| return true; |
| } |
| const parentHash = curr.parents!.length > 0 ? curr.parents![0] : ''; |
| curr = this.commitsByHash.get(parentHash); |
| } |
| return false; |
| } |
| |
| private sortCommits() { |
| this.commits.sort((a, b) => { |
| const diff = new Date(b.timestamp!).valueOf() - new Date(a.timestamp!).valueOf(); |
| if (diff !== 0) { |
| return diff; |
| } |
| // Timestamps are the same, attempt to sort by lineage. |
| if (this.childOf(a, b)) { |
| return -1; |
| } |
| return 1; |
| }); |
| } |
| |
| /** |
| * processCommits adds metadata to commit objects, maps reverts and relands, and gathers |
| * taskspecs references by commits. |
| */ |
| private processCommits() { |
| for (const 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; |
| // 'Interesting' looks for non-ignored failures, 'Failures' looks for any failure. |
| details.hasNonIgnoredFailure = |
| details.hasNonIgnoredFailure || |
| (!commit.ignoreFailure && |
| (task.status === TASK_STATUS_FAILURE || task.status === TASK_STATUS_MISHAP)); |
| details.hasFailure = |
| details.hasFailure || |
| task.status === TASK_STATUS_FAILURE || |
| task.status === TASK_STATUS_MISHAP; |
| details.hasTaskComment = |
| details.hasTaskComment || (this.comments.get(commit.hash)?.get(taskSpec)?.length || 0) > 0; |
| } |
| } |
| |
| 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) { |
| 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; |
| const reverted = findRevertedCommit(this.commitsByHash, commit); |
| if (reverted) { |
| commit.isRevert = true; |
| this.revertedMap.set(reverted.hash, commit); |
| reverted.ignoreFailure = true; |
| } |
| commit.isReland = false; |
| const 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 = false; |
| |
| // 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; |
| } |
| } |
| |
| class State { |
| filter: Filter = 'Interesting'; |
| |
| search: string = ''; |
| |
| displayCommitSubject: boolean = false; |
| |
| repo: string = defaultRepo(); |
| } |
| |
| export class CommitsTableSk extends ElementSk { |
| private _displayCommitSubject: boolean = false; |
| |
| private _filter: Filter = 'Interesting'; |
| |
| private _search: string = ''; |
| |
| private _repo: string = defaultRepo(); |
| |
| private lastColumn: number = 1; |
| |
| private mishapTasks: Array<Task> = []; |
| |
| private refreshHandle?: number; |
| |
| private requestLimiter: RequestLimiter = new RequestLimiter(); |
| |
| private stateHasChanged: () => void = () => {}; |
| |
| private updatesRunning = false; |
| |
| 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, COMMIT_START_ROW)}> |
| <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, COMMIT_START_ROW)}> |
| <div id="repoContainer"> |
| <div id="repoLabel">Repo:</div> |
| <select |
| id="repoSelector" |
| @change=${(e: Event) => { |
| el.repo = (e.target as any).value; |
| }}> |
| ${repos().map((r) => html`<option value=${r}>${r}</option>`)} |
| </select> |
| </div> |
| <div class="refresh"> |
| <input-sk |
| type="number" |
| textPrefix="Reload (s): " |
| id="reloadInput" |
| @change=${() => el.update()}></input-sk> |
| <input-sk |
| type="number" |
| textPrefix="Commits:   " |
| id="commitsInput" |
| @change=${() => el.update()}> |
| </input-sk> |
| <div class="lastLoaded"> |
| ${el.data.lastLoaded |
| ? `Loaded ${el.data.lastLoaded.toLocaleTimeString()}` |
| : '(Not yet loaded)'} |
| </div> |
| </div> |
| </div> |
| <branches-sk |
| style=${el.gridLocation( |
| COMMIT_START_ROW, |
| BRANCH_START_COL, |
| COMMIT_START_ROW + el.data.commits.length |
| )}></branches-sk> |
| <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"> |
| <tabs-sk |
| @tab-selected-sk=${(e: CustomEvent) => (el.filter = FILTER_INDEX[e.detail.index])}> |
| ${Array.from(FILTER_INFO).map(([filter, info]) => |
| filter === 'Search' |
| ? html`` |
| : html`<button |
| title=${info.title} |
| class=${el._filter === filter ? 'selected' : ''}> |
| ${info.text} |
| <help-icon-sk class="tiny"></help-icon-sk> |
| </button> ` |
| )} |
| </tabs-sk> |
| <input-sk |
| id="searchInput" |
| class=${el.filter === 'Search' ? 'selected' : ''} |
| label="Filter task spec" |
| @change=${el.searchFilter}> |
| </input-sk> |
| <a href="${taskSchedulerUrl()}/trigger" target="_blank" rel="noopener"> |
| <button> |
| <add-icon-sk></add-icon-sk> |
| Trigger a Job |
| </button> |
| </a> |
| <a href=${el.reRunMishapsUrl()} target="_blank" rel="noopener"> |
| <button> |
| <autorenew-icon-sk></autorenew-icon-sk> |
| Re-Run Purple Jobs |
| </button> |
| </a> |
| </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.stateHasChanged = stateReflector( |
| () => this.getState(), |
| (fromUrl) => this.setState(fromUrl) |
| ); |
| // Now that we set the default object, use the real getState. |
| this.getState = () => this.getCurrentState(); |
| // Update the Url with the real values after the stateReflector has applied any settings from |
| // the url. |
| DomReady.then(() => this.stateHasChanged()); |
| // setState will be called once the page is fully loaded, this is where we call update(). |
| } |
| |
| // We provide this to stateReflector initially, to give it a typed but empty object to create |
| // deltas from. This allows our 'default' values to still be reflected in the url. |
| private getState = () => { |
| const hintableSettings: HintableObject = { |
| filter: '', |
| search: '', |
| repo: '', |
| displayCommitSubject: false, |
| }; |
| return hintableSettings; |
| }; |
| |
| private getCurrentState(): HintableObject { |
| const state: State = { |
| filter: this.filter, |
| search: this.search, |
| displayCommitSubject: this.displayCommitSubject, |
| repo: this.repo, |
| }; |
| return state as unknown as HintableObject; |
| } |
| |
| private setState(fromUrl: HintableObject) { |
| const state = fromUrl as unknown as State; |
| // Using empty default values in the default State object (so all values, including our true |
| // defaults are reflected in the url) means the initial load will try to set filter and |
| // repo to the empty string, prevent this. |
| if (state.filter) { |
| this._filter = state.filter; |
| } |
| this._search = state.search; |
| $$<HTMLInputElement>('#searchInput', this)!.value = this._search; |
| this._displayCommitSubject = state.displayCommitSubject; |
| if (state.repo) { |
| this.repo = state.repo; |
| } |
| |
| if (this.updatesRunning) { |
| // Updates are already running and self-scheduling, just update the UI. |
| this.draw(); |
| } else { |
| // This is the first time we're reading the query state (page load). |
| this.update(); |
| } |
| } |
| |
| disconnectedCallback() { |
| document.removeEventListener('click', this.onClick); |
| } |
| |
| get displayCommitSubject() { |
| return this._displayCommitSubject; |
| } |
| |
| set displayCommitSubject(v: boolean) { |
| this._displayCommitSubject = v; |
| this.stateHasChanged(); |
| $('.commit-text').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.stateHasChanged(); |
| this.draw(); |
| } |
| |
| get search(): string { |
| return this._search; |
| } |
| |
| set search(v: string) { |
| this._search = v; |
| this.stateHasChanged(); |
| this.draw(); |
| } |
| |
| get repo(): string { |
| return this._repo; |
| } |
| |
| set repo(v: string) { |
| if (v !== this._repo) { |
| this._repo = v; |
| ($$('#repoSelector', this) as HTMLSelectElement)!.value = v; |
| this.stateHasChanged(); |
| this.dispatchEvent(new CustomEvent('repo-changed', { bubbles: true, detail: v })); |
| this.update(); |
| } |
| } |
| |
| 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 reRunMishapsUrl() { |
| const jobStrings: { job: Array<string> } = { |
| job: this.mishapTasks.map((task) => { |
| // Jobs are named after task, test, or perf tasks, but not |
| // uploads. If this is an upload, trim the prefix. |
| let jobName = task.name; |
| if (jobName.startsWith('Upload-')) { |
| jobName = jobName.substring('Upload-'.length); |
| } |
| return `${jobName}@${task.revision}`; |
| }), |
| }; |
| return `${taskSchedulerUrl()}/trigger?${fromObject(jobStrings)}`; |
| } |
| |
| 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. |
| * @param searchRegex Regex to search with, must be set if this._filter === "Search". |
| */ |
| private includeTaskSpec(taskSpec: string, searchRegex?: RegExp): 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 searchRegex!.test(taskSpec); |
| default: |
| return false; |
| } |
| } |
| |
| /** |
| * 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) => !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> = []; |
| |
| const relanded = this.data.relandedMap.get(commit.hash); |
| if (relanded && relanded.timestamp! > commit.timestamp!) { |
| res.push( |
| html`<redo-icon-sk |
| class="tiny fill-green" |
| @mouseenter=${() => this.highlightAssociatedCommit(relanded.hash, false)} |
| @mouseleave=${() => this.highlightAssociatedCommit(relanded.hash, false)}> |
| </redo-icon-sk>` |
| ); |
| } |
| const reverted = this.data.revertedMap.get(commit.hash); |
| if (reverted && reverted.timestamp! > commit.timestamp!) { |
| res.push( |
| html`<undo-icon-sk |
| class="tiny fill-red" |
| @mouseenter=${() => this.highlightAssociatedCommit(reverted.hash, true)} |
| @mouseleave=${() => this.highlightAssociatedCommit(reverted.hash, true)}> |
| </undo-icon-sk>` |
| ); |
| } |
| if (commit.ignoreFailure) { |
| res.push(html`<block-icon-sk class="tiny"></block-icon-sk>`); |
| } |
| const atHash = this.data.comments.get(commit.hash); |
| if (atHash && (atHash.get('') || []).length > 0) { |
| res.push(html`<comment-icon-sk class="tiny"></comment-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 compile our regex once, rather than on ever taskspec. |
| const searchRegex = this._filter === 'Search' ? new RegExp(this._search, 'i') : undefined; |
| // 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, searchRegex)) |
| .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) => { |
| const 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); |
| if (task.status === TASK_STATUS_MISHAP) { |
| this.mishapTasks.push(task); |
| } |
| 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} |
| @mouseenter=${() => this.taskMouseInOut(task)} |
| @mouseleave=${() => this.taskMouseInOut(task)}> |
| ${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" |
| @mouseenter=${() => this.taskMouseInOut(task)} |
| @mouseleave=${() => this.taskMouseInOut(task)} |
| style=${this.gridLocation(rowStart, colStart, rowStart + displayTaskRows.length)}> |
| ${this.multiCommitTaskSlots(displayTaskRows, rowStart, task)} |
| </div>` |
| ); |
| } |
| }); |
| } |
| } |
| |
| private taskMouseInOut(task: Task) { |
| task.commits!.forEach((hash) => { |
| $$<HTMLDivElement>(`.${this.attributeStringFromHash(hash)}`, this)!.classList.toggle( |
| `task-emphasize-${task.status.toLowerCase()}` |
| ); |
| }); |
| } |
| |
| // Return a time label if one should be used for the commit at the given index. |
| private timeLabel(commits: Commit[], index: number, timePoints: { label: string; time: Date }[]) { |
| if (index === commits.length - 1) { |
| return null; |
| } |
| |
| const curr = new Date(commits[index].timestamp!); |
| const next = new Date(commits[index + 1].timestamp!); |
| let ret = null; |
| for (const moment of timePoints) { |
| if (moment.time <= curr && moment.time > next) { |
| ret = html`<span class="time-label">${moment.label}</span>`; |
| } |
| } |
| return ret; |
| } |
| |
| /** |
| * 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); |
| this.mishapTasks = []; |
| const taskStartRow = COMMIT_START_ROW; |
| const tasksAddedToTemplate: Set<TaskId> = new Set(); |
| const now = Date.now(); |
| // Explicitly privide 'now' in case we patched it out for testing. |
| const today = new Date(now); |
| today.setHours(0, 0, 0, 0); |
| const yesterday = new Date(today.valueOf()); |
| yesterday.setDate(yesterday.getDate() - 1); |
| const timePoints = [ |
| { label: '-1h', time: new Date(now - 60 * 60 * 1000) }, |
| { label: '-3h', time: new Date(now - 3 * 60 * 60 * 1000) }, |
| { label: 'today', time: today }, |
| { label: 'yesterday', time: yesterday }, |
| ]; |
| timePoints.sort((a, b) => b.time.valueOf() - a.time.valueOf()); |
| // 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; |
| const timeLabel = this.timeLabel(this.data.commits, i, timePoints); |
| |
| const tasksBySpec = this.data.tasksByCommit.get(commit.hash); |
| if (tasksBySpec) { |
| this.addTasks(tasksBySpec, taskSpecStartCols, rowStart, i, tasksAddedToTemplate, res); |
| } |
| // Draw commits last so the span.highlight-row naturally renders above the tasks. |
| res.push(html` |
| <div class="commit-container" style=${this.gridLocation(rowStart, COMMIT_START_COL)}> |
| <div class="time-spacer">${timeLabel}</div> |
| <div |
| class="commit ${this.attributeStringFromHash(commit.hash)}" |
| title=${title} |
| data-commit-index=${i}> |
| <span class="nowrap commit-text">${text}</span> |
| <span class="nowrap icons">${this.commitIcons(commit)}</span> |
| </div> |
| ${timeLabel ? html`<span class="time-underline"></span>` : html``} |
| </div> |
| <span |
| class="highlight-row" |
| style=${this.gridLocation( |
| rowStart, |
| COMMIT_START_COL + 1, |
| rowStart + 1, |
| this.lastColumn |
| )}></span> |
| `); |
| } |
| // 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; |
| |
| const earlierCommit = this.data.commits[earlierCommitIndex]; |
| if (currentCommitInTask.parents!.indexOf(earlierCommit.hash) === -1) { |
| // Branch leaves a gap. |
| thisTaskOverCommits.push(false); |
| } else if (task.commits!.indexOf(earlierCommit.hash) !== -1) { |
| // 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. |
| 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; |
| } |
| this.updatesRunning = true; |
| 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).finally(() => { |
| this.draw(); |
| const branchesSk = $$('branches-sk', this) as BranchesSk; |
| branchesSk.commits = this.data.commits; |
| branchesSk.branchHeads = this.data.branchHeads; |
| 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 truncate(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. |
| const gerritRE = /(.|[\r\n])*Reviewed-on:.*\/([0-9]*)/g; |
| const 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. |
| const 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) { |
| // eslint-disable-next-line new-cap |
| maybeValue = new valuetype(); |
| map.set(key, maybeValue); |
| } |
| return maybeValue; |
| } |