diff --git a/keymaps/git.cson b/keymaps/git.cson index af3ff892bf..1f24a4282f 100644 --- a/keymaps/git.cson +++ b/keymaps/git.cson @@ -70,3 +70,4 @@ 'home': 'github:co-author:home' 'end': 'github:co-author:end' 'delete': 'github:co-author:delete' + 'shift-backspace': 'github:co-author-exclude' diff --git a/lib/controllers/commit-controller.js b/lib/controllers/commit-controller.js index 3aa490d25b..240d4a618d 100644 --- a/lib/controllers/commit-controller.js +++ b/lib/controllers/commit-controller.js @@ -7,7 +7,7 @@ import {CompositeDisposable} from 'event-kit'; import fs from 'fs-extra'; import CommitView from '../views/commit-view'; -import {AuthorPropType} from '../prop-types'; +import {AuthorPropType, UserStorePropType} from '../prop-types'; import {autobind} from '../helpers'; export const COMMIT_GRAMMAR_SCOPE = 'text.git-commit'; @@ -31,7 +31,7 @@ export default class CommitController extends React.Component { stagedChangesExist: PropTypes.bool.isRequired, lastCommit: PropTypes.object.isRequired, currentBranch: PropTypes.object.isRequired, - mentionableUsers: PropTypes.arrayOf(AuthorPropType), + userStore: UserStorePropType.isRequired, selectedCoAuthors: PropTypes.arrayOf(AuthorPropType), updateSelectedCoAuthors: PropTypes.func, prepareToCommit: PropTypes.func.isRequired, @@ -105,7 +105,7 @@ export default class CommitController extends React.Component { onChangeMessage={this.handleMessageChange} toggleExpandedCommitMessageEditor={this.toggleExpandedCommitMessageEditor} deactivateCommitBox={this.isCommitMessageEditorExpanded()} - mentionableUsers={this.props.mentionableUsers} + userStore={this.props.userStore} selectedCoAuthors={this.props.selectedCoAuthors} updateSelectedCoAuthors={this.props.updateSelectedCoAuthors} /> diff --git a/lib/controllers/git-tab-controller.js b/lib/controllers/git-tab-controller.js index 7b98772179..ec38e5517d 100644 --- a/lib/controllers/git-tab-controller.js +++ b/lib/controllers/git-tab-controller.js @@ -15,6 +15,7 @@ export default class GitTabController extends React.Component { static propTypes = { repository: PropTypes.object.isRequired, + loginModel: PropTypes.object.isRequired, lastCommit: CommitPropType.isRequired, recentCommits: PropTypes.arrayOf(CommitPropType).isRequired, @@ -62,15 +63,13 @@ export default class GitTabController extends React.Component { this.refView = null; this.state = { - mentionableUsers: [], selectedCoAuthors: [], }; this.userStore = new UserStore({ repository: this.props.repository, - onDidUpdate: users => { - this.setState({mentionableUsers: users}); - }, + login: this.props.loginModel, + config: this.props.config, }); } @@ -93,7 +92,7 @@ export default class GitTabController extends React.Component { mergeConflicts={this.props.mergeConflicts} workingDirectoryPath={this.props.workingDirectoryPath} mergeMessage={this.props.mergeMessage} - mentionableUsers={this.state.mentionableUsers} + userStore={this.userStore} selectedCoAuthors={this.state.selectedCoAuthors} updateSelectedCoAuthors={this.updateSelectedCoAuthors} @@ -135,16 +134,9 @@ export default class GitTabController extends React.Component { this.refView.refRoot.addEventListener('focusin', this.rememberLastFocus); } - componentDidUpdate(prevProps) { - if (prevProps.repository !== this.props.repository) { - this.userStore = new UserStore({ - repository: this.props.repository, - onDidUpdate: users => { - this.setState({mentionableUsers: users}); - }, - }); - } - + componentDidUpdate() { + this.userStore.setRepository(this.props.repository); + this.userStore.setLoginModel(this.props.loginModel); this.refreshResolutionProgress(false, false); } @@ -258,7 +250,7 @@ export default class GitTabController extends React.Component { updateSelectedCoAuthors(selectedCoAuthors, newAuthor) { if (newAuthor) { - this.userStore.addUsers({[newAuthor.email]: newAuthor.name}); + this.userStore.addUsers([newAuthor]); selectedCoAuthors = selectedCoAuthors.concat([newAuthor]); } this.setState({selectedCoAuthors}); diff --git a/lib/controllers/remote-pr-controller.js b/lib/controllers/remote-pr-controller.js index 5c10783454..19100aef4d 100644 --- a/lib/controllers/remote-pr-controller.js +++ b/lib/controllers/remote-pr-controller.js @@ -4,9 +4,10 @@ import yubikiri from 'yubikiri'; import {shell} from 'electron'; import {RemotePropType, BranchSetPropType} from '../prop-types'; +import LoadingView from '../views/loading-view'; import GithubLoginView from '../views/github-login-view'; import ObserveModel from '../views/observe-model'; -import {UNAUTHENTICATED} from '../shared/keytar-strategy'; +import {UNAUTHENTICATED, INSUFFICIENT} from '../shared/keytar-strategy'; import {nullRemote} from '../models/remote'; import PrInfoController from './pr-info-controller'; import {autobind} from '../helpers'; @@ -49,29 +50,40 @@ export default class RemotePrController extends React.Component { ); } - renderWithData(loginData) { - const { - host, remote, branches, loginModel, selectedPrUrl, - aheadCount, pushInProgress, onSelectPr, onUnpinPr, - } = this.props; - const token = loginData.token; + renderWithData({token}) { + let inner; + if (token === null) { + inner = ; + } else if (token === UNAUTHENTICATED) { + inner = ; + } else if (token === INSUFFICIENT) { + inner = ( + +

+ Your token no longer has sufficient authorizations. Please re-authenticate and generate a new one. +

+
+ ); + } else { + const { + host, remote, branches, loginModel, selectedPrUrl, + aheadCount, pushInProgress, onSelectPr, onUnpinPr, + } = this.props; - return ( -
- {token && token !== UNAUTHENTICATED && - - } - {(!token || token === UNAUTHENTICATED) && } -
- ); + inner = ( + + ); + } + + return
{inner}
; } handleLogin(token) { diff --git a/lib/controllers/root-controller.js b/lib/controllers/root-controller.js index ca1e10db50..3e9cde0d52 100644 --- a/lib/controllers/root-controller.js +++ b/lib/controllers/root-controller.js @@ -193,6 +193,7 @@ export default class RootController extends React.Component { confirm={this.props.confirm} config={this.props.config} repository={this.props.repository} + loginModel={this.loginModel} initializeRepo={this.initializeRepo} resolutionProgress={this.props.resolutionProgress} ensureGitTab={this.gitTabTracker.ensureVisible} diff --git a/lib/models/author.js b/lib/models/author.js new file mode 100644 index 0000000000..4a699ecd82 --- /dev/null +++ b/lib/models/author.js @@ -0,0 +1,100 @@ +const NEW = Symbol('new'); + +export const NO_REPLY_GITHUB_EMAIL = 'noreply@github.com'; + +export default class Author { + constructor(email, fullName, login = null, isNew = null) { + this.email = email; + this.fullName = fullName; + this.login = login; + this.new = isNew === NEW; + } + + static createNew(email, fullName) { + return new this(email, fullName, null, NEW); + } + + getEmail() { + return this.email; + } + + getFullName() { + return this.fullName; + } + + getLogin() { + return this.login; + } + + isNoReply() { + return this.email === NO_REPLY_GITHUB_EMAIL; + } + + hasLogin() { + return this.login !== null; + } + + isNew() { + return this.new; + } + + isPresent() { + return true; + } + + matches(other) { + return this.getEmail() === other.getEmail(); + } + + toString() { + let s = `${this.fullName} <${this.email}>`; + if (this.hasLogin()) { + s += ` @${this.login}`; + } + return s; + } + + static compare(a, b) { + if (a.getFullName() < b.getFullName()) { return -1; } + if (a.getFullName() > b.getFullName()) { return 1; } + return 0; + } +} + +export const nullAuthor = { + getEmail() { + return ''; + }, + + getFullName() { + return ''; + }, + + getLogin() { + return null; + }, + + isNoReply() { + return false; + }, + + hasLogin() { + return false; + }, + + isNew() { + return false; + }, + + isPresent() { + return false; + }, + + matches(other) { + return other === this; + }, + + toString() { + return 'null author'; + }, +}; diff --git a/lib/models/github-login-model.js b/lib/models/github-login-model.js index 7da9bc6e72..b9f6fe75c3 100644 --- a/lib/models/github-login-model.js +++ b/lib/models/github-login-model.js @@ -1,10 +1,15 @@ +import crypto from 'crypto'; import {Emitter} from 'event-kit'; -import {UNAUTHENTICATED, createStrategy} from '../shared/keytar-strategy'; +import {UNAUTHENTICATED, INSUFFICIENT, createStrategy} from '../shared/keytar-strategy'; let instance = null; export default class GithubLoginModel { + // Be sure that we're requesting at least this many scopes on the token we grant through github.atom.io or we'll + // give everyone a really frustrating experience ;-) + static REQUIRED_SCOPES = ['repo', 'read:org', 'user:email'] + static get() { if (!instance) { instance = new GithubLoginModel(); @@ -16,6 +21,7 @@ export default class GithubLoginModel { this._Strategy = Strategy; this._strategy = null; this.emitter = new Emitter(); + this.checked = new Set(); } async getStrategy() { @@ -34,11 +40,41 @@ export default class GithubLoginModel { async getToken(account) { const strategy = await this.getStrategy(); - let password = await strategy.getPassword('atom-github', account); - if (!password) { + const password = await strategy.getPassword('atom-github', account); + if (!password || password === UNAUTHENTICATED) { // User is not logged in - password = UNAUTHENTICATED; + return UNAUTHENTICATED; } + + if (/^https?:\/\//.test(account)) { + // Avoid storing tokens in memory longer than necessary. Let's cache token scope checks by storing a set of + // checksums instead. + const hash = crypto.createHash('md5'); + hash.update(password); + const fingerprint = hash.digest('base64'); + + if (!this.checked.has(fingerprint)) { + try { + const scopes = new Set(await this.getScopes(account, password)); + + for (const scope of this.constructor.REQUIRED_SCOPES) { + if (!scopes.has(scope)) { + // Token doesn't have enough OAuth scopes, need to reauthenticate + return INSUFFICIENT; + } + } + + // We're good + this.checked.add(fingerprint); + } catch (e) { + // Bad credential most likely + // eslint-disable-next-line no-console + console.error(`Unable to validate token scopes against ${account}`, e); + return UNAUTHENTICATED; + } + } + } + return password; } @@ -54,6 +90,23 @@ export default class GithubLoginModel { this.didUpdate(); } + async getScopes(host, token) { + if (atom.inSpecMode()) { + throw new Error('Attempt to check token scopes in specs'); + } + + const response = await fetch(host, { + method: 'HEAD', + headers: {Authorization: `bearer ${token}`}, + }); + + if (response.status !== 200) { + throw new Error(`Unable to check token for OAuth scopes against ${host}: ${await response.text()}`); + } + + return response.headers.get('X-OAuth-Scopes').split(/\s*,\s*/); + } + didUpdate() { this.emitter.emit('did-update'); } diff --git a/lib/models/remote.js b/lib/models/remote.js index e272d62767..d7002a3f13 100644 --- a/lib/models/remote.js +++ b/lib/models/remote.js @@ -33,6 +33,10 @@ export default class Remote { return this.getName(); } + getSlug() { + return `${this.owner}/${this.repo}`; + } + isPresent() { return true; } @@ -90,6 +94,10 @@ export const nullRemote = { return fallback; }, + getSlug() { + return ''; + }, + isPresent() { return false; }, diff --git a/lib/models/repository-states/present.js b/lib/models/repository-states/present.js index 6075a609fa..bd8c2fb7c9 100644 --- a/lib/models/repository-states/present.js +++ b/lib/models/repository-states/present.js @@ -11,6 +11,7 @@ import Hunk from '../hunk'; import HunkLine from '../hunk-line'; import DiscardHistory from '../discard-history'; import Branch, {nullBranch} from '../branch'; +import Author from '../author'; import BranchSet from '../branch-set'; import Remote from '../remote'; import Commit from '../commit'; @@ -232,7 +233,16 @@ export default class Present extends State { ], // eslint-disable-next-line no-shadow () => this.executePipelineAction('COMMIT', (message, options) => { - return this.git().commit(message, options); + const opts = (!options || !options.coAuthors) + ? options + : { + ...options, + coAuthors: options.coAuthors.map(author => { + return {email: author.getEmail(), name: author.getFullName()}; + }), + }; + + return this.git().commit(message, opts); }, message, options), ); } @@ -616,8 +626,9 @@ export default class Present extends State { // For now we'll do the naive thing and invalidate anytime HEAD moves. This ensures that we get new authors // introduced by newly created commits or pulled commits. // This means that we are constantly re-fetching data. If performance becomes a concern we can optimize - return this.cache.getOrSet(Keys.authors, () => { - return this.git().getAuthors(options); + return this.cache.getOrSet(Keys.authors, async () => { + const authorMap = await this.git().getAuthors(options); + return Object.keys(authorMap).map(email => new Author(email, authorMap[email])); }); } diff --git a/lib/models/repository-states/state.js b/lib/models/repository-states/state.js index aa1756107e..c24f9080a1 100644 --- a/lib/models/repository-states/state.js +++ b/lib/models/repository-states/state.js @@ -293,7 +293,7 @@ export default class State { // Author information getAuthors() { - return Promise.resolve({}); + return Promise.resolve([]); } // Branches diff --git a/lib/models/repository.js b/lib/models/repository.js index 7a3d58ca5a..b785b55f01 100644 --- a/lib/models/repository.js +++ b/lib/models/repository.js @@ -6,6 +6,7 @@ import fs from 'fs-extra'; import {getNullActionPipelineManager} from '../action-pipeline'; import CompositeGitStrategy from '../composite-git-strategy'; import Remote, {nullRemote} from './remote'; +import Author, {nullAuthor} from './author'; import Branch from './branch'; import {Loading, Absent, LoadingGuess, AbsentGuess} from './repository-states'; @@ -241,7 +242,10 @@ export default class Repository { } }); } - return committer; + + return committer.name !== null && committer.email !== null + ? new Author(committer.email, committer.name) + : nullAuthor; } } diff --git a/lib/models/user-store.js b/lib/models/user-store.js index dd1b5d9374..2894c8c53d 100644 --- a/lib/models/user-store.js +++ b/lib/models/user-store.js @@ -1,79 +1,262 @@ +import yubikiri from 'yubikiri'; +import {Emitter, CompositeDisposable} from 'event-kit'; + +import RelayNetworkLayerManager from '../relay-network-layer-manager'; +import Author, {nullAuthor} from './author'; +import {UNAUTHENTICATED, INSUFFICIENT} from '../shared/keytar-strategy'; +import ModelObserver from './model-observer'; + // This is a guess about what a reasonable value is. Can adjust if performance is poor. const MAX_COMMITS = 5000; -export const NO_REPLY_GITHUB_EMAIL = 'noreply@github.com'; +export const source = { + PENDING: Symbol('pending'), + GITLOG: Symbol('git log'), + GITHUBAPI: Symbol('github API'), +}; + +class GraphQLCache { + // One hour + static MAX_AGE_MS = 3.6e6 + + constructor() { + this.bySlug = new Map(); + } + + get(remote) { + const slug = remote.getSlug(); + const {ts, data} = this.bySlug.get(slug) || { + ts: -Infinity, + data: {}, + }; + + if (Date.now() - ts > this.constructor.MAX_AGE_MS) { + this.bySlug.delete(slug); + return null; + } + return data; + } + + set(remote, data) { + this.bySlug.set(remote.getSlug(), {ts: Date.now(), data}); + } +} export default class UserStore { - constructor({repository, onDidUpdate}) { - this.repository = repository; - this.repository.onDidUpdate(() => { - this.loadUsers(); - }); - this.onDidUpdate = onDidUpdate || (() => {}); + constructor({repository, login, config}) { + this.emitter = new Emitter(); + this.subs = new CompositeDisposable(); + // TODO: [ku 3/2018] Consider using Dexie (indexDB wrapper) like Desktop and persist users across sessions - this.users = {}; - this.committer = {}; - this.populate(); + this.allUsers = new Map(); + this.excludedUsers = new Set(); + this.users = []; + this.committer = nullAuthor; + + this.last = { + source: source.PENDING, + repository: null, + excludedUsers: this.excludedUsers, + }; + this.cache = new GraphQLCache(); + + this.repositoryObserver = new ModelObserver({ + fetchData: r => yubikiri({ + committer: r.getCommitter(), + authors: r.getAuthors({max: MAX_COMMITS}), + remotes: r.getRemotes(), + }), + didUpdate: () => this.loadUsers(), + }); + this.repositoryObserver.setActiveModel(repository); + + this.loginObserver = new ModelObserver({ + didUpdate: () => this.loadUsers(), + }); + this.loginObserver.setActiveModel(login); + + this.subs.add( + config.observe('github.excludedUsers', value => { + this.excludedUsers = new Set( + (value || '').split(/\s*,\s*/).filter(each => each.length > 0), + ); + return this.loadUsers(); + }), + ); + } + + dispose() { + this.subs.dispose(); + this.emitter.dispose(); } - populate() { - if (this.repository.isPresent()) { - this.loadUsers(); + async loadUsers() { + const data = this.repositoryObserver.getActiveModelData(); + + if (!data) { + return; + } + + this.setCommitter(data.committer); + const githubRemotes = data.remotes.filter(remote => remote.isGithubRepo()); + + if (githubRemotes.length === 0) { + this.addUsers(data.authors, source.GITLOG); } else { - this.repository.onDidChangeState(({from, to}) => { - if (!from.isPresent() && to.isPresent()) { - this.loadUsers(); + await this.loadUsersFromGraphQL(githubRemotes); + } + } + + loadUsersFromGraphQL(remotes) { + return Promise.all( + remotes.map(remote => this.loadMentionableUsers(remote)), + ); + } + + async loadMentionableUsers(remote) { + const cached = this.cache.get(remote); + if (cached !== null) { + this.addUsers(cached, source.GITHUBAPI); + return; + } + + const loginModel = this.loginObserver.getActiveModel(); + if (!loginModel) { + return; + } + + const token = await loginModel.getToken('https://api.github.com'); + if (token === UNAUTHENTICATED || token === INSUFFICIENT) { + return; + } + + const fetchQuery = RelayNetworkLayerManager.getFetchQuery('https://api.github.com/graphql', token); + + let hasMore = true; + let cursor = null; + const remoteUsers = []; + + while (hasMore) { + const response = await fetchQuery({ + name: 'GetMentionableUsers', + text: ` + query GetMentionableUsers($owner: String!, $name: String!, $first: Int!, $after: String) { + repository(owner: $owner, name: $name) { + mentionableUsers(first: $first, after: $after) { + nodes { + login + email + name + } + pageInfo { + hasNextPage + endCursor + } + } + } + } + `, + }, { + owner: remote.getOwner(), + name: remote.getRepo(), + first: 100, + after: cursor, + }); + + if (response.errors && response.errors.length > 1) { + // eslint-disable-next-line no-console + console.error(`Error fetching mentionable users:\n${response.errors.map(e => e.message).join('\n')}`); + } + + if (!response.data) { + break; + } + + const connection = response.data.repository.mentionableUsers; + const authors = connection.nodes.map(node => { + if (node.email === '') { + node.email = `${node.login}@users.noreply.github.com`; } + + return new Author(node.email, node.name, node.login); }); + this.addUsers(authors, source.GITHUBAPI); + remoteUsers.push(...authors); + + cursor = connection.pageInfo.endCursor; + hasMore = connection.pageInfo.hasNextPage; } + + this.cache.set(remote, remoteUsers); } - loadUsers() { - this.loadUsersFromLocalRepo(); + addUsers(users, nextSource) { + let changed = false; + + if ( + nextSource !== this.last.source || + this.repositoryObserver.getActiveModel() !== this.last.repository || + this.excludedUsers !== this.last.excludedUsers + ) { + changed = true; + this.allUsers.clear(); + } + + for (const author of users) { + if (!this.allUsers.has(author.getEmail())) { + changed = true; + } + this.allUsers.set(author.getEmail(), author); + } + + if (changed) { + this.finalize(); + } + this.last.source = nextSource; + this.last.repository = this.repositoryObserver.getActiveModel(); + this.last.excludedUsers = this.excludedUsers; } - async loadUsersFromLocalRepo() { - const users = await this.repository.getAuthors({max: MAX_COMMITS}); - const committer = await this.repository.getCommitter(); - this.setCommitter(committer); - this.addUsers(users); + finalize() { + // TODO: [ku 3/2018] consider sorting based on most recent authors or commit frequency + const users = []; + for (const author of this.allUsers.values()) { + if (author.matches(this.committer)) { continue; } + if (author.isNoReply()) { continue; } + if (this.excludedUsers.has(author.getEmail())) { continue; } + + users.push(author); + } + users.sort(Author.compare); + this.users = users; this.didUpdate(); } - addUsersFromGraphQL(response) { - // TODO: [ku 3/2018] also get users from GraphQL API if available. Will need to reshape the data accordingly - // This will get called in relay query renderer callback - // this.addUsers(users); + setRepository(repository) { + this.repositoryObserver.setActiveModel(repository); } - addUsers(users) { - this.users = {...this.users, ...users}; + setLoginModel(login) { + this.loginObserver.setActiveModel(login); } setCommitter(committer) { + const changed = !this.committer.matches(committer); this.committer = committer; + if (changed) { + this.finalize(); + } } didUpdate() { - this.onDidUpdate(this.getUsers()); + this.emitter.emit('did-update', this.getUsers()); + } + + onDidUpdate(callback) { + return this.emitter.on('did-update', callback); } getUsers() { - // TODO: [ku 3/2018] consider sorting based on most recent authors or commit frequency - // Also, this is obviously not the most performant. Optimize once we incorporate github username info, - // as this will likely impact the shape of the data we store - const users = this.users; - - // you wouldn't download a car. you wouldn't add yourself as a co author. - delete users[this.committer.email]; - delete users[NO_REPLY_GITHUB_EMAIL]; - - return Object.keys(users) - .map(email => ({email, name: this.users[email]})) - .sort((a, b) => { - if (a.name < b.name) { return -1; } - if (a.name > b.name) { return 1; } - return 0; - }); + return this.users; } } diff --git a/lib/prop-types.js b/lib/prop-types.js index b099508491..2d17a1994a 100644 --- a/lib/prop-types.js +++ b/lib/prop-types.js @@ -38,8 +38,8 @@ export const CommitPropType = PropTypes.shape({ }); export const AuthorPropType = PropTypes.shape({ - email: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, + getEmail: PropTypes.func.isRequired, + getFullName: PropTypes.func.isRequired, }); export const RelayConnectionPropType = nodePropType => PropTypes.shape({ @@ -86,3 +86,8 @@ export const MergeConflictItemPropType = PropTypes.shape({ theirs: PropTypes.oneOf(statusNames).isRequired, }).isRequired, }); + +export const UserStorePropType = PropTypes.shape({ + getUsers: PropTypes.func.isRequired, + onDidUpdate: PropTypes.func.isRequired, +}); diff --git a/lib/relay-network-layer-manager.js b/lib/relay-network-layer-manager.js index 1ae291dcaa..cd0c6c4982 100644 --- a/lib/relay-network-layer-manager.js +++ b/lib/relay-network-layer-manager.js @@ -1,3 +1,4 @@ +import util from 'util'; import {Environment, Network, RecordSource, Store} from 'relay-runtime'; import moment from 'moment'; @@ -13,12 +14,69 @@ function logRatelimitApi(headers) { console.debug(`GitHub API Rate Limit: ${remaining}/${total} — resets ${resetsIn}`); } -const tokenPerEnvironmentUrl = new Map(); +const responsesByQuery = new Map(); + +export function expectRelayQuery(operationPattern, response) { + let resolve, reject; + const promise = new Promise((resolve0, reject0) => { + resolve = () => resolve0({data: response}); + reject = reject0; + }); + + const existing = responsesByQuery.get(operationPattern.name) || []; + existing.push({promise, response, variables: operationPattern.variables || {}}); + responsesByQuery.set(operationPattern.name, existing); + + const disable = () => responsesByQuery.delete(operationPattern.name); + + return {promise, resolve, reject, disable}; +} + +export function clearRelayExpectations() { + responsesByQuery.clear(); +} + +const tokenPerURL = new Map(); +const fetchPerURL = new Map(); function createFetchQuery(url) { - return function fetchQuery(operation, variables, cacheConfig, uploadables) { - const currentToken = tokenPerEnvironmentUrl.get(url); - return fetch(url, { + if (atom.inSpecMode()) { + return function specFetchQuery(operation, variables, cacheConfig, uploadables) { + const expectations = responsesByQuery.get(operation.name) || []; + const match = expectations.find(expectation => { + if (Object.keys(expectation.variables).length !== Object.keys(variables).length) { + return false; + } + + for (const key in expectation.variables) { + if (expectation.variables[key] !== variables[key]) { + return false; + } + } + + return true; + }); + + if (!match) { + // eslint-disable-next-line no-console + console.log( + `GraphQL query ${operation.name} was:\n ${operation.text.replace(/\n/g, '\n ')}\n` + + util.inspect(variables), + ); + + const e = new Error(`Unexpected GraphQL query: ${operation.name}`); + e.rawStack = e.stack; + throw e; + } + + return match.promise; + }; + } + + return async function fetchQuery(operation, variables, cacheConfig, uploadables) { + const currentToken = tokenPerURL.get(url); + + const response = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', @@ -29,13 +87,20 @@ function createFetchQuery(url) { query: operation.text, variables, }), - }).then(response => { - try { - atom && atom.inDevMode() && logRatelimitApi(response.headers); - } catch (_e) { /* do nothing */ } - - return response.json(); }); + + try { + atom && atom.inDevMode() && logRatelimitApi(response.headers); + } catch (_e) { /* do nothing */ } + + if (response.status !== 200) { + const e = new Error(`GraphQL API endpoint at ${url} returned ${response.status}`); + e.response = response; + e.rawStack = e.stack; + throw e; + } + + return response.json(); }; } @@ -43,17 +108,26 @@ export default class RelayNetworkLayerManager { static getEnvironmentForHost(host, token) { host = host === 'github.com' ? 'https://api.github.com' : host; const url = host === 'https://api.github.com' ? `${host}/graphql` : `${host}/api/v3/graphql`; - const config = relayEnvironmentPerGithubHost.get(host) || {}; - let {environment, network} = config; - tokenPerEnvironmentUrl.set(url, token); + let {environment, network} = relayEnvironmentPerGithubHost.get(host) || {}; + tokenPerURL.set(url, token); if (!environment) { const source = new RecordSource(); const store = new Store(source); - network = Network.create(createFetchQuery(url)); + network = Network.create(this.getFetchQuery(url, token)); environment = new Environment({network, store}); relayEnvironmentPerGithubHost.set(host, {environment, network}); } return environment; } + + static getFetchQuery(url, token) { + tokenPerURL.set(url, token); + let fetch = fetchPerURL.get(url); + if (!fetch) { + fetch = createFetchQuery(url); + fetchPerURL.set(fetch); + } + return fetch; + } } diff --git a/lib/shared/keytar-strategy.js b/lib/shared/keytar-strategy.js index b68f71b0ce..86dcea24e2 100644 --- a/lib/shared/keytar-strategy.js +++ b/lib/shared/keytar-strategy.js @@ -18,6 +18,8 @@ if (typeof atom === 'undefined') { const UNAUTHENTICATED = Symbol('UNAUTHENTICATED'); +const INSUFFICIENT = Symbol('INSUFFICIENT'); + class KeytarStrategy { static get keytar() { return require('keytar'); @@ -245,6 +247,7 @@ async function createStrategy() { module.exports = { UNAUTHENTICATED, + INSUFFICIENT, KeytarStrategy, SecurityBinaryStrategy, InMemoryStrategy, diff --git a/lib/views/co-author-form.js b/lib/views/co-author-form.js index a4aa6b0bbf..23d5f9f1ba 100644 --- a/lib/views/co-author-form.js +++ b/lib/views/co-author-form.js @@ -1,6 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; +import Author from '../models/author'; import Commands, {Command} from '../atom/commands'; import {autobind} from '../helpers'; @@ -75,7 +76,7 @@ export default class CoAuthorForm extends React.Component { confirm() { if (this.isInputValid()) { - this.props.onSubmit({name: this.state.name, email: this.state.email}); + this.props.onSubmit(new Author(this.state.email, this.state.name)); } } diff --git a/lib/views/commit-view.js b/lib/views/commit-view.js index b721ab97b7..debdea7442 100644 --- a/lib/views/commit-view.js +++ b/lib/views/commit-view.js @@ -8,8 +8,10 @@ import Tooltip from '../atom/tooltip'; import AtomTextEditor from '../atom/atom-text-editor'; import CoAuthorForm from './co-author-form'; import RefHolder from '../models/ref-holder'; +import Author from '../models/author'; +import ObserveModel from './observe-model'; import {LINE_ENDING_REGEX, autobind} from '../helpers'; -import {AuthorPropType} from '../prop-types'; +import {AuthorPropType, UserStorePropType} from '../prop-types'; const TOOLTIP_DELAY = 200; @@ -39,7 +41,7 @@ export default class CommitView extends React.Component { deactivateCommitBox: PropTypes.bool.isRequired, maximumCharacterLimit: PropTypes.number.isRequired, message: PropTypes.string.isRequired, - mentionableUsers: PropTypes.arrayOf(AuthorPropType), + userStore: UserStorePropType.isRequired, selectedCoAuthors: PropTypes.arrayOf(AuthorPropType), updateSelectedCoAuthors: PropTypes.func, commit: PropTypes.func.isRequired, @@ -55,7 +57,7 @@ export default class CommitView extends React.Component { this, 'submitNewCoAuthor', 'cancelNewCoAuthor', 'didChangeCommitMessage', 'didMoveCursor', 'toggleHardWrap', 'toggleCoAuthorInput', 'abortMerge', 'commit', 'amendLastCommit', 'toggleExpandedCommitMessageEditor', - 'renderCoAuthorListItem', 'onSelectedCoAuthorsChanged', + 'renderCoAuthorListItem', 'onSelectedCoAuthorsChanged', 'excludeCoAuthor', ); this.state = { @@ -130,6 +132,7 @@ export default class CommitView extends React.Component { 'github:co-author:home': this.proxyKeyCode(36), 'github:co-author:delete': this.proxyKeyCode(46), 'github:co-author:escape': this.proxyKeyCode(27), + 'github:co-author-exclude': this.excludeCoAuthor, }), this.props.config.onDidChange('github.automaticCommitMessageWrapping', () => this.forceUpdate()), ); @@ -246,30 +249,33 @@ export default class CommitView extends React.Component { } renderCoAuthorInput() { - if (!this.state.showCoAuthorInput) { return null; } return ( - + )} + ); } @@ -381,6 +387,20 @@ export default class CommitView extends React.Component { }); } + excludeCoAuthor() { + const author = this.refCoAuthorSelect.map(c => c.getFocusedOption()); + if (!author || author.isNew()) { + return; + } + + let excluded = this.props.config.get('github.excludedUsers'); + if (excluded && excluded !== '') { + excluded += ', '; + } + excluded += author.getEmail(); + this.props.config.set('github.excludedUsers', excluded); + } + abortMerge() { this.props.abortMerge(); } @@ -464,11 +484,16 @@ export default class CommitView extends React.Component { matchAuthors(authors, filterText, selectedAuthors) { const matchedAuthors = authors.filter((author, index) => { - const isAlreadySelected = selectedAuthors && selectedAuthors.find(selected => selected.email === author.email); - const matchesFilter = `${author.name}${author.email}`.toLowerCase().indexOf(filterText.toLowerCase()) !== -1; + const isAlreadySelected = selectedAuthors && selectedAuthors.find(selected => selected.matches(author)); + const matchesFilter = [ + author.getLogin(), + author.getFullName(), + author.getEmail(), + ].some(field => field && field.toLowerCase().indexOf(filterText.toLowerCase()) !== -1); + return !isAlreadySelected && matchesFilter; }); - matchedAuthors.push({name: filterText, email: 'Add new author', isNew: true}); + matchedAuthors.push(Author.createNew('Add new author', filterText)); return matchedAuthors; } @@ -484,24 +509,31 @@ export default class CommitView extends React.Component { renderCoAuthorListItem(author) { return ( -
- {this.renderCoAuthorListItemField('name', author.name)} - {this.renderCoAuthorListItemField('email', author.email)} +
+ {this.renderCoAuthorListItemField('name', author.getFullName())} + {author.hasLogin() && this.renderCoAuthorListItemField('login', '@' + author.getLogin())} + {this.renderCoAuthorListItemField('email', author.getEmail())}
); } renderCoAuthorValue(author) { - return ( - {author.name} - ); + const fullName = author.getFullName(); + if (fullName && fullName.length > 0) { + return {author.getFullName()}; + } + if (author.hasLogin()) { + return @{author.getLogin()}; + } + + return {author.getEmail()}; } onSelectedCoAuthorsChanged(selectedCoAuthors) { - const newAuthor = selectedCoAuthors.find(author => author.isNew); + const newAuthor = selectedCoAuthors.find(author => author.isNew()); if (newAuthor) { - this.setState({coAuthorInput: newAuthor.name, showCoAuthorForm: true}); + this.setState({coAuthorInput: newAuthor.getFullName(), showCoAuthorForm: true}); } else { this.props.updateSelectedCoAuthors(selectedCoAuthors); } diff --git a/lib/views/git-tab-view.js b/lib/views/git-tab-view.js index ac75712030..44c6bd9d9e 100644 --- a/lib/views/git-tab-view.js +++ b/lib/views/git-tab-view.js @@ -8,7 +8,7 @@ import GitLogo from './git-logo'; import CommitController from '../controllers/commit-controller'; import RecentCommitsController from '../controllers/recent-commits-controller'; import {isValidWorkdir, autobind} from '../helpers'; -import {AuthorPropType} from '../prop-types'; +import {AuthorPropType, UserStorePropType} from '../prop-types'; export default class GitTabView extends React.Component { static focus = { @@ -32,7 +32,7 @@ export default class GitTabView extends React.Component { mergeConflicts: PropTypes.arrayOf(PropTypes.object), workingDirectoryPath: PropTypes.string, mergeMessage: PropTypes.string, - mentionableUsers: PropTypes.arrayOf(AuthorPropType), + userStore: UserStorePropType.isRequired, selectedCoAuthors: PropTypes.arrayOf(AuthorPropType), updateSelectedCoAuthors: PropTypes.func.isRequired, @@ -184,7 +184,7 @@ export default class GitTabView extends React.Component { isLoading={this.props.isLoading} lastCommit={this.props.lastCommit} repository={this.props.repository} - mentionableUsers={this.props.mentionableUsers} + userStore={this.props.userStore} selectedCoAuthors={this.props.selectedCoAuthors} updateSelectedCoAuthors={this.props.updateSelectedCoAuthors} /> diff --git a/lib/views/loading-view.js b/lib/views/loading-view.js new file mode 100644 index 0000000000..847496adb3 --- /dev/null +++ b/lib/views/loading-view.js @@ -0,0 +1,11 @@ +import React from 'react'; + +export default class LoadingView extends React.Component { + render() { + return ( +
+ +
+ ); + } +} diff --git a/package.json b/package.json index bbc41482e3..81f7360a1c 100644 --- a/package.json +++ b/package.json @@ -150,6 +150,11 @@ "type": "boolean", "default": true, "description": "Resolve merge conflicts with in-editor controls" + }, + "excludedUsers": { + "type": "string", + "default": "", + "description": "Comma-separated list of email addresses to exclude from the co-author selection list" } }, "deserializers": { diff --git a/test/controllers/commit-controller.test.js b/test/controllers/commit-controller.test.js index 396b69c2dc..2988d871e4 100644 --- a/test/controllers/commit-controller.test.js +++ b/test/controllers/commit-controller.test.js @@ -5,6 +5,7 @@ import {shallow} from 'enzyme'; import Commit from '../../lib/models/commit'; import {nullBranch} from '../../lib/models/branch'; +import UserStore from '../../lib/models/user-store'; import CommitController, {COMMIT_GRAMMAR_SCOPE} from '../../lib/controllers/commit-controller'; import {cloneRepository, buildRepository, buildRepositoryWithPipeline} from '../helpers'; @@ -24,6 +25,7 @@ describe('CommitController', function() { lastCommit = new Commit({sha: 'a1e23fd45', message: 'last commit message'}); const noop = () => {}; + const store = new UserStore({config}); app = ( {}; + + return ( + + ); + } + + it('renders a loading message while fetching the token', function() { + const wrapper = mount(createApp()); + assert.isTrue(wrapper.find('LoadingView').exists()); + }); + + it('shows the login view if unauthenticated', async function() { + loginModel.getToken.restore(); + sinon.stub(loginModel, 'getToken').returns(Promise.resolve(UNAUTHENTICATED)); + + const wrapper = mount(createApp()); + + await assert.async.isTrue(wrapper.update().find('GithubLoginView').exists()); + assert.strictEqual( + wrapper.find('GithubLoginView').find('p').text(), + 'Log in to GitHub to access PR information and more!', + ); + }); + + it('shows the login view if more scopes are required', async function() { + loginModel.getToken.restore(); + sinon.stub(loginModel, 'getToken').returns(Promise.resolve(INSUFFICIENT)); + + const wrapper = mount(createApp()); + + await assert.async.isTrue(wrapper.update().find('GithubLoginView').exists()); + assert.strictEqual( + wrapper.find('GithubLoginView').find('p').text(), + 'Your token no longer has sufficient authorizations. Please re-authenticate and generate a new one.', + ); + }); + + it('renders pull request info if authenticated', async function() { + const wrapper = mount(createApp()); + + await assert.async.isTrue(wrapper.update().find('PrInfoController').exists()); + + const controller = wrapper.update().find('PrInfoController'); + assert.strictEqual(controller.prop('remote'), remote); + assert.strictEqual(controller.prop('branches'), branchSet); + assert.strictEqual(controller.prop('loginModel'), loginModel); + }); +}); diff --git a/test/helpers.js b/test/helpers.js index 9ae40fa868..eedbeae74c 100644 --- a/test/helpers.js +++ b/test/helpers.js @@ -7,13 +7,14 @@ import transpiler from 'atom-babel6-transpiler'; import React from 'react'; import ReactDom from 'react-dom'; import sinon from 'sinon'; +import {Directory} from 'atom'; import Repository from '../lib/models/repository'; import GitShellOutStrategy from '../lib/git-shell-out-strategy'; import WorkerManager from '../lib/worker-manager'; import ContextMenuInterceptor from '../lib/context-menu-interceptor'; import getRepoPipelineManager from '../lib/get-repo-pipeline-manager'; -import {Directory} from 'atom'; +import {clearRelayExpectations} from '../lib/relay-network-layer-manager'; assert.autocrlfEqual = (actual, expected, ...args) => { const newActual = actual.replace(/\r\n/g, '\n'); @@ -248,6 +249,8 @@ afterEach(function() { ContextMenuInterceptor.dispose(); global.sinon.restore(); + + clearRelayExpectations(); }); // eslint-disable-next-line jasmine/no-global-setup diff --git a/test/models/author.test.js b/test/models/author.test.js new file mode 100644 index 0000000000..6f5952ec2c --- /dev/null +++ b/test/models/author.test.js @@ -0,0 +1,31 @@ +import Author, {nullAuthor, NO_REPLY_GITHUB_EMAIL} from '../../lib/models/author'; + +describe('Author', function() { + it('recognizes the no-reply GitHub email address', function() { + const a0 = new Author('foo@bar.com', 'Eh'); + assert.isFalse(a0.isNoReply()); + + const a1 = new Author(NO_REPLY_GITHUB_EMAIL, 'Whatever'); + assert.isTrue(a1.isNoReply()); + }); + + it('distinguishes authors with a GitHub handle', function() { + const a0 = new Author('foo@bar.com', 'Eh', 'handle'); + assert.isTrue(a0.hasLogin()); + + const a1 = new Author('other@bar.com', 'Nah'); + assert.isFalse(a1.hasLogin()); + }); + + it('implements matching by email address', function() { + const a0 = new Author('same@same.com', 'Zero'); + const a1 = new Author('same@same.com', 'One'); + const a2 = new Author('same@same.com', 'Two', 'two'); + const a3 = new Author('different@same.com', 'Three'); + + assert.isTrue(a0.matches(a1)); + assert.isTrue(a0.matches(a2)); + assert.isFalse(a0.matches(a3)); + assert.isFalse(a0.matches(nullAuthor)); + }); +}); diff --git a/test/models/github-login-model.test.js b/test/models/github-login-model.test.js index 0a3e03da85..9dd2ed41b8 100644 --- a/test/models/github-login-model.test.js +++ b/test/models/github-login-model.test.js @@ -1,5 +1,11 @@ import GithubLoginModel from '../../lib/models/github-login-model'; -import {KeytarStrategy, SecurityBinaryStrategy, InMemoryStrategy, UNAUTHENTICATED} from '../../lib/shared/keytar-strategy'; +import { + KeytarStrategy, + SecurityBinaryStrategy, + InMemoryStrategy, + UNAUTHENTICATED, + INSUFFICIENT, +} from '../../lib/shared/keytar-strategy'; describe('GithubLoginModel', function() { [null, KeytarStrategy, SecurityBinaryStrategy, InMemoryStrategy].forEach(function(Strategy) { @@ -23,4 +29,35 @@ describe('GithubLoginModel', function() { }); }); }); + + describe('required OAuth scopes', function() { + let loginModel; + + beforeEach(async function() { + loginModel = new GithubLoginModel(InMemoryStrategy); + await loginModel.setToken('https://api.github.com', '1234'); + }); + + it('returns INSUFFICIENT if scopes are present', async function() { + sinon.stub(loginModel, 'getScopes').returns(Promise.resolve(['repo', 'read:org'])); + + assert.strictEqual(await loginModel.getToken('https://api.github.com'), INSUFFICIENT); + }); + + it('returns the token if at least the required scopes are present', async function() { + sinon.stub(loginModel, 'getScopes').returns(Promise.resolve(['repo', 'read:org', 'user:email', 'extra'])); + + assert.strictEqual(await loginModel.getToken('https://api.github.com'), '1234'); + }); + + it('caches checked tokens', async function() { + sinon.stub(loginModel, 'getScopes').returns(Promise.resolve(['repo', 'read:org', 'user:email'])); + + assert.strictEqual(await loginModel.getToken('https://api.github.com'), '1234'); + assert.strictEqual(loginModel.getScopes.callCount, 1); + + assert.strictEqual(await loginModel.getToken('https://api.github.com'), '1234'); + assert.strictEqual(loginModel.getScopes.callCount, 1); + }); + }); }); diff --git a/test/models/repository.test.js b/test/models/repository.test.js index acab459700..ca28ee4131 100644 --- a/test/models/repository.test.js +++ b/test/models/repository.test.js @@ -514,13 +514,14 @@ describe('Repository', function() { const workingDirPath = await cloneRepository('three-files'); const repository = new Repository(workingDirPath); await repository.getLoadPromise(); - assert.deepEqual(await repository.getCommitter(), { - name: FAKE_USER.name, - email: FAKE_USER.email, - }); + + const committer = await repository.getCommitter(); + assert.isTrue(committer.isPresent()); + assert.strictEqual(committer.getFullName(), FAKE_USER.name); + assert.strictEqual(committer.getEmail(), FAKE_USER.email); }); - it('returns empty object if user name or email do not exist', async function() { + it('returns a null object if user name or email do not exist', async function() { const workingDirPath = await cloneRepository('three-files'); const repository = new Repository(workingDirPath); await repository.getLoadPromise(); @@ -529,10 +530,43 @@ describe('Repository', function() { // getting the local config for testing purposes only because we don't // want to blow away global config when running tests. - assert.deepEqual(await repository.getCommitter({local: true}), { - name: null, - email: null, - }); + const committer = await repository.getCommitter({local: true}); + assert.isFalse(committer.isPresent()); + }); + }); + + describe('getAuthors', function() { + it('returns user names and emails', async function() { + const workingDirPath = await cloneRepository('multiple-commits'); + const repository = new Repository(workingDirPath); + await repository.getLoadPromise(); + + await repository.git.exec(['config', 'user.name', 'Mona Lisa']); + await repository.git.exec(['config', 'user.email', 'mona@lisa.com']); + await repository.git.commit('Commit from Mona', {allowEmpty: true}); + + await repository.git.exec(['config', 'user.name', 'Hubot']); + await repository.git.exec(['config', 'user.email', 'hubot@github.com']); + await repository.git.commit('Commit from Hubot', {allowEmpty: true}); + + await repository.git.exec(['config', 'user.name', 'Me']); + await repository.git.exec(['config', 'user.email', 'me@github.com']); + await repository.git.commit('Commit from me', {allowEmpty: true}); + + const authors = await repository.getAuthors({max: 3}); + assert.lengthOf(authors, 3); + + const expected = [ + ['mona@lisa.com', 'Mona Lisa'], + ['hubot@github.com', 'Hubot'], + ['me@github.com', 'Me'], + ]; + for (const [email, fullName] of expected) { + assert.isTrue( + authors.some(author => author.getEmail() === email && author.getFullName() === fullName), + `getAuthors() output includes ${fullName} <${email}>`, + ); + } }); }); diff --git a/test/models/user-store.test.js b/test/models/user-store.test.js index b6653999db..10ef5c993c 100644 --- a/test/models/user-store.test.js +++ b/test/models/user-store.test.js @@ -1,33 +1,207 @@ import dedent from 'dedent-js'; -import UserStore, {NO_REPLY_GITHUB_EMAIL} from '../../lib/models/user-store'; - +import UserStore, {source} from '../../lib/models/user-store'; +import Author, {nullAuthor} from '../../lib/models/author'; +import GithubLoginModel from '../../lib/models/github-login-model'; +import {InMemoryStrategy} from '../../lib/shared/keytar-strategy'; +import {expectRelayQuery} from '../../lib/relay-network-layer-manager'; import {cloneRepository, buildRepository, FAKE_USER} from '../helpers'; describe('UserStore', function() { - it('loads store with users and committer in repo upon construction', async function() { + let login, atomEnv, config, store; + + beforeEach(function() { + atomEnv = global.buildAtomEnvironment(); + config = atomEnv.config; + + login = new GithubLoginModel(InMemoryStrategy); + sinon.stub(login, 'getScopes').returns(Promise.resolve(GithubLoginModel.REQUIRED_SCOPES)); + }); + + afterEach(function() { + if (store) { + store.dispose(); + } + atomEnv.destroy(); + }); + + function nextUpdatePromise() { + return new Promise(resolve => { + const sub = store.onDidUpdate(() => { + sub.dispose(); + resolve(); + }); + }); + } + + function expectPagedRelayQueries(options, ...pages) { + const opts = { + owner: 'me', + name: 'stuff', + ...options, + }; + + let lastCursor = null; + return pages.map((page, index) => { + const isLast = index === pages.length - 1; + const nextCursor = isLast ? null : `page-${index + 1}`; + + const result = expectRelayQuery({ + name: 'GetMentionableUsers', + variables: {owner: opts.owner, name: opts.name, first: 100, after: lastCursor}, + }, { + repository: { + mentionableUsers: { + nodes: page, + pageInfo: { + hasNextPage: !isLast, + endCursor: nextCursor, + }, + }, + }, + }); + + lastCursor = nextCursor; + return result; + }); + } + + async function commitAs(repository, ...accounts) { + const committerName = await repository.getConfig('user.name'); + const committerEmail = await repository.getConfig('user.email'); + + for (const {name, email} of accounts) { + await repository.setConfig('user.name', name); + await repository.setConfig('user.email', email); + await repository.commit('message', {allowEmpty: true}); + } + + await repository.setConfig('user.name', committerName); + await repository.setConfig('user.email', committerEmail); + } + + it('loads store with local git users and committer in a repo with no GitHub remote', async function() { const workdirPath = await cloneRepository('multiple-commits'); const repository = await buildRepository(workdirPath); - const store = new UserStore({repository}); + store = new UserStore({repository, config}); assert.deepEqual(store.getUsers(), []); - assert.deepEqual(store.committer, {}); + assert.strictEqual(store.committer, nullAuthor); // Store is populated asynchronously - await assert.async.deepEqual(store.getUsers(), [ - { - email: 'kuychaco@github.com', - name: 'Katrina Uychaco', - }, + await nextUpdatePromise(); + assert.deepEqual(store.getUsers(), [ + new Author('kuychaco@github.com', 'Katrina Uychaco'), + ]); + assert.deepEqual(store.committer, new Author(FAKE_USER.email, FAKE_USER.name)); + }); + + it('loads store with mentionable users from the GitHub API in a repo with a GitHub remote', async function() { + await login.setToken('https://api.github.com', '1234'); + + const workdirPath = await cloneRepository('multiple-commits'); + const repository = await buildRepository(workdirPath); + + await repository.setConfig('remote.origin.url', 'git@github.com:me/stuff.git'); + await repository.setConfig('remote.origin.fetch', '+refs/heads/*:refs/remotes/origin/*'); + await repository.setConfig('remote.old.url', 'git@sourceforge.com:me/stuff.git'); + await repository.setConfig('remote.old.fetch', '+refs/heads/*:refs/remotes/old/*'); + + const [{resolve}] = expectPagedRelayQueries({}, [ + {login: 'annthurium', email: 'annthurium@github.com', name: 'Tilde Ann Thurium'}, + {login: 'octocat', email: 'mona@lisa.com', name: 'Mona Lisa'}, + {login: 'smashwilson', email: 'smashwilson@github.com', name: 'Ash Wilson'}, + ]); + + store = new UserStore({repository, login, config}); + await nextUpdatePromise(); + + resolve(); + await nextUpdatePromise(); + + assert.deepEqual(store.getUsers(), [ + new Author('smashwilson@github.com', 'Ash Wilson', 'smashwilson'), + new Author('mona@lisa.com', 'Mona Lisa', 'octocat'), + new Author('annthurium@github.com', 'Tilde Ann Thurium', 'annthurium'), + ]); + }); + + it('loads users from multiple pages from the GitHub API', async function() { + await login.setToken('https://api.github.com', '1234'); + + const workdirPath = await cloneRepository('multiple-commits'); + const repository = await buildRepository(workdirPath); + + await repository.setConfig('remote.origin.url', 'git@github.com:me/stuff.git'); + await repository.setConfig('remote.origin.fetch', '+refs/heads/*:refs/remotes/origin/*'); + + const [{resolve: resolve0}, {resolve: resolve1}] = expectPagedRelayQueries({}, + [ + {login: 'annthurium', email: 'annthurium@github.com', name: 'Tilde Ann Thurium'}, + {login: 'octocat', email: 'mona@lisa.com', name: 'Mona Lisa'}, + {login: 'smashwilson', email: 'smashwilson@github.com', name: 'Ash Wilson'}, + ], + [ + {login: 'zzz', email: 'zzz@github.com', name: 'Zzzzz'}, + {login: 'aaa', email: 'aaa@github.com', name: 'Aahhhhh'}, + ], + ); + + store = new UserStore({repository, login, config}); + + await nextUpdatePromise(); + assert.deepEqual(store.getUsers(), []); + + resolve0(); + await nextUpdatePromise(); + + assert.deepEqual(store.getUsers(), [ + new Author('smashwilson@github.com', 'Ash Wilson', 'smashwilson'), + new Author('mona@lisa.com', 'Mona Lisa', 'octocat'), + new Author('annthurium@github.com', 'Tilde Ann Thurium', 'annthurium'), + ]); + + resolve1(); + await nextUpdatePromise(); + + assert.deepEqual(store.getUsers(), [ + new Author('aaa@github.com', 'Aahhhhh', 'aaa'), + new Author('smashwilson@github.com', 'Ash Wilson', 'smashwilson'), + new Author('mona@lisa.com', 'Mona Lisa', 'octocat'), + new Author('annthurium@github.com', 'Tilde Ann Thurium', 'annthurium'), + new Author('zzz@github.com', 'Zzzzz', 'zzz'), + ]); + }); + + it('infers no-reply emails for users without a public email address', async function() { + await login.setToken('https://api.github.com', '1234'); + + const workdirPath = await cloneRepository('multiple-commits'); + const repository = await buildRepository(workdirPath); + + await repository.setConfig('remote.origin.url', 'git@github.com:me/stuff.git'); + await repository.setConfig('remote.origin.fetch', '+refs/heads/*:refs/remotes/origin/*'); + + const [{resolve}] = expectPagedRelayQueries({}, [ + {login: 'simurai', email: '', name: 'simurai'}, + ]); + + store = new UserStore({repository, login, config}); + await nextUpdatePromise(); + + resolve(); + await nextUpdatePromise(); + + assert.deepEqual(store.getUsers(), [ + new Author('simurai@users.noreply.github.com', 'simurai', 'simurai'), ]); - await assert.async.deepEqual(store.committer, FAKE_USER); }); it('excludes committer and no reply user from `getUsers`', async function() { const workdirPath = await cloneRepository('multiple-commits'); const repository = await buildRepository(workdirPath); - const store = new UserStore({repository}); - await store.loadUsersFromLocalRepo(); + store = new UserStore({repository, config}); + await assert.async.lengthOf(store.getUsers(), 1); sinon.spy(store, 'addUsers'); // make a commit with FAKE_USER as committer @@ -40,40 +214,30 @@ describe('UserStore', function() { // verify that FAKE_USER is not in users returned from `getUsers` const users = store.getUsers(); - const committerFromStore = users.find(user => user.email === FAKE_USER.email); - assert.isUndefined(committerFromStore); + assert.isFalse(users.some(user => user.getEmail() === FAKE_USER.email)); // verify that no-reply email address is not in users array - const noReplyUser = users.find(user => user.email === NO_REPLY_GITHUB_EMAIL); - assert.isUndefined(noReplyUser); + assert.isFalse(users.some(user => user.isNoReply())); }); describe('addUsers', function() { it('adds specified users and does not overwrite existing users', async function() { const workdirPath = await cloneRepository('multiple-commits'); const repository = await buildRepository(workdirPath); - const store = new UserStore({repository}); + store = new UserStore({repository, config}); + await nextUpdatePromise(); - await assert.async.lengthOf(store.getUsers(), 1); + assert.lengthOf(store.getUsers(), 1); - store.addUsers({ - 'mona@lisa.com': 'Mona Lisa', - 'hubot@github.com': 'Hubot Robot', - }); + store.addUsers([ + new Author('mona@lisa.com', 'Mona Lisa'), + new Author('hubot@github.com', 'Hubot Robot'), + ], source.GITLOG); - await assert.async.deepEqual(store.getUsers(), [ - { - name: 'Hubot Robot', - email: 'hubot@github.com', - }, - { - name: 'Katrina Uychaco', - email: 'kuychaco@github.com', - }, - { - name: 'Mona Lisa', - email: 'mona@lisa.com', - }, + assert.deepEqual(store.getUsers(), [ + new Author('hubot@github.com', 'Hubot Robot'), + new Author('kuychaco@github.com', 'Katrina Uychaco'), + new Author('mona@lisa.com', 'Mona Lisa'), ]); }); }); @@ -82,19 +246,19 @@ describe('UserStore', function() { const workdirPath = await cloneRepository('multiple-commits'); const repository = await buildRepository(workdirPath); - const store = new UserStore({repository}); - await assert.async.deepEqual(store.committer, FAKE_USER); + store = new UserStore({repository, config}); + await nextUpdatePromise(); + assert.deepEqual(store.committer, new Author(FAKE_USER.email, FAKE_USER.name)); const newEmail = 'foo@bar.com'; - await repository.setConfig('user.email', newEmail); - - repository.refresh(); - await assert.async.deepEqual(store.committer, {name: FAKE_USER.name, email: newEmail}); - const newName = 'Foo Bar'; + + await repository.setConfig('user.email', newEmail); await repository.setConfig('user.name', newName); repository.refresh(); - await assert.async.deepEqual(store.committer, {name: newName, email: newEmail}); + await nextUpdatePromise(); + + assert.deepEqual(store.committer, new Author(newEmail, newName)); }); it('refetches users when HEAD changes', async function() { @@ -105,12 +269,10 @@ describe('UserStore', function() { await repository.commit('commit 2', {allowEmpty: true}); await repository.checkout('master'); - const store = new UserStore({repository}); - await assert.async.deepEqual(store.getUsers(), [ - { - email: 'kuychaco@github.com', - name: 'Katrina Uychaco', - }, + store = new UserStore({repository, config}); + await nextUpdatePromise(); + assert.deepEqual(store.getUsers(), [ + new Author('kuychaco@github.com', 'Katrina Uychaco'), ]); sinon.spy(store, 'addUsers'); @@ -122,13 +284,241 @@ describe('UserStore', function() { Co-authored-by: New Author `, {allowEmpty: true}); - await assert.async.equal(store.addUsers.callCount, 1); - assert.isOk(store.getUsers().find(user => { - return user.name === 'New Author' && user.email === 'new-author@email.com'; + repository.refresh(); + await nextUpdatePromise(); + + await assert.strictEqual(store.addUsers.callCount, 1); + assert.isTrue(store.getUsers().some(user => { + return user.getFullName() === 'New Author' && user.getEmail() === 'new-author@email.com'; })); // Change head due to branch checkout await repository.checkout('new-branch'); - await assert.async.equal(store.addUsers.callCount, 2); + repository.refresh(); + + await assert.async.strictEqual(store.addUsers.callCount, 2); + }); + + it('refetches users when a token becomes available', async function() { + const workdirPath = await cloneRepository('multiple-commits'); + const repository = await buildRepository(workdirPath); + + const gitAuthors = [ + new Author('kuychaco@github.com', 'Katrina Uychaco'), + ]; + + const graphqlAuthors = [ + new Author('smashwilson@github.com', 'Ash Wilson', 'smashwilson'), + new Author('mona@lisa.com', 'Mona Lisa', 'octocat'), + new Author('annthurium@github.com', 'Tilde Ann Thurium', 'annthurium'), + ]; + + const [{resolve}] = expectPagedRelayQueries({}, [ + {login: 'annthurium', email: 'annthurium@github.com', name: 'Tilde Ann Thurium'}, + {login: 'octocat', email: 'mona@lisa.com', name: 'Mona Lisa'}, + {login: 'smashwilson', email: 'smashwilson@github.com', name: 'Ash Wilson'}, + ]); + resolve(); + + store = new UserStore({repository, login, config}); + await nextUpdatePromise(); + + assert.deepEqual(store.getUsers(), gitAuthors); + + await repository.setConfig('remote.origin.url', 'git@github.com:me/stuff.git'); + await repository.setConfig('remote.origin.fetch', '+refs/heads/*:refs/remotes/origin/*'); + + repository.refresh(); + + // Token is not available, so authors are still queried from git + assert.deepEqual(store.getUsers(), gitAuthors); + + await login.setToken('https://api.github.com', '1234'); + + await nextUpdatePromise(); + assert.deepEqual(store.getUsers(), graphqlAuthors); + }); + + it('refetches users when the repository changes', async function() { + const workdirPath0 = await cloneRepository('multiple-commits'); + const repository0 = await buildRepository(workdirPath0); + await commitAs(repository0, {name: 'committer0', email: 'committer0@github.com'}); + + const workdirPath1 = await cloneRepository('multiple-commits'); + const repository1 = await buildRepository(workdirPath1); + await commitAs(repository1, {name: 'committer1', email: 'committer1@github.com'}); + + store = new UserStore({repository: repository0, config}); + await nextUpdatePromise(); + + assert.deepEqual(store.getUsers(), [ + new Author('kuychaco@github.com', 'Katrina Uychaco'), + new Author('committer0@github.com', 'committer0'), + ]); + + store.setRepository(repository1); + await nextUpdatePromise(); + + assert.deepEqual(store.getUsers(), [ + new Author('kuychaco@github.com', 'Katrina Uychaco'), + new Author('committer1@github.com', 'committer1'), + ]); + }); + + describe('GraphQL response caching', function() { + it('caches mentionable users acquired from GraphQL', async function() { + await login.setToken('https://api.github.com', '1234'); + + const workdirPath = await cloneRepository('multiple-commits'); + const repository = await buildRepository(workdirPath); + + await repository.setConfig('remote.origin.url', 'git@github.com:me/stuff.git'); + await repository.setConfig('remote.origin.fetch', '+refs/heads/*:refs/remotes/origin/*'); + + const [{resolve, disable}] = expectPagedRelayQueries({}, [ + {login: 'annthurium', email: 'annthurium@github.com', name: 'Tilde Ann Thurium'}, + {login: 'octocat', email: 'mona@lisa.com', name: 'Mona Lisa'}, + {login: 'smashwilson', email: 'smashwilson@github.com', name: 'Ash Wilson'}, + ]); + resolve(); + + store = new UserStore({repository, login, config}); + sinon.spy(store, 'loadUsers'); + + // The first update is triggered by the commiter, the second from GraphQL results arriving. + await nextUpdatePromise(); + await nextUpdatePromise(); + + disable(); + + repository.refresh(); + + await assert.async.strictEqual(store.loadUsers.callCount, 2); + await store.loadUsers.returnValues[1]; + + assert.deepEqual(store.getUsers(), [ + new Author('smashwilson@github.com', 'Ash Wilson', 'smashwilson'), + new Author('mona@lisa.com', 'Mona Lisa', 'octocat'), + new Author('annthurium@github.com', 'Tilde Ann Thurium', 'annthurium'), + ]); + }); + + it('re-uses cached users per repository', async function() { + await login.setToken('https://api.github.com', '1234'); + + const workdirPath0 = await cloneRepository('multiple-commits'); + const repository0 = await buildRepository(workdirPath0); + await repository0.setConfig('remote.origin.url', 'git@github.com:me/zero.git'); + await repository0.setConfig('remote.origin.fetch', '+refs/heads/*:refs/remotes/origin/*'); + + const workdirPath1 = await cloneRepository('multiple-commits'); + const repository1 = await buildRepository(workdirPath1); + await repository1.setConfig('remote.origin.url', 'git@github.com:me/one.git'); + await repository1.setConfig('remote.origin.fetch', '+refs/heads/*:refs/remotes/origin/*'); + + const results = id => [ + {login: 'aaa', email: `aaa-${id}@a.com`, name: 'AAA'}, + {login: 'bbb', email: `bbb-${id}@b.com`, name: 'BBB'}, + {login: 'ccc', email: `ccc-${id}@c.com`, name: 'CCC'}, + ]; + const [{resolve: resolve0, disable: disable0}] = expectPagedRelayQueries({name: 'zero'}, results('0')); + const [{resolve: resolve1, disable: disable1}] = expectPagedRelayQueries({name: 'one'}, results('1')); + resolve0(); + resolve1(); + + store = new UserStore({repository: repository0, login, config}); + await nextUpdatePromise(); + await nextUpdatePromise(); + + store.setRepository(repository1); + await nextUpdatePromise(); + + sinon.spy(store, 'loadUsers'); + disable0(); + disable1(); + + store.setRepository(repository0); + await nextUpdatePromise(); + + assert.deepEqual(store.getUsers(), [ + new Author('aaa-0@a.com', 'AAA', 'aaa'), + new Author('bbb-0@b.com', 'BBB', 'bbb'), + new Author('ccc-0@c.com', 'CCC', 'ccc'), + ]); + }); + }); + + describe('excluded users', function() { + it('do not appear in the list from git', async function() { + config.set('github.excludedUsers', 'evil@evilcorp.org'); + + const workdirPath = await cloneRepository('multiple-commits'); + const repository = await buildRepository(workdirPath); + await commitAs(repository, + {name: 'evil0', email: 'evil@evilcorp.org'}, + {name: 'ok', email: 'ok@somewhere.net'}, + {name: 'evil1', email: 'evil@evilcorp.org'}, + ); + + store = new UserStore({repository, config}); + await nextUpdatePromise(); + + assert.deepEqual(store.getUsers(), [ + new Author('kuychaco@github.com', 'Katrina Uychaco'), + new Author('ok@somewhere.net', 'ok'), + ]); + }); + + it('do not appear in the list from GraphQL', async function() { + config.set('github.excludedUsers', 'evil@evilcorp.org, other@evilcorp.org'); + await login.setToken('https://api.github.com', '1234'); + + const workdirPath = await cloneRepository('multiple-commits'); + const repository = await buildRepository(workdirPath); + await repository.setConfig('remote.origin.url', 'git@github.com:me/stuff.git'); + await repository.setConfig('remote.origin.fetch', '+refs/heads/*:refs/remotes/origin/*'); + + const [{resolve}] = expectPagedRelayQueries({}, [ + {login: 'evil0', email: 'evil@evilcorp.org', name: 'evil0'}, + {login: 'octocat', email: 'mona@lisa.com', name: 'Mona Lisa'}, + ]); + resolve(); + + store = new UserStore({repository, login, config}); + await nextUpdatePromise(); + await nextUpdatePromise(); + + assert.deepEqual(store.getUsers(), [ + new Author('mona@lisa.com', 'Mona Lisa', 'octocat'), + ]); + }); + + it('are updated when the config option changes', async function() { + config.set('github.excludedUsers', 'evil0@evilcorp.org'); + + const workdirPath = await cloneRepository('multiple-commits'); + const repository = await buildRepository(workdirPath); + await commitAs(repository, + {name: 'evil0', email: 'evil0@evilcorp.org'}, + {name: 'ok', email: 'ok@somewhere.net'}, + {name: 'evil1', email: 'evil1@evilcorp.org'}, + ); + + store = new UserStore({repository, config}); + await nextUpdatePromise(); + + assert.deepEqual(store.getUsers(), [ + new Author('kuychaco@github.com', 'Katrina Uychaco'), + new Author('evil1@evilcorp.org', 'evil1'), + new Author('ok@somewhere.net', 'ok'), + ]); + + config.set('github.excludedUsers', 'evil0@evilcorp.org, evil1@evilcorp.org'); + + assert.deepEqual(store.getUsers(), [ + new Author('kuychaco@github.com', 'Katrina Uychaco'), + new Author('ok@somewhere.net', 'ok'), + ]); + }); }); }); diff --git a/test/views/co-author-form.test.js b/test/views/co-author-form.test.js index cc1167f82f..b2980682ce 100644 --- a/test/views/co-author-form.test.js +++ b/test/views/co-author-form.test.js @@ -2,6 +2,7 @@ import React from 'react'; import {mount} from 'enzyme'; import CoAuthorForm from '../../lib/views/co-author-form'; +import Author from '../../lib/models/author'; describe('CoAuthorForm', function() { let atomEnv; @@ -50,10 +51,7 @@ describe('CoAuthorForm', function() { wrapper.find('.btn-primary').simulate('click'); - assert.deepEqual(didSubmit.firstCall.args[0], { - name, - email, - }); + assert.deepEqual(didSubmit.firstCall.args[0], new Author(email, name)); }); it('submit button is initially disabled', function() { diff --git a/test/views/commit-view.test.js b/test/views/commit-view.test.js index 50fb298fa9..6bef7bd4ab 100644 --- a/test/views/commit-view.test.js +++ b/test/views/commit-view.test.js @@ -4,6 +4,7 @@ import {shallow, mount} from 'enzyme'; import {cloneRepository, buildRepository} from '../helpers'; import Commit, {nullCommit} from '../../lib/models/commit'; import Branch, {nullBranch} from '../../lib/models/branch'; +import UserStore from '../../lib/models/user-store'; import CommitView from '../../lib/views/commit-view'; describe('CommitView', function() { @@ -19,6 +20,7 @@ describe('CommitView', function() { lastCommit = new Commit({sha: '1234abcd', message: 'commit message'}); const noop = () => {}; const returnTruthyPromise = () => Promise.resolve(true); + const store = new UserStore({config}); app = (