Add UI for A/B testing on owned commits.
authordewei_zhu@apple.com <dewei_zhu@apple.com@268f45cc-cd09-0410-ab3c-d52691b4dbfc>
Fri, 22 Dec 2017 06:25:24 +0000 (06:25 +0000)
committerdewei_zhu@apple.com <dewei_zhu@apple.com@268f45cc-cd09-0410-ab3c-d52691b4dbfc>
Fri, 22 Dec 2017 06:25:24 +0000 (06:25 +0000)
https://bugs.webkit.org/show_bug.cgi?id=177993

Reviewed by Ryosuke Niwa.

Customizable test group form should support specifying and A/B testing owned commits.
Introduce 'IntermediateCommitSet' to achieve the goal of specifying owned commits for A/B test.
In order to support configure A/B testing that may need to add/remove owned commits, CommitSet may be the
closest thing we can get. However, it is a subclass of DataModelObject, which means CommitSet is a representation
of 'commit_sets' table and can only be updated from server data. Thus, we want something like CustomCommitSet that
is not a representation of database table, but unlike CustomCommitSet, it should store information about commits
rather than a revision. As a result, IntermediateCommitSet is introduced. For a longer term, we may replace
CustomCommitSet with IntermediateCommitSet as it carries more information and could potentially simplify some
CustomCommitSet related APIs by using commit id instead of commit revision.
Extend ButtonBase class so that we can enable/disable a button.

* public/v3/components/button-base.js:
(ButtonBase):
(ButtonBase.prototype.setDisabled): Enable/disable a button.
(ButtonBase.prototype.render):
(ButtonBase.cssTemplate): Added css rule for disabled button.
* public/v3/components/combo-box.js: Added.
(ComboBox):
(ComboBox.prototype.didConstructShadowTree): Setup text field.
(ComboBox.prototype.render):
(ComboBox.prototype._candidateNameForCurrentIndex): Returns candidate name based on current index.
(ComboBox.prototype._candidateElementForCurrentIndex): Returns a list element based on current index.
(ComboBox.prototype._autoCompleteIfOnlyOneMatchingItem): Supports auto completion.
(ComboBox.prototype._moveCandidate): Supports arrow up/down.
(ComboBox.prototype._updateCandidateList): Hide/unhide candidate list and high-light selected candidate.
(ComboBox.prototype._renderCandidateList): Render candidate list base on value on text field.
(ComboBox.htmlTemplate):
(ComboBox.cssTemplate):
* public/v3/components/customizable-test-group-form.js:
(CustomizableTestGroupForm):
(CustomizableTestGroupForm.prototype.didConstructShadowTree): Only fetch the full commits when we about to create a customized A/B tests.
(CustomizableTestGroupForm.prototype._computeCommitSetMap): Compute the CustomCommitSet based on IntermediateCommitSet and
other revision related information in some map.
(CustomizableTestGroupForm.prototype.render):
(CustomizableTestGroupForm.prototype._renderCustomRevisionTable):
(CustomizableTestGroupForm.prototype._constructTableBodyList): This function builds table body for each highest level repository.
It will also include the owned repository rows in the same table body if the commits for highest level repository owns other commits.
(CustomizableTestGroupForm.prototype._constructTableRowForCommitsWithoutOwner): Build a table row for a highest level repository.
(CustomizableTestGroupForm.prototype._constructTableRowForCommitsWithOwner): Build a table row for repository with owner.
(CustomizableTestGroupForm.prototype._constructTableRowForIncompleteOwnedCommits): Build a table row for an unspecified repository.
(CustomizableTestGroupForm.prototype._constructRevisionRadioButtons): Update the logic to support build radio buttons for the owned repository rows.
(CustomizableTestGroupForm.cssTemplate):
* public/v3/components/minus-button.js: Added.
(MinusButton):
(MinusButton.buttonContent):
* public/v3/components/owned-commit-viewer.js:
(OwnedCommitViewer.prototype._renderOwnedCommitTable):
* public/v3/components/plus-button.js: Added.
(PlusButton):
(PlusButton.buttonContent):
* public/v3/index.html: Added new components.
* public/v3/models/commit-log.js: Added owner and owned commit information.
(CommitLog):
(CommitLog.prototype.ownedCommits): Returns a list of commits owned by current commit.
(CommitLog.prototype.ownerCommit): Return owner commit of current commit.
(CommitLog.prototype.setOwnerCommits): Set owner commit of current commit.
(CommitLog.prototype.label): Remove unnecessary 'else'.
(CommitLog.prototype.diff): Remove unused 'fromRevisionForURL' and tiny code cleanup.
(CommitLog.prototype.ownedCommitForOwnedRepository):
(CommitLog.prototype.fetchOwnedCommits): Sets the owner for those owned commits. The owner of a commit with multiple owner
commits will be overwritten by each time this function is called.
(CommitLog.ownedCommitDifferenceForOwnerCommits): A more generic version of diffOwnedCommits. diffOwnedCommits only accepts 2 commits,
but ownedCommitDifferenceForOwnerCommits supports multiple commits.
(CommitLog.diffOwnedCommits): Deleted and should use 'CommitLog.ownedCommitDifferenceForOwnerCommits' instead.
* public/v3/models/commit-set.js:
(CommitSet.prototype.topLevelRepositories):
(CommitSet.prototype.commitForRepository):
(IntermediateCommitSet): Take CommitSet as argument, note the commit from CommitSet doesn't contains full information of the commit.
Always call 'fetchFullCommits' once before any further usages.
(IntermediateCommitSet.prototype.fetchCommitLogs): Fetch all commits information in current commit set.
(IntermediateCommitSet.prototype._fetchCommitLogAndOwnedCommits): Fetch commit log and owned commits if necessary.
(IntermediateCommitSet.prototype.updateRevisionForOwnerRepository): Updates a commit for a repository by given a revision of the repository.
(IntermediateCommitSet.prototype.setCommitForRepository): Sets a commit for a repository in commit set.
(IntermediateCommitSet.prototype.removeCommitForRepository): Removes a commit for a repository in commit set.
(IntermediateCommitSet.prototype.ownsCommitsForRepository): Returns whether the commit for repository owns commits.
(IntermediateCommitSet.prototype.repositories): Returns all repositories in the commit set.
(IntermediateCommitSet.prototype.highestLevelRepositories): Returns all repositories those don't have an owner.
(IntermediateCommitSet.prototype.commitForRepository): Returns a commit for a given repository.
(IntermediateCommitSet.prototype.ownedRepositoriesForOwnerRepository): Returns all repositories owned by a given repository in current commit set.
(IntermediateCommitSet.prototype.ownerCommitForRepository): Returns owner commit for a given owned repository.
* tools/js/v3-models.js: Added import for 'IntermediateCommitSet'.
* unit-tests/commit-log-tests.js: Updated unittest which tests 'ownerCommit' function.
* unit-tests/commit-set-tests.js: Added unit tests for IntermediateCommitSet.

git-svn-id: https://svn.webkit.org/repository/webkit/trunk@226259 268f45cc-cd09-0410-ab3c-d52691b4dbfc

13 files changed:
Websites/perf.webkit.org/ChangeLog
Websites/perf.webkit.org/public/v3/components/button-base.js
Websites/perf.webkit.org/public/v3/components/combo-box.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/components/customizable-test-group-form.js
Websites/perf.webkit.org/public/v3/components/minus-button.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/components/owned-commit-viewer.js
Websites/perf.webkit.org/public/v3/components/plus-button.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/index.html
Websites/perf.webkit.org/public/v3/models/commit-log.js
Websites/perf.webkit.org/public/v3/models/commit-set.js
Websites/perf.webkit.org/tools/js/v3-models.js
Websites/perf.webkit.org/unit-tests/commit-log-tests.js
Websites/perf.webkit.org/unit-tests/commit-set-tests.js

