From 4a50d55b33e7a84b516ae56a6047963e467eeba5 Mon Sep 17 00:00:00 2001 From: Noah Koontz Date: Tue, 27 Oct 2020 12:02:18 -0700 Subject: [PATCH] feat: update dashboard configuration instructutions --- nerdlets/maintainer-dashboard/dashboard.js | 180 ++--------- nerdlets/maintainer-dashboard/githubData.js | 155 +-------- .../graphql/GithubQueries.js | 171 ++++++++++ nerdlets/maintainer-dashboard/issueTable.js | 149 +++++++++ nerdlets/maintainer-dashboard/settings.js | 294 ++++++++++++------ 5 files changed, 543 insertions(+), 406 deletions(-) create mode 100644 nerdlets/maintainer-dashboard/graphql/GithubQueries.js create mode 100644 nerdlets/maintainer-dashboard/issueTable.js diff --git a/nerdlets/maintainer-dashboard/dashboard.js b/nerdlets/maintainer-dashboard/dashboard.js index a35341c..fc7b5a5 100644 --- a/nerdlets/maintainer-dashboard/dashboard.js +++ b/nerdlets/maintainer-dashboard/dashboard.js @@ -1,6 +1,5 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { client } from './graphql/ApolloClientInstance'; import * as humanizeDuration from 'humanize-duration'; import BootstrapTable from 'react-bootstrap-table-next'; import filterFactory, { @@ -11,16 +10,9 @@ import filterFactory, { multiSelectFilter } from 'react-bootstrap-table2-filter'; import { - Card, - CardHeader, - CardBody, - HeadingText, Spinner, - Grid, - GridItem, Stack, StackItem, - Spacing, Table, TableHeader, TableHeaderCell, @@ -30,150 +22,12 @@ import { Tabs, TabsItem, BillboardChart, - BlockText, Icon, - Modal, - TextField, Link, - Select, - SelectItem, - UserStorageMutation + Tooltip } from 'nr1'; import { getGithubData } from './githubData'; -import { IssueLabel } from './issueLabel'; -import PullRequestLogo from './img/git-pull-request-16.svg'; -import IssueLogo from './img/issue-opened-16.svg'; - -class IssueTable extends React.PureComponent { - static propTypes = { - items: PropTypes.arrayOf(PropTypes.object) - }; - - constructor(props) { - super(props); - } - - render() { - const sortCaret = order => { - let type; - if (order === 'asc') type = Icon.TYPE.INTERFACE__ARROW__ARROW_TOP; - else if (order === 'desc') - type = Icon.TYPE.INTERFACE__ARROW__ARROW_BOTTOM; - else type = Icon.TYPE.INTERFACE__ARROW__ARROW_VERTICAL; - return ( - - ) - }, - { - dataField: 'repository.name', - text: 'Repository', - sort: true, - sortCaret, - filter: textFilter() - }, - { - dataField: 'createdAt', - text: 'Open', - sort: true, - sortValue: cell => Date.now() - new Date(cell).getTime(), - sortCaret, - type: 'date', - filter: dateFilter({}), - formatter: cell => - humanizeDuration(Date.now() - new Date(cell).getTime(), { - largest: 1 - }) - }, - { - dataField: 'author.login', - text: 'User', - sort: true, - sortCaret, - filter: textFilter() - }, - { - dataField: 'labels.nodes', - text: 'Labels', - filter: multiSelectFilter({ - comparator: Comparator.LIKE, - options: allLabels.reduce((a, { name }) => { - a[name] = name; - return a; - }, {}), - withoutEmptyOption: true - }), - filterValue: cell => cell.map(({ name }) => name), - formatter: cell => - cell.map(({ name, color }) => ( - - )) - }, - { - dataField: 'title', - text: 'Title', - filter: textFilter() - } - ]; - - return ( - <> - - - ); - } -} +import { IssueTable } from './issueTable'; export default class Dashboard extends React.Component { static propTypes = { @@ -200,7 +54,7 @@ export default class Dashboard extends React.Component { this.setState( await getGithubData(this.props.client, { scanRepos: this.props.scanRepos, - companyUsers: this.props.companyUsers.concat(this.props.ignoreUsers), // TODO: ignore users by adding them to the company users? + companyUsers: this.props.companyUsers.concat(this.props.ignoreUsers), ignoreLabels: this.props.ignoreLabels, staleTime: this.props.staleTime }) @@ -272,10 +126,34 @@ export default class Dashboard extends React.Component { - + + New Items{' '} + + + + + } + value="new" + > - + + Stale Items{' '} + + + + + } + value="stale" + > diff --git a/nerdlets/maintainer-dashboard/githubData.js b/nerdlets/maintainer-dashboard/githubData.js index d684b48..a853ce5 100644 --- a/nerdlets/maintainer-dashboard/githubData.js +++ b/nerdlets/maintainer-dashboard/githubData.js @@ -1,157 +1,4 @@ -import gql from 'graphql-tag'; - -// TODO: split query into search + virtualized table to improve caching -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 - } -`; - -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 - } -`; - -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} -`; - -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} -`; - -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`; -} - -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()}`; -} - -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()}`; -} +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. diff --git a/nerdlets/maintainer-dashboard/graphql/GithubQueries.js b/nerdlets/maintainer-dashboard/graphql/GithubQueries.js new file mode 100644 index 0000000..7e49630 --- /dev/null +++ b/nerdlets/maintainer-dashboard/graphql/GithubQueries.js @@ -0,0 +1,171 @@ +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()}`; +} \ No newline at end of file diff --git a/nerdlets/maintainer-dashboard/issueTable.js b/nerdlets/maintainer-dashboard/issueTable.js new file mode 100644 index 0000000..7bf8f83 --- /dev/null +++ b/nerdlets/maintainer-dashboard/issueTable.js @@ -0,0 +1,149 @@ +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 { 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'; + +/** + * + */ +export class IssueTable extends React.PureComponent { + static propTypes = { + items: PropTypes.arrayOf(PropTypes.object) + }; + + constructor(props) { + super(props); + } + + render() { + const sortCaret = order => { + let type; + if (order === 'asc') type = Icon.TYPE.INTERFACE__ARROW__ARROW_TOP; + else if (order === 'desc') + type = Icon.TYPE.INTERFACE__ARROW__ARROW_BOTTOM; + else type = Icon.TYPE.INTERFACE__ARROW__ARROW_VERTICAL; + return ( + + ) + }, + { + dataField: 'repository.name', + text: 'Repository', + sort: true, + sortCaret, + filter: textFilter() + }, + { + dataField: 'createdAt', + text: 'Open', + sort: true, + sortValue: cell => Date.now() - new Date(cell).getTime(), + sortCaret, + type: 'date', + filter: dateFilter({}), + formatter: cell => + humanizeDuration(Date.now() - new Date(cell).getTime(), { + largest: 1 + }) + }, + { + dataField: 'author.login', + text: 'User', + sort: true, + sortCaret, + filter: textFilter() + }, + { + dataField: 'labels.nodes', + text: 'Labels', + filter: multiSelectFilter({ + comparator: Comparator.LIKE, + options: allLabels.reduce((a, { name }) => { + a[name] = name; + return a; + }, {}), + withoutEmptyOption: true + }), + filterValue: cell => cell.map(({ name }) => name), + formatter: cell => + cell.map(({ name, color }) => ( + + )) + }, + { + dataField: 'title', + text: 'Title', + filter: textFilter() + } + ]; + + return ( + <> + + + ); + } +} \ No newline at end of file diff --git a/nerdlets/maintainer-dashboard/settings.js b/nerdlets/maintainer-dashboard/settings.js index 6f6a7fd..85a44cc 100644 --- a/nerdlets/maintainer-dashboard/settings.js +++ b/nerdlets/maintainer-dashboard/settings.js @@ -34,6 +34,28 @@ const GET_CUR_USER_INFO = gql` `; export default class SettingsUI extends React.Component { + static splitRepositoryNames(searchTerm) { + return Array.from( + new Set( + searchTerm + .split(/(,\s*)|\s+/g) + .map(n => n && n.trim()) + .filter(n => n && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(n)) + ) + ); + } + + static splitLogins(searchTerm) { + return Array.from( + new Set( + searchTerm + .split(/(,\s*)|\s+/g) + .map(n => n && n.trim()) + .filter(n => n) + ) + ); + } + constructor(props) { super(props); this.state = { @@ -185,13 +207,18 @@ export default class SettingsUI extends React.Component { fullWidth horizontalType={Stack.HORIZONTAL_TYPE.FILL} directionType={Stack.DIRECTION_TYPE.VERTICAL} - gapType={Stack.GAP_TYPE.EXTRA_LARGE} + gapType={Stack.GAP_TYPE.LARGE} > Dashboard Configuration + + + Personal Access Token + + Supply a personal access token to allow this dashboard to access @@ -218,8 +245,8 @@ export default class SettingsUI extends React.Component { > ) : null} + + + Repositories + + Select which repositories you would like this tool to scan. To add - options not on the list, you can enter comma or space separated - repository names to the filter and press enter. + options not on the list, enter comma or space separated repository + names in the box and press enter. this.setState(({ repoValue }) => ({ - repoValue: name - .split(/(,\s*)|\s+/g) - .map(n => n && n.trim()) - .filter( - n => - n && - !repoValue.includes(n) && - /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(n) - ) + repoValue: SettingsUI.splitRepositoryNames(name) + .filter(n => !repoValue.includes(n)) .concat(repoValue) })) } onChange={value => this.setState({ repoValue: value })} value={this.state.repoValue} data={this.state.patStatus.repoOptions} - placeholder="Add repositories to monitor (ex. newrelic/nr1-ospo)" + placeholder="Enter a repository name (e.g. newrelic/nr1-ospo)" + filter="contains" + messages={{ + emptyFilter: + 'Did not match any suggested repositories to that name.', + emptyList: + 'Enter a personal access token to see suggested repositories, or start typing to add your own.', + createOption({ searchTerm }) { + const split = SettingsUI.splitRepositoryNames(searchTerm); + if (!split.length) + return 'Invalid repository name (make sure to include the organization)'; + if (split.length === 1) return `Add repository ${split[0]}`; + return `Add repositories ${split.join(', ')}`; + } + }} /> - - Optionally select users or labels this tool should ignore items - from. - - - - - this.setState(({ labelValue }) => ({ - labelValue: labelValue.concat([name]) - })) - } - onChange={value => - this.setState({ - labelValue: value.map(v => - typeof v !== 'string' ? v.name : v - ) - }) - } - value={this.state.labelValue} - placeholder="Select labels to filter" - data={this.props.labelOptions} - textField="name" - itemComponent={({ item }) => ( - - )} - /> - - - - this.setState(({ userValue }) => ({ - userValue: name - .split(/(,\s*)|\s+/g) - .map(n => n && n.trim()) - .filter(n => n && !userValue.includes(n)) - .concat(userValue) - })) - } - onChange={value => this.setState({ userValue: value })} - value={this.state.userValue} - data={[]} - placeholder="Select users to filter" - /> - - - - Optionally adjust the time required for an item to be considered - stale. - - - - - - - this.setState({ - timeValue: target.value - }) - } - invalid={ - this.state.timeValue !== '' && - isNaN(parseFloat(this.state.timeValue)) - ? 'Value is not a number' - : false - } - value={this.state.timeValue} - /> - - - - - + Advanced Configuration + + + + + + Denylist Labels + + + + + Optionally select labels this tool should denylist. Issues + or PRs with the selected labels will not be shown. + + + + + this.setState(({ labelValue }) => ({ + labelValue: labelValue.concat([name]) + })) + } + onChange={value => + this.setState({ + labelValue: value.map(v => + typeof v !== 'string' ? v.name : v + ) + }) + } + value={this.state.labelValue} + placeholder="Select labels to filter" + data={this.props.labelOptions} + textField="name" + itemComponent={({ item }) => ( + + )} + filter="contains" + /> + + + + Employee GitHub Usernames + + + + + This dashboard pulls a list of current employee GitHub + handles from shared account storage, using it to determine + if an Issue or PR has received a response from inside the + company. You can specify additional GitHub usernames this + dashboard should treat as employees here. + + + + + this.setState(({ userValue }) => ({ + userValue: SettingsUI.splitLogins(name) + .filter(n => !userValue.includes(n)) + .concat(userValue) + })) + } + onChange={value => this.setState({ userValue: value })} + value={this.state.userValue} + data={[]} + placeholder="Enter additional GitHub usernames" + /> + + + + Stale Duration Threshold + + + + + Optionally adjust the duration of time an Issue and PR + should go without activity before it is considered stale. + The suggested time is around 2 weeks. + + + + + + + this.setState({ + timeValue: target.value + }) + } + invalid={ + this.state.timeValue !== '' && + isNaN(parseFloat(this.state.timeValue)) + ? 'Value is not a number' + : false + } + value={this.state.timeValue} + /> + + + + + + + +