diff --git a/.eslintrc.js b/.eslintrc.js index 108c4697..b0c7a0cc 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -6,6 +6,8 @@ module.exports = { 'comma-dangle': ['error', 'always-multiline'], 'rulesdir/no-api-in-views': 'off', 'rulesdir/no-multiple-api-calls': 'off', + '@lwc/lwc/no-async-await': 'off', + 'es/no-nullish-coalescing-operators' : 'off' }, settings: { 'import/resolver': { diff --git a/CHANGELOG.md b/CHANGELOG.md index 903c8aff..6da33bfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +#1.4.0 +- Updated the extension to work with new GitHub UI +- Updated the extension to work with GitHub's new PR merge experience + #1.3.74 - Moved the previous query string params to Onyx diff --git a/assets/manifest-firefox.json b/assets/manifest-firefox.json index e008db29..bc596a0b 100644 --- a/assets/manifest-firefox.json +++ b/assets/manifest-firefox.json @@ -2,7 +2,7 @@ "manifest_version": 3, "name": "K2 for GitHub", - "version": "1.3.74", + "version": "1.4.0", "description": "Manage your Kernel Scheduling from directly inside GitHub", "browser_specific_settings": { diff --git a/assets/manifest.json b/assets/manifest.json index 81329de3..605d7380 100644 --- a/assets/manifest.json +++ b/assets/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 3, "name": "K2 for GitHub", - "version": "1.3.74", + "version": "1.4.0", "description": "Manage your Kernel Scheduling from directly inside GitHub", "icons": { diff --git a/package.json b/package.json index 2b724bae..469d3127 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "k2-extension", - "version": "1.3.74", + "version": "1.4.0", "description": "A Chrome Extension for Kernel Schedule", "private": true, "scripts": { diff --git a/src/css/content.scss b/src/css/content.scss index 4d425ae8..11ad4e4a 100644 --- a/src/css/content.scss +++ b/src/css/content.scss @@ -570,3 +570,28 @@ $color-dark-yellow: #DAA520; color: rgb(230, 237, 243) } } + +.loader { + width: 14px; + aspect-ratio: 1; + border-radius: 50%; + border: 2px solid #514b82; + animation: + l20-1 0.8s infinite linear alternate, + l20-2 1.6s infinite linear; +} +@keyframes l20-1{ + 0% {clip-path: polygon(50% 50%,0 0, 50% 0%, 50% 0%, 50% 0%, 50% 0%, 50% 0% )} + 12.5% {clip-path: polygon(50% 50%,0 0, 50% 0%, 100% 0%, 100% 0%, 100% 0%, 100% 0% )} + 25% {clip-path: polygon(50% 50%,0 0, 50% 0%, 100% 0%, 100% 100%, 100% 100%, 100% 100% )} + 50% {clip-path: polygon(50% 50%,0 0, 50% 0%, 100% 0%, 100% 100%, 50% 100%, 0% 100% )} + 62.5% {clip-path: polygon(50% 50%,100% 0, 100% 0%, 100% 0%, 100% 100%, 50% 100%, 0% 100% )} + 75% {clip-path: polygon(50% 50%,100% 100%, 100% 100%, 100% 100%, 100% 100%, 50% 100%, 0% 100% )} + 100% {clip-path: polygon(50% 50%,50% 100%, 50% 100%, 50% 100%, 50% 100%, 50% 100%, 0% 100% )} +} +@keyframes l20-2{ + 0% {transform:scaleY(1) rotate(0deg)} + 49.99%{transform:scaleY(1) rotate(135deg)} + 50% {transform:scaleY(-1) rotate(0deg)} + 100% {transform:scaleY(-1) rotate(-135deg)} +} diff --git a/src/js/lib/pages/github/_base.js b/src/js/lib/pages/github/_base.js index 72a7e59a..ae2485b6 100644 --- a/src/js/lib/pages/github/_base.js +++ b/src/js/lib/pages/github/_base.js @@ -1,4 +1,5 @@ import $ from 'jquery'; +import * as API from '../../api'; /** * This class is to be extended by each of the distinct types of webpages that the extension works on @@ -7,6 +8,43 @@ import $ from 'jquery'; export default function () { const Page = {}; + const REVIEWER_CHECKLIST_URL = 'https://raw.githubusercontent.com/Expensify/App/main/contributingGuides/REVIEWER_CHECKLIST.md'; + const BUGZERO_CHECKLIST_URL = 'https://raw.githubusercontent.com/Expensify/App/main/contributingGuides/BUGZERO_CHECKLIST.md'; + + /** + * Gets the contents of the reviewer checklist from GitHub and then posts it as a comment to the current PR + * @param {Event} e + * @param {'bugzero' | 'reviewer'} checklistType Type of target checklist + */ + const copyReviewerChecklist = async (e, checklistType) => { + const checklistUrl = checklistType === 'bugzero' ? BUGZERO_CHECKLIST_URL : REVIEWER_CHECKLIST_URL; + + e.preventDefault(); + + // Get the button element + const button = e.target; + + // Save the original content of the button + const originalContent = button.innerHTML; + + // Replace the button content with a loader + button.innerHTML = '
'; + + try { + // Fetch the checklist contents + const response = await fetch(checklistUrl); + const fileContents = await response.text(); + + // Call the API to add the comment + await API.addComment(fileContents); + } catch (error) { + console.error('Error fetching the checklist:', error); + } finally { + // Restore the original button content + button.innerHTML = originalContent; + } + }; + /** * A unique identifier for each page */ @@ -47,11 +85,34 @@ export default function () { Page.setup = function () {}; Page.getRepoOwner = function () { - return $('.author a span').text(); + return document.querySelectorAll('.AppHeader-context-item-label.Truncate-text')[0] // Org name next to GitHub logo + .textContent.trim(); }; Page.getRepo = function () { - return $('.js-current-repository').text(); + return document.querySelectorAll('.AppHeader-context-item-label.Truncate-text')[1] // Repo name next to GitHub logo + .textContent.trim(); + }; + + /** + * Renders buttons for copying checklists in issue/PR bodies + * @param {'bugzero' | 'reviewer'} checklistType Type of target checklist + */ + Page.renderCopyChecklistButtons = function (checklistType) { + // Look through all the comments on the page to find one that has the template for the copy/paste checklist button + // eslint-disable-next-line rulesdir/prefer-underscore-method + $('.markdown-body > p').each((i, el) => { + const commentHtml = $(el).html(); + + // When the button template is found, replace it with an HTML button and then put that back into the DOM so someone can click on it + if (commentHtml && commentHtml.indexOf('you can simply click: [this button]') > -1) { + const newHtml = commentHtml.replace('[this button]', ''); + $(el).html(newHtml); + + // Now that the button is on the page, add a click handler to it (always remove all handlers first so that we know there will always be one handler attached) + $('.k2-copy-checklist').off().on('click', e => copyReviewerChecklist(e, checklistType)); + } + }); }; return Page; diff --git a/src/js/lib/pages/github/createpr.js b/src/js/lib/pages/github/createpr.js deleted file mode 100644 index 7a12a3a3..00000000 --- a/src/js/lib/pages/github/createpr.js +++ /dev/null @@ -1,30 +0,0 @@ -import $ from 'jquery'; -import Base from './_base'; - -/** - * This class handles what happs on the create PR page - * the code is duplicated in pr.js so copy over any changes - * - * @returns {Object} - */ -export default function () { - const CreatePrPage = new Base(); - - /** - * Regex for the create new PR page - */ - CreatePrPage.urlPath = '^(/[\\w-]+/[\\w-]+/compare/.*)$'; - - /** - * Runs on page load, adds qa guidelines content and event listener to show/hide the guidelines - */ - CreatePrPage.setup = function () { - // eslint-disable-next-line no-undef - addQAGuidelines(); - - // eslint-disable-next-line no-undef - $('#k2-extension-qa-guidelines-toggle').on('change', toggleQAGuidelines); - }; - - return CreatePrPage; -} diff --git a/src/js/lib/pages/github/issue.js b/src/js/lib/pages/github/issue.js index a537ebc5..b636041a 100644 --- a/src/js/lib/pages/github/issue.js +++ b/src/js/lib/pages/github/issue.js @@ -13,50 +13,16 @@ import * as API from '../../api'; let clearErrorTimeoutID; function catchError(e) { - $('.gh-header-actions .k2-element').remove(); - $('.gh-header-actions').append('OOPS!'); + $('div[data-component="PH_Actions"] .k2-element').remove(); // K2 elements in action buttons + $('div[data-component="PH_Actions"]') // Action buttons next to issue title + .append('OOPS!'); console.error(e); clearTimeout(clearErrorTimeoutID); clearErrorTimeoutID = setTimeout(() => { - $('.gh-header-actions .k2-element').remove(); + $('div[data-component="PH_Actions"] .k2-element').remove(); }, 30000); } -/** - * Gets the contents of the reviewer checklist from GitHub and then posts it as a comment to the current PR - * @param {Event} e - */ -const copyReviewerChecklist = (e) => { - e.preventDefault(); - const pathToChecklist = 'https://raw.githubusercontent.com/Expensify/App/main/contributingGuides/BUGZERO_CHECKLIST.md'; - $.get(pathToChecklist) - .done((fileContents) => { - if (!fileContents) { - console.error(`could not load contents of ${pathToChecklist} for some reason`); - return; - } - - API.addComment(fileContents); - }); -}; - -const renderCopyChecklistButton = () => { - // Look through all the comments on the page to find one that has the template for the copy/paste checklist button - // eslint-disable-next-line rulesdir/prefer-underscore-method - $('.js-comment-body').each((i, el) => { - const commentHtml = $(el).html(); - - // When the button template is found, replace it with an HTML button and then put that back into the DOM so someone can click on it - if (commentHtml && commentHtml.indexOf('you can simply click: [this button]') > -1) { - const newHtml = commentHtml.replace('[this button]', ''); - $(el).html(newHtml); - - // Now that the button is on the page, add a click handler to it (always remove all handlers first so that we know there will always be one handler attached) - $('.k2-copy-checklist').off().on('click', copyReviewerChecklist); - } - }); -}; - /** * Sets the owner of an issue when it doesn't have an owner yet * @param {String} owner to set @@ -104,27 +70,32 @@ function replaceOwner(oldOwner, newOwner) { /** * This method is all about adding the "issue owner" functionality which melvin will use to see who should be providing ksv2 updates to an issue. + * @param {String | null} [issueOwner] GitHub username of the issue owner. Null means no owner, undefined means get the owner from issue body */ -const refreshAssignees = () => { +const renderAssignees = (issueOwner) => { // Always start by erasing whatever was drawn before (so it always starts from a clean slate) - $('.js-issue-assignees .k2-element').remove(); + $('div[data-testid="sidebar-section"] .k2-element').remove(); - // Check if there is an owner for the issue - const ghDescription = $('.comment-body').text(); - const regexResult = ghDescription.match(/Current Issue Owner:\s@(?\S+)/i); - const currentOwner = regexResult && regexResult.groups && regexResult.groups.owner; + let currentOwner = issueOwner; + + // if issue owner is not provided, then try to get it from the issue body + if (currentOwner === undefined) { + const ghDescription = $('.markdown-body').first().text(); + const regexResult = ghDescription.match(/Current Issue Owner:\s@(?\S+)/i); + currentOwner = regexResult && regexResult.groups && regexResult.groups.owner; + } // Add buttons to each assignee - $('.js-issue-assignees > p > span').each((i, el) => { - const assignee = $(el).find('.assignee span').text(); + $('div[data-testid="issue-assignees"]').each((i, el) => { + const assignee = $(el).text(); if (assignee === currentOwner) { - $(el).append(` + $(el).closest('li').append(` `); } else { - $(el).append(` + $(el).closest('li').append(` @@ -133,15 +104,15 @@ const refreshAssignees = () => { }); // Remove the owner with this button is clicked - $('.k2-button-remove-owner').off('click').on('click', (e) => { + $('.k2-button-remove-owner').off('click').on('click', async (e) => { e.preventDefault(); const owner = $(e.target).data('owner'); removeOwner(owner); - return false; + renderAssignees(null); }); // Make a new owner when this button is clicked - $('.k2-button-make-owner').off('click').on('click', (e) => { + $('.k2-button-make-owner').off('click').on('click', async (e) => { e.preventDefault(); const newOwner = $(e.target).data('owner'); if (currentOwner) { @@ -149,14 +120,15 @@ const refreshAssignees = () => { } else { setOwner(newOwner); } - return false; + renderAssignees(newOwner); }); }; const refreshPicker = function () { // Add our wrappers to the DOM which all the React components will be rendered into if (!$('.k2picker-wrapper').length) { - $('.js-issue-labels').after(sidebarWrapperHTML); + $('div[data-testid="issue-viewer-metadata-pane"] > :nth-child(3)') // Labels section in right side panel + .after(sidebarWrapperHTML); } new K2picker().draw(); @@ -172,7 +144,7 @@ const refreshPicker = function () { * @returns {Object} */ export default function () { - let allreadySetup = false; + let alreadySetup = false; ReactNativeOnyx.init({ keys: ONYXKEYS, }); @@ -183,27 +155,29 @@ export default function () { IssuePage.setup = function () { // Prevent this function from running twice (it sometimes does that because of how chrome triggers the extension) - if (allreadySetup) { + if (alreadySetup) { return; } - allreadySetup = true; + alreadySetup = true; // Draw them once when the page is loaded setTimeout(refreshPicker, 500); - setTimeout(refreshAssignees, 500); + setTimeout(renderAssignees, 500); // Every second, check to see if the pickers are still there, and if not, redraw them setInterval(() => { if (!$('.k2picker-wrapper').length) { refreshPicker(); } - if (!$('.js-issue-assignees .k2-element').length) { - refreshAssignees(); + + if (!$('div[data-testid="issue-viewer-metadata-pane"] > :nth-child(2) .k2-element') // Assignee section in right side panel + .length) { + renderAssignees(); } }, 1000); // Waiting 2 seconds to call this gives the page enough time to load so that there is a better chance that all the comments will be rendered - setInterval(renderCopyChecklistButton, 2000); + setInterval(() => IssuePage.renderCopyChecklistButtons('bugzero'), 2000); }; return IssuePage; diff --git a/src/js/lib/pages/github/pr.js b/src/js/lib/pages/github/pr.js index cc568192..e115ae8e 100644 --- a/src/js/lib/pages/github/pr.js +++ b/src/js/lib/pages/github/pr.js @@ -1,108 +1,58 @@ import $ from 'jquery'; import Base from './_base'; -import * as API from '../../api'; -/** - * Check whether or not the current repo allows someone to merge their own PR that they created. This is limited - * to specific repos in infra. - * - * @return {boolean} - */ -function isSelfMergingAllowed() { - const reposAllowSelfMerge = [ - 'salt', - 'ops-configs', - 'terraform', - ]; - const repoName = window.location.pathname.split('/')[2].toLowerCase(); - return reposAllowSelfMerge.indexOf(repoName) > -1; -} - -/** - * Gets the contents of the reviewer checklist from GitHub and then posts it as a comment to the current PR - * @param {Event} e - */ -const copyReviewerChecklist = (e) => { - e.preventDefault(); - const pathToChecklist = 'https://raw.githubusercontent.com/Expensify/App/main/contributingGuides/REVIEWER_CHECKLIST.md'; - $.get(pathToChecklist) - .done((fileContents) => { - if (!fileContents) { - console.error(`could not load contents of ${pathToChecklist} for some reason`); - return; - } - - API.addComment(fileContents); - }); -}; - -const renderCopyChecklistButton = () => { - // Look through all the comments on the page to find one that has the template for the copy/paste checklist button - // eslint-disable-next-line rulesdir/prefer-underscore-method - $('.js-comment-body').each((i, el) => { - const commentHtml = $(el).html(); +const refreshHold = function () { + const prTitle = $('.js-issue-title').text(); - // When the button template is found, replace it with an HTML button and then put that back into the DOM so someone can click on it - if (commentHtml && commentHtml.indexOf('you can simply click: [this button]') > -1) { - const newHtml = commentHtml.replace('[this button]', ''); - $(el).html(newHtml); + const isNewMergeUI = $('div[data-testid="mergebox-partial"]').length; - // Now that the button is on the page, add a click handler to it (always remove all handlers first so that we know there will always be one handler attached) - $('.k2-copy-checklist').off().on('click', copyReviewerChecklist); + // Classic merge experience + if (!isNewMergeUI) { + if (prTitle.toLowerCase().indexOf('[hold') > -1 || prTitle.toLowerCase().indexOf('[wip') > -1) { + $('.branch-action') // Entire PR merge section + .removeClass('branch-action-state-clean') + .addClass('branch-action-state-dirty'); + $('.merge-message button') // Merge pull request button + .removeClass('btn-primary') + .attr('disabled', 'disabled'); + // eslint-disable-next-line rulesdir/prefer-underscore-method + $('.branch-action-item').last().find('.completeness-indicator') // "Merging status" section above the merge button + .removeClass('completeness-indicator-success') + .addClass('completeness-indicator-problem') + .end() + .find('.status-heading') // Header for the "merging status" section + .text('This pull request has a hold on it and cannot be merged') + .end() + .find('.status-meta') // Body text for the "merging status" section + .html('Remove the HOLD or WIP label from the title of the PR to make it mergeable') + .end() + .find('.octicon') + .removeClass('octicon-check') + .addClass('octicon-alert'); } - }); -}; - -const refreshHold = function () { - const prTitle = $('.js-issue-title').text(); - const prAuthor = $('.pull-header-username').text(); - const getCurrentUser = API.getCurrentUser(); - const branchName = $('.head-ref').text(); + return; + } if (prTitle.toLowerCase().indexOf('[hold') > -1 || prTitle.toLowerCase().indexOf('[wip') > -1) { - $('.branch-action') - .removeClass('branch-action-state-clean') - .addClass('branch-action-state-dirty'); - $('.merge-message button') - .removeClass('btn-primary') + $('div[data-testid="mergebox-partial"] > div > div:last-of-type') // Entire PR merge section + .removeClass('borderColor-success-emphasis'); + $('div[data-testid="mergebox-partial"] > div > div > div button').first() // Merge pull request button + .css({backgroundColor: 'var(--bgColor-neutral-muted)', borderColor: 'var(--bgColor-neutral-muted)'}) .attr('disabled', 'disabled'); - // eslint-disable-next-line rulesdir/prefer-underscore-method - $('.branch-action-item').last().find('.completeness-indicator') - .removeClass('completeness-indicator-success') - .addClass('completeness-indicator-problem') - .end() - .find('.status-heading') - .text('This pull request has a hold on it and cannot be merged') - .end() - .find('.status-meta') - .html('Remove the HOLD or WIP label from the title of the PR to make it mergeable') - .end() - .find('.octicon') - .removeClass('octicon-check') - .addClass('octicon-alert'); - } - - if (!(branchName.toLowerCase() === 'master' || branchName.toLowerCase() === 'main') && !isSelfMergingAllowed() && getCurrentUser === prAuthor) { - $('.branch-action') - .removeClass('branch-action-state-clean') - .addClass('branch-action-state-dirty'); - $('.merge-message button') - .removeClass('btn-primary') + $('div[data-testid="mergebox-partial"] > div > div button[data-component="IconButton"]').first() // Dropdown button next to merge button + .css({backgroundColor: 'var(--bgColor-neutral-muted)', borderColor: 'var(--bgColor-neutral-muted)'}) .attr('disabled', 'disabled'); - // eslint-disable-next-line rulesdir/prefer-underscore-method - $('.branch-action-item').last().find('.completeness-indicator') - .removeClass('completeness-indicator-success') - .addClass('completeness-indicator-problem') - .end() - .find('.status-heading') - .text('You cannot merge your own PR.') - .end() - .find('.status-meta') - .html('I\'m sorry Dave, I\'m afraid you can\'t merge your own PR') - .end() - .find('.octicon') - .removeClass('octicon-check') - .addClass('octicon-alert'); + $('div[data-testid="mergebox-partial"] > div > div > div > div > div') // Container for merge pull request button + .css({borderColor: 'var(--bgColor-neutral-muted)'}); + $('div[data-testid="mergeability-icon-wrapper"] div').css({backgroundColor: 'var(--bgColor-neutral-emphasis)'}); // Icon on the left side of the merge panel + $('div[data-testid="mergebox-partial"] > div > div > section:last-of-type svg') // "Merging status" section above the merge button + .parent() + .removeClass('bgColor-success-emphasis') + .css({backgroundColor: 'var(--bgColor-neutral-emphasis)'}); + $('div[data-testid="mergebox-partial"] > div > div > section:last-of-type h3') // Header for the "merging status" section + .text('This pull request has a hold on it and cannot be merged'); + $('div[data-testid="mergebox-partial"] > div > div > section:last-of-type p') // Body text for the "merging status" section + .html('Remove the HOLD or WIP label from the title of the PR to make it mergeable'); } }; @@ -126,8 +76,9 @@ export default function () { setInterval(refreshHold, 1000); // Waiting 2 seconds to call this gives the page enough time to load so that there is a better chance that all the comments will be rendered - setInterval(renderCopyChecklistButton, 2000); + setInterval(() => PrPage.renderCopyChecklistButtons('reviewer'), 2000); }; return PrPage; } + diff --git a/src/js/module/K2picker/K2PickerPicker.js b/src/js/module/K2picker/K2PickerPicker.js index 6b26cd7c..d37f0098 100644 --- a/src/js/module/K2picker/K2PickerPicker.js +++ b/src/js/module/K2picker/K2PickerPicker.js @@ -20,7 +20,7 @@ class K2PickerPicker extends React.Component { componentDidMount() { // eslint-disable-next-line rulesdir/prefer-underscore-method - $('.js-issue-labels .IssueLabel').each((i, el) => { + $('div[data-testid="issue-labels"] a > span').each((i, el) => { const label = $(el).text().trim(); if (['Hourly', 'Daily', 'Weekly', 'Monthly'].indexOf(label) > -1) { this.setActiveLabel(label); diff --git a/src/js/module/K2pickerarea/K2PickerareaPicker.js b/src/js/module/K2pickerarea/K2PickerareaPicker.js index 6266b0a9..cf9ad186 100644 --- a/src/js/module/K2pickerarea/K2PickerareaPicker.js +++ b/src/js/module/K2pickerarea/K2PickerareaPicker.js @@ -36,7 +36,7 @@ class K2PickerareaPicker extends React.Component { }, }; // eslint-disable-next-line rulesdir/prefer-underscore-method - $('.js-issue-labels .IssueLabel').each((i, el) => { + $('div[data-testid="issue-labels"] a > span').each((i, el) => { const label = $(el).text().trim(); if (this.state[label]) { this.state[label].className = this.state[label].className.replace('inactive', 'active'); diff --git a/src/js/module/K2pickertype/K2PickertypePicker.js b/src/js/module/K2pickertype/K2PickertypePicker.js index 365fd6ba..d65fbea8 100644 --- a/src/js/module/K2pickertype/K2PickertypePicker.js +++ b/src/js/module/K2pickertype/K2PickertypePicker.js @@ -25,7 +25,7 @@ class K2PickertypePicker extends React.Component { */ componentDidMount() { // eslint-disable-next-line rulesdir/prefer-underscore-method - $('.js-issue-labels .IssueLabel').each((i, el) => { + $('div[data-testid="issue-labels"] a > span').each((i, el) => { const label = $(el).text().trim(); if (['Improvement', 'Task', 'NewFeature'].indexOf(label) > -1) { this.setActiveLabel(label); diff --git a/src/js/module/ToggleReview/Toggle.js b/src/js/module/ToggleReview/Toggle.js index d6ee3558..266562a9 100644 --- a/src/js/module/ToggleReview/Toggle.js +++ b/src/js/module/ToggleReview/Toggle.js @@ -17,7 +17,7 @@ class Toggle extends React.Component { componentDidMount() { // eslint-disable-next-line rulesdir/prefer-underscore-method - $('.js-issue-labels .IssueLabel').each((i, el) => { + $('div[data-testid="issue-labels"] a > span').each((i, el) => { const label = $(el).text().trim(); if (['Reviewing'].indexOf(label) > -1) { this.setActiveLabel(label);