diff --git a/nerdlets/maintainer-dashboard/components/dashboard.js b/nerdlets/maintainer-dashboard/components/dashboard.js new file mode 100644 index 0000000..97aa6b6 --- /dev/null +++ b/nerdlets/maintainer-dashboard/components/dashboard.js @@ -0,0 +1,146 @@ +import React from 'react'; +import { + Card, + CardHeader, + CardBody, + HeadingText, + Spinner, + Grid, + GridItem, + Stack, + StackItem, + Spacing, + Table, + TableHeader, + TableHeaderCell, + TableRow, + TableRowCell, + Button, + Tabs, + TabsItem, + BillboardChart, + BlockText, + Icon, + Modal, + TextField, + Link, + Select, + SelectItem, + UserStorageMutation, +} from 'nr1'; +import { KNOWN_LABEL_COLORS } from './issueLabel'; +import SettingsUI from './settings'; +import DashboardData from './dashboardData'; +import UserSettingsQuery from '../util/storageUtil'; +import { client } from '../graphql/ApolloClientInstance'; +import NewRelicUsers from '../data/userdata-sample.json'; + +const RELICS = Object.values(NewRelicUsers) + .filter((u) => u.user_type === 'relic' || u.user_type === 'contractor') + .map((u) => u.login) + .sort(); + +export default class MaintainerDashboard extends React.Component { + constructor(props) { + super(props); + this.state = { + queryKey: 0, + settingsHidden: true, + }; + } + + render() { + return ( + + + + {({ loading, token, settings }) => { + if (loading) + return ; + // create apollo client and settings UI + const gqlClient = client(token); + // return settings selection front and center if we have no settings + if (!token || !settings) + return ( + ({ name, color }))} + onSubmit={() => + this.setState(({ queryKey }) => ({ + settingsHidden: true, + queryKey: queryKey + 1, + })) + } + client={gqlClient} + style={{ maxWidth: '60em' }} + /> + ); + return ( + // else return the dashboard with a settings modal + <> + + + + + + + + + + + + ); + }} + + + + ); + } +} diff --git a/nerdlets/maintainer-dashboard/dashboard.js b/nerdlets/maintainer-dashboard/components/dashboardData.js similarity index 92% rename from nerdlets/maintainer-dashboard/dashboard.js rename to nerdlets/maintainer-dashboard/components/dashboardData.js index 0f78f16..38bb2ee 100644 --- a/nerdlets/maintainer-dashboard/dashboard.js +++ b/nerdlets/maintainer-dashboard/components/dashboardData.js @@ -1,14 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; import * as humanizeDuration from 'humanize-duration'; -import BootstrapTable from 'react-bootstrap-table-next'; -import filterFactory, { - textFilter, - selectFilter, - dateFilter, - Comparator, - multiSelectFilter, -} from 'react-bootstrap-table2-filter'; import { Spinner, Stack, @@ -18,7 +10,6 @@ import { TableHeaderCell, TableRow, TableRowCell, - Button, Tabs, TabsItem, BillboardChart, @@ -26,10 +17,12 @@ import { Link, Tooltip, } from 'nr1'; -import { getGithubData } from './githubData'; +import { findDashboardItems } from '../graphql/githubData'; import { IssueTable } from './issueTable'; -export default class Dashboard extends React.Component { +// TODO: figure out how to fix the tab labels from duplicating the key + +export default class DashboardData extends React.Component { static propTypes = { client: PropTypes.object, scanRepos: PropTypes.arrayOf(PropTypes.string), @@ -52,7 +45,7 @@ export default class Dashboard extends React.Component { async componentDidMount() { // send to UI this.setState( - await getGithubData(this.props.client, { + await findDashboardItems(this.props.client, { scanRepos: this.props.scanRepos, companyUsers: this.props.companyUsers.concat(this.props.ignoreUsers), ignoreLabels: this.props.ignoreLabels, diff --git a/nerdlets/maintainer-dashboard/issueLabel.js b/nerdlets/maintainer-dashboard/components/issueLabel.js similarity index 100% rename from nerdlets/maintainer-dashboard/issueLabel.js rename to nerdlets/maintainer-dashboard/components/issueLabel.js diff --git a/nerdlets/maintainer-dashboard/issueTable.js b/nerdlets/maintainer-dashboard/components/issueTable.js similarity index 97% rename from nerdlets/maintainer-dashboard/issueTable.js rename to nerdlets/maintainer-dashboard/components/issueTable.js index f3a8eda..203d397 100644 --- a/nerdlets/maintainer-dashboard/issueTable.js +++ b/nerdlets/maintainer-dashboard/components/issueTable.js @@ -11,8 +11,8 @@ import filterFactory, { } from 'react-bootstrap-table2-filter'; import { Button, Icon } from 'nr1'; import { IssueLabel } from './issueLabel'; -import PullRequestLogo from './img/git-pull-request-16.svg'; -import IssueLogo from './img/issue-opened-16.svg'; +import PullRequestLogo from '../img/git-pull-request-16.svg'; +import IssueLogo from '../img/issue-opened-16.svg'; /** */ export class IssueTable extends React.PureComponent { diff --git a/nerdlets/maintainer-dashboard/settings.js b/nerdlets/maintainer-dashboard/components/settings.js similarity index 91% rename from nerdlets/maintainer-dashboard/settings.js rename to nerdlets/maintainer-dashboard/components/settings.js index c46019f..0117b30 100644 --- a/nerdlets/maintainer-dashboard/settings.js +++ b/nerdlets/maintainer-dashboard/components/settings.js @@ -1,5 +1,4 @@ import React from 'react'; -import gql from 'graphql-tag'; import { HeadingText, Stack, @@ -14,24 +13,8 @@ import { } from 'nr1'; import { Multiselect } from 'react-widgets'; import { IssueLabel } from './issueLabel'; -import UserSettingsQuery from './storageUtil'; - -const GET_CUR_USER_INFO = gql` - query($repoCursor: String) { - viewer { - login - repositories( - affiliations: [OWNER, COLLABORATOR, ORGANIZATION_MEMBER] - first: 100 - after: $repoCursor - ) { - nodes { - nameWithOwner - } - } - } - } -`; +import { getUserInfo } from '../graphql/githubData'; +import UserSettingsQuery from '../util/storageUtil'; export default class SettingsUI extends React.Component { static splitRepositoryNames(searchTerm) { @@ -105,49 +88,39 @@ export default class SettingsUI extends React.Component { }); // test the token with a user information query const curNum = ++this.curFetchIndex; - return this.props.client - .query({ - query: GET_CUR_USER_INFO, - fetchPolicy: 'network-only', - context: { - headers: { - authorization: `Bearer ${token}`, + try { + const data = await getUserInfo(this.props.client, this.state.token); + // prevent race conditions + if (curNum === this.curFetchIndex) { + this.setState({ + patStatus: { + valid: true, + userName: data?.viewer?.login, + repoOptions: data?.viewer?.repositories?.nodes?.map?.( + ({ nameWithOwner }) => nameWithOwner + ), }, - }, - }) - .then(({ data }) => { - // prevent race conditions - if (curNum === this.curFetchIndex) { + }); + } + } catch (e) { + // prevent race condition + if (curNum === this.curFetchIndex) { + if (e?.networkError?.statusCode === 401) this.setState({ patStatus: { - valid: true, - userName: data?.viewer?.login, - repoOptions: data?.viewer?.repositories?.nodes?.map?.( - ({ nameWithOwner }) => nameWithOwner - ), + valid: false, + message: 'Token returned authorization error', }, }); - } - }) - .catch((e) => { - // prevent race condition - if (curNum === this.curFetchIndex) { - if (e?.networkError?.statusCode === 401) - this.setState({ - patStatus: { - valid: false, - message: 'Token returned authorization error', - }, - }); - else - this.setState({ - patStatus: { - valid: false, - message: 'Unknown GitHub API error', - }, - }); - } - }); + else + this.setState({ + patStatus: { + valid: false, + message: 'Unknown GitHub API error', + }, + }); + } + } } handlePATToken(event) { diff --git a/nerdlets/maintainer-dashboard/githubData.js b/nerdlets/maintainer-dashboard/githubData.js deleted file mode 100644 index 7cc1498..0000000 --- a/nerdlets/maintainer-dashboard/githubData.js +++ /dev/null @@ -1,82 +0,0 @@ -import { - SEARCH_NEW_ITEMS_QUERY, - SEARCH_STALE_ITEMS_QUERY, - makeDefStaleSearch, - makeMaybeStaleSearch, - makeNewSearch, -} from './graphql/GithubQueries'; - -/** - * Run a GraphQL query to get information about new and stale items for a given - * set of repositories. TODO: better overview of new/stale items - * - * @param {any} client Apollo GraphQL client to use to query the GitHub GraphQL - * API. Must be preloaded with the proper credentials. - * @param {string[]} options.scanRepos The repositories to scan, in "org/name" - * format. - * @param {string[]} options.companyUsers Login names of GitHub accounts - * associated with employees. This value is used to determine which items - * have received a response from someone in the company. - * @param {string[]} options.ignoreLabels Issue/PR labels to exclude. All - * issues/PRs with these labels will be ignored. - * @param {number} options.staleTime Duration in milliseconds that a item should - * remain inactive for it to be considered stale. - * @returns {object} TODO: more docs - */ -export async function getGithubData(client, options) { - const { scanRepos, companyUsers, staleTime, ignoreLabels } = options; - const staleDate = new Date(Date.now() - staleTime); - // fetch all the data - const [newRes, staleRes] = await Promise.all([ - client.query({ - query: SEARCH_NEW_ITEMS_QUERY, - variables: { - query: makeNewSearch(companyUsers, scanRepos, ignoreLabels), - }, - }), - client.query({ - query: SEARCH_STALE_ITEMS_QUERY, - variables: { - queryDefStale: makeDefStaleSearch( - companyUsers, - scanRepos, - ignoreLabels, - staleDate - ), - queryMaybeStale: makeMaybeStaleSearch( - companyUsers, - scanRepos, - ignoreLabels, - staleDate - ), - timeSince: staleDate.toISOString(), - }, - }), - ]); - // filter the stale data to only the actually stale items - const nrSet = new Set(companyUsers); - // if every comment by a relic is stale, then the issue is stale - // TODO: filter timeline events for interactions - const filteredMaybeItems = staleRes.data.maybeStale.nodes.filter((n) => - n.timelineItems.nodes.every((c) => - c?.author && nrSet.has(c.author.login) - ? new Date(c.updatedAt) <= staleDate - : true - ) - ); - return { - newSearchCount: Math.max( - newRes.data.search.issueCount, - newRes.data.search.nodes.length - ), - newSearchItems: newRes.data.search.nodes, - staleSearchCount: - Math.max( - staleRes.data.definitelyStale.issueCount, - staleRes.data.definitelyStale.nodes.length - ) + filteredMaybeItems.length, - staleSearchItems: staleRes.data.definitelyStale.nodes.concat( - filteredMaybeItems - ), - }; -} diff --git a/nerdlets/maintainer-dashboard/graphql/ApolloClientInstance.js b/nerdlets/maintainer-dashboard/graphql/ApolloClientInstance.js index 414eea5..e9eec7e 100644 --- a/nerdlets/maintainer-dashboard/graphql/ApolloClientInstance.js +++ b/nerdlets/maintainer-dashboard/graphql/ApolloClientInstance.js @@ -23,7 +23,7 @@ const GITHUB_BASE_URL = 'https://api.github.com/graphql'; export const client = (userToken) => { return new ApolloClient({ link: ApolloLink.from([ - // new RetryLink(), + new RetryLink(), onError(({ graphQLErrors, networkError }) => { if (graphQLErrors) { console.error('GRAPHQL_ERR::>', graphQLErrors); diff --git a/nerdlets/maintainer-dashboard/graphql/ErrorMessage.js b/nerdlets/maintainer-dashboard/graphql/ErrorMessage.js deleted file mode 100644 index 4fe290d..0000000 --- a/nerdlets/maintainer-dashboard/graphql/ErrorMessage.js +++ /dev/null @@ -1,14 +0,0 @@ -import React from 'react'; - -/** - * Renders error content for GraphQL API calls Borrowed from: - * https://github.com/the-road-to-graphql/react-graphql-github-apollo/blob/mast - * r/src/Error/index.js - */ -const ErrorMessage = ({ error }) => ( -
- {error.toString()} -
-); - -export default ErrorMessage; diff --git a/nerdlets/maintainer-dashboard/graphql/GithubQueries.js b/nerdlets/maintainer-dashboard/graphql/GithubQueries.js deleted file mode 100644 index ba43a6d..0000000 --- a/nerdlets/maintainer-dashboard/graphql/GithubQueries.js +++ /dev/null @@ -1,171 +0,0 @@ -import gql from 'graphql-tag'; - -// TODO: split query into search + virtualized table to improve caching -/** - * Fragment indicating the values and structure of all issue objects fetched - * using the github queries below. - */ -const ISSUE_FRAGMENT = gql` - fragment GetIssueInfo on Issue { - id - title - author { - login - url - } - repository { - name - url - } - labels(first: 100, orderBy: { field: NAME, direction: ASC }) { - nodes { - name - color - } - } - number - url - createdAt - } -`; - -/** - * Fragment indicating the values and structure of all PR objects fetched using - * the github queries below. - */ -const PR_FRAGMENT = gql` - fragment GetPRInfo on PullRequest { - id - title - author { - login - url - } - repository { - name - url - } - labels(first: 100, orderBy: { field: NAME, direction: ASC }) { - nodes { - name - color - } - } - number - url - createdAt - } -`; - -/** - * Query to perform a GitHub search and fetch all "Issues" (Issues and PRs) that - * match a given query. We can use this graphQL query and GitHub's powerful - * search syntax to only fetch items which were created by users who are not - * employees. Use makeNewSearch to construct a search query paramter that finds - * PRs and Issues that are open and not created/commented by an employee. - * - * @param {string} query The GitHub search query to search with. - */ -export const SEARCH_NEW_ITEMS_QUERY = gql` - query SearchResults($query: String!) { - search(query: $query, type: ISSUE, first: 100) { - nodes { - __typename - ...GetIssueInfo - ...GetPRInfo - } - issueCount - } - rateLimit { - limit - cost - remaining - resetAt - } - } - ${PR_FRAGMENT} - ${ISSUE_FRAGMENT} -`; - -export const SEARCH_STALE_ITEMS_QUERY = gql` - query SearchResults( - $queryDefStale: String! - $queryMaybeStale: String! - $timeSince: DateTime! - ) { - definitelyStale: search(query: $queryDefStale, type: ISSUE, first: 100) { - issueCount - nodes { - __typename - ...GetIssueInfo - ...GetPRInfo - } - } - maybeStale: search(query: $queryMaybeStale, type: ISSUE, first: 100) { - issueCount - nodes { - __typename - ...GetIssueInfo - ...GetPRInfo - ... on Issue { - timelineItems(since: $timeSince, last: 100) { - nodes { - ... on Comment { - id - author { - login - } - updatedAt - } - } - } - } - ... on PullRequest { - timelineItems(since: $timeSince, last: 100) { - nodes { - ... on Comment { - id - author { - login - } - updatedAt - } - } - } - } - } - } - rateLimit { - limit - cost - remaining - resetAt - } - } - ${PR_FRAGMENT} - ${ISSUE_FRAGMENT} -`; - -export function makeNewSearch(users, repos, ignoreLabels) { - return `${repos.map((r) => `repo:${r}`).join(' ')} ${users - .map((u) => `-author:${u} -commenter:${u}`) - .join(' ')} ${ignoreLabels.map((l) => `-label:${l}`).join(' ')} is:open`; -} - -export function makeDefStaleSearch(users, repos, ignoreLabels, date) { - return `${repos.map((r) => `repo:${r}`).join(' ')} ${users - .map((u) => `-author:${u} commenter:${u}`) - .join(' ')} ${ignoreLabels - .map((l) => `-label:${l}`) - .join(' ')} is:open updated:<=${date.toISOString()}`; -} - -export function makeMaybeStaleSearch(users, repos, ignoreLabels, date) { - return `${repos.map((r) => `repo:${r}`).join(' ')} ${users - .map((u) => `-author:${u} commenter:${u}`) - .join(' ')} ${ignoreLabels - .map((l) => `-label:${l}`) - .join( - ' ' - )} is:open updated:>${date.toISOString()} created:<=${date.toISOString()}`; -} diff --git a/nerdlets/maintainer-dashboard/graphql/githubData.js b/nerdlets/maintainer-dashboard/graphql/githubData.js new file mode 100644 index 0000000..4e84baa --- /dev/null +++ b/nerdlets/maintainer-dashboard/graphql/githubData.js @@ -0,0 +1,413 @@ +import gql from 'graphql-tag'; + +// TODO: split query into search + virtualized table to improve caching +// TODO: implement query pagination to allow getting more than 100 items +// TODO: more precise filtering of the timeline items (only checking for comments right now) + +/** + * Fragment indicating the values and structure of all issue objects fetched + * using the github queries below. + */ +const ISSUE_FRAGMENT = gql` + fragment GetIssueInfo on Issue { + id + title + author { + login + url + } + repository { + name + url + } + labels(first: 100, orderBy: { field: NAME, direction: ASC }) { + nodes { + name + color + } + } + number + url + createdAt + } +`; + +/** + * Fragment indicating the values and structure of all PR objects fetched using + * the github queries below. + */ +const PR_FRAGMENT = gql` + fragment GetPRInfo on PullRequest { + id + title + author { + login + url + } + repository { + name + url + } + labels(first: 100, orderBy: { field: NAME, direction: ASC }) { + nodes { + name + color + } + } + number + url + createdAt + } +`; + +/** + * Query to perform a GitHub search and fetch all "Issues" (Issues and PRs) that + * match a given query. We can use this graphQL query and GitHub's powerful + * search syntax to only fetch items which were created by users who are not + * employees. Use makeNewSearch to construct a search query paramter that finds + * PRs and Issues that are open and not created/commented by an employee. + * + * Note that due to the nature of the typically large search query, this GraphQL + * query may take a few seconds to execute. + * + * @param {string} query The GitHub search query to search with. + */ +const SEARCH_NEW_ITEMS_QUERY = gql` + query SearchResults($query: String!) { + search(query: $query, type: ISSUE, first: 100) { + nodes { + __typename + ...GetIssueInfo + ...GetPRInfo + } + issueCount + } + rateLimit { + limit + cost + remaining + resetAt + } + } + ${PR_FRAGMENT} + ${ISSUE_FRAGMENT} +`; + +/** + * Query to perform a GitHub search and fetch all "Issues" (Issues and PRs) that + * match several given queries. We can use this graphQL query and GitHub's + * powerful search syntax to only fetch items which are considered stale. + * + * In this case, a "stale" item is an item that received a response from an + * employee but then received no other response from an employee for at least a + * certain duration. GitHub's search syntax doesn't quite allow us to get all + * this information in a single search, so the query is split into two separate + * searches: + * + * * definitelyStale - Issues or PRs that were not created by an employee, have + * at least one comment from an employee, and were last updated before the + * stale threshold. "Last updated" in GitHub terms can mean a variety of things + * (including comments from other users), so this query doesn't catch issues or + * PRs that are active but haven't received an employee response in awhile. The + * query for this search is generated by makeDefStaleSearch. + * + * * maybeStale - Issues or PRs that were not created by a an employee, and were + * created before the stale threshold but updated after it. As GitHub search + * cannot determine if an employee has commented within a certain time, this + * search fetches the TimeLine items of each issue/pr candidate, expecting the + * application to use this data to determine which Issues/PRs have received + * employee follow up. The query for this search is generated by + * makeDefStaleSearch, and the results from this search can be filtered using + * filterMaybeStaleItems. + * + * Note that due to the nature of the typically large search query, this GraphQL + * query may take a few seconds to execute. + * + * @param {string} queryDefStale The GitHub search query to search for + * definitelyStale items. Use makeDefStaleSearch to make this query. + * @param {string} queryMaybeStale The GitHub search query to search for + * maybeStale items. Use makeMaybeStaleSearch to make this query. + * @param {string} timeSince An ISO Date string indicating the threshold to use + * for considering items to be stale (ex. two weeks before today). + */ +const SEARCH_STALE_ITEMS_QUERY = gql` + query SearchResults( + $queryDefStale: String! + $queryMaybeStale: String! + $timeSince: DateTime! + ) { + definitelyStale: search(query: $queryDefStale, type: ISSUE, first: 100) { + issueCount + nodes { + __typename + ...GetIssueInfo + ...GetPRInfo + } + } + maybeStale: search(query: $queryMaybeStale, type: ISSUE, first: 100) { + issueCount + nodes { + __typename + ...GetIssueInfo + ...GetPRInfo + ... on Issue { + timelineItems(since: $timeSince, last: 100) { + nodes { + ... on Comment { + id + author { + login + } + updatedAt + } + } + } + } + ... on PullRequest { + timelineItems(since: $timeSince, last: 100) { + nodes { + ... on Comment { + id + author { + login + } + updatedAt + } + } + } + } + } + } + rateLimit { + limit + cost + remaining + resetAt + } + } + ${PR_FRAGMENT} + ${ISSUE_FRAGMENT} +`; + +/** + * Utility function to create a search query that looks for items that haven't + * received an employee response yet. See SEARCH_NEW_ITEMS_QUERY for more + * information. + * + * @param {string[]} users GitHub logins (usernames, not emails) to treat as + * employees. + * @param {string[]} repos Repositories to pull Issues and PRs from. + * @param {string[]} ignoreLabels Labels (ex. "bug") to ignore when searching. + * Issues and PRs with any of these labels will be excluded from results. + */ + +function makeNewSearch(users, repos, ignoreLabels) { + return `${repos.map((r) => `repo:${r}`).join(' ')} ${users + .map((u) => `-author:${u} -commenter:${u}`) + .join(' ')} ${ignoreLabels.map((l) => `-label:${l}`).join(' ')} is:open`; +} + +/** + * Utility function to create a search query that looks for items that are + * definitely stale. See SEARCH_STALE_ITEMS_QUERY for more information. + * + * @param {string[]} users GitHub logins (usernames, not emails) to treat as + * employees. + * @param {string[]} repos Repositories to pull Issues and PRs from. + * @param {string[]} ignoreLabels Labels (ex. "bug") to ignore when searching. + * Issues and PRs with any of these labels will be excluded from results. + * @param {Date} date A Date to treat as the threshold to considering an item to + * be stale (ex. two weeks before today). + */ +function makeDefStaleSearch(users, repos, ignoreLabels, date) { + return `${repos.map((r) => `repo:${r}`).join(' ')} ${users + .map((u) => `-author:${u} commenter:${u}`) + .join(' ')} ${ignoreLabels + .map((l) => `-label:${l}`) + .join(' ')} is:open updated:<=${date.toISOString()}`; +} + +/** + * Utility function to create a search query that looks for items that might be + * stale. See SEARCH_STALE_ITEMS_QUERY for more information. + * + * @param {string[]} users GitHub logins (usernames, not emails) to treat as + * employees. + * @param {string[]} repos Repositories to pull Issues and PRs from. + * @param {string[]} ignoreLabels Labels (ex. "bug") to ignore when searching. + * Issues and PRs with any of these labels will be excluded from results. + * @param {Date} date A Date to treat as the threshold to considering an item to + * be stale (ex. two weeks before today). + */ +function makeMaybeStaleSearch(users, repos, ignoreLabels, date) { + return `${repos.map((r) => `repo:${r}`).join(' ')} ${users + .map((u) => `-author:${u} commenter:${u}`) + .join(' ')} ${ignoreLabels + .map((l) => `-label:${l}`) + .join( + ' ' + )} is:open updated:>${date.toISOString()} created:<=${date.toISOString()}`; +} + +/** + * Filter SEARCH_STALE_ITEMS_QUERY.maybeStale.nodes based on which users are + * considered employees. + * + * @param {{ timelineItems: { nodes: any[] } }[]} items An array of Issues/PRs + * with a timeline + * @param {string[]} employeeLogins A list of GitHub logins to treat as + * employees + * @param {Date} staleDate A date to treat as the threshold of being stale (ex. + * two weeks before today). + * @returns {{ timelineItems: { nodes: any[] } }[]} The items paramter array + * with the stale Issues/PRs removed. + */ +function filterMaybeStaleItems(items, employeeLogins, staleDate) { + const employeeSet = new Set(employeeLogins); + return items.filter((n) => + n.timelineItems.nodes.every((c) => + c?.author && employeeSet.has(c.author.login) + ? new Date(c.updatedAt) <= staleDate + : true + ) + ); +} + +/** + * Get some useful information about the user we are currently authenticated as. + * This query is used to both test the authentication mechanism and fetch + * information about the account that can be used to provide helpful + * suggestions in the settings. + * + * @param {?string} repoCursor An endCursor to paginate over the repositories + * with. + */ +const GET_CUR_USER_INFO_QUERY = gql` + query($repoCursor: String) { + viewer { + login + repositories( + affiliations: [OWNER, COLLABORATOR, ORGANIZATION_MEMBER] + first: 100 + after: $repoCursor + ) { + nodes { + nameWithOwner + } + } + } + } +`; + +/** + * Run a GraphQL query to get information about new and stale items for a given + * set of repositories. In this case, new items are Issues/PRs that have not + * received a response from an employee, and stale items are Issues/PRs that + * have received an employee response but have not received a follow up for + * longer than a certain period of time. For more information about the exact + * process used to determine which item is new/stale, check out the + * SEARCH_NEW_ITEMS_QUERY and SEARCH_STALE_ITEMS_QUERY queries. + * + * Note: this function currently is limited to 100 items per new/stale. This + * will be fixed when query pagination is implemented. + * + * @param {any} client Apollo GraphQL client to use to query the GitHub GraphQL + * API. Must be preloaded with the proper credentials. + * @param {string[]} options.scanRepos The repositories to scan, in "org/name" + * format. + * @param {string[]} options.companyUsers Login names of GitHub accounts + * associated with employees. This value is used to determine which items + * have received a response from someone in the company. Login names should be + * usernames not emails (ex. "prototypicalpro") + * @param {string[]} options.ignoreLabels Issue/PR labels to exclude (ex."bug"). + * All issues/PRs with these labels will be excluded from the results. + * @param {number} options.staleTime Duration in milliseconds that a item should + * remain inactive for it to be considered stale. + * @returns {{ + * newSearchCount: number; + * newSearchItems: object[]; + * staleSearchCount: number; + * staleSearchItems: object[]; + * }} + * An object containing the results of the queries. The "items" arrays will contain objects of either + * ISSUE_FRAGMENT or PR_FRAGMENT structure. + */ +export async function findDashboardItems(client, options) { + const { scanRepos, companyUsers, staleTime, ignoreLabels } = options; + const staleDate = new Date(Date.now() - staleTime); + // fetch all the data + const [newRes, staleRes] = await Promise.all([ + client.query({ + query: SEARCH_NEW_ITEMS_QUERY, + variables: { + query: makeNewSearch(companyUsers, scanRepos, ignoreLabels), + }, + }), + client.query({ + query: SEARCH_STALE_ITEMS_QUERY, + variables: { + queryDefStale: makeDefStaleSearch( + companyUsers, + scanRepos, + ignoreLabels, + staleDate + ), + queryMaybeStale: makeMaybeStaleSearch( + companyUsers, + scanRepos, + ignoreLabels, + staleDate + ), + timeSince: staleDate.toISOString(), + }, + }), + ]); + // if every comment by an employee is stale, then the issue is stale + const filteredMaybeItems = filterMaybeStaleItems( + staleRes.data.maybeStale.nodes, + companyUsers, + staleDate + ); + return { + newSearchCount: Math.max( + newRes.data.search.issueCount, + newRes.data.search.nodes.length + ), + newSearchItems: newRes.data.search.nodes, + staleSearchCount: + Math.max( + staleRes.data.definitelyStale.issueCount, + staleRes.data.definitelyStale.nodes.length + ) + filteredMaybeItems.length, + staleSearchItems: staleRes.data.definitelyStale.nodes.concat( + filteredMaybeItems + ), + }; +} + +/** + * Get some useful information about the user we are currently authenticated as. + * This function can both test the authentication mechanism and fetch + * information about the account that can be used to provide helpful + * suggestions in the settings. + * + * @param {any} client Apollo GraphQL client to use to query the GitHub GraphQL + * API. + * @param {string} token GitHub personal access token to use for authenticating + * this request + * @returns {Promise} A promise containing the result of the + * GET_CUR_USER_INFO_QUERY. Will thrown an exception if the supplied token + * is invalid. + */ +export async function getUserInfo(client, token) { + const { data } = await client.query({ + query: GET_CUR_USER_INFO_QUERY, + fetchPolicy: 'network-only', + context: { + headers: { + authorization: `Bearer ${token}`, + }, + }, + }); + return data; +} diff --git a/nerdlets/maintainer-dashboard/index.js b/nerdlets/maintainer-dashboard/index.js index 984d061..fa9749b 100644 --- a/nerdlets/maintainer-dashboard/index.js +++ b/nerdlets/maintainer-dashboard/index.js @@ -1,146 +1,8 @@ import React from 'react'; -import { - Card, - CardHeader, - CardBody, - HeadingText, - Spinner, - Grid, - GridItem, - Stack, - StackItem, - Spacing, - Table, - TableHeader, - TableHeaderCell, - TableRow, - TableRowCell, - Button, - Tabs, - TabsItem, - BillboardChart, - BlockText, - Icon, - Modal, - TextField, - Link, - Select, - SelectItem, - UserStorageMutation, -} from 'nr1'; -import { KNOWN_LABEL_COLORS } from './issueLabel'; -import SettingsUI from './settings'; -import Dashboard from './dashboard'; -import UserSettingsQuery from './storageUtil'; -import { client } from './graphql/ApolloClientInstance'; -import NewRelicUsers from './data/userdata-sample.json'; - -const RELICS = Object.values(NewRelicUsers) - .filter((u) => u.user_type === 'relic' || u.user_type === 'contractor') - .map((u) => u.login) - .sort(); - -export default class MaintainerDashboard extends React.Component { - constructor(props) { - super(props); - this.state = { - queryKey: 0, - settingsHidden: true, - }; - } +import MaintainerDashboard from './components/dashboard'; +export default class Root extends React.PureComponent { render() { - return ( - - - - {({ loading, token, settings }) => { - if (loading) - return ; - // create apollo client and settings UI - const gqlClient = client(token); - // return settings selection front and center if we have no settings - if (!token || !settings) - return ( - ({ name, color }))} - onSubmit={() => - this.setState(({ queryKey }) => ({ - settingsHidden: true, - queryKey: queryKey + 1, - })) - } - client={gqlClient} - style={{ maxWidth: '60em' }} - /> - ); - return ( - // else return the dashboard with a settings modal - <> - - - - - - - - - - - - ); - }} - - - - ); + return ; } } diff --git a/nerdlets/maintainer-dashboard/storageUtil.js b/nerdlets/maintainer-dashboard/util/storageUtil.js similarity index 100% rename from nerdlets/maintainer-dashboard/storageUtil.js rename to nerdlets/maintainer-dashboard/util/storageUtil.js