github: also pull users email

Michael Muré created

Change summary

bridge/github/config.go       |   9 +
bridge/github/import.go       |  31 +++++++
bridge/github/import_query.go |  16 +++
bug/person.go                 |  31 ++++++-
commands/ls.go                |   2 
commands/show.go              |   4 
graphql/gqlgen.yml            |  11 ++
graphql/graph/gen_graph.go    | 149 +++++++++++++++++++++++++++++++-----
graphql/resolvers/root.go     |   4 
graphql/schema.graphql        |  12 ++
termui/bug_table.go           |   2 
termui/show_bug.go            |  12 +-
webui/public/index.html       |   2 
webui/src/Author.js           |   6 +
webui/src/bug/Bug.js          |   1 
webui/src/bug/LabelChange.js  |   1 
webui/src/bug/Message.js      |   2 
webui/src/bug/SetStatus.js    |   1 
webui/src/bug/SetTitle.js     |   1 
webui/src/list/BugRow.js      |   3 
20 files changed, 252 insertions(+), 48 deletions(-)

Detailed changes

bridge/github/config.go 🔗

@@ -29,7 +29,10 @@ func (*Github) Configure(repo repository.RepoCommon) (core.Configuration, error)
 	conf := make(core.Configuration)
 
 	fmt.Println()
-	fmt.Println("git-bug will generate an access token in your Github profile. Your credential are not stored and are only used to generate the token. The token is stored in the repository git config.")
+	fmt.Println("git-bug will now generate an access token in your Github profile. Your credential are not stored and are only used to generate the token. The token is stored in the repository git config.")
+	fmt.Println()
+	fmt.Println("The token will have the following scopes:")
+	fmt.Println("  - user:email: to be able to read public-only users email")
 	// fmt.Println("The token will have the \"repo\" permission, giving it read/write access to your repositories and issues. There is no narrower scope available, sorry :-|")
 	fmt.Println()
 
@@ -120,7 +123,9 @@ func requestTokenWith2FA(note, username, password, otpCode string) (*http.Respon
 		Note        string   `json:"note"`
 		Fingerprint string   `json:"fingerprint"`
 	}{
-		// Scopes:      []string{"repo"},
+		// user:email is requested to be able to read public emails
+		//     - a private email will stay private, even with this token
+		Scopes:      []string{"user:email"},
 		Note:        note,
 		Fingerprint: randomFingerprint(),
 	}

bridge/github/import.go 🔗

@@ -565,9 +565,29 @@ func (gi *githubImporter) makePerson(actor *actor) bug.Person {
 	if actor == nil {
 		return gi.ghost
 	}
+	var name string
+	var email string
+
+	switch actor.Typename {
+	case "User":
+		if actor.User.Name != nil {
+			name = string(*(actor.User.Name))
+		}
+		email = string(actor.User.Email)
+	case "Organization":
+		if actor.Organization.Name != nil {
+			name = string(*(actor.Organization.Name))
+		}
+		if actor.Organization.Email != nil {
+			email = string(*(actor.Organization.Email))
+		}
+	case "Bot":
+	}
 
 	return bug.Person{
-		Name:      string(actor.Login),
+		Name:      name,
+		Email:     email,
+		Login:     string(actor.Login),
 		AvatarUrl: string(actor.AvatarUrl),
 	}
 }
@@ -584,9 +604,16 @@ func (gi *githubImporter) fetchGhost() error {
 		return err
 	}
 
+	var name string
+	if q.User.Name != nil {
+		name = string(*q.User.Name)
+	}
+
 	gi.ghost = bug.Person{
-		Name:      string(q.User.Login),
+		Name:      name,
+		Login:     string(q.User.Login),
 		AvatarUrl: string(q.User.AvatarUrl),
+		Email:     string(q.User.Email),
 	}
 
 	return nil

bridge/github/import_query.go 🔗

@@ -10,8 +10,17 @@ type pageInfo struct {
 }
 
 type actor struct {
+	Typename  githubv4.String `graphql:"__typename"`
 	Login     githubv4.String
 	AvatarUrl githubv4.String
+	User      struct {
+		Name  *githubv4.String
+		Email githubv4.String
+	} `graphql:"... on User"`
+	Organization struct {
+		Name  *githubv4.String
+		Email *githubv4.String
+	} `graphql:"... on Organization"`
 }
 
 type actorEvent struct {
@@ -152,5 +161,10 @@ type commentEditQuery struct {
 }
 
 type userQuery struct {
-	User actor `graphql:"user(login: $login)"`
+	User struct {
+		Login     githubv4.String
+		AvatarUrl githubv4.String
+		Name      *githubv4.String
+		Email     githubv4.String
+	} `graphql:"user(login: $login)"`
 }

bug/person.go 🔗

@@ -12,6 +12,7 @@ import (
 type Person struct {
 	Name      string `json:"name"`
 	Email     string `json:"email"`
+	Login     string `json:"login"`
 	AvatarUrl string `json:"avatar_url"`
 }
 
@@ -38,12 +39,15 @@ func GetUser(repo repository.Repo) (Person, error) {
 
 // Match tell is the Person match the given query string
 func (p Person) Match(query string) bool {
-	return strings.Contains(strings.ToLower(p.Name), strings.ToLower(query))
+	query = strings.ToLower(query)
+
+	return strings.Contains(strings.ToLower(p.Name), query) ||
+		strings.Contains(strings.ToLower(p.Login), query)
 }
 
 func (p Person) Validate() error {
-	if text.Empty(p.Name) {
-		return fmt.Errorf("name is not set")
+	if text.Empty(p.Name) && text.Empty(p.Login) {
+		return fmt.Errorf("either name or login should be set")
 	}
 
 	if strings.Contains(p.Name, "\n") {
@@ -54,6 +58,14 @@ func (p Person) Validate() error {
 		return fmt.Errorf("name is not fully printable")
 	}
 
+	if strings.Contains(p.Login, "\n") {
+		return fmt.Errorf("login should be a single line")
+	}
+
+	if !text.Safe(p.Login) {
+		return fmt.Errorf("login is not fully printable")
+	}
+
 	if strings.Contains(p.Email, "\n") {
 		return fmt.Errorf("email should be a single line")
 	}
@@ -69,6 +81,15 @@ func (p Person) Validate() error {
 	return nil
 }
 
-func (p Person) String() string {
-	return fmt.Sprintf("%s <%s>", p.Name, p.Email)
+func (p Person) DisplayName() string {
+	switch {
+	case p.Name == "" && p.Login != "":
+		return p.Login
+	case p.Name != "" && p.Login == "":
+		return p.Name
+	case p.Name != "" && p.Login != "":
+		return fmt.Sprintf("%s (%s)", p.Name, p.Login)
+	}
+
+	panic("invalid person data")
 }

commands/ls.go 🔗

@@ -59,7 +59,7 @@ func runLsBug(cmd *cobra.Command, args []string) error {
 
 		// truncate + pad if needed
 		titleFmt := fmt.Sprintf("%-50.50s", snapshot.Title)
-		authorFmt := fmt.Sprintf("%-15.15s", author.Name)
+		authorFmt := fmt.Sprintf("%-15.15s", author.DisplayName())
 
 		fmt.Printf("%s %s\t%s\t%s\t%s\n",
 			colors.Cyan(b.HumanId()),

commands/show.go 🔗

@@ -39,7 +39,7 @@ func runShowBug(cmd *cobra.Command, args []string) error {
 	)
 
 	fmt.Printf("%s opened this issue %s\n\n",
-		colors.Magenta(firstComment.Author.Name),
+		colors.Magenta(firstComment.Author.DisplayName()),
 		firstComment.FormatTimeRel(),
 	)
 
@@ -59,7 +59,7 @@ func runShowBug(cmd *cobra.Command, args []string) error {
 		fmt.Printf("%s#%d %s <%s>\n\n",
 			indent,
 			i,
-			comment.Author.Name,
+			comment.Author.DisplayName(),
 			comment.Author.Email,
 		)
 

graphql/gqlgen.yml 🔗

@@ -15,6 +15,15 @@ models:
     model: github.com/MichaelMure/git-bug/bug.Comment
   Person:
     model: github.com/MichaelMure/git-bug/bug.Person
+    fields:
+      name:
+        resolver: true
+      email:
+        resolver: true
+      login:
+        resolver: true
+      avatarUrl:
+        resolver: true
   Label:
     model: github.com/MichaelMure/git-bug/bug.Label
   Hash:
@@ -27,6 +36,8 @@ models:
     model: github.com/MichaelMure/git-bug/bug.SetTitleOperation
   AddCommentOperation:
     model: github.com/MichaelMure/git-bug/bug.AddCommentOperation
+  EditCommentOperation:
+    model: github.com/MichaelMure/git-bug/bug.EditCommentOperation
   SetStatusOperation:
     model: github.com/MichaelMure/git-bug/bug.SetStatusOperation
   LabelChangeOperation:

graphql/graph/gen_graph.go 🔗

@@ -45,6 +45,7 @@ type ResolverRoot interface {
 	LabelChangeOperation() LabelChangeOperationResolver
 	LabelChangeTimelineItem() LabelChangeTimelineItemResolver
 	Mutation() MutationResolver
+	Person() PersonResolver
 	Query() QueryResolver
 	Repository() RepositoryResolver
 	SetStatusOperation() SetStatusOperationResolver
@@ -200,9 +201,11 @@ type ComplexityRoot struct {
 	}
 
 	Person struct {
-		Email     func(childComplexity int) int
-		Name      func(childComplexity int) int
-		AvatarUrl func(childComplexity int) int
+		Name        func(childComplexity int) int
+		Email       func(childComplexity int) int
+		Login       func(childComplexity int) int
+		DisplayName func(childComplexity int) int
+		AvatarUrl   func(childComplexity int) int
 	}
 
 	Query struct {
@@ -301,6 +304,13 @@ type MutationResolver interface {
 	SetTitle(ctx context.Context, repoRef *string, prefix string, title string) (bug.Snapshot, error)
 	Commit(ctx context.Context, repoRef *string, prefix string) (bug.Snapshot, error)
 }
+type PersonResolver interface {
+	Name(ctx context.Context, obj *bug.Person) (*string, error)
+	Email(ctx context.Context, obj *bug.Person) (*string, error)
+	Login(ctx context.Context, obj *bug.Person) (*string, error)
+
+	AvatarURL(ctx context.Context, obj *bug.Person) (*string, error)
+}
 type QueryResolver interface {
 	DefaultRepository(ctx context.Context) (*models.Repository, error)
 	Repository(ctx context.Context, id string) (*models.Repository, error)
@@ -1650,6 +1660,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
 
 		return e.complexity.PageInfo.EndCursor(childComplexity), true
 
+	case "Person.name":
+		if e.complexity.Person.Name == nil {
+			break
+		}
+
+		return e.complexity.Person.Name(childComplexity), true
+
 	case "Person.email":
 		if e.complexity.Person.Email == nil {
 			break
@@ -1657,12 +1674,19 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
 
 		return e.complexity.Person.Email(childComplexity), true
 
-	case "Person.name":
-		if e.complexity.Person.Name == nil {
+	case "Person.login":
+		if e.complexity.Person.Login == nil {
 			break
 		}
 
-		return e.complexity.Person.Name(childComplexity), true
+		return e.complexity.Person.Login(childComplexity), true
+
+	case "Person.displayName":
+		if e.complexity.Person.DisplayName == nil {
+			break
+		}
+
+		return e.complexity.Person.DisplayName(childComplexity), true
 
 	case "Person.avatarUrl":
 		if e.complexity.Person.AvatarUrl == nil {
@@ -5280,6 +5304,7 @@ var personImplementors = []string{"Person"}
 func (ec *executionContext) _Person(ctx context.Context, sel ast.SelectionSet, obj *bug.Person) graphql.Marshaler {
 	fields := graphql.CollectFields(ctx, sel, personImplementors)
 
+	var wg sync.WaitGroup
 	out := graphql.NewOrderedMap(len(fields))
 	invalid := false
 	for i, field := range fields {
@@ -5288,26 +5313,69 @@ func (ec *executionContext) _Person(ctx context.Context, sel ast.SelectionSet, o
 		switch field.Name {
 		case "__typename":
 			out.Values[i] = graphql.MarshalString("Person")
-		case "email":
-			out.Values[i] = ec._Person_email(ctx, field, obj)
 		case "name":
-			out.Values[i] = ec._Person_name(ctx, field, obj)
+			wg.Add(1)
+			go func(i int, field graphql.CollectedField) {
+				out.Values[i] = ec._Person_name(ctx, field, obj)
+				wg.Done()
+			}(i, field)
+		case "email":
+			wg.Add(1)
+			go func(i int, field graphql.CollectedField) {
+				out.Values[i] = ec._Person_email(ctx, field, obj)
+				wg.Done()
+			}(i, field)
+		case "login":
+			wg.Add(1)
+			go func(i int, field graphql.CollectedField) {
+				out.Values[i] = ec._Person_login(ctx, field, obj)
+				wg.Done()
+			}(i, field)
+		case "displayName":
+			out.Values[i] = ec._Person_displayName(ctx, field, obj)
 			if out.Values[i] == graphql.Null {
 				invalid = true
 			}
 		case "avatarUrl":
-			out.Values[i] = ec._Person_avatarUrl(ctx, field, obj)
+			wg.Add(1)
+			go func(i int, field graphql.CollectedField) {
+				out.Values[i] = ec._Person_avatarUrl(ctx, field, obj)
+				wg.Done()
+			}(i, field)
 		default:
 			panic("unknown field " + strconv.Quote(field.Name))
 		}
 	}
-
+	wg.Wait()
 	if invalid {
 		return graphql.Null
 	}
 	return out
 }
 
+// nolint: vetshadow
+func (ec *executionContext) _Person_name(ctx context.Context, field graphql.CollectedField, obj *bug.Person) graphql.Marshaler {
+	rctx := &graphql.ResolverContext{
+		Object: "Person",
+		Args:   nil,
+		Field:  field,
+	}
+	ctx = graphql.WithResolverContext(ctx, rctx)
+	resTmp := ec.FieldMiddleware(ctx, obj, func(ctx context.Context) (interface{}, error) {
+		return ec.resolvers.Person().Name(ctx, obj)
+	})
+	if resTmp == nil {
+		return graphql.Null
+	}
+	res := resTmp.(*string)
+	rctx.Result = res
+
+	if res == nil {
+		return graphql.Null
+	}
+	return graphql.MarshalString(*res)
+}
+
 // nolint: vetshadow
 func (ec *executionContext) _Person_email(ctx context.Context, field graphql.CollectedField, obj *bug.Person) graphql.Marshaler {
 	rctx := &graphql.ResolverContext{
@@ -5317,18 +5385,22 @@ func (ec *executionContext) _Person_email(ctx context.Context, field graphql.Col
 	}
 	ctx = graphql.WithResolverContext(ctx, rctx)
 	resTmp := ec.FieldMiddleware(ctx, obj, func(ctx context.Context) (interface{}, error) {
-		return obj.Email, nil
+		return ec.resolvers.Person().Email(ctx, obj)
 	})
 	if resTmp == nil {
 		return graphql.Null
 	}
-	res := resTmp.(string)
+	res := resTmp.(*string)
 	rctx.Result = res
-	return graphql.MarshalString(res)
+
+	if res == nil {
+		return graphql.Null
+	}
+	return graphql.MarshalString(*res)
 }
 
 // nolint: vetshadow
-func (ec *executionContext) _Person_name(ctx context.Context, field graphql.CollectedField, obj *bug.Person) graphql.Marshaler {
+func (ec *executionContext) _Person_login(ctx context.Context, field graphql.CollectedField, obj *bug.Person) graphql.Marshaler {
 	rctx := &graphql.ResolverContext{
 		Object: "Person",
 		Args:   nil,
@@ -5336,7 +5408,30 @@ func (ec *executionContext) _Person_name(ctx context.Context, field graphql.Coll
 	}
 	ctx = graphql.WithResolverContext(ctx, rctx)
 	resTmp := ec.FieldMiddleware(ctx, obj, func(ctx context.Context) (interface{}, error) {
-		return obj.Name, nil
+		return ec.resolvers.Person().Login(ctx, obj)
+	})
+	if resTmp == nil {
+		return graphql.Null
+	}
+	res := resTmp.(*string)
+	rctx.Result = res
+
+	if res == nil {
+		return graphql.Null
+	}
+	return graphql.MarshalString(*res)
+}
+
+// nolint: vetshadow
+func (ec *executionContext) _Person_displayName(ctx context.Context, field graphql.CollectedField, obj *bug.Person) graphql.Marshaler {
+	rctx := &graphql.ResolverContext{
+		Object: "Person",
+		Args:   nil,
+		Field:  field,
+	}
+	ctx = graphql.WithResolverContext(ctx, rctx)
+	resTmp := ec.FieldMiddleware(ctx, obj, func(ctx context.Context) (interface{}, error) {
+		return obj.DisplayName(), nil
 	})
 	if resTmp == nil {
 		if !ec.HasError(rctx) {
@@ -5358,14 +5453,18 @@ func (ec *executionContext) _Person_avatarUrl(ctx context.Context, field graphql
 	}
 	ctx = graphql.WithResolverContext(ctx, rctx)
 	resTmp := ec.FieldMiddleware(ctx, obj, func(ctx context.Context) (interface{}, error) {
-		return obj.AvatarUrl, nil
+		return ec.resolvers.Person().AvatarURL(ctx, obj)
 	})
 	if resTmp == nil {
 		return graphql.Null
 	}
-	res := resTmp.(string)
+	res := resTmp.(*string)
 	rctx.Result = res
-	return graphql.MarshalString(res)
+
+	if res == nil {
+		return graphql.Null
+	}
+	return graphql.MarshalString(*res)
 }
 
 var queryImplementors = []string{"Query"}
@@ -7922,11 +8021,17 @@ type PageInfo {
 
 """Represents an person in a git object."""
 type Person {
-  """The email of the person."""
+  """The name of the person, if known."""
+  name: String
+
+  """The email of the person, if known."""
   email: String
 
-  """The name of the person."""
-  name: String!
+  """The login of the person, if known."""
+  login: String
+
+  """A string containing the either the name of the person, its login or both"""
+  displayName: String!
 
   """An url to an avatar"""
   avatarUrl: String

graphql/resolvers/root.go 🔗

@@ -32,6 +32,10 @@ func (RootResolver) Bug() graph.BugResolver {
 	return &bugResolver{}
 }
 
+func (r RootResolver) Person() graph.PersonResolver {
+	return &personResolver{}
+}
+
 func (RootResolver) CommentHistoryStep() graph.CommentHistoryStepResolver {
 	return &commentHistoryStepResolver{}
 }

graphql/schema.graphql 🔗

@@ -16,11 +16,17 @@ type PageInfo {
 
 """Represents an person in a git object."""
 type Person {
-  """The email of the person."""
+  """The name of the person, if known."""
+  name: String
+
+  """The email of the person, if known."""
   email: String
 
-  """The name of the person."""
-  name: String!
+  """The login of the person, if known."""
+  login: String
+
+  """A string containing the either the name of the person, its login or both"""
+  displayName: String!
 
   """An url to an avatar"""
   avatarUrl: String

termui/bug_table.go 🔗

@@ -299,7 +299,7 @@ func (bt *bugTable) render(v *gocui.View, maxX int) {
 		id := text.LeftPadMaxLine(snap.HumanId(), columnWidths["id"], 2)
 		status := text.LeftPadMaxLine(snap.Status.String(), columnWidths["status"], 2)
 		title := text.LeftPadMaxLine(snap.Title, columnWidths["title"], 2)
-		author := text.LeftPadMaxLine(person.Name, columnWidths["author"], 2)
+		author := text.LeftPadMaxLine(person.DisplayName(), columnWidths["author"], 2)
 		summary := text.LeftPadMaxLine(snap.Summary(), columnWidths["summary"], 2)
 		lastEdit := text.LeftPadMaxLine(humanize.Time(snap.LastEditTime()), columnWidths["lastEdit"], 2)
 

termui/show_bug.go 🔗

@@ -8,8 +8,8 @@ import (
 	"github.com/MichaelMure/git-bug/bug"
 	"github.com/MichaelMure/git-bug/cache"
 	"github.com/MichaelMure/git-bug/util/colors"
-	"github.com/MichaelMure/git-bug/util/text"
 	"github.com/MichaelMure/git-bug/util/git"
+	"github.com/MichaelMure/git-bug/util/text"
 	"github.com/jroimartin/gocui"
 )
 
@@ -233,7 +233,7 @@ func (sb *showBug) renderMain(g *gocui.Gui, mainView *gocui.View) error {
 		colors.Cyan(snap.HumanId()),
 		colors.Bold(snap.Title),
 		colors.Yellow(snap.Status),
-		colors.Magenta(snap.Author.Name),
+		colors.Magenta(snap.Author.DisplayName()),
 		snap.CreatedAt.Format(timeLayout),
 		edited,
 	)
@@ -276,7 +276,7 @@ func (sb *showBug) renderMain(g *gocui.Gui, mainView *gocui.View) error {
 
 			message, _ := text.WrapLeftPadded(comment.Message, maxX, 4)
 			content := fmt.Sprintf("%s commented on %s%s\n\n%s",
-				colors.Magenta(comment.Author.Name),
+				colors.Magenta(comment.Author.DisplayName()),
 				comment.CreatedAt.Time().Format(timeLayout),
 				edited,
 				message,
@@ -294,7 +294,7 @@ func (sb *showBug) renderMain(g *gocui.Gui, mainView *gocui.View) error {
 			setTitle := op.(*bug.SetTitleTimelineItem)
 
 			content := fmt.Sprintf("%s changed the title to %s on %s",
-				colors.Magenta(setTitle.Author.Name),
+				colors.Magenta(setTitle.Author.DisplayName()),
 				colors.Bold(setTitle.Title),
 				setTitle.UnixTime.Time().Format(timeLayout),
 			)
@@ -311,7 +311,7 @@ func (sb *showBug) renderMain(g *gocui.Gui, mainView *gocui.View) error {
 			setStatus := op.(*bug.SetStatusTimelineItem)
 
 			content := fmt.Sprintf("%s %s the bug on %s",
-				colors.Magenta(setStatus.Author.Name),
+				colors.Magenta(setStatus.Author.DisplayName()),
 				colors.Bold(setStatus.Status.Action()),
 				setStatus.UnixTime.Time().Format(timeLayout),
 			)
@@ -360,7 +360,7 @@ func (sb *showBug) renderMain(g *gocui.Gui, mainView *gocui.View) error {
 			}
 
 			content := fmt.Sprintf("%s %s on %s",
-				colors.Magenta(labelChange.Author.Name),
+				colors.Magenta(labelChange.Author.DisplayName()),
 				action.String(),
 				labelChange.UnixTime.Time().Format(timeLayout),
 			)

webui/public/index.html 🔗

@@ -6,7 +6,7 @@
     <meta name="theme-color" content="#000000">
     <link rel="manifest" href="%PUBLIC_URL%/manifest.json">
     <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
-    <title>git-bug-webui(1)</title>
+    <title>git-bug webui</title>
 </head>
 <body>
 <noscript>You need to enable JavaScript to run this app.</noscript>

webui/src/Author.js 🔗

@@ -14,9 +14,13 @@ const styles = theme => ({
 const Author = ({ author, bold, classes }) => {
   const klass = bold ? [classes.author, classes.bold] : [classes.author];
 
+  if(!author.email) {
+    return <span className={klass.join(' ')}>{author.displayName}</span>
+  }
+
   return (
     <Tooltip title={author.email}>
-      <span className={klass.join(' ')}>{author.name}</span>
+      <span className={klass.join(' ')}>{author.displayName}</span>
     </Tooltip>
   );
 };

webui/src/bug/Message.js 🔗

@@ -47,6 +47,7 @@ Message.createFragment = gql`
       author {
         name
         email
+        displayName
       }
       message
     }
@@ -60,6 +61,7 @@ Message.commentFragment = gql`
       author {
         name
         email
+        displayName
       }
       message
     }

webui/src/list/BugRow.js 🔗

@@ -77,7 +77,7 @@ const BugRow = ({ bug, classes }) => (
         <Typography color={'textSecondary'}>
           {bug.humanId} opened
           <Date date={bug.createdAt} />
-          by {bug.author.name}
+          by {bug.author.displayName}
         </Typography>
       </div>
     </TableCell>
@@ -94,6 +94,7 @@ BugRow.fragment = gql`
     labels
     author {
       name
+      displayName
     }
   }
 `;