index d13e27a..7fcb997 100644 (file)
@@ -1,3 +1,94 @@
+2017-12-21  Dewei Zhu  <dewei_zhu@apple.com>
+
+        Add UI for A/B testing on owned commits.
+        https://bugs.webkit.org/show_bug.cgi?id=177993
+
+        Reviewed by Ryosuke Niwa.
+
+        Customizable test group form should support specifying and A/B testing owned commits.
+        Introduce 'IntermediateCommitSet' to achieve the goal of specifying owned commits for A/B test.
+        In order to support configure A/B testing that may need to add/remove owned commits, CommitSet may be the
+        closest thing we can get. However, it is a subclass of DataModelObject, which means CommitSet is a representation
+        of 'commit_sets' table and can only be updated from server data. Thus, we want something like CustomCommitSet that
+        is not a representation of database table, but unlike CustomCommitSet, it should store information about commits
+        rather than a revision. As a result, IntermediateCommitSet is introduced. For a longer term, we may replace
+        CustomCommitSet with IntermediateCommitSet as it carries more information and could potentially simplify some
+        CustomCommitSet related APIs by using commit id instead of commit revision.
+        Extend ButtonBase class so that we can enable/disable a button.
+
+        * public/v3/components/button-base.js:
+        (ButtonBase):
+        (ButtonBase.prototype.setDisabled): Enable/disable a button.
+        (ButtonBase.prototype.render):
+        (ButtonBase.cssTemplate): Added css rule for disabled button.
+        * public/v3/components/combo-box.js: Added.
+        (ComboBox):
+        (ComboBox.prototype.didConstructShadowTree): Setup text field.
+        (ComboBox.prototype.render):
+        (ComboBox.prototype._candidateNameForCurrentIndex): Returns candidate name based on current index.
+        (ComboBox.prototype._candidateElementForCurrentIndex): Returns a list element based on current index.
+        (ComboBox.prototype._autoCompleteIfOnlyOneMatchingItem): Supports auto completion.
+        (ComboBox.prototype._moveCandidate): Supports arrow up/down.
+        (ComboBox.prototype._updateCandidateList): Hide/unhide candidate list and high-light selected candidate.
+        (ComboBox.prototype._renderCandidateList): Render candidate list base on value on text field.
+        (ComboBox.htmlTemplate):
+        (ComboBox.cssTemplate):
+        * public/v3/components/customizable-test-group-form.js:
+        (CustomizableTestGroupForm):
+        (CustomizableTestGroupForm.prototype.didConstructShadowTree): Only fetch the full commits when we about to create a customized A/B tests.
+        (CustomizableTestGroupForm.prototype._computeCommitSetMap): Compute the CustomCommitSet based on IntermediateCommitSet and
+        other revision related information in some map.
+        (CustomizableTestGroupForm.prototype.render):
+        (CustomizableTestGroupForm.prototype._renderCustomRevisionTable):
+        (CustomizableTestGroupForm.prototype._constructTableBodyList): This function builds table body for each highest level repository.
+        It will also include the owned repository rows in the same table body if the commits for highest level repository owns other commits.
+        (CustomizableTestGroupForm.prototype._constructTableRowForCommitsWithoutOwner): Build a table row for a highest level repository.
+        (CustomizableTestGroupForm.prototype._constructTableRowForCommitsWithOwner): Build a table row for repository with owner.
+        (CustomizableTestGroupForm.prototype._constructTableRowForIncompleteOwnedCommits): Build a table row for an unspecified repository.
+        (CustomizableTestGroupForm.prototype._constructRevisionRadioButtons): Update the logic to support build radio buttons for the owned repository rows.
+        (CustomizableTestGroupForm.cssTemplate):
+        * public/v3/components/minus-button.js: Added.
+        (MinusButton):
+        (MinusButton.buttonContent):
+        * public/v3/components/owned-commit-viewer.js:
+        (OwnedCommitViewer.prototype._renderOwnedCommitTable):
+        * public/v3/components/plus-button.js: Added.
+        (PlusButton):
+        (PlusButton.buttonContent):
+        * public/v3/index.html: Added new components.
+        * public/v3/models/commit-log.js: Added owner and owned commit information.
+        (CommitLog):
+        (CommitLog.prototype.ownedCommits): Returns a list of commits owned by current commit.
+        (CommitLog.prototype.ownerCommit): Return owner commit of current commit.
+        (CommitLog.prototype.setOwnerCommits): Set owner commit of current commit.
+        (CommitLog.prototype.label): Remove unnecessary 'else'.
+        (CommitLog.prototype.diff): Remove unused 'fromRevisionForURL' and tiny code cleanup.
+        (CommitLog.prototype.ownedCommitForOwnedRepository):
+        (CommitLog.prototype.fetchOwnedCommits): Sets the owner for those owned commits. The owner of a commit with multiple owner
+        commits will be overwritten by each time this function is called.
+        (CommitLog.ownedCommitDifferenceForOwnerCommits): A more generic version of diffOwnedCommits. diffOwnedCommits only accepts 2 commits,
+        but ownedCommitDifferenceForOwnerCommits supports multiple commits.
+        (CommitLog.diffOwnedCommits): Deleted and should use 'CommitLog.ownedCommitDifferenceForOwnerCommits' instead.
+        * public/v3/models/commit-set.js:
+        (CommitSet.prototype.topLevelRepositories):
+        (CommitSet.prototype.commitForRepository):
+        (IntermediateCommitSet): Take CommitSet as argument, note the commit from CommitSet doesn't contains full information of the commit.
+        Always call 'fetchFullCommits' once before any further usages.
+        (IntermediateCommitSet.prototype.fetchCommitLogs): Fetch all commits information in current commit set.
+        (IntermediateCommitSet.prototype._fetchCommitLogAndOwnedCommits): Fetch commit log and owned commits if necessary.
+        (IntermediateCommitSet.prototype.updateRevisionForOwnerRepository): Updates a commit for a repository by given a revision of the repository.
+        (IntermediateCommitSet.prototype.setCommitForRepository): Sets a commit for a repository in commit set.
+        (IntermediateCommitSet.prototype.removeCommitForRepository): Removes a commit for a repository in commit set.
+        (IntermediateCommitSet.prototype.ownsCommitsForRepository): Returns whether the commit for repository owns commits.
+        (IntermediateCommitSet.prototype.repositories): Returns all repositories in the commit set.
+        (IntermediateCommitSet.prototype.highestLevelRepositories): Returns all repositories those don't have an owner.
+        (IntermediateCommitSet.prototype.commitForRepository): Returns a commit for a given repository.
+        (IntermediateCommitSet.prototype.ownedRepositoriesForOwnerRepository): Returns all repositories owned by a given repository in current commit set.
+        (IntermediateCommitSet.prototype.ownerCommitForRepository): Returns owner commit for a given owned repository.
+        * tools/js/v3-models.js: Added import for 'IntermediateCommitSet'.
+        * unit-tests/commit-log-tests.js: Updated unittest which tests 'ownerCommit' function.
+        * unit-tests/commit-set-tests.js: Added unit tests for IntermediateCommitSet.
+
 2017-12-13  Dewei Zhu  <dewei_zhu@apple.com>
 
         Add a test freshness page.
 2017-12-13  Dewei Zhu  <dewei_zhu@apple.com>
 
         Add a test freshness page.
