diff options
23 files changed, 1055 insertions, 532 deletions
diff --git a/bridge/github/import.go b/bridge/github/import.go index 78e93436..e8a4d3cb 100644 --- a/bridge/github/import.go +++ b/bridge/github/import.go @@ -100,7 +100,7 @@ func (gi *githubImporter) ImportAll(ctx context.Context, repo *cache.RepoCache, return out, nil } -func (gi *githubImporter) ensureIssue(repo *cache.RepoCache, issue issueTimeline) (*cache.BugCache, error) { +func (gi *githubImporter) ensureIssue(repo *cache.RepoCache, issue issue) (*cache.BugCache, error) { // ensure issue author author, err := gi.ensurePerson(repo, issue.Author) if err != nil { diff --git a/bridge/github/import_query.go b/bridge/github/import_query.go index 58f6d95e..228d204a 100644 --- a/bridge/github/import_query.go +++ b/bridge/github/import_query.go @@ -47,14 +47,9 @@ type userContentEdit struct { } type issueComment struct { - authorEvent - Body githubv4.String - Url githubv4.URI - - UserContentEdits struct { - Nodes []userContentEdit - PageInfo pageInfo - } `graphql:"userContentEdits(last: $commentEditLast, before: $commentEditBefore)"` + authorEvent // NOTE: contains Id + Body githubv4.String + Url githubv4.URI } type timelineItem struct { @@ -96,70 +91,6 @@ type timelineItem struct { } `graphql:"... on RenamedTitleEvent"` } -type issueTimeline struct { - authorEvent - Title string - Body githubv4.String - Url githubv4.URI - - TimelineItems struct { - Edges []struct { - Cursor githubv4.String - Node timelineItem - } - PageInfo pageInfo - } `graphql:"timelineItems(first: $timelineFirst, after: $timelineAfter)"` - - UserContentEdits struct { - Nodes []userContentEdit - PageInfo pageInfo - } `graphql:"userContentEdits(last: $issueEditLast, before: $issueEditBefore)"` -} - -type issueEdit struct { - UserContentEdits struct { - Nodes []userContentEdit - PageInfo pageInfo - } `graphql:"userContentEdits(last: $issueEditLast, before: $issueEditBefore)"` -} - -type issueTimelineQuery struct { - Repository struct { - Issues struct { - Nodes []issueTimeline - PageInfo pageInfo - } `graphql:"issues(first: $issueFirst, after: $issueAfter, orderBy: {field: CREATED_AT, direction: ASC}, filterBy: {since: $issueSince})"` - } `graphql:"repository(owner: $owner, name: $name)"` -} - -type issueEditQuery struct { - Repository struct { - Issues struct { - Nodes []issueEdit - PageInfo pageInfo - } `graphql:"issues(first: $issueFirst, after: $issueAfter, orderBy: {field: CREATED_AT, direction: ASC}, filterBy: {since: $issueSince})"` - } `graphql:"repository(owner: $owner, name: $name)"` -} - -type commentEditQuery struct { - Repository struct { - Issues struct { - Nodes []struct { - Timeline struct { - Nodes []struct { - IssueComment struct { - UserContentEdits struct { - Nodes []userContentEdit - PageInfo pageInfo - } `graphql:"userContentEdits(last: $commentEditLast, before: $commentEditBefore)"` - } `graphql:"... on IssueComment"` - } - } `graphql:"timeline(first: $timelineFirst, after: $timelineAfter)"` - } - } `graphql:"issues(first: $issueFirst, after: $issueAfter, orderBy: {field: CREATED_AT, direction: ASC}, filterBy: {since: $issueSince})"` - } `graphql:"repository(owner: $owner, name: $name)"` -} - type ghostQuery struct { User struct { Login githubv4.String @@ -187,3 +118,57 @@ type loginQuery struct { Login string `graphql:"login"` } `graphql:"viewer"` } + +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)"` +} + +type issue struct { + authorEvent + Title string + Number githubv4.Int + Body githubv4.String + Url githubv4.URI +} + +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 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 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)"` +} diff --git a/bridge/github/iterator.go b/bridge/github/iterator.go index 40b00292..d21faae8 100644 --- a/bridge/github/iterator.go +++ b/bridge/github/iterator.go @@ -4,398 +4,371 @@ import ( "context" "time" + "github.com/pkg/errors" "github.com/shurcooL/githubv4" ) -type indexer struct{ index int } +type iterator struct { + // Github graphql client + gc *githubv4.Client -type issueEditIterator struct { - index int - query issueEditQuery - variables map[string]interface{} -} + // The iterator will only query issues updated or created after the date given in + // the variable since. + since time.Time -type commentEditIterator struct { - index int - query commentEditQuery - variables map[string]interface{} -} + // Shared context, which is used for all graphql queries. + ctx context.Context -type timelineIterator struct { - index int - query issueTimelineQuery - variables map[string]interface{} + // Sticky error + err error - issueEdit indexer - commentEdit indexer + // Issue iterator + issueIter issueIter +} - // lastEndCursor cache the timeline end cursor for one iteration - lastEndCursor githubv4.String +type issueIter struct { + iterVars + query issueQuery + issueEditIter []issueEditIter + timelineIter []timelineIter } -type iterator struct { - // github graphql client - gc *githubv4.Client +type issueEditIter struct { + iterVars + query issueEditQuery +} - // if since is given the iterator will query only the updated - // and created issues after this date - since time.Time +type timelineIter struct { + iterVars + query timelineQuery + commentEditIter []commentEditIter +} - // number of timelines/userEditcontent/issueEdit to query - // at a time, more capacity = more used memory = less queries - // to make - capacity int +type commentEditIter struct { + iterVars + query commentEditQuery +} - // shared context used for all graphql queries - ctx context.Context +type iterVars struct { + // Iterator index + index int - // sticky error - err error + // 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 - // timeline iterator - timeline timelineIterator + // Variable assignments for graphql query + variables varmap +} - // issue edit iterator - issueEdit issueEditIterator +type varmap map[string]interface{} - // comment edit iterator - commentEdit commentEditIterator +func newIterVars(capacity int) iterVars { + return iterVars{ + index: -1, + capacity: capacity, + variables: varmap{}, + } } -// NewIterator create and initialize a new iterator +// 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, - capacity: capacity, - ctx: ctx, - timeline: timelineIterator{ - index: -1, - issueEdit: indexer{-1}, - commentEdit: indexer{-1}, - variables: map[string]interface{}{ - "owner": githubv4.String(owner), - "name": githubv4.String(project), - }, - }, - commentEdit: commentEditIterator{ - index: -1, - variables: map[string]interface{}{ - "owner": githubv4.String(owner), - "name": githubv4.String(project), - }, - }, - issueEdit: issueEditIterator{ - index: -1, - variables: map[string]interface{}{ - "owner": githubv4.String(owner), - "name": githubv4.String(project), - }, + gc: client, + since: since, + ctx: ctx, + issueIter: issueIter{ + iterVars: newIterVars(capacity), + timelineIter: make([]timelineIter, capacity), + issueEditIter: make([]issueEditIter, capacity), }, } - - i.initTimelineQueryVariables() - return i -} - -// init issue timeline variables -func (i *iterator) initTimelineQueryVariables() { - i.timeline.variables["issueFirst"] = githubv4.Int(1) - i.timeline.variables["issueAfter"] = (*githubv4.String)(nil) - i.timeline.variables["issueSince"] = githubv4.DateTime{Time: i.since} - i.timeline.variables["timelineFirst"] = githubv4.Int(i.capacity) - i.timeline.variables["timelineAfter"] = (*githubv4.String)(nil) - // Fun fact, github provide the comment edition in reverse chronological - // order, because haha. Look at me, I'm dying of laughter. - i.timeline.variables["issueEditLast"] = githubv4.Int(i.capacity) - i.timeline.variables["issueEditBefore"] = (*githubv4.String)(nil) - i.timeline.variables["commentEditLast"] = githubv4.Int(i.capacity) - i.timeline.variables["commentEditBefore"] = (*githubv4.String)(nil) -} - -// init issue edit variables -func (i *iterator) initIssueEditQueryVariables() { - i.issueEdit.variables["issueFirst"] = githubv4.Int(1) - i.issueEdit.variables["issueAfter"] = i.timeline.variables["issueAfter"] - i.issueEdit.variables["issueSince"] = githubv4.DateTime{Time: i.since} - i.issueEdit.variables["issueEditLast"] = githubv4.Int(i.capacity) - i.issueEdit.variables["issueEditBefore"] = (*githubv4.String)(nil) -} - -// init issue comment variables -func (i *iterator) initCommentEditQueryVariables() { - i.commentEdit.variables["issueFirst"] = githubv4.Int(1) - i.commentEdit.variables["issueAfter"] = i.timeline.variables["issueAfter"] - i.commentEdit.variables["issueSince"] = githubv4.DateTime{Time: i.since} - i.commentEdit.variables["timelineFirst"] = githubv4.Int(1) - i.commentEdit.variables["timelineAfter"] = (*githubv4.String)(nil) - i.commentEdit.variables["commentEditLast"] = githubv4.Int(i.capacity) - i.commentEdit.variables["commentEditBefore"] = (*githubv4.String)(nil) -} - -// reverse UserContentEdits arrays in both of the issue and -// comment timelines -func (i *iterator) reverseTimelineEditNodes() { - node := i.timeline.query.Repository.Issues.Nodes[0] - reverseEdits(node.UserContentEdits.Nodes) - for index, ce := range node.TimelineItems.Edges { - if ce.Node.Typename == "IssueComment" && len(node.TimelineItems.Edges) != 0 { - reverseEdits(node.TimelineItems.Edges[index].Node.IssueComment.UserContentEdits.Nodes) + 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 } -// Error return last encountered error -func (i *iterator) Error() error { - return i.err +func (v *varmap) setOwnerProject(owner, project string) { + (*v)["owner"] = githubv4.String(owner) + (*v)["name"] = githubv4.String(project) } -func (i *iterator) queryIssue() bool { - ctx, cancel := context.WithTimeout(i.ctx, defaultTimeout) - defer cancel() - - if err := i.gc.Query(ctx, &i.timeline.query, i.timeline.variables); err != nil { - i.err = err - return false - } +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 = "" +} - issues := i.timeline.query.Repository.Issues.Nodes - if len(issues) == 0 { - return false +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 = "" } - - i.reverseTimelineEditNodes() - return true } -// NextIssue try to query the next issue and return true. Only one issue is -// queried at each call. -func (i *iterator) NextIssue() bool { - if i.err != nil { - return false +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 = "" } +} - // if $issueAfter variable is nil we can directly make the first query - if i.timeline.variables["issueAfter"] == (*githubv4.String)(nil) { - nextIssue := i.queryIssue() - // prevent from infinite loop by setting a non nil cursor - issues := i.timeline.query.Repository.Issues - i.timeline.variables["issueAfter"] = issues.PageInfo.EndCursor - return nextIssue +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 = "" + } } +} - issues := i.timeline.query.Repository.Issues - if !issues.PageInfo.HasNextPage { - return false +// Error return last encountered error +func (i *iterator) Error() error { + if i.err != nil { + return i.err } + return i.ctx.Err() // might return nil +} - // if we have more issues, query them - i.timeline.variables["timelineAfter"] = (*githubv4.String)(nil) - i.timeline.index = -1 - - timelineEndCursor := issues.Nodes[0].TimelineItems.PageInfo.EndCursor - // store cursor for future use - i.timeline.lastEndCursor = timelineEndCursor +func (i *iterator) HasError() bool { + return i.err != nil || i.ctx.Err() != nil +} - // query issue block - nextIssue := i.queryIssue() - i.timeline.variables["issueAfter"] = issues.PageInfo.EndCursor +func (i *iterator) currIssueItem() *issue { + return &i.issueIter.query.Repository.Issues.Nodes[i.issueIter.index] +} - return nextIssue +func (i *iterator) currIssueEditIter() *issueEditIter { + return &i.issueIter.issueEditIter[i.issueIter.index] } -// IssueValue return the actual issue value -func (i *iterator) IssueValue() issueTimeline { - issues := i.timeline.query.Repository.Issues - return issues.Nodes[0] +func (i *iterator) currTimelineIter() *timelineIter { + return &i.issueIter.timelineIter[i.issueIter.index] } -// NextTimelineItem return true if there is a next timeline item and increments the index by one. -// It is used iterates over all the timeline items. Extra queries are made if it is necessary. -func (i *iterator) NextTimelineItem() bool { - if i.err != nil { - return false - } +func (i *iterator) currCommentEditIter() *commentEditIter { + timelineIter := i.currTimelineIter() + return &timelineIter.commentEditIter[timelineIter.index] +} - if i.ctx.Err() != nil { - return false - } +func (i *iterator) currIssueGqlNodeId() githubv4.ID { + return i.currIssueItem().Id +} - timelineItems := i.timeline.query.Repository.Issues.Nodes[0].TimelineItems - // after NextIssue call it's good to check wether we have some timelineItems items or not - if len(timelineItems.Edges) == 0 { +// 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 } - - if i.timeline.index < len(timelineItems.Edges)-1 { - i.timeline.index++ + 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 !timelineItems.PageInfo.HasNextPage { + if !issues.PageInfo.HasNextPage { return false } + nextIssue := i.queryIssue() + return nextIssue +} - i.timeline.lastEndCursor = timelineItems.PageInfo.EndCursor - - // more timelines, query them - i.timeline.variables["timelineAfter"] = timelineItems.PageInfo.EndCursor +// 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 err := i.gc.Query(ctx, &i.timeline.query, i.timeline.variables); err != nil { + 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 } - - timelineItems = i.timeline.query.Repository.Issues.Nodes[0].TimelineItems - // (in case github returns something weird) just for safety: better return a false than a panic - if len(timelineItems.Edges) == 0 { + i.resetIssueEditVars() + i.resetTimelineVars() + issueItems := &i.issueIter.query.Repository.Issues.Nodes + if len(*issueItems) <= 0 { + i.issueIter.index = -1 return false } - - i.reverseTimelineEditNodes() - i.timeline.index = 0 + i.issueIter.index = 0 return true } -// TimelineItemValue return the actual timeline item value -func (i *iterator) TimelineItemValue() timelineItem { - timelineItems := i.timeline.query.Repository.Issues.Nodes[0].TimelineItems - return timelineItems.Edges[i.timeline.index].Node -} - -func (i *iterator) queryIssueEdit() bool { - ctx, cancel := context.WithTimeout(i.ctx, defaultTimeout) - defer cancel() - - if err := i.gc.Query(ctx, &i.issueEdit.query, i.issueEdit.variables); err != nil { - i.err = err - //i.timeline.issueEdit.index = -1 +// 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 } - - issueEdits := i.issueEdit.query.Repository.Issues.Nodes[0].UserContentEdits - // reverse issue edits because github - reverseEdits(issueEdits.Nodes) - - // this is not supposed to happen - if len(issueEdits.Nodes) == 0 { - i.timeline.issueEdit.index = -1 + 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 } - - i.issueEdit.index = 0 - i.timeline.issueEdit.index = -2 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. + // 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 } -// NextIssueEdit return true if there is a next issue edit and increments the index by one. -// It is used iterates over all the issue edits. Extra queries are made if it is necessary. -func (i *iterator) NextIssueEdit() bool { - if i.err != nil { +// 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 } - - if i.ctx.Err() != nil { + 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 +} - // this mean we looped over all available issue edits in the timeline. - // now we have to use i.issueEditQuery - if i.timeline.issueEdit.index == -2 { - issueEdits := i.issueEdit.query.Repository.Issues.Nodes[0].UserContentEdits - if i.issueEdit.index < len(issueEdits.Nodes)-1 { - i.issueEdit.index++ - return i.nextValidIssueEdit() - } - - if !issueEdits.PageInfo.HasPreviousPage { - i.timeline.issueEdit.index = -1 - i.issueEdit.index = -1 - return false - } - - // if there is more edits, query them - i.issueEdit.variables["issueEditBefore"] = issueEdits.PageInfo.StartCursor - return i.queryIssueEdit() - } - - issueEdits := i.timeline.query.Repository.Issues.Nodes[0].UserContentEdits - // if there is no edit, the UserContentEdits given by github is empty. That - // means that the original message is given by the issue message. - // - // if there is edits, the UserContentEdits given by github contains both the - // original message and the following edits. The issue message give the last - // version so we don't care about that. - // - // the tricky part: for an issue older than the UserContentEdits API, github - // doesn't have the previous message version anymore and give an edition - // with .Diff == nil. We have to filter them. - if len(issueEdits.Nodes) == 0 { +// 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 } - - // loop over them timeline comment edits - if i.timeline.issueEdit.index < len(issueEdits.Nodes)-1 { - i.timeline.issueEdit.index++ - return i.nextValidIssueEdit() + 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 !issueEdits.PageInfo.HasPreviousPage { - i.timeline.issueEdit.index = -1 + if !tlItems.PageInfo.HasNextPage { return false } - - // if there is more edits, query them - i.initIssueEditQueryVariables() - i.issueEdit.variables["issueEditBefore"] = issueEdits.PageInfo.StartCursor - return i.queryIssueEdit() + nextTlItem := i.queryTimeline() + return nextTlItem } -// IssueEditValue return the actual issue edit value -func (i *iterator) IssueEditValue() userContentEdit { - // if we are using issue edit query - if i.timeline.issueEdit.index == -2 { - issueEdits := i.issueEdit.query.Repository.Issues.Nodes[0].UserContentEdits - return issueEdits.Nodes[i.issueEdit.index] - } - - issueEdits := i.timeline.query.Repository.Issues.Nodes[0].UserContentEdits - // else get it from timeline issue edit query - return issueEdits.Nodes[i.timeline.issueEdit.index] +// 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) queryCommentEdit() bool { +func (i *iterator) queryTimeline() bool { ctx, cancel := context.WithTimeout(i.ctx, defaultTimeout) defer cancel() - - if err := i.gc.Query(ctx, &i.commentEdit.query, i.commentEdit.variables); err != nil { + 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 +} - commentEdits := i.commentEdit.query.Repository.Issues.Nodes[0].Timeline.Nodes[0].IssueComment.UserContentEdits - // this is not supposed to happen - if len(commentEdits.Nodes) == 0 { - i.timeline.commentEdit.index = -1 +// 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 } - reverseEdits(commentEdits.Nodes) + 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 + } - i.commentEdit.index = 0 - i.timeline.commentEdit.index = -2 + 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() } @@ -407,72 +380,40 @@ func (i *iterator) nextValidCommentEdit() bool { return true } -// NextCommentEdit return true if there is a next comment edit and increments the index by one. -// It is used iterates over all the comment edits. Extra queries are made if it is necessary. -func (i *iterator) NextCommentEdit() bool { - if i.err != nil { - return false - } - - if i.ctx.Err() != nil { - return false - } - - // same as NextIssueEdit - if i.timeline.commentEdit.index == -2 { - commentEdits := i.commentEdit.query.Repository.Issues.Nodes[0].Timeline.Nodes[0].IssueComment.UserContentEdits - if i.commentEdit.index < len(commentEdits.Nodes)-1 { - i.commentEdit.index++ - return i.nextValidCommentEdit() - } +// CommentEditValue returns the actual comment edit value. +func (i *iterator) CommentEditValue() userContentEdit { + cei := i.currCommentEditIter() + return cei.query.Node.IssueComment.UserContentEdits.Nodes[cei.index] +} - if !commentEdits.PageInfo.HasPreviousPage { - i.timeline.commentEdit.index = -1 - i.commentEdit.index = -1 - return false - } +func (i *iterator) queryCommentEdit() bool { + ctx, cancel := context.WithTimeout(i.ctx, defaultTimeout) + defer cancel() + cei := i.currCommentEditIter() - // if there is more comment edits, query them - i.commentEdit.variables["commentEditBefore"] = commentEdits.PageInfo.StartCursor - return i.queryCommentEdit() + if endCursor := cei.query.Node.IssueComment.UserContentEdits.PageInfo.EndCursor; endCursor != "" { + cei.variables["commentEditBefore"] = endCursor } - - commentEdits := i.timeline.query.Repository.Issues.Nodes[0].TimelineItems.Edges[i.timeline.index].Node.IssueComment - // if there is no comment edits - if len(commentEdits.UserContentEdits.Nodes) == 0 { + tmlnVal := i.TimelineItemValue() + if tmlnVal.Typename != "IssueComment" { + i.err = errors.New("Call to queryCommentEdit() while timeline item is not a comment") return false } - - // loop over them timeline comment edits - if i.timeline.commentEdit.index < len(commentEdits.UserContentEdits.Nodes)-1 { - i.timeline.commentEdit.index++ - return i.nextValidCommentEdit() - } - - if !commentEdits.UserContentEdits.PageInfo.HasPreviousPage { - i.timeline.commentEdit.index = -1 + cei.variables["gqlNodeId"] = tmlnVal.IssueComment.Id + if err := i.gc.Query(ctx, &cei.query, cei.variables); err != nil { + i.err = err return false } - - i.initCommentEditQueryVariables() - if i.timeline.index == 0 { - i.commentEdit.variables["timelineAfter"] = i.timeline.lastEndCursor - } else { - i.commentEdit.variables["timelineAfter"] = i.timeline.query.Repository.Issues.Nodes[0].TimelineItems.Edges[i.timeline.index-1].Cursor - } - - i.commentEdit.variables["commentEditBefore"] = commentEdits.UserContentEdits.PageInfo.StartCursor - - return i.queryCommentEdit() -} - -// CommentEditValue return the actual comment edit value -func (i *iterator) CommentEditValue() userContentEdit { - if i.timeline.commentEdit.index == -2 { - return i.commentEdit.query.Repository.Issues.Nodes[0].Timeline.Nodes[0].IssueComment.UserContentEdits.Nodes[i.commentEdit.index] + ceItems := cei.query.Node.IssueComment.UserContentEdits.Nodes + if len(ceItems) <= 0 { + cei.index = -1 + return false } - - return i.timeline.query.Repository.Issues.Nodes[0].TimelineItems.Edges[i.timeline.index].Node.IssueComment.UserContentEdits.Nodes[i.timeline.commentEdit.index] + // 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) { diff --git a/bug/operation_pack.go b/bug/operation_pack.go index 0bd3fb7d..1a8ef0db 100644 --- a/bug/operation_pack.go +++ b/bug/operation_pack.go @@ -6,6 +6,7 @@ import ( "github.com/pkg/errors" + "github.com/MichaelMure/git-bug/entity" "github.com/MichaelMure/git-bug/repository" ) @@ -47,10 +48,10 @@ func (opp *OperationPack) UnmarshalJSON(data []byte) error { } if aux.Version < formatVersion { - return fmt.Errorf("outdated repository format, please use https://github.com/MichaelMure/git-bug-migration to upgrade") + return entity.NewErrOldFormatVersion(aux.Version) } if aux.Version > formatVersion { - return fmt.Errorf("your version of git-bug is too old for this repository (version %v), please upgrade to the latest version", aux.Version) + return entity.NewErrNewFormatVersion(aux.Version) } for _, raw := range aux.Operations { diff --git a/entity/err.go b/entity/err.go index 7d6c662e..90304d03 100644 --- a/entity/err.go +++ b/entity/err.go @@ -30,3 +30,29 @@ func IsErrMultipleMatch(err error) bool { _, ok := err.(*ErrMultipleMatch) return ok } + +// ErrOldFormatVersion indicate that the read data has a too old format. +type ErrOldFormatVersion struct { + formatVersion uint +} + +func NewErrOldFormatVersion(formatVersion uint) *ErrOldFormatVersion { + return &ErrOldFormatVersion{formatVersion: formatVersion} +} + +func (e ErrOldFormatVersion) Error() string { + return fmt.Sprintf("outdated repository format %v, please use https://github.com/MichaelMure/git-bug-migration to upgrade", e.formatVersion) +} + +// ErrNewFormatVersion indicate that the read data is too new for this software. +type ErrNewFormatVersion struct { + formatVersion uint +} + +func NewErrNewFormatVersion(formatVersion uint) *ErrNewFormatVersion { + return &ErrNewFormatVersion{formatVersion: formatVersion} +} + +func (e ErrNewFormatVersion) Error() string { + return fmt.Sprintf("your version of git-bug is too old for this repository (version %v), please upgrade to the latest version", e.formatVersion) +} @@ -5,7 +5,7 @@ go 1.13 require ( github.com/99designs/gqlgen v0.10.3-0.20200209012558-b7a58a1c0e4b github.com/99designs/keyring v1.1.6 - github.com/MichaelMure/go-term-text v0.2.9 + github.com/MichaelMure/go-term-text v0.2.10 github.com/araddon/dateparse v0.0.0-20190622164848-0fb0a474d195 github.com/awesome-gocui/gocui v0.6.1-0.20191115151952-a34ffb055986 github.com/blang/semver v3.5.1+incompatible @@ -37,7 +37,7 @@ require ( github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f // indirect github.com/skratchdot/open-golang v0.0.0-20190402232053-79abb63cd66e github.com/spf13/cobra v1.1.1 - github.com/stretchr/testify v1.6.1 + github.com/stretchr/testify v1.7.0 github.com/tecbot/gorocksdb v0.0.0-20191217155057-f0fad39f321c // indirect github.com/vektah/gqlparser v1.3.1 github.com/xanzy/go-gitlab v0.40.1 @@ -47,7 +47,7 @@ require ( golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43 golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208 golang.org/x/sys v0.0.0-20201020230747-6e5568b54d1a // indirect - golang.org/x/text v0.3.4 + golang.org/x/text v0.3.5 golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e // indirect google.golang.org/appengine v1.6.7 // indirect ) @@ -43,6 +43,7 @@ github.com/Masterminds/semver v1.4.2/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF0 github.com/Masterminds/vcs v1.13.0/go.mod h1:N09YCmOQr6RLxC6UNHzuVwAdodYbbnycGHSmwVJjcKA= github.com/MichaelMure/go-term-text v0.2.9 h1:jUxInT3rDhl4WoJgLnmMS3hR79zigyJS1TqKFDTI6xE= github.com/MichaelMure/go-term-text v0.2.9/go.mod h1:2QSU/Nn2u41Tqoar+90RlYuhjngJPYgod7evnsYwkWc= +github.com/MichaelMure/go-term-text v0.2.10/go.mod h1:DrWFodEEZsSgK1PQY9dqTn+pw3zGeYDmVF5PA8ECZhs= github.com/Microsoft/go-winio v0.4.14 h1:+hMXMk01us9KgxGb7ftKQt2Xpf5hH/yky+TDA+qxleU= github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= @@ -77,6 +78,7 @@ github.com/blevesearch/bleve v1.0.10/go.mod h1:KHAOH5HuVGn9fo+dN5TkqcA1HcuOQ89go github.com/blevesearch/bleve v1.0.12/go.mod h1:G0ErXWdIrUSYZLPoMpS9Z3saTnTsk4ebhPsVv/+0nxk= github.com/blevesearch/bleve v1.0.13 h1:NtqdA+2UL715y2/9Epg9Ie9uspNcilGMYNM+tT+HfAo= github.com/blevesearch/bleve v1.0.13/go.mod h1:3y+16vR4Cwtis/bOGCt7r+CHKB2/ewizEqKBUycXomA= +github.com/blevesearch/bleve v1.0.14 h1:Q8r+fHTt35jtGXJUM0ULwM3Tzg+MRfyai4ZkWDy2xO4= github.com/blevesearch/bleve v1.0.14/go.mod h1:e/LJTr+E7EaoVdkQZTfoz7dt4KoDNvDbLb8MSKuNTLQ= github.com/blevesearch/blevex v0.0.0-20190916190636-152f0fe5c040 h1:SjYVcfJVZoCfBlg+fkaq2eoZHTf5HaJfaTeTkOtyfHQ= github.com/blevesearch/blevex v0.0.0-20190916190636-152f0fe5c040/go.mod h1:WH+MU2F4T0VmSdaPX+Wu5GYoZBrYWdOZWSjzvYcDmqQ= @@ -95,28 +97,33 @@ github.com/blevesearch/zap/v11 v11.0.10/go.mod h1:BdqdgKy6u0Jgw/CqrMfP2Gue/Eldcf github.com/blevesearch/zap/v11 v11.0.12/go.mod h1:JLfFhc8DWP01zMG/6VwEY2eAnlJsTN1vDE4S0rC5Y78= github.com/blevesearch/zap/v11 v11.0.13 h1:NDvmjAyeEQsBbPElubVPqrBtSDOftXYwxkHeZfflU4A= github.com/blevesearch/zap/v11 v11.0.13/go.mod h1:qKkNigeXbxZwym02wsxoQpbme1DgAwTvRlT/beIGfTM= +github.com/blevesearch/zap/v11 v11.0.14 h1:IrDAvtlzDylh6H2QCmS0OGcN9Hpf6mISJlfKjcwJs7k= github.com/blevesearch/zap/v11 v11.0.14/go.mod h1:MUEZh6VHGXv1PKx3WnCbdP404LGG2IZVa/L66pyFwnY= github.com/blevesearch/zap/v12 v12.0.10 h1:T1/GXNBxC9eetfuMwCM5RLWXeharSMyAdNEdXVtBuHA= github.com/blevesearch/zap/v12 v12.0.10/go.mod h1:QtKkjpmV/sVFEnKSaIWPXZJAaekL97TrTV3ImhNx+nw= github.com/blevesearch/zap/v12 v12.0.12/go.mod h1:1HrB4hhPfI8u8x4SPYbluhb8xhflpPvvj8EcWImNnJY= github.com/blevesearch/zap/v12 v12.0.13 h1:05Ebdmv2tRTUytypG4DlOIHLLw995DtVV0Zl3YwwDew= github.com/blevesearch/zap/v12 v12.0.13/go.mod h1:0RTeU1uiLqsPoybUn6G/Zgy6ntyFySL3uWg89NgX3WU= +github.com/blevesearch/zap/v12 v12.0.14 h1:2o9iRtl1xaRjsJ1xcqTyLX414qPAwykHNV7wNVmbp3w= github.com/blevesearch/zap/v12 v12.0.14/go.mod h1:rOnuZOiMKPQj18AEKEHJxuI14236tTQ1ZJz4PAnWlUg= github.com/blevesearch/zap/v13 v13.0.2 h1:quhI5OVFX33dhPpUW+nLyXGpu7QT8qTgzu6qA/fRRXM= github.com/blevesearch/zap/v13 v13.0.2/go.mod h1:/9QLKla8/8mloJvQQutPhB+tw6y35urvKeAFeun2JGA= github.com/blevesearch/zap/v13 v13.0.4/go.mod h1:YdB7UuG7TBWu/1dz9e2SaLp1RKfFfdJx+ulIK5HR1bA= github.com/blevesearch/zap/v13 v13.0.5 h1:+Gcwl95uei3MgBlJAddBFRv9gl+FMNcXpMa7BX3byJw= github.com/blevesearch/zap/v13 v13.0.5/go.mod h1:HTfWECmzBN7BbdBxdEigpUsD6MOPFOO84tZ0z/g3CnE= +github.com/blevesearch/zap/v13 v13.0.6 h1:r+VNSVImi9cBhTNNR+Kfl5uiGy8kIbb0JMz/h8r6+O4= github.com/blevesearch/zap/v13 v13.0.6/go.mod h1:L89gsjdRKGyGrRN6nCpIScCvvkyxvmeDCwZRcjjPCrw= github.com/blevesearch/zap/v14 v14.0.1 h1:s8KeqX53Vc4eRaziHsnY2bYUE+8IktWqRL9W5H5VDMY= github.com/blevesearch/zap/v14 v14.0.1/go.mod h1:Y+tUL9TypMca5+96m7iJb2lpcntETXSeDoI5BBX2tvY= github.com/blevesearch/zap/v14 v14.0.3/go.mod h1:oObAhcDHw7p1ahiTCqhRkdxdl7UA8qpvX10pSgrTMHc= github.com/blevesearch/zap/v14 v14.0.4 h1:BnWWkdgmPhK50J9dkBlQrWB4UDa22OMPIUzn1oXcXfY= github.com/blevesearch/zap/v14 v14.0.4/go.mod h1:sTwuFoe1n/+VtaHNAjY3W5GzHZ5UxFkw1MZ82P/WKpA= +github.com/blevesearch/zap/v14 v14.0.5 h1:NdcT+81Nvmp2zL+NhwSvGSLh7xNgGL8QRVZ67njR0NU= github.com/blevesearch/zap/v14 v14.0.5/go.mod h1:bWe8S7tRrSBTIaZ6cLRbgNH4TUDaC9LZSpRGs85AsGY= github.com/blevesearch/zap/v15 v15.0.1/go.mod h1:ho0frqAex2ktT9cYFAxQpoQXsxb/KEfdjpx4s49rf/M= github.com/blevesearch/zap/v15 v15.0.2 h1:7wV4ksnKzBibLaWBolzbxngxdVAUmF7HJ+gMOqkzsdQ= github.com/blevesearch/zap/v15 v15.0.2/go.mod h1:nfycXPgfbio8l+ZSkXUkhSNjTpp57jZ0/MKa6TigWvM= +github.com/blevesearch/zap/v15 v15.0.3 h1:Ylj8Oe+mo0P25tr9iLPp33lN6d4qcztGjaIsP51UxaY= github.com/blevesearch/zap/v15 v15.0.3/go.mod h1:iuwQrImsh1WjWJ0Ue2kBqY83a0rFtJTqfa9fp1rbVVU= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= @@ -357,6 +364,7 @@ github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Ky github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= @@ -414,6 +422,7 @@ github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40T github.com/rcrowley/go-metrics v0.0.0-20190826022208-cac0b30c2563/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 h1:OdAsTTz6OkFY5QxjkYwrChwuRruF69c169dPK26NUlk= github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rs/cors v1.6.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU= @@ -466,6 +475,7 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE= github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ= @@ -484,6 +494,7 @@ github.com/willf/bitset v1.1.10 h1:NotGKqX0KwQ72NUzqrjZq5ipPNDQex9lo3WpaS8L2sc= github.com/willf/bitset v1.1.10/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4= github.com/xanzy/go-gitlab v0.39.0 h1:7aiZ03fJfCdqoHFhsZq/SoVYp2lR91hfYWmiXLOU5Qo= github.com/xanzy/go-gitlab v0.39.0/go.mod h1:sPLojNBn68fMUWSxIJtdVVIP8uSBYqesTfDUseX11Ug= +github.com/xanzy/go-gitlab v0.40.1 h1:jHueLh5Inzv20TL5Yki+CaLmyvtw3Yq7blbWx7GmglQ= github.com/xanzy/go-gitlab v0.40.1/go.mod h1:sPLojNBn68fMUWSxIJtdVVIP8uSBYqesTfDUseX11Ug= github.com/xanzy/ssh-agent v0.2.1/go.mod h1:mLlQY/MoOhWBj+gOGMQkOeiEvkx+8pJSI+0Bx9h2kr4= github.com/xanzy/ssh-agent v0.3.0 h1:wUMzuKtKilRgBAD1sUb8gOwwRr2FGoBVumcjoOACClI= @@ -646,6 +657,7 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.4 h1:0YWbFKbhXG/wIiuHDSKpS0Iy7FSA+u45VtBMfQcFTTc= golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= diff --git a/identity/version.go b/identity/version.go index 73e4d7c7..bbf93575 100644 --- a/identity/version.go +++ b/identity/version.go @@ -8,6 +8,7 @@ import ( "github.com/pkg/errors" + "github.com/MichaelMure/git-bug/entity" "github.com/MichaelMure/git-bug/repository" "github.com/MichaelMure/git-bug/util/lamport" "github.com/MichaelMure/git-bug/util/text" @@ -102,8 +103,11 @@ func (v *Version) UnmarshalJSON(data []byte) error { return err } - if aux.FormatVersion != formatVersion { - return fmt.Errorf("unknown format version %v", aux.FormatVersion) + if aux.FormatVersion < formatVersion { + return entity.NewErrOldFormatVersion(aux.FormatVersion) + } + if aux.FormatVersion > formatVersion { + return entity.NewErrNewFormatVersion(aux.FormatVersion) } v.time = aux.Time diff --git a/webui/package-lock.json b/webui/package-lock.json index 185c4c0c..7d9d8503 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -17570,9 +17570,9 @@ "integrity": "sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw=" }, "prettier": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.1.2.tgz", - "integrity": "sha512-16c7K+x4qVlJg9rEbXl7HEGmQyZlG4R9AgP+oHKRMsMsuk8s+ATStlf1NpDqyBI1HpVyfjLOeMhH2LvuNvV5Vg==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.2.1.tgz", + "integrity": "sha512-PqyhM2yCjg/oKkFPtTGUojv7gnZAoG80ttl45O6x2Ug/rMJw4wcc9k6aaf2hibP7BGVCCM33gZoGjyvt9mm16Q==", "dev": true }, "prettier-linter-helpers": { diff --git a/webui/package.json b/webui/package.json index 4f2a8da4..39696a25 100644 --- a/webui/package.json +++ b/webui/package.json @@ -38,7 +38,7 @@ "eslint-config-prettier": "^6.12.0", "eslint-plugin-graphql": "^4.0.0", "eslint-plugin-prettier": "^3.1.4", - "prettier": "^2.1.2" + "prettier": "^2.2.1" }, "scripts": { "start": "npm run generate && react-scripts start", diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 16663870..3a5ef025 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -4,12 +4,14 @@ import { Route, Switch } from 'react-router'; import Layout from './layout'; import BugPage from './pages/bug'; import ListPage from './pages/list'; +import NewBugPage from './pages/new/NewBugPage'; export default function App() { return ( <Layout> <Switch> <Route path="/" exact component={ListPage} /> + <Route path="/new" exact component={NewBugPage} /> <Route path="/bug/:id" exact component={BugPage} /> </Switch> </Layout> diff --git a/webui/src/components/BugTitleForm/BugTitleForm.tsx b/webui/src/components/BugTitleForm/BugTitleForm.tsx new file mode 100644 index 00000000..16441c93 --- /dev/null +++ b/webui/src/components/BugTitleForm/BugTitleForm.tsx @@ -0,0 +1,197 @@ +import React, { useState } from 'react'; + +import { + Button, + fade, + makeStyles, + TextField, + Typography, +} from '@material-ui/core'; + +import { TimelineDocument } from '../../pages/bug/TimelineQuery.generated'; +import Author from 'src/components/Author'; +import Date from 'src/components/Date'; +import { BugFragment } from 'src/pages/bug/Bug.generated'; + +import { useSetTitleMutation } from './SetTitle.generated'; + +/** + * Css in JS styles + */ +const useStyles = makeStyles((theme) => ({ + header: { + display: 'flex', + flexDirection: 'column', + }, + headerTitle: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + }, + readOnlyTitle: { + ...theme.typography.h5, + }, + readOnlyId: { + ...theme.typography.subtitle1, + marginLeft: theme.spacing(1), + }, + editButtonContainer: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'flex-start', + alignItems: 'center', + minWidth: 200, + marginLeft: theme.spacing(2), + }, + greenButton: { + marginLeft: '8px', + backgroundColor: '#2ea44fd9', + color: '#fff', + '&:hover': { + backgroundColor: '#2ea44f', + }, + }, + titleInput: { + borderRadius: theme.shape.borderRadius, + borderColor: fade(theme.palette.primary.main, 0.2), + borderStyle: 'solid', + borderWidth: '1px', + backgroundColor: fade(theme.palette.primary.main, 0.05), + padding: theme.spacing(0, 0), + minWidth: 336, + transition: theme.transitions.create([ + 'width', + 'borderColor', + 'backgroundColor', + ]), + }, +})); + +interface Props { + bug: BugFragment; +} + +/** + * Component for bug title change + * @param bug Selected bug in list page + */ +function BugTitleForm({ bug }: Props) { + const [bugTitleEdition, setbugTitleEdition] = useState(false); + const [setTitle, { loading, error }] = useSetTitleMutation(); + const [issueTitle, setIssueTitle] = useState(bug.title); + const classes = useStyles(); + let issueTitleInput: any; + + function isFormValid() { + if (issueTitleInput) { + return issueTitleInput.value.length > 0 ? true : false; + } else { + return false; + } + } + + function submitNewTitle() { + if (!isFormValid()) return; + setTitle({ + variables: { + input: { + prefix: bug.humanId, + title: issueTitleInput.value, + }, + }, + refetchQueries: [ + // TODO: update the cache instead of refetching + { + query: TimelineDocument, + variables: { + id: bug.id, + first: 100, + }, + }, + ], + awaitRefetchQueries: true, + }).then(() => setbugTitleEdition(false)); + } + + function cancelChange() { + setIssueTitle(bug.title); + setbugTitleEdition(false); + } + + function editableBugTitle() { + return ( + <form className={classes.headerTitle} onSubmit={submitNewTitle}> + <TextField + inputRef={(node) => { + issueTitleInput = node; + }} + className={classes.titleInput} + variant="outlined" + fullWidth + margin="dense" + value={issueTitle} + onChange={(event: any) => setIssueTitle(event.target.value)} + /> + <div className={classes.editButtonContainer}> + <Button + size="small" + variant="contained" + type="submit" + disabled={issueTitle.length === 0} + > + Save + </Button> + <Button size="small" onClick={() => cancelChange()}> + Cancel + </Button> + </div> + </form> + ); + } + + function readonlyBugTitle() { + return ( + <div className={classes.headerTitle}> + <div> + <span className={classes.readOnlyTitle}>{bug.title}</span> + <span className={classes.readOnlyId}>{bug.humanId}</span> + </div> + <div className={classes.editButtonContainer}> + <Button + size="small" + variant="contained" + onClick={() => setbugTitleEdition(!bugTitleEdition)} + > + Edit + </Button> + <Button + className={classes.greenButton} + size="small" + variant="contained" + href="/new" + > + New issue + </Button> + </div> + </div> + ); + } + + if (loading) return <div>Loading...</div>; + if (error) return <div>Error</div>; + + return ( + <div className={classes.header}> + {bugTitleEdition ? editableBugTitle() : readonlyBugTitle()} + <div className="classes.headerSubtitle"> + <Typography color={'textSecondary'}> + <Author author={bug.author} /> + {' opened this bug '} + <Date date={bug.createdAt} /> + </Typography> + </div> + </div> + ); +} + +export default BugTitleForm; diff --git a/webui/src/components/BugTitleForm/SetTitle.graphql b/webui/src/components/BugTitleForm/SetTitle.graphql new file mode 100644 index 00000000..b96af155 --- /dev/null +++ b/webui/src/components/BugTitleForm/SetTitle.graphql @@ -0,0 +1,7 @@ +mutation setTitle($input: SetTitleInput!) { + setTitle(input: $input) { + bug { + id + } + } +}
\ No newline at end of file diff --git a/webui/src/components/CloseBugButton/CloseBug.graphql b/webui/src/components/CloseBugButton/CloseBug.graphql new file mode 100644 index 00000000..e2f4bff2 --- /dev/null +++ b/webui/src/components/CloseBugButton/CloseBug.graphql @@ -0,0 +1,8 @@ +# Write your query or mutation here +mutation closeBug($input: CloseBugInput!) { + closeBug(input: $input) { + bug { + id + } + } +}
\ No newline at end of file diff --git a/webui/src/components/CloseBugButton/CloseBugButton.tsx b/webui/src/components/CloseBugButton/CloseBugButton.tsx new file mode 100644 index 00000000..19f56cab --- /dev/null +++ b/webui/src/components/CloseBugButton/CloseBugButton.tsx @@ -0,0 +1,55 @@ +import React from 'react'; + +import Button from '@material-ui/core/Button'; + +import { BugFragment } from 'src/pages/bug/Bug.generated'; +import { TimelineDocument } from 'src/pages/bug/TimelineQuery.generated'; + +import { useCloseBugMutation } from './CloseBug.generated'; + +interface Props { + bug: BugFragment; + disabled: boolean; +} + +function CloseBugButton({ bug, disabled }: Props) { + const [closeBug, { loading, error }] = useCloseBugMutation(); + + function closeBugAction() { + closeBug({ + variables: { + input: { + prefix: bug.id, + }, + }, + refetchQueries: [ + // TODO: update the cache instead of refetching + { + query: TimelineDocument, + variables: { + id: bug.id, + first: 100, + }, + }, + ], + awaitRefetchQueries: true, + }); + } + + if (loading) return <div>Loading...</div>; + if (error) return <div>Error</div>; + + return ( + <div> + <Button + variant="contained" + onClick={() => closeBugAction()} + disabled={bug.status === 'CLOSED' || disabled} + > + Close issue + </Button> + </div> + ); +} + +export default CloseBugButton; diff --git a/webui/src/components/ReopenBugButton/OpenBug.graphql b/webui/src/components/ReopenBugButton/OpenBug.graphql new file mode 100644 index 00000000..cf9e49e5 --- /dev/null +++ b/webui/src/components/ReopenBugButton/OpenBug.graphql @@ -0,0 +1,7 @@ +mutation openBug($input: OpenBugInput!) { + openBug(input: $input) { + bug { + id + } + } +}
\ No newline at end of file diff --git a/webui/src/components/ReopenBugButton/ReopenBugButton.tsx b/webui/src/components/ReopenBugButton/ReopenBugButton.tsx new file mode 100644 index 00000000..195ca512 --- /dev/null +++ b/webui/src/components/ReopenBugButton/ReopenBugButton.tsx @@ -0,0 +1,55 @@ +import React from 'react'; + +import Button from '@material-ui/core/Button'; + +import { BugFragment } from 'src/pages/bug/Bug.generated'; +import { TimelineDocument } from 'src/pages/bug/TimelineQuery.generated'; + +import { useOpenBugMutation } from './OpenBug.generated'; + +interface Props { + bug: BugFragment; + disabled: boolean; +} + +function ReopenBugButton({ bug, disabled }: Props) { + const [openBug, { loading, error }] = useOpenBugMutation(); + + function openBugAction() { + openBug({ + variables: { + input: { + prefix: bug.id, + }, + }, + refetchQueries: [ + // TODO: update the cache instead of refetching + { + query: TimelineDocument, + variables: { + id: bug.id, + first: 100, + }, + }, + ], + awaitRefetchQueries: true, + }); + } + + if (loading) return <div>Loading...</div>; + if (error) return <div>Error</div>; + + return ( + <div> + <Button + variant="contained" + onClick={() => openBugAction()} + disabled={bug.status === 'OPEN' || disabled} + > + Reopen issue + </Button> + </div> + ); +} + +export default ReopenBugButton; diff --git a/webui/src/layout/CommentInput/CommentInput.tsx b/webui/src/layout/CommentInput/CommentInput.tsx new file mode 100644 index 00000000..86cc7dbb --- /dev/null +++ b/webui/src/layout/CommentInput/CommentInput.tsx @@ -0,0 +1,107 @@ +import React, { useState, useEffect } from 'react'; + +import Tab from '@material-ui/core/Tab'; +import Tabs from '@material-ui/core/Tabs'; +import TextField from '@material-ui/core/TextField'; +import { makeStyles } from '@material-ui/core/styles'; + +import Content from 'src/components/Content'; + +/** + * Styles + */ +const useStyles = makeStyles((theme) => ({ + container: { + margin: theme.spacing(2, 0), + padding: theme.spacing(0, 2, 2, 2), + }, + textarea: {}, + tabContent: { + margin: theme.spacing(2, 0), + }, + preview: { + borderBottom: `solid 3px ${theme.palette.grey['200']}`, + minHeight: '5rem', + }, +})); + +type TabPanelProps = { + children: React.ReactNode; + value: number; + index: number; +} & React.HTMLProps<HTMLDivElement>; +function TabPanel({ children, value, index, ...props }: TabPanelProps) { + return ( + <div + role="tabpanel" + hidden={value !== index} + id={`editor-tabpanel-${index}`} + aria-labelledby={`editor-tab-${index}`} + {...props} + > + {value === index && children} + </div> + ); +} + +const a11yProps = (index: number) => ({ + id: `editor-tab-${index}`, + 'aria-controls': `editor-tabpanel-${index}`, +}); + +type Props = { + inputProps?: any; + loading: boolean; + onChange: (comment: string) => void; +}; + +/** + * Component for issue comment input + * + * @param inputProps Reset input value + * @param loading Disable input when component not ready yet + * @param onChange Callback to return input value changes + */ +function CommentInput({ inputProps, loading, onChange }: Props) { + const [input, setInput] = useState<string>(''); + const [tab, setTab] = useState(0); + const classes = useStyles(); + + useEffect(() => { + if (inputProps) setInput(inputProps.value); + }, [inputProps]); + + useEffect(() => { + onChange(input); + }, [input, onChange]); + + return ( + <div> + <Tabs value={tab} onChange={(_, t) => setTab(t)}> + <Tab label="Write" {...a11yProps(0)} /> + <Tab label="Preview" {...a11yProps(1)} /> + </Tabs> + <div className={classes.tabContent}> + <TabPanel value={tab} index={0}> + <TextField + fullWidth + label="Comment" + placeholder="Leave a comment" + className={classes.textarea} + multiline + value={input} + variant="filled" + rows="4" // TODO: rowsMin support + onChange={(e: any) => setInput(e.target.value)} + disabled={loading} + /> + </TabPanel> + <TabPanel value={tab} index={1} className={classes.preview}> + <Content markdown={input} /> + </TabPanel> + </div> + </div> + ); +} + +export default CommentInput; diff --git a/webui/src/pages/bug/Bug.tsx b/webui/src/pages/bug/Bug.tsx index 8d6d11cc..bd6e44c4 100644 --- a/webui/src/pages/bug/Bug.tsx +++ b/webui/src/pages/bug/Bug.tsx @@ -1,10 +1,8 @@ import React from 'react'; -import Typography from '@material-ui/core/Typography/Typography'; import { makeStyles } from '@material-ui/core/styles'; -import Author from 'src/components/Author'; -import Date from 'src/components/Date'; +import BugTitleForm from 'src/components/BugTitleForm/BugTitleForm'; import Label from 'src/components/Label'; import IfLoggedIn from 'src/layout/IfLoggedIn'; @@ -12,21 +10,19 @@ import { BugFragment } from './Bug.generated'; import CommentForm from './CommentForm'; import TimelineQuery from './TimelineQuery'; +/** + * Css in JS Styles + */ const useStyles = makeStyles((theme) => ({ main: { maxWidth: 1000, margin: 'auto', marginTop: theme.spacing(4), + overflow: 'hidden', }, header: { marginLeft: theme.spacing(3) + 40, - }, - title: { - ...theme.typography.h5, - }, - id: { - ...theme.typography.subtitle1, - marginLeft: theme.spacing(1), + marginRight: theme.spacing(2), }, container: { display: 'flex', @@ -73,17 +69,11 @@ type Props = { function Bug({ bug }: Props) { const classes = useStyles(); + return ( <main className={classes.main}> <div className={classes.header}> - <span className={classes.title}>{bug.title}</span> - <span className={classes.id}>{bug.humanId}</span> - - <Typography color={'textSecondary'}> - <Author author={bug.author} /> - {' opened this bug '} - <Date date={bug.createdAt} /> - </Typography> + <BugTitleForm bug={bug} /> </div> <div className={classes.container}> @@ -92,7 +82,7 @@ function Bug({ bug }: Props) { <IfLoggedIn> {() => ( <div className={classes.commentForm}> - <CommentForm bugId={bug.id} /> + <CommentForm bug={bug} /> </div> )} </IfLoggedIn> diff --git a/webui/src/pages/bug/CommentForm.tsx b/webui/src/pages/bug/CommentForm.tsx index f2a2eb6c..c623dabb 100644 --- a/webui/src/pages/bug/CommentForm.tsx +++ b/webui/src/pages/bug/CommentForm.tsx @@ -2,13 +2,13 @@ import React, { useState, useRef } from 'react'; import Button from '@material-ui/core/Button'; import Paper from '@material-ui/core/Paper'; -import Tab from '@material-ui/core/Tab'; -import Tabs from '@material-ui/core/Tabs'; -import TextField from '@material-ui/core/TextField'; import { makeStyles, Theme } from '@material-ui/core/styles'; -import Content from 'src/components/Content'; +import CommentInput from '../../layout/CommentInput/CommentInput'; +import CloseBugButton from 'src/components/CloseBugButton/CloseBugButton'; +import ReopenBugButton from 'src/components/ReopenBugButton/ReopenBugButton'; +import { BugFragment } from './Bug.generated'; import { useAddCommentMutation } from './CommentForm.generated'; import { TimelineDocument } from './TimelineQuery.generated'; @@ -30,40 +30,24 @@ const useStyles = makeStyles<Theme, StyleProps>((theme) => ({ display: 'flex', justifyContent: 'flex-end', }, + greenButton: { + marginLeft: '8px', + backgroundColor: '#2ea44fd9', + color: '#fff', + '&:hover': { + backgroundColor: '#2ea44f', + }, + }, })); -type TabPanelProps = { - children: React.ReactNode; - value: number; - index: number; -} & React.HTMLProps<HTMLDivElement>; -function TabPanel({ children, value, index, ...props }: TabPanelProps) { - return ( - <div - role="tabpanel" - hidden={value !== index} - id={`editor-tabpanel-${index}`} - aria-labelledby={`editor-tab-${index}`} - {...props} - > - {value === index && children} - </div> - ); -} - -const a11yProps = (index: number) => ({ - id: `editor-tab-${index}`, - 'aria-controls': `editor-tabpanel-${index}`, -}); - type Props = { - bugId: string; + bug: BugFragment; }; -function CommentForm({ bugId }: Props) { +function CommentForm({ bug }: Props) { const [addComment, { loading }] = useAddCommentMutation(); - const [input, setInput] = useState<string>(''); - const [tab, setTab] = useState(0); + const [issueComment, setIssueComment] = useState(''); + const [inputProp, setInputProp] = useState<any>(''); const classes = useStyles({ loading }); const form = useRef<HTMLFormElement>(null); @@ -71,8 +55,8 @@ function CommentForm({ bugId }: Props) { addComment({ variables: { input: { - prefix: bugId, - message: input, + prefix: bug.id, + message: issueComment, }, }, refetchQueries: [ @@ -80,60 +64,50 @@ function CommentForm({ bugId }: Props) { { query: TimelineDocument, variables: { - id: bugId, + id: bug.id, first: 100, }, }, ], awaitRefetchQueries: true, - }).then(() => setInput('')); + }).then(() => resetForm()); }; + function resetForm() { + setInputProp({ + value: '', + }); + } + const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { e.preventDefault(); - submit(); + if (issueComment.length > 0) submit(); }; - const handleKeyDown = (e: React.KeyboardEvent<HTMLElement>) => { - // Submit on cmd/ctrl+enter - if ((e.metaKey || e.altKey) && e.keyCode === 13) { - submit(); - } - }; + function getCloseButton() { + return <CloseBugButton bug={bug} disabled={issueComment.length > 0} />; + } + + function getReopenButton() { + return <ReopenBugButton bug={bug} disabled={issueComment.length > 0} />; + } return ( <Paper className={classes.container}> <form onSubmit={handleSubmit} ref={form}> - <Tabs value={tab} onChange={(_, t) => setTab(t)}> - <Tab label="Write" {...a11yProps(0)} /> - <Tab label="Preview" {...a11yProps(1)} /> - </Tabs> - <div className={classes.tabContent}> - <TabPanel value={tab} index={0}> - <TextField - onKeyDown={handleKeyDown} - fullWidth - label="Comment" - placeholder="Leave a comment" - className={classes.textarea} - multiline - value={input} - variant="filled" - rows="4" // TODO: rowsMin support - onChange={(e: any) => setInput(e.target.value)} - disabled={loading} - /> - </TabPanel> - <TabPanel value={tab} index={1} className={classes.preview}> - <Content markdown={input} /> - </TabPanel> - </div> + <CommentInput + inputProps={inputProp} + loading={loading} + onChange={(comment: string) => setIssueComment(comment)} + /> <div className={classes.actions}> + {bug.status === 'OPEN' ? getCloseButton() : getReopenButton()} <Button + className={classes.greenButton} variant="contained" color="primary" type="submit" - disabled={loading} + disabled={loading || issueComment.length === 0} > Comment </Button> diff --git a/webui/src/pages/list/ListQuery.tsx b/webui/src/pages/list/ListQuery.tsx index 7eb6f4c5..424ffac0 100644 --- a/webui/src/pages/list/ListQuery.tsx +++ b/webui/src/pages/list/ListQuery.tsx @@ -2,6 +2,7 @@ import { ApolloError } from '@apollo/client'; import React, { useState, useEffect, useRef } from 'react'; import { useLocation, useHistory, Link } from 'react-router-dom'; +import { Button } 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'; @@ -40,6 +41,17 @@ const useStyles = makeStyles<Theme, StylesProps>((theme) => ({ alignItems: 'center', justifyContent: 'space-between', }, + filterissueLabel: { + fontSize: '14px', + fontWeight: 'bold', + paddingRight: '12px', + }, + filterissueContainer: { + display: 'flex', + flexDirection: 'row', + alignItems: 'flex-start', + justifyContents: 'left', + }, search: { borderRadius: theme.shape.borderRadius, borderColor: fade(theme.palette.primary.main, 0.2), @@ -95,6 +107,13 @@ const useStyles = makeStyles<Theme, StylesProps>((theme) => ({ padding: theme.spacing(2, 3), }, }, + greenButton: { + backgroundColor: '#2ea44fd9', + color: '#fff', + '&:hover': { + backgroundColor: '#2ea44f', + }, + }, })); function editParams( @@ -271,21 +290,29 @@ function ListQuery() { return ( <Paper className={classes.main}> <header className={classes.header}> - <h1>Issues</h1> - <form onSubmit={formSubmit}> - <InputBase - 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> + <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, + }} + /> + <button type="submit" hidden> + Search + </button> + </form> + </div> + <Button className={classes.greenButton} variant="contained" href="/new"> + New issue + </Button> </header> <FilterToolbar query={query} queryLocation={queryLocation} /> {content} diff --git a/webui/src/pages/new/NewBug.graphql b/webui/src/pages/new/NewBug.graphql new file mode 100644 index 00000000..92df016e --- /dev/null +++ b/webui/src/pages/new/NewBug.graphql @@ -0,0 +1,7 @@ +mutation newBug($input: NewBugInput!) { + newBug(input: $input) { + bug { + humanId + } + } +}
\ No newline at end of file diff --git a/webui/src/pages/new/NewBugPage.tsx b/webui/src/pages/new/NewBugPage.tsx new file mode 100644 index 00000000..c70cddaa --- /dev/null +++ b/webui/src/pages/new/NewBugPage.tsx @@ -0,0 +1,118 @@ +import React, { FormEvent, useState } from 'react'; + +import { Button } from '@material-ui/core'; +import Paper from '@material-ui/core/Paper'; +import TextField from '@material-ui/core/TextField/TextField'; +import { fade, makeStyles, Theme } from '@material-ui/core/styles'; + +import CommentInput from '../../layout/CommentInput/CommentInput'; + +import { useNewBugMutation } from './NewBug.generated'; + +/** + * Css in JS styles + */ +const useStyles = makeStyles((theme: Theme) => ({ + main: { + maxWidth: 800, + margin: 'auto', + marginTop: theme.spacing(4), + marginBottom: theme.spacing(4), + padding: theme.spacing(2), + overflow: 'hidden', + }, + titleInput: { + borderRadius: theme.shape.borderRadius, + borderColor: fade(theme.palette.primary.main, 0.2), + borderStyle: 'solid', + borderWidth: '1px', + backgroundColor: fade(theme.palette.primary.main, 0.05), + padding: theme.spacing(0, 0), + transition: theme.transitions.create([ + 'width', + 'borderColor', + 'backgroundColor', + ]), + }, + form: { + display: 'flex', + flexDirection: 'column', + }, + actions: { + display: 'flex', + justifyContent: 'flex-end', + }, + greenButton: { + backgroundColor: '#2ea44fd9', + color: '#fff', + '&:hover': { + backgroundColor: '#2ea44f', + }, + }, +})); + +/** + * Form to create a new issue + */ +function NewBugPage() { + const [newBug, { loading, error }] = useNewBugMutation(); + const [issueTitle, setIssueTitle] = useState(''); + const [issueComment, setIssueComment] = useState(''); + const classes = useStyles(); + let issueTitleInput: any; + + function submitNewIssue(e: FormEvent) { + e.preventDefault(); + if (!isFormValid()) return; + newBug({ + variables: { + input: { + title: issueTitle, + message: issueComment, + }, + }, + }); + issueTitleInput.value = ''; + } + + function isFormValid() { + return issueTitle.length > 0 && issueComment.length > 0 ? true : false; + } + + if (loading) return <div>Loading...</div>; + if (error) return <div>Error</div>; + + return ( + <Paper className={classes.main}> + <form className={classes.form} onSubmit={submitNewIssue}> + <TextField + inputRef={(node) => { + issueTitleInput = node; + }} + label="Title" + className={classes.titleInput} + variant="outlined" + fullWidth + margin="dense" + onChange={(event: any) => setIssueTitle(event.target.value)} + /> + <CommentInput + loading={false} + onChange={(comment: string) => setIssueComment(comment)} + /> + <div className={classes.actions}> + <Button + className={classes.greenButton} + variant="contained" + type="submit" + disabled={isFormValid() ? false : true} + > + Submit new issue + </Button> + </div> + </form> + </Paper> + ); +} + +export default NewBugPage; |