Skip to content
This repository has been archived by the owner on Dec 15, 2022. It is now read-only.

Populate co-authors from mentionable users from the GitHub API #1476

Merged
merged 68 commits into from
May 30, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
68 commits
Select commit Hold shift + click to select a range
ac04a71
Return canned GraphQL query responses in spec mode
smashwilson May 18, 2018
88c48ff
Spec for the simple case of loading mentionable users
smashwilson May 18, 2018
92b063f
Load users from GraphQL if a GitHub remote and token are present
smashwilson May 21, 2018
5bea3ad
Assert against mentionable users that aren't also in git history
smashwilson May 21, 2018
5f850b4
Match expected GraphQL queries by variables
smashwilson May 21, 2018
350ff6e
Fetch multiple pages of mentionable users
smashwilson May 21, 2018
3e9bc31
Infer no-reply emails
smashwilson May 21, 2018
d8e512e
Call addUsers once
smashwilson May 21, 2018
7f7d07f
Author model
smashwilson May 21, 2018
45f24ac
Construct Authors from Present.getAuthors()
smashwilson May 22, 2018
f845cdf
Return an Author or nullAuthor from getCommitter()
smashwilson May 22, 2018
19f9d9f
Sort and match Authors
smashwilson May 22, 2018
00ae45c
Store Authors sorted within the UserStore
smashwilson May 22, 2018
9576c1f
Use a ModelObserver to track the Repository
smashwilson May 22, 2018
0a6a903
Turn UserStore into an event Emitter
smashwilson May 22, 2018
e4f92a8
PropType shapes for new models
smashwilson May 22, 2018
edf3bb1
getAuthors() stub should return []
smashwilson May 22, 2018
7ee56cb
The UserStore observes its Repository
smashwilson May 22, 2018
c4292de
Update the UserStore in the updateSelectedCoAuthors callback
smashwilson May 22, 2018
e7772b5
Pass the UserStore through the component tree
smashwilson May 22, 2018
65066a3
Observe the passed UserStore and update the Select component
smashwilson May 22, 2018
ea65ac0
Pass an empty UserStore in tests
smashwilson May 22, 2018
c9e0883
Import shuffle
smashwilson May 22, 2018
b9b9705
I... have no idea how this was passing before?
smashwilson May 22, 2018
3985f55
Use the Author model in the test fixture
smashwilson May 22, 2018
0c64139
:fire: unused import
smashwilson May 22, 2018
1d84a61
Present talks model objects, GSOS talks raw objects
smashwilson May 22, 2018
68ea76c
More Author model usage
smashwilson May 22, 2018
f9e7146
Another Author model
smashwilson May 22, 2018
c8c6051
Acquire the token from the GithubLoginModel
smashwilson May 23, 2018
138dadc
Track the last source of users in a UserStore
smashwilson May 23, 2018
bb734d4
:fire: console.logs
smashwilson May 23, 2018
6bf5413
Override results when the Repository has changed
smashwilson May 23, 2018
92a2cfa
Pass the GithubLoginModel to the UserStore
smashwilson May 23, 2018
79b10a1
Declare query variables
smashwilson May 23, 2018
4f8906a
Change the loginModel
smashwilson May 23, 2018
06c073a
Create and test for new Authors
smashwilson May 24, 2018
423f258
Create Author instances in CoAuthorForm
smashwilson May 24, 2018
137f61a
Use Author models in the CommitView co-author forms
smashwilson May 24, 2018
90394fc
Update CoAuthor form test
smashwilson May 24, 2018
25c3edd
:fire: console.log again
smashwilson May 24, 2018
db7e78f
Create a helper for stubbing paginated data
smashwilson May 24, 2018
0d3b316
getSlug() on Remote because "slug" is more fun than "nwo"
smashwilson May 24, 2018
25095ca
Disable stubbed GraphQL queries
smashwilson May 24, 2018
d687e7b
Cache GraphQL responses for an hour
smashwilson May 24, 2018
91e8819
Merge branch 'master' into aw/mentionable-users
smashwilson May 24, 2018
3d8073f
Check a token's OAuth scopes against the required ones on each getToken
smashwilson May 25, 2018
875540b
Handle an INSUFFICIENT result in UserStore
smashwilson May 25, 2018
b5c49c6
Stub getScopes in UserStore tests
smashwilson May 25, 2018
29e8590
Log GraphQL expected variables in spec mode
smashwilson May 25, 2018
6faf54e
Handle errors during the getScopes() request
smashwilson May 25, 2018
744c267
Handle the "insufficient scopes" case in RemotePrController
smashwilson May 25, 2018
7c24b6b
Oh GithubLoginView already lets you customize a message
smashwilson May 25, 2018
dbf5ac9
Report GraphQL errors from non-200 responses
smashwilson May 25, 2018
930e89f
Don't lose the token on every launch :eyes:
smashwilson May 25, 2018
e752302
Autocomplete on login
smashwilson May 25, 2018
cbf56ab
Render a handle if we have one
smashwilson May 25, 2018
1948962
Our tokens have read:org
smashwilson May 25, 2018
cc96a5d
Merge branch 'master' into aw/mentionable-users
smashwilson May 25, 2018
5210a78
Merge branch 'master' into aw/mentionable-users
smashwilson May 29, 2018
f9f6ac7
Pass UNAUTHENTICATED results through GithubLoginModel
smashwilson May 29, 2018
240c754
Respect an excludedUsers config setting
smashwilson May 29, 2018
f2610c4
Include schema for excludedUsers
smashwilson May 29, 2018
086ee0a
Pass config to the UserStore initializer
smashwilson May 30, 2018
1ec5ed9
Gracefully handle GraphQL errors in the UserStore
smashwilson May 30, 2018
a82ce61
Exclude co-authors on shift-delete
smashwilson May 30, 2018
3c9b2ca
Merge branch 'master' into aw/mentionable-users
smashwilson May 30, 2018
dd96476
Initialize UserStore correctly in tests
smashwilson May 30, 2018
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions keymaps/git.cson
Original file line number Diff line number Diff line change
Expand Up @@ -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'
6 changes: 3 additions & 3 deletions lib/controllers/commit-controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand Down Expand Up @@ -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}
/>
Expand Down
24 changes: 8 additions & 16 deletions lib/controllers/git-tab-controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
});
}

Expand All @@ -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}

Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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});
Expand Down
58 changes: 35 additions & 23 deletions lib/controllers/remote-pr-controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 = <LoadingView />;
} else if (token === UNAUTHENTICATED) {
inner = <GithubLoginView onLogin={this.handleLogin} />;
} else if (token === INSUFFICIENT) {
inner = (
<GithubLoginView onLogin={this.handleLogin}>
<p>
Your token no longer has sufficient authorizations. Please re-authenticate and generate a new one.
</p>
</GithubLoginView>
);
} else {
const {
host, remote, branches, loginModel, selectedPrUrl,
aheadCount, pushInProgress, onSelectPr, onUnpinPr,
} = this.props;

return (
<div className="github-RemotePrController">
{token && token !== UNAUTHENTICATED &&
<PrInfoController
{...{
host, remote, branches, token, loginModel, selectedPrUrl,
aheadCount, pushInProgress, onSelectPr, onUnpinPr,
}}
onLogin={this.handleLogin}
onLogout={this.handleLogout}
onCreatePr={this.handleCreatePr}
/>
}
{(!token || token === UNAUTHENTICATED) && <GithubLoginView onLogin={this.handleLogin} />}
</div>
);
inner = (
<PrInfoController
{...{
host, remote, branches, token, loginModel, selectedPrUrl,
aheadCount, pushInProgress, onSelectPr, onUnpinPr,
}}
onLogin={this.handleLogin}
onLogout={this.handleLogout}
onCreatePr={this.handleCreatePr}
/>
);
}

return <div className="github-RemotePrController">{inner}</div>;
}

handleLogin(token) {
Expand Down
1 change: 1 addition & 0 deletions lib/controllers/root-controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
100 changes: 100 additions & 0 deletions lib/models/author.js
Original file line number Diff line number Diff line change
@@ -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';
},
};
61 changes: 57 additions & 4 deletions lib/models/github-login-model.js
Original file line number Diff line number Diff line change
@@ -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']

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how hard would it be to leverage what we've built here into eventually having a unified Atom login experience? For Teletype, the GitHub package, and any other future packages that might want it?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unified Atom login experience

I would love to have this so much. We'd need to find a way to make "GitHub identity" a part of the core API somehow, so that packages could just consume it. Maybe have a separate package that exists purely to handle the UI for authentication...

I'm not sure how much effort it would be. We'd need to scope it out first in an Atom RFC and get buy-in from the core team. Maybe we could wait to see how @shana can improve our authentication story first, and then generalize from there.


static get() {
if (!instance) {
instance = new GithubLoginModel();
Expand All @@ -16,6 +21,7 @@ export default class GithubLoginModel {
this._Strategy = Strategy;
this._strategy = null;
this.emitter = new Emitter();
this.checked = new Set();
}

async getStrategy() {
Expand All @@ -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;
}

Expand All @@ -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',

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TIL -- I didn't realize HEAD is a http method. (tho I guess there are all kindsa other weird ones like PURGE)

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');
}
Expand Down
8 changes: 8 additions & 0 deletions lib/models/remote.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ export default class Remote {
return this.getName();
}

getSlug() {
return `${this.owner}/${this.repo}`;
}

isPresent() {
return true;
}
Expand Down Expand Up @@ -90,6 +94,10 @@ export const nullRemote = {
return fallback;
},

getSlug() {
return '';
},

isPresent() {
return false;
},
Expand Down
Loading