index 36439fc..4c0e34b 100644 (file)
@@ -1,5 +1,18 @@
 
 class ButtonBase extends ComponentBase {
 
 class ButtonBase extends ComponentBase {
+
+    constructor(name)
+    {
+        super(name);
+        this._disabled = false;
+    }
+
+    setDisabled(disabled)
+    {
+        this._disabled = disabled;
+        this.enqueueToRender();
+    }
+
     didConstructShadowTree()
     {
         this.content('button').addEventListener('click', this.createEventHandler(() => {
     didConstructShadowTree()
     {
         this.content('button').addEventListener('click', this.createEventHandler(() => {
@@ -7,6 +20,15 @@ class ButtonBase extends ComponentBase {
         }));
     }
 
         }));
     }
 
+    render()
+    {
+        super.render();
+        if (this._disabled)
+            this.content('button').setAttribute('disabled', '');
+        else
+            this.content('button').removeAttribute('disabled');
+    }
+
     static htmlTemplate()
     {
         return `<a id="button" href="#"><svg viewBox="0 0 100 100">${this.buttonContent()}</svg></a>`;
     static htmlTemplate()
     {
         return `<a id="button" href="#"><svg viewBox="0 0 100 100">${this.buttonContent()}</svg></a>`;
@@ -35,6 +57,12 @@ class ButtonBase extends ComponentBase {
                 opacity: 0.6;
             }
 
                 opacity: 0.6;
             }
 
+            a[disabled] {
+                pointer-events: none;
+                cursor: default;
+                opacity: 0.2;
+            }
+
             svg {
                 display: block;
             }
             svg {
                 display: block;
             }
diff --git a/Websites/perf.webkit.org/public/v3/components/combo-box.js b/Websites/perf.webkit.org/public/v3/components/combo-box.js
new file mode 100644 (file)
index 0000000..4e58528
--- /dev/null
@@ -0,0 +1,205 @@
+class ComboBox extends ComponentBase {
+
+    constructor(candidates, maxCandidateListLength)
+    {
+        super('combo-box');
+        this._candidates = candidates;
+        this._maxCandidateListLength = maxCandidateListLength || 1000;
+        this._candidateList = [];
+        this._currentCandidateIndex = null;
+        this._showCandidateList = false;
+        this._renderCandidateListLazily = new LazilyEvaluatedFunction(this._renderCandidateList.bind(this));
+        this._updateCandidateListLazily = new LazilyEvaluatedFunction(this._updateCandidateList.bind(this));
+    }
+
+    didConstructShadowTree()
+    {
+        super.didConstructShadowTree();
+        const textField = this.content('text-field');
+        textField.addEventListener('input', () => {
+            this._showCandidateList = true;
+            this._currentCandidateIndex = null;
+            this.enqueueToRender();
+        });
+        textField.addEventListener('change', () => {
+            this._autoCompleteIfOnlyOneMatchingItem();
+            this._currentCandidateIndex = null;
+        });
+        textField.addEventListener('blur', () => {
+            this._showCandidateList = false;
+            this._autoCompleteIfOnlyOneMatchingItem();
+            this.enqueueToRender();
+        });
+        textField.addEventListener('focus', () => {
+            this._showCandidateList = true;
+            this.enqueueToRender();
+        });
+        textField.addEventListener('keydown', (event) => {
+            if (event.key === 'ArrowDown' || event.key === 'ArrowUp')
+                this._moveCandidate(event.key === 'ArrowDown');
+            else if (event.key === 'Tab' || event.key === 'Enter') {
+                let candidate = this._currentCandidateIndex === null ? null : this._candidateList[this._currentCandidateIndex];
+                if (!candidate && this._candidateList.length === 1)
+                    candidate = this._candidateList[0];
+                if (candidate)
+                    this.dispatchAction('update', candidate);
+            }
+        });
+    }
+
+    render()
+    {
+        super.render();
+
+        const candidateElementList = this._renderCandidateListLazily.evaluate(this._candidates, this.content('text-field').value);
+
+        console.assert(this._currentCandidateIndex === null || (this._currentCandidateIndex >= 0 && this._currentCandidateIndex < candidateElementList.length));
+        const selectedCandidateElement = this._currentCandidateIndex === null ? null : candidateElementList[this._currentCandidateIndex];
+        this._updateCandidateListLazily.evaluate(selectedCandidateElement, this._showCandidateList);
+    }
+
+    _autoCompleteIfOnlyOneMatchingItem()
+    {
+        const textFieldValueInLowerCase = this.content('text-field').value.toLowerCase();
+        if (!textFieldValueInLowerCase.length)
+            return;
+
+        let matchingCandidateCount = 0;
+        let matchingCandidate = null;
+        for (const candidate of this._candidates) {
+            if (candidate.toLowerCase().includes(textFieldValueInLowerCase)) {
+                matchingCandidateCount += 1;
+                matchingCandidate = candidate;
+            }
+            if (matchingCandidateCount > 1)
+                break;
+        }
+        if (matchingCandidateCount === 1)
+            this.dispatchAction('update', matchingCandidate);
+    }
+
+    _moveCandidate(forward)
+    {
+        const candidateListLength = this._candidateList.length;
+        if (!candidateListLength)
+            return;
+        let newIndex = this._currentCandidateIndex;
+        if (newIndex === null)
+            newIndex = forward ? 0 : candidateListLength - 1;
+        else {
+            newIndex += forward ? 1 : -1;
+            if (newIndex >= this._candidateList.length)
+                newIndex = this._candidateList.length - 1;
+            if (newIndex < 0)
+                newIndex = 0;
+        }
+        this._currentCandidateIndex = newIndex;
+        this.enqueueToRender();
+    }
+
+    _renderCandidateList(candidates, key)
+    {
+        const element = ComponentBase.createElement;
+        const link = ComponentBase.createLink;
+        const candidatesStartingWithKey = [];
+        const candidatesContainingKey = [];
+        for (const candidate of candidates) {
+            const searchResult = candidate.toLowerCase().indexOf(key.toLowerCase());
+            if (key && searchResult < 0)
+                continue;
+            if (!searchResult)
+                candidatesStartingWithKey.push(candidate);
+            else
+                candidatesContainingKey.push(candidate);
+        }
+        this._candidateList = candidatesStartingWithKey.concat(candidatesContainingKey).slice(0, this._maxCandidateListLength);
+        const candidateElementList = this._candidateList.map((candidate) => {
+            const item = link(candidate, () => null);
+            // FIXME: We should use 'onlick' callback provided by 'ComponentBase.createLink'. However, in this case,
+            // 'blur' event will be triggered before 'onlick', we have to use 'mousedown' instead.
+            item.addEventListener('mousedown', () => {
+                this.dispatchAction('update', candidate);
+                this.enqueueToRender();
+            });
+            return element('li', item);
+        });
+
+        this.renderReplace(this.content('candidate-list'), candidateElementList);
+        return candidateElementList;
+    }
+
+    _updateCandidateList(selectedCandidateElement, showCandidateList)
+    {
+
+        const candidateList = this.content('candidate-list');
+        candidateList.style.display = showCandidateList ? null : 'none';
+
+        const previouslySelectedCandidateElement = candidateList.querySelector('.selected');
+        if (previouslySelectedCandidateElement)
+            previouslySelectedCandidateElement.classList.remove('selected');
+
+        if (selectedCandidateElement) {
+            selectedCandidateElement.className = 'selected';
+            selectedCandidateElement.scrollIntoViewIfNeeded();
+        }
+    }
+
+    static htmlTemplate()
+    {
+        return `<div id='combox-box'>
+            <input id='text-field'></input>
+            <ul id="candidate-list"></ul>
+        </div>`;
+    }
+
+    static cssTemplate()
+    {
+        return `
+            div {
+                position: relative;
+                height: 1.4rem;
+                left: 0;
+            }
+
+            ul:empty {
+                display: none;
+            }
+
+            ul {
+                transition: background 250ms ease-in;
+                margin: 0;
+                padding: 0.1rem 0.3rem;
+                list-style: none;
+                background: rgba(255, 255, 255, 0.95);
+                border: solid 1px #ccc;
+                top: 1.5rem;
+                border-radius: 0.2rem;
+                z-index: 10;
+                position: absolute;
+                min-width: 8.5rem;
+                max-height: 10rem;
+                overflow: auto;
+            }
+
+            li {
+                text-align: left;
+                margin: 0;
+                padding: 0;
+            }
+
+            li:hover,
+            li.selected {
+                background: rgba(204, 153, 51, 0.1);
+            }
+
+            li a {
+                text-decoration: none;
+                font-size: 0.8rem;
+                color: inherit;
+                display: block;
+            }
+        `;
+    }
+}
+
+ComponentBase.defineElement('combo-box', ComboBox);
index 4693801..53ef7f7 100644 (file)
@@ -1,4 +1,3 @@
-
 class CustomizableTestGroupForm extends TestGroupForm {
 
     constructor()
 class CustomizableTestGroupForm extends TestGroupForm {
 
     constructor()
@@ -7,15 +6,20 @@ class CustomizableTestGroupForm extends TestGroupForm {
         this._commitSetMap = null;
         this._name = null;
         this._isCustomized = false;
         this._commitSetMap = null;
         this._name = null;
         this._isCustomized = false;
-        this._revisionEditorMap = {};
-
-        this._renderCustomRevisionTableLazily = new LazilyEvaluatedFunction(this._renderCustomRevisionTable.bind(this));
+        this._revisionEditorMap = null;
+        this._ownerRevisionMap = null;
+        this._checkedLabelByPosition = null;
+        this._hasIncompleteOwnedRepository = null;
+        this._fetchingCommitPromises = [];
     }
 
     setCommitSetMap(map)
     {
         this._commitSetMap = map;
         this._isCustomized = false;
     }
 
     setCommitSetMap(map)
     {
         this._commitSetMap = map;
         this._isCustomized = false;
+        this._fetchingCommitPromises = [];
+        this._checkedLabelByPosition = new Map;
+        this._hasIncompleteOwnedRepository = new Map;
         this.enqueueToRender();
     }
 
         this.enqueueToRender();
     }
 
@@ -32,9 +36,30 @@ class CustomizableTestGroupForm extends TestGroupForm {
         nameControl.oninput = () => {
             this._name = nameControl.value;
             this.enqueueToRender();
         nameControl.oninput = () => {
             this._name = nameControl.value;
             this.enqueueToRender();
-        }
+        };
 
         this.content('customize-link').onclick = this.createEventHandler(() => {
 
         this.content('customize-link').onclick = this.createEventHandler(() => {
+            if (!this._isCustomized) {
+                const originalCommitSetMap = this._commitSetMap;
+                const fetchingCommitPromises = [];
+                this._commitSetMap = new Map;
+                for (const label in originalCommitSetMap) {
+                    const intermediateCommitSet = new IntermediateCommitSet(originalCommitSetMap[label]);
+                    fetchingCommitPromises.push(intermediateCommitSet.fetchCommitLogs());
+                    this._commitSetMap.set(label, intermediateCommitSet);
+                }
+                this._fetchingCommitPromises = fetchingCommitPromises;
+
+                return Promise.all(fetchingCommitPromises).then(() => {
+                    if (this._fetchingCommitPromises !== fetchingCommitPromises)
+                        return;
+                    this._isCustomized = true;
+                    this._fetchingCommitPromises = [];
+                    for (const label in originalCommitSetMap)
+                        this._checkedLabelByPosition.set(label, new Map);
+                    this.enqueueToRender();
+                });
+            }
             this._isCustomized = true;
             this.enqueueToRender();
         });
             this._isCustomized = true;
             this.enqueueToRender();
         });
@@ -47,13 +72,14 @@ class CustomizableTestGroupForm extends TestGroupForm {
             return this._commitSetMap;
 
         const map = {};
             return this._commitSetMap;
 
         const map = {};
-        for (const label in this._commitSetMap) {
-            const originalCommitSet = this._commitSetMap;
+        for (const [label, commitSet] of this._commitSetMap) {
             const customCommitSet = new CustomCommitSet;
             const customCommitSet = new CustomCommitSet;
-            for (let repository of this._commitSetMap[label].repositories()) {
-                const revisionEditor = this._revisionEditorMap[label].get(repository);
+            for (const repository of commitSet.repositories()) {
+                const revisionEditor = this._revisionEditorMap.get(label).get(repository);
                 console.assert(revisionEditor);
                 console.assert(revisionEditor);
-                customCommitSet.setRevisionForRepository(repository, revisionEditor.value);
+                const ownerRevision = this._ownerRevisionMap.get(label).get(repository) || null;
+
+                customCommitSet.setRevisionForRepository(repository, revisionEditor.value, null, ownerRevision);
             }
             map[label] = customCommitSet;
         }
             }
             map[label] = customCommitSet;
         }
@@ -67,24 +93,38 @@ class CustomizableTestGroupForm extends TestGroupForm {
         this.content('start-button').disabled = !(this._commitSetMap && this._name);
         this.content('customize-link-container').style.display = !this._commitSetMap ? 'none' : null;
 
         this.content('start-button').disabled = !(this._commitSetMap && this._name);
         this.content('customize-link-container').style.display = !this._commitSetMap ? 'none' : null;
 
-        this._renderCustomRevisionTableLazily.evaluate(this._commitSetMap, this._isCustomized);
+        this._renderCustomRevisionTable(this._commitSetMap, this._isCustomized);
     }
 
     _renderCustomRevisionTable(commitSetMap, isCustomized)
     {
         if (!commitSetMap || !isCustomized) {
             this.renderReplace(this.content('custom-table'), []);
     }
 
     _renderCustomRevisionTable(commitSetMap, isCustomized)
     {
         if (!commitSetMap || !isCustomized) {
             this.renderReplace(this.content('custom-table'), []);
-            return null;
+            return;
         }
 
         const repositorySet = new Set;
         }
 
         const repositorySet = new Set;
+        const ownedRepositoriesByRepository = new Map;
         const commitSetLabels = [];
         const commitSetLabels = [];
-        this._revisionEditorMap = {};
-        for (const label in commitSetMap) {
-            for (const repository of commitSetMap[label].repositories())
+        this._revisionEditorMap = new Map;
+        this._ownerRevisionMap = new Map;
+        for (const [label, commitSet] of commitSetMap) {
+            for (const repository of commitSet.highestLevelRepositories()) {
                 repositorySet.add(repository);
                 repositorySet.add(repository);
+                const ownedRepositories = commitSetMap.get(label).ownedRepositoriesForOwnerRepository(repository);
+
+                if (!ownedRepositories)
+                    continue;
+
+                if (!ownedRepositoriesByRepository.has(repository))
+                    ownedRepositoriesByRepository.set(repository, new Set);
+                const ownedRepositorySet = ownedRepositoriesByRepository.get(repository);
+                for (const ownedRepository of ownedRepositories)
+                    ownedRepositorySet.add(ownedRepository)
+            }
             commitSetLabels.push(label);
             commitSetLabels.push(label);
-            this._revisionEditorMap[label] = new Map;
+            this._revisionEditorMap.set(label, new Map);
+            this._ownerRevisionMap.set(label, new Map);
         }
 
         const repositoryList = Repository.sortByNamePreferringOnesWithURL(Array.from(repositorySet.values()));
         }
 
         const repositoryList = Repository.sortByNamePreferringOnesWithURL(Array.from(repositorySet.values()));
@@ -92,34 +132,145 @@ class CustomizableTestGroupForm extends TestGroupForm {
         this.renderReplace(this.content('custom-table'), [
             element('thead',
                 element('tr',
         this.renderReplace(this.content('custom-table'), [
             element('thead',
                 element('tr',
-                    [element('td', 'Repository'), commitSetLabels.map((label) => element('td', {colspan: commitSetLabels.length + 1}, label))])),
-            element('tbody',
-                repositoryList.map((repository) => {
-                    const cells = [element('th', repository.label())];
-                    for (const label in commitSetMap)
-                        cells.push(this._constructRevisionRadioButtons(commitSetMap, repository, label));
-                    return element('tr', cells);
-                }))]);
-
-        return repositoryList;
+                    [element('td', {colspan: 2}, 'Repository'), commitSetLabels.map((label) => element('td', {colspan: commitSetLabels.length + 1}, label)), element('td')])),
+            this._constructTableBodyList(repositoryList, commitSetMap, ownedRepositoriesByRepository, this._hasIncompleteOwnedRepository)]);
+    }
+
+    _constructTableBodyList(repositoryList, commitSetMap, ownedRepositoriesByRepository, hasIncompleteOwnedRepository)
+    {
+        const element = ComponentBase.createElement;
+        const tableBodyList = [];
+        for(const repository of repositoryList) {
+            const rows = [];
+            const allCommitSetSpecifiesOwnerCommit = Array.from(commitSetMap.values()).every((commitSet) => commitSet.ownsCommitsForRepository(repository));
+            const hasIncompleteRow = hasIncompleteOwnedRepository.get(repository);
+            const ownedRepositories = ownedRepositoriesByRepository.get(repository);
+
+            rows.push(this._constructTableRowForCommitsWithoutOwner(commitSetMap, repository, allCommitSetSpecifiesOwnerCommit, hasIncompleteOwnedRepository));
+
+            if ((!ownedRepositories || !ownedRepositories.size) && !hasIncompleteRow) {
+                tableBodyList.push(element('tbody', rows));
+                continue;
+            }
+
+            if (ownedRepositories) {
+                for (const ownedRepository of ownedRepositories)
+                    rows.push(this._constructTableRowForCommitsWithOwner(commitSetMap, ownedRepository, repository));
+            }
+
+            if (hasIncompleteRow) {
+                const commits = Array.from(commitSetMap.values()).map((commitSet) => commitSet.commitForRepository(repository));
+                const commitDiff = CommitLog.ownedCommitDifferenceForOwnerCommits(...commits);
+                rows.push(this._constructTableRowForIncompleteOwnedCommits(commitSetMap, repository, commitDiff));
+            }
+            tableBodyList.push(element('tbody', rows));
+        }
+        return tableBodyList;
+    }
+
+    _constructTableRowForCommitsWithoutOwner(commitSetMap, repository, ownsRepositories, hasIncompleteOwnedRepository)
+    {
+        const element = ComponentBase.createElement;
+        const cells = [element('th', {colspan: 2}, repository.label())];
+
+        for (const label of commitSetMap.keys())
+            cells.push(this._constructRevisionRadioButtons(commitSetMap, repository, label, null, ownsRepositories));
+
+        if (ownsRepositories) {
+            const plusButton = new PlusButton();
+            plusButton.setDisabled(hasIncompleteOwnedRepository.get(repository));
+            plusButton.listenToAction('activate', () => {
+                this._hasIncompleteOwnedRepository.set(repository, true);
+                this.enqueueToRender();
+            });
+            cells.push(element('td', plusButton));
+        } else
+            cells.push(element('td'));
+
+        return element('tr', cells);
+    }
+
+    _constructTableRowForCommitsWithOwner(commitSetMap, repository, ownerRepository)
+    {
+        const element = ComponentBase.createElement;
+        const cells = [element('td', {class: 'owner-repository-label'}), element('th', repository.label())];
+        const minusButton = new MinusButton();
+
+        for (const label of commitSetMap.keys())
+            cells.push(this._constructRevisionRadioButtons(commitSetMap, repository, label, ownerRepository, false));
+
+        minusButton.listenToAction('activate', () => {
+            for (const commitSet of commitSetMap.values())
+                commitSet.removeCommitForRepository(repository)
+            this.enqueueToRender();
+        });
+        cells.push(element('td', minusButton));
+        return element('tr', cells);
     }
 
     }
 
-    _constructRevisionRadioButtons(commitSetMap, repository, rowLabel)
+    _constructTableRowForIncompleteOwnedCommits(commitSetMap, ownerRepository, commitDiff)
     {
         const element = ComponentBase.createElement;
     {
         const element = ComponentBase.createElement;
-        const revisionEditor = element('input');
+        const configurationCount =  commitSetMap.size;
+        const numberOfCellsPerConfiguration = configurationCount + 1;
+        const changedRepositories = Array.from(commitDiff.keys());
+        const minusButton = new MinusButton();
+        const comboBox = new ComboBox(changedRepositories.map((repository) => repository.name()).sort());
 
 
-        this._revisionEditorMap[rowLabel].set(repository, revisionEditor);
+        comboBox.listenToAction('update', (repositoryName) => {
+            const targetRepository = changedRepositories.find((repository) => repositoryName === repository.name());
+            const ownedCommitDifferenceForRepository = Array.from(commitDiff.get(targetRepository).values());
+            const commitSetList = Array.from(commitSetMap.values());
+
+            console.assert(ownedCommitDifferenceForRepository.length === commitSetList.length);
+            for (let i = 0; i < commitSetList.length; i++)
+                commitSetList[i].setCommitForRepository(targetRepository, ownedCommitDifferenceForRepository[i]);
+            this._hasIncompleteOwnedRepository.set(ownerRepository, false);
+            this.enqueueToRender();
+        });
+
+        const cells = [element('td', {class: 'owner-repository-label'}), element('th', comboBox), element('td', {colspan: configurationCount * numberOfCellsPerConfiguration})];
+
+        minusButton.listenToAction('activate', () => {
+            this._hasIncompleteOwnedRepository.set(ownerRepository, false);
+            this.enqueueToRender();
+        });
+        cells.push(element('td', minusButton));
+
+        return element('tr', cells);
+    }
+
+    _constructRevisionRadioButtons(commitSetMap, repository, rowLabel, ownerRepository, ownsRepositories)
+    {
+        const element = ComponentBase.createElement;
+        const revisionEditor = element('input', {disabled: !!ownerRepository,
+            onchange: () => {
+                if (!ownsRepositories)
+                    return;
+                commitSetMap.get(rowLabel).updateRevisionForOwnerRepository(repository, revisionEditor.value).catch(
+                    () => revisionEditor.value = '');
+            }});
+
+        this._revisionEditorMap.get(rowLabel).set(repository, revisionEditor);
 
         const nodes = [];
 
         const nodes = [];
-        for (let labelToChoose in commitSetMap) {
-            const commit = commitSetMap[labelToChoose].commitForRepository(repository);
-            const checked = labelToChoose == rowLabel;
+        for (const labelToChoose of commitSetMap.keys()) {
+            const commit = commitSetMap.get(labelToChoose).commitForRepository(repository);
+            const checkedLabel = this._checkedLabelByPosition.get(rowLabel).get(repository) || rowLabel;
+            const checked =  labelToChoose == checkedLabel;
             const radioButton = element('input', {type: 'radio', name: `${rowLabel}-${repository.id()}-radio`, checked,
             const radioButton = element('input', {type: 'radio', name: `${rowLabel}-${repository.id()}-radio`, checked,
-                onchange: () => { revisionEditor.value = commit ? commit.revision() : ''; }});
+                onchange: () => {
+                    this._checkedLabelByPosition.get(rowLabel).set(repository, labelToChoose);
+                    revisionEditor.value = commit ? commit.revision() : '';
+                    if (commit && commit.ownerCommit())
+                        this._ownerRevisionMap.get(rowLabel).set(repository, commit.ownerCommit().revision());
+                }});
 
 
-            if (checked)
+            if (checked) {
                 revisionEditor.value = commit ? commit.revision() : '';
                 revisionEditor.value = commit ? commit.revision() : '';
+                if (commit && commit.ownerCommit())
+                    this._ownerRevisionMap.get(rowLabel).set(repository, commit.ownerCommit().revision());
+            }
             nodes.push(element('td', element('label', [radioButton, labelToChoose])));
         }
         nodes.push(element('td', revisionEditor));
             nodes.push(element('td', element('label', [radioButton, labelToChoose])));
         }
         nodes.push(element('td', revisionEditor));
@@ -139,6 +290,21 @@ class CustomizableTestGroupForm extends TestGroupForm {
                 margin: 1rem 0;
             }
 
                 margin: 1rem 0;
             }
 
+            #custom-table td.owner-repository-label {
+                border-top: solid 2px transparent;
+                border-bottom: solid 1px transparent;
+                min-width: 2rem;
+                text-align: right;
+            }
+
+            #custom-table tr:last-child td.owner-repository-label {
+                border-bottom: solid 1px #ddd;
+            }
+
+            #custom-table th {
+                min-width: 12rem;
+            }
+
             #custom-table,
             #custom-table td,
             #custom-table th {
             #custom-table,
             #custom-table td,
             #custom-table th {
diff --git a/Websites/perf.webkit.org/public/v3/components/minus-button.js b/Websites/perf.webkit.org/public/v3/components/minus-button.js
new file mode 100644 (file)
index 0000000..4095665
--- /dev/null
@@ -0,0 +1,17 @@
+
+class MinusButton extends ButtonBase {
+    constructor()
+    {
+        super('minus-button');
+    }
+
+    static buttonContent()
+    {
+        return `<g stroke="black" stroke-width="10" id="icon">
+            <circle cx="50" cy="50" r="40" fill="transparent"/>
+            <polygon points="25,50 75,50" />
+        </g>`;
+    }
+}
+
+ComponentBase.defineElement('minus-button', MinusButton);
index 68f7433..5ec2e6f 100644 (file)
@@ -43,7 +43,7 @@ class OwnedCommitViewer extends ComponentBase {
         if (!previousOwnedCommits || !currentOwnedCommits)
             return;
 
         if (!previousOwnedCommits || !currentOwnedCommits)
             return;
 
-        const difference = CommitLog.diffOwnedCommits(this._previousCommit, this._currentCommit);
+        const difference = CommitLog.ownedCommitDifferenceForOwnerCommits(this._previousCommit, this._currentCommit);
         const sortedRepositories = Repository.sortByName([...difference.keys()]);
         const element = ComponentBase.createElement;
 
         const sortedRepositories = Repository.sortByName([...difference.keys()]);
         const element = ComponentBase.createElement;
 
diff --git a/Websites/perf.webkit.org/public/v3/components/plus-button.js b/Websites/perf.webkit.org/public/v3/components/plus-button.js
new file mode 100644 (file)
index 0000000..0849e2c
--- /dev/null
@@ -0,0 +1,18 @@
+
+class PlusButton extends ButtonBase {
+    constructor()
+    {
+        super('plus-button');
+    }
+
+    static buttonContent()
+    {
+        return `<g stroke="black" stroke-width="10" id="icon">
+            <circle cx="50" cy="50" r="40" fill="transparent"/>
+            <polygon points="50,25 50,75" />
+            <polygon points="25,50 75,50" />
+        </g>`;
+    }
+}
+
+ComponentBase.defineElement('plus-button', PlusButton);
index 1a21c0f..0233f96 100644 (file)
@@ -99,6 +99,9 @@ Run tools/bundle-v3-scripts to speed up the load time for production.`);
         <script src="components/custom-configuration-test-group-form.js"></script>
         <script src="components/instant-file-uploader.js"></script>
         <script src="components/freshness-indicator.js"></script>
         <script src="components/custom-configuration-test-group-form.js"></script>
         <script src="components/instant-file-uploader.js"></script>
         <script src="components/freshness-indicator.js"></script>
+        <script src="components/plus-button.js"></script>
+        <script src="components/minus-button.js"></script>
+        <script src="components/combo-box.js"></script>
 
         <script src="pages/page.js"></script>
         <script src="pages/page-router.js"></script>
 
         <script src="pages/page.js"></script>
         <script src="pages/page-router.js"></script>
index 7be4214..2c4b49f 100644 (file)
@@ -12,6 +12,8 @@ class CommitLog extends DataModelObject {
         if (this._remoteId)
             this.ensureNamedStaticMap('remoteId')[this._remoteId] = this;
         this._ownedCommits = null;
         if (this._remoteId)
             this.ensureNamedStaticMap('remoteId')[this._remoteId] = this;
         this._ownedCommits = null;
+        this._ownerCommit = null;
+        this._ownedCommitByOwnedRepository = new Map;
     }
 
     updateSingleton(rawData)
     }
 
     updateSingleton(rawData)
@@ -36,13 +38,17 @@ class CommitLog extends DataModelObject {
     message() { return this._rawData['message']; }
     url() { return this._repository.urlForRevision(this._rawData['revision']); }
     ownsCommits() { return this._rawData['ownsCommits']; }
     message() { return this._rawData['message']; }
     url() { return this._repository.urlForRevision(this._rawData['revision']); }
     ownsCommits() { return this._rawData['ownsCommits']; }
+    ownedCommits() { return this._ownedCommits; }
+    ownerCommit() { return this._ownerCommit; }
+
+    setOwnerCommits(ownerCommit) { this._ownerCommit = ownerCommit; }
 
     label()
     {
 
     label()
     {
-        var revision = this.revision();
+        const revision = this.revision();
         if (parseInt(revision) == revision) // e.g. r12345
             return 'r' + revision;
         if (parseInt(revision) == revision) // e.g. r12345
             return 'r' + revision;
-        else if (revision.length == 40) // e.g. git hash
+        if (revision.length == 40) // e.g. git hash
             return revision.substring(0, 8);
         return revision;
     }
             return revision.substring(0, 8);
         return revision;
     }
@@ -59,12 +65,10 @@ class CommitLog extends DataModelObject {
 
         const to = this.revision();
         const from = previousCommit.revision();
 
         const to = this.revision();
         const from = previousCommit.revision();
-        let fromRevisionForURL = from;
         let label = null;
         let label = null;
-        if (parseInt(from) == from) { // e.g. r12345.
-            fromRevisionForURL = (parseInt(from) + 1).toString;
+        if (parseInt(from) == from)// e.g. r12345.
             label = `r${from}-r${this.revision()}`;
             label = `r${from}-r${this.revision()}`;
-        else if (to.length == 40) // e.g. git hash
+        else if (to.length == 40) // e.g. git hash
             label = `${from.substring(0, 8)}..${to.substring(0, 8)}`;
         else
             label = `${from} - ${to}`;
             label = `${from.substring(0, 8)}..${to.substring(0, 8)}`;
         else
             label = `${from} - ${to}`;
@@ -86,6 +90,8 @@ class CommitLog extends DataModelObject {
         });
     }
 
         });
     }
 
+    ownedCommitForOwnedRepository(ownedRepository) { return this._ownedCommitByOwnedRepository.get(ownedRepository); }
+
     fetchOwnedCommits()
     {
         if (!this.repository().ownedRepositories())
     fetchOwnedCommits()
     {
         if (!this.repository().ownedRepositories())
@@ -99,6 +105,10 @@ class CommitLog extends DataModelObject {
 
         return CommitLog.cachedFetch(`../api/commits/${this.repository().id()}/owned-commits?owner-revision=${escape(this.revision())}`).then((data) => {
             this._ownedCommits = CommitLog._constructFromRawData(data);
 
         return CommitLog.cachedFetch(`../api/commits/${this.repository().id()}/owned-commits?owner-revision=${escape(this.revision())}`).then((data) => {
             this._ownedCommits = CommitLog._constructFromRawData(data);
+            this._ownedCommits.forEach((ownedCommit) => {
+                ownedCommit.setOwnerCommits(this);
+                this._ownedCommitByOwnedRepository.set(ownedCommit.repository(), ownedCommit);
+            });
             return this._ownedCommits;
         });
     }
             return this._ownedCommits;
         });
     }
@@ -111,25 +121,27 @@ class CommitLog extends DataModelObject {
         return ownedCommitMap;
     }
 
         return ownedCommitMap;
     }
 
-    static diffOwnedCommits(previousCommit, currentCommit)
+    static ownedCommitDifferenceForOwnerCommits(...commits)
     {
     {
-        console.assert(previousCommit);
-        console.assert(currentCommit);
-        console.assert(previousCommit._ownedCommits);
-        console.assert(currentCommit._ownedCommits);
-
-        const previousOwnedCommitMap = previousCommit._buildOwnedCommitMap();
-        const currentOwnedCommitMap = currentCommit._buildOwnedCommitMap();
-        const ownedCommitRepositories = new Set([...currentOwnedCommitMap.keys(), ...previousOwnedCommitMap.keys()]);
-        const difference = new Map;
+        console.assert(commits.length >= 2);
+
+        const ownedCommitRepositories = new Set;
+        const ownedCommitMapList = commits.map((commit) => {
+            console.assert(commit);
+            console.assert(commit._ownedCommits);
+            const ownedCommitMap = commit._buildOwnedCommitMap();
+            for (const repository of ownedCommitMap.keys())
+                ownedCommitRepositories.add(repository);
+            return ownedCommitMap;
+        });
 
 
+        const difference = new Map;
         ownedCommitRepositories.forEach((ownedCommitRepository) => {
         ownedCommitRepositories.forEach((ownedCommitRepository) => {
-            const currentRevision = currentOwnedCommitMap.get(ownedCommitRepository);
-            const previousRevision = previousOwnedCommitMap.get(ownedCommitRepository);
-            if (currentRevision != previousRevision)
-                difference.set(ownedCommitRepository, [previousRevision, currentRevision]);
+            const ownedCommits = ownedCommitMapList.map((ownedCommitMap) => ownedCommitMap.get(ownedCommitRepository));
+            const uniqueOwnedCommits = new Set(ownedCommits);
+            if (uniqueOwnedCommits.size > 1)
+                difference.set(ownedCommitRepository, ownedCommits);
         });
         });
-
         return difference;
     }
 
         return difference;
     }
 
index b178ac8..cf8a1cc 100644 (file)
@@ -70,8 +70,8 @@ class CommitSet extends DataModelObject {
     commitForRepository(repository) { return this._repositoryToCommitMap.get(repository); }
     ownerCommitForRepository(repository) { return this._repositoryToCommitOwnerMap.get(repository); }
     topLevelRepositories() { return Repository.sortByNamePreferringOnesWithURL(this._repositories.filter((repository) => !this.ownerRevisionForRepository(repository))); }
     commitForRepository(repository) { return this._repositoryToCommitMap.get(repository); }
     ownerCommitForRepository(repository) { return this._repositoryToCommitOwnerMap.get(repository); }
     topLevelRepositories() { return Repository.sortByNamePreferringOnesWithURL(this._repositories.filter((repository) => !this.ownerRevisionForRepository(repository))); }
-
     ownedRepositoriesForOwnerRepository(repository) { return this._ownerRepositoryToOwnedRepositoriesMap.get(repository); }
     ownedRepositoriesForOwnerRepository(repository) { return this._ownerRepositoryToOwnedRepositoriesMap.get(repository); }
+    commitForRepository(repository) { return this._repositoryToCommitMap.get(repository); }
 
     revisionForRepository(repository)
     {
 
     revisionForRepository(repository)
     {
@@ -248,8 +248,104 @@ class CustomCommitSet {
     }
 }
 
     }
 }
 
+class IntermediateCommitSet {
+
+    constructor(commitSet)
+    {
+        console.assert(commitSet instanceof CommitSet);
+        this._commitByRepository = new Map;
+        this._ownerToOwnedRepositories = new Map;
+        this._fetchingPromiseByRepository = new Map;
+
+        for (const repository of commitSet.repositories())
+            this.setCommitForRepository(repository, commitSet.commitForRepository(repository), commitSet.ownerCommitForRepository(repository));
+    }
+
+    fetchCommitLogs()
+    {
+        const fetchingPromises = [];
+        for (const [repository, commit] of this._commitByRepository)
+            fetchingPromises.push(this._fetchCommitLogAndOwnedCommits(repository, commit.revision()));
+        return Promise.all(fetchingPromises);
+    }
+
+    _fetchCommitLogAndOwnedCommits(repository, revision)
+    {
+        return CommitLog.fetchForSingleRevision(repository, revision).then((commits) => {
+            console.assert(commits.length === 1);
+            const commit = commits[0];
+            if (!commit.ownsCommits())
+                return commit;
+            return commit.fetchOwnedCommits().then(() => commit);
+        });
+    }
+
+    updateRevisionForOwnerRepository(repository, revision)
+    {
+        const fetchingPromise = this._fetchCommitLogAndOwnedCommits(repository, revision);
+        this._fetchingPromiseByRepository.set(repository, fetchingPromise);
+        return fetchingPromise.then((commit) => {
+            const currentFetchingPromise = this._fetchingPromiseByRepository.get(repository);
+            if (currentFetchingPromise !== fetchingPromise)
+                return;
+            this._fetchingPromiseByRepository.set(repository, null);
+            this.setCommitForRepository(repository, commit);
+        });
+    }
+
+    setCommitForRepository(repository, commit, ownerCommit = null)
+    {
+        console.assert(repository instanceof Repository);
+        console.assert(commit instanceof CommitLog);
+        this._commitByRepository.set(repository, commit);
+        if (!ownerCommit)
+            ownerCommit = commit.ownerCommit();
+        if (ownerCommit) {
+            const ownerRepository = ownerCommit.repository();
+            if (!this._ownerToOwnedRepositories.has(ownerRepository))
+                this._ownerToOwnedRepositories.set(ownerRepository, new Set);
+            const repositorySet = this._ownerToOwnedRepositories.get(ownerRepository);
+            repositorySet.add(repository);
+        }
+    }
+
+    removeCommitForRepository(repository)
+    {
+        console.assert(repository instanceof Repository);
+        this._fetchingPromiseByRepository.set(repository, null);
+        const ownerCommit = this.ownerCommitForRepository(repository);
+        if (ownerCommit) {
+            const repositorySet = this._ownerToOwnedRepositories.get(ownerCommit.repository());
+            console.assert(repositorySet.has(repository));
+            repositorySet.delete(repository);
+        } else if (this._ownerToOwnedRepositories.has(repository)) {
+            const ownedRepositories = this._ownerToOwnedRepositories.get(repository);
+            for (const ownedRepository of ownedRepositories)
+                this._commitByRepository.delete(ownedRepository);
+            this._ownerToOwnedRepositories.delete(repository);
+        }
+        this._commitByRepository.delete(repository);
+    }
+
+    ownsCommitsForRepository(repository) { return this.commitForRepository(repository).ownsCommits(); }
+
+    repositories() { return Array.from(this._commitByRepository.keys()); }
+    highestLevelRepositories() { return Repository.sortByNamePreferringOnesWithURL(this.repositories().filter((repository) => !this.ownerCommitForRepository(repository))); }
+    commitForRepository(repository) { return this._commitByRepository.get(repository); }
+    ownedRepositoriesForOwnerRepository(repository) { return this._ownerToOwnedRepositories.get(repository); }
+
+    ownerCommitForRepository(repository)
+    {
+        const commit = this._commitByRepository.get(repository);
+        if (!commit)
+            return null;
+        return commit.ownerCommit();
+    }
+}
+
 if (typeof module != 'undefined') {
     module.exports.CommitSet = CommitSet;
     module.exports.MeasurementCommitSet = MeasurementCommitSet;
     module.exports.CustomCommitSet = CustomCommitSet;
 if (typeof module != 'undefined') {
     module.exports.CommitSet = CommitSet;
     module.exports.MeasurementCommitSet = MeasurementCommitSet;
     module.exports.CustomCommitSet = CustomCommitSet;
+    module.exports.IntermediateCommitSet = IntermediateCommitSet;
 }
 }
index aad8e1e..d0efe7c 100644 (file)
@@ -16,7 +16,6 @@ importFromV3('models/build-request.js', 'BuildRequest');
 importFromV3('models/builder.js', 'Build');
 importFromV3('models/builder.js', 'Builder');
 importFromV3('models/commit-log.js', 'CommitLog');
 importFromV3('models/builder.js', 'Build');
 importFromV3('models/builder.js', 'Builder');
 importFromV3('models/commit-log.js', 'CommitLog');
-importFromV3('models/commit-set.js', 'CustomCommitSet')
 importFromV3('models/manifest.js', 'Manifest');
 importFromV3('models/measurement-adaptor.js', 'MeasurementAdaptor');
 importFromV3('models/measurement-cluster.js', 'MeasurementCluster');
 importFromV3('models/manifest.js', 'Manifest');
 importFromV3('models/measurement-adaptor.js', 'MeasurementAdaptor');
 importFromV3('models/measurement-cluster.js', 'MeasurementCluster');
@@ -27,6 +26,7 @@ importFromV3('models/repository.js', 'Repository');
 importFromV3('models/commit-set.js', 'MeasurementCommitSet');
 importFromV3('models/commit-set.js', 'CommitSet');
 importFromV3('models/commit-set.js', 'CustomCommitSet');
 importFromV3('models/commit-set.js', 'MeasurementCommitSet');
 importFromV3('models/commit-set.js', 'CommitSet');
 importFromV3('models/commit-set.js', 'CustomCommitSet');
+importFromV3('models/commit-set.js', 'IntermediateCommitSet');
 importFromV3('models/test.js', 'Test');
 importFromV3('models/test-group.js', 'TestGroup');
 importFromV3('models/time-series.js', 'TimeSeries');
 importFromV3('models/test.js', 'Test');
 importFromV3('models/test-group.js', 'TestGroup');
 importFromV3('models/time-series.js', 'TimeSeries');
index 3b31e5c..d9263b0 100644 (file)
@@ -191,7 +191,8 @@ describe('CommitLog', function () {
         });
 
         it('should return owned-commit for a valid commit revision', () => {
         });
 
         it('should return owned-commit for a valid commit revision', () => {
-            const fetchingPromise = ownerCommit().fetchOwnedCommits();
+            const commit = ownerCommit();
+            const fetchingPromise = commit.fetchOwnedCommits();
             const requests = MockRemoteAPI.requests;
             assert.equal(requests.length, 1);
             assert.equal(requests[0].url, '../api/commits/111/owned-commits?owner-revision=10.11.4%2015E65');
             const requests = MockRemoteAPI.requests;
             assert.equal(requests.length, 1);
             assert.equal(requests[0].url, '../api/commits/111/owned-commits?owner-revision=10.11.4%2015E65');
@@ -208,6 +209,7 @@ describe('CommitLog', function () {
                 assert.equal(ownedCommits[0].repository(), MockModels.ownedRepository);
                 assert.equal(ownedCommits[0].revision(), '6f8b0dbbda95a440503b88db1dd03dad3a7b07fb');
                 assert.equal(ownedCommits[0].id(), 233);
                 assert.equal(ownedCommits[0].repository(), MockModels.ownedRepository);
                 assert.equal(ownedCommits[0].revision(), '6f8b0dbbda95a440503b88db1dd03dad3a7b07fb');
                 assert.equal(ownedCommits[0].id(), 233);
+                assert.equal(ownedCommits[0].ownerCommit(), commit);
             });
         });
 
             });
         });
 
@@ -233,6 +235,7 @@ describe('CommitLog', function () {
                 assert.equal(ownedCommits[0].repository(), MockModels.ownedRepository);
                 assert.equal(ownedCommits[0].revision(), '6f8b0dbbda95a440503b88db1dd03dad3a7b07fb');
                 assert.equal(ownedCommits[0].id(), 233);
                 assert.equal(ownedCommits[0].repository(), MockModels.ownedRepository);
                 assert.equal(ownedCommits[0].revision(), '6f8b0dbbda95a440503b88db1dd03dad3a7b07fb');
                 assert.equal(ownedCommits[0].id(), 233);
+                assert.equal(ownedCommits[0].ownerCommit(), commit);
                 return commit.fetchOwnedCommits();
             }).then((ownedCommits) => {
                 assert.equal(requests.length, 1);
                 return commit.fetchOwnedCommits();
             }).then((ownedCommits) => {
                 assert.equal(requests.length, 1);
@@ -241,12 +244,12 @@ describe('CommitLog', function () {
         });
     });
 
         });
     });
 
-    describe('diffOwnedCommits', () => {
+    describe('ownedCommitDifferenceForOwnerCommits', () => {
         beforeEach(() => {
             MockRemoteAPI.reset();
         });
 
         beforeEach(() => {
             MockRemoteAPI.reset();
         });
 
-        it('should return difference between 2 owned-commits', () => {
+        it('should return difference between owned-commits of 2 owner commits', () => {
             const oneCommit = ownerCommit();
             const otherCommit = otherOwnerCommit();
             const fetchingPromise = oneCommit.fetchOwnedCommits();
             const oneCommit = ownerCommit();
             const otherCommit = otherOwnerCommit();
             const fetchingPromise = oneCommit.fetchOwnedCommits();
@@ -267,7 +270,17 @@ describe('CommitLog', function () {
                 time: +(new Date('2016-05-13T00:55:57.841344Z')),
             }]});
 
                 time: +(new Date('2016-05-13T00:55:57.841344Z')),
             }]});
 
-            return fetchingPromise.then(() => {
+            return fetchingPromise.then((ownedCommits) => {
+                assert.equal(ownedCommits.length, 2);
+                assert.equal(ownedCommits[0].repository(), MockModels.ownedRepository);
+                assert.equal(ownedCommits[0].revision(), '6f8b0dbbda95a440503b88db1dd03dad3a7b07fb');
+                assert.equal(ownedCommits[0].id(), 233);
+                assert.equal(ownedCommits[0].ownerCommit(), oneCommit);
+                assert.equal(ownedCommits[1].repository(), MockModels.webkitGit);
+                assert.equal(ownedCommits[1].revision(), '04a6c72038f0b771a19248ca2549e1258617b5fc');
+                assert.equal(ownedCommits[1].id(), 299);
+                assert.equal(ownedCommits[1].ownerCommit(), oneCommit);
+
                 const otherFetchingPromise = otherCommit.fetchOwnedCommits();
                 assert.equal(requests.length, 2);
                 assert.equal(requests[1].url, '../api/commits/111/owned-commits?owner-revision=10.11.4%2015E66');
                 const otherFetchingPromise = otherCommit.fetchOwnedCommits();
                 assert.equal(requests.length, 2);
                 assert.equal(requests[1].url, '../api/commits/111/owned-commits?owner-revision=10.11.4%2015E66');
@@ -286,8 +299,17 @@ describe('CommitLog', function () {
                 }]});
 
                 return otherFetchingPromise;
                 }]});
 
                 return otherFetchingPromise;
-            }).then(() => {
-                const difference = CommitLog.diffOwnedCommits(oneCommit, otherCommit);
+            }).then((ownedCommits) => {
+                assert.equal(ownedCommits.length, 2);
+                assert.equal(ownedCommits[0].repository(), MockModels.ownedRepository);
+                assert.equal(ownedCommits[0].revision(), 'd5099e03b482abdd77f6c4dcb875afd05bda5ab8');
+                assert.equal(ownedCommits[0].id(), 234);
+                assert.equal(ownedCommits[0].ownerCommit(), otherCommit);
+                assert.equal(ownedCommits[1].repository(), MockModels.webkitGit);
+                assert.equal(ownedCommits[1].revision(), '04a6c72038f0b771a19248ca2549e1258617b5fc');
+                assert.equal(ownedCommits[1].id(), 299);
+                assert.equal(ownedCommits[1].ownerCommit(), otherCommit);
+                const difference = CommitLog.ownedCommitDifferenceForOwnerCommits(oneCommit, otherCommit);
                 assert.equal(difference.size, 1);
                 assert.equal(difference.keys().next().value, MockModels.ownedRepository);
             });
                 assert.equal(difference.size, 1);
                 assert.equal(difference.keys().next().value, MockModels.ownedRepository);
             });
index ab76d58..ebb7bcd 100644 (file)
@@ -3,6 +3,7 @@
 const assert = require('assert');
 require('../tools/js/v3-models.js');
 const MockModels = require('./resources/mock-v3-models.js').MockModels;
 const assert = require('assert');
 require('../tools/js/v3-models.js');
 const MockModels = require('./resources/mock-v3-models.js').MockModels;
+const MockRemoteAPI = require('../unit-tests/resources/mock-remote-api.js').MockRemoteAPI;
 
 function createPatch()
 {
 
 function createPatch()
 {
@@ -51,6 +52,272 @@ function customCommitSetWithOwnedRepositoryHasSameNameAsNotOwnedRepository()
     return customCommitSet;
 }
 
     return customCommitSet;
 }
 
+function ownerCommit()
+{
+    return new CommitLog(5, {
+        repository: MockModels.ownerRepository,
+        revision: 'owner-commit-0',
+        ownsCommits: true,
+        time: null,
+    });
+}
+
+function partialOwnerCommit()
+{
+    return new CommitLog(5, {
+        repository: MockModels.ownerRepository,
+        revision: 'owner-commit-0',
+        ownsCommits: null,
+        time: +(new Date('2016-05-13T00:55:57.841344Z')),
+    });
+}
+
+function ownedCommit()
+{
+    return new CommitLog(6, {
+        repository: MockModels.ownedRepository,
+        revision: 'owned-commit-0',
+        ownsCommits: null,
+        time: 1456932774000
+    });
+}
+
+function webkitCommit()
+{
+    return new CommitLog(2017, {
+        repository: MockModels.webkit,
+        revision: 'webkit-commit-0',
+        ownsCommits: false,
+        time: 1456932773000
+    });
+}
+
+describe('IntermediateCommitSet', () => {
+    MockRemoteAPI.inject();
+    MockModels.inject();
+
+    describe('setCommitForRepository', () => {
+        it('should allow set commit for owner repository', () => {
+            const commitSet = new IntermediateCommitSet(new CommitSet);
+            const commit = ownerCommit();
+            commitSet.setCommitForRepository(MockModels.ownerRepository, commit);
+            assert.equal(commit, commitSet.commitForRepository(MockModels.ownerRepository));
+        });
+
+        it('should allow set commit for owned repository', () => {
+            const commitSet = new IntermediateCommitSet(new CommitSet);
+            const commit = ownerCommit();
+
+            const fetchingPromise = commit.fetchOwnedCommits();
+            const requests = MockRemoteAPI.requests;
+            assert.equal(requests.length, 1);
+            assert.equal(requests[0].url, '../api/commits/111/owned-commits?owner-revision=owner-commit-0');
+            assert.equal(requests[0].method, 'GET');
+
+            requests[0].resolve({commits: [{
+                id: 233,
+                repository: MockModels.ownedRepository.id(),
+                revision: '6f8b0dbbda95a440503b88db1dd03dad3a7b07fb',
+                time: +(new Date('2016-05-13T00:55:57.841344Z')),
+            }]});
+
+            return fetchingPromise.then(() => {
+                const ownedCommit = commit.ownedCommits()[0];
+                commitSet.setCommitForRepository(MockModels.ownerRepository, commit);
+                commitSet.setCommitForRepository(MockModels.ownedRepository, ownedCommit);
+                assert.equal(commit, commitSet.commitForRepository(MockModels.ownerRepository));
+                assert.equal(ownedCommit, commitSet.commitForRepository(MockModels.ownedRepository));
+                assert.deepEqual(commitSet.repositories(), [MockModels.ownerRepository, MockModels.ownedRepository]);
+            });
+        });
+    });
+
+    describe('fetchCommitLogs', () => {
+
+        it('should fetch CommitLog object with owned commits information',  () => {
+            const commit = partialOwnerCommit();
+            assert.equal(commit.ownsCommits(), null);
+            const owned = ownedCommit();
+
+            const commitSet = CommitSet.ensureSingleton('53246456', {revisionItems: [{commit}, {commit: owned, ownerCommit: commit}]});
+            const intermediateCommitSet =new IntermediateCommitSet(commitSet);
+            const fetchingPromise = intermediateCommitSet.fetchCommitLogs();
+
+            const requests = MockRemoteAPI.requests;
+            assert.equal(requests.length, 2);
+            assert.equal(requests[0].url, '/api/commits/111/owner-commit-0');
+            assert.equal(requests[0].method, 'GET');
+            assert.equal(requests[1].url, '/api/commits/112/owned-commit-0');
+            assert.equal(requests[1].method, 'GET');
+
+            requests[0].resolve({commits: [{
+                id: 5,
+                repository: MockModels.ownerRepository,
+                revision: 'owner-commit-0',
+                ownsCommits: true,
+                time: +(new Date('2016-05-13T00:55:57.841344Z')),
+            }]});
+            requests[1].resolve({commits: [{
+                id: 6,
+                repository: MockModels.ownedRepository,
+                revision: 'owned-commit-0',
+                ownsCommits: false,
+                time: 1456932774000,
+            }]});
+
+            return MockRemoteAPI.waitForRequest().then(() => {
+                assert.equal(requests.length, 3);
+                assert.equal(requests[2].url, '../api/commits/111/owned-commits?owner-revision=owner-commit-0');
+                assert.equal(requests[2].method, 'GET');
+
+                requests[2].resolve({commits: [{
+                    id: 6,
+                    repository: MockModels.ownedRepository.id(),
+                    revision: 'owned-commit-0',
+                    ownsCommits: false,
+                    time: 1456932774000,
+                }]});
+                return fetchingPromise;
+            }).then(() => {
+                assert(commit.ownsCommits());
+                assert.equal(commit.ownedCommits().length, 1);
+                assert.equal(commit.ownedCommits()[0], owned);
+                assert.equal(owned.ownerCommit(), commit);
+                assert.equal(owned.repository(), MockModels.ownedRepository);
+                assert.equal(intermediateCommitSet.commitForRepository(MockModels.ownedRepository), owned);
+                assert.equal(intermediateCommitSet.ownerCommitForRepository(MockModels.ownedRepository), commit);
+                assert.deepEqual(intermediateCommitSet.repositories(), [MockModels.ownerRepository, MockModels.ownedRepository]);
+            });
+        });
+    });
+
+    describe('updateRevisionForOwnerRepository', () => {
+
+        it('should update CommitSet based on the latest invocation', () => {
+            const commitSet = new IntermediateCommitSet(new CommitSet);
+            const firstUpdatePromise = commitSet.updateRevisionForOwnerRepository(MockModels.webkit, 'webkit-commit-0');
+            const secondUpdatePromise = commitSet.updateRevisionForOwnerRepository(MockModels.webkit, 'webkit-commit-1');
+            const requests = MockRemoteAPI.requests;
+
+            assert(requests.length, 2);
+            assert.equal(requests[0].url, '/api/commits/11/webkit-commit-0');
+            assert.equal(requests[0].method, 'GET');
+            assert.equal(requests[1].url, '/api/commits/11/webkit-commit-1');
+            assert.equal(requests[1].method, 'GET');
+
+            requests[1].resolve({commits: [{
+                id: 2018,
+                repository: MockModels.webkit.id(),
+                revision: 'webkit-commit-1',
+                ownsCommits: false,
+                time: 1456932774000,
+            }]});
+
+            let commit = null;
+            return secondUpdatePromise.then(() => {
+                commit = commitSet.commitForRepository(MockModels.webkit);
+
+                requests[0].resolve({commits: [{
+                    id: 2017,
+                    repository: MockModels.webkit.id(),
+                    revision: 'webkit-commit-0',
+                    ownsCommits: false,
+                    time: 1456932773000,
+                }]});
+
+                assert.equal(commit.revision(), 'webkit-commit-1');
+                assert.equal(commit.id(), 2018);
+
+                return firstUpdatePromise;
+            }).then(() => {
+                const currentCommit = commitSet.commitForRepository(MockModels.webkit);
+                assert.equal(commit, currentCommit);
+            });
+        });
+
+    });
+
+    describe('removeCommitForRepository', () => {
+        it('should remove owned commits when owner commit is removed', () => {
+            const commitSet = new IntermediateCommitSet(new CommitSet);
+            const commit = ownerCommit();
+
+            const fetchingPromise = commit.fetchOwnedCommits();
+            const requests = MockRemoteAPI.requests;
+            assert.equal(requests.length, 1);
+            assert.equal(requests[0].url, '../api/commits/111/owned-commits?owner-revision=owner-commit-0');
+            assert.equal(requests[0].method, 'GET');
+
+            requests[0].resolve({commits: [{
+                id: 233,
+                repository: MockModels.ownedRepository.id(),
+                revision: '6f8b0dbbda95a440503b88db1dd03dad3a7b07fb',
+                ownsCommits: true
+            }]});
+
+            return fetchingPromise.then(() => {
+                commitSet.setCommitForRepository(MockModels.ownerRepository, commit);
+                commitSet.setCommitForRepository(MockModels.ownedRepository, commit.ownedCommits()[0]);
+                commitSet.removeCommitForRepository(MockModels.ownerRepository);
+                assert.deepEqual(commitSet.repositories(), []);
+            });
+        });
+
+        it('should not remove owner commits when owned commit is removed', () => {
+            const commitSet = new IntermediateCommitSet(new CommitSet);
+            const commit = ownerCommit();
+
+            const fetchingPromise = commit.fetchOwnedCommits();
+            const requests = MockRemoteAPI.requests;
+            assert.equal(requests.length, 1);
+            assert.equal(requests[0].url, '../api/commits/111/owned-commits?owner-revision=owner-commit-0');
+            assert.equal(requests[0].method, 'GET');
+
+            requests[0].resolve({commits: [{
+                id: 233,
+                repository: MockModels.ownedRepository.id(),
+                revision: '6f8b0dbbda95a440503b88db1dd03dad3a7b07fb',
+                time: +(new Date('2016-05-13T00:55:57.841344Z')),
+            }]});
+
+            return fetchingPromise.then(() => {
+                commitSet.setCommitForRepository(MockModels.ownerRepository, commit);
+                commitSet.setCommitForRepository(MockModels.ownedRepository, commit.ownedCommits()[0]);
+                commitSet.removeCommitForRepository(MockModels.ownedRepository);
+                assert.deepEqual(commitSet.repositories(), [MockModels.ownerRepository]);
+            });
+        });
+
+        it('should not update commit set for repository if removeCommitForRepository called before updateRevisionForOwnerRepository finishes', () => {
+            const commitSet = new IntermediateCommitSet(new CommitSet);
+            const commit = webkitCommit();
+            commitSet.setCommitForRepository(MockModels.webkit, commit);
+            const updatePromise = commitSet.updateRevisionForOwnerRepository(MockModels.webkit, 'webkit-commit-1');
+
+            commitSet.removeCommitForRepository(MockModels.webkit);
+
+            const requests = MockRemoteAPI.requests;
+            assert.equal(requests[0].url, '/api/commits/11/webkit-commit-1');
+            assert.equal(requests[0].method, 'GET');
+
+            requests[0].resolve({commits: [{
+                id: 2018,
+                repository: MockModels.webkit.id(),
+                revision: 'webkit-commit-1',
+                ownsCommits: false,
+                time: 1456932774000,
+            }]});
+
+            return updatePromise.then(() => {
+                assert.deepEqual(commitSet.repositories(), []);
+                assert(!commitSet.commitForRepository(MockModels.webkit));
+            });
+        });
+
+    });
+
+});
+
 describe('CustomCommitSet', () => {
     MockModels.inject();
 
 describe('CustomCommitSet', () => {
     MockModels.inject();