diff options
author | Daniel Smith <[email protected]> | 2025-06-19 11:12:47 +0200 |
---|---|---|
committer | Daniel Smith <[email protected]> | 2025-06-30 09:54:48 +0000 |
commit | 42aeaf259e2281683299794c508e1d8c7812806a (patch) | |
tree | e7953bcbf16c6efa5cd08b836703d05737d7c150 | |
parent | c39a21a4154e7f89bb261e6b94de47c8ba352963 (diff) |
Fixes: QTQAINFRA-7256
Fixes: QTQAINFRA-7257
Fixes: QTQAINFRA-7259
Change-Id: Id9c851b26fb2f7a8fbf2a5e0b6d2722e4306aabe
Reviewed-by: Daniel Smith <[email protected]>
-rw-r--r-- | src/main/java/com/google/gerrit/plugins/cherrypickstatus/CherryPickRestApi.java | 93 | ||||
-rw-r--r-- | src/main/resources/static/cherrypick-status-viewer.js | 608 |
2 files changed, 512 insertions, 189 deletions
diff --git a/src/main/java/com/google/gerrit/plugins/cherrypickstatus/CherryPickRestApi.java b/src/main/java/com/google/gerrit/plugins/cherrypickstatus/CherryPickRestApi.java index 7983f32..d7c14e4 100644 --- a/src/main/java/com/google/gerrit/plugins/cherrypickstatus/CherryPickRestApi.java +++ b/src/main/java/com/google/gerrit/plugins/cherrypickstatus/CherryPickRestApi.java @@ -121,6 +121,22 @@ public class CherryPickRestApi { this.hashtags = hashtags; } + // Option for filtering by changeId or sha1. Populated from query parameters. + @Option(name = "--changeid", usage = "Gerrit Change-Id or commit sha1") + private String changeId; + + public void setChangeId(String changeId) { + this.changeId = changeId; + } + + // Option for free-text search on subject. Populated from query parameters. + @Option(name = "--subject", usage = "Free-text search for subject line") + private String subject; + + public void setSubject(String subject) { + this.subject = subject; + } + // Constructor for dependency injection. Initializes fields. @Inject public GetDashboard( @@ -141,15 +157,15 @@ public class CherryPickRestApi { // Determines the set of projects visible to the current user based on READ permissions. // Filters by repoName if provided, otherwise checks all projects. - public Set<Project.NameKey> getVisibleProjects(CurrentUser user) + public Set<Project.NameKey> getVisibleProjects(CurrentUser user, String repoFilter) throws PermissionBackendException { Set<Project.NameKey> visibleProjects = new TreeSet<>(); // Use TreeSet for sorted order. try { PermissionBackend.WithUser withUser = permissionBackend.user(user); // If a specific repository is requested, check permission for that one. - if (repoName != null && !repoName.isEmpty()) { - Project.NameKey repoKey = Project.NameKey.parse(repoName); + if (repoFilter != null && !repoFilter.isEmpty()) { + Project.NameKey repoKey = Project.NameKey.parse(repoFilter); // Filter the single repo based on user's READ permission. visibleProjects.addAll(withUser.filter(ProjectPermission.READ, Set.of(repoKey))); } else { @@ -203,7 +219,10 @@ public class CherryPickRestApi { // Determine visible projects and add them to the request body. try { - Set<Project.NameKey> visibleProjects = getVisibleProjects(user); + // If searching by changeId, ignore the repoName filter from the query params. + String effectiveRepoFilter = + (changeId != null && !changeId.isEmpty()) ? null : repoName; + Set<Project.NameKey> visibleProjects = getVisibleProjects(user, effectiveRepoFilter); List<String> repos = visibleProjects.stream().map(Project.NameKey::get).collect(Collectors.toList()); requestBody.put("repos", repos); // Add list of visible repo names. @@ -213,35 +232,43 @@ public class CherryPickRestApi { } // Add optional filters to the request body if they are set. - if (owner != null && !owner.isEmpty()) { - requestBody.put("owner", owner); - } - if (onlyStuck) { - requestBody.put("onlyStuck", true); - } - if (exclAbandon) { - requestBody.put("exclAbandon", true); - } - if (onlyExternalTqtc) { - requestBody.put("onlyExternalTqtc", true); - } - if (branches != null && !branches.isEmpty()) { - // Split the comma-separated string into a list of branch names - List<String> branchList = List.of(branches.split(",")); - requestBody.put("branches", branchList); // Pass branches as a list of strings - } - if (hashtags != null && !hashtags.isEmpty()) { - // Split the comma-separated string into a list of hashtag names - List<String> hashtagList = List.of(hashtags.split(",")); - requestBody.put("hashtags", hashtagList); // Pass hashtags as a list of strings - } - - // Add pagination parameter. Convert page string to integer. - try { - requestBody.put("page", Integer.parseInt(page)); - } catch (NumberFormatException e) { - log.warn("Invalid page parameter received: {}", page); - throw RestApiException.wrap("Invalid page parameter: " + page, e); + if (changeId != null && !changeId.isEmpty()) { + requestBody.put("changeId", changeId); + // When searching by changeId, other filters and pagination are ignored by the backend. + } else { + if (subject != null && !subject.isEmpty()) { + requestBody.put("subject", subject); + } + if (owner != null && !owner.isEmpty()) { + requestBody.put("owner", owner); + } + if (onlyStuck) { + requestBody.put("onlyStuck", true); + } + if (exclAbandon) { + requestBody.put("exclAbandon", true); + } + if (onlyExternalTqtc) { + requestBody.put("onlyExternalTqtc", true); + } + if (branches != null && !branches.isEmpty()) { + // Split the comma-separated string into a list of branch names + List<String> branchList = List.of(branches.split(",")); + requestBody.put("branches", branchList); // Pass branches as a list of strings + } + if (hashtags != null && !hashtags.isEmpty()) { + // Split the comma-separated string into a list of hashtag names + List<String> hashtagList = List.of(hashtags.split(",")); + requestBody.put("hashtags", hashtagList); // Pass hashtags as a list of strings + } + + // Add pagination parameter. Convert page string to integer. + try { + requestBody.put("page", Integer.parseInt(page)); + } catch (NumberFormatException e) { + log.warn("Invalid page parameter received: {}", page); + throw RestApiException.wrap("Invalid page parameter: " + page, e); + } } // Serialize the request body to JSON. diff --git a/src/main/resources/static/cherrypick-status-viewer.js b/src/main/resources/static/cherrypick-status-viewer.js index 2747d60..78049e8 100644 --- a/src/main/resources/static/cherrypick-status-viewer.js +++ b/src/main/resources/static/cherrypick-status-viewer.js @@ -40,6 +40,51 @@ Gerrit.install(plugin => { return debounced; } + function getStatusByEnum(status, dependsOn) { + const statusToColor = { + NOT_CREATED: { + textOverride: dependsOn + ? `Depends on ${dependsOn}` : 'Not yet picked', bg: 'lightgrey', text: 'black' + }, + NEW: { textOverride: "New", bg: 'hsl(48, 100%, 50%)', text: 'black' }, + MERGE_CONFLICT: { textOverride: "Merge conflict", bg: 'orange', text: 'black' }, + MERGED: { textOverride: "Merged", bg: 'hsl(120, 100%, 30%)', text: 'white' }, + ABANDONED: { textOverride: "Abandoned", bg: '#ccccff', text: 'black' }, + DEFERRED: { textOverride: "Deferred", bg: '#ffccff', text: 'black' }, + STAGED: { textOverride: "Integrating", bg: 'rgb(204, 255, 153)', text: 'black' }, + INTEGRATING: { textOverride: "Integrating", bg: 'rgb(204, 255, 153)', text: 'black' }, + FAILED_CI: { textOverride: "CI Failed", bg: 'darkred', text: 'white' } + }; + return statusToColor[status] || { bg: '', text: '' }; + } + + function sortBranchNames(branches) { + return branches.sort((a, b) => { + const [aMajor, aMinor, aPatch] = a.split('.').map(Number); + const [bMajor, bMinor, bPatch] = b.split('.').map(Number); + + if (!(a.match(/\d/))) { + if (a === "master" || a === "dev") + return -1; + else if (a.match(/\w/)) + return 1; + } + if (aMajor !== bMajor) { + return bMajor - aMajor; + } + if (aMinor !== bMinor) { + return bMinor - aMinor; + } + if (aPatch === undefined) { + return -1; + } + if (bPatch === undefined) { + return 1; + } + return (bPatch || 0) - (aPatch || 0); + }); + } + class CherryPickMain extends HTMLElement { constructor() { super(); @@ -55,6 +100,8 @@ Gerrit.install(plugin => { // Read initial state from URL parameters const urlParams = new URLSearchParams(window.location.search); this.currentFilters = { + changeid: urlParams.get('changeid') || '', + subject: urlParams.get('subject') || '', owner: urlParams.get('owner') || '', repo: urlParams.get('repo') || '', onlyStuck: urlParams.get('onlyStuck') === 'true', @@ -239,33 +286,33 @@ Gerrit.install(plugin => { th:hover .branch-filter-icon { /* Branch header */ display: inline-block; } + + .header-wrapper { + display: flex; + align-items: center; + gap: 0.5rem; + } + .header-input { + width: 130px; /* Enough for placeholder */ + transition: width 0.25s ease-in-out; + padding: 0.25rem; + border: 1px solid #ccc; + border-radius: 3px; + } + .header-input:focus { + width: 100%; /* Expand to fill flex container */ + } </style> <div class="filter-container"> <!-- Group 1: Text Filters (Owner, Repo, Branch) & Branch Tags --> <div class="filter-group"> - <!-- Owner Filter --> + <!-- Change ID Filter --> <div class="filter-item tooltip-container"> - <label for="ownerFilter">Owner:</label> - <input class="filter-input" type="text" id="ownerFilter" list="ownerSuggestions" placeholder="Filter by owner email"> - <datalist id="ownerSuggestions"></datalist> - <span class="tooltip-text">Filter changes by owner's email address (full match, select from suggestions).</span> - </div> - <!-- Only Mine Filter --> - ${isLoggedIn ? ` - <div class="filter-item tooltip-container" style="padding-top:7px"> - <input type="checkbox" id="onlyMineFilter" name="onlyMineFilter"> - <label for="onlyMineFilter" class="filter-checkbox-label">Only Mine</label> - <span class="tooltip-text">Show only changes owned by you.</span> - </div> - ` : ''} - <!-- Repo Filter --> - <div class="filter-item tooltip-container"> - <label for="repoFilter">Repo:</label> - <input class="filter-input" type="text" id="repoFilter" list="repoSuggestions" placeholder="Filter by repository"> - <datalist id="repoSuggestions"></datalist> - <span class="tooltip-text">Filter changes by repository name (prefix or full match).</span> + <label for="changeIdFilter">Change ID/SHA1:</label> + <input class="filter-input" type="text" id="changeIdFilter" placeholder="Gerrit Change-Id or SHA1"> + <span class="tooltip-text">Search by Gerrit Change-Id (I-prefixed) or commit SHA1 (7-40 hex chars). This ignores other filters.</span> </div> <!-- Branch Filter Wrapper --> <div class="branch-filter-wrapper"> @@ -298,18 +345,26 @@ Gerrit.install(plugin => { <!-- Group 2: Status Checkboxes --> <div class="filter-group"> - <!-- Only Stuck Filter --> - <div class="filter-item tooltip-container"> - <input type="checkbox" id="onlyStuckFilter" name="onlyStuckFilter"> - <label for="onlyStuckFilter" class="filter-checkbox-label">Only Stuck</label> - <span class="tooltip-text">Show only changes that are not yet merged or actively integrating. (Automatically enabled when filtering by branch).</span> - </div> - <!-- Exclude Abandon Filter --> + <!-- Only Stuck Filter --> + <div class="filter-item tooltip-container"> + <input type="checkbox" id="onlyStuckFilter" name="onlyStuckFilter"> + <label for="onlyStuckFilter" class="filter-checkbox-label">Only Stuck</label> + <span class="tooltip-text">Show only changes that are not yet merged or actively integrating. (Automatically enabled when filtering by branch).</span> + </div> + <!-- Exclude Abandon Filter --> + <div class="filter-item tooltip-container"> + <input type="checkbox" id="exclAbandonFilter" name="exclAbandonFilter"> + <label for="exclAbandonFilter" class="filter-checkbox-label">Exclude Abandon</label> + <span class="tooltip-text">Hide changes where the only unmerged branch targets are abandoned, or depend directly on an abandonded change. Requires 'Only Stuck' to be enabled.</span> + </div> + <!-- Only Mine Filter --> + ${isLoggedIn ? ` <div class="filter-item tooltip-container"> - <input type="checkbox" id="exclAbandonFilter" name="exclAbandonFilter"> - <label for="exclAbandonFilter" class="filter-checkbox-label">Exclude Abandon</label> - <span class="tooltip-text">Hide changes where the only unmerged branch targets are abandoned, or depend directly on an abandonded change. Requires 'Only Stuck' to be enabled.</span> + <input type="checkbox" id="onlyMineFilter" name="onlyMineFilter"> + <label for="onlyMineFilter" class="filter-checkbox-label">Only Mine</label> + <span class="tooltip-text">Show only changes owned by you.</span> </div> + ` : ''} <!-- Only Externals Filter --> <div class="filter-item tooltip-container"> <input type="checkbox" id="onlyExternalTqtcFilter" name="onlyExternalTqtcFilter" disabled> <!-- Initially disabled --> @@ -350,18 +405,17 @@ Gerrit.install(plugin => { <button id="nextPage">></button> </div> `; - if (isLoggedIn) { - this.onlyMineFilterCheckbox = this.shadowRoot.getElementById('onlyMineFilter'); - this.onlyMineFilterCheckbox?.addEventListener('change', () => - this.handleOnlyMineFilterChange()); // Use optional chaining - } - - this.ownerFilterInput = this.shadowRoot.getElementById('ownerFilter'); - this.repoFilterInput = this.shadowRoot.getElementById('repoFilter'); + this.onlyMineFilterCheckbox = null; + this.ownerFilterInput = null; + this.repoFilterInput = null; + this.repoSuggestionsDatalist = null; + this.ownerSuggestionsDatalist = null; + this.subjectSearchInput = null; // Will be assigned in _render + this.userEmail = null; + + this.changeIdFilterInput = this.shadowRoot.getElementById('changeIdFilter'); this.applyFiltersButton = this.shadowRoot.getElementById('applyFilters'); this.clearFiltersButton = this.shadowRoot.getElementById('clearFilters'); - this.repoSuggestionsDatalist = this.shadowRoot.getElementById('repoSuggestions'); - this.ownerSuggestionsDatalist = this.shadowRoot.getElementById('ownerSuggestions'); this.onlyStuckFilterCheckbox = this.shadowRoot.getElementById('onlyStuckFilter'); this.exclAbandonFilterCheckbox = this.shadowRoot.getElementById('exclAbandonFilter'); this.onlyExternalTqtcFilterCheckbox = this.shadowRoot.getElementById('onlyExternalTqtcFilter'); @@ -373,6 +427,15 @@ Gerrit.install(plugin => { this.hashtagFilterTagsContainer = this.shadowRoot.getElementById('hashtagFilterTagsContainer'); this.hashtagSuggestionsDatalist = this.shadowRoot.getElementById('hashtagSuggestions'); + // Change ID filter listeners + this.changeIdFilterInput?.addEventListener('input', () => this.updateFilterStates()); + this.changeIdFilterInput?.addEventListener('change', () => this.applyFilters()); + this.changeIdFilterInput?.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + this.applyFilters(); + } + }); + // Remove explicit apply button listener, apply on change/add/remove // this.applyFiltersButton?.addEventListener('click', () => this.applyFilters()); this.clearFiltersButton?.addEventListener('click', () => this.clearFilters()); @@ -382,27 +445,8 @@ Gerrit.install(plugin => { // Debounce owner input for API calls this.debouncedFetchOwners = debounce(this.fetchOwnerSuggestions.bind(this), 300); - this.ownerFilterInput?.addEventListener('input', () => { - this.currentFilters.owner = this.ownerFilterInput.value; - this.debouncedFetchOwners(this.ownerFilterInput.value); // Fetch suggestions on input - }); - this.ownerFilterInput?.addEventListener('change', () => { - // On change (selection, enter, blur), cancel pending fetches and apply - this.debouncedFetchOwners.cancel(); - this.applyFilters(); - }); - // Debounce repo input for API calls this.debouncedFetchRepos = debounce(this.fetchRepoSuggestions.bind(this), 300); - this.repoFilterInput?.addEventListener('input', () => { - this.currentFilters.repo = this.repoFilterInput.value; - this.debouncedFetchRepos(this.repoFilterInput.value); // Fetch suggestions on input - }); - this.repoFilterInput?.addEventListener('change', () => { - // On change (selection, enter, blur), cancel pending fetches and apply - this.debouncedFetchRepos.cancel(); - this.applyFilters(); - }); // Branch filter listeners this.addBranchFilterButton?.addEventListener('click', () => this.addBranchFilter()); @@ -641,25 +685,37 @@ Gerrit.install(plugin => { this.applyFilters(); } - connectedCallback() { + async fetchUserEmail() { + if (!isLoggedIn || this.userEmail) return; + try { + const response = await fetch('http://code.qt.io/a/accounts/self'); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const text = await response.text(); + const data = JSON.parse(text.substring(4)); + this.userEmail = data.email; + } catch (error) { + console.error('Error fetching user email:', error); + } + } + + async connectedCallback() { // Set initial input/checkbox values from currentFilters (read from URL in constructor) - this.ownerFilterInput.value = this.currentFilters.owner; - this.repoFilterInput.value = this.currentFilters.repo; + this.changeIdFilterInput.value = this.currentFilters.changeid; this.onlyStuckFilterCheckbox.checked = this.currentFilters.onlyStuck; // Note: exclAbandon and onlyExternalTqtc are handled by updateDependentCheckboxStates + // We need user's email to correctly determine initial state of "Only Mine" checkbox + await this.fetchUserEmail(); + // Set initial enabled/disabled states and potentially correct checked states + this.updateFilterStates(); this.updateDependentCheckboxStates(); // Ensure the actual checked state matches the filter state after dependency logic this.exclAbandonFilterCheckbox.checked = this.currentFilters.exclAbandon && !this.exclAbandonFilterCheckbox.disabled; this.onlyExternalTqtcFilterCheckbox.checked = this.currentFilters.onlyExternalTqtc && !this.onlyExternalTqtcFilterCheckbox.disabled; - - // Handle 'Only Mine' checkbox state based on loaded owner filter - if (this.onlyMineFilterCheckbox) { - this.onlyMineFilterCheckbox.checked = !!this.currentFilters.owner; - } - // Render initial branch tags this._renderBranchTags(); this._renderHashtagTags(); @@ -676,9 +732,11 @@ Gerrit.install(plugin => { } // Handle browser back/forward button clicks - handlePopState(event) { + async handlePopState(event) { const urlParams = new URLSearchParams(window.location.search); this.currentFilters = { + changeid: urlParams.get('changeid') || '', + subject: urlParams.get('subject') || '', owner: urlParams.get('owner') || '', repo: urlParams.get('repo') || '', onlyStuck: urlParams.get('onlyStuck') === 'true', @@ -691,24 +749,19 @@ Gerrit.install(plugin => { this.selectedBranches = new Set(this.currentFilters.branches); this.selectedHashtags = new Set(this.currentFilters.hashtags); + // We need user's email to correctly determine initial state of "Only Mine" checkbox + await this.fetchUserEmail(); + // Update UI elements to reflect the state from the URL - this.ownerFilterInput.value = this.currentFilters.owner; - this.repoFilterInput.value = this.currentFilters.repo; + this.changeIdFilterInput.value = this.currentFilters.changeid; this.onlyStuckFilterCheckbox.checked = this.currentFilters.onlyStuck; // Restore enabled/disabled states and potentially correct checked states + this.updateFilterStates(); this.updateDependentCheckboxStates(); // Ensure the actual checked state matches the filter state after dependency logic this.exclAbandonFilterCheckbox.checked = this.currentFilters.exclAbandon && !this.exclAbandonFilterCheckbox.disabled; this.onlyExternalTqtcFilterCheckbox.checked = this.currentFilters.onlyExternalTqtc && !this.onlyExternalTqtcFilterCheckbox.disabled; - - if (this.onlyMineFilterCheckbox) { - // If owner matches logged-in user's email (fetched elsewhere), check 'Only Mine' - if (!this.currentFilters.owner) { - this.onlyMineFilterCheckbox.checked = false; - } - } - // Re-render branch tags this._renderBranchTags(); this._renderHashtagTags(); @@ -720,33 +773,6 @@ Gerrit.install(plugin => { this.isLoggedIn = await restApi.getLoggedIn(); } - sortBranchNames(branches) { - return branches.sort((a, b) => { - const [aMajor, aMinor, aPatch] = a.split('.').map(Number); - const [bMajor, bMinor, bPatch] = b.split('.').map(Number); - - if (!(a.match(/\d/))) { - if (a === "master" || a === "dev") - return -1; - else if (a.match(/\w/)) - return 1; - } - if (aMajor !== bMajor) { - return bMajor - aMajor; - } - if (aMinor !== bMinor) { - return bMinor - aMinor; - } - if (aPatch === undefined) { - return -1; - } - if (bPatch === undefined) { - return 1; - } - return (bPatch || 0) - (aPatch || 0); - }); - } - getContrastColor(hexColor) { // Convert hex color to RGB const r = parseInt(hexColor.substring(1, 3), 16); @@ -760,44 +786,23 @@ Gerrit.install(plugin => { return luminance > 0.5 ? '#000000' : '#ffffff'; } - getStatusByEnum(status, dependsOn) { - const statusToColor = { - NOT_CREATED: { - textOverride: dependsOn - ? `Depends on ${dependsOn}` : 'Not yet picked', bg: 'lightgrey', text: 'black' - }, - NEW: { textOverride: "New", bg: 'hsl(48, 100%, 50%)', text: 'black' }, - MERGE_CONFLICT: { textOverride: "Merge conflict", bg: 'orange', text: 'black' }, - MERGED: { textOverride: "Merged", bg: 'hsl(120, 100%, 30%)', text: 'white' }, - ABANDONED: { textOverride: "Abandoned", bg: '#ccccff', text: 'black' }, - DEFERRED: { textOverride: "Deferred", bg: '#ffccff', text: 'black' }, - STAGED: { textOverride: "Integrating", bg: 'rgb(204, 255, 153)', text: 'black' }, - INTEGRATING: { textOverride: "Integrating", bg: 'rgb(204, 255, 153)', text: 'black' }, - FAILED_CI: { textOverride: "CI Failed", bg: 'darkred', text: 'white' } - }; - return statusToColor[status] || { bg: '', text: '' }; - } - async handleOnlyMineFilterChange() { if (this.onlyMineFilterCheckbox && this.onlyMineFilterCheckbox.checked) { - try { - const response = await fetch('http://code.qt.io/a/accounts/self'); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - // trim response of leading ")]}'" characters - const text = await response.text(); - const data = JSON.parse(text.substring(4)); - this.ownerFilterInput.value = data.email; - this.currentFilters.owner = data.email; - } catch (error) { - console.error('Error fetching user email:', error); - this.errorMessage = 'Error fetching user email. Please try again later.'; - this._render(); - return; + await this.fetchUserEmail(); // Ensure we have the user's email + + if (this.userEmail) { + if (this.ownerFilterInput) this.ownerFilterInput.value = this.userEmail; + this.currentFilters.owner = this.userEmail; + } else { + console.error("Could not apply 'Only Mine' filter: user email not available."); + // Revert the checkbox state as we couldn't fulfill the request + this.onlyMineFilterCheckbox.checked = false; + this.errorMessage = 'Error fetching your user email. Cannot apply "Only Mine" filter.'; + this._render(); + return; } } else { - this.ownerFilterInput.value = ''; + if (this.ownerFilterInput) this.ownerFilterInput.value = ''; this.currentFilters.owner = ''; } this.applyFilters(); @@ -805,8 +810,8 @@ Gerrit.install(plugin => { async fetchRepoSuggestions(prefix) { // Don't search for empty or very short strings, or if restApi not ready - if (!prefix || prefix.length < 1 || !restApi) { - this.repoSuggestionsDatalist.innerHTML = ''; + if (!prefix || prefix.length < 1 || !restApi || !this.repoSuggestionsDatalist) { + if (this.repoSuggestionsDatalist) this.repoSuggestionsDatalist.innerHTML = ''; return; } try { @@ -824,8 +829,8 @@ Gerrit.install(plugin => { async fetchOwnerSuggestions(prefix) { // Don't search for empty or very short strings, or if restApi not ready - if (!prefix || prefix.length < 1 || !restApi) { - this.ownerSuggestionsDatalist.innerHTML = ''; + if (!prefix || prefix.length < 1 || !restApi || !this.ownerSuggestionsDatalist) { + if (this.ownerSuggestionsDatalist) this.ownerSuggestionsDatalist.innerHTML = ''; return; } try { @@ -883,6 +888,8 @@ Gerrit.install(plugin => { try { const params = new URLSearchParams(); + if (this.currentFilters.changeid) params.set('changeid', this.currentFilters.changeid); + if (this.currentFilters.subject) params.set('subject', this.currentFilters.subject); if (this.currentFilters.owner) params.set('owner', this.currentFilters.owner); if (this.currentFilters.repo) params.set('repo', this.currentFilters.repo); if (this.currentFilters.onlyStuck) params.set('onlyStuck', 'true'); @@ -908,7 +915,7 @@ Gerrit.install(plugin => { } } - this.branchColumns = new Set(this.sortBranchNames(Array.from(allBranches))); + this.branchColumns = new Set(sortBranchNames(Array.from(allBranches))); this.totalPages = data.totalPages || 1; // Ensure totalPages is at least 1 this.errorMessage = null; @@ -923,6 +930,8 @@ Gerrit.install(plugin => { // Function to update the browser URL updateURL() { const params = new URLSearchParams(); + if (this.currentFilters.changeid) params.set('changeid', this.currentFilters.changeid); + if (this.currentFilters.subject) params.set('subject', this.currentFilters.subject); if (this.currentFilters.owner) params.set('owner', this.currentFilters.owner); if (this.currentFilters.repo) params.set('repo', this.currentFilters.repo); if (this.currentFilters.onlyStuck) params.set('onlyStuck', 'true'); @@ -943,15 +952,88 @@ Gerrit.install(plugin => { history.pushState({ filters: this.currentFilters, page: this.currentPage }, '', newUrl); } + updateFilterStates() { + const hasChangeId = this.changeIdFilterInput.value.trim() !== ''; + // Subject search is no longer an exclusive filter on its own. + + // Only ChangeID is an exclusive filter. + const hasExclusiveFilter = hasChangeId; + + // All other filters are disabled if ChangeID is present. + const fieldsToDisable = [ + this.ownerFilterInput, this.repoFilterInput, + this.onlyStuckFilterCheckbox, this.exclAbandonFilterCheckbox, + this.onlyExternalTqtcFilterCheckbox, this.branchFilterInput, + this.addBranchFilterButton, this.hashtagFilterInput, + this.addHashtagFilterButton, this.onlyMineFilterCheckbox, + this.subjectSearchInput // Subject input is disabled by ChangeID + ]; + + fieldsToDisable.forEach(field => { + if (field) field.disabled = hasExclusiveFilter; + }); + + // ChangeID input is never disabled by other filters. + // Subject input is disabled by ChangeID (handled above). + + this.shadowRoot.querySelectorAll('.remove-branch-btn, .remove-hashtag-btn').forEach(btn => { + btn.disabled = hasExclusiveFilter; + }); + + if (!hasExclusiveFilter) { + // If no exclusive filter, manage dependent checkboxes normally. + this.updateDependentCheckboxStates(); + } else { + // If we are disabling via changeId, ensure dependent checkboxes are also disabled. + this.onlyStuckFilterCheckbox.disabled = true; + this.exclAbandonFilterCheckbox.disabled = true; + this.onlyExternalTqtcFilterCheckbox.disabled = true; + } + } applyFilters() { + this.currentFilters.changeid = this.changeIdFilterInput.value.trim(); + if (this.subjectSearchInput) { + this.currentFilters.subject = this.subjectSearchInput.value.trim(); + } + if (this.repoFilterInput) { + this.currentFilters.repo = this.repoFilterInput.value.trim(); + } + if (this.ownerFilterInput) { + this.currentFilters.owner = this.ownerFilterInput.value.trim(); + } + + const changeId = this.currentFilters.changeid; + if (changeId) { + const isGerritId = /^I[0-9a-f]{40}$/i.test(changeId); + const isSha1 = /^[0-9a-f]{7,40}$/i.test(changeId); + if (!isGerritId && !isSha1) { + this.errorMessage = `Invalid Change ID or SHA1. A Gerrit Change-ID must be 'I' followed by 40 hex chars. A SHA1 must be 7-40 hex chars.`; + this.loading = false; + this._render(); + return; + } + } + this.errorMessage = null; // Clear previous validation error + + this.updateFilterStates(); this.currentPage = 1; // Reset to first page when filters change this.fetchData(); // fetchData will update the URL } clearFilters() { - this.ownerFilterInput.value = ''; - this.repoFilterInput.value = ''; + this.changeIdFilterInput.value = ''; + if (this.subjectSearchInput) { + this.subjectSearchInput.value = ''; + } + if (this.ownerFilterInput) { + this.ownerFilterInput.value = ''; + } + if (this.repoFilterInput) { + this.repoFilterInput.value = ''; + } + this.currentFilters.changeid = ''; + this.currentFilters.subject = ''; this.currentFilters.owner = ''; this.currentFilters.repo = ''; if (this.onlyMineFilterCheckbox) { @@ -972,9 +1054,9 @@ Gerrit.install(plugin => { this.selectedHashtags.clear(); this.hashtagFilterInput.value = ''; this._renderHashtagTags(); - this.updateDependentCheckboxStates(); // Ensure dependent checkboxes are disabled and unchecked - this.repoSuggestionsDatalist.innerHTML = ''; - this.ownerSuggestionsDatalist.innerHTML = ''; + this.updateFilterStates(); // Re-enable all filters + if (this.repoSuggestionsDatalist) this.repoSuggestionsDatalist.innerHTML = ''; + if (this.ownerSuggestionsDatalist) this.ownerSuggestionsDatalist.innerHTML = ''; this.applyFilters(); // Fetch data with cleared filters } @@ -1047,9 +1129,26 @@ Gerrit.install(plugin => { const thead = this.shadowRoot.querySelector('thead tr'); thead.innerHTML = ` - <th>Subject</th> - <th>Repo</th> - <th>Owner Email</th> + <th> + <div class="header-wrapper"> + <span>Subject</span> + <input type="text" id="subjectSearchInput" class="header-input" placeholder="Search subject..." title="Free-text search for subject. Case-insensitive, no special operators supported. Cannot be used with Change ID/SHA1."> + </div> + </th> + <th> + <div class="header-wrapper"> + <span>Repo</span> + <input class="header-input" type="text" id="repoFilter" list="repoSuggestions" placeholder="Filter by repository" title="Filter changes by repository name (prefix or full match)."> + <datalist id="repoSuggestions"></datalist> + </div> + </th> + <th> + <div class="header-wrapper"> + <span>Owner</span> + <input class="header-input" type="text" id="ownerFilter" list="ownerSuggestions" placeholder="Filter by owner email" title="Filter changes by owner's email address (full match, select from suggestions)."> + <datalist id="ownerSuggestions"></datalist> + </div> + </th> <th>Merged Date</th> ${Array.from(this.branchColumns).map(b => ` <th> @@ -1060,6 +1159,80 @@ Gerrit.install(plugin => { </th>`).join('')} `; + // Get all header inputs and datalists + this.subjectSearchInput = this.shadowRoot.getElementById('subjectSearchInput'); + this.repoFilterInput = this.shadowRoot.getElementById('repoFilter'); + this.ownerFilterInput = this.shadowRoot.getElementById('ownerFilter'); + this.onlyMineFilterCheckbox = this.shadowRoot.getElementById('onlyMineFilter'); + this.repoSuggestionsDatalist = this.shadowRoot.getElementById('repoSuggestions'); + this.ownerSuggestionsDatalist = this.shadowRoot.getElementById('ownerSuggestions'); + + // Set current values from filters + if (this.subjectSearchInput) this.subjectSearchInput.value = this.currentFilters.subject || ''; + if (this.repoFilterInput) this.repoFilterInput.value = this.currentFilters.repo || ''; + if (this.ownerFilterInput) this.ownerFilterInput.value = this.currentFilters.owner || ''; + + // Attach listeners for subject + if (this.subjectSearchInput) { + this.subjectSearchInput.addEventListener('input', () => { + this.updateFilterStates(); + }); + + const applySubjectSearch = () => { + const newValue = this.subjectSearchInput.value.trim(); + // Only apply if value has actually changed + if (newValue !== this.currentFilters.subject) { + this.applyFilters(); + } + }; + + this.subjectSearchInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + applySubjectSearch(); + } + }); + this.subjectSearchInput.addEventListener('blur', () => { + applySubjectSearch(); + }); + } + + // Attach listeners for repo + if (this.repoFilterInput) { + this.repoFilterInput.addEventListener('input', () => { + this.currentFilters.repo = this.repoFilterInput.value; + this.debouncedFetchRepos(this.repoFilterInput.value); + }); + this.repoFilterInput.addEventListener('change', () => { + this.debouncedFetchRepos.cancel(); + this.applyFilters(); + }); + } + + // Attach listeners for owner + if (this.ownerFilterInput) { + this.ownerFilterInput.addEventListener('input', () => { + this.currentFilters.owner = this.ownerFilterInput.value; + if (this.onlyMineFilterCheckbox) { + this.onlyMineFilterCheckbox.checked = !!(this.userEmail && this.currentFilters.owner === this.userEmail); + } + this.debouncedFetchOwners(this.ownerFilterInput.value); + }); + this.ownerFilterInput.addEventListener('change', () => { + this.debouncedFetchOwners.cancel(); + this.applyFilters(); + }); + } + + // Attach listener for 'Only Mine' checkbox + if (this.onlyMineFilterCheckbox) { + this.onlyMineFilterCheckbox.checked = !!(this.userEmail && this.currentFilters.owner === this.userEmail); + this.onlyMineFilterCheckbox.addEventListener('change', () => this.handleOnlyMineFilterChange()); + } + + // Call after inputs are created to set initial disabled states + this.updateFilterStates(); + + const tbody = this.shadowRoot.querySelector('tbody'); tbody.innerHTML = this.changes .filter(change => { @@ -1102,7 +1275,7 @@ Gerrit.install(plugin => { ${Array.from(this.branchColumns).map(b => { const branch = change.branches[b] || {}; const status = branch.status; - const overrides = this.getStatusByEnum(status, branch.depends_on); + const overrides = getStatusByEnum(status, branch.depends_on); const text = overrides.textOverride || '-'; const url = branch.url; const content = url @@ -1117,14 +1290,14 @@ Gerrit.install(plugin => { tbody.querySelectorAll('.repo-filter-icon').forEach(icon => { icon.addEventListener('click', (event) => { - this.repoFilterInput.value = event.target.dataset.repo; + if (this.repoFilterInput) this.repoFilterInput.value = event.target.dataset.repo; this.currentFilters.repo = event.target.dataset.repo; this.applyFilters(); }); }); tbody.querySelectorAll('.owner-filter-icon').forEach(icon => { icon.addEventListener('click', (event) => { - this.ownerFilterInput.value = event.target.dataset.owner; + if (this.ownerFilterInput) this.ownerFilterInput.value = event.target.dataset.owner; this.currentFilters.owner = event.target.dataset.owner; this.applyFilters(); }); @@ -1158,4 +1331,127 @@ Gerrit.install(plugin => { } customElements.define('cherrypick-status-main', CherryPickMain); + + class CherryPickCommitStatus extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + this.shadowRoot.innerHTML = ` + <style> + h3 { + font-family: var(--font-family); + font-size: var(--font-size-large, 1em); + font-weight: var(--font-weight-bold); + line-height: var(--line-height-large); + margin: 0 0 0.5rem 0; + border-bottom: 1px solid var(--border-color); + padding-bottom: 0.25rem; + } + .status-table { + border-collapse: collapse; + width: 100%; + font-size: var(--font-size-small, 0.9em); + } + .status-table th, .status-table td { + border: 1px solid var(--border-color, #ddd); + padding: 0.25rem 0.5rem; + text-align: center; + } + .status-table th { + background-color: var(--background-color-tertiary, #f8f9fa); + font-weight: var(--font-weight-bold); + } + .loading, .error { + padding: 0.5rem; + color: var(--deemphasized-text-color); + } + .error { + color: var(--error-text-color); + } + a { + color: currentColor; + text-decoration: none; + } + a:hover { + text-decoration: underline; + } + </style> + <div id="container"></div> + `; + this.container = this.shadowRoot.getElementById('container'); + } + + setData(element) { + const commitMessage = element.revision.commit.message; + if (commitMessage && commitMessage.includes('Pick-to:')) { + const changeId = element.change.triplet_id || element.change.id; + this.fetchStatus(changeId); + } + } + + async fetchStatus(changeId) { + this.container.innerHTML = `<div class="loading">Loading cherry-pick status...</div>`; + try { + const response = await fetch(`/config/server/getdashboard?changeid=${changeId}`); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const data = await response.json(); + + if (data.changes && data.changes.length > 0) { + this.render(data.changes[0]); + } else { + this.container.innerHTML = ''; // No data found, render nothing. + } + } catch (err) { + console.error('Error fetching cherry-pick status:', err); + this.container.innerHTML = `<div class="error">Error loading cherry-pick status.</div>`; + } + } + + render(change) { + const branches = change.branches || {}; + const branchNames = sortBranchNames(Object.keys(branches)); + + if (branchNames.length === 0) { + this.container.innerHTML = ''; // No branches to show + return; + } + + const tableHeader = branchNames.map(b => `<th>${b}</th>`).join(''); + const tableBody = branchNames.map(b => { + const branchInfo = branches[b] || {}; + const status = branchInfo.status; + const overrides = getStatusByEnum(status, branchInfo.depends_on); + const text = overrides.textOverride || '-'; + const url = branchInfo.url; + const content = url ? `<a href="http://code.qt.io/$%7Burl%7D" target="_blank" title="View change for ${b}">${text}</a>` : text; + return `<td style="background-color: ${overrides.bg}; color: ${overrides.text}">${content}</td>`; + }).join(''); + + this.container.innerHTML = ` + <h3>Cherry-Pick Status</h3> + <table class="status-table"> + <thead> + <tr>${tableHeader}</tr> + </thead> + <tbody> + <tr>${tableBody}</tr> + </tbody> + </table> + `; + } + } + + customElements.define('cherrypick-commit-status', CherryPickCommitStatus); + + plugin.hook('commit-container').onAttached(element => { + // The hook can be called multiple times, so check if element already exists. + if (!isLoggedIn || element.content.querySelector('cherrypick-commit-status')) { + return; + } + const statusElement = document.createElement('cherrypick-commit-status'); + statusElement.setData(element); + element.appendChild(statusElement); + }); }); |