diff --git a/src/css/content.scss b/src/css/content.scss index ce6878ff..d557baec 100644 --- a/src/css/content.scss +++ b/src/css/content.scss @@ -20,6 +20,10 @@ $color-light-blue: #12a4d5; $color-darker-green: #2cb528; $color-dark-yellow: #DAA520; +.owner { + color: $color-dark-yellow !important; +} + .k2dashboard, .passwordform { max-width: 1680px; @@ -540,3 +544,15 @@ $color-dark-yellow: #DAA520; position: sticky; top: 60px; } + +.js-issue-assignees .k2-button { + position: absolute; + right: -12px; + margin-top: -5px !important; +} + +.alert { + color: $color-alert; + font-weight: bold; + margin: 8px 16px; +} diff --git a/src/js/component/list-item/ListItemIssue.js b/src/js/component/list-item/ListItemIssue.js index 962b11a8..447452e4 100644 --- a/src/js/component/list-item/ListItemIssue.js +++ b/src/js/component/list-item/ListItemIssue.js @@ -51,12 +51,18 @@ class ListItemIssue extends React.Component { this.isHelpWanted = _.some(this.props.issue.labels, {name: 'Help Wanted'}) ? ' help-wanted' : ''; this.isContributorAssigned = this.isExternal && !this.isHelpWanted ? ' contributor-assigned' : ''; this.isUnderReview = _.find(this.props.issue.labels, label => label.name.toLowerCase() === 'reviewing'); + this.isCurrentUserOwner = this.props.issue.currentUserIsOwner; } render() { this.parseIssue(); return (
+ {this.isCurrentUserOwner && ( + + {'★ '} + + )} { return null; } + // Put the issues owned by the current user at the top of the list + const sortedData = _.sortBy(filteredData, 'currentUserIsOwner'); + return (
{ </div> ) : ( <div> - {_.map(filteredData, issue => <ListItemIssue key={`issue_raw_${issue.id}`} issue={issue} />)} + {_.map(sortedData, issue => <ListItemIssue key={`issue_raw_${issue.id}`} issue={issue} />)} </div> )} </div> diff --git a/src/js/lib/actions/Issues.js b/src/js/lib/actions/Issues.js index b9874ea5..82a82e34 100644 --- a/src/js/lib/actions/Issues.js +++ b/src/js/lib/actions/Issues.js @@ -25,9 +25,29 @@ function getDailyImprovements() { function getAllAssigned() { API.getIssuesAssigned() .then((issues) => { + const currentUser = API.getCurrentUser(); + const issuesMarkedWithOwner = _.reduce(issues, (finalObject, issue) => { + const regexResult = issue.body.match(/Current Issue Owner:\s@(?<owner>\S+)/i); + const currentOwner = regexResult && regexResult.groups && regexResult.groups.owner; + if (!currentOwner || currentOwner !== currentUser) { + return { + ...finalObject, + [issue.id]: issue, + }; + } + + return { + ...finalObject, + [issue.id]: { + ...issue, + currentUserIsOwner: true, + }, + }; + }, {}); + // Always use set() here because there is no way to remove issues from Onyx // that get closed and are no longer assigned - ReactNativeOnyx.set(ONYXKEYS.ISSUES.ASSIGNED, issues); + ReactNativeOnyx.set(ONYXKEYS.ISSUES.ASSIGNED, issuesMarkedWithOwner); }); } diff --git a/src/js/lib/api.js b/src/js/lib/api.js index a9bde9a9..85295040 100644 --- a/src/js/lib/api.js +++ b/src/js/lib/api.js @@ -10,7 +10,12 @@ let octokit; */ function getOctokit() { if (!octokit) { - octokit = new Octokit({auth: Preferences.getGitHubToken()}); + /* eslint-disable-next-line no-console */ + console.log('authenticate with auth token', Preferences.getGitHubToken()); + octokit = new Octokit({ + auth: Preferences.getGitHubToken(), + userAgent: 'expensify-k2-extension', + }); } return octokit; } @@ -320,6 +325,7 @@ function getIssues(assignee = 'none', labels) { url createdAt updatedAt + body assignees(first: 100) { nodes { avatarUrl @@ -395,6 +401,21 @@ function addComment(comment) { return getOctokit().rest.issues.createComment({...getRequestParams(), body: comment}); } +/** + * @returns {Promise} + */ +function getCurrentIssueDescription() { + return getOctokit().rest.issues.get({...getRequestParams()}); +} + +/** + * @param {String} body + * @returns {Promise} + */ +function setCurrentIssueBody(body) { + return getOctokit().rest.issues.update({...getRequestParams(), body}); +} + export { addComment, getCheckRuns, @@ -407,4 +428,6 @@ export { getMilestones, getCurrentUser, getPullsByType, + getCurrentIssueDescription, + setCurrentIssueBody, }; diff --git a/src/js/lib/pages/github/issue.js b/src/js/lib/pages/github/issue.js index 38dc61ca..d73f4a3e 100644 --- a/src/js/lib/pages/github/issue.js +++ b/src/js/lib/pages/github/issue.js @@ -1,3 +1,4 @@ +/* eslint-disable rulesdir/prefer-underscore-method */ import $ from 'jquery'; import ReactNativeOnyx from 'react-native-onyx'; import Base from './_base'; @@ -8,6 +9,120 @@ import K2pickerType from '../../../module/K2pickertype/K2pickertype'; import ToggleReview from '../../../module/ToggleReview/ToggleReview'; import K2comments from '../../../module/K2comments/K2comments'; import ONYXKEYS from '../../../ONYXKEYS'; +import * as API from '../../api'; + +let clearErrorTimeoutID; +function catchError(e) { + $('.gh-header-actions .k2-element').remove(); + $('.gh-header-actions').append('<span class="alert k2-element">OOPS!</span>'); + console.error(e); + clearTimeout(clearErrorTimeoutID); + clearErrorTimeoutID = setTimeout(() => { + $('.gh-header-actions .k2-element').remove(); + }, 30000); +} + +/** + * Sets the owner of an issue when it doesn't have an owner yet + * @param {String} owner to set + */ +function setOwner(owner) { + API.getCurrentIssueDescription() + .then((response) => { + const ghDescription = response.data.body; + const newDescription = `${ghDescription} + +<details><summary>Issue Owner</summary>Current Issue Owner: @${owner}</details>`; + API.setCurrentIssueBody(newDescription); + }) + .catch(catchError); +} + +/** + * Removes the existing owner of an issue + * @param {String} owner to remove + */ +function removeOwner(owner) { + API.getCurrentIssueDescription() + .then((response) => { + const ghDescription = response.data.body; + const newDescription = ghDescription.replace(`<details><summary>Issue Owner</summary>Current Issue Owner: @${owner}</details>`, ''); + API.setCurrentIssueBody(newDescription); + }) + .catch(catchError); +} + +/** + * Replaces the existing issue owner with a different owner + * @param {String} oldOwner + * @param {String} newOwner + */ +function replaceOwner(oldOwner, newOwner) { + API.getCurrentIssueDescription() + .then((response) => { + const ghDescription = response.data.body; + const newDescription = ghDescription.replace(`Current Issue Owner: @${oldOwner}`, `Current Issue Owner: @${newOwner}`); + API.setCurrentIssueBody(newDescription); + }) + .catch(catchError); +} + +/** + * 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. + */ +const refreshAssignees = () => { + // Always start by erasing whatever was drawn before (so it always starts from a clean slate) + $('.js-issue-assignees .k2-element').remove(); + + // Do nothing if there is only one person assigned. Owners can only be set when there are + // multiple assignees + if ($('.js-issue-assignees > p > span').length <= 1) { + return; + } + + // Check if there is an owner for the issue + const ghDescription = $('.comment-body').text(); + const regexResult = ghDescription.match(/Current Issue Owner:\s@(?<owner>\S+)/i); + const 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(); + if (assignee === currentOwner) { + $(el).append(` + <button type="button" class="Button flex-md-order-2 m-0 owner k2-element k2-button k2-button-remove-owner" data-owner="${currentOwner}"> + ★ + </button> + `); + } else { + $(el).append(` + <button type="button" class="Button flex-md-order-2 m-0 k2-element k2-button k2-button-make-owner" data-owner="${assignee}"> + ○ + </button> + `); + } + }); + + // Remove the owner with this button is clicked + $('.k2-button-remove-owner').off('click').on('click', (e) => { + e.preventDefault(); + const owner = $(e.target).data('owner'); + removeOwner(owner); + return false; + }); + + // Make a new owner when this button is clicked + $('.k2-button-make-owner').off('click').on('click', (e) => { + e.preventDefault(); + const newOwner = $(e.target).data('owner'); + if (currentOwner) { + replaceOwner(currentOwner, newOwner); + } else { + setOwner(newOwner); + } + return false; + }); +}; const refreshPicker = function () { // Add our wrappers to the DOM which all the React components will be rendered into @@ -28,6 +143,7 @@ const refreshPicker = function () { * @returns {Object} */ export default function () { + let allreadySetup = false; ReactNativeOnyx.init({ keys: ONYXKEYS, }); @@ -37,14 +153,32 @@ export default function () { IssuePage.urlPath = '^(/[\\w-]+/[\\w-.]+/issues/\\d+)$'; IssuePage.setup = function () { + // Prevent this function from running twice (it sometimes does that because of how chrome triggers the extension) + if (allreadySetup) { + return; + } + allreadySetup = true; + + let refreshPickerTimeoutID; + let refreshAssigneesTimeoutID; setTimeout(refreshPicker, 500); + setTimeout(refreshAssignees, 500); // Listen for when the sidebar is redrawn, then redraw our pickers $(document).bind('DOMNodeRemoved', (e) => { - if (!$(e.target).is('#partial-discussion-sidebar')) { - return; + if ($(e.target).hasClass('sidebar-assignee')) { + // Make sure that only one setTimeout runs at a time + clearTimeout(refreshAssigneesTimeoutID); + refreshAssigneesTimeoutID = setTimeout(refreshAssignees, 500); + } + + if ($(e.target).is('#partial-discussion-sidebar')) { + // Make sure that only one setTimeout runs at a time + clearTimeout(refreshPickerTimeoutID); + refreshPickerTimeoutID = setTimeout(refreshPicker, 500); + clearTimeout(refreshAssigneesTimeoutID); + refreshAssigneesTimeoutID = setTimeout(refreshAssignees, 500); } - setTimeout(refreshPicker, 500); }); }; diff --git a/src/js/module/dashboard/Legend.js b/src/js/module/dashboard/Legend.js index dec3ea04..e2f22a9d 100644 --- a/src/js/module/dashboard/Legend.js +++ b/src/js/module/dashboard/Legend.js @@ -41,6 +41,11 @@ const Legend = () => { {' '} External </div> + <div> + <span className="owner">★</span> + {' '} + Issue owner + </div> <div className="issue"> <sup>I</sup> {' '}