Detailed changes
@@ -193,6 +193,12 @@ type ComplexityRoot struct {
Target func(childComplexity int) int
}
+ EditCommentPayload struct {
+ Bug func(childComplexity int) int
+ ClientMutationID func(childComplexity int) int
+ Operation func(childComplexity int) int
+ }
+
Identity struct {
AvatarUrl func(childComplexity int) int
DisplayName func(childComplexity int) int
@@ -258,6 +264,7 @@ type ComplexityRoot struct {
AddComment func(childComplexity int, input models.AddCommentInput) int
ChangeLabels func(childComplexity int, input *models.ChangeLabelInput) int
CloseBug func(childComplexity int, input models.CloseBugInput) int
+ EditComment func(childComplexity int, input models.EditCommentInput) int
NewBug func(childComplexity int, input models.NewBugInput) int
OpenBug func(childComplexity int, input models.OpenBugInput) int
SetTitle func(childComplexity int, input models.SetTitleInput) int
@@ -433,6 +440,7 @@ type LabelChangeTimelineItemResolver interface {
type MutationResolver interface {
NewBug(ctx context.Context, input models.NewBugInput) (*models.NewBugPayload, error)
AddComment(ctx context.Context, input models.AddCommentInput) (*models.AddCommentPayload, error)
+ EditComment(ctx context.Context, input models.EditCommentInput) (*models.EditCommentPayload, error)
ChangeLabels(ctx context.Context, input *models.ChangeLabelInput) (*models.ChangeLabelPayload, error)
OpenBug(ctx context.Context, input models.OpenBugInput) (*models.OpenBugPayload, error)
CloseBug(ctx context.Context, input models.CloseBugInput) (*models.CloseBugPayload, error)
@@ -1059,6 +1067,27 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.EditCommentOperation.Target(childComplexity), true
+ case "EditCommentPayload.bug":
+ if e.complexity.EditCommentPayload.Bug == nil {
+ break
+ }
+
+ return e.complexity.EditCommentPayload.Bug(childComplexity), true
+
+ case "EditCommentPayload.clientMutationId":
+ if e.complexity.EditCommentPayload.ClientMutationID == nil {
+ break
+ }
+
+ return e.complexity.EditCommentPayload.ClientMutationID(childComplexity), true
+
+ case "EditCommentPayload.operation":
+ if e.complexity.EditCommentPayload.Operation == nil {
+ break
+ }
+
+ return e.complexity.EditCommentPayload.Operation(childComplexity), true
+
case "Identity.avatarUrl":
if e.complexity.Identity.AvatarUrl == nil {
break
@@ -1333,6 +1362,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.Mutation.CloseBug(childComplexity, args["input"].(models.CloseBugInput)), true
+ case "Mutation.editComment":
+ if e.complexity.Mutation.EditComment == nil {
+ break
+ }
+
+ args, err := ec.field_Mutation_editComment_args(context.TODO(), rawArgs)
+ if err != nil {
+ return 0, false
+ }
+
+ return e.complexity.Mutation.EditComment(childComplexity, args["input"].(models.EditCommentInput)), true
+
case "Mutation.newBug":
if e.complexity.Mutation.NewBug == nil {
break
@@ -2034,6 +2075,30 @@ type AddCommentPayload {
operation: AddCommentOperation!
}
+input EditCommentInput {
+ """A unique identifier for the client performing the mutation."""
+ clientMutationId: String
+ """"The name of the repository. If not set, the default repository is used."""
+ repoRef: String
+ """The bug ID's prefix."""
+ prefix: String!
+ """The target."""
+ target: String!
+ """The new message to be set."""
+ message: String!
+ """The collection of file's hash required for the first message."""
+ files: [Hash!]
+}
+
+type EditCommentPayload {
+ """A unique identifier for the client performing the mutation."""
+ clientMutationId: String
+ """The affected bug."""
+ bug: Bug!
+ """The resulting operation."""
+ operation: EditCommentOperation!
+}
+
input ChangeLabelInput {
"""A unique identifier for the client performing the mutation."""
clientMutationId: String
@@ -2290,6 +2355,8 @@ type Mutation {
newBug(input: NewBugInput!): NewBugPayload!
"""Add a new comment to a bug"""
addComment(input: AddCommentInput!): AddCommentPayload!
+ """Change a comment of a bug"""
+ editComment(input: EditCommentInput!): EditCommentPayload!
"""Add or remove a set of label on a bug"""
changeLabels(input: ChangeLabelInput): ChangeLabelPayload!
"""Change a bug's status to open"""
@@ -2657,6 +2724,20 @@ func (ec *executionContext) field_Mutation_closeBug_args(ctx context.Context, ra
return args, nil
}
+func (ec *executionContext) field_Mutation_editComment_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) {
+ var err error
+ args := map[string]interface{}{}
+ var arg0 models.EditCommentInput
+ if tmp, ok := rawArgs["input"]; ok {
+ arg0, err = ec.unmarshalNEditCommentInput2githubᚗcomᚋMichaelMureᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐEditCommentInput(ctx, tmp)
+ if err != nil {
+ return nil, err
+ }
+ }
+ args["input"] = arg0
+ return args, nil
+}
+
func (ec *executionContext) field_Mutation_newBug_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) {
var err error
args := map[string]interface{}{}
@@ -5591,6 +5672,105 @@ func (ec *executionContext) _EditCommentOperation_files(ctx context.Context, fie
return ec.marshalNHash2ᚕgithubᚗcomᚋMichaelMureᚋgitᚑbugᚋrepositoryᚐHashᚄ(ctx, field.Selections, res)
}
+func (ec *executionContext) _EditCommentPayload_clientMutationId(ctx context.Context, field graphql.CollectedField, obj *models.EditCommentPayload) (ret graphql.Marshaler) {
+ defer func() {
+ if r := recover(); r != nil {
+ ec.Error(ctx, ec.Recover(ctx, r))
+ ret = graphql.Null
+ }
+ }()
+ fc := &graphql.FieldContext{
+ Object: "EditCommentPayload",
+ Field: field,
+ Args: nil,
+ IsMethod: false,
+ }
+
+ ctx = graphql.WithFieldContext(ctx, fc)
+ resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
+ ctx = rctx // use context from middleware stack in children
+ return obj.ClientMutationID, nil
+ })
+ if err != nil {
+ ec.Error(ctx, err)
+ return graphql.Null
+ }
+ if resTmp == nil {
+ return graphql.Null
+ }
+ res := resTmp.(*string)
+ fc.Result = res
+ return ec.marshalOString2ᚖstring(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) _EditCommentPayload_bug(ctx context.Context, field graphql.CollectedField, obj *models.EditCommentPayload) (ret graphql.Marshaler) {
+ defer func() {
+ if r := recover(); r != nil {
+ ec.Error(ctx, ec.Recover(ctx, r))
+ ret = graphql.Null
+ }
+ }()
+ fc := &graphql.FieldContext{
+ Object: "EditCommentPayload",
+ Field: field,
+ Args: nil,
+ IsMethod: false,
+ }
+
+ ctx = graphql.WithFieldContext(ctx, fc)
+ resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
+ ctx = rctx // use context from middleware stack in children
+ return obj.Bug, nil
+ })
+ if err != nil {
+ ec.Error(ctx, err)
+ return graphql.Null
+ }
+ if resTmp == nil {
+ if !graphql.HasFieldError(ctx, fc) {
+ ec.Errorf(ctx, "must not be null")
+ }
+ return graphql.Null
+ }
+ res := resTmp.(models.BugWrapper)
+ fc.Result = res
+ return ec.marshalNBug2githubᚗcomᚋMichaelMureᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐBugWrapper(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) _EditCommentPayload_operation(ctx context.Context, field graphql.CollectedField, obj *models.EditCommentPayload) (ret graphql.Marshaler) {
+ defer func() {
+ if r := recover(); r != nil {
+ ec.Error(ctx, ec.Recover(ctx, r))
+ ret = graphql.Null
+ }
+ }()
+ fc := &graphql.FieldContext{
+ Object: "EditCommentPayload",
+ Field: field,
+ Args: nil,
+ IsMethod: false,
+ }
+
+ ctx = graphql.WithFieldContext(ctx, fc)
+ resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
+ ctx = rctx // use context from middleware stack in children
+ return obj.Operation, nil
+ })
+ if err != nil {
+ ec.Error(ctx, err)
+ return graphql.Null
+ }
+ if resTmp == nil {
+ if !graphql.HasFieldError(ctx, fc) {
+ ec.Errorf(ctx, "must not be null")
+ }
+ return graphql.Null
+ }
+ res := resTmp.(*bug.EditCommentOperation)
+ fc.Result = res
+ return ec.marshalNEditCommentOperation2ᚖgithubᚗcomᚋMichaelMureᚋgitᚑbugᚋbugᚐEditCommentOperation(ctx, field.Selections, res)
+}
+
func (ec *executionContext) _Identity_id(ctx context.Context, field graphql.CollectedField, obj models.IdentityWrapper) (ret graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
@@ -6817,6 +6997,47 @@ func (ec *executionContext) _Mutation_addComment(ctx context.Context, field grap
return ec.marshalNAddCommentPayload2ᚖgithubᚗcomᚋMichaelMureᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐAddCommentPayload(ctx, field.Selections, res)
}
+func (ec *executionContext) _Mutation_editComment(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
+ defer func() {
+ if r := recover(); r != nil {
+ ec.Error(ctx, ec.Recover(ctx, r))
+ ret = graphql.Null
+ }
+ }()
+ fc := &graphql.FieldContext{
+ Object: "Mutation",
+ Field: field,
+ Args: nil,
+ IsMethod: true,
+ }
+
+ ctx = graphql.WithFieldContext(ctx, fc)
+ rawArgs := field.ArgumentMap(ec.Variables)
+ args, err := ec.field_Mutation_editComment_args(ctx, rawArgs)
+ if err != nil {
+ ec.Error(ctx, err)
+ return graphql.Null
+ }
+ fc.Args = args
+ resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
+ ctx = rctx // use context from middleware stack in children
+ return ec.resolvers.Mutation().EditComment(rctx, args["input"].(models.EditCommentInput))
+ })
+ if err != nil {
+ ec.Error(ctx, err)
+ return graphql.Null
+ }
+ if resTmp == nil {
+ if !graphql.HasFieldError(ctx, fc) {
+ ec.Errorf(ctx, "must not be null")
+ }
+ return graphql.Null
+ }
+ res := resTmp.(*models.EditCommentPayload)
+ fc.Result = res
+ return ec.marshalNEditCommentPayload2ᚖgithubᚗcomᚋMichaelMureᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐEditCommentPayload(ctx, field.Selections, res)
+}
+
func (ec *executionContext) _Mutation_changeLabels(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
@@ -9971,6 +10192,54 @@ func (ec *executionContext) unmarshalInputCloseBugInput(ctx context.Context, obj
return it, nil
}
+func (ec *executionContext) unmarshalInputEditCommentInput(ctx context.Context, obj interface{}) (models.EditCommentInput, error) {
+ var it models.EditCommentInput
+ var asMap = obj.(map[string]interface{})
+
+ for k, v := range asMap {
+ switch k {
+ case "clientMutationId":
+ var err error
+ it.ClientMutationID, err = ec.unmarshalOString2ᚖstring(ctx, v)
+ if err != nil {
+ return it, err
+ }
+ case "repoRef":
+ var err error
+ it.RepoRef, err = ec.unmarshalOString2ᚖstring(ctx, v)
+ if err != nil {
+ return it, err
+ }
+ case "prefix":
+ var err error
+ it.Prefix, err = ec.unmarshalNString2string(ctx, v)
+ if err != nil {
+ return it, err
+ }
+ case "target":
+ var err error
+ it.Target, err = ec.unmarshalNString2string(ctx, v)
+ if err != nil {
+ return it, err
+ }
+ case "message":
+ var err error
+ it.Message, err = ec.unmarshalNString2string(ctx, v)
+ if err != nil {
+ return it, err
+ }
+ case "files":
+ var err error
+ it.Files, err = ec.unmarshalOHash2ᚕgithubᚗcomᚋMichaelMureᚋgitᚑbugᚋrepositoryᚐHashᚄ(ctx, v)
+ if err != nil {
+ return it, err
+ }
+ }
+ }
+
+ return it, nil
+}
+
func (ec *executionContext) unmarshalInputNewBugInput(ctx context.Context, obj interface{}) (models.NewBugInput, error) {
var it models.NewBugInput
var asMap = obj.(map[string]interface{})
@@ -11254,6 +11523,40 @@ func (ec *executionContext) _EditCommentOperation(ctx context.Context, sel ast.S
return out
}
+var editCommentPayloadImplementors = []string{"EditCommentPayload"}
+
+func (ec *executionContext) _EditCommentPayload(ctx context.Context, sel ast.SelectionSet, obj *models.EditCommentPayload) graphql.Marshaler {
+ fields := graphql.CollectFields(ec.OperationContext, sel, editCommentPayloadImplementors)
+
+ out := graphql.NewFieldSet(fields)
+ var invalids uint32
+ for i, field := range fields {
+ switch field.Name {
+ case "__typename":
+ out.Values[i] = graphql.MarshalString("EditCommentPayload")
+ case "clientMutationId":
+ out.Values[i] = ec._EditCommentPayload_clientMutationId(ctx, field, obj)
+ case "bug":
+ out.Values[i] = ec._EditCommentPayload_bug(ctx, field, obj)
+ if out.Values[i] == graphql.Null {
+ invalids++
+ }
+ case "operation":
+ out.Values[i] = ec._EditCommentPayload_operation(ctx, field, obj)
+ if out.Values[i] == graphql.Null {
+ invalids++
+ }
+ default:
+ panic("unknown field " + strconv.Quote(field.Name))
+ }
+ }
+ out.Dispatch()
+ if invalids > 0 {
+ return graphql.Null
+ }
+ return out
+}
+
var identityImplementors = []string{"Identity"}
func (ec *executionContext) _Identity(ctx context.Context, sel ast.SelectionSet, obj models.IdentityWrapper) graphql.Marshaler {
@@ -11734,6 +12037,11 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet)
if out.Values[i] == graphql.Null {
invalids++
}
+ case "editComment":
+ out.Values[i] = ec._Mutation_editComment(ctx, field)
+ if out.Values[i] == graphql.Null {
+ invalids++
+ }
case "changeLabels":
out.Values[i] = ec._Mutation_changeLabels(ctx, field)
if out.Values[i] == graphql.Null {
@@ -13130,6 +13438,38 @@ func (ec *executionContext) marshalNCreateOperation2ᚖgithubᚗcomᚋMichaelMur
return ec._CreateOperation(ctx, sel, v)
}
+func (ec *executionContext) unmarshalNEditCommentInput2githubᚗcomᚋMichaelMureᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐEditCommentInput(ctx context.Context, v interface{}) (models.EditCommentInput, error) {
+ return ec.unmarshalInputEditCommentInput(ctx, v)
+}
+
+func (ec *executionContext) marshalNEditCommentOperation2githubᚗcomᚋMichaelMureᚋgitᚑbugᚋbugᚐEditCommentOperation(ctx context.Context, sel ast.SelectionSet, v bug.EditCommentOperation) graphql.Marshaler {
+ return ec._EditCommentOperation(ctx, sel, &v)
+}
+
+func (ec *executionContext) marshalNEditCommentOperation2ᚖgithubᚗcomᚋMichaelMureᚋgitᚑbugᚋbugᚐEditCommentOperation(ctx context.Context, sel ast.SelectionSet, v *bug.EditCommentOperation) graphql.Marshaler {
+ if v == nil {
+ if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {
+ ec.Errorf(ctx, "must not be null")
+ }
+ return graphql.Null
+ }
+ return ec._EditCommentOperation(ctx, sel, v)
+}
+
+func (ec *executionContext) marshalNEditCommentPayload2githubᚗcomᚋMichaelMureᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐEditCommentPayload(ctx context.Context, sel ast.SelectionSet, v models.EditCommentPayload) graphql.Marshaler {
+ return ec._EditCommentPayload(ctx, sel, &v)
+}
+
+func (ec *executionContext) marshalNEditCommentPayload2ᚖgithubᚗcomᚋMichaelMureᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐEditCommentPayload(ctx context.Context, sel ast.SelectionSet, v *models.EditCommentPayload) graphql.Marshaler {
+ if v == nil {
+ if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {
+ ec.Errorf(ctx, "must not be null")
+ }
+ return graphql.Null
+ }
+ return ec._EditCommentPayload(ctx, sel, v)
+}
+
func (ec *executionContext) unmarshalNHash2githubᚗcomᚋMichaelMureᚋgitᚑbugᚋrepositoryᚐHash(ctx context.Context, v interface{}) (repository.Hash, error) {
var res repository.Hash
return res, res.UnmarshalGQL(v)
@@ -111,6 +111,30 @@ type CommentEdge struct {
Node *bug.Comment `json:"node"`
}
+type EditCommentInput struct {
+ // A unique identifier for the client performing the mutation.
+ ClientMutationID *string `json:"clientMutationId"`
+ // "The name of the repository. If not set, the default repository is used.
+ RepoRef *string `json:"repoRef"`
+ // The bug ID's prefix.
+ Prefix string `json:"prefix"`
+ // The target.
+ Target string `json:"target"`
+ // The new message to be set.
+ Message string `json:"message"`
+ // The collection of file's hash required for the first message.
+ Files []repository.Hash `json:"files"`
+}
+
+type EditCommentPayload struct {
+ // A unique identifier for the client performing the mutation.
+ ClientMutationID *string `json:"clientMutationId"`
+ // The affected bug.
+ Bug BugWrapper `json:"bug"`
+ // The resulting operation.
+ Operation *bug.EditCommentOperation `json:"operation"`
+}
+
type IdentityConnection struct {
Edges []*IdentityEdge `json:"edges"`
Nodes []IdentityWrapper `json:"nodes"`
@@ -5,6 +5,7 @@ import (
"time"
"github.com/MichaelMure/git-bug/api/auth"
+ "github.com/MichaelMure/git-bug/entity"
"github.com/MichaelMure/git-bug/api/graphql/graph"
"github.com/MichaelMure/git-bug/api/graphql/models"
"github.com/MichaelMure/git-bug/bug"
@@ -89,6 +90,34 @@ func (r mutationResolver) AddComment(ctx context.Context, input models.AddCommen
}, nil
}
+func (r mutationResolver) EditComment(ctx context.Context, input models.EditCommentInput) (*models.EditCommentPayload, error) {
+ repo, b, err := r.getBug(input.RepoRef, input.Prefix)
+ if err != nil {
+ return nil, err
+ }
+
+ author, err := auth.UserFromCtx(ctx, repo)
+ if err != nil {
+ return nil, err
+ }
+
+ op, err := b.EditCommentRaw(author, time.Now().Unix(), entity.Id(input.Target), input.Message, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ err = b.Commit()
+ if err != nil {
+ return nil, err
+ }
+
+ return &models.EditCommentPayload{
+ ClientMutationID: input.ClientMutationID,
+ Bug: models.NewLoadedBug(b.Snapshot()),
+ Operation: op,
+ }, nil
+}
+
func (r mutationResolver) ChangeLabels(ctx context.Context, input *models.ChangeLabelInput) (*models.ChangeLabelPayload, error) {
repo, b, err := r.getBug(input.RepoRef, input.Prefix)
if err != nil {
@@ -42,6 +42,30 @@ type AddCommentPayload {
operation: AddCommentOperation!
}
+input EditCommentInput {
+ """A unique identifier for the client performing the mutation."""
+ clientMutationId: String
+ """"The name of the repository. If not set, the default repository is used."""
+ repoRef: String
+ """The bug ID's prefix."""
+ prefix: String!
+ """The ID of the comment to be changed."""
+ target: String!
+ """The new message to be set."""
+ message: String!
+ """The collection of file's hash required for the first message."""
+ files: [Hash!]
+}
+
+type EditCommentPayload {
+ """A unique identifier for the client performing the mutation."""
+ clientMutationId: String
+ """The affected bug."""
+ bug: Bug!
+ """The resulting operation."""
+ operation: EditCommentOperation!
+}
+
input ChangeLabelInput {
"""A unique identifier for the client performing the mutation."""
clientMutationId: String
@@ -8,6 +8,8 @@ type Mutation {
newBug(input: NewBugInput!): NewBugPayload!
"""Add a new comment to a bug"""
addComment(input: AddCommentInput!): AddCommentPayload!
+ """Change a comment of a bug"""
+ editComment(input: EditCommentInput!): EditCommentPayload!
"""Add or remove a set of label on a bug"""
changeLabels(input: ChangeLabelInput): ChangeLabelPayload!
"""Change a bug's status to open"""
@@ -3,12 +3,13 @@ package auth
import (
"encoding/base64"
"encoding/json"
- "errors"
"fmt"
"strconv"
"strings"
"time"
+ "github.com/pkg/errors"
+
"github.com/MichaelMure/git-bug/entity"
"github.com/MichaelMure/git-bug/repository"
)
@@ -159,7 +160,8 @@ func List(repo repository.RepoKeyring, opts ...ListOption) ([]Credential, error)
item, err := repo.Keyring().Get(key)
if err != nil {
- return nil, err
+ // skip unreadable items, nothing much we can do for them anyway
+ continue
}
cred, err := decode(item)
@@ -38,6 +38,16 @@ func AuthorFilter(query string) Filter {
}
}
+// MetadataFilter return a Filter that match a bug metadata at creation time
+func MetadataFilter(pair query.StringPair) Filter {
+ return func(excerpt *BugExcerpt, resolver resolver) bool {
+ if value, ok := excerpt.CreateMetadata[pair.Key]; ok {
+ return value == pair.Value
+ }
+ return false
+ }
+}
+
// LabelFilter return a Filter that match a label
func LabelFilter(label string) Filter {
return func(excerpt *BugExcerpt, resolver resolver) bool {
@@ -109,6 +119,7 @@ func NoLabelFilter() Filter {
type Matcher struct {
Status []Filter
Author []Filter
+ Metadata []Filter
Actor []Filter
Participant []Filter
Label []Filter
@@ -127,6 +138,9 @@ func compileMatcher(filters query.Filters) *Matcher {
for _, value := range filters.Author {
result.Author = append(result.Author, AuthorFilter(value))
}
+ for _, value := range filters.Metadata {
+ result.Metadata = append(result.Metadata, MetadataFilter(value))
+ }
for _, value := range filters.Actor {
result.Actor = append(result.Actor, ActorFilter(value))
}
@@ -153,6 +167,10 @@ func (f *Matcher) Match(excerpt *BugExcerpt, resolver resolver) bool {
return false
}
+ if match := f.orMatch(f.Metadata, excerpt, resolver); !match {
+ return false
+ }
+
if match := f.orMatch(f.Participant, excerpt, resolver); !match {
return false
}
@@ -8,12 +8,14 @@ import (
"sort"
"strings"
"time"
+ "unicode/utf8"
+
+ "github.com/blevesearch/bleve"
"github.com/MichaelMure/git-bug/bug"
"github.com/MichaelMure/git-bug/entity"
"github.com/MichaelMure/git-bug/query"
"github.com/MichaelMure/git-bug/repository"
- "github.com/blevesearch/bleve"
)
const bugCacheFile = "bug-cache"
@@ -523,11 +525,24 @@ func (c *RepoCache) addBugToSearchIndex(snap *bug.Snapshot) error {
Text []string
}{}
+ // See https://github.com/blevesearch/bleve/issues/1576
+ var sb strings.Builder
+ normalize := func(text string) string {
+ sb.Reset()
+ for _, field := range strings.Fields(text) {
+ if utf8.RuneCountInString(field) < 100 {
+ sb.WriteString(field)
+ sb.WriteRune(' ')
+ }
+ }
+ return sb.String()
+ }
+
for _, comment := range snap.Comments {
- searchableBug.Text = append(searchableBug.Text, comment.Message)
+ searchableBug.Text = append(searchableBug.Text, normalize(comment.Message))
}
- searchableBug.Text = append(searchableBug.Text, snap.Title)
+ searchableBug.Text = append(searchableBug.Text, normalize(snap.Title))
index, err := c.repo.GetBleveIndex("bug")
if err != nil {
@@ -1,7 +1,9 @@
package cache
import (
+ "strings"
"testing"
+ "time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -278,3 +280,21 @@ func checkBugPresence(t *testing.T, cache *RepoCache, bug *BugCache, presence bo
require.Equal(t, bug, b)
}
}
+
+func TestLongDescription(t *testing.T) {
+ // See https://github.com/MichaelMure/git-bug/issues/606
+
+ text := strings.Repeat("x", 65536)
+
+ repo := repository.CreateGoGitTestRepo(false)
+ defer repository.CleanupTestRepos(repo)
+
+ backend, err := NewRepoCache(repo)
+ require.NoError(t, err)
+
+ i, err := backend.NewIdentity("René Descartes", "rene@descartes.fr")
+ require.NoError(t, err)
+
+ _, _, err = backend.NewBugRaw(i, time.Now().Unix(), text, text, nil, nil)
+ require.NoError(t, err)
+}
@@ -19,6 +19,7 @@ import (
type lsOptions struct {
statusQuery []string
authorQuery []string
+ metadataQuery []string
participantQuery []string
actorQuery []string
labelQuery []string
@@ -65,6 +66,8 @@ git bug ls status:open --by creation "foo bar" baz
"Filter by status. Valid values are [open,closed]")
flags.StringSliceVarP(&options.authorQuery, "author", "a", nil,
"Filter by author")
+ flags.StringSliceVarP(&options.metadataQuery, "metadata", "m", nil,
+ "Filter by metadata. Example: github-url=URL")
flags.StringSliceVarP(&options.participantQuery, "participant", "p", nil,
"Filter by participant")
flags.StringSliceVarP(&options.actorQuery, "actor", "A", nil,
@@ -337,6 +340,16 @@ func completeQuery(q *query.Query, opts lsOptions) error {
}
q.Author = append(q.Author, opts.authorQuery...)
+ for _, str := range opts.metadataQuery {
+ tokens := strings.Split(str, "=")
+ if len(tokens) < 2 {
+ return fmt.Errorf("no \"=\" in key=value metadata markup")
+ }
+ var pair query.StringPair
+ pair.Key = tokens[0]
+ pair.Value = tokens[1]
+ q.Metadata = append(q.Metadata, pair)
+ }
q.Participant = append(q.Participant, opts.participantQuery...)
q.Actor = append(q.Actor, opts.actorQuery...)
q.Label = append(q.Label, opts.labelQuery...)
@@ -4,9 +4,12 @@ import (
"context"
"fmt"
"log"
+ "net"
"net/http"
+ "net/url"
"os"
"os/signal"
+ "strconv"
"time"
"github.com/99designs/gqlgen/graphql/playground"
@@ -27,10 +30,12 @@ import (
const webUIOpenConfigKey = "git-bug.webui.open"
type webUIOptions struct {
+ host string
port int
open bool
noOpen bool
readOnly bool
+ query string
}
func newWebUICommand() *cobra.Command {
@@ -54,10 +59,12 @@ Available git config:
flags := cmd.Flags()
flags.SortFlags = false
+ flags.StringVar(&options.host, "host", "127.0.0.1", "Network address or hostname to listen to (default to 127.0.0.1)")
flags.BoolVar(&options.open, "open", false, "Automatically open the web UI in the default browser")
flags.BoolVar(&options.noOpen, "no-open", false, "Prevent the automatic opening of the web UI in the default browser")
- flags.IntVarP(&options.port, "port", "p", 0, "Port to listen to (default is random)")
+ flags.IntVarP(&options.port, "port", "p", 0, "Port to listen to (default to random available port)")
flags.BoolVar(&options.readOnly, "read-only", false, "Whether to run the web UI in read-only mode")
+ flags.StringVarP(&options.query, "query", "q", "", "The query to open in the web UI bug list")
return cmd
}
@@ -71,8 +78,14 @@ func runWebUI(env *Env, opts webUIOptions, args []string) error {
}
}
- addr := fmt.Sprintf("127.0.0.1:%d", opts.port)
+ addr := net.JoinHostPort(opts.host, strconv.Itoa(opts.port))
webUiAddr := fmt.Sprintf("http://%s", addr)
+ toOpen := webUiAddr
+
+ if len(opts.query) > 0 {
+ // Explicitly set the query parameter instead of going with a default one.
+ toOpen = fmt.Sprintf("%s/?q=%s", webUiAddr, url.QueryEscape(opts.query))
+ }
router := mux.NewRouter()
@@ -150,7 +163,7 @@ func runWebUI(env *Env, opts webUIOptions, args []string) error {
shouldOpen := (configOpen && !opts.noOpen) || opts.open
if shouldOpen {
- err = open.Run(webUiAddr)
+ err = open.Run(toOpen)
if err != nil {
env.out.Println(err)
}
@@ -28,6 +28,10 @@ You can pass an additional query to filter and order the list. This query can be
\fB\-a\fP, \fB\-\-author\fP=[]
Filter by author
+.PP
+\fB\-m\fP, \fB\-\-metadata\fP=[]
+ Filter by metadata. Example: github\-url=URL
+
.PP
\fB\-p\fP, \fB\-\-participant\fP=[]
Filter by participant
@@ -21,6 +21,10 @@ Available git config:
.SH OPTIONS
+.PP
+\fB\-\-host\fP="127.0.0.1"
+ Network address or hostname to listen to (default to 127.0.0.1)
+
.PP
\fB\-\-open\fP[=false]
Automatically open the web UI in the default browser
@@ -31,12 +35,16 @@ Available git config:
.PP
\fB\-p\fP, \fB\-\-port\fP=0
- Port to listen to (default is random)
+ Port to listen to (default to random available port)
.PP
\fB\-\-read\-only\fP[=false]
Whether to run the web UI in read\-only mode
+.PP
+\fB\-q\fP, \fB\-\-query\fP=""
+ The query to open in the web UI bug list
+
.PP
\fB\-h\fP, \fB\-\-help\fP[=false]
help for webui
@@ -34,6 +34,7 @@ git bug ls status:open --by creation "foo bar" baz
```
-s, --status strings Filter by status. Valid values are [open,closed]
-a, --author strings Filter by author
+ -m, --metadata strings Filter by metadata. Example: github-url=URL
-p, --participant strings Filter by participant
-A, --actor strings Filter by actor
-l, --label strings Filter by label
@@ -17,11 +17,13 @@ git-bug webui [flags]
### Options
```
- --open Automatically open the web UI in the default browser
- --no-open Prevent the automatic opening of the web UI in the default browser
- -p, --port int Port to listen to (default is random)
- --read-only Whether to run the web UI in read-only mode
- -h, --help help for webui
+ --host string Network address or hostname to listen to (default to 127.0.0.1) (default "127.0.0.1")
+ --open Automatically open the web UI in the default browser
+ --no-open Prevent the automatic opening of the web UI in the default browser
+ -p, --port int Port to listen to (default to random available port)
+ --read-only Whether to run the web UI in read-only mode
+ -q, --query string The query to open in the web UI bug list
+ -h, --help help for webui
```
### SEE ALSO
@@ -10,8 +10,8 @@ A few tips:
- queries are case insensitive.
- you can combine as many qualifiers as you want.
-- you can use double quotes for multi-word search terms. For example, `author:"René Descartes"` searches for bugs opened by René Descartes, whereas `author:René Descartes` will throw an error since full-text search is not yet supported.
-- instead of a complete ID, you can use any prefix length. For example `participant=9ed1a`.
+- you can use double quotes for multi-word search terms. For example, `author:"René Descartes"` searches for bugs opened by René Descartes, whereas `author:René Descartes` will search for bug with René as the author and containing Descartes in a text.
+- instead of a complete ID, you can use any prefix length, as long as there is no ambiguity. For example `participant=9ed1a`.
## Filtering
@@ -36,7 +36,7 @@ You can filter based on the person who opened the bug.
### Filtering by participant
-You can filter based on the person who participated in any activity related to the bug (Opened bug or added a comment).
+You can filter based on the person who participated in any activity related to the bug (opened bug or added a comment).
| Qualifier | Example |
| --- | --- |
@@ -51,7 +51,6 @@ You can filter based on the person who interacted with the bug.
| --- | --- |
| `actor:QUERY` | `actor:descartes` matches bugs edited by `René Descartes` or `Robert Descartes` |
| | `actor:"rené descartes"` matches bugs edited by `René Descartes` |
-| `
**NOTE**: interaction with bugs include: opening the bug, adding comments, adding/removing labels etc...
@@ -90,8 +89,8 @@ Note: to deal with differently-set clocks on distributed computers, `git-bug` us
### Sort by Id
-| Qualifier | Example |
-| --- | --- |
+| Qualifier | Example |
+| --- | --- |
| `sort:id-desc` | `sort:id-desc` will sort bugs by their descending Ids |
| `sort:id` or `sort:id-asc` | `sort:id` will sort bugs by their ascending Ids |
@@ -99,8 +98,8 @@ Note: to deal with differently-set clocks on distributed computers, `git-bug` us
You can sort bugs by their creation time.
-| Qualifier | Example |
-| --- | --- |
+| Qualifier | Example |
+| --- | --- |
| `sort:creation` or `sort:creation-desc` | `sort:creation` will sort bugs by their descending creation time |
| `sort:creation-asc` | `sort:creation-asc` will sort bugs by their ascending creation time |
@@ -108,7 +107,7 @@ You can sort bugs by their creation time.
You can sort bugs by their edit time.
-| Qualifier | Example |
-| --- | --- |
+| Qualifier | Example |
+| --- | --- |
| `sort:edit` or `sort:edit-desc` | `sort:edit` will sort bugs by their descending last edition time |
| `sort:edit-asc` | `sort:edit-asc` will sort bugs by their ascending last edition time |
@@ -27,16 +27,16 @@ require (
github.com/shurcooL/githubv4 v0.0.0-20190601194912-068505affed7
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/spf13/cobra v1.1.3
github.com/stretchr/testify v1.7.0
github.com/vektah/gqlparser v1.3.1
- github.com/xanzy/go-gitlab v0.40.1
+ github.com/xanzy/go-gitlab v0.44.0
github.com/xanzy/ssh-agent v0.3.0 // indirect
golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897
golang.org/x/net v0.0.0-20201024042810-be3efd7ff127 // indirect
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/sys v0.0.0-20210320140829-1e4c9ba3b0c4
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
@@ -74,6 +74,8 @@ github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84=
+github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
+github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
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 v1.0.0 h1:pnilj2Qi3YSEGdWgLj1Pn9Io7ukfXPoQcpAI1Bv8n/o=
@@ -425,9 +427,11 @@ github.com/shurcooL/githubv4 v0.0.0-20190601194912-068505affed7 h1:Vk3RiBQpF0Ja+
github.com/shurcooL/githubv4 v0.0.0-20190601194912-068505affed7/go.mod h1:hAF0iLZy4td2EX+/8Tw+4nodhlMrwN3HupfaXj3zkGo=
github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f h1:tygelZueB1EtXkPI6mQ4o9DQ0+FKW41hTbunoXZCTqk=
github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f/go.mod h1:AuYgA5Kyo4c7HfUmvRGs/6rGlMMV/6B1bVnB9JxJEEg=
+github.com/shurcooL/httpfs v0.0.0-20171119174359-809beceb2371 h1:SWV2fHctRpRrp49VXJ6UZja7gU9QLHwRpIPBN89SKEo=
github.com/shurcooL/httpfs v0.0.0-20171119174359-809beceb2371/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg=
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
+github.com/shurcooL/vfsgen v0.0.0-20180121065927-ffb13db8def0 h1:JJV9CsgM9EC9w2iVkwuz+sMx8yRFe89PJRUrv6hPCIA=
github.com/shurcooL/vfsgen v0.0.0-20180121065927-ffb13db8def0/go.mod h1:TrYk7fJVaAttu97ZZKrO9UbRa8izdowaMIZcxYMbVaw=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
@@ -440,8 +444,8 @@ github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasO
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
-github.com/spf13/cobra v1.1.1 h1:KfztREH0tPxJJ+geloSLaAkaPkr4ki2Er5quFV1TDo4=
-github.com/spf13/cobra v1.1.1/go.mod h1:WnodtKOvamDL/PwE2M4iKs8aMDBZ5Q5klgD3qfVJQMI=
+github.com/spf13/cobra v1.1.3 h1:xghbfqPkxzxP3C/f3n5DdpAbdKLj4ZE4BWQI362l53M=
+github.com/spf13/cobra v1.1.3/go.mod h1:pGADOWyqRD/YMrPZigI/zbliZ2wVD/23d+is3pSWzOo=
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
@@ -478,6 +482,8 @@ 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.40.1 h1:jHueLh5Inzv20TL5Yki+CaLmyvtw3Yq7blbWx7GmglQ=
github.com/xanzy/go-gitlab v0.40.1/go.mod h1:sPLojNBn68fMUWSxIJtdVVIP8uSBYqesTfDUseX11Ug=
+github.com/xanzy/go-gitlab v0.44.0 h1:cEiGhqu7EpFGuei2a2etAwB+x6403E5CvpLn35y+GPs=
+github.com/xanzy/go-gitlab v0.44.0/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=
github.com/xanzy/ssh-agent v0.3.0/go.mod h1:3s9xbODqPuuhK9JV1R321M/FlMZSBvE5aY6eAcqrDh0=
@@ -633,6 +639,8 @@ golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201020230747-6e5568b54d1a h1:e3IU37lwO4aq3uoRKINC7JikojFmE5gO7xhfxs8VC34=
golang.org/x/sys v0.0.0-20201020230747-6e5568b54d1a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4 h1:EZ2mChiOa8udjfp6rRmswTbtZN/QzUQp4ptM4rnjHvc=
+golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -793,9 +801,10 @@ gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bl
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
@@ -884,6 +884,12 @@ _git-bug_ls()
local_nonpersistent_flags+=("--author")
local_nonpersistent_flags+=("--author=")
local_nonpersistent_flags+=("-a")
+ flags+=("--metadata=")
+ two_word_flags+=("--metadata")
+ two_word_flags+=("-m")
+ local_nonpersistent_flags+=("--metadata")
+ local_nonpersistent_flags+=("--metadata=")
+ local_nonpersistent_flags+=("-m")
flags+=("--participant=")
two_word_flags+=("--participant")
two_word_flags+=("-p")
@@ -1358,6 +1364,10 @@ _git-bug_webui()
flags_with_completion=()
flags_completion=()
+ flags+=("--host=")
+ two_word_flags+=("--host")
+ local_nonpersistent_flags+=("--host")
+ local_nonpersistent_flags+=("--host=")
flags+=("--open")
local_nonpersistent_flags+=("--open")
flags+=("--no-open")
@@ -1370,6 +1380,12 @@ _git-bug_webui()
local_nonpersistent_flags+=("-p")
flags+=("--read-only")
local_nonpersistent_flags+=("--read-only")
+ flags+=("--query=")
+ two_word_flags+=("--query")
+ two_word_flags+=("-q")
+ local_nonpersistent_flags+=("--query")
+ local_nonpersistent_flags+=("--query=")
+ local_nonpersistent_flags+=("-q")
must_have_one_flag=()
must_have_one_noun=()
@@ -1,261 +1,225 @@
-using namespace System.Management.Automation
-using namespace System.Management.Automation.Language
-Register-ArgumentCompleter -Native -CommandName 'git-bug' -ScriptBlock {
- param($wordToComplete, $commandAst, $cursorPosition)
- $commandElements = $commandAst.CommandElements
- $command = @(
- 'git-bug'
- for ($i = 1; $i -lt $commandElements.Count; $i++) {
- $element = $commandElements[$i]
- if ($element -isnot [StringConstantExpressionAst] -or
- $element.StringConstantType -ne [StringConstantType]::BareWord -or
- $element.Value.StartsWith('-')) {
- break
+# powershell completion for git-bug -*- shell-script -*-
+
+function __git-bug_debug {
+ if ($env:BASH_COMP_DEBUG_FILE) {
+ "$args" | Out-File -Append -FilePath "$env:BASH_COMP_DEBUG_FILE"
+ }
+}
+
+filter __git-bug_escapeStringWithSpecialChars {
+ $_ -replace '\s|#|@|\$|;|,|''|\{|\}|\(|\)|"|`|\||<|>|&','`$&'
+}
+
+Register-ArgumentCompleter -CommandName 'git-bug' -ScriptBlock {
+ param(
+ $WordToComplete,
+ $CommandAst,
+ $CursorPosition
+ )
+
+ # Get the current command line and convert into a string
+ $Command = $CommandAst.CommandElements
+ $Command = "$Command"
+
+ __git-bug_debug ""
+ __git-bug_debug "========= starting completion logic =========="
+ __git-bug_debug "WordToComplete: $WordToComplete Command: $Command CursorPosition: $CursorPosition"
+
+ # The user could have moved the cursor backwards on the command-line.
+ # We need to trigger completion from the $CursorPosition location, so we need
+ # to truncate the command-line ($Command) up to the $CursorPosition location.
+ # Make sure the $Command is longer then the $CursorPosition before we truncate.
+ # This happens because the $Command does not include the last space.
+ if ($Command.Length -gt $CursorPosition) {
+ $Command=$Command.Substring(0,$CursorPosition)
+ }
+ __git-bug_debug "Truncated command: $Command"
+
+ $ShellCompDirectiveError=1
+ $ShellCompDirectiveNoSpace=2
+ $ShellCompDirectiveNoFileComp=4
+ $ShellCompDirectiveFilterFileExt=8
+ $ShellCompDirectiveFilterDirs=16
+
+ # Prepare the command to request completions for the program.
+ # Split the command at the first space to separate the program and arguments.
+ $Program,$Arguments = $Command.Split(" ",2)
+ $RequestComp="$Program __completeNoDesc $Arguments"
+ __git-bug_debug "RequestComp: $RequestComp"
+
+ # we cannot use $WordToComplete because it
+ # has the wrong values if the cursor was moved
+ # so use the last argument
+ if ($WordToComplete -ne "" ) {
+ $WordToComplete = $Arguments.Split(" ")[-1]
+ }
+ __git-bug_debug "New WordToComplete: $WordToComplete"
+
+
+ # Check for flag with equal sign
+ $IsEqualFlag = ($WordToComplete -Like "--*=*" )
+ if ( $IsEqualFlag ) {
+ __git-bug_debug "Completing equal sign flag"
+ # Remove the flag part
+ $Flag,$WordToComplete = $WordToComplete.Split("=",2)
+ }
+
+ if ( $WordToComplete -eq "" -And ( -Not $IsEqualFlag )) {
+ # If the last parameter is complete (there is a space following it)
+ # We add an extra empty parameter so we can indicate this to the go method.
+ __git-bug_debug "Adding extra empty parameter"
+ # We need to use `"`" to pass an empty argument a "" or '' does not work!!!
+ $RequestComp="$RequestComp" + ' `"`"'
+ }
+
+ __git-bug_debug "Calling $RequestComp"
+ #call the command store the output in $out and redirect stderr and stdout to null
+ # $Out is an array contains each line per element
+ Invoke-Expression -OutVariable out "$RequestComp" 2>&1 | Out-Null
+
+
+ # get directive from last line
+ [int]$Directive = $Out[-1].TrimStart(':')
+ if ($Directive -eq "") {
+ # There is no directive specified
+ $Directive = 0
+ }
+ __git-bug_debug "The completion directive is: $Directive"
+
+ # remove directive (last element) from out
+ $Out = $Out | Where-Object { $_ -ne $Out[-1] }
+ __git-bug_debug "The completions are: $Out"
+
+ if (($Directive -band $ShellCompDirectiveError) -ne 0 ) {
+ # Error code. No completion.
+ __git-bug_debug "Received error from custom completion go code"
+ return
+ }
+
+ $Longest = 0
+ $Values = $Out | ForEach-Object {
+ #Split the output in name and description
+ $Name, $Description = $_.Split("`t",2)
+ __git-bug_debug "Name: $Name Description: $Description"
+
+ # Look for the longest completion so that we can format things nicely
+ if ($Longest -lt $Name.Length) {
+ $Longest = $Name.Length
+ }
+
+ # Set the description to a one space string if there is none set.
+ # This is needed because the CompletionResult does not accept an empty string as argument
+ if (-Not $Description) {
+ $Description = " "
+ }
+ @{Name="$Name";Description="$Description"}
+ }
+
+
+ $Space = " "
+ if (($Directive -band $ShellCompDirectiveNoSpace) -ne 0 ) {
+ # remove the space here
+ __git-bug_debug "ShellCompDirectiveNoSpace is called"
+ $Space = ""
+ }
+
+ if (($Directive -band $ShellCompDirectiveNoFileComp) -ne 0 ) {
+ __git-bug_debug "ShellCompDirectiveNoFileComp is called"
+
+ if ($Values.Length -eq 0) {
+ # Just print an empty string here so the
+ # shell does not start to complete paths.
+ # We cannot use CompletionResult here because
+ # it does not accept an empty string as argument.
+ ""
+ return
+ }
+ }
+
+ if ((($Directive -band $ShellCompDirectiveFilterFileExt) -ne 0 ) -or
+ (($Directive -band $ShellCompDirectiveFilterDirs) -ne 0 )) {
+ __git-bug_debug "ShellCompDirectiveFilterFileExt ShellCompDirectiveFilterDirs are not supported"
+
+ # return here to prevent the completion of the extensions
+ return
+ }
+
+ $Values = $Values | Where-Object {
+ # filter the result
+ $_.Name -like "$WordToComplete*"
+
+ # Join the flag back if we have a equal sign flag
+ if ( $IsEqualFlag ) {
+ __git-bug_debug "Join the equal sign flag back to the completion value"
+ $_.Name = $Flag + "=" + $_.Name
+ }
+ }
+
+ # Get the current mode
+ $Mode = (Get-PSReadLineKeyHandler | Where-Object {$_.Key -eq "Tab" }).Function
+ __git-bug_debug "Mode: $Mode"
+
+ $Values | ForEach-Object {
+
+ # store temporay because switch will overwrite $_
+ $comp = $_
+
+ # PowerShell supports three different completion modes
+ # - TabCompleteNext (default windows style - on each key press the next option is displayed)
+ # - Complete (works like bash)
+ # - MenuComplete (works like zsh)
+ # You set the mode with Set-PSReadLineKeyHandler -Key Tab -Function <mode>
+
+ # CompletionResult Arguments:
+ # 1) CompletionText text to be used as the auto completion result
+ # 2) ListItemText text to be displayed in the suggestion list
+ # 3) ResultType type of completion result
+ # 4) ToolTip text for the tooltip with details about the object
+
+ switch ($Mode) {
+
+ # bash like
+ "Complete" {
+
+ if ($Values.Length -eq 1) {
+ __git-bug_debug "Only one completion left"
+
+ # insert space after value
+ [System.Management.Automation.CompletionResult]::new($($comp.Name | __git-bug_escapeStringWithSpecialChars) + $Space, "$($comp.Name)", 'ParameterValue', "$($comp.Description)")
+
+ } else {
+ # Add the proper number of spaces to align the descriptions
+ while($comp.Name.Length -lt $Longest) {
+ $comp.Name = $comp.Name + " "
+ }
+
+ # Check for empty description and only add parentheses if needed
+ if ($($comp.Description) -eq " " ) {
+ $Description = ""
+ } else {
+ $Description = " ($($comp.Description))"
+ }
+
+ [System.Management.Automation.CompletionResult]::new("$($comp.Name)$Description", "$($comp.Name)$Description", 'ParameterValue', "$($comp.Description)")
+ }
+ }
+
+ # zsh like
+ "MenuComplete" {
+ # insert space after value
+ # MenuComplete will automatically show the ToolTip of
+ # the highlighted value at the bottom of the suggestions.
+ [System.Management.Automation.CompletionResult]::new($($comp.Name | __git-bug_escapeStringWithSpecialChars) + $Space, "$($comp.Name)", 'ParameterValue', "$($comp.Description)")
+ }
+
+ # TabCompleteNext and in case we get something unknown
+ Default {
+ # Like MenuComplete but we don't want to add a space here because
+ # the user need to press space anyway to get the completion.
+ # Description will not be shown because thats not possible with TabCompleteNext
+ [System.Management.Automation.CompletionResult]::new($($comp.Name | __git-bug_escapeStringWithSpecialChars), "$($comp.Name)", 'ParameterValue', "$($comp.Description)")
}
- $element.Value
- }
- ) -join ';'
- $completions = @(switch ($command) {
- 'git-bug' {
- [CompletionResult]::new('add', 'add', [CompletionResultType]::ParameterValue, 'Create a new bug.')
- [CompletionResult]::new('bridge', 'bridge', [CompletionResultType]::ParameterValue, 'Configure and use bridges to other bug trackers.')
- [CompletionResult]::new('commands', 'commands', [CompletionResultType]::ParameterValue, 'Display available commands.')
- [CompletionResult]::new('comment', 'comment', [CompletionResultType]::ParameterValue, 'Display or add comments to a bug.')
- [CompletionResult]::new('deselect', 'deselect', [CompletionResultType]::ParameterValue, 'Clear the implicitly selected bug.')
- [CompletionResult]::new('label', 'label', [CompletionResultType]::ParameterValue, 'Display, add or remove labels to/from a bug.')
- [CompletionResult]::new('ls', 'ls', [CompletionResultType]::ParameterValue, 'List bugs.')
- [CompletionResult]::new('ls-id', 'ls-id', [CompletionResultType]::ParameterValue, 'List bug identifiers.')
- [CompletionResult]::new('ls-label', 'ls-label', [CompletionResultType]::ParameterValue, 'List valid labels.')
- [CompletionResult]::new('pull', 'pull', [CompletionResultType]::ParameterValue, 'Pull bugs update from a git remote.')
- [CompletionResult]::new('push', 'push', [CompletionResultType]::ParameterValue, 'Push bugs update to a git remote.')
- [CompletionResult]::new('rm', 'rm', [CompletionResultType]::ParameterValue, 'Remove an existing bug.')
- [CompletionResult]::new('select', 'select', [CompletionResultType]::ParameterValue, 'Select a bug for implicit use in future commands.')
- [CompletionResult]::new('show', 'show', [CompletionResultType]::ParameterValue, 'Display the details of a bug.')
- [CompletionResult]::new('status', 'status', [CompletionResultType]::ParameterValue, 'Display or change a bug status.')
- [CompletionResult]::new('termui', 'termui', [CompletionResultType]::ParameterValue, 'Launch the terminal UI.')
- [CompletionResult]::new('title', 'title', [CompletionResultType]::ParameterValue, 'Display or change a title of a bug.')
- [CompletionResult]::new('user', 'user', [CompletionResultType]::ParameterValue, 'Display or change the user identity.')
- [CompletionResult]::new('version', 'version', [CompletionResultType]::ParameterValue, 'Show git-bug version information.')
- [CompletionResult]::new('webui', 'webui', [CompletionResultType]::ParameterValue, 'Launch the web UI.')
- break
- }
- 'git-bug;add' {
- [CompletionResult]::new('-t', 't', [CompletionResultType]::ParameterName, 'Provide a title to describe the issue')
- [CompletionResult]::new('--title', 'title', [CompletionResultType]::ParameterName, 'Provide a title to describe the issue')
- [CompletionResult]::new('-m', 'm', [CompletionResultType]::ParameterName, 'Provide a message to describe the issue')
- [CompletionResult]::new('--message', 'message', [CompletionResultType]::ParameterName, 'Provide a message to describe the issue')
- [CompletionResult]::new('-F', 'F', [CompletionResultType]::ParameterName, 'Take the message from the given file. Use - to read the message from the standard input')
- [CompletionResult]::new('--file', 'file', [CompletionResultType]::ParameterName, 'Take the message from the given file. Use - to read the message from the standard input')
- break
- }
- 'git-bug;bridge' {
- [CompletionResult]::new('auth', 'auth', [CompletionResultType]::ParameterValue, 'List all known bridge authentication credentials.')
- [CompletionResult]::new('configure', 'configure', [CompletionResultType]::ParameterValue, 'Configure a new bridge.')
- [CompletionResult]::new('pull', 'pull', [CompletionResultType]::ParameterValue, 'Pull updates.')
- [CompletionResult]::new('push', 'push', [CompletionResultType]::ParameterValue, 'Push updates.')
- [CompletionResult]::new('rm', 'rm', [CompletionResultType]::ParameterValue, 'Delete a configured bridge.')
- break
- }
- 'git-bug;bridge;auth' {
- [CompletionResult]::new('add-token', 'add-token', [CompletionResultType]::ParameterValue, 'Store a new token')
- [CompletionResult]::new('rm', 'rm', [CompletionResultType]::ParameterValue, 'Remove a credential.')
- [CompletionResult]::new('show', 'show', [CompletionResultType]::ParameterValue, 'Display an authentication credential.')
- break
- }
- 'git-bug;bridge;auth;add-token' {
- [CompletionResult]::new('-t', 't', [CompletionResultType]::ParameterName, 'The target of the bridge. Valid values are [github,gitlab,jira,launchpad-preview]')
- [CompletionResult]::new('--target', 'target', [CompletionResultType]::ParameterName, 'The target of the bridge. Valid values are [github,gitlab,jira,launchpad-preview]')
- [CompletionResult]::new('-l', 'l', [CompletionResultType]::ParameterName, 'The login in the remote bug-tracker')
- [CompletionResult]::new('--login', 'login', [CompletionResultType]::ParameterName, 'The login in the remote bug-tracker')
- [CompletionResult]::new('-u', 'u', [CompletionResultType]::ParameterName, 'The user to add the token to. Default is the current user')
- [CompletionResult]::new('--user', 'user', [CompletionResultType]::ParameterName, 'The user to add the token to. Default is the current user')
- break
- }
- 'git-bug;bridge;auth;rm' {
- break
- }
- 'git-bug;bridge;auth;show' {
- break
- }
- 'git-bug;bridge;configure' {
- [CompletionResult]::new('-n', 'n', [CompletionResultType]::ParameterName, 'A distinctive name to identify the bridge')
- [CompletionResult]::new('--name', 'name', [CompletionResultType]::ParameterName, 'A distinctive name to identify the bridge')
- [CompletionResult]::new('-t', 't', [CompletionResultType]::ParameterName, 'The target of the bridge. Valid values are [github,gitlab,jira,launchpad-preview]')
- [CompletionResult]::new('--target', 'target', [CompletionResultType]::ParameterName, 'The target of the bridge. Valid values are [github,gitlab,jira,launchpad-preview]')
- [CompletionResult]::new('-u', 'u', [CompletionResultType]::ParameterName, 'The URL of the remote repository')
- [CompletionResult]::new('--url', 'url', [CompletionResultType]::ParameterName, 'The URL of the remote repository')
- [CompletionResult]::new('-b', 'b', [CompletionResultType]::ParameterName, 'The base URL of your remote issue tracker')
- [CompletionResult]::new('--base-url', 'base-url', [CompletionResultType]::ParameterName, 'The base URL of your remote issue tracker')
- [CompletionResult]::new('-l', 'l', [CompletionResultType]::ParameterName, 'The login on your remote issue tracker')
- [CompletionResult]::new('--login', 'login', [CompletionResultType]::ParameterName, 'The login on your remote issue tracker')
- [CompletionResult]::new('-c', 'c', [CompletionResultType]::ParameterName, 'The identifier or prefix of an already known credential for your remote issue tracker (see "git-bug bridge auth")')
- [CompletionResult]::new('--credential', 'credential', [CompletionResultType]::ParameterName, 'The identifier or prefix of an already known credential for your remote issue tracker (see "git-bug bridge auth")')
- [CompletionResult]::new('--token', 'token', [CompletionResultType]::ParameterName, 'A raw authentication token for the remote issue tracker')
- [CompletionResult]::new('--token-stdin', 'token-stdin', [CompletionResultType]::ParameterName, 'Will read the token from stdin and ignore --token')
- [CompletionResult]::new('-o', 'o', [CompletionResultType]::ParameterName, 'The owner of the remote repository')
- [CompletionResult]::new('--owner', 'owner', [CompletionResultType]::ParameterName, 'The owner of the remote repository')
- [CompletionResult]::new('-p', 'p', [CompletionResultType]::ParameterName, 'The name of the remote repository')
- [CompletionResult]::new('--project', 'project', [CompletionResultType]::ParameterName, 'The name of the remote repository')
- break
- }
- 'git-bug;bridge;pull' {
- [CompletionResult]::new('-n', 'n', [CompletionResultType]::ParameterName, 'force importing all bugs')
- [CompletionResult]::new('--no-resume', 'no-resume', [CompletionResultType]::ParameterName, 'force importing all bugs')
- [CompletionResult]::new('-s', 's', [CompletionResultType]::ParameterName, 'import only bugs updated after the given date (ex: "200h" or "june 2 2019")')
- [CompletionResult]::new('--since', 'since', [CompletionResultType]::ParameterName, 'import only bugs updated after the given date (ex: "200h" or "june 2 2019")')
- break
- }
- 'git-bug;bridge;push' {
- break
- }
- 'git-bug;bridge;rm' {
- break
- }
- 'git-bug;commands' {
- [CompletionResult]::new('-p', 'p', [CompletionResultType]::ParameterName, 'Output the command description as well as Markdown compatible comment')
- [CompletionResult]::new('--pretty', 'pretty', [CompletionResultType]::ParameterName, 'Output the command description as well as Markdown compatible comment')
- break
- }
- 'git-bug;comment' {
- [CompletionResult]::new('add', 'add', [CompletionResultType]::ParameterValue, 'Add a new comment to a bug.')
- [CompletionResult]::new('edit', 'edit', [CompletionResultType]::ParameterValue, 'Edit an existing comment on a bug.')
- break
- }
- 'git-bug;comment;add' {
- [CompletionResult]::new('-F', 'F', [CompletionResultType]::ParameterName, 'Take the message from the given file. Use - to read the message from the standard input')
- [CompletionResult]::new('--file', 'file', [CompletionResultType]::ParameterName, 'Take the message from the given file. Use - to read the message from the standard input')
- [CompletionResult]::new('-m', 'm', [CompletionResultType]::ParameterName, 'Provide the new message from the command line')
- [CompletionResult]::new('--message', 'message', [CompletionResultType]::ParameterName, 'Provide the new message from the command line')
- break
- }
- 'git-bug;comment;edit' {
- [CompletionResult]::new('-F', 'F', [CompletionResultType]::ParameterName, 'Take the message from the given file. Use - to read the message from the standard input')
- [CompletionResult]::new('--file', 'file', [CompletionResultType]::ParameterName, 'Take the message from the given file. Use - to read the message from the standard input')
- [CompletionResult]::new('-m', 'm', [CompletionResultType]::ParameterName, 'Provide the new message from the command line')
- [CompletionResult]::new('--message', 'message', [CompletionResultType]::ParameterName, 'Provide the new message from the command line')
- break
- }
- 'git-bug;deselect' {
- break
- }
- 'git-bug;label' {
- [CompletionResult]::new('add', 'add', [CompletionResultType]::ParameterValue, 'Add a label to a bug.')
- [CompletionResult]::new('rm', 'rm', [CompletionResultType]::ParameterValue, 'Remove a label from a bug.')
- break
- }
- 'git-bug;label;add' {
- break
- }
- 'git-bug;label;rm' {
- break
- }
- 'git-bug;ls' {
- [CompletionResult]::new('-s', 's', [CompletionResultType]::ParameterName, 'Filter by status. Valid values are [open,closed]')
- [CompletionResult]::new('--status', 'status', [CompletionResultType]::ParameterName, 'Filter by status. Valid values are [open,closed]')
- [CompletionResult]::new('-a', 'a', [CompletionResultType]::ParameterName, 'Filter by author')
- [CompletionResult]::new('--author', 'author', [CompletionResultType]::ParameterName, 'Filter by author')
- [CompletionResult]::new('-p', 'p', [CompletionResultType]::ParameterName, 'Filter by participant')
- [CompletionResult]::new('--participant', 'participant', [CompletionResultType]::ParameterName, 'Filter by participant')
- [CompletionResult]::new('-A', 'A', [CompletionResultType]::ParameterName, 'Filter by actor')
- [CompletionResult]::new('--actor', 'actor', [CompletionResultType]::ParameterName, 'Filter by actor')
- [CompletionResult]::new('-l', 'l', [CompletionResultType]::ParameterName, 'Filter by label')
- [CompletionResult]::new('--label', 'label', [CompletionResultType]::ParameterName, 'Filter by label')
- [CompletionResult]::new('-t', 't', [CompletionResultType]::ParameterName, 'Filter by title')
- [CompletionResult]::new('--title', 'title', [CompletionResultType]::ParameterName, 'Filter by title')
- [CompletionResult]::new('-n', 'n', [CompletionResultType]::ParameterName, 'Filter by absence of something. Valid values are [label]')
- [CompletionResult]::new('--no', 'no', [CompletionResultType]::ParameterName, 'Filter by absence of something. Valid values are [label]')
- [CompletionResult]::new('-b', 'b', [CompletionResultType]::ParameterName, 'Sort the results by a characteristic. Valid values are [id,creation,edit]')
- [CompletionResult]::new('--by', 'by', [CompletionResultType]::ParameterName, 'Sort the results by a characteristic. Valid values are [id,creation,edit]')
- [CompletionResult]::new('-d', 'd', [CompletionResultType]::ParameterName, 'Select the sorting direction. Valid values are [asc,desc]')
- [CompletionResult]::new('--direction', 'direction', [CompletionResultType]::ParameterName, 'Select the sorting direction. Valid values are [asc,desc]')
- [CompletionResult]::new('-f', 'f', [CompletionResultType]::ParameterName, 'Select the output formatting style. Valid values are [default,plain,json,org-mode]')
- [CompletionResult]::new('--format', 'format', [CompletionResultType]::ParameterName, 'Select the output formatting style. Valid values are [default,plain,json,org-mode]')
- break
- }
- 'git-bug;ls-id' {
- break
- }
- 'git-bug;ls-label' {
- break
- }
- 'git-bug;pull' {
- break
- }
- 'git-bug;push' {
- break
- }
- 'git-bug;rm' {
- break
- }
- 'git-bug;select' {
- break
- }
- 'git-bug;show' {
- [CompletionResult]::new('--field', 'field', [CompletionResultType]::ParameterName, 'Select field to display. Valid values are [author,authorEmail,createTime,lastEdit,humanId,id,labels,shortId,status,title,actors,participants]')
- [CompletionResult]::new('-f', 'f', [CompletionResultType]::ParameterName, 'Select the output formatting style. Valid values are [default,json,org-mode]')
- [CompletionResult]::new('--format', 'format', [CompletionResultType]::ParameterName, 'Select the output formatting style. Valid values are [default,json,org-mode]')
- break
- }
- 'git-bug;status' {
- [CompletionResult]::new('close', 'close', [CompletionResultType]::ParameterValue, 'Mark a bug as closed.')
- [CompletionResult]::new('open', 'open', [CompletionResultType]::ParameterValue, 'Mark a bug as open.')
- break
- }
- 'git-bug;status;close' {
- break
- }
- 'git-bug;status;open' {
- break
- }
- 'git-bug;termui' {
- break
- }
- 'git-bug;title' {
- [CompletionResult]::new('edit', 'edit', [CompletionResultType]::ParameterValue, 'Edit a title of a bug.')
- break
- }
- 'git-bug;title;edit' {
- [CompletionResult]::new('-t', 't', [CompletionResultType]::ParameterName, 'Provide a title to describe the issue')
- [CompletionResult]::new('--title', 'title', [CompletionResultType]::ParameterName, 'Provide a title to describe the issue')
- break
- }
- 'git-bug;user' {
- [CompletionResult]::new('-f', 'f', [CompletionResultType]::ParameterName, 'Select field to display. Valid values are [email,humanId,id,lastModification,lastModificationLamports,login,metadata,name]')
- [CompletionResult]::new('--field', 'field', [CompletionResultType]::ParameterName, 'Select field to display. Valid values are [email,humanId,id,lastModification,lastModificationLamports,login,metadata,name]')
- [CompletionResult]::new('adopt', 'adopt', [CompletionResultType]::ParameterValue, 'Adopt an existing identity as your own.')
- [CompletionResult]::new('create', 'create', [CompletionResultType]::ParameterValue, 'Create a new identity.')
- [CompletionResult]::new('ls', 'ls', [CompletionResultType]::ParameterValue, 'List identities.')
- break
- }
- 'git-bug;user;adopt' {
- break
- }
- 'git-bug;user;create' {
- break
- }
- 'git-bug;user;ls' {
- [CompletionResult]::new('-f', 'f', [CompletionResultType]::ParameterName, 'Select the output formatting style. Valid values are [default,json]')
- [CompletionResult]::new('--format', 'format', [CompletionResultType]::ParameterName, 'Select the output formatting style. Valid values are [default,json]')
- break
- }
- 'git-bug;version' {
- [CompletionResult]::new('-n', 'n', [CompletionResultType]::ParameterName, 'Only show the version number')
- [CompletionResult]::new('--number', 'number', [CompletionResultType]::ParameterName, 'Only show the version number')
- [CompletionResult]::new('-c', 'c', [CompletionResultType]::ParameterName, 'Only show the commit hash')
- [CompletionResult]::new('--commit', 'commit', [CompletionResultType]::ParameterName, 'Only show the commit hash')
- [CompletionResult]::new('-a', 'a', [CompletionResultType]::ParameterName, 'Show all version information')
- [CompletionResult]::new('--all', 'all', [CompletionResultType]::ParameterName, 'Show all version information')
- break
- }
- 'git-bug;webui' {
- [CompletionResult]::new('--open', 'open', [CompletionResultType]::ParameterName, 'Automatically open the web UI in the default browser')
- [CompletionResult]::new('--no-open', 'no-open', [CompletionResultType]::ParameterName, 'Prevent the automatic opening of the web UI in the default browser')
- [CompletionResult]::new('-p', 'p', [CompletionResultType]::ParameterName, 'Port to listen to (default is random)')
- [CompletionResult]::new('--port', 'port', [CompletionResultType]::ParameterName, 'Port to listen to (default is random)')
- [CompletionResult]::new('--read-only', 'read-only', [CompletionResultType]::ParameterName, 'Whether to run the web UI in read-only mode')
- break
}
- })
- $completions.Where{ $_.CompletionText -like "$wordToComplete*" } |
- Sort-Object -Property ListItemText
-}
+
+ }
+}
@@ -11,16 +11,20 @@ type tokenKind int
const (
_ tokenKind = iota
tokenKindKV
+ tokenKindKVV
tokenKindSearch
)
type token struct {
kind tokenKind
- // KV
+ // KV and KVV
qualifier string
value string
+ // KVV only
+ subQualifier string
+
// Search
term string
}
@@ -33,6 +37,15 @@ func newTokenKV(qualifier, value string) token {
}
}
+func newTokenKVV(qualifier, subQualifier, value string) token {
+ return token{
+ kind: tokenKindKVV,
+ qualifier: qualifier,
+ subQualifier: subQualifier,
+ value: value,
+ }
+}
+
func newTokenSearch(term string) token {
return token{
kind: tokenKindSearch,
@@ -43,44 +56,68 @@ func newTokenSearch(term string) token {
// tokenize parse and break a input into tokens ready to be
// interpreted later by a parser to get the semantic.
func tokenize(query string) ([]token, error) {
- fields, err := splitQuery(query)
+ fields, err := splitFunc(query, unicode.IsSpace)
if err != nil {
return nil, err
}
var tokens []token
for _, field := range fields {
- split := strings.Split(field, ":")
-
- // full text search
- if len(split) == 1 {
- tokens = append(tokens, newTokenSearch(removeQuote(field)))
- continue
+ chunks, err := splitFunc(field, func(r rune) bool { return r == ':' })
+ if err != nil {
+ return nil, err
}
- if len(split) != 2 {
- return nil, fmt.Errorf("can't tokenize \"%s\"", field)
+ if strings.HasPrefix(field, ":") || strings.HasSuffix(field, ":") {
+ return nil, fmt.Errorf("empty qualifier or value")
}
- if len(split[0]) == 0 {
- return nil, fmt.Errorf("can't tokenize \"%s\": empty qualifier", field)
- }
- if len(split[1]) == 0 {
- return nil, fmt.Errorf("empty value for qualifier \"%s\"", split[0])
+ // pre-process chunks
+ for i, chunk := range chunks {
+ if len(chunk) == 0 {
+ return nil, fmt.Errorf("empty qualifier or value")
+ }
+ chunks[i] = removeQuote(chunk)
}
- tokens = append(tokens, newTokenKV(split[0], removeQuote(split[1])))
+ switch len(chunks) {
+ case 1: // full text search
+ tokens = append(tokens, newTokenSearch(chunks[0]))
+
+ case 2: // KV
+ tokens = append(tokens, newTokenKV(chunks[0], chunks[1]))
+
+ case 3: // KVV
+ tokens = append(tokens, newTokenKVV(chunks[0], chunks[1], chunks[2]))
+
+ default:
+ return nil, fmt.Errorf("can't tokenize \"%s\": too many separators", field)
+ }
}
return tokens, nil
}
-// split the query into chunks by splitting on whitespaces but respecting
+func removeQuote(field string) string {
+ runes := []rune(field)
+ if len(runes) >= 2 {
+ r1 := runes[0]
+ r2 := runes[len(runes)-1]
+
+ if r1 == r2 && isQuote(r1) {
+ return string(runes[1 : len(runes)-1])
+ }
+ }
+ return field
+}
+
+// split the input into chunks by splitting according to separatorFunc but respecting
// quotes
-func splitQuery(query string) ([]string, error) {
+func splitFunc(input string, separatorFunc func(r rune) bool) ([]string, error) {
lastQuote := rune(0)
inQuote := false
- isToken := func(r rune) bool {
+ // return true if it's part of a chunk, or false if it's a rune that delimit one, as determined by the separatorFunc.
+ isChunk := func(r rune) bool {
switch {
case !inQuote && isQuote(r):
lastQuote = r
@@ -93,19 +130,19 @@ func splitQuery(query string) ([]string, error) {
case inQuote:
return true
default:
- return !unicode.IsSpace(r)
+ return !separatorFunc(r)
}
}
var result []string
- var token strings.Builder
- for _, r := range query {
- if isToken(r) {
- token.WriteRune(r)
+ var chunk strings.Builder
+ for _, r := range input {
+ if isChunk(r) {
+ chunk.WriteRune(r)
} else {
- if token.Len() > 0 {
- result = append(result, token.String())
- token.Reset()
+ if chunk.Len() > 0 {
+ result = append(result, chunk.String())
+ chunk.Reset()
}
}
}
@@ -114,8 +151,8 @@ func splitQuery(query string) ([]string, error) {
return nil, fmt.Errorf("unmatched quote")
}
- if token.Len() > 0 {
- result = append(result, token.String())
+ if chunk.Len() > 0 {
+ result = append(result, chunk.String())
}
return result, nil
@@ -124,16 +161,3 @@ func splitQuery(query string) ([]string, error) {
func isQuote(r rune) bool {
return r == '"' || r == '\''
}
-
-func removeQuote(field string) string {
- runes := []rune(field)
- if len(runes) >= 2 {
- r1 := runes[0]
- r2 := runes[len(runes)-1]
-
- if r1 == r2 && isQuote(r1) {
- return string(runes[1 : len(runes)-1])
- }
- }
- return field
-}
@@ -3,7 +3,7 @@ package query
import (
"testing"
- "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
func TestTokenize(t *testing.T) {
@@ -37,6 +37,14 @@ func TestTokenize(t *testing.T) {
{`key:'value value`, nil},
{`key:value value'`, nil},
+ // sub-qualifier positive testing
+ {`key:subkey:"value:value"`, []token{newTokenKVV("key", "subkey", "value:value")}},
+
+ // sub-qualifier negative testing
+ {`key:subkey:value:value`, nil},
+ {`key:subkey:`, nil},
+ {`key:subkey:"value`, nil},
+
// full text search
{"search", []token{newTokenSearch("search")}},
{"search more terms", []token{
@@ -51,13 +59,15 @@ func TestTokenize(t *testing.T) {
}
for _, tc := range tests {
- tokens, err := tokenize(tc.input)
- if tc.tokens == nil {
- assert.Error(t, err)
- assert.Nil(t, tokens)
- } else {
- assert.NoError(t, err)
- assert.Equal(t, tc.tokens, tokens)
- }
+ t.Run(tc.input, func(t *testing.T) {
+ tokens, err := tokenize(tc.input)
+ if tc.tokens == nil {
+ require.Error(t, err)
+ require.Nil(t, tokens)
+ } else {
+ require.NoError(t, err)
+ require.Equal(t, tc.tokens, tokens)
+ }
+ })
}
}
@@ -67,6 +67,15 @@ func Parse(query string) (*Query, error) {
default:
return nil, fmt.Errorf("unknown qualifier \"%s\"", t.qualifier)
}
+
+ case tokenKindKVV:
+ switch t.qualifier {
+ case "metadata":
+ q.Metadata = append(q.Metadata, StringPair{Key: t.subQualifier, Value: t.value})
+
+ default:
+ return nil, fmt.Errorf("unknown qualifier \"%s:%s\"", t.qualifier, t.subQualifier)
+ }
}
}
return q, nil
@@ -3,7 +3,7 @@ package query
import (
"testing"
- "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
"github.com/MichaelMure/git-bug/bug"
)
@@ -62,6 +62,11 @@ func TestParse(t *testing.T) {
}},
{"sort:unknown", nil},
+ // KVV
+ {`metadata:key:"https://www.example.com/"`, &Query{
+ Filters: Filters{Metadata: []StringPair{{"key", "https://www.example.com/"}}},
+ }},
+
// Search
{"search", &Query{
Search: []string{"search"},
@@ -90,17 +95,17 @@ func TestParse(t *testing.T) {
t.Run(tc.input, func(t *testing.T) {
query, err := Parse(tc.input)
if tc.output == nil {
- assert.Error(t, err)
- assert.Nil(t, query)
+ require.Error(t, err)
+ require.Nil(t, query)
} else {
- assert.NoError(t, err)
+ require.NoError(t, err)
if tc.output.OrderBy != 0 {
- assert.Equal(t, tc.output.OrderBy, query.OrderBy)
+ require.Equal(t, tc.output.OrderBy, query.OrderBy)
}
if tc.output.OrderDirection != 0 {
- assert.Equal(t, tc.output.OrderDirection, query.OrderDirection)
+ require.Equal(t, tc.output.OrderDirection, query.OrderDirection)
}
- assert.Equal(t, tc.output.Filters, query.Filters)
+ require.Equal(t, tc.output.Filters, query.Filters)
}
})
}
@@ -23,10 +23,17 @@ func NewQuery() *Query {
type Search []string
+// StringPair is a key/value pair of strings
+type StringPair struct {
+ Key string
+ Value string
+}
+
// Filters is a collection of Filter that implement a complex filter
type Filters struct {
Status []bug.Status
Author []string
+ Metadata []StringPair
Actor []string
Participant []string
Label []string
@@ -20,6 +20,7 @@ func NewMemConfig() *MemConfig {
}
func (mc *MemConfig) StoreString(key, value string) error {
+ key = normalizeKey(key)
mc.config[key] = value
return nil
}
@@ -33,6 +34,7 @@ func (mc *MemConfig) StoreTimestamp(key string, value time.Time) error {
}
func (mc *MemConfig) ReadAll(keyPrefix string) (map[string]string, error) {
+ keyPrefix = normalizeKey(keyPrefix)
result := make(map[string]string)
for key, val := range mc.config {
if strings.HasPrefix(key, keyPrefix) {
@@ -44,6 +46,7 @@ func (mc *MemConfig) ReadAll(keyPrefix string) (map[string]string, error) {
func (mc *MemConfig) ReadString(key string) (string, error) {
// unlike git, the mock can only store one value for the same key
+ key = normalizeKey(key)
val, ok := mc.config[key]
if !ok {
return "", ErrNoConfigEntry
@@ -54,9 +57,9 @@ func (mc *MemConfig) ReadString(key string) (string, error) {
func (mc *MemConfig) ReadBool(key string) (bool, error) {
// unlike git, the mock can only store one value for the same key
- val, ok := mc.config[key]
- if !ok {
- return false, ErrNoConfigEntry
+ val, err := mc.ReadString(key)
+ if err != nil {
+ return false, err
}
return strconv.ParseBool(val)
@@ -78,6 +81,7 @@ func (mc *MemConfig) ReadTimestamp(key string) (time.Time, error) {
// RmConfigs remove all key/value pair matching the key prefix
func (mc *MemConfig) RemoveAll(keyPrefix string) error {
+ keyPrefix = normalizeKey(keyPrefix)
found := false
for key := range mc.config {
if strings.HasPrefix(key, keyPrefix) {
@@ -92,3 +96,12 @@ func (mc *MemConfig) RemoveAll(keyPrefix string) error {
return nil
}
+
+func normalizeKey(key string) string {
+ // this feels so wrong, but that's apparently how git behave.
+ // only section and final segment are case insensitive, subsection in between are not.
+ s := strings.Split(key, ".")
+ s[0] = strings.ToLower(s[0])
+ s[len(s)-1] = strings.ToLower(s[len(s)-1])
+ return strings.Join(s, ".")
+}
@@ -113,4 +113,43 @@ func testConfig(t *testing.T, config Config) {
"section.subsection.subsection.opt1": "foo5",
"section.subsection.subsection.opt2": "foo6",
}, all)
+
+ // missing section + case insensitive
+ val, err = config.ReadString("section2.opt1")
+ require.Error(t, err)
+
+ val, err = config.ReadString("section.opt1")
+ require.NoError(t, err)
+ require.Equal(t, "foo", val)
+
+ val, err = config.ReadString("SECTION.OPT1")
+ require.NoError(t, err)
+ require.Equal(t, "foo", val)
+
+ _, err = config.ReadString("SECTION2.OPT3")
+ require.Error(t, err)
+
+ // missing subsection + case insensitive
+ val, err = config.ReadString("section.subsection.opt1")
+ require.NoError(t, err)
+ require.Equal(t, "foo3", val)
+
+ // for some weird reason, subsection ARE case sensitive
+ _, err = config.ReadString("SECTION.SUBSECTION.OPT1")
+ require.Error(t, err)
+
+ _, err = config.ReadString("SECTION.SUBSECTION1.OPT1")
+ require.Error(t, err)
+
+ // missing sub-subsection + case insensitive
+ val, err = config.ReadString("section.subsection.subsection.opt1")
+ require.NoError(t, err)
+ require.Equal(t, "foo5", val)
+
+ // for some weird reason, subsection ARE case sensitive
+ _, err = config.ReadString("SECTION.SUBSECTION.SUBSECTION.OPT1")
+ require.Error(t, err)
+
+ _, err = config.ReadString("SECTION.SUBSECTION.SUBSECTION1.OPT1")
+ require.Error(t, err)
}
@@ -5,7 +5,6 @@ import (
"fmt"
"io/ioutil"
"os"
- "os/exec"
"path/filepath"
"sort"
"strings"
@@ -21,6 +20,7 @@ import (
"github.com/go-git/go-git/v5/plumbing/filemode"
"github.com/go-git/go-git/v5/plumbing/object"
"golang.org/x/crypto/openpgp"
+ "golang.org/x/sys/execabs"
"github.com/MichaelMure/git-bug/util/lamport"
)
@@ -264,7 +264,7 @@ func (repo *GoGitRepo) GetCoreEditor() (string, error) {
}
for _, cmd := range priorities {
- if _, err = exec.LookPath(cmd); err == nil {
+ if _, err = execabs.LookPath(cmd); err == nil {
return cmd, nil
}
@@ -134,7 +134,7 @@ func (cr *goGitConfigReader) ReadString(key string) (string, error) {
}
return section.Option(optionName), nil
default:
- subsectionName := strings.Join(split[1:len(split)-2], ".")
+ subsectionName := strings.Join(split[1:len(split)-1], ".")
optionName := split[len(split)-1]
if !section.HasSubsection(subsectionName) {
return "", ErrNoConfigEntry
@@ -1,5 +1,8 @@
# git-bug rich web UI
+## Prerequisites
+[ReactJS](https://reactjs.org/) | [Material UI](https://material-ui.com/) | [GraphQL](https://graphql.org/) | [Apollo GraphQL](https://www.apollographql.com/docs/react/)
+
## How to develop
### Run GraphQL backend
@@ -26,4 +29,7 @@ The development version of the WebUI is configured to query the backend on the p
## Bundle the web UI
-Once the webUI is good enough for a new release, run `make pack-webui` from the root directory to bundle the compiled js into the go binary.
+Once the webUI is good enough for a new release:
+1. run `make build` from webui folder
+2. run `make pack-webui` from the *root directory* to bundle the compiled js into the go binary.
+ - You must have Go installed on Your machine to run this command.
@@ -12572,8 +12572,7 @@
"growly": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz",
- "integrity": "sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE=",
- "optional": true
+ "integrity": "sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE="
},
"gzip-size": {
"version": "5.1.1",
@@ -16116,10 +16115,9 @@
"integrity": "sha1-jZ2+KJZKSsVxLpExZCEHxx6Q7EA="
},
"node-notifier": {
- "version": "8.0.0",
- "resolved": "https://registry.npmjs.org/node-notifier/-/node-notifier-8.0.0.tgz",
- "integrity": "sha512-46z7DUmcjoYdaWyXouuFNNfUo6eFa94t23c53c+lG/9Cvauk4a98rAUp9672X5dxGdQmLpPzTxzu8f/OeEPaFA==",
- "optional": true,
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/node-notifier/-/node-notifier-8.0.1.tgz",
+ "integrity": "sha512-BvEXF+UmsnAfYfoapKM9nGxnP+Wn7P91YfXmrKnfcYCx6VBeoN5Ez5Ogck6I8Bi5k4RlpqRYaw75pAwzX9OphA==",
"requires": {
"growly": "^1.3.0",
"is-wsl": "^2.2.0",
@@ -16130,22 +16128,22 @@
},
"dependencies": {
"semver": {
- "version": "7.3.2",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.2.tgz",
- "integrity": "sha512-OrOb32TeeambH6UrhtShmF7CRDqhL6/5XpPNp2DuRH6+9QLw/orhp72j87v8Qa1ScDkvrrBNpZcDejAirJmfXQ==",
- "optional": true
+ "version": "7.3.4",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.4.tgz",
+ "integrity": "sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw==",
+ "requires": {
+ "lru-cache": "^6.0.0"
+ }
},
"uuid": {
- "version": "8.3.0",
- "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.0.tgz",
- "integrity": "sha512-fX6Z5o4m6XsXBdli9g7DtWgAx+osMsRRZFKma1mIUsLCz6vRvv+pz5VNbyu9UEDzpMWulZfvpgb/cmDXVulYFQ==",
- "optional": true
+ "version": "8.3.2",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
+ "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="
},
"which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
- "optional": true,
"requires": {
"isexe": "^2.0.0"
}
@@ -20221,8 +20219,7 @@
"shellwords": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/shellwords/-/shellwords-0.1.1.tgz",
- "integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==",
- "optional": true
+ "integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww=="
},
"side-channel": {
"version": "1.0.3",
@@ -21,92 +21,78 @@ var WebUIAssets = func() http.FileSystem {
fs := vfsgen۰FS{
"/": &vfsgen۰DirInfo{
name: "/",
- modTime: time.Date(2020, 6, 27, 21, 4, 34, 651378504, time.UTC),
+ modTime: time.Date(2021, 2, 19, 22, 21, 2, 16455900, time.UTC),
},
"/asset-manifest.json": &vfsgen۰CompressedFileInfo{
name: "asset-manifest.json",
- modTime: time.Date(2020, 6, 27, 21, 4, 34, 651378504, time.UTC),
- uncompressedSize: 849,
+ modTime: time.Date(2021, 2, 19, 22, 21, 2, 17421700, time.UTC),
+ uncompressedSize: 683,
@@ -0,0 +1,21 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1024 1024">
+ <defs>
@@ -5,6 +5,7 @@ import Layout from './components/Header';
import BugPage from './pages/bug';
import ListPage from './pages/list';
import NewBugPage from './pages/new/NewBugPage';
+import NotFoundPage from './pages/notfound/NotFoundPage';
export default function App() {
return (
@@ -13,6 +14,7 @@ export default function App() {
<Route path="/" exact component={ListPage} />
<Route path="/new" exact component={NewBugPage} />
<Route path="/bug/:id" exact component={BugPage} />
+ <Route component={NotFoundPage} />
</Switch>
</Layout>
);
@@ -0,0 +1,36 @@
+import React from 'react';
+
+import Button from '@material-ui/core/Button';
+import { makeStyles } from '@material-ui/core/styles';
+import ArrowBackIcon from '@material-ui/icons/ArrowBack';
+
+const useStyles = makeStyles((theme) => ({
+ backButton: {
+ position: 'sticky',
+ top: '80px',
+ backgroundColor: theme.palette.primary.dark,
+ color: theme.palette.primary.contrastText,
+ '&:hover': {
+ backgroundColor: theme.palette.primary.main,
+ color: theme.palette.primary.contrastText,
+ },
+ },
+}));
+
+function BackToListButton() {
+ const classes = useStyles();
+
+ return (
+ <Button
+ variant="contained"
+ className={classes.backButton}
+ aria-label="back to issue list"
+ href="/"
+ >
+ <ArrowBackIcon />
+ Back to List
+ </Button>
+ );
+}
+
+export default BackToListButton;
@@ -1,12 +1,6 @@
import React, { useState } from 'react';
-import {
- Button,
- fade,
- makeStyles,
- TextField,
- Typography,
-} from '@material-ui/core';
+import { Button, makeStyles, Typography } from '@material-ui/core';
import { TimelineDocument } from '../../pages/bug/TimelineQuery.generated';
import IfLoggedIn from '../IfLoggedIn/IfLoggedIn';
@@ -14,6 +8,7 @@ import Author from 'src/components/Author';
import Date from 'src/components/Date';
import { BugFragment } from 'src/pages/bug/Bug.generated';
+import BugTitleInput from './BugTitleInput';
import { useSetTitleMutation } from './SetTitle.generated';
/**
@@ -45,26 +40,16 @@ const useStyles = makeStyles((theme) => ({
marginLeft: theme.spacing(2),
},
greenButton: {
- marginLeft: '8px',
- backgroundColor: '#2ea44fd9',
- color: '#fff',
+ marginLeft: theme.spacing(1),
+ backgroundColor: theme.palette.success.main,
+ color: theme.palette.success.contrastText,
'&:hover': {
- backgroundColor: '#2ea44f',
+ backgroundColor: theme.palette.success.dark,
+ color: theme.palette.primary.contrastText,
},
},
- 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',
- ]),
+ saveButton: {
+ marginRight: theme.spacing(1),
},
}));
@@ -85,7 +70,7 @@ function BugTitleForm({ bug }: Props) {
function isFormValid() {
if (issueTitleInput) {
- return issueTitleInput.value.length > 0 ? true : false;
+ return issueTitleInput.value.length > 0;
} else {
return false;
}
@@ -122,11 +107,11 @@ function BugTitleForm({ bug }: Props) {
function editableBugTitle() {
return (
<form className={classes.headerTitle} onSubmit={submitNewTitle}>
- <TextField
+ <BugTitleInput
inputRef={(node) => {
issueTitleInput = node;
}}
- className={classes.titleInput}
+ label="Title"
variant="outlined"
fullWidth
margin="dense"
@@ -135,6 +120,7 @@ function BugTitleForm({ bug }: Props) {
/>
<div className={classes.editButtonContainer}>
<Button
+ className={classes.saveButton}
size="small"
variant="contained"
type="submit"
@@ -173,7 +159,7 @@ function BugTitleForm({ bug }: Props) {
variant="contained"
href="/new"
>
- New issue
+ New bug
</Button>
</div>
)}
@@ -0,0 +1,40 @@
+import { createStyles, fade, withStyles, TextField } from '@material-ui/core';
+import { Theme } from '@material-ui/core/styles';
+
+const BugTitleInput = withStyles((theme: Theme) =>
+ createStyles({
+ root: {
+ '& .MuiInputLabel-outlined': {
+ color: theme.palette.text.primary,
+ },
+ '& input:valid + fieldset': {
+ color: theme.palette.text.primary,
+ borderColor: theme.palette.divider,
+ borderWidth: 2,
+ },
+ '& input:valid:hover + fieldset': {
+ color: theme.palette.text.primary,
+ borderColor: fade(theme.palette.divider, 0.3),
+ borderWidth: 2,
+ },
+ '& input:valid:focus + fieldset': {
+ color: theme.palette.text.primary,
+ borderColor: theme.palette.divider,
+ },
+ '& input:invalid + fieldset': {
+ borderColor: theme.palette.error.main,
+ borderWidth: 2,
+ },
+ '& input:invalid:hover + fieldset': {
+ borderColor: theme.palette.error.main,
+ borderWidth: 2,
+ },
+ '& input:invalid:focus + fieldset': {
+ borderColor: theme.palette.error.main,
+ borderWidth: 2,
+ },
+ },
+ })
+)(TextField);
+
+export default BugTitleInput;
@@ -1,12 +1,21 @@
import React from 'react';
import Button from '@material-ui/core/Button';
+import { makeStyles, Theme } from '@material-ui/core/styles';
+import ErrorOutlineIcon from '@material-ui/icons/ErrorOutline';
import { BugFragment } from 'src/pages/bug/Bug.generated';
import { TimelineDocument } from 'src/pages/bug/TimelineQuery.generated';
import { useCloseBugMutation } from './CloseBug.generated';
+const useStyles = makeStyles((theme: Theme) => ({
+ closeIssueIcon: {
+ color: theme.palette.secondary.dark,
+ paddingTop: '0.1rem',
+ },
+}));
+
interface Props {
bug: BugFragment;
disabled: boolean;
@@ -14,6 +23,7 @@ interface Props {
function CloseBugButton({ bug, disabled }: Props) {
const [closeBug, { loading, error }] = useCloseBugMutation();
+ const classes = useStyles();
function closeBugAction() {
closeBug({
@@ -45,8 +55,9 @@ function CloseBugButton({ bug, disabled }: Props) {
variant="contained"
onClick={() => closeBugAction()}
disabled={bug.status === 'CLOSED' || disabled}
+ startIcon={<ErrorOutlineIcon className={classes.closeIssueIcon} />}
>
- Close issue
+ Close bug
</Button>
</div>
);
@@ -51,6 +51,7 @@ const a11yProps = (index: number) => ({
type Props = {
inputProps?: any;
+ inputText?: string;
loading: boolean;
onChange: (comment: string) => void;
};
@@ -62,8 +63,8 @@ type Props = {
* @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>('');
+function CommentInput({ inputProps, inputText, loading, onChange }: Props) {
+ const [input, setInput] = useState<string>(inputText ? inputText : '');
const [tab, setTab] = useState(0);
const classes = useStyles();
@@ -11,7 +11,7 @@ const useStyles = makeStyles({
const PreTag = (props: React.HTMLProps<HTMLPreElement>) => {
const classes = useStyles();
- return <pre className={classes.tag} {...props}></pre>;
+ return <pre className={classes.tag} {...props} />;
};
export default PreTag;
@@ -1,11 +1,15 @@
import React from 'react';
-import { Link } from 'react-router-dom';
+import { Link, useLocation } from 'react-router-dom';
import AppBar from '@material-ui/core/AppBar';
+import Tab, { TabProps } from '@material-ui/core/Tab';
+import Tabs from '@material-ui/core/Tabs';
import Toolbar from '@material-ui/core/Toolbar';
+import Tooltip from '@material-ui/core/Tooltip/Tooltip';
import { makeStyles } from '@material-ui/core/styles';
import CurrentIdentity from '../CurrentIdentity/CurrentIdentity';
+import { LightSwitch } from '../Themer';
const useStyles = makeStyles((theme) => ({
offset: {
@@ -14,35 +18,99 @@ const useStyles = makeStyles((theme) => ({
filler: {
flexGrow: 1,
},
+ appBar: {
+ backgroundColor: theme.palette.primary.dark,
+ color: theme.palette.primary.contrastText,
+ },
appTitle: {
...theme.typography.h6,
- color: 'white',
+ color: theme.palette.primary.contrastText,
textDecoration: 'none',
display: 'flex',
alignItems: 'center',
},
+ lightSwitch: {
+ padding: '0 20px',
+ },
logo: {
height: '42px',
marginRight: theme.spacing(2),
},
}));
+function a11yProps(index: any) {
+ return {
+ id: `nav-tab-${index}`,
+ 'aria-controls': `nav-tabpanel-${index}`,
+ };
+}
+
+const DisabledTabWithTooltip = (props: TabProps) => {
+ /*The span elements around disabled tabs are needed, as the tooltip
+ * won't be triggered by disabled elements.
+ * See: https://material-ui.com/components/tooltips/#disabled-elements
+ * This must be done in a wrapper component, otherwise the TabS component
+ * cannot pass it styles down to the Tab component. Resulting in (console)
+ * warnings. This wrapper acceps the passed down TabProps and pass it around
+ * the span element to the Tab component.
+ */
+ const msg = `This feature doesn't exist yet. Come help us build it.`;
+ return (
+ <Tooltip title={msg}>
+ <span>
+ <Tab disabled {...props} />
+ </span>
+ </Tooltip>
+ );
+};
+
function Header() {
const classes = useStyles();
+ const location = useLocation();
+ const [selectedTab, setTab] = React.useState(location.pathname);
+
+ const handleTabClick = (
+ event: React.ChangeEvent<{}>,
+ newTabValue: string
+ ) => {
+ setTab(newTabValue);
+ };
return (
<>
- <AppBar position="fixed" color="primary">
+ <AppBar position="fixed" className={classes.appBar}>
<Toolbar>
<Link to="/" className={classes.appTitle}>
- <img src="/logo.svg" className={classes.logo} alt="git-bug" />
+ <img src="/logo.svg" className={classes.logo} alt="git-bug logo" />
git-bug
</Link>
- <div className={classes.filler}></div>
+ <div className={classes.filler} />
+ <div className={classes.lightSwitch}>
+ <LightSwitch />
+ </div>
<CurrentIdentity />
</Toolbar>
</AppBar>
<div className={classes.offset} />
+ <Tabs
+ centered
+ value={selectedTab}
+ onChange={handleTabClick}
+ aria-label="nav tabs"
+ >
+ <DisabledTabWithTooltip label="Code" value="/code" {...a11yProps(1)} />
+ <Tab label="Bugs" value="/" component={Link} to="/" {...a11yProps(2)} />
+ <DisabledTabWithTooltip
+ label="Pull Requests"
+ value="/pulls"
+ {...a11yProps(3)}
+ />
+ <DisabledTabWithTooltip
+ label="Settings"
+ value="/settings"
+ {...a11yProps(4)}
+ />
+ </Tabs>
</>
);
}
@@ -46,7 +46,7 @@ function ReopenBugButton({ bug, disabled }: Props) {
onClick={() => openBugAction()}
disabled={bug.status === 'OPEN' || disabled}
>
- Reopen issue
+ Reopen bug
</Button>
</div>
);
@@ -0,0 +1,65 @@
+import React, { createContext, useContext, useState } from 'react';
+
+import { fade, ThemeProvider } from '@material-ui/core';
+import IconButton from '@material-ui/core/IconButton/IconButton';
+import Tooltip from '@material-ui/core/Tooltip/Tooltip';
+import { Theme } from '@material-ui/core/styles';
+import { NightsStayRounded, WbSunnyRounded } from '@material-ui/icons';
+import { makeStyles } from '@material-ui/styles';
+
+const ThemeContext = createContext({
+ toggleMode: () => {},
+ mode: '',
+});
+
+const useStyles = makeStyles((theme: Theme) => ({
+ iconButton: {
+ color: fade(theme.palette.primary.contrastText, 0.5),
+ },
+}));
+
+const LightSwitch = () => {
+ const { mode, toggleMode } = useContext(ThemeContext);
+ const nextMode = mode === 'light' ? 'dark' : 'light';
+ const description = `Switch to ${nextMode} theme`;
+ const classes = useStyles();
+
+ return (
+ <Tooltip title={description}>
+ <IconButton
+ onClick={toggleMode}
+ aria-label={description}
+ className={classes.iconButton}
+ >
+ {mode === 'light' ? <WbSunnyRounded /> : <NightsStayRounded />}
+ </IconButton>
+ </Tooltip>
+ );
+};
+
+type Props = {
+ children: React.ReactNode;
+ lightTheme: Theme;
+ darkTheme: Theme;
+};
+const Themer = ({ children, lightTheme, darkTheme }: Props) => {
+ const savedMode = localStorage.getItem('themeMode');
+ const preferedMode = savedMode != null ? savedMode : 'light';
+ const [mode, setMode] = useState(preferedMode);
+
+ const toggleMode = () => {
+ const preferedMode = mode === 'light' ? 'dark' : 'light';
+ localStorage.setItem('themeMode', preferedMode);
+ setMode(preferedMode);
+ };
+
+ const preferedTheme = mode === 'dark' ? darkTheme : lightTheme;
+
+ return (
+ <ThemeContext.Provider value={{ toggleMode: toggleMode, mode: mode }}>
+ <ThemeProvider theme={preferedTheme}>{children}</ThemeProvider>
+ </ThemeContext.Provider>
+ );
+};
+
+export { Themer as default, LightSwitch };
@@ -3,18 +3,17 @@ import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter } from 'react-router-dom';
-import ThemeProvider from '@material-ui/styles/ThemeProvider';
-
import App from './App';
import apolloClient from './apollo';
-import theme from './theme';
+import Themer from './components/Themer';
+import { defaultLightTheme, defaultDarkTheme } from './themes/index';
ReactDOM.render(
<ApolloProvider client={apolloClient}>
<BrowserRouter>
- <ThemeProvider theme={theme}>
+ <Themer lightTheme={defaultLightTheme} darkTheme={defaultDarkTheme}>
<App />
- </ThemeProvider>
+ </Themer>
</BrowserRouter>
</ApolloProvider>,
document.getElementById('root')
@@ -18,11 +18,17 @@ const useStyles = makeStyles((theme) => ({
maxWidth: 1000,
margin: 'auto',
marginTop: theme.spacing(4),
- overflow: 'hidden',
},
header: {
- marginLeft: theme.spacing(3) + 40,
marginRight: theme.spacing(2),
+ marginLeft: theme.spacing(3) + 40,
+ },
+ title: {
+ ...theme.typography.h5,
+ },
+ id: {
+ ...theme.typography.subtitle1,
+ marginLeft: theme.spacing(1),
},
container: {
display: 'flex',
@@ -36,11 +42,11 @@ const useStyles = makeStyles((theme) => ({
marginRight: theme.spacing(2),
minWidth: 400,
},
- sidebar: {
+ rightSidebar: {
marginTop: theme.spacing(2),
flex: '0 0 200px',
},
- sidebarTitle: {
+ rightSidebarTitle: {
fontWeight: 'bold',
},
labelList: {
@@ -59,6 +65,7 @@ const useStyles = makeStyles((theme) => ({
...theme.typography.body2,
},
commentForm: {
+ marginTop: theme.spacing(2),
marginLeft: 48,
},
}));
@@ -75,10 +82,9 @@ function Bug({ bug }: Props) {
<div className={classes.header}>
<BugTitleForm bug={bug} />
</div>
-
<div className={classes.container}>
<div className={classes.timeline}>
- <TimelineQuery id={bug.id} />
+ <TimelineQuery bug={bug} />
<IfLoggedIn>
{() => (
<div className={classes.commentForm}>
@@ -87,8 +93,8 @@ function Bug({ bug }: Props) {
)}
</IfLoggedIn>
</div>
- <div className={classes.sidebar}>
- <span className={classes.sidebarTitle}>Labels</span>
+ <div className={classes.rightSidebar}>
+ <span className={classes.rightSidebarTitle}>Labels</span>
<ul className={classes.labelList}>
{bug.labels.length === 0 && (
<span className={classes.noLabel}>None yet</span>
@@ -3,6 +3,8 @@ import { RouteComponentProps } from 'react-router-dom';
import CircularProgress from '@material-ui/core/CircularProgress';
+import NotFoundPage from '../notfound/NotFoundPage';
+
import Bug from './Bug';
import { useGetBugQuery } from './BugQuery.generated';
@@ -15,8 +17,8 @@ const BugQuery: React.FC<Props> = ({ match }: Props) => {
variables: { id: match.params.id },
});
if (loading) return <CircularProgress />;
+ if (!data?.repository?.bug) return <NotFoundPage />;
if (error) return <p>Error: {error}</p>;
- if (!data?.repository?.bug) return <p>404.</p>;
return <Bug bug={data.repository.bug} />;
};
@@ -15,7 +15,6 @@ import { TimelineDocument } from './TimelineQuery.generated';
type StyleProps = { loading: boolean };
const useStyles = makeStyles<Theme, StyleProps>((theme) => ({
container: {
- margin: theme.spacing(2, 0),
padding: theme.spacing(0, 2, 2, 2),
},
textarea: {},
@@ -28,14 +27,16 @@ const useStyles = makeStyles<Theme, StyleProps>((theme) => ({
},
actions: {
display: 'flex',
+ gap: '1em',
justifyContent: 'flex-end',
},
greenButton: {
marginLeft: '8px',
- backgroundColor: '#2ea44fd9',
- color: '#fff',
+ backgroundColor: theme.palette.success.main,
+ color: theme.palette.success.contrastText,
'&:hover': {
- backgroundColor: '#2ea44f',
+ backgroundColor: theme.palette.success.dark,
+ color: theme.palette.primary.contrastText,
},
},
}));
@@ -0,0 +1,16 @@
+#import "./MessageCommentFragment.graphql"
+#import "./MessageCreateFragment.graphql"
+
+mutation EditComment($input: EditCommentInput!) {
+ editComment(input: $input) {
+ bug {
+ id
+ timeline {
+ comments: nodes {
+ ...Create
+ ...AddComment
+ }
+ }
+ }
+ }
+}
@@ -0,0 +1,123 @@
+import React, { useState, useRef } from 'react';
+
+import Button from '@material-ui/core/Button';
+import Paper from '@material-ui/core/Paper';
+import { makeStyles, Theme } from '@material-ui/core/styles';
+
+import CommentInput from '../../components/CommentInput/CommentInput';
+
+import { BugFragment } from './Bug.generated';
+import { useEditCommentMutation } from './EditCommentForm.generated';
+import { AddCommentFragment } from './MessageCommentFragment.generated';
+import { CreateFragment } from './MessageCreateFragment.generated';
+
+type StyleProps = { loading: boolean };
+const useStyles = makeStyles<Theme, StyleProps>((theme) => ({
+ container: {
+ padding: theme.spacing(0, 2, 2, 2),
+ },
+ textarea: {},
+ tabContent: {
+ margin: theme.spacing(2, 0),
+ },
+ preview: {
+ borderBottom: `solid 3px ${theme.palette.grey['200']}`,
+ minHeight: '5rem',
+ },
+ actions: {
+ display: 'flex',
+ justifyContent: 'flex-end',
+ },
+ greenButton: {
+ marginLeft: '8px',
+ backgroundColor: theme.palette.success.main,
+ color: theme.palette.success.contrastText,
+ '&:hover': {
+ backgroundColor: theme.palette.success.dark,
+ color: theme.palette.success.contrastText,
+ },
+ },
+}));
+
+type Props = {
+ bug: BugFragment;
+ comment: AddCommentFragment | CreateFragment;
+ onCancel?: () => void;
+ onPostSubmit?: (comments: any) => void;
+};
+
+function EditCommentForm({ bug, comment, onCancel, onPostSubmit }: Props) {
+ const [editComment, { loading }] = useEditCommentMutation();
+ const [message, setMessage] = useState<string>(comment.message);
+ const [inputProp, setInputProp] = useState<any>('');
+ const classes = useStyles({ loading });
+ const form = useRef<HTMLFormElement>(null);
+
+ const submit = () => {
+ editComment({
+ variables: {
+ input: {
+ prefix: bug.id,
+ message: message,
+ target: comment.id,
+ },
+ },
+ }).then((result) => {
+ const comments = result.data?.editComment.bug.timeline.comments as (
+ | AddCommentFragment
+ | CreateFragment
+ )[];
+ // NOTE Searching for the changed comment could be dropped if GraphQL get
+ // filter by id argument for timelineitems
+ const modifiedComment = comments.find((elem) => elem.id === comment.id);
+ if (onPostSubmit) onPostSubmit(modifiedComment);
+ });
+ resetForm();
+ };
+
+ function resetForm() {
+ setInputProp({
+ value: '',
+ });
+ }
+
+ const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
+ e.preventDefault();
+ if (message.length > 0) submit();
+ };
+
+ function getCancelButton() {
+ return (
+ <Button onClick={onCancel} variant="contained">
+ Cancel
+ </Button>
+ );
+ }
+
+ return (
+ <Paper className={classes.container}>
+ <form onSubmit={handleSubmit} ref={form}>
+ <CommentInput
+ inputProps={inputProp}
+ loading={loading}
+ onChange={(message: string) => setMessage(message)}
+ inputText={comment.message}
+ />
+ <div className={classes.actions}>
+ {onCancel && getCancelButton()}
+ <Button
+ className={classes.greenButton}
+ variant="contained"
+ color="primary"
+ type="submit"
+ disabled={loading || message.length === 0}
+ >
+ Update Comment
+ </Button>
+ </div>
+ </form>
+ </Paper>
+ );
+}
+
+export default EditCommentForm;
@@ -1,14 +1,22 @@
-import React from 'react';
+import React, { useState } from 'react';
+import IconButton from '@material-ui/core/IconButton';
import Paper from '@material-ui/core/Paper';
+import Tooltip from '@material-ui/core/Tooltip/Tooltip';
import { makeStyles } from '@material-ui/core/styles';
+import EditIcon from '@material-ui/icons/Edit';
+import HistoryIcon from '@material-ui/icons/History';
import Author, { Avatar } from 'src/components/Author';
import Content from 'src/components/Content';
import Date from 'src/components/Date';
+import IfLoggedIn from 'src/components/IfLoggedIn/IfLoggedIn';
+import { BugFragment } from './Bug.generated';
+import EditCommentForm from './EditCommentForm';
import { AddCommentFragment } from './MessageCommentFragment.generated';
import { CreateFragment } from './MessageCreateFragment.generated';
+import MessageHistoryDialog from './MessageHistoryDialog';
const useStyles = makeStyles((theme) => ({
author: {
@@ -27,11 +35,13 @@ const useStyles = makeStyles((theme) => ({
},
header: {
...theme.typography.body1,
- color: '#444',
padding: '0.5rem 1rem',
- borderBottom: '1px solid #ddd',
+ borderBottom: `1px solid ${theme.palette.divider}`,
display: 'flex',
- backgroundColor: '#e2f1ff',
+ borderTopRightRadius: theme.shape.borderRadius,
+ borderTopLeftRadius: theme.shape.borderRadius,
+ backgroundColor: theme.palette.info.main,
+ color: theme.palette.info.contrastText,
},
title: {
flex: 1,
@@ -47,32 +57,135 @@ const useStyles = makeStyles((theme) => ({
},
body: {
...theme.typography.body2,
- padding: '0 1rem',
+ padding: '0.5rem',
+ },
+ headerActions: {
+ color: theme.palette.info.contrastText,
+ padding: '0rem',
+ marginLeft: theme.spacing(1),
+ fontSize: '0.75rem',
+ '&:hover': {
+ backgroundColor: 'inherit',
+ },
},
}));
+type HistBtnProps = {
+ bugId: string;
+ commentId: string;
+};
+function HistoryMenuToggleButton({ bugId, commentId }: HistBtnProps) {
+ const classes = useStyles();
+ const [open, setOpen] = React.useState(false);
+
+ const handleClickOpen = () => {
+ setOpen(true);
+ };
+
+ const handleClose = () => {
+ setOpen(false);
+ };
+
+ return (
+ <div>
+ <IconButton
+ aria-label="more"
+ aria-controls="long-menu"
+ aria-haspopup="true"
+ onClick={handleClickOpen}
+ className={classes.headerActions}
+ >
+ <HistoryIcon />
+ </IconButton>
+ {
+ // Render CustomizedDialogs on open to prevent fetching the history
+ // before opening the history menu.
+ open && (
+ <MessageHistoryDialog
+ bugId={bugId}
+ commentId={commentId}
+ open={open}
+ onClose={handleClose}
+ />
+ )
+ }
+ </div>
+ );
+}
+
type Props = {
+ bug: BugFragment;
op: AddCommentFragment | CreateFragment;
};
-
-function Message({ op }: Props) {
+function Message({ bug, op }: Props) {
const classes = useStyles();
- return (
- <article className={classes.container}>
- <Avatar author={op.author} className={classes.avatar} />
+ const [editMode, switchToEditMode] = useState(false);
+ const [comment, setComment] = useState(op);
+
+ const editComment = (id: String) => {
+ switchToEditMode(true);
+ };
+
+ function readMessageView() {
+ return (
<Paper elevation={1} className={classes.bubble}>
<header className={classes.header}>
<div className={classes.title}>
- <Author className={classes.author} author={op.author} />
+ <Author className={classes.author} author={comment.author} />
<span> commented </span>
- <Date date={op.createdAt} />
+ <Date date={comment.createdAt} />
</div>
- {op.edited && <div className={classes.tag}>Edited</div>}
+ {comment.edited && (
+ <HistoryMenuToggleButton bugId={bug.id} commentId={comment.id} />
+ )}
+ <IfLoggedIn>
+ {() => (
+ <Tooltip title="Edit Message" placement="top" arrow={true}>
+ <IconButton
+ disableRipple
+ className={classes.headerActions}
+ aria-label="edit message"
+ onClick={() => editComment(comment.id)}
+ >
+ <EditIcon />
+ </IconButton>
+ </Tooltip>
+ )}
+ </IfLoggedIn>
</header>
<section className={classes.body}>
- <Content markdown={op.message} />
+ <Content markdown={comment.message} />
</section>
</Paper>
+ );
+ }
+
+ function editMessageView() {
+ const cancelEdition = () => {
+ switchToEditMode(false);
+ };
+
+ const onPostSubmit = (comment: AddCommentFragment | CreateFragment) => {
+ setComment(comment);
+ switchToEditMode(false);
+ };
+
+ return (
+ <div className={classes.bubble}>
+ <EditCommentForm
+ bug={bug}
+ onCancel={cancelEdition}
+ onPostSubmit={onPostSubmit}
+ comment={comment}
+ />
+ </div>
+ );
+ }
+
+ return (
+ <article className={classes.container}>
+ <Avatar author={comment.author} className={classes.avatar} />
+ {editMode ? editMessageView() : readMessageView()}
</article>
);
}
@@ -1,8 +1,13 @@
#import "../../components/fragments.graphql"
fragment AddComment on AddCommentTimelineItem {
+ id
createdAt
...authored
edited
message
+ history {
+ message
+ date
+ }
}
@@ -1,8 +1,13 @@
#import "../../components/fragments.graphql"
fragment Create on CreateTimelineItem {
+ id
createdAt
...authored
edited
message
+ history {
+ message
+ date
+ }
}
@@ -0,0 +1,15 @@
+#import "./MessageCommentFragment.graphql"
+#import "./MessageCreateFragment.graphql"
+
+query MessageHistory($bugIdPrefix: String!) {
+ repository {
+ bug(prefix: $bugIdPrefix) {
+ timeline {
+ comments: nodes {
+ ...Create
+ ...AddComment
+ }
+ }
+ }
+ }
+}
@@ -0,0 +1,235 @@
+import moment from 'moment';
+import React from 'react';
+import Moment from 'react-moment';
+
+import MuiAccordion from '@material-ui/core/Accordion';
+import MuiAccordionDetails from '@material-ui/core/AccordionDetails';
+import MuiAccordionSummary from '@material-ui/core/AccordionSummary';
+import CircularProgress from '@material-ui/core/CircularProgress';
+import Dialog from '@material-ui/core/Dialog';
+import MuiDialogContent from '@material-ui/core/DialogContent';
+import MuiDialogTitle from '@material-ui/core/DialogTitle';
+import Grid from '@material-ui/core/Grid';
+import IconButton from '@material-ui/core/IconButton';
+import Tooltip from '@material-ui/core/Tooltip/Tooltip';
+import Typography from '@material-ui/core/Typography';
+import {
+ createStyles,
+ Theme,
+ withStyles,
+ WithStyles,
+} from '@material-ui/core/styles';
+import CloseIcon from '@material-ui/icons/Close';
+import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
+
+import { AddCommentFragment } from './MessageCommentFragment.generated';
+import { CreateFragment } from './MessageCreateFragment.generated';
+import { useMessageHistoryQuery } from './MessageHistory.generated';
+
+const styles = (theme: Theme) =>
+ createStyles({
+ root: {
+ margin: 0,
+ padding: theme.spacing(2),
+ },
+ closeButton: {
+ position: 'absolute',
+ right: theme.spacing(1),
+ top: theme.spacing(1),
+ },
+ });
+
+export interface DialogTitleProps extends WithStyles<typeof styles> {
+ id: string;
+ children: React.ReactNode;
+ onClose: () => void;
+}
+
+const DialogTitle = withStyles(styles)((props: DialogTitleProps) => {
+ const { children, classes, onClose, ...other } = props;
+ return (
+ <MuiDialogTitle disableTypography className={classes.root} {...other}>
+ <Typography variant="h6">{children}</Typography>
+ {onClose ? (
+ <IconButton
+ aria-label="close"
+ className={classes.closeButton}
+ onClick={onClose}
+ >
+ <CloseIcon />
+ </IconButton>
+ ) : null}
+ </MuiDialogTitle>
+ );
+});
+
+const DialogContent = withStyles((theme: Theme) => ({
+ root: {
+ padding: theme.spacing(2),
+ },
+}))(MuiDialogContent);
+
+const Accordion = withStyles({
+ root: {
+ border: '1px solid rgba(0, 0, 0, .125)',
+ boxShadow: 'none',
+ '&:not(:last-child)': {
+ borderBottom: 0,
+ },
+ '&:before': {
+ display: 'none',
+ },
+ '&$expanded': {
+ margin: 'auto',
+ },
+ },
+ expanded: {},
+})(MuiAccordion);
+
+const AccordionSummary = withStyles((theme) => ({
+ root: {
+ backgroundColor: theme.palette.primary.light,
+ borderBottomWidth: '1px',
+ borderBottomStyle: 'solid',
+ borderBottomColor: theme.palette.divider,
+ marginBottom: -1,
+ minHeight: 56,
+ '&$expanded': {
+ minHeight: 56,
+ },
+ },
+ content: {
+ '&$expanded': {
+ margin: '12px 0',
+ },
+ },
+ expanded: {},
+}))(MuiAccordionSummary);
+
+const AccordionDetails = withStyles((theme) => ({
+ root: {
+ padding: theme.spacing(2),
+ },
+}))(MuiAccordionDetails);
+
+type Props = {
+ bugId: string;
+ commentId: string;
+ open: boolean;
+ onClose: () => void;
+};
+function MessageHistoryDialog({ bugId, commentId, open, onClose }: Props) {
+ const [expanded, setExpanded] = React.useState<string | false>('panel0');
+
+ const { loading, error, data } = useMessageHistoryQuery({
+ variables: { bugIdPrefix: bugId },
+ });
+ if (loading) {
+ return (
+ <Dialog
+ onClose={onClose}
+ aria-labelledby="customized-dialog-title"
+ open={open}
+ fullWidth
+ maxWidth="sm"
+ >
+ <DialogTitle id="customized-dialog-title" onClose={onClose}>
+ Loading...
+ </DialogTitle>
+ <DialogContent dividers>
+ <Grid container justify="center">
+ <CircularProgress />
+ </Grid>
+ </DialogContent>
+ </Dialog>
+ );
+ }
+ if (error) {
+ return (
+ <Dialog
+ onClose={onClose}
+ aria-labelledby="customized-dialog-title"
+ open={open}
+ fullWidth
+ maxWidth="sm"
+ >
+ <DialogTitle id="customized-dialog-title" onClose={onClose}>
+ Something went wrong...
+ </DialogTitle>
+ <DialogContent dividers>
+ <p>Error: {error}</p>
+ </DialogContent>
+ </Dialog>
+ );
+ }
+
+ const comments = data?.repository?.bug?.timeline.comments as (
+ | AddCommentFragment
+ | CreateFragment
+ )[];
+ // NOTE Searching for the changed comment could be dropped if GraphQL get
+ // filter by id argument for timelineitems
+ const comment = comments.find((elem) => elem.id === commentId);
+ // Sort by most recent edit. Must create a copy of constant history as
+ // reverse() modifies inplace.
+ const history = comment?.history.slice().reverse();
+ const editCount = history?.length === undefined ? 0 : history?.length - 1;
+
+ const handleChange = (panel: string) => (
+ event: React.ChangeEvent<{}>,
+ newExpanded: boolean
+ ) => {
+ setExpanded(newExpanded ? panel : false);
+ };
+
+ const getSummary = (index: number, date: Date) => {
+ const desc =
+ index === editCount ? 'Created ' : `#${editCount - index} • Edited `;
+ const mostRecent = index === 0 ? ' (most recent)' : '';
+ return (
+ <>
+ <Tooltip title={moment(date).format('LLLL')}>
+ <span>
+ {desc}
+ <Moment date={date} format="on ll" />
+ {mostRecent}
+ </span>
+ </Tooltip>
+ </>
+ );
+ };
+
+ return (
+ <Dialog
+ onClose={onClose}
+ aria-labelledby="customized-dialog-title"
+ open={open}
+ fullWidth
+ maxWidth="md"
+ >
+ <DialogTitle id="customized-dialog-title" onClose={onClose}>
+ {`Edited ${editCount} ${editCount > 1 ? 'times' : 'time'}.`}
+ </DialogTitle>
+ <DialogContent dividers>
+ {history?.map((edit, index) => (
+ <Accordion
+ square
+ expanded={expanded === 'panel' + index}
+ onChange={handleChange('panel' + index)}
+ >
+ <AccordionSummary
+ expandIcon={<ExpandMoreIcon />}
+ aria-controls="panel1d-content"
+ id="panel1d-header"
+ >
+ <Typography>{getSummary(index, edit.date)}</Typography>
+ </AccordionSummary>
+ <AccordionDetails>{edit.message}</AccordionDetails>
+ </Accordion>
+ ))}
+ </DialogContent>
+ </Dialog>
+ );
+}
+
+export default MessageHistoryDialog;
@@ -2,6 +2,7 @@ import React from 'react';
import { makeStyles } from '@material-ui/core/styles';
+import { BugFragment } from './Bug.generated';
import LabelChange from './LabelChange';
import Message from './Message';
import SetStatus from './SetStatus';
@@ -18,9 +19,10 @@ const useStyles = makeStyles((theme) => ({
type Props = {
ops: Array<TimelineItemFragment>;
+ bug: BugFragment;
};
-function Timeline({ ops }: Props) {
+function Timeline({ bug, ops }: Props) {
const classes = useStyles();
return (
@@ -28,9 +30,9 @@ function Timeline({ ops }: Props) {
{ops.map((op, index) => {
switch (op.__typename) {
case 'CreateTimelineItem':
- return <Message key={index} op={op} />;
+ return <Message key={index} op={op} bug={bug} />;
case 'AddCommentTimelineItem':
- return <Message key={index} op={op} />;
+ return <Message key={index} op={op} bug={bug} />;
case 'LabelChangeTimelineItem':
return <LabelChange key={index} op={op} />;
case 'SetTitleTimelineItem':
@@ -2,17 +2,18 @@ import React from 'react';
import CircularProgress from '@material-ui/core/CircularProgress';
+import { BugFragment } from './Bug.generated';
import Timeline from './Timeline';
import { useTimelineQuery } from './TimelineQuery.generated';
type Props = {
- id: string;
+ bug: BugFragment;
};
-const TimelineQuery = ({ id }: Props) => {
+const TimelineQuery = ({ bug }: Props) => {
const { loading, error, data } = useTimelineQuery({
variables: {
- id,
+ id: bug.id,
first: 100,
},
});
@@ -25,7 +26,7 @@ const TimelineQuery = ({ id }: Props) => {
return null;
}
- return <Timeline ops={nodes} />;
+ return <Timeline ops={nodes} bug={bug} />;
};
export default TimelineQuery;
@@ -9,5 +9,8 @@ fragment BugRow on Bug {
labels {
...Label
}
+ comments {
+ totalCount
+ }
...authored
}
@@ -6,6 +6,7 @@ import TableRow from '@material-ui/core/TableRow/TableRow';
import Tooltip from '@material-ui/core/Tooltip/Tooltip';
import { makeStyles } from '@material-ui/core/styles';
import CheckCircleOutline from '@material-ui/icons/CheckCircleOutline';
+import CommentOutlinedIcon from '@material-ui/icons/CommentOutlined';
import ErrorOutline from '@material-ui/icons/ErrorOutline';
import Date from 'src/components/Date';
@@ -74,6 +75,13 @@ const useStyles = makeStyles((theme) => ({
display: 'inline-block',
},
},
+ commentCount: {
+ fontSize: '1rem',
+ marginLeft: theme.spacing(0.5),
+ },
+ commentCountCell: {
+ display: 'inline-flex',
+ },
}));
type Props = {
@@ -82,6 +90,8 @@ type Props = {
function BugRow({ bug }: Props) {
const classes = useStyles();
+ // Subtract 1 from totalCount as 1 comment is the bug description
+ const commentCount = bug.comments.totalCount - 1;
return (
<TableRow hover>
<TableCell className={classes.cell}>
@@ -105,6 +115,12 @@ function BugRow({ bug }: Props) {
by {bug.author.displayName}
</div>
</div>
+ {commentCount > 0 && (
+ <span className={classes.commentCountCell}>
+ <CommentOutlinedIcon aria-label="Comment count" />
+ <span className={classes.commentCount}>{commentCount}</span>
+ </span>
+ )}
</TableCell>
</TableRow>
);
@@ -65,7 +65,7 @@ function stringify(params: Query): string {
const useStyles = makeStyles((theme) => ({
element: {
...theme.typography.body2,
- color: '#444',
+ color: theme.palette.text.secondary,
padding: theme.spacing(0, 1),
fontWeight: 400,
textDecoration: 'none',
@@ -75,7 +75,7 @@ const useStyles = makeStyles((theme) => ({
},
itemActive: {
fontWeight: 600,
- color: '#333',
+ color: theme.palette.text.primary,
},
icon: {
paddingRight: theme.spacing(0.5),
@@ -19,8 +19,8 @@ import { useBugCountQuery } from './FilterToolbar.generated';
const useStyles = makeStyles((theme) => ({
toolbar: {
- backgroundColor: theme.palette.grey['100'],
- borderColor: theme.palette.grey['300'],
+ backgroundColor: theme.palette.primary.light,
+ borderColor: theme.palette.divider,
borderWidth: '1px 0',
borderStyle: 'solid',
margin: theme.spacing(0, -1),
@@ -40,7 +40,7 @@ function CountingFilter({ query, children, ...props }: CountingFilterProps) {
variables: { query },
});
- var prefix;
+ let prefix;
if (loading) prefix = '...';
else if (error || !data?.repository) prefix = '???';
// TODO: better prefixes & error handling
@@ -6,7 +6,7 @@ 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';
-import { fade, makeStyles, Theme } from '@material-ui/core/styles';
+import { makeStyles, Theme } from '@material-ui/core/styles';
import ErrorOutline from '@material-ui/icons/ErrorOutline';
import KeyboardArrowLeft from '@material-ui/icons/KeyboardArrowLeft';
import KeyboardArrowRight from '@material-ui/icons/KeyboardArrowRight';
@@ -56,10 +56,11 @@ const useStyles = makeStyles<Theme, StylesProps>((theme) => ({
},
search: {
borderRadius: theme.shape.borderRadius,
- borderColor: fade(theme.palette.primary.main, 0.2),
+ color: theme.palette.text.secondary,
+ borderColor: theme.palette.divider,
borderStyle: 'solid',
borderWidth: '1px',
- backgroundColor: fade(theme.palette.primary.main, 0.05),
+ backgroundColor: theme.palette.primary.light,
padding: theme.spacing(0, 1),
width: ({ searching }) => (searching ? '20rem' : '15rem'),
transition: theme.transitions.create([
@@ -69,13 +70,11 @@ const useStyles = makeStyles<Theme, StylesProps>((theme) => ({
]),
},
searchFocused: {
- borderColor: fade(theme.palette.primary.main, 0.4),
backgroundColor: theme.palette.background.paper,
- width: '20rem!important',
},
placeholderRow: {
padding: theme.spacing(1),
- borderBottomColor: theme.palette.grey['300'],
+ borderBottomColor: theme.palette.divider,
borderBottomWidth: '1px',
borderBottomStyle: 'solid',
display: 'flex',
@@ -91,7 +90,8 @@ const useStyles = makeStyles<Theme, StylesProps>((theme) => ({
...theme.typography.h5,
padding: theme.spacing(8),
textAlign: 'center',
- borderBottomColor: theme.palette.grey['300'],
+ color: theme.palette.text.hint,
+ borderBottomColor: theme.palette.divider,
borderBottomWidth: '1px',
borderBottomStyle: 'solid',
'& > p': {
@@ -99,21 +99,25 @@ const useStyles = makeStyles<Theme, StylesProps>((theme) => ({
},
},
errorBox: {
- color: theme.palette.error.main,
+ color: theme.palette.error.dark,
'& > pre': {
fontSize: '1rem',
textAlign: 'left',
- backgroundColor: theme.palette.grey['900'],
- color: theme.palette.common.white,
+ borderColor: theme.palette.divider,
+ borderWidth: '1px',
+ borderRadius: theme.shape.borderRadius,
+ borderStyle: 'solid',
+ color: theme.palette.text.primary,
marginTop: theme.spacing(4),
padding: theme.spacing(2, 3),
},
},
greenButton: {
- backgroundColor: '#2ea44fd9',
- color: '#fff',
+ backgroundColor: theme.palette.success.main,
+ color: theme.palette.success.contrastText,
'&:hover': {
- backgroundColor: '#2ea44f',
+ backgroundColor: theme.palette.success.dark,
+ color: theme.palette.primary.contrastText,
},
},
}));
@@ -319,7 +323,7 @@ function ListQuery() {
variant="contained"
href="/new"
>
- New issue
+ New bug
</Button>
)}
</IfLoggedIn>
@@ -1,10 +1,10 @@
import React, { FormEvent, useState } from 'react';
+import { useHistory } from 'react-router-dom';
-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 { Button, Paper } from '@material-ui/core';
+import { makeStyles, Theme } from '@material-ui/core/styles';
+import BugTitleInput from '../../components/BugTitleForm/BugTitleInput';
import CommentInput from '../../components/CommentInput/CommentInput';
import { useNewBugMutation } from './NewBug.generated';
@@ -21,19 +21,6 @@ const useStyles = makeStyles((theme: Theme) => ({
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',
@@ -43,10 +30,11 @@ const useStyles = makeStyles((theme: Theme) => ({
justifyContent: 'flex-end',
},
greenButton: {
- backgroundColor: '#2ea44fd9',
- color: '#fff',
+ backgroundColor: theme.palette.success.main,
+ color: theme.palette.success.contrastText,
'&:hover': {
- backgroundColor: '#2ea44f',
+ backgroundColor: theme.palette.success.dark,
+ color: theme.palette.primary.contrastText,
},
},
}));
@@ -59,7 +47,9 @@ function NewBugPage() {
const [issueTitle, setIssueTitle] = useState('');
const [issueComment, setIssueComment] = useState('');
const classes = useStyles();
+
let issueTitleInput: any;
+ let history = useHistory();
function submitNewIssue(e: FormEvent) {
e.preventDefault();
@@ -71,12 +61,15 @@ function NewBugPage() {
message: issueComment,
},
},
+ }).then(function (data) {
+ const id = data.data?.newBug.bug.humanId;
+ history.push('/bug/' + id);
});
issueTitleInput.value = '';
}
function isFormValid() {
- return issueTitle.length > 0 && issueComment.length > 0 ? true : false;
+ return issueTitle.length > 0;
}
if (loading) return <div>Loading...</div>;
@@ -85,12 +78,11 @@ function NewBugPage() {
return (
<Paper className={classes.main}>
<form className={classes.form} onSubmit={submitNewIssue}>
- <TextField
+ <BugTitleInput
inputRef={(node) => {
issueTitleInput = node;
}}
label="Title"
- className={classes.titleInput}
variant="outlined"
fullWidth
margin="dense"
@@ -107,7 +99,7 @@ function NewBugPage() {
type="submit"
disabled={isFormValid() ? false : true}
>
- Submit new issue
+ Submit new bug
</Button>
</div>
</form>
@@ -0,0 +1,52 @@
+import React from 'react';
+
+import { makeStyles } from '@material-ui/core/styles';
+
+import BackToListButton from '../../components/BackToListButton';
+
+const useStyles = makeStyles((theme) => ({
+ main: {
+ maxWidth: 1000,
+ margin: 'auto',
+ marginTop: theme.spacing(10),
+ },
+ logo: {
+ height: '350px',
+ display: 'block',
+ marginLeft: 'auto',
+ marginRight: 'auto',
+ },
+ icon: {
+ display: 'block',
+ marginLeft: 'auto',
+ marginRight: 'auto',
+ fontSize: '80px',
+ },
+ backLink: {
+ marginTop: theme.spacing(1),
+ textAlign: 'center',
+ },
+ header: {
+ fontSize: '30px',
+ textAlign: 'center',
+ },
+}));
+
+function NotFoundPage() {
+ const classes = useStyles();
+ return (
+ <main className={classes.main}>
+ <h1 className={classes.header}>404 – Page not found</h1>
+ <img
+ src="/logo-alpha-flat-outline.svg"
+ className={classes.logo}
+ alt="git-bug Logo"
+ />
+ <div className={classes.backLink}>
+ <BackToListButton />
+ </div>
+ </main>
+ );
+}
+
+export default NotFoundPage;
@@ -1,11 +0,0 @@
-import { createMuiTheme } from '@material-ui/core/styles';
-
-const theme = createMuiTheme({
- palette: {
- primary: {
- main: '#263238',
- },
- },
-});
-
-export default theme;
@@ -0,0 +1,26 @@
+import { createMuiTheme } from '@material-ui/core/styles';
+
+const defaultDarkTheme = createMuiTheme({
+ palette: {
+ type: 'dark',
+ primary: {
+ dark: '#263238',
+ main: '#2a393e',
+ light: '#525252',
+ },
+ error: {
+ main: '#f44336',
+ dark: '#ff4949',
+ },
+ info: {
+ main: '#2a393e',
+ contrastText: '#ffffffb3',
+ },
+ success: {
+ main: '#2ea44fd9',
+ contrastText: '#fff',
+ },
+ },
+});
+
+export default defaultDarkTheme;
@@ -0,0 +1,26 @@
+import { createMuiTheme } from '@material-ui/core/styles';
+
+const defaultLightTheme = createMuiTheme({
+ palette: {
+ type: 'light',
+ primary: {
+ dark: '#263238',
+ main: '#5a6b73',
+ light: '#f5f5f5',
+ contrastText: '#fff',
+ },
+ info: {
+ main: '#e2f1ff',
+ contrastText: '#555',
+ },
+ success: {
+ main: '#2ea44fd9',
+ contrastText: '#fff',
+ },
+ text: {
+ secondary: '#555',
+ },
+ },
+});
+
+export default defaultLightTheme;
@@ -0,0 +1,4 @@
+import defaultDarkTheme from './DefaultDark';
+import defaultLightTheme from './DefaultLight';
+
+export { defaultLightTheme, defaultDarkTheme };