aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--bridge/core/import.go12
-rw-r--r--bridge/github/config.go2
-rw-r--r--bridge/github/export.go4
-rw-r--r--bridge/github/export_test.go2
-rw-r--r--bridge/github/import.go517
-rw-r--r--bridge/github/import_mediator.go436
-rw-r--r--bridge/github/import_query.go252
-rw-r--r--bridge/github/iterator.go423
-rw-r--r--bug/operation.go2
-rw-r--r--cache/repo_cache.go2
-rw-r--r--cache/repo_cache_test.go5
-rw-r--r--cache/resolvers.go29
-rw-r--r--entity/dag/clock.go7
-rw-r--r--identity/resolver.go35
-rw-r--r--repository/gogit.go2
-rw-r--r--webui/.eslintrc.js1
-rw-r--r--webui/src/components/BackToListButton.tsx4
-rw-r--r--webui/src/components/BugTitleForm/BugTitleForm.tsx12
-rw-r--r--webui/src/components/Header/Header.tsx21
-rw-r--r--webui/src/components/Label.tsx49
-rw-r--r--webui/src/pages/bug/Bug.tsx19
-rw-r--r--webui/src/pages/bug/LabelChange.tsx9
-rw-r--r--webui/src/pages/bug/labels/LabelMenu.tsx309
-rw-r--r--webui/src/pages/bug/labels/SetLabel.graphql13
-rw-r--r--webui/src/pages/list/BugRow.tsx47
-rw-r--r--webui/src/pages/list/Filter.tsx116
-rw-r--r--webui/src/pages/list/FilterToolbar.tsx70
-rw-r--r--webui/src/pages/list/ListIdentities.graphql13
-rw-r--r--webui/src/pages/list/ListLabels.graphql10
-rw-r--r--webui/src/pages/list/ListQuery.tsx115
30 files changed, 1613 insertions, 925 deletions
diff --git a/bridge/core/import.go b/bridge/core/import.go
index 0b0b4c68..c1965d4e 100644
--- a/bridge/core/import.go
+++ b/bridge/core/import.go
@@ -34,6 +34,9 @@ const (
// but not severe enough to consider the import a failure.
ImportEventWarning
+ // The import system (web API) has reached the rate limit
+ ImportEventRateLimiting
+
// Error happened during import
ImportEventError
)
@@ -87,6 +90,8 @@ func (er ImportResult) String() string {
parts = append(parts, fmt.Sprintf("err: %s", er.Err))
}
return strings.Join(parts, " ")
+ case ImportEventRateLimiting:
+ return fmt.Sprintf("rate limiting: %s", er.Reason)
default:
panic("unknown import result")
@@ -165,3 +170,10 @@ func NewImportIdentity(id entity.Id) ImportResult {
Event: ImportEventIdentity,
}
}
+
+func NewImportRateLimiting(msg string) ImportResult {
+ return ImportResult{
+ Reason: msg,
+ Event: ImportEventRateLimiting,
+ }
+}
diff --git a/bridge/github/config.go b/bridge/github/config.go
index 2b5af7fb..1e23c8ee 100644
--- a/bridge/github/config.go
+++ b/bridge/github/config.go
@@ -251,7 +251,7 @@ func promptUserToGoToBrowser(url, userCode string) {
fmt.Println("Please visit the following Github URL in a browser and enter your user authentication code.")
fmt.Println()
fmt.Println(" URL:", url)
- fmt.Println(" user authentiation code:", userCode)
+ fmt.Println(" user authentication code:", userCode)
fmt.Println()
}
diff --git a/bridge/github/export.go b/bridge/github/export.go
index 1a59fbb3..264f2a23 100644
--- a/bridge/github/export.go
+++ b/bridge/github/export.go
@@ -504,7 +504,7 @@ func (ge *githubExporter) cacheGithubLabels(ctx context.Context, gc *githubv4.Cl
return nil
}
-func (ge *githubExporter) getLabelID(gc *githubv4.Client, label string) (string, error) {
+func (ge *githubExporter) getLabelID(label string) (string, error) {
label = strings.ToLower(label)
for cachedLabel, ID := range ge.cachedLabels {
if label == strings.ToLower(cachedLabel) {
@@ -598,7 +598,7 @@ func (ge *githubExporter) createGithubLabelV4(gc *githubv4.Client, label, labelC
func (ge *githubExporter) getOrCreateGithubLabelID(ctx context.Context, gc *githubv4.Client, repositoryID string, label bug.Label) (string, error) {
// try to get label id from cache
- labelID, err := ge.getLabelID(gc, string(label))
+ labelID, err := ge.getLabelID(string(label))
if err == nil {
return labelID, nil
}
diff --git a/bridge/github/export_test.go b/bridge/github/export_test.go
index b7a36bcf..78425e60 100644
--- a/bridge/github/export_test.go
+++ b/bridge/github/export_test.go
@@ -268,7 +268,7 @@ func TestGithubPushPull(t *testing.T) {
require.True(t, ok)
require.Equal(t, issueOrigin, target)
- //TODO: maybe more tests to ensure bug final state
+ // TODO: maybe more tests to ensure bug final state
})
}
}
diff --git a/bridge/github/import.go b/bridge/github/import.go
index af62746f..bf43a877 100644
--- a/bridge/github/import.go
+++ b/bridge/github/import.go
@@ -15,6 +15,8 @@ import (
"github.com/MichaelMure/git-bug/util/text"
)
+const EmptyTitlePlaceholder = "<empty string>"
+
// githubImporter implement the Importer interface
type githubImporter struct {
conf core.Configuration
@@ -22,8 +24,8 @@ type githubImporter struct {
// default client
client *githubv4.Client
- // iterator
- iterator *iterator
+ // mediator to access the Github API
+ mediator *importMediator
// send only channel
out chan<- core.ImportResult
@@ -31,7 +33,6 @@ type githubImporter struct {
func (gi *githubImporter) Init(_ context.Context, repo *cache.RepoCache, conf core.Configuration) error {
gi.conf = conf
-
creds, err := auth.List(repo,
auth.WithTarget(target),
auth.WithKind(auth.KindToken),
@@ -40,11 +41,9 @@ func (gi *githubImporter) Init(_ context.Context, repo *cache.RepoCache, conf co
if err != nil {
return err
}
-
- if len(creds) == 0 {
+ if len(creds) <= 0 {
return ErrMissingIdentityToken
}
-
gi.client = buildClient(creds[0].(*auth.Token))
return nil
@@ -53,46 +52,95 @@ func (gi *githubImporter) Init(_ context.Context, repo *cache.RepoCache, conf co
// ImportAll iterate over all the configured repository issues and ensure the creation of the
// missing issues / timeline items / edits / label events ...
func (gi *githubImporter) ImportAll(ctx context.Context, repo *cache.RepoCache, since time.Time) (<-chan core.ImportResult, error) {
- gi.iterator = NewIterator(ctx, gi.client, 10, gi.conf[confKeyOwner], gi.conf[confKeyProject], since)
+ gi.mediator = NewImportMediator(ctx, gi.client, gi.conf[confKeyOwner], gi.conf[confKeyProject], since)
out := make(chan core.ImportResult)
gi.out = out
go func() {
defer close(gi.out)
-
- // Loop over all matching issues
- for gi.iterator.NextIssue() {
- issue := gi.iterator.IssueValue()
- // create issue
- b, err := gi.ensureIssue(repo, issue)
- if err != nil {
- err := fmt.Errorf("issue creation: %v", err)
- out <- core.NewImportError(err, "")
- return
+ var currBug *cache.BugCache
+ var currEvent ImportEvent
+ var nextEvent ImportEvent
+ var err error
+ for {
+ // An IssueEvent contains the issue in its most recent state. If an issue
+ // has at least one issue edit, then the history of the issue edits is
+ // represented by IssueEditEvents. That is, the unedited (original) issue
+ // might be saved only in the IssueEditEvent following the IssueEvent.
+ // Since we replicate the edit history we need to either use the IssueEvent
+ // (if there are no edits) or the IssueEvent together with its first
+ // IssueEditEvent (if there are edits).
+ // Exactly the same is true for comments and comment edits.
+ // As a consequence we need to look at the current event and one look ahead
+ // event.
+ currEvent = nextEvent
+ if currEvent == nil {
+ currEvent = gi.getEventHandleMsgs()
}
-
- // loop over timeline items
- for gi.iterator.NextTimelineItem() {
- item := gi.iterator.TimelineItemValue()
- err := gi.ensureTimelineItem(repo, b, item)
+ if currEvent == nil {
+ break
+ }
+ nextEvent = gi.getEventHandleMsgs()
+
+ switch event := currEvent.(type) {
+ case RateLimitingEvent:
+ out <- core.NewImportRateLimiting(event.msg)
+ case IssueEvent:
+ // first: commit what is being held in currBug
+ if err = gi.commit(currBug, out); err != nil {
+ out <- core.NewImportError(err, "")
+ return
+ }
+ // second: create new issue
+ switch next := nextEvent.(type) {
+ case IssueEditEvent:
+ // consuming and using next event
+ nextEvent = nil
+ currBug, err = gi.ensureIssue(ctx, repo, &event.issue, &next.userContentEdit)
+ default:
+ currBug, err = gi.ensureIssue(ctx, repo, &event.issue, nil)
+ }
+ if err != nil {
+ err := fmt.Errorf("issue creation: %v", err)
+ out <- core.NewImportError(err, "")
+ return
+ }
+ case IssueEditEvent:
+ err = gi.ensureIssueEdit(ctx, repo, currBug, event.issueId, &event.userContentEdit)
+ if err != nil {
+ err = fmt.Errorf("issue edit: %v", err)
+ out <- core.NewImportError(err, "")
+ return
+ }
+ case TimelineEvent:
+ if next, ok := nextEvent.(CommentEditEvent); ok && event.Typename == "IssueComment" {
+ // consuming and using next event
+ nextEvent = nil
+ err = gi.ensureComment(ctx, repo, currBug, &event.timelineItem.IssueComment, &next.userContentEdit)
+ } else {
+ err = gi.ensureTimelineItem(ctx, repo, currBug, &event.timelineItem)
+ }
if err != nil {
err = fmt.Errorf("timeline item creation: %v", err)
out <- core.NewImportError(err, "")
return
}
- }
-
- if !b.NeedCommit() {
- out <- core.NewImportNothing(b.Id(), "no imported operation")
- } else if err := b.Commit(); err != nil {
- // commit bug state
- err = fmt.Errorf("bug commit: %v", err)
- out <- core.NewImportError(err, "")
- return
+ case CommentEditEvent:
+ err = gi.ensureCommentEdit(ctx, repo, currBug, event.commentId, &event.userContentEdit)
+ if err != nil {
+ err = fmt.Errorf("comment edit: %v", err)
+ out <- core.NewImportError(err, "")
+ return
+ }
+ default:
+ panic("Unknown event type")
}
}
-
- if err := gi.iterator.Error(); err != nil {
+ // commit what is being held in currBug before returning
+ if err = gi.commit(currBug, out); err != nil {
+ out <- core.NewImportError(err, "")
+ }
+ if err = gi.mediator.Error(); err != nil {
gi.out <- core.NewImportError(err, "")
}
}()
@@ -100,9 +148,35 @@ func (gi *githubImporter) ImportAll(ctx context.Context, repo *cache.RepoCache,
return out, nil
}
-func (gi *githubImporter) ensureIssue(repo *cache.RepoCache, issue issue) (*cache.BugCache, error) {
- // ensure issue author
- author, err := gi.ensurePerson(repo, issue.Author)
+func (gi *githubImporter) getEventHandleMsgs() ImportEvent {
+ for {
+ // read event from import mediator
+ event := gi.mediator.NextImportEvent()
+ // consume (and use) all rate limiting events
+ if e, ok := event.(RateLimitingEvent); ok {
+ gi.out <- core.NewImportRateLimiting(e.msg)
+ continue
+ }
+ return event
+ }
+}
+
+func (gi *githubImporter) commit(b *cache.BugCache, out chan<- core.ImportResult) error {
+ if b == nil {
+ return nil
+ }
+ if !b.NeedCommit() {
+ out <- core.NewImportNothing(b.Id(), "no imported operation")
+ return nil
+ } else if err := b.Commit(); err != nil {
+ // commit bug state
+ return fmt.Errorf("bug commit: %v", err)
+ }
+ return nil
+}
+
+func (gi *githubImporter) ensureIssue(ctx context.Context, repo *cache.RepoCache, issue *issue, issueEdit *userContentEdit) (*cache.BugCache, error) {
+ author, err := gi.ensurePerson(ctx, repo, issue.Author)
if err != nil {
return nil, err
}
@@ -112,113 +186,66 @@ func (gi *githubImporter) ensureIssue(repo *cache.RepoCache, issue issue) (*cach
return excerpt.CreateMetadata[core.MetaKeyOrigin] == target &&
excerpt.CreateMetadata[metaKeyGithubId] == parseId(issue.Id)
})
- if err != nil && err != bug.ErrBugNotExist {
+ if err == nil {
+ return b, nil
+ }
+ if err != bug.ErrBugNotExist {
return nil, err
}
- // get issue edits
- var issueEdits []userContentEdit
- for gi.iterator.NextIssueEdit() {
- issueEdits = append(issueEdits, gi.iterator.IssueEditValue())
+ // At Github there exist issues with seemingly empty titles. An example is
+ // https://github.com/NixOS/nixpkgs/issues/72730 .
+ // The title provided by the GraphQL API actually consists of a space followed by a
+ // zero width space (U+200B). This title would cause the NewBugRaw() function to
+ // return an error: empty title.
+ title := string(issue.Title)
+ if title == " \u200b" { // U+200B == zero width space
+ title = EmptyTitlePlaceholder
}
- // if issueEdits is empty
- if len(issueEdits) == 0 {
- if err == bug.ErrBugNotExist {
- cleanText, err := text.Cleanup(string(issue.Body))
- if err != nil {
- return nil, err
- }
-
- // create bug
- b, _, err = repo.NewBugRaw(
- author,
- issue.CreatedAt.Unix(),
- issue.Title,
- cleanText,
- nil,
- map[string]string{
- core.MetaKeyOrigin: target,
- metaKeyGithubId: parseId(issue.Id),
- metaKeyGithubUrl: issue.Url.String(),
- })
- if err != nil {
- return nil, err
- }
-
- // importing a new bug
- gi.out <- core.NewImportBug(b.Id())
- }
+ var textInput string
+ if issueEdit != nil {
+ // use the first issue edit: it represents the bug creation itself
+ textInput = string(*issueEdit.Diff)
} else {
- // create bug from given issueEdits
- for i, edit := range issueEdits {
- if i == 0 && b != nil {
- // The first edit in the github result is the issue creation itself, we already have that
- continue
- }
-
- cleanText, err := text.Cleanup(string(*edit.Diff))
- if err != nil {
- return nil, err
- }
-
- // if the bug doesn't exist
- if b == nil {
- // we create the bug as soon as we have a legit first edition
- b, _, err = repo.NewBugRaw(
- author,
- issue.CreatedAt.Unix(),
- issue.Title, // TODO: this is the *current* title, not the original one
- cleanText,
- nil,
- map[string]string{
- core.MetaKeyOrigin: target,
- metaKeyGithubId: parseId(issue.Id),
- metaKeyGithubUrl: issue.Url.String(),
- },
- )
-
- if err != nil {
- return nil, err
- }
- // importing a new bug
- gi.out <- core.NewImportBug(b.Id())
- continue
- }
-
- // other edits will be added as CommentEdit operations
- target, err := b.ResolveOperationWithMetadata(metaKeyGithubId, parseId(issue.Id))
- if err == cache.ErrNoMatchingOp {
- // original comment is missing somehow, issuing a warning
- gi.out <- core.NewImportWarning(fmt.Errorf("comment ID %s to edit is missing", parseId(issue.Id)), b.Id())
- continue
- }
- if err != nil {
- return nil, err
- }
+ // if there are no issue edits then the issue struct holds the bug creation
+ textInput = string(issue.Body)
+ }
+ cleanText, err := text.Cleanup(textInput)
+ if err != nil {
+ return nil, err
+ }
- err = gi.ensureCommentEdit(repo, b, target, edit)
- if err != nil {
- return nil, err
- }
- }
+ // create bug
+ b, _, err = repo.NewBugRaw(
+ author,
+ issue.CreatedAt.Unix(),
+ title, // TODO: this is the *current* title, not the original one
+ cleanText,
+ nil,
+ map[string]string{
+ core.MetaKeyOrigin: target,
+ metaKeyGithubId: parseId(issue.Id),
+ metaKeyGithubUrl: issue.Url.String(),
+ })
+ if err != nil {
+ return nil, err
}
+ // importing a new bug
+ gi.out <- core.NewImportBug(b.Id())
return b, nil
}
-func (gi *githubImporter) ensureTimelineItem(repo *cache.RepoCache, b *cache.BugCache, item timelineItem) error {
+func (gi *githubImporter) ensureIssueEdit(ctx context.Context, repo *cache.RepoCache, bug *cache.BugCache, ghIssueId githubv4.ID, edit *userContentEdit) error {
+ return gi.ensureCommentEdit(ctx, repo, bug, ghIssueId, edit)
+}
+
+func (gi *githubImporter) ensureTimelineItem(ctx context.Context, repo *cache.RepoCache, b *cache.BugCache, item *timelineItem) error {
switch item.Typename {
case "IssueComment":
- // collect all comment edits
- var commentEdits []userContentEdit
- for gi.iterator.NextCommentEdit() {
- commentEdits = append(commentEdits, gi.iterator.CommentEditValue())
- }
-
- // ensureTimelineComment send import events over out chanel
- err := gi.ensureTimelineComment(repo, b, item.IssueComment, commentEdits)
+ err := gi.ensureComment(ctx, repo, b, &item.IssueComment, nil)
if err != nil {
return fmt.Errorf("timeline comment creation: %v", err)
}
@@ -234,7 +261,7 @@ func (gi *githubImporter) ensureTimelineItem(repo *cache.RepoCache, b *cache.Bug
if err != cache.ErrNoMatchingOp {
return err
}
- author, err := gi.ensurePerson(repo, item.LabeledEvent.Actor)
+ author, err := gi.ensurePerson(ctx, repo, item.LabeledEvent.Actor)
if err != nil {
return err
}
@@ -263,7 +290,7 @@ func (gi *githubImporter) ensureTimelineItem(repo *cache.RepoCache, b *cache.Bug
if err != cache.ErrNoMatchingOp {
return err
}
- author, err := gi.ensurePerson(repo, item.UnlabeledEvent.Actor)
+ author, err := gi.ensurePerson(ctx, repo, item.UnlabeledEvent.Actor)
if err != nil {
return err
}
@@ -293,7 +320,7 @@ func (gi *githubImporter) ensureTimelineItem(repo *cache.RepoCache, b *cache.Bug
if err == nil {
return nil
}
- author, err := gi.ensurePerson(repo, item.ClosedEvent.Actor)
+ author, err := gi.ensurePerson(ctx, repo, item.ClosedEvent.Actor)
if err != nil {
return err
}
@@ -319,7 +346,7 @@ func (gi *githubImporter) ensureTimelineItem(repo *cache.RepoCache, b *cache.Bug
if err == nil {
return nil
}
- author, err := gi.ensurePerson(repo, item.ReopenedEvent.Actor)
+ author, err := gi.ensurePerson(ctx, repo, item.ReopenedEvent.Actor)
if err != nil {
return err
}
@@ -345,14 +372,25 @@ func (gi *githubImporter) ensureTimelineItem(repo *cache.RepoCache, b *cache.Bug
if err == nil {
return nil
}
- author, err := gi.ensurePerson(repo, item.RenamedTitleEvent.Actor)
+ author, err := gi.ensurePerson(ctx, repo, item.RenamedTitleEvent.Actor)
if err != nil {
return err
}
+
+ // At Github there exist issues with seemingly empty titles. An example is
+ // https://github.com/NixOS/nixpkgs/issues/72730 .
+ // The title provided by the GraphQL API actually consists of a space followed
+ // by a zero width space (U+200B). This title would cause the NewBugRaw()
+ // function to return an error: empty title.
+ title := string(item.RenamedTitleEvent.CurrentTitle)
+ if title == " \u200b" { // U+200B == zero width space
+ title = EmptyTitlePlaceholder
+ }
+
op, err := b.SetTitleRaw(
author,
item.RenamedTitleEvent.CreatedAt.Unix(),
- string(item.RenamedTitleEvent.CurrentTitle),
+ title,
map[string]string{metaKeyGithubId: id},
)
if err != nil {
@@ -366,97 +404,62 @@ func (gi *githubImporter) ensureTimelineItem(repo *cache.RepoCache, b *cache.Bug
return nil
}
-func (gi *githubImporter) ensureTimelineComment(repo *cache.RepoCache, b *cache.BugCache, item issueComment, edits []userContentEdit) error {
- // ensure person
- author, err := gi.ensurePerson(repo, item.Author)
+func (gi *githubImporter) ensureCommentEdit(ctx context.Context, repo *cache.RepoCache, b *cache.BugCache, ghTargetId githubv4.ID, edit *userContentEdit) error {
+ // find comment
+ target, err := b.ResolveOperationWithMetadata(metaKeyGithubId, parseId(ghTargetId))
if err != nil {
return err
}
-
- targetOpID, err := b.ResolveOperationWithMetadata(metaKeyGithubId, parseId(item.Id))
- if err != nil && err != cache.ErrNoMatchingOp {
+ _, err = b.ResolveOperationWithMetadata(metaKeyGithubId, parseId(edit.Id))
+ if err == nil {
+ return nil
+ }
+ if err != cache.ErrNoMatchingOp {
// real error
return err
}
- // if no edits are given we create the comment
- if len(edits) == 0 {
- if err == cache.ErrNoMatchingOp {
- cleanText, err := text.Cleanup(string(item.Body))
- if err != nil {
- return err
- }
-
- // add comment operation
- op, err := b.AddCommentRaw(
- author,
- item.CreatedAt.Unix(),
- cleanText,
- nil,
- map[string]string{
- metaKeyGithubId: parseId(item.Id),
- metaKeyGithubUrl: parseId(item.Url.String()),
- },
- )
- if err != nil {
- return err
- }
-
- gi.out <- core.NewImportComment(op.Id())
- return nil
- }
-
- } else {
- for i, edit := range edits {
- if i == 0 && targetOpID != "" {
- // The first edit in the github result is the comment creation itself, we already have that
- continue
- }
-
- // ensure editor identity
- editor, err := gi.ensurePerson(repo, edit.Editor)
- if err != nil {
- return err
- }
+ editor, err := gi.ensurePerson(ctx, repo, edit.Editor)
+ if err != nil {
+ return err
+ }
- // create comment when target is empty
- if targetOpID == "" {
- cleanText, err := text.Cleanup(string(*edit.Diff))
- if err != nil {
- return err
- }
+ if edit.DeletedAt != nil {
+ // comment deletion, not supported yet
+ return nil
+ }
- op, err := b.AddCommentRaw(
- editor,
- edit.CreatedAt.Unix(),
- cleanText,
- nil,
- map[string]string{
- metaKeyGithubId: parseId(item.Id),
- metaKeyGithubUrl: item.Url.String(),
- },
- )
- if err != nil {
- return err
- }
- gi.out <- core.NewImportComment(op.Id())
+ cleanText, err := text.Cleanup(string(*edit.Diff))
+ if err != nil {
+ return err
+ }
- // set target for the next edit now that the comment is created
- targetOpID = op.Id()
- continue
- }
+ // comment edition
+ op, err := b.EditCommentRaw(
+ editor,
+ edit.CreatedAt.Unix(),
+ target,
+ cleanText,
+ map[string]string{
+ metaKeyGithubId: parseId(edit.Id),
+ },
+ )
- err = gi.ensureCommentEdit(repo, b, targetOpID, edit)
- if err != nil {
- return err
- }
- }
+ if err != nil {
+ return err
}
+
+ gi.out <- core.NewImportCommentEdition(op.Id())
return nil
}
-func (gi *githubImporter) ensureCommentEdit(repo *cache.RepoCache, b *cache.BugCache, target entity.Id, edit userContentEdit) error {
- _, err := b.ResolveOperationWithMetadata(metaKeyGithubId, parseId(edit.Id))
+func (gi *githubImporter) ensureComment(ctx context.Context, repo *cache.RepoCache, b *cache.BugCache, comment *issueComment, firstEdit *userContentEdit) error {
+ author, err := gi.ensurePerson(ctx, repo, comment.Author)
+ if err != nil {
+ return err
+ }
+
+ _, err = b.ResolveOperationWithMetadata(metaKeyGithubId, parseId(comment.Id))
if err == nil {
return nil
}
@@ -465,50 +468,44 @@ func (gi *githubImporter) ensureCommentEdit(repo *cache.RepoCache, b *cache.BugC
return err
}
- editor, err := gi.ensurePerson(repo, edit.Editor)
+ var textInput string
+ if firstEdit != nil {
+ // use the first comment edit: it represents the comment creation itself
+ textInput = string(*firstEdit.Diff)
+ } else {
+ // if there are not comment edits, then the comment struct holds the comment creation
+ textInput = string(comment.Body)
+ }
+ cleanText, err := text.Cleanup(textInput)
if err != nil {
return err
}
- switch {
- case edit.DeletedAt != nil:
- // comment deletion, not supported yet
- return nil
-
- case edit.DeletedAt == nil:
-
- cleanText, err := text.Cleanup(string(*edit.Diff))
- if err != nil {
- return err
- }
-
- // comment edition
- op, err := b.EditCommentRaw(
- editor,
- edit.CreatedAt.Unix(),
- target,
- cleanText,
- map[string]string{
- metaKeyGithubId: parseId(edit.Id),
- },
- )
-
- if err != nil {
- return err
- }
-
- gi.out <- core.NewImportCommentEdition(op.Id())
- return nil
+ // add comment operation
+ op, err := b.AddCommentRaw(
+ author,
+ comment.CreatedAt.Unix(),
+ cleanText,
+ nil,
+ map[string]string{
+ metaKeyGithubId: parseId(comment.Id),
+ metaKeyGithubUrl: comment.Url.String(),
+ },
+ )
+ if err != nil {
+ return err
}
+
+ gi.out <- core.NewImportComment(op.Id())
return nil
}
// ensurePerson create a bug.Person from the Github data
-func (gi *githubImporter) ensurePerson(repo *cache.RepoCache, actor *actor) (*cache.IdentityCache, error) {
+func (gi *githubImporter) ensurePerson(ctx context.Context, repo *cache.RepoCache, actor *actor) (*cache.IdentityCache, error) {
// When a user has been deleted, Github return a null actor, while displaying a profile named "ghost"
// in it's UI. So we need a special case to get it.
if actor == nil {
- return gi.getGhost(repo)
+ return gi.getGhost(ctx, repo)
}
// Look first in the cache
@@ -521,7 +518,6 @@ func (gi *githubImporter) ensurePerson(repo *cache.RepoCache, actor *actor) (*ca
}
// importing a new identity
-
var name string
var email string
@@ -565,48 +561,37 @@ func (gi *githubImporter) ensurePerson(repo *cache.RepoCache, actor *actor) (*ca
return i, nil
}
-func (gi *githubImporter) getGhost(repo *cache.RepoCache) (*cache.IdentityCache, error) {
+func (gi *githubImporter) getGhost(ctx context.Context, repo *cache.RepoCache) (*cache.IdentityCache, error) {
+ loginName := "ghost"
// Look first in the cache
- i, err := repo.ResolveIdentityImmutableMetadata(metaKeyGithubLogin, "ghost")
+ i, err := repo.ResolveIdentityImmutableMetadata(metaKeyGithubLogin, loginName)
if err == nil {
return i, nil
}
if entity.IsErrMultipleMatch(err) {
return nil, err
}
-
- var q ghostQuery
-
- variables := map[string]interface{}{
- "login": githubv4.String("ghost"),
- }
-
- ctx, cancel := context.WithTimeout(gi.iterator.ctx, defaultTimeout)
- defer cancel()
-
- err = gi.client.Query(ctx, &q, variables)
+ user, err := gi.mediator.User(ctx, loginName)
if err != nil {
return nil, err
}
-
- var name string
- if q.User.Name != nil {
- name = string(*q.User.Name)
+ userName := ""
+ if user.Name != nil {
+ userName = string(*user.Name)
}
-
return repo.NewIdentityRaw(
- name,
+ userName,
"",
- string(q.User.Login),
- string(q.User.AvatarUrl),
+ string(user.Login),
+ string(user.AvatarUrl),
nil,
map[string]string{
- metaKeyGithubLogin: string(q.User.Login),
+ metaKeyGithubLogin: string(user.Login),
},
)
}
-// parseId convert the unusable githubv4.ID (an interface{}) into a string
+// parseId converts the unusable githubv4.ID (an interface{}) into a string
func parseId(id githubv4.ID) string {
return fmt.Sprintf("%v", id)
}
diff --git a/bridge/github/import_mediator.go b/bridge/github/import_mediator.go
new file mode 100644
index 00000000..873d5f62
--- /dev/null
+++ b/bridge/github/import_mediator.go
@@ -0,0 +1,436 @@
+package github
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/shurcooL/githubv4"
+)
+
+const (
+ // These values influence how fast the github graphql rate limit is exhausted.
+ NumIssues = 40
+ NumIssueEdits = 100
+ NumTimelineItems = 100
+ NumCommentEdits = 100
+
+ ChanCapacity = 128
+)
+
+// importMediator provides a convenient interface to retrieve issues from the Github GraphQL API.
+type importMediator struct {
+ // Github graphql client
+ gc *githubv4.Client
+
+ // name of the repository owner on Github
+ owner string
+
+ // name of the Github repository
+ project string
+
+ // since specifies which issues to import. Issues that have been updated at or after the
+ // given date should be imported.
+ since time.Time
+
+ // importEvents holds events representing issues, comments, edits, ...
+ // In this channel issues are immediately followed by their issue edits and comments are
+ // immediately followed by their comment edits.
+ importEvents chan ImportEvent
+
+ // Sticky error
+ err error
+}
+
+type ImportEvent interface {
+ isImportEvent()
+}
+
+type RateLimitingEvent struct {
+ msg string
+}
+
+func (RateLimitingEvent) isImportEvent() {}
+
+type IssueEvent struct {
+ issue
+}
+
+func (IssueEvent) isImportEvent() {}
+
+type IssueEditEvent struct {
+ issueId githubv4.ID
+ userContentEdit
+}
+
+func (IssueEditEvent) isImportEvent() {}
+
+type TimelineEvent struct {
+ issueId githubv4.ID
+ timelineItem
+}
+
+func (TimelineEvent) isImportEvent() {}
+
+type CommentEditEvent struct {
+ commentId githubv4.ID
+ userContentEdit
+}
+
+func (CommentEditEvent) isImportEvent() {}
+
+func (mm *importMediator) NextImportEvent() ImportEvent {
+ return <-mm.importEvents
+}
+
+func NewImportMediator(ctx context.Context, client *githubv4.Client, owner, project string, since time.Time) *importMediator {
+ mm := importMediator{
+ gc: client,
+ owner: owner,
+ project: project,
+ since: since,
+ importEvents: make(chan ImportEvent, ChanCapacity),
+ err: nil,
+ }
+ go func() {
+ mm.fillImportEvents(ctx)
+ close(mm.importEvents)
+ }()
+ return &mm
+}
+
+type varmap map[string]interface{}
+
+func newIssueVars(owner, project string, since time.Time) varmap {
+ return varmap{
+ "owner": githubv4.String(owner),
+ "name": githubv4.String(project),
+ "issueSince": githubv4.DateTime{Time: since},
+ "issueFirst": githubv4.Int(NumIssues),
+ "issueEditLast": githubv4.Int(NumIssueEdits),
+ "issueEditBefore": (*githubv4.String)(nil),
+ "timelineFirst": githubv4.Int(NumTimelineItems),
+ "timelineAfter": (*githubv4.String)(nil),
+ "commentEditLast": githubv4.Int(NumCommentEdits),
+ "commentEditBefore": (*githubv4.String)(nil),
+ }
+}
+
+func newIssueEditVars() varmap {
+ return varmap{
+ "issueEditLast": githubv4.Int(NumIssueEdits),
+ }
+}
+
+func newTimelineVars() varmap {
+ return varmap{
+ "timelineFirst": githubv4.Int(NumTimelineItems),
+ "commentEditLast": githubv4.Int(NumCommentEdits),
+ "commentEditBefore": (*githubv4.String)(nil),
+ }
+}
+
+func newCommentEditVars() varmap {
+ return varmap{
+ "commentEditLast": githubv4.Int(NumCommentEdits),
+ }
+}
+
+func (mm *importMediator) Error() error {
+ return mm.err
+}
+
+func (mm *importMediator) User(ctx context.Context, loginName string) (*user, error) {
+ query := userQuery{}
+ vars := varmap{"login": githubv4.String(loginName)}
+ if err := mm.mQuery(ctx, &query, vars); err != nil {
+ return nil, err
+ }
+ return &query.User, nil
+}
+
+func (mm *importMediator) fillImportEvents(ctx context.Context) {
+ initialCursor := githubv4.String("")
+ issues, hasIssues := mm.queryIssue(ctx, initialCursor)
+ for hasIssues {
+ for _, node := range issues.Nodes {
+ select {
+ case <-ctx.Done():
+ return
+ case mm.importEvents <- IssueEvent{node.issue}:
+ }
+
+ // issue edit events follow the issue event
+ mm.fillIssueEditEvents(ctx, &node)
+ // last come the timeline events
+ mm.fillTimelineEvents(ctx, &node)
+ }
+ if !issues.PageInfo.HasNextPage {
+ break
+ }
+ issues, hasIssues = mm.queryIssue(ctx, issues.PageInfo.EndCursor)
+ }
+}
+
+func (mm *importMediator) fillIssueEditEvents(ctx context.Context, issueNode *issueNode) {
+ edits := &issueNode.UserContentEdits
+ hasEdits := true
+ for hasEdits {
+ for edit := range reverse(edits.Nodes) {
+ if edit.Diff == nil || string(*edit.Diff) == "" {
+ // issueEdit.Diff == nil happen if the event is older than early
+ // 2018, Github doesn't have the data before that. Best we can do is
+ // to ignore the event.
+ continue
+ }
+ select {
+ case <-ctx.Done():
+ return
+ case mm.importEvents <- IssueEditEvent{issueId: issueNode.issue.Id, userContentEdit: edit}:
+ }
+ }
+ if !edits.PageInfo.HasPreviousPage {
+ break
+ }
+ edits, hasEdits = mm.queryIssueEdits(ctx, issueNode.issue.Id, edits.PageInfo.EndCursor)
+ }
+}
+
+func (mm *importMediator) queryIssueEdits(ctx context.Context, nid githubv4.ID, cursor githubv4.String) (*userContentEditConnection, bool) {
+ vars := newIssueEditVars()
+ vars["gqlNodeId"] = nid
+ if cursor == "" {
+ vars["issueEditBefore"] = (*githubv4.String)(nil)
+ } else {
+ vars["issueEditBefore"] = cursor
+ }
+ query := issueEditQuery{}
+ if err := mm.mQuery(ctx, &query, vars); err != nil {
+ mm.err = err
+ return nil, false
+ }
+ connection := &query.Node.Issue.UserContentEdits
+ if len(connection.Nodes) <= 0 {
+ return nil, false
+ }
+ return connection, true
+}
+
+func (mm *importMediator) fillTimelineEvents(ctx context.Context, issueNode *issueNode) {
+ items := &issueNode.TimelineItems
+ hasItems := true
+ for hasItems {
+ for _, item := range items.Nodes {
+ select {
+ case <-ctx.Done():
+ return
+ case mm.importEvents <- TimelineEvent{issueId: issueNode.issue.Id, timelineItem: item}:
+ }
+ if item.Typename == "IssueComment" {
+ // Issue comments are different than other timeline items in that
+ // they may have associated user content edits.
+ // Right after the comment we send the comment edits.
+ mm.fillCommentEdits(ctx, &item)
+ }
+ }
+ if !items.PageInfo.HasNextPage {
+ break
+ }
+ items, hasItems = mm.queryTimeline(ctx, issueNode.issue.Id, items.PageInfo.EndCursor)
+ }
+}
+
+func (mm *importMediator) queryTimeline(ctx context.Context, nid githubv4.ID, cursor githubv4.String) (*timelineItemsConnection, bool) {
+ vars := newTimelineVars()
+ vars["gqlNodeId"] = nid
+ if cursor == "" {
+ vars["timelineAfter"] = (*githubv4.String)(nil)
+ } else {
+ vars["timelineAfter"] = cursor
+ }
+ query := timelineQuery{}
+ if err := mm.mQuery(ctx, &query, vars); err != nil {
+ mm.err = err
+ return nil, false
+ }
+ connection := &query.Node.Issue.TimelineItems
+ if len(connection.Nodes) <= 0 {
+ return nil, false
+ }
+ return connection, true
+}
+
+func (mm *importMediator) fillCommentEdits(ctx context.Context, item *timelineItem) {
+ // Here we are only concerned with timeline items of type issueComment.
+ if item.Typename != "IssueComment" {
+ return
+ }
+ // First: setup message handling while submitting GraphQL queries.
+ comment := &item.IssueComment
+ edits := &comment.UserContentEdits
+ hasEdits := true
+ for hasEdits {
+ for edit := range reverse(edits.Nodes) {
+ if edit.Diff == nil || string(*edit.Diff) == "" {
+ // issueEdit.Diff == nil happen if the event is older than early
+ // 2018, Github doesn't have the data before that. Best we can do is
+ // to ignore the event.
+ continue
+ }
+ select {
+ case <-ctx.Done():
+ return
+ case mm.importEvents <- CommentEditEvent{commentId: comment.Id, userContentEdit: edit}:
+ }
+ }
+ if !edits.PageInfo.HasPreviousPage {
+ break
+ }
+ edits, hasEdits = mm.queryCommentEdits(ctx, comment.Id, edits.PageInfo.EndCursor)
+ }
+}
+
+func (mm *importMediator) queryCommentEdits(ctx context.Context, nid githubv4.ID, cursor githubv4.String) (*userContentEditConnection, bool) {
+ vars := newCommentEditVars()
+ vars["gqlNodeId"] = nid
+ if cursor == "" {
+ vars["commentEditBefore"] = (*githubv4.String)(nil)
+ } else {
+ vars["commentEditBefore"] = cursor
+ }
+ query := commentEditQuery{}
+ if err := mm.mQuery(ctx, &query, vars); err != nil {
+ mm.err = err
+ return nil, false
+ }
+ connection := &query.Node.IssueComment.UserContentEdits
+ if len(connection.Nodes) <= 0 {
+ return nil, false
+ }
+ return connection, true
+}
+
+func (mm *importMediator) queryIssue(ctx context.Context, cursor githubv4.String) (*issueConnection, bool) {
+ vars := newIssueVars(mm.owner, mm.project, mm.since)
+ if cursor == "" {
+ vars["issueAfter"] = (*githubv4.String)(nil)
+ } else {
+ vars["issueAfter"] = cursor
+ }
+ query := issueQuery{}
+ if err := mm.mQuery(ctx, &query, vars); err != nil {
+ mm.err = err
+ return nil, false
+ }
+ connection := &query.Repository.Issues
+ if len(connection.Nodes) <= 0 {
+ return nil, false
+ }
+ return connection, true
+}
+
+func reverse(eds []userContentEdit) chan userContentEdit {
+ ret := make(chan userContentEdit)
+ go func() {
+ for i := range eds {
+ ret <- eds[len(eds)-1-i]
+ }
+ close(ret)
+ }()
+ return ret
+}
+
+// mQuery executes a single GraphQL query. The variable query is used to derive the GraphQL query
+// and it is used to populate the response into it. It should be a pointer to a struct that
+// corresponds to the Github graphql schema and it has to implement the rateLimiter interface. If
+// there is a Github rate limiting error, then the function sleeps and retries after the rate limit
+// is expired. If there is another error, then the method will retry before giving up.
+func (mm *importMediator) mQuery(ctx context.Context, query rateLimiter, vars map[string]interface{}) error {
+ if err := mm.queryOnce(ctx, query, vars); err == nil {
+ // success: done
+ return nil
+ }
+ // failure: we will retry
+ // To retry is important for importing projects with a big number of issues, because
+ // there may be temporary network errors or momentary internal errors of the github servers.
+ retries := 3
+ var err error
+ for i := 0; i < retries; i++ {
+ // wait a few seconds before retry
+ sleepTime := time.Duration(8*(i+1)) * time.Second
+ timer := time.NewTimer(sleepTime)
+ select {
+ case <-ctx.Done():
+ stop(timer)
+ return ctx.Err()
+ case <-timer.C:
+ }
+ err = mm.queryOnce(ctx, query, vars)
+ if err == nil {
+ // success: done
+ return nil
+ }
+ }
+ return err
+}
+
+func (mm *importMediator) queryOnce(ctx context.Context, query rateLimiter, vars map[string]interface{}) error {
+ // first: just send the query to the graphql api
+ vars["dryRun"] = githubv4.Boolean(false)
+ qctx, cancel := context.WithTimeout(ctx, defaultTimeout)
+ defer cancel()
+ err := mm.gc.Query(qctx, query, vars)
+ if err == nil {
+ // no error: done
+ return nil
+ }
+ // matching the error string
+ if !strings.Contains(err.Error(), "API rate limit exceeded") {
+ // an error, but not the API rate limit error: done
+ return err
+ }
+ // a rate limit error
+ // ask the graphql api for rate limiting information
+ vars["dryRun"] = githubv4.Boolean(true)
+ qctx, cancel = context.WithTimeout(ctx, defaultTimeout)
+ defer cancel()
+ if err := mm.gc.Query(qctx, query, vars); err != nil {
+ return err
+ }
+ rateLimit := query.rateLimit()
+ if rateLimit.Cost > rateLimit.Remaining {
+ // sleep
+ resetTime := rateLimit.ResetAt.Time
+ // Add a few seconds (8) for good measure
+ resetTime = resetTime.Add(8 * time.Second)
+ msg := fmt.Sprintf("Github GraphQL API: import will sleep until %s", resetTime.String())
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case mm.importEvents <- RateLimitingEvent{msg}:
+ }
+ timer := time.NewTimer(time.Until(resetTime))
+ select {
+ case <-ctx.Done():
+ stop(timer)
+ return ctx.Err()
+ case <-timer.C:
+ }
+ }
+ // run the original query again
+ vars["dryRun"] = githubv4.Boolean(false)
+ qctx, cancel = context.WithTimeout(ctx, defaultTimeout)
+ defer cancel()
+ err = mm.gc.Query(qctx, query, vars)
+ return err // might be nil
+}
+
+func stop(t *time.Timer) {
+ if !t.Stop() {
+ select {
+ case <-t.C:
+ default:
+ }
+ }
+}
diff --git a/bridge/github/import_query.go b/bridge/github/import_query.go
index 228d204a..461daf94 100644
--- a/bridge/github/import_query.go
+++ b/bridge/github/import_query.go
@@ -2,37 +2,136 @@ package github
import "github.com/shurcooL/githubv4"
-type pageInfo struct {
- EndCursor githubv4.String
- HasNextPage bool
- StartCursor githubv4.String
- HasPreviousPage bool
+type rateLimit struct {
+ Cost githubv4.Int
+ Limit githubv4.Int
+ NodeCount githubv4.Int
+ Remaining githubv4.Int
+ ResetAt githubv4.DateTime
+ Used githubv4.Int
}
-type actor struct {
- Typename githubv4.String `graphql:"__typename"`
+type rateLimiter interface {
+ rateLimit() rateLimit
+}
+
+type userQuery struct {
+ RateLimit rateLimit `graphql:"rateLimit(dryRun: $dryRun)"`
+ User user `graphql:"user(login: $login)"`
+}
+
+func (q *userQuery) rateLimit() rateLimit {
+ return q.RateLimit
+}
+
+type labelsQuery struct {
+ //RateLimit rateLimit `graphql:"rateLimit(dryRun: $dryRun)"`
+ Repository struct {
+ Labels struct {
+ Nodes []struct {
+ ID string `graphql:"id"`
+ Name string `graphql:"name"`
+ Color string `graphql:"color"`
+ Description string `graphql:"description"`
+ }
+ PageInfo pageInfo
+ } `graphql:"labels(first: $first, after: $after)"`
+ } `graphql:"repository(owner: $owner, name: $name)"`
+}
+
+type loginQuery struct {
+ //RateLimit rateLimit `graphql:"rateLimit(dryRun: $dryRun)"`
+ Viewer struct {
+ Login string `graphql:"login"`
+ } `graphql:"viewer"`
+}
+
+type issueQuery struct {
+ RateLimit rateLimit `graphql:"rateLimit(dryRun: $dryRun)"`
+ Repository struct {
+ Issues issueConnection `graphql:"issues(first: $issueFirst, after: $issueAfter, orderBy: {field: CREATED_AT, direction: ASC}, filterBy: {since: $issueSince})"`
+ } `graphql:"repository(owner: $owner, name: $name)"`
+}
+
+func (q *issueQuery) rateLimit() rateLimit {
+ return q.RateLimit
+}
+
+type issueEditQuery struct {
+ RateLimit rateLimit `graphql:"rateLimit(dryRun: $dryRun)"`
+ Node struct {
+ Typename githubv4.String `graphql:"__typename"`
+ Issue struct {
+ UserContentEdits userContentEditConnection `graphql:"userContentEdits(last: $issueEditLast, before: $issueEditBefore)"`
+ } `graphql:"... on Issue"`
+ } `graphql:"node(id: $gqlNodeId)"`
+}
+
+func (q *issueEditQuery) rateLimit() rateLimit {
+ return q.RateLimit
+}
+
+type timelineQuery struct {
+ RateLimit rateLimit `graphql:"rateLimit(dryRun: $dryRun)"`
+ Node struct {
+ Typename githubv4.String `graphql:"__typename"`
+ Issue struct {
+ TimelineItems timelineItemsConnection `graphql:"timelineItems(first: $timelineFirst, after: $timelineAfter)"`
+ } `graphql:"... on Issue"`
+ } `graphql:"node(id: $gqlNodeId)"`
+}
+
+func (q *timelineQuery) rateLimit() rateLimit {
+ return q.RateLimit
+}
+
+type commentEditQuery struct {
+ RateLimit rateLimit `graphql:"rateLimit(dryRun: $dryRun)"`
+ Node struct {
+ Typename githubv4.String `graphql:"__typename"`
+ IssueComment struct {
+ UserContentEdits userContentEditConnection `graphql:"userContentEdits(last: $commentEditLast, before: $commentEditBefore)"`
+ } `graphql:"... on IssueComment"`
+ } `graphql:"node(id: $gqlNodeId)"`
+}
+
+func (q *commentEditQuery) rateLimit() rateLimit {
+ return q.RateLimit
+}
+
+type user struct {
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"`
+ Name *githubv4.String
}
-type actorEvent struct {
- Id githubv4.ID
- CreatedAt githubv4.DateTime
- Actor *actor
+type issueConnection struct {
+ Nodes []issueNode
+ PageInfo pageInfo
}
-type authorEvent struct {
- Id githubv4.ID
- CreatedAt githubv4.DateTime
- Author *actor
+type issueNode struct {
+ issue
+ UserContentEdits userContentEditConnection `graphql:"userContentEdits(last: $issueEditLast, before: $issueEditBefore)"`
+ TimelineItems timelineItemsConnection `graphql:"timelineItems(first: $timelineFirst, after: $timelineAfter)"`
+}
+
+type issue struct {
+ authorEvent
+ Title githubv4.String
+ Number githubv4.Int
+ Body githubv4.String
+ Url githubv4.URI
+}
+
+type timelineItemsConnection struct {
+ Nodes []timelineItem
+ PageInfo pageInfo
+}
+
+type userContentEditConnection struct {
+ Nodes []userContentEdit
+ PageInfo pageInfo
}
type userContentEdit struct {
@@ -46,12 +145,6 @@ type userContentEdit struct {
Diff *githubv4.String
}
-type issueComment struct {
- authorEvent // NOTE: contains Id
- Body githubv4.String
- Url githubv4.URI
-}
-
type timelineItem struct {
Typename githubv4.String `graphql:"__typename"`
@@ -91,84 +184,43 @@ type timelineItem struct {
} `graphql:"... on RenamedTitleEvent"`
}
-type ghostQuery struct {
- User struct {
- Login githubv4.String
- AvatarUrl githubv4.String
- Name *githubv4.String
- } `graphql:"user(login: $login)"`
-}
-
-type labelsQuery struct {
- Repository struct {
- Labels struct {
- Nodes []struct {
- ID string `graphql:"id"`
- Name string `graphql:"name"`
- Color string `graphql:"color"`
- Description string `graphql:"description"`
- }
- PageInfo pageInfo
- } `graphql:"labels(first: $first, after: $after)"`
- } `graphql:"repository(owner: $owner, name: $name)"`
-}
-
-type loginQuery struct {
- Viewer struct {
- Login string `graphql:"login"`
- } `graphql:"viewer"`
-}
+type issueComment struct {
+ authorEvent // NOTE: contains Id
+ Body githubv4.String
+ Url githubv4.URI
-type issueQuery struct {
- Repository struct {
- Issues struct {
- Nodes []issue
- PageInfo pageInfo
- } `graphql:"issues(first: $issueFirst, after: $issueAfter, orderBy: {field: CREATED_AT, direction: ASC}, filterBy: {since: $issueSince})"`
- } `graphql:"repository(owner: $owner, name: $name)"`
+ UserContentEdits userContentEditConnection `graphql:"userContentEdits(last: $commentEditLast, before: $commentEditBefore)"`
}
-type issue struct {
- authorEvent
- Title string
- Number githubv4.Int
- Body githubv4.String
- Url githubv4.URI
+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 issueEditQuery struct {
- Node struct {
- Typename githubv4.String `graphql:"__typename"`
- Issue struct {
- UserContentEdits struct {
- Nodes []userContentEdit
- TotalCount githubv4.Int
- PageInfo pageInfo
- } `graphql:"userContentEdits(last: $issueEditLast, before: $issueEditBefore)"`
- } `graphql:"... on Issue"`
- } `graphql:"node(id: $gqlNodeId)"`
+type actorEvent struct {
+ Id githubv4.ID
+ CreatedAt githubv4.DateTime
+ Actor *actor
}
-type timelineQuery struct {
- Node struct {
- Typename githubv4.String `graphql:"__typename"`
- Issue struct {
- TimelineItems struct {
- Nodes []timelineItem
- PageInfo pageInfo
- } `graphql:"timelineItems(first: $timelineFirst, after: $timelineAfter)"`
- } `graphql:"... on Issue"`
- } `graphql:"node(id: $gqlNodeId)"`
+type authorEvent struct {
+ Id githubv4.ID
+ CreatedAt githubv4.DateTime
+ Author *actor
}
-type commentEditQuery struct {
- Node struct {
- Typename githubv4.String `graphql:"__typename"`
- IssueComment struct {
- UserContentEdits struct {
- Nodes []userContentEdit
- PageInfo pageInfo
- } `graphql:"userContentEdits(last: $commentEditLast, before: $commentEditBefore)"`
- } `graphql:"... on IssueComment"`
- } `graphql:"node(id: $gqlNodeId)"`
+type pageInfo struct {
+ EndCursor githubv4.String
+ HasNextPage bool
+ StartCursor githubv4.String
+ HasPreviousPage bool
}
diff --git a/bridge/github/iterator.go b/bridge/github/iterator.go
deleted file mode 100644
index d21faae8..00000000
--- a/bridge/github/iterator.go
+++ /dev/null
@@ -1,423 +0,0 @@
-package github
-
-import (
- "context"
- "time"
-
- "github.com/pkg/errors"
- "github.com/shurcooL/githubv4"
-)
-
-type iterator struct {
- // Github graphql client
- gc *githubv4.Client
-
- // The iterator will only query issues updated or created after the date given in
- // the variable since.
- since time.Time
-
- // Shared context, which is used for all graphql queries.
- ctx context.Context
-
- // Sticky error
- err error
-
- // Issue iterator
- issueIter issueIter
-}
-
-type issueIter struct {
- iterVars
- query issueQuery
- issueEditIter []issueEditIter
- timelineIter []timelineIter
-}
-
-type issueEditIter struct {
- iterVars
- query issueEditQuery
-}
-
-type timelineIter struct {
- iterVars
- query timelineQuery
- commentEditIter []commentEditIter
-}
-
-type commentEditIter struct {
- iterVars
- query commentEditQuery
-}
-
-type iterVars struct {
- // Iterator index
- index int
-
- // capacity is the number of elements (issues, issue edits, timeline items, or
- // comment edits) to query at a time. More capacity = more used memory =
- // less queries to make.
- capacity int
-
- // Variable assignments for graphql query
- variables varmap
-}
-
-type varmap map[string]interface{}
-
-func newIterVars(capacity int) iterVars {
- return iterVars{
- index: -1,
- capacity: capacity,
- variables: varmap{},
- }
-}
-
-// NewIterator creates and initialize a new iterator.
-func NewIterator(ctx context.Context, client *githubv4.Client, capacity int, owner, project string, since time.Time) *iterator {
- i := &iterator{
- gc: client,
- since: since,
- ctx: ctx,
- issueIter: issueIter{
- iterVars: newIterVars(capacity),
- timelineIter: make([]timelineIter, capacity),
- issueEditIter: make([]issueEditIter, capacity),
- },
- }
- i.issueIter.variables.setOwnerProject(owner, project)
- for idx := range i.issueIter.issueEditIter {
- ie := &i.issueIter.issueEditIter[idx]
- ie.iterVars = newIterVars(capacity)
- }
- for i1 := range i.issueIter.timelineIter {
- tli := &i.issueIter.timelineIter[i1]
- tli.iterVars = newIterVars(capacity)
- tli.commentEditIter = make([]commentEditIter, capacity)
- for i2 := range tli.commentEditIter {
- cei := &tli.commentEditIter[i2]
- cei.iterVars = newIterVars(capacity)
- }
- }
- i.resetIssueVars()
- return i
-}
-
-func (v *varmap) setOwnerProject(owner, project string) {
- (*v)["owner"] = githubv4.String(owner)
- (*v)["name"] = githubv4.String(project)
-}
-
-func (i *iterator) resetIssueVars() {
- vars := &i.issueIter.variables
- (*vars)["issueFirst"] = githubv4.Int(i.issueIter.capacity)
- (*vars)["issueAfter"] = (*githubv4.String)(nil)
- (*vars)["issueSince"] = githubv4.DateTime{Time: i.since}
- i.issueIter.query.Repository.Issues.PageInfo.HasNextPage = true
- i.issueIter.query.Repository.Issues.PageInfo.EndCursor = ""
-}
-
-func (i *iterator) resetIssueEditVars() {
- for idx := range i.issueIter.issueEditIter {
- ie := &i.issueIter.issueEditIter[idx]
- ie.variables["issueEditLast"] = githubv4.Int(ie.capacity)
- ie.variables["issueEditBefore"] = (*githubv4.String)(nil)
- ie.query.Node.Issue.UserContentEdits.PageInfo.HasNextPage = true
- ie.query.Node.Issue.UserContentEdits.PageInfo.EndCursor = ""
- }
-}
-
-func (i *iterator) resetTimelineVars() {
- for idx := range i.issueIter.timelineIter {
- ip := &i.issueIter.timelineIter[idx]
- ip.variables["timelineFirst"] = githubv4.Int(ip.capacity)
- ip.variables["timelineAfter"] = (*githubv4.String)(nil)
- ip.query.Node.Issue.TimelineItems.PageInfo.HasNextPage = true
- ip.query.Node.Issue.TimelineItems.PageInfo.EndCursor = ""
- }
-}
-
-func (i *iterator) resetCommentEditVars() {
- for i1 := range i.issueIter.timelineIter {
- for i2 := range i.issueIter.timelineIter[i1].commentEditIter {
- ce := &i.issueIter.timelineIter[i1].commentEditIter[i2]
- ce.variables["commentEditLast"] = githubv4.Int(ce.capacity)
- ce.variables["commentEditBefore"] = (*githubv4.String)(nil)
- ce.query.Node.IssueComment.UserContentEdits.PageInfo.HasNextPage = true
- ce.query.Node.IssueComment.UserContentEdits.PageInfo.EndCursor = ""
- }
- }
-}
-
-// Error return last encountered error
-func (i *iterator) Error() error {
- if i.err != nil {
- return i.err
- }
- return i.ctx.Err() // might return nil
-}
-
-func (i *iterator) HasError() bool {
- return i.err != nil || i.ctx.Err() != nil
-}
-
-func (i *iterator) currIssueItem() *issue {
- return &i.issueIter.query.Repository.Issues.Nodes[i.issueIter.index]
-}
-
-func (i *iterator) currIssueEditIter() *issueEditIter {
- return &i.issueIter.issueEditIter[i.issueIter.index]
-}
-
-func (i *iterator) currTimelineIter() *timelineIter {
- return &i.issueIter.timelineIter[i.issueIter.index]
-}
-
-func (i *iterator) currCommentEditIter() *commentEditIter {
- timelineIter := i.currTimelineIter()
- return &timelineIter.commentEditIter[timelineIter.index]
-}
-
-func (i *iterator) currIssueGqlNodeId() githubv4.ID {
- return i.currIssueItem().Id
-}
-
-// NextIssue returns true if there exists a next issue and advances the iterator by one.
-// It is used to iterate over all issues. Queries to github are made when necessary.
-func (i *iterator) NextIssue() bool {
- if i.HasError() {
- return false
- }
- index := &i.issueIter.index
- issues := &i.issueIter.query.Repository.Issues
- issueItems := &issues.Nodes
- if 0 <= *index && *index < len(*issueItems)-1 {
- *index += 1
- return true
- }
-
- if !issues.PageInfo.HasNextPage {
- return false
- }
- nextIssue := i.queryIssue()
- return nextIssue
-}
-
-// IssueValue returns the actual issue value.
-func (i *iterator) IssueValue() issue {
- return *i.currIssueItem()
-}
-
-func (i *iterator) queryIssue() bool {
- ctx, cancel := context.WithTimeout(i.ctx, defaultTimeout)
- defer cancel()
- if endCursor := i.issueIter.query.Repository.Issues.PageInfo.EndCursor; endCursor != "" {
- i.issueIter.variables["issueAfter"] = endCursor
- }
- if err := i.gc.Query(ctx, &i.issueIter.query, i.issueIter.variables); err != nil {
- i.err = err
- return false
- }
- i.resetIssueEditVars()
- i.resetTimelineVars()
- issueItems := &i.issueIter.query.Repository.Issues.Nodes
- if len(*issueItems) <= 0 {
- i.issueIter.index = -1
- return false
- }
- i.issueIter.index = 0
- return true
-}
-
-// NextIssueEdit returns true if there exists a next issue edit and advances the iterator
-// by one. It is used to iterate over all the issue edits. Queries to github are made when
-// necessary.
-func (i *iterator) NextIssueEdit() bool {
- if i.HasError() {
- return false
- }
- ieIter := i.currIssueEditIter()
- ieIdx := &ieIter.index
- ieItems := ieIter.query.Node.Issue.UserContentEdits
- if 0 <= *ieIdx && *ieIdx < len(ieItems.Nodes)-1 {
- *ieIdx += 1
- return i.nextValidIssueEdit()
- }
- if !ieItems.PageInfo.HasNextPage {
- return false
- }
- querySucc := i.queryIssueEdit()
- if !querySucc {
- return false
- }
- return i.nextValidIssueEdit()
-}
-
-func (i *iterator) nextValidIssueEdit() bool {
- // issueEdit.Diff == nil happen if the event is older than early 2018, Github doesn't have
- // the data before that. Best we can do is to ignore the event.
- if issueEdit := i.IssueEditValue(); issueEdit.Diff == nil || string(*issueEdit.Diff) == "" {
- return i.NextIssueEdit()
- }
- return true
-}
-
-// IssueEditValue returns the actual issue edit value.
-func (i *iterator) IssueEditValue() userContentEdit {
- iei := i.currIssueEditIter()
- return iei.query.Node.Issue.UserContentEdits.Nodes[iei.index]
-}
-
-func (i *iterator) queryIssueEdit() bool {
- ctx, cancel := context.WithTimeout(i.ctx, defaultTimeout)
- defer cancel()
- iei := i.currIssueEditIter()
- if endCursor := iei.query.Node.Issue.UserContentEdits.PageInfo.EndCursor; endCursor != "" {
- iei.variables["issueEditBefore"] = endCursor
- }
- iei.variables["gqlNodeId"] = i.currIssueGqlNodeId()
- if err := i.gc.Query(ctx, &iei.query, iei.variables); err != nil {
- i.err = err
- return false
- }
- issueEditItems := iei.query.Node.Issue.UserContentEdits.Nodes
- if len(issueEditItems) <= 0 {
- iei.index = -1
- return false
- }
- // The UserContentEditConnection in the Github API serves its elements in reverse chronological
- // order. For our purpose we have to reverse the edits.
- reverseEdits(issueEditItems)
- iei.index = 0
- return true
-}
-
-// NextTimelineItem returns true if there exists a next timeline item and advances the iterator
-// by one. It is used to iterate over all the timeline items. Queries to github are made when
-// necessary.
-func (i *iterator) NextTimelineItem() bool {
- if i.HasError() {
- return false
- }
- tlIter := &i.issueIter.timelineIter[i.issueIter.index]
- tlIdx := &tlIter.index
- tlItems := tlIter.query.Node.Issue.TimelineItems
- if 0 <= *tlIdx && *tlIdx < len(tlItems.Nodes)-1 {
- *tlIdx += 1
- return true
- }
- if !tlItems.PageInfo.HasNextPage {
- return false
- }
- nextTlItem := i.queryTimeline()
- return nextTlItem
-}
-
-// TimelineItemValue returns the actual timeline item value.
-func (i *iterator) TimelineItemValue() timelineItem {
- tli := i.currTimelineIter()
- return tli.query.Node.Issue.TimelineItems.Nodes[tli.index]
-}
-
-func (i *iterator) queryTimeline() bool {
- ctx, cancel := context.WithTimeout(i.ctx, defaultTimeout)
- defer cancel()
- tli := i.currTimelineIter()
- if endCursor := tli.query.Node.Issue.TimelineItems.PageInfo.EndCursor; endCursor != "" {
- tli.variables["timelineAfter"] = endCursor
- }
- tli.variables["gqlNodeId"] = i.currIssueGqlNodeId()
- if err := i.gc.Query(ctx, &tli.query, tli.variables); err != nil {
- i.err = err
- return false
- }
- i.resetCommentEditVars()
- timelineItems := &tli.query.Node.Issue.TimelineItems
- if len(timelineItems.Nodes) <= 0 {
- tli.index = -1
- return false
- }
- tli.index = 0
- return true
-}
-
-// NextCommentEdit returns true if there exists a next comment edit and advances the iterator
-// by one. It is used to iterate over all issue edits. Queries to github are made when
-// necessary.
-func (i *iterator) NextCommentEdit() bool {
- if i.HasError() {
- return false
- }
-
- tmlnVal := i.TimelineItemValue()
- if tmlnVal.Typename != "IssueComment" {
- // The timeline iterator does not point to a comment.
- i.err = errors.New("Call to NextCommentEdit() while timeline item is not a comment")
- return false
- }
-
- cei := i.currCommentEditIter()
- ceIdx := &cei.index
- ceItems := &cei.query.Node.IssueComment.UserContentEdits
- if 0 <= *ceIdx && *ceIdx < len(ceItems.Nodes)-1 {
- *ceIdx += 1
- return i.nextValidCommentEdit()
- }
- if !ceItems.PageInfo.HasNextPage {
- return false
- }
- querySucc := i.queryCommentEdit()
- if !querySucc {
- return false
- }
- return i.nextValidCommentEdit()
-}
-
-func (i *iterator) nextValidCommentEdit() bool {
- // if comment edit diff is a nil pointer or points to an empty string look for next value
- if commentEdit := i.CommentEditValue(); commentEdit.Diff == nil || string(*commentEdit.Diff) == "" {
- return i.NextCommentEdit()
- }
- return true
-}
-
-// CommentEditValue returns the actual comment edit value.
-func (i *iterator) CommentEditValue() userContentEdit {
- cei := i.currCommentEditIter()
- return cei.query.Node.IssueComment.UserContentEdits.Nodes[cei.index]
-}
-
-func (i *iterator) queryCommentEdit() bool {
- ctx, cancel := context.WithTimeout(i.ctx, defaultTimeout)
- defer cancel()
- cei := i.currCommentEditIter()
-
- if endCursor := cei.query.Node.IssueComment.UserContentEdits.PageInfo.EndCursor; endCursor != "" {
- cei.variables["commentEditBefore"] = endCursor
- }
- tmlnVal := i.TimelineItemValue()
- if tmlnVal.Typename != "IssueComment" {
- i.err = errors.New("Call to queryCommentEdit() while timeline item is not a comment")
- return false
- }
- cei.variables["gqlNodeId"] = tmlnVal.IssueComment.Id
- if err := i.gc.Query(ctx, &cei.query, cei.variables); err != nil {
- i.err = err
- return false
- }
- ceItems := cei.query.Node.IssueComment.UserContentEdits.Nodes
- if len(ceItems) <= 0 {
- cei.index = -1
- return false
- }
- // The UserContentEditConnection in the Github API serves its elements in reverse chronological
- // order. For our purpose we have to reverse the edits.
- reverseEdits(ceItems)
- cei.index = 0
- return true
-}
-
-func reverseEdits(edits []userContentEdit) {
- for i, j := 0, len(edits)-1; i < j; i, j = i+1, j-1 {
- edits[i], edits[j] = edits[j], edits[i]
- }
-}
diff --git a/bug/operation.go b/bug/operation.go
index 8daa2cde..b3610381 100644
--- a/bug/operation.go
+++ b/bug/operation.go
@@ -113,6 +113,8 @@ func operationUnmarshaller(author identity.Interface, raw json.RawMessage) (dag.
op.Author_ = author
case *CreateOperation:
op.Author_ = author
+ case *EditCommentOperation:
+ op.Author_ = author
case *LabelChangeOperation:
op.Author_ = author
case *NoOpOperation:
diff --git a/cache/repo_cache.go b/cache/repo_cache.go
index 58022bda..14d5f3db 100644
--- a/cache/repo_cache.go
+++ b/cache/repo_cache.go
@@ -195,7 +195,7 @@ func (c *RepoCache) buildCache() error {
c.bugExcerpts = make(map[entity.Id]*BugExcerpt)
- allBugs := bug.ReadAll(c.repo)
+ allBugs := bug.ReadAllWithResolver(c.repo, newIdentityCacheResolverNoLock(c))
// wipe the index just to be sure
err := c.repo.ClearBleveIndex("bug")
diff --git a/cache/repo_cache_test.go b/cache/repo_cache_test.go
index fab8fff0..d13fa026 100644
--- a/cache/repo_cache_test.go
+++ b/cache/repo_cache_test.go
@@ -86,11 +86,12 @@ func TestCache(t *testing.T) {
require.Empty(t, cache.identities)
require.Empty(t, cache.identitiesExcerpts)
- // Reload, only excerpt are loaded
+ // Reload, only excerpt are loaded, but as we need to load the identities used in the bugs
+ // to check the signatures, we also load the identity used above
cache, err = NewRepoCache(repo)
require.NoError(t, err)
require.Empty(t, cache.bugs)
- require.Empty(t, cache.identities)
+ require.Len(t, cache.identities, 1)
require.Len(t, cache.bugExcerpts, 2)
require.Len(t, cache.identitiesExcerpts, 2)
diff --git a/cache/resolvers.go b/cache/resolvers.go
index 36b70d3b..e53c3660 100644
--- a/cache/resolvers.go
+++ b/cache/resolvers.go
@@ -20,3 +20,32 @@ func newIdentityCacheResolver(cache *RepoCache) *identityCacheResolver {
func (i *identityCacheResolver) ResolveIdentity(id entity.Id) (identity.Interface, error) {
return i.cache.ResolveIdentity(id)
}
+
+var _ identity.Resolver = &identityCacheResolverNoLock{}
+
+// identityCacheResolverNoLock is an identity Resolver that retrieve identities from
+// the cache, without locking it.
+type identityCacheResolverNoLock struct {
+ cache *RepoCache
+}
+
+func newIdentityCacheResolverNoLock(cache *RepoCache) *identityCacheResolverNoLock {
+ return &identityCacheResolverNoLock{cache: cache}
+}
+
+func (ir *identityCacheResolverNoLock) ResolveIdentity(id entity.Id) (identity.Interface, error) {
+ cached, ok := ir.cache.identities[id]
+ if ok {
+ return cached, nil
+ }
+
+ i, err := identity.ReadLocal(ir.cache.repo, id)
+ if err != nil {
+ return nil, err
+ }
+
+ cached = NewIdentityCache(ir.cache, i)
+ ir.cache.identities[id] = cached
+
+ return cached, nil
+}
diff --git a/entity/dag/clock.go b/entity/dag/clock.go
index dc9bb72d..793fa1bf 100644
--- a/entity/dag/clock.go
+++ b/entity/dag/clock.go
@@ -9,7 +9,7 @@ import (
// ClockLoader is the repository.ClockLoader for Entity
func ClockLoader(defs ...Definition) repository.ClockLoader {
- clocks := make([]string, len(defs)*2)
+ clocks := make([]string, 0, len(defs)*2)
for _, def := range defs {
clocks = append(clocks, fmt.Sprintf(creationClockPattern, def.Namespace))
clocks = append(clocks, fmt.Sprintf(editClockPattern, def.Namespace))
@@ -18,8 +18,9 @@ func ClockLoader(defs ...Definition) repository.ClockLoader {
return repository.ClockLoader{
Clocks: clocks,
Witnesser: func(repo repository.ClockedRepo) error {
- // We don't care about the actual identity so an IdentityStub will do
- resolver := identity.NewStubResolver()
+ // we need to actually load the identities because of the commit signature check when reading,
+ // which require the full identities with crypto keys
+ resolver := identity.NewCachedResolver(identity.NewSimpleResolver(repo))
for _, def := range defs {
// we actually just need to read all entities,
diff --git a/identity/resolver.go b/identity/resolver.go
index ab380a12..8e066e9d 100644
--- a/identity/resolver.go
+++ b/identity/resolver.go
@@ -1,6 +1,8 @@
package identity
import (
+ "sync"
+
"github.com/MichaelMure/git-bug/entity"
"github.com/MichaelMure/git-bug/repository"
)
@@ -34,3 +36,36 @@ func NewStubResolver() *StubResolver {
func (s *StubResolver) ResolveIdentity(id entity.Id) (Interface, error) {
return &IdentityStub{id: id}, nil
}
+
+// CachedResolver is a resolver ensuring that loading is done only once through another Resolver.
+type CachedResolver struct {
+ mu sync.RWMutex
+ resolver Resolver
+ identities map[entity.Id]Interface
+}
+
+func NewCachedResolver(resolver Resolver) *CachedResolver {
+ return &CachedResolver{
+ resolver: resolver,
+ identities: make(map[entity.Id]Interface),
+ }
+}
+
+func (c *CachedResolver) ResolveIdentity(id entity.Id) (Interface, error) {
+ c.mu.RLock()
+ if i, ok := c.identities[id]; ok {
+ c.mu.RUnlock()
+ return i, nil
+ }
+ c.mu.RUnlock()
+
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ i, err := c.resolver.ResolveIdentity(id)
+ if err != nil {
+ return nil, err
+ }
+ c.identities[id] = i
+ return i, nil
+}
diff --git a/repository/gogit.go b/repository/gogit.go
index 248c34d5..20454bd7 100644
--- a/repository/gogit.go
+++ b/repository/gogit.go
@@ -335,7 +335,7 @@ func (repo *GoGitRepo) ClearBleveIndex(name string) error {
repo.indexesMutex.Lock()
defer repo.indexesMutex.Unlock()
- path := filepath.Join(repo.path, "indexes", name)
+ path := filepath.Join(repo.path, "git-bug", "indexes", name)
err := os.RemoveAll(path)
if err != nil {
diff --git a/webui/.eslintrc.js b/webui/.eslintrc.js
index 2dfa7543..125fe801 100644
--- a/webui/.eslintrc.js
+++ b/webui/.eslintrc.js
@@ -38,4 +38,5 @@ module.exports = {
settings: {
'import/internal-regex': '^src/',
},
+ ignorePatterns: ['**/*.generated.tsx'],
};
diff --git a/webui/src/components/BackToListButton.tsx b/webui/src/components/BackToListButton.tsx
index 7ca53ad0..41e1d68a 100644
--- a/webui/src/components/BackToListButton.tsx
+++ b/webui/src/components/BackToListButton.tsx
@@ -1,4 +1,5 @@
import React from 'react';
+import { Link } from 'react-router-dom';
import Button from '@material-ui/core/Button';
import { makeStyles } from '@material-ui/core/styles';
@@ -25,7 +26,8 @@ function BackToListButton() {
variant="contained"
className={classes.backButton}
aria-label="back to issue list"
- href="/"
+ component={Link}
+ to="/"
>
<ArrowBackIcon />
Back to List
diff --git a/webui/src/components/BugTitleForm/BugTitleForm.tsx b/webui/src/components/BugTitleForm/BugTitleForm.tsx
index a7d5a820..665ecd4c 100644
--- a/webui/src/components/BugTitleForm/BugTitleForm.tsx
+++ b/webui/src/components/BugTitleForm/BugTitleForm.tsx
@@ -1,4 +1,5 @@
import React, { useState } from 'react';
+import { Link } from 'react-router-dom';
import { Button, makeStyles, Typography } from '@material-ui/core';
@@ -78,6 +79,10 @@ function BugTitleForm({ bug }: Props) {
function submitNewTitle() {
if (!isFormValid()) return;
+ if (bug.title === issueTitleInput.value) {
+ cancelChange();
+ return;
+ }
setTitle({
variables: {
input: {
@@ -106,7 +111,7 @@ function BugTitleForm({ bug }: Props) {
function editableBugTitle() {
return (
- <form className={classes.headerTitle} onSubmit={submitNewTitle}>
+ <form className={classes.headerTitle}>
<BugTitleInput
inputRef={(node) => {
issueTitleInput = node;
@@ -123,7 +128,7 @@ function BugTitleForm({ bug }: Props) {
className={classes.saveButton}
size="small"
variant="contained"
- type="submit"
+ onClick={() => submitNewTitle()}
disabled={issueTitle.length === 0}
>
Save
@@ -157,7 +162,8 @@ function BugTitleForm({ bug }: Props) {
className={classes.greenButton}
size="small"
variant="contained"
- href="/new"
+ component={Link}
+ to="/new"
>
New bug
</Button>
diff --git a/webui/src/components/Header/Header.tsx b/webui/src/components/Header/Header.tsx
index 3064f6e4..63146cc9 100644
--- a/webui/src/components/Header/Header.tsx
+++ b/webui/src/components/Header/Header.tsx
@@ -67,14 +67,14 @@ const DisabledTabWithTooltip = (props: TabProps) => {
function Header() {
const classes = useStyles();
const location = useLocation();
- const [selectedTab, setTab] = React.useState(location.pathname);
- const handleTabClick = (
- event: React.ChangeEvent<{}>,
- newTabValue: string
- ) => {
- setTab(newTabValue);
- };
+ // Prevents error of invalid tab selection in <Tabs>
+ // Will return a valid tab path or false if path is unkown.
+ function highlightTab() {
+ const validTabs = ['/', '/code', '/pulls', '/settings'];
+ const tab = validTabs.find((tabPath) => tabPath === location.pathname);
+ return tab === undefined ? false : tab;
+ }
return (
<>
@@ -92,12 +92,7 @@ function Header() {
</Toolbar>
</AppBar>
<div className={classes.offset} />
- <Tabs
- centered
- value={selectedTab}
- onChange={handleTabClick}
- aria-label="nav tabs"
- >
+ <Tabs centered value={highlightTab()} aria-label="nav tabs">
<DisabledTabWithTooltip label="Code" value="/code" {...a11yProps(1)} />
<Tab label="Bugs" value="/" component={Link} to="/" {...a11yProps(2)} />
<DisabledTabWithTooltip
diff --git a/webui/src/components/Label.tsx b/webui/src/components/Label.tsx
index 111f6d7f..a1d3c6f9 100644
--- a/webui/src/components/Label.tsx
+++ b/webui/src/components/Label.tsx
@@ -1,56 +1,47 @@
import React from 'react';
+import { Chip } from '@material-ui/core';
import { common } from '@material-ui/core/colors';
-import { makeStyles } from '@material-ui/core/styles';
import {
- getContrastRatio,
darken,
+ getContrastRatio,
} from '@material-ui/core/styles/colorManipulator';
+import { Color } from '../gqlTypes';
import { LabelFragment } from '../graphql/fragments.generated';
-import { Color } from 'src/gqlTypes';
+
+const _rgb = (color: Color) =>
+ 'rgb(' + color.R + ',' + color.G + ',' + color.B + ')';
// Minimum contrast between the background and the text color
const contrastThreshold = 2.5;
-
// Guess the text color based on the background color
const getTextColor = (background: string) =>
getContrastRatio(background, common.white) >= contrastThreshold
? common.white // White on dark backgrounds
: common.black; // And black on light ones
-const _rgb = (color: Color) =>
- 'rgb(' + color.R + ',' + color.G + ',' + color.B + ')';
-
// Create a style object from the label RGB colors
-const createStyle = (color: Color) => ({
+const createStyle = (color: Color, maxWidth?: string) => ({
backgroundColor: _rgb(color),
color: getTextColor(_rgb(color)),
borderBottomColor: darken(_rgb(color), 0.2),
+ maxWidth: maxWidth,
});
-const useStyles = makeStyles((theme) => ({
- label: {
- ...theme.typography.body1,
- padding: '1px 6px 0.5px',
- fontSize: '0.9em',
- fontWeight: 500,
- margin: '0.05em 1px calc(-1.5px + 0.05em)',
- borderRadius: '3px',
- display: 'inline-block',
- borderBottom: 'solid 1.5px',
- verticalAlign: 'bottom',
- },
-}));
-
-type Props = { label: LabelFragment };
-function Label({ label }: Props) {
- const classes = useStyles();
+type Props = {
+ label: LabelFragment;
+ maxWidth?: string;
+ className?: string;
+};
+function Label({ label, maxWidth, className }: Props) {
return (
- <span className={classes.label} style={createStyle(label.color)}>
- {label.name}
- </span>
+ <Chip
+ size={'small'}
+ label={label.name}
+ className={className}
+ style={createStyle(label.color, maxWidth)}
+ />
);
}
-
export default Label;
diff --git a/webui/src/pages/bug/Bug.tsx b/webui/src/pages/bug/Bug.tsx
index 25281f96..b32b0948 100644
--- a/webui/src/pages/bug/Bug.tsx
+++ b/webui/src/pages/bug/Bug.tsx
@@ -9,6 +9,7 @@ import Label from 'src/components/Label';
import { BugFragment } from './Bug.generated';
import CommentForm from './CommentForm';
import TimelineQuery from './TimelineQuery';
+import LabelMenu from './labels/LabelMenu';
/**
* Css in JS Styles
@@ -53,13 +54,15 @@ const useStyles = makeStyles((theme) => ({
listStyle: 'none',
padding: 0,
margin: 0,
+ display: 'flex',
+ flexDirection: 'row',
+ flexWrap: 'wrap',
},
label: {
- marginTop: theme.spacing(1),
- marginBottom: theme.spacing(1),
- '& > *': {
- display: 'block',
- },
+ marginTop: theme.spacing(0.1),
+ marginBottom: theme.spacing(0.1),
+ marginLeft: theme.spacing(0.25),
+ marginRight: theme.spacing(0.25),
},
noLabel: {
...theme.typography.body2,
@@ -94,14 +97,16 @@ function Bug({ bug }: Props) {
</IfLoggedIn>
</div>
<div className={classes.rightSidebar}>
- <span className={classes.rightSidebarTitle}>Labels</span>
+ <span className={classes.rightSidebarTitle}>
+ <LabelMenu bug={bug} />
+ </span>
<ul className={classes.labelList}>
{bug.labels.length === 0 && (
<span className={classes.noLabel}>None yet</span>
)}
{bug.labels.map((l) => (
<li className={classes.label} key={l.name}>
- <Label label={l} key={l.name} />
+ <Label label={l} key={l.name} maxWidth="25ch" />
</li>
))}
</ul>
diff --git a/webui/src/pages/bug/LabelChange.tsx b/webui/src/pages/bug/LabelChange.tsx
index c40636c1..712c33fa 100644
--- a/webui/src/pages/bug/LabelChange.tsx
+++ b/webui/src/pages/bug/LabelChange.tsx
@@ -16,6 +16,11 @@ const useStyles = makeStyles((theme) => ({
author: {
fontWeight: 'bold',
},
+ label: {
+ maxWidth: '50ch',
+ marginLeft: theme.spacing(0.25),
+ marginRight: theme.spacing(0.25),
+ },
}));
type Props = {
@@ -30,12 +35,12 @@ function LabelChange({ op }: Props) {
<Author author={op.author} className={classes.author} />
{added.length > 0 && <span> added the </span>}
{added.map((label, index) => (
- <Label key={index} label={label} />
+ <Label key={index} label={label} className={classes.label} />
))}
{added.length > 0 && removed.length > 0 && <span> and</span>}
{removed.length > 0 && <span> removed the </span>}
{removed.map((label, index) => (
- <Label key={index} label={label} />
+ <Label key={index} label={label} className={classes.label} />
))}
<span>
{' '}
diff --git a/webui/src/pages/bug/labels/LabelMenu.tsx b/webui/src/pages/bug/labels/LabelMenu.tsx
new file mode 100644
index 00000000..645f472c
--- /dev/null
+++ b/webui/src/pages/bug/labels/LabelMenu.tsx
@@ -0,0 +1,309 @@
+import React, { useEffect, useRef, useState } from 'react';
+
+import { IconButton } from '@material-ui/core';
+import Menu from '@material-ui/core/Menu';
+import MenuItem from '@material-ui/core/MenuItem';
+import TextField from '@material-ui/core/TextField';
+import { makeStyles, withStyles } from '@material-ui/core/styles';
+import { darken } from '@material-ui/core/styles/colorManipulator';
+import CheckIcon from '@material-ui/icons/Check';
+import SettingsIcon from '@material-ui/icons/Settings';
+
+import { Color } from '../../../gqlTypes';
+import {
+ ListLabelsDocument,
+ useListLabelsQuery,
+} from '../../list/ListLabels.generated';
+import { BugFragment } from '../Bug.generated';
+import { GetBugDocument } from '../BugQuery.generated';
+
+import { useSetLabelMutation } from './SetLabel.generated';
+
+type DropdownTuple = [string, string, Color];
+
+type FilterDropdownProps = {
+ children: React.ReactNode;
+ dropdown: DropdownTuple[];
+ hasFilter?: boolean;
+ itemActive: (key: string) => boolean;
+ onClose: () => void;
+ toggleLabel: (key: string, active: boolean) => void;
+ onNewItem: (name: string) => void;
+} & React.ButtonHTMLAttributes<HTMLButtonElement>;
+
+const CustomTextField = withStyles((theme) => ({
+ root: {
+ margin: '0 8px 12px 8px',
+ '& label.Mui-focused': {
+ margin: '0 2px',
+ color: theme.palette.text.secondary,
+ },
+ '& .MuiInput-underline::before': {
+ borderBottomColor: theme.palette.divider,
+ },
+ '& .MuiInput-underline::after': {
+ borderBottomColor: theme.palette.divider,
+ },
+ },
+}))(TextField);
+
+const ITEM_HEIGHT = 48;
+
+const useStyles = makeStyles((theme) => ({
+ gearBtn: {
+ ...theme.typography.body2,
+ color: theme.palette.text.secondary,
+ padding: theme.spacing(0, 1),
+ fontWeight: 400,
+ textDecoration: 'none',
+ display: 'flex',
+ background: 'none',
+ border: 'none',
+ '&:hover': {
+ backgroundColor: 'transparent',
+ color: theme.palette.text.primary,
+ },
+ },
+ menu: {
+ '& .MuiMenu-paper': {
+ //somehow using "width" won't override the default width...
+ minWidth: '35ch',
+ },
+ },
+ labelcolor: {
+ minWidth: '0.5rem',
+ display: 'flex',
+ borderRadius: '0.25rem',
+ marginRight: '5px',
+ marginLeft: '3px',
+ },
+ labelsheader: {
+ display: 'flex',
+ flexDirection: 'row',
+ },
+ menuRow: {
+ display: 'flex',
+ alignItems: 'initial',
+ },
+}));
+
+const _rgb = (color: Color) =>
+ 'rgb(' + color.R + ',' + color.G + ',' + color.B + ')';
+
+// Create a style object from the label RGB colors
+const createStyle = (color: Color) => ({
+ backgroundColor: _rgb(color),
+ borderBottomColor: darken(_rgb(color), 0.2),
+});
+
+function FilterDropdown({
+ children,
+ dropdown,
+ hasFilter,
+ itemActive,
+ onClose,
+ toggleLabel,
+ onNewItem,
+}: FilterDropdownProps) {
+ const [open, setOpen] = useState(false);
+ const [filter, setFilter] = useState<string>('');
+ const buttonRef = useRef<HTMLButtonElement>(null);
+ const searchRef = useRef<HTMLButtonElement>(null);
+ const classes = useStyles({ active: false });
+
+ useEffect(() => {
+ searchRef && searchRef.current && searchRef.current.focus();
+ }, [filter]);
+
+ return (
+ <>
+ <div className={classes.labelsheader}>
+ Labels
+ <IconButton
+ ref={buttonRef}
+ onClick={() => setOpen(!open)}
+ className={classes.gearBtn}
+ disableRipple
+ >
+ <SettingsIcon fontSize={'small'} />
+ </IconButton>
+ </div>
+
+ <Menu
+ className={classes.menu}
+ getContentAnchorEl={null}
+ ref={searchRef}
+ anchorOrigin={{
+ vertical: 'bottom',
+ horizontal: 'left',
+ }}
+ transformOrigin={{
+ vertical: 'top',
+ horizontal: 'left',
+ }}
+ open={open}
+ onClose={() => {
+ setOpen(false);
+ onClose();
+ }}
+ onExited={() => setFilter('')}
+ anchorEl={buttonRef.current}
+ PaperProps={{
+ style: {
+ maxHeight: ITEM_HEIGHT * 4.5,
+ width: '25ch',
+ },
+ }}
+ >
+ {hasFilter && (
+ <CustomTextField
+ onChange={(e) => {
+ const { value } = e.target;
+ setFilter(value);
+ }}
+ onKeyDown={(e) => e.stopPropagation()}
+ value={filter}
+ label={`Filter ${children}`}
+ />
+ )}
+ {filter !== '' &&
+ dropdown.filter((d) => d[1].toLowerCase() === filter.toLowerCase())
+ .length <= 0 && (
+ <MenuItem
+ style={{ whiteSpace: 'normal', wordBreak: 'break-all' }}
+ onClick={() => {
+ onNewItem(filter);
+ setFilter('');
+ setOpen(false);
+ }}
+ >
+ Create new label '{filter}'
+ </MenuItem>
+ )}
+ {dropdown
+ .sort(function (x, y) {
+ // true values first
+ return itemActive(x[1]) === itemActive(y[1]) ? 0 : x ? -1 : 1;
+ })
+ .filter((d) => d[1].toLowerCase().includes(filter.toLowerCase()))
+ .map(([key, value, color]) => (
+ <MenuItem
+ style={{ whiteSpace: 'normal', wordBreak: 'break-word' }}
+ onClick={() => {
+ toggleLabel(key, itemActive(key));
+ }}
+ key={key}
+ selected={itemActive(key)}
+ >
+ <div className={classes.menuRow}>
+ {itemActive(key) && <CheckIcon />}
+ <div
+ className={classes.labelcolor}
+ style={createStyle(color)}
+ />
+ {value}
+ </div>
+ </MenuItem>
+ ))}
+ </Menu>
+ </>
+ );
+}
+
+type Props = {
+ bug: BugFragment;
+};
+function LabelMenu({ bug }: Props) {
+ const { data: labelsData } = useListLabelsQuery();
+ const [bugLabelNames, setBugLabelNames] = useState(
+ bug.labels.map((l) => l.name)
+ );
+ const [selectedLabels, setSelectedLabels] = useState(
+ bug.labels.map((l) => l.name)
+ );
+
+ const [setLabelMutation] = useSetLabelMutation();
+
+ function toggleLabel(key: string, active: boolean) {
+ const labels: string[] = active
+ ? selectedLabels.filter((label) => label !== key)
+ : selectedLabels.concat([key]);
+ setSelectedLabels(labels);
+ }
+
+ function diff(oldState: string[], newState: string[]) {
+ const added = newState.filter((x) => !oldState.includes(x));
+ const removed = oldState.filter((x) => !newState.includes(x));
+ return {
+ added: added,
+ removed: removed,
+ };
+ }
+
+ const changeBugLabels = (selectedLabels: string[]) => {
+ const labels = diff(bugLabelNames, selectedLabels);
+ if (labels.added.length > 0 || labels.removed.length > 0) {
+ setLabelMutation({
+ variables: {
+ input: {
+ prefix: bug.id,
+ added: labels.added,
+ Removed: labels.removed,
+ },
+ },
+ refetchQueries: [
+ // TODO: update the cache instead of refetching
+ {
+ query: GetBugDocument,
+ variables: { id: bug.id },
+ },
+ {
+ query: ListLabelsDocument,
+ },
+ ],
+ awaitRefetchQueries: true,
+ })
+ .then((res) => {
+ setSelectedLabels(selectedLabels);
+ setBugLabelNames(selectedLabels);
+ })
+ .catch((e) => console.log(e));
+ }
+ };
+
+ function isActive(key: string) {
+ return selectedLabels.includes(key);
+ }
+
+ function createNewLabel(name: string) {
+ changeBugLabels(selectedLabels.concat([name]));
+ }
+
+ let labels: any = [];
+ if (
+ labelsData?.repository &&
+ labelsData.repository.validLabels &&
+ labelsData.repository.validLabels.nodes
+ ) {
+ labels = labelsData.repository.validLabels.nodes.map((node) => [
+ node.name,
+ node.name,
+ node.color,
+ ]);
+ }
+
+ return (
+ <FilterDropdown
+ onClose={() => changeBugLabels(selectedLabels)}
+ itemActive={isActive}
+ toggleLabel={toggleLabel}
+ dropdown={labels}
+ onNewItem={createNewLabel}
+ hasFilter
+ >
+ Labels
+ </FilterDropdown>
+ );
+}
+
+export default LabelMenu;
diff --git a/webui/src/pages/bug/labels/SetLabel.graphql b/webui/src/pages/bug/labels/SetLabel.graphql
new file mode 100644
index 00000000..44dfae11
--- /dev/null
+++ b/webui/src/pages/bug/labels/SetLabel.graphql
@@ -0,0 +1,13 @@
+mutation SetLabel($input: ChangeLabelInput) {
+ changeLabels(input: $input) {
+ results{
+ status,
+ label{
+ name,
+ color{R},
+ color{G},
+ color{B}
+ }
+ }
+ }
+}
diff --git a/webui/src/pages/list/BugRow.tsx b/webui/src/pages/list/BugRow.tsx
index 1f5d22aa..87e45581 100644
--- a/webui/src/pages/list/BugRow.tsx
+++ b/webui/src/pages/list/BugRow.tsx
@@ -59,28 +59,36 @@ const useStyles = makeStyles((theme) => ({
width: '100%',
lineHeight: '20px',
},
+ bugTitleWrapper: {
+ display: 'flex',
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ //alignItems: 'center',
+ },
title: {
display: 'inline',
color: theme.palette.text.primary,
fontSize: '1.3rem',
fontWeight: 500,
+ marginBottom: theme.spacing(1),
+ },
+ label: {
+ maxWidth: '40ch',
+ marginLeft: theme.spacing(0.25),
+ marginRight: theme.spacing(0.25),
},
details: {
lineHeight: '1.5rem',
color: theme.palette.text.secondary,
},
- labels: {
- paddingLeft: theme.spacing(1),
- '& > *': {
- display: 'inline-block',
- },
- },
commentCount: {
fontSize: '1rem',
marginLeft: theme.spacing(0.5),
},
commentCountCell: {
display: 'inline-flex',
+ minWidth: theme.spacing(5),
+ marginLeft: theme.spacing(0.5),
},
}));
@@ -98,15 +106,12 @@ function BugRow({ bug }: Props) {
<BugStatus status={bug.status} className={classes.status} />
<div className={classes.expand}>
<Link to={'bug/' + bug.humanId}>
- <div className={classes.expand}>
+ <div className={classes.bugTitleWrapper}>
<span className={classes.title}>{bug.title}</span>
- {bug.labels.length > 0 && (
- <span className={classes.labels}>
- {bug.labels.map((l) => (
- <Label key={l.name} label={l} />
- ))}
- </span>
- )}
+ {bug.labels.length > 0 &&
+ bug.labels.map((l) => (
+ <Label key={l.name} label={l} className={classes.label} />
+ ))}
</div>
</Link>
<div className={classes.details}>
@@ -115,12 +120,14 @@ function BugRow({ bug }: Props) {
&nbsp;by {bug.author.displayName}
</div>
</div>
- {commentCount > 0 && (
- <span className={classes.commentCountCell}>
- <CommentOutlinedIcon aria-label="Comment count" />
- <span className={classes.commentCount}>{commentCount}</span>
- </span>
- )}
+ <span className={classes.commentCountCell}>
+ {commentCount > 0 && (
+ <>
+ <CommentOutlinedIcon aria-label="Comment count" />
+ <span className={classes.commentCount}>{commentCount}</span>
+ </>
+ )}
+ </span>
</TableCell>
</TableRow>
);
diff --git a/webui/src/pages/list/Filter.tsx b/webui/src/pages/list/Filter.tsx
index 66702078..3559b3ce 100644
--- a/webui/src/pages/list/Filter.tsx
+++ b/webui/src/pages/list/Filter.tsx
@@ -1,13 +1,36 @@
import clsx from 'clsx';
import { LocationDescriptor } from 'history';
-import React, { useState, useRef } from 'react';
+import React, { useRef, useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import Menu from '@material-ui/core/Menu';
import MenuItem from '@material-ui/core/MenuItem';
import { SvgIconProps } from '@material-ui/core/SvgIcon';
-import { makeStyles } from '@material-ui/core/styles';
+import TextField from '@material-ui/core/TextField';
+import { makeStyles, withStyles } from '@material-ui/core/styles';
+import { darken } from '@material-ui/core/styles/colorManipulator';
import ArrowDropDown from '@material-ui/icons/ArrowDropDown';
+import CheckIcon from '@material-ui/icons/Check';
+
+import { Color } from '../../gqlTypes';
+
+const CustomTextField = withStyles((theme) => ({
+ root: {
+ margin: '0 8px 12px 8px',
+ '& label.Mui-focused': {
+ margin: '0 2px',
+ color: theme.palette.text.secondary,
+ },
+ '& .MuiInput-underline::before': {
+ borderBottomColor: theme.palette.divider,
+ },
+ '& .MuiInput-underline::after': {
+ borderBottomColor: theme.palette.divider,
+ },
+ },
+}))(TextField);
+
+const ITEM_HEIGHT = 48;
export type Query = { [key: string]: string[] };
@@ -80,9 +103,36 @@ const useStyles = makeStyles((theme) => ({
icon: {
paddingRight: theme.spacing(0.5),
},
+ labelMenu: {
+ '& .MuiMenu-paper': {
+ //somehow using "width" won't override the default width...
+ minWidth: '35ch',
+ },
+ },
+ labelMenuItem: {
+ whiteSpace: 'normal',
+ wordBreak: 'break-word',
+ display: 'flex',
+ alignItems: 'initial',
+ },
+ labelcolor: {
+ minWidth: '0.5rem',
+ display: 'flex',
+ borderRadius: '0.25rem',
+ marginRight: '5px',
+ marginLeft: '3px',
+ },
}));
+const _rgb = (color: Color) =>
+ 'rgb(' + color.R + ',' + color.G + ',' + color.B + ')';
+
+// Create a style object from the label RGB colors
+const createStyle = (color: Color) => ({
+ backgroundColor: _rgb(color),
+ borderBottomColor: darken(_rgb(color), 0.2),
+});
-type DropdownTuple = [string, string];
+type DropdownTuple = [string, string, Color?];
type FilterDropdownProps = {
children: React.ReactNode;
@@ -90,6 +140,7 @@ type FilterDropdownProps = {
itemActive: (key: string) => boolean;
icon?: React.ComponentType<SvgIconProps>;
to: (key: string) => LocationDescriptor;
+ hasFilter?: boolean;
} & React.ButtonHTMLAttributes<HTMLButtonElement>;
function FilterDropdown({
@@ -98,12 +149,19 @@ function FilterDropdown({
itemActive,
icon: Icon,
to,
+ hasFilter,
...props
}: FilterDropdownProps) {
const [open, setOpen] = useState(false);
+ const [filter, setFilter] = useState<string>('');
const buttonRef = useRef<HTMLButtonElement>(null);
+ const searchRef = useRef<HTMLButtonElement>(null);
const classes = useStyles({ active: false });
+ useEffect(() => {
+ searchRef && searchRef.current && searchRef.current.focus();
+ }, [filter]);
+
const content = (
<>
{Icon && <Icon fontSize="small" classes={{ root: classes.icon }} />}
@@ -123,7 +181,9 @@ function FilterDropdown({
<ArrowDropDown fontSize="small" />
</button>
<Menu
+ className={classes.labelMenu}
getContentAnchorEl={null}
+ ref={searchRef}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
@@ -135,18 +195,45 @@ function FilterDropdown({
open={open}
onClose={() => setOpen(false)}
anchorEl={buttonRef.current}
+ PaperProps={{
+ style: {
+ maxHeight: ITEM_HEIGHT * 4.5,
+ width: '25ch',
+ },
+ }}
>
- {dropdown.map(([key, value]) => (
- <MenuItem
- component={Link}
- to={to(key)}
- className={itemActive(key) ? classes.itemActive : undefined}
- onClick={() => setOpen(false)}
- key={key}
- >
- {value}
- </MenuItem>
- ))}
+ {hasFilter && (
+ <CustomTextField
+ onChange={(e) => {
+ const { value } = e.target;
+ setFilter(value);
+ }}
+ onKeyDown={(e) => e.stopPropagation()}
+ value={filter}
+ label={`Filter ${children}`}
+ />
+ )}
+ {dropdown
+ .filter((d) => d[1].toLowerCase().includes(filter.toLowerCase()))
+ .map(([key, value, color]) => (
+ <MenuItem
+ component={Link}
+ to={to(key)}
+ className={classes.labelMenuItem}
+ selected={itemActive(key)}
+ onClick={() => setOpen(false)}
+ key={key}
+ >
+ {itemActive(key) && <CheckIcon />}
+ {color && (
+ <div
+ className={classes.labelcolor}
+ style={createStyle(color)}
+ />
+ )}
+ {value}
+ </MenuItem>
+ ))}
</Menu>
</>
);
@@ -158,6 +245,7 @@ export type FilterProps = {
icon?: React.ComponentType<SvgIconProps>;
children: React.ReactNode;
};
+
function Filter({ active, to, children, icon: Icon }: FilterProps) {
const classes = useStyles();
diff --git a/webui/src/pages/list/FilterToolbar.tsx b/webui/src/pages/list/FilterToolbar.tsx
index 74eefe4c..e109578d 100644
--- a/webui/src/pages/list/FilterToolbar.tsx
+++ b/webui/src/pages/list/FilterToolbar.tsx
@@ -8,14 +8,16 @@ import CheckCircleOutline from '@material-ui/icons/CheckCircleOutline';
import ErrorOutline from '@material-ui/icons/ErrorOutline';
import {
+ Filter,
FilterDropdown,
FilterProps,
- Filter,
parse,
- stringify,
Query,
+ stringify,
} from './Filter';
import { useBugCountQuery } from './FilterToolbar.generated';
+import { useListIdentitiesQuery } from './ListIdentities.generated';
+import { useListLabelsQuery } from './ListLabels.generated';
const useStyles = makeStyles((theme) => ({
toolbar: {
@@ -35,6 +37,7 @@ type CountingFilterProps = {
query: string; // the query used as a source to count the number of element
children: React.ReactNode;
} & FilterProps;
+
function CountingFilter({ query, children, ...props }: CountingFilterProps) {
const { data, loading, error } = useBugCountQuery({
variables: { query },
@@ -57,14 +60,45 @@ type Props = {
query: string;
queryLocation: (query: string) => LocationDescriptor;
};
+
function FilterToolbar({ query, queryLocation }: Props) {
const classes = useStyles();
const params: Query = parse(query);
+ const { data: identitiesData } = useListIdentitiesQuery();
+ const { data: labelsData } = useListLabelsQuery();
+
+ let identities: any = [];
+ let labels: any = [];
+
+ if (
+ identitiesData?.repository &&
+ identitiesData.repository.allIdentities &&
+ identitiesData.repository.allIdentities.nodes
+ ) {
+ identities = identitiesData.repository.allIdentities.nodes.map((node) => [
+ node.name,
+ node.name,
+ ]);
+ }
+
+ if (
+ labelsData?.repository &&
+ labelsData.repository.validLabels &&
+ labelsData.repository.validLabels.nodes
+ ) {
+ labels = labelsData.repository.validLabels.nodes.map((node) => [
+ node.name,
+ node.name,
+ node.color,
+ ]);
+ }
const hasKey = (key: string): boolean =>
params[key] && params[key].length > 0;
const hasValue = (key: string, value: string): boolean =>
hasKey(key) && params[key].includes(value);
+ const containsValue = (key: string, value: string): boolean =>
+ hasKey(key) && params[key].indexOf(value) !== -1;
const loc = pipe(stringify, queryLocation);
const replaceParam = (key: string, value: string) => (
params: Query
@@ -78,6 +112,20 @@ function FilterToolbar({ query, queryLocation }: Props) {
...params,
[key]: params[key] && params[key].includes(value) ? [] : [value],
});
+ const toggleOrAddParam = (key: string, value: string) => (
+ params: Query
+ ): Query => {
+ const values = params[key];
+ return {
+ ...params,
+ [key]:
+ params[key] && params[key].includes(value)
+ ? values.filter((v) => v !== value)
+ : values
+ ? [...values, value]
+ : [value],
+ };
+ };
const clearParam = (key: string) => (params: Query): Query => ({
...params,
[key]: [],
@@ -116,6 +164,22 @@ function FilterToolbar({ query, queryLocation }: Props) {
<Filter active={hasKey('label')}>Label</Filter>
*/}
<FilterDropdown
+ dropdown={identities}
+ itemActive={(key) => hasValue('author', key)}
+ to={(key) => pipe(toggleOrAddParam('author', key), loc)(params)}
+ hasFilter
+ >
+ Author
+ </FilterDropdown>
+ <FilterDropdown
+ dropdown={labels}
+ itemActive={(key) => containsValue('label', key)}
+ to={(key) => pipe(toggleOrAddParam('label', key), loc)(params)}
+ hasFilter
+ >
+ Labels
+ </FilterDropdown>
+ <FilterDropdown
dropdown={[
['id', 'ID'],
['creation', 'Newest'],
@@ -124,7 +188,7 @@ function FilterToolbar({ query, queryLocation }: Props) {
['edit-asc', 'Least recently updated'],
]}
itemActive={(key) => hasValue('sort', key)}
- to={(key) => pipe(replaceParam('sort', key), loc)(params)}
+ to={(key) => pipe(toggleParam('sort', key), loc)(params)}
>
Sort
</FilterDropdown>
diff --git a/webui/src/pages/list/ListIdentities.graphql b/webui/src/pages/list/ListIdentities.graphql
new file mode 100644
index 00000000..73073ae8
--- /dev/null
+++ b/webui/src/pages/list/ListIdentities.graphql
@@ -0,0 +1,13 @@
+query ListIdentities {
+ repository {
+ allIdentities {
+ nodes {
+ id
+ humanId
+ name
+ email
+ displayName
+ }
+ }
+ }
+}
diff --git a/webui/src/pages/list/ListLabels.graphql b/webui/src/pages/list/ListLabels.graphql
new file mode 100644
index 00000000..8b2f561a
--- /dev/null
+++ b/webui/src/pages/list/ListLabels.graphql
@@ -0,0 +1,10 @@
+query ListLabels {
+ repository {
+ validLabels {
+ nodes {
+ name,
+ color{R,G,B}
+ }
+ }
+ }
+}
diff --git a/webui/src/pages/list/ListQuery.tsx b/webui/src/pages/list/ListQuery.tsx
index 500ccf77..2b46dca5 100644
--- a/webui/src/pages/list/ListQuery.tsx
+++ b/webui/src/pages/list/ListQuery.tsx
@@ -1,19 +1,23 @@
import { ApolloError } from '@apollo/client';
+import { pipe } from '@arrows/composition';
import React, { useState, useEffect, useRef } from 'react';
import { useLocation, useHistory, Link } from 'react-router-dom';
-import { Button } from '@material-ui/core';
+import { Button, FormControl, Menu, MenuItem } from '@material-ui/core';
import IconButton from '@material-ui/core/IconButton';
import InputBase from '@material-ui/core/InputBase';
import Paper from '@material-ui/core/Paper';
import { makeStyles, Theme } from '@material-ui/core/styles';
+import ArrowDropDownIcon from '@material-ui/icons/ArrowDropDown';
import ErrorOutline from '@material-ui/icons/ErrorOutline';
import KeyboardArrowLeft from '@material-ui/icons/KeyboardArrowLeft';
import KeyboardArrowRight from '@material-ui/icons/KeyboardArrowRight';
import Skeleton from '@material-ui/lab/Skeleton';
+import { useCurrentIdentityQuery } from '../../components/CurrentIdentity/CurrentIdentity.generated';
import IfLoggedIn from 'src/components/IfLoggedIn/IfLoggedIn';
+import { parse, Query, stringify } from './Filter';
import FilterToolbar from './FilterToolbar';
import List from './List';
import { useListBugsQuery } from './ListQuery.generated';
@@ -35,24 +39,17 @@ const useStyles = makeStyles<Theme, StylesProps>((theme) => ({
},
header: {
display: 'flex',
- padding: theme.spacing(2),
- '& > h1': {
- ...theme.typography.h6,
- margin: theme.spacing(0, 2),
- },
- alignItems: 'center',
- justifyContent: 'space-between',
+ padding: theme.spacing(1),
},
filterissueLabel: {
fontSize: '14px',
fontWeight: 'bold',
paddingRight: '12px',
},
- filterissueContainer: {
+ form: {
display: 'flex',
- flexDirection: 'row',
- alignItems: 'flex-start',
- justifyContents: 'left',
+ flexGrow: 1,
+ marginRight: theme.spacing(1),
},
search: {
borderRadius: theme.shape.borderRadius,
@@ -62,7 +59,7 @@ const useStyles = makeStyles<Theme, StylesProps>((theme) => ({
borderWidth: '1px',
backgroundColor: theme.palette.primary.light,
padding: theme.spacing(0, 1),
- width: ({ searching }) => (searching ? '20rem' : '15rem'),
+ width: '100%',
transition: theme.transitions.create([
'width',
'borderColor',
@@ -192,6 +189,8 @@ function ListQuery() {
const query = params.has('q') ? params.get('q') || '' : 'status:open';
const [input, setInput] = useState(query);
+ const [filterMenuIsOpen, setFilterMenuIsOpen] = useState(false);
+ const filterButtonRef = useRef<HTMLButtonElement>(null);
const classes = useStyles({ searching: !!input });
@@ -293,35 +292,85 @@ function ListQuery() {
history.push(queryLocation(input));
};
+ const {
+ loading: ciqLoading,
+ error: ciqError,
+ data: ciqData,
+ } = useCurrentIdentityQuery();
+ if (ciqError || ciqLoading || !ciqData?.repository?.userIdentity) {
+ return null;
+ }
+ const user = ciqData.repository.userIdentity;
+
+ const loc = pipe(stringify, queryLocation);
+ const qparams: Query = parse(query);
+ const replaceParam = (key: string, value: string) => (
+ params: Query
+ ): Query => ({
+ ...params,
+ [key]: [value],
+ });
+
return (
<Paper className={classes.main}>
<header className={classes.header}>
- <div className="filterissueContainer">
- <form onSubmit={formSubmit}>
- <label className={classes.filterissueLabel} htmlFor="issuefilter">
- Filter
- </label>
- <InputBase
- id="issuefilter"
- placeholder="Filter"
- value={input}
- onInput={(e: any) => setInput(e.target.value)}
- classes={{
- root: classes.search,
- focused: classes.searchFocused,
+ <form className={classes.form} onSubmit={formSubmit}>
+ <FormControl>
+ <Button
+ aria-haspopup="true"
+ ref={filterButtonRef}
+ onClick={(e) => setFilterMenuIsOpen(true)}
+ >
+ Filter <ArrowDropDownIcon />
+ </Button>
+ <Menu
+ open={filterMenuIsOpen}
+ onClose={() => setFilterMenuIsOpen(false)}
+ getContentAnchorEl={null}
+ anchorEl={filterButtonRef.current}
+ anchorOrigin={{
+ vertical: 'bottom',
+ horizontal: 'left',
}}
- />
- <button type="submit" hidden>
- Search
- </button>
- </form>
- </div>
+ transformOrigin={{
+ vertical: 'top',
+ horizontal: 'left',
+ }}
+ >
+ <MenuItem
+ component={Link}
+ to={pipe(
+ replaceParam('author', user.displayName),
+ replaceParam('sort', 'creation'),
+ loc
+ )(qparams)}
+ onClick={() => setFilterMenuIsOpen(false)}
+ >
+ Your newest issues
+ </MenuItem>
+ </Menu>
+ </FormControl>
+ <InputBase
+ id="issuefilter"
+ placeholder="Filter"
+ value={input}
+ onInput={(e: any) => setInput(e.target.value)}
+ classes={{
+ root: classes.search,
+ focused: classes.searchFocused,
+ }}
+ />
+ <button type="submit" hidden>
+ Search
+ </button>
+ </form>
<IfLoggedIn>
{() => (
<Button
className={classes.greenButton}
variant="contained"
- href="/new"
+ component={Link}
+ to="/new"
>
New bug
</Button>