Merge remote-tracking branch 'origin/master' into cheshirekow-jira

This commit is contained in:
Michael Muré 2020-02-14 22:56:59 +01:00
commit e9aff2a2a1
No known key found for this signature in database
GPG Key ID: A4457C029293126F
87 changed files with 6802 additions and 4086 deletions

View File

@ -7,32 +7,12 @@ matrix:
- language: go
go: 1.13.x
- language: node_js
node_js: 8
node_js: node
before_install:
- cd webui
after_success: []
- language: node_js
node_js: 9
before_install:
- cd webui
after_success: []
- language: node_js
node_js: 10
before_install:
- cd webui
after_success: []
- language: node_js
node_js: 11
before_install:
- cd webui
after_success: []
- language: node_js
node_js: 12
before_install:
- cd webui
after_success: []
- language: node_js
node_js: 13
node_js: lts/*
before_install:
- cd webui
after_success: []

View File

@ -44,13 +44,13 @@ That's all !
</details>
<details><summary>go get (unstable)</summary>
<details><summary>go install (unstable)</summary>
```shell
go get -u github.com/MichaelMure/git-bug
go install github.com/MichaelMure/git-bug
```
If it's not done already, add golang binary directory in your PATH:
If it's not done already, add the golang binary directory in your PATH:
```bash
export PATH=$PATH:$(go env GOROOT)/bin:$(go env GOPATH)/bin

View File

@ -1,6 +1,8 @@
package auth
import (
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"regexp"
@ -16,15 +18,18 @@ const (
configKeyKind = "kind"
configKeyTarget = "target"
configKeyCreateTime = "createtime"
configKeySalt = "salt"
configKeyPrefixMeta = "meta."
MetaKeyLogin = "login"
MetaKeyLogin = "login"
MetaKeyBaseURL = "base-url"
)
type CredentialKind string
const (
KindToken CredentialKind = "token"
KindLogin CredentialKind = "login"
KindLoginPassword CredentialKind = "login-password"
)
@ -36,9 +41,10 @@ func NewErrMultipleMatchCredential(matching []entity.Id) *entity.ErrMultipleMatc
type Credential interface {
ID() entity.Id
Target() string
Kind() CredentialKind
Target() string
CreateTime() time.Time
Salt() []byte
Validate() error
Metadata() map[string]string
@ -46,7 +52,7 @@ type Credential interface {
SetMetadata(key string, value string)
// Return all the specific properties of the credential that need to be saved into the configuration.
// This does not include Target, Kind, CreateTime and Metadata.
// This does not include Target, Kind, CreateTime, Metadata or Salt.
toConfig() map[string]string
}
@ -107,15 +113,23 @@ func loadFromConfig(rawConfigs map[string]string, id entity.Id) (Credential, err
}
var cred Credential
var err error
switch CredentialKind(configs[configKeyKind]) {
case KindToken:
cred = NewTokenFromConfig(configs)
cred, err = NewTokenFromConfig(configs)
case KindLogin:
cred, err = NewLoginFromConfig(configs)
case KindLoginPassword:
cred, err = NewLoginPasswordFromConfig(configs)
default:
return nil, fmt.Errorf("unknown credential type %s", configs[configKeyKind])
}
if err != nil {
return nil, fmt.Errorf("loading credential: %v", err)
}
return cred, nil
}
@ -133,6 +147,23 @@ func metaFromConfig(configs map[string]string) map[string]string {
return result
}
func makeSalt() []byte {
result := make([]byte, 16)
_, err := rand.Read(result)
if err != nil {
panic(err)
}
return result
}
func saltFromConfig(configs map[string]string) ([]byte, error) {
val, ok := configs[configKeySalt]
if !ok {
return nil, fmt.Errorf("no credential salt found")
}
return base64.StdEncoding.DecodeString(val)
}
// List load all existing credentials
func List(repo repository.RepoConfig, opts ...Option) ([]Credential, error) {
rawConfigs, err := repo.GlobalConfig().ReadAll(configKeyPrefix + ".")
@ -210,6 +241,16 @@ func Store(repo repository.RepoConfig, cred Credential) error {
return err
}
// Salt
if len(cred.Salt()) != 16 {
panic("credentials need to be salted")
}
encoded := base64.StdEncoding.EncodeToString(cred.Salt())
err = repo.GlobalConfig().StoreString(prefix+configKeySalt, encoded)
if err != nil {
return err
}
// Metadata
for key, val := range cred.Metadata() {
err := repo.GlobalConfig().StoreString(prefix+configKeyPrefixMeta+key, val)

View File

@ -0,0 +1,90 @@
package auth
import (
"fmt"
"time"
"github.com/MichaelMure/git-bug/bridge/core"
"github.com/MichaelMure/git-bug/repository"
)
type credentialBase struct {
target string
createTime time.Time
salt []byte
meta map[string]string
}
func newCredentialBase(target string) *credentialBase {
return &credentialBase{
target: target,
createTime: time.Now(),
salt: makeSalt(),
}
}
func newCredentialBaseFromConfig(conf map[string]string) (*credentialBase, error) {
base := &credentialBase{
target: conf[configKeyTarget],
meta: metaFromConfig(conf),
}
if createTime, ok := conf[configKeyCreateTime]; ok {
t, err := repository.ParseTimestamp(createTime)
if err != nil {
return nil, err
}
base.createTime = t
} else {
return nil, fmt.Errorf("missing create time")
}
salt, err := saltFromConfig(conf)
if err != nil {
return nil, err
}
base.salt = salt
return base, nil
}
func (cb *credentialBase) Target() string {
return cb.target
}
func (cb *credentialBase) CreateTime() time.Time {
return cb.createTime
}
func (cb *credentialBase) Salt() []byte {
return cb.salt
}
func (cb *credentialBase) validate() error {
if cb.target == "" {
return fmt.Errorf("missing target")
}
if cb.createTime.IsZero() || cb.createTime.Equal(time.Time{}) {
return fmt.Errorf("missing creation time")
}
if !core.TargetExist(cb.target) {
return fmt.Errorf("unknown target")
}
return nil
}
func (cb *credentialBase) Metadata() map[string]string {
return cb.meta
}
func (cb *credentialBase) GetMetadata(key string) (string, bool) {
val, ok := cb.meta[key]
return val, ok
}
func (cb *credentialBase) SetMetadata(key string, value string) {
if cb.meta == nil {
cb.meta = make(map[string]string)
}
cb.meta[key] = value
}

View File

@ -14,7 +14,7 @@ func TestCredential(t *testing.T) {
repo := repository.NewMockRepoForTest()
storeToken := func(val string, target string) *Token {
token := NewToken(val, target)
token := NewToken(target, val)
err := Store(repo, token)
require.NoError(t, err)
return token
@ -100,3 +100,25 @@ func sameIds(t *testing.T, a []Credential, b []Credential) {
assert.ElementsMatch(t, ids(a), ids(b))
}
func testCredentialSerial(t *testing.T, original Credential) Credential {
repo := repository.NewMockRepoForTest()
original.SetMetadata("test", "value")
assert.NotEmpty(t, original.ID().String())
assert.NotEmpty(t, original.Salt())
assert.NoError(t, Store(repo, original))
loaded, err := LoadWithId(repo, original.ID())
assert.NoError(t, err)
assert.Equal(t, original.ID(), loaded.ID())
assert.Equal(t, original.Kind(), loaded.Kind())
assert.Equal(t, original.Target(), loaded.Target())
assert.Equal(t, original.CreateTime().Unix(), loaded.CreateTime().Unix())
assert.Equal(t, original.Salt(), loaded.Salt())
assert.Equal(t, original.Metadata(), loaded.Metadata())
return loaded
}

67
bridge/core/auth/login.go Normal file
View File

@ -0,0 +1,67 @@
package auth
import (
"crypto/sha256"
"fmt"
"github.com/MichaelMure/git-bug/entity"
)
const (
configKeyLoginLogin = "login"
)
var _ Credential = &Login{}
type Login struct {
*credentialBase
Login string
}
func NewLogin(target, login string) *Login {
return &Login{
credentialBase: newCredentialBase(target),
Login: login,
}
}
func NewLoginFromConfig(conf map[string]string) (*Login, error) {
base, err := newCredentialBaseFromConfig(conf)
if err != nil {
return nil, err
}
return &Login{
credentialBase: base,
Login: conf[configKeyLoginLogin],
}, nil
}
func (lp *Login) ID() entity.Id {
h := sha256.New()
_, _ = h.Write(lp.salt)
_, _ = h.Write([]byte(lp.target))
_, _ = h.Write([]byte(lp.Login))
return entity.Id(fmt.Sprintf("%x", h.Sum(nil)))
}
func (lp *Login) Kind() CredentialKind {
return KindLogin
}
func (lp *Login) Validate() error {
err := lp.credentialBase.validate()
if err != nil {
return err
}
if lp.Login == "" {
return fmt.Errorf("missing login")
}
return nil
}
func (lp *Login) toConfig() map[string]string {
return map[string]string{
configKeyLoginLogin: lp.Login,
}
}

View File

@ -0,0 +1,76 @@
package auth
import (
"crypto/sha256"
"fmt"
"github.com/MichaelMure/git-bug/entity"
)
const (
configKeyLoginPasswordLogin = "login"
configKeyLoginPasswordPassword = "password"
)
var _ Credential = &LoginPassword{}
type LoginPassword struct {
*credentialBase
Login string
Password string
}
func NewLoginPassword(target, login, password string) *LoginPassword {
return &LoginPassword{
credentialBase: newCredentialBase(target),
Login: login,
Password: password,
}
}
func NewLoginPasswordFromConfig(conf map[string]string) (*LoginPassword, error) {
base, err := newCredentialBaseFromConfig(conf)
if err != nil {
return nil, err
}
return &LoginPassword{
credentialBase: base,
Login: conf[configKeyLoginPasswordLogin],
Password: conf[configKeyLoginPasswordPassword],
}, nil
}
func (lp *LoginPassword) ID() entity.Id {
h := sha256.New()
_, _ = h.Write(lp.salt)
_, _ = h.Write([]byte(lp.target))
_, _ = h.Write([]byte(lp.Login))
_, _ = h.Write([]byte(lp.Password))
return entity.Id(fmt.Sprintf("%x", h.Sum(nil)))
}
func (lp *LoginPassword) Kind() CredentialKind {
return KindLoginPassword
}
func (lp *LoginPassword) Validate() error {
err := lp.credentialBase.validate()
if err != nil {
return err
}
if lp.Login == "" {
return fmt.Errorf("missing login")
}
if lp.Password == "" {
return fmt.Errorf("missing password")
}
return nil
}
func (lp *LoginPassword) toConfig() map[string]string {
return map[string]string{
configKeyLoginPasswordLogin: lp.Login,
configKeyLoginPasswordPassword: lp.Password,
}
}

View File

@ -0,0 +1,14 @@
package auth
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestLoginPasswordSerial(t *testing.T) {
original := NewLoginPassword("github", "jean", "jacques")
loaded := testCredentialSerial(t, original)
assert.Equal(t, original.Login, loaded.(*LoginPassword).Login)
assert.Equal(t, original.Password, loaded.(*LoginPassword).Password)
}

View File

@ -0,0 +1,13 @@
package auth
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestLoginSerial(t *testing.T) {
original := NewLogin("github", "jean")
loaded := testCredentialSerial(t, original)
assert.Equal(t, original.Login, loaded.(*Login).Login)
}

View File

@ -3,104 +3,68 @@ package auth
import (
"crypto/sha256"
"fmt"
"time"
"github.com/MichaelMure/git-bug/bridge/core"
"github.com/MichaelMure/git-bug/entity"
"github.com/MichaelMure/git-bug/repository"
)
const (
tokenValueKey = "value"
configKeyTokenValue = "value"
)
var _ Credential = &Token{}
// Token holds an API access token data
type Token struct {
target string
createTime time.Time
Value string
meta map[string]string
*credentialBase
Value string
}
// NewToken instantiate a new token
func NewToken(value, target string) *Token {
func NewToken(target, value string) *Token {
return &Token{
target: target,
createTime: time.Now(),
Value: value,
credentialBase: newCredentialBase(target),
Value: value,
}
}
func NewTokenFromConfig(conf map[string]string) *Token {
token := &Token{}
token.target = conf[configKeyTarget]
if createTime, ok := conf[configKeyCreateTime]; ok {
if t, err := repository.ParseTimestamp(createTime); err == nil {
token.createTime = t
}
func NewTokenFromConfig(conf map[string]string) (*Token, error) {
base, err := newCredentialBaseFromConfig(conf)
if err != nil {
return nil, err
}
token.Value = conf[tokenValueKey]
token.meta = metaFromConfig(conf)
return token
return &Token{
credentialBase: base,
Value: conf[configKeyTokenValue],
}, nil
}
func (t *Token) ID() entity.Id {
sum := sha256.Sum256([]byte(t.target + t.Value))
return entity.Id(fmt.Sprintf("%x", sum))
}
func (t *Token) Target() string {
return t.target
h := sha256.New()
_, _ = h.Write(t.salt)
_, _ = h.Write([]byte(t.target))
_, _ = h.Write([]byte(t.Value))
return entity.Id(fmt.Sprintf("%x", h.Sum(nil)))
}
func (t *Token) Kind() CredentialKind {
return KindToken
}
func (t *Token) CreateTime() time.Time {
return t.createTime
}
// Validate ensure token important fields are valid
func (t *Token) Validate() error {
err := t.credentialBase.validate()
if err != nil {
return err
}
if t.Value == "" {
return fmt.Errorf("missing value")
}
if t.target == "" {
return fmt.Errorf("missing target")
}
if t.createTime.IsZero() || t.createTime.Equal(time.Time{}) {
return fmt.Errorf("missing creation time")
}
if !core.TargetExist(t.target) {
return fmt.Errorf("unknown target")
}
return nil
}
func (t *Token) Metadata() map[string]string {
return t.meta
}
func (t *Token) GetMetadata(key string) (string, bool) {
val, ok := t.meta[key]
return val, ok
}
func (t *Token) SetMetadata(key string, value string) {
if t.meta == nil {
t.meta = make(map[string]string)
}
t.meta[key] = value
}
func (t *Token) toConfig() map[string]string {
return map[string]string{
tokenValueKey: t.Value,
configKeyTokenValue: t.Value,
}
}

View File

@ -0,0 +1,13 @@
package auth
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestTokenSerial(t *testing.T) {
original := NewToken("github", "value")
loaded := testCredentialSerial(t, original)
assert.Equal(t, original.Value, loaded.(*Token).Value)
}

View File

@ -3,6 +3,7 @@ package github
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
@ -70,25 +71,7 @@ func (g *Github) Configure(repo *cache.RepoCache, params core.BridgeParams) (cor
return nil, fmt.Errorf("invalid parameter owner: %v", owner)
}
login := params.Login
if login == "" {
validator := func(name string, value string) (string, error) {
ok, err := validateUsername(value)
if err != nil {
return "", err
}
if !ok {
return "invalid login", nil
}
return "", nil
}
login, err = input.Prompt("Github login", "login", input.Required, validator)
if err != nil {
return nil, err
}
}
var login string
var cred auth.Credential
switch {
@ -97,10 +80,27 @@ func (g *Github) Configure(repo *cache.RepoCache, params core.BridgeParams) (cor
if err != nil {
return nil, err
}
l, ok := cred.GetMetadata(auth.MetaKeyLogin)
if !ok {
return nil, fmt.Errorf("credential doesn't have a login")
}
login = l
case params.TokenRaw != "":
cred = auth.NewToken(params.TokenRaw, target)
cred.SetMetadata(auth.MetaKeyLogin, login)
token := auth.NewToken(target, params.TokenRaw)
login, err = getLoginFromToken(token)
if err != nil {
return nil, err
}
token.SetMetadata(auth.MetaKeyLogin, login)
cred = token
default:
login = params.Login
if login == "" {
login, err = input.Prompt("Github login", "login", input.Required, usernameValidator)
if err != nil {
return nil, err
}
}
cred, err = promptTokenOptions(repo, login, owner, project)
if err != nil {
return nil, err
@ -159,6 +159,17 @@ func (*Github) ValidateConfig(conf core.Configuration) error {
return nil
}
func usernameValidator(name string, value string) (string, error) {
ok, err := validateUsername(value)
if err != nil {
return "", err
}
if !ok {
return "invalid login", nil
}
return "", nil
}
func requestToken(note, login, password string, scope string) (*http.Response, error) {
return requestTokenWith2FA(note, login, password, "", scope)
}
@ -231,7 +242,11 @@ func randomFingerprint() string {
func promptTokenOptions(repo repository.RepoConfig, login, owner, project string) (auth.Credential, error) {
for {
creds, err := auth.List(repo, auth.WithTarget(target), auth.WithMeta(auth.MetaKeyLogin, login))
creds, err := auth.List(repo,
auth.WithTarget(target),
auth.WithKind(auth.KindToken),
auth.WithMeta(auth.MetaKeyLogin, login),
)
if err != nil {
return nil, err
}
@ -275,19 +290,13 @@ func promptTokenOptions(repo repository.RepoConfig, login, owner, project string
switch index {
case 1:
value, err := promptToken()
if err != nil {
return nil, err
}
token := auth.NewToken(value, target)
token.SetMetadata(auth.MetaKeyLogin, login)
return token, nil
return promptToken()
case 2:
value, err := loginAndRequestToken(login, owner, project)
if err != nil {
return nil, err
}
token := auth.NewToken(value, target)
token := auth.NewToken(target, value)
token.SetMetadata(auth.MetaKeyLogin, login)
return token, nil
default:
@ -296,7 +305,7 @@ func promptTokenOptions(repo repository.RepoConfig, login, owner, project string
}
}
func promptToken() (string, error) {
func promptToken() (*auth.Token, error) {
fmt.Println("You can generate a new token by visiting https://github.com/settings/tokens.")
fmt.Println("Choose 'Generate new token' and set the necessary access scope for your repository.")
fmt.Println()
@ -312,14 +321,28 @@ func promptToken() (string, error) {
panic("regexp compile:" + err.Error())
}
var login string
validator := func(name string, value string) (complaint string, err error) {
if re.MatchString(value) {
return "", nil
if !re.MatchString(value) {
return "token has incorrect format", nil
}
return "token has incorrect format", nil
login, err = getLoginFromToken(auth.NewToken(target, value))
if err != nil {
return fmt.Sprintf("token is invalid: %v", err), nil
}
return "", nil
}
return input.Prompt("Enter token", "token", input.Required, validator)
rawToken, err := input.Prompt("Enter token", "token", input.Required, validator)
if err != nil {
return nil, err
}
token := auth.NewToken(target, rawToken)
token.SetMetadata(auth.MetaKeyLogin, login)
return token, nil
}
func loginAndRequestToken(login, owner, project string) (string, error) {
@ -543,3 +566,22 @@ func validateProject(owner, project string, token *auth.Token) (bool, error) {
return resp.StatusCode == http.StatusOK, nil
}
func getLoginFromToken(token *auth.Token) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
defer cancel()
client := buildClient(token)
var q loginQuery
err := client.Query(ctx, &q, nil)
if err != nil {
return "", err
}
if q.Viewer.Login == "" {
return "", fmt.Errorf("github say username is empty")
}
return q.Viewer.Login, nil
}

View File

@ -154,8 +154,8 @@ func TestValidateProject(t *testing.T) {
t.Skip("Env var GITHUB_TOKEN_PUBLIC missing")
}
tokenPrivate := auth.NewToken(envPrivate, target)
tokenPublic := auth.NewToken(envPublic, target)
tokenPrivate := auth.NewToken(target, envPrivate)
tokenPublic := auth.NewToken(target, envPublic)
type args struct {
owner string

View File

@ -157,7 +157,7 @@ func TestPushPull(t *testing.T) {
defer backend.Close()
interrupt.RegisterCleaner(backend.Close)
token := auth.NewToken(envToken, target)
token := auth.NewToken(target, envToken)
token.SetMetadata(auth.MetaKeyLogin, login)
err = auth.Store(repo, token)
require.NoError(t, err)

View File

@ -168,14 +168,6 @@ type ghostQuery struct {
} `graphql:"user(login: $login)"`
}
type labelQuery struct {
Repository struct {
Label struct {
ID string `graphql:"id"`
} `graphql:"label(name: $label)"`
} `graphql:"repository(owner: $owner, name: $name)"`
}
type labelsQuery struct {
Repository struct {
Labels struct {
@ -189,3 +181,9 @@ type labelsQuery struct {
} `graphql:"labels(first: $first, after: $after)"`
} `graphql:"repository(owner: $owner, name: $name)"`
}
type loginQuery struct {
Viewer struct {
Login string `graphql:"login"`
} `graphql:"viewer"`
}

View File

@ -144,7 +144,7 @@ func Test_Importer(t *testing.T) {
login := "test-identity"
author.SetMetadata(metaKeyGithubLogin, login)
token := auth.NewToken(envToken, target)
token := auth.NewToken(target, envToken)
token.SetMetadata(auth.MetaKeyLogin, login)
err = auth.Store(repo, token)
require.NoError(t, err)

View File

@ -35,9 +35,6 @@ func (g *Gitlab) Configure(repo *cache.RepoCache, params core.BridgeParams) (cor
if params.Owner != "" {
fmt.Println("warning: --owner is ineffective for a gitlab bridge")
}
if params.Login != "" {
fmt.Println("warning: --login is ineffective for a gitlab bridge")
}
conf := make(core.Configuration)
var err error
@ -53,24 +50,25 @@ func (g *Gitlab) Configure(repo *cache.RepoCache, params core.BridgeParams) (cor
}
}
var url string
var projectURL string
// get project url
switch {
case params.URL != "":
url = params.URL
projectURL = params.URL
default:
// terminal prompt
url, err = promptURL(repo, baseUrl)
projectURL, err = promptProjectURL(repo, baseUrl)
if err != nil {
return nil, errors.Wrap(err, "url prompt")
}
}
if !strings.HasPrefix(url, params.BaseURL) {
return nil, fmt.Errorf("base URL (%s) doesn't match the project URL (%s)", params.BaseURL, url)
if !strings.HasPrefix(projectURL, params.BaseURL) {
return nil, fmt.Errorf("base URL (%s) doesn't match the project URL (%s)", params.BaseURL, projectURL)
}
var login string
var cred auth.Credential
switch {
@ -79,16 +77,30 @@ func (g *Gitlab) Configure(repo *cache.RepoCache, params core.BridgeParams) (cor
if err != nil {
return nil, err
}
l, ok := cred.GetMetadata(auth.MetaKeyLogin)
if !ok {
return nil, fmt.Errorf("credential doesn't have a login")
}
login = l
case params.TokenRaw != "":
token := auth.NewToken(params.TokenRaw, target)
login, err := getLoginFromToken(baseUrl, token)
token := auth.NewToken(target, params.TokenRaw)
login, err = getLoginFromToken(baseUrl, token)
if err != nil {
return nil, err
}
token.SetMetadata(auth.MetaKeyLogin, login)
token.SetMetadata(auth.MetaKeyBaseURL, baseUrl)
cred = token
default:
cred, err = promptTokenOptions(repo, baseUrl)
login := params.Login
if login == "" {
// TODO: validate username
login, err = input.Prompt("Gitlab login", "login", input.Required)
if err != nil {
return nil, err
}
}
cred, err = promptTokenOptions(repo, login, baseUrl)
if err != nil {
return nil, err
}
@ -100,7 +112,7 @@ func (g *Gitlab) Configure(repo *cache.RepoCache, params core.BridgeParams) (cor
}
// validate project url and get its ID
id, err := validateProjectURL(baseUrl, url, token)
id, err := validateProjectURL(baseUrl, projectURL, token)
if err != nil {
return nil, errors.Wrap(err, "project validation")
}
@ -122,7 +134,7 @@ func (g *Gitlab) Configure(repo *cache.RepoCache, params core.BridgeParams) (cor
}
}
return conf, nil
return conf, core.FinishConfig(repo, metaKeyGitlabLogin, login)
}
func (g *Gitlab) ValidateConfig(conf core.Configuration) error {
@ -176,9 +188,14 @@ func promptBaseUrl() (string, error) {
return input.Prompt("Base url", "url", input.Required, validator)
}
func promptTokenOptions(repo repository.RepoConfig, baseUrl string) (auth.Credential, error) {
func promptTokenOptions(repo repository.RepoConfig, login, baseUrl string) (auth.Credential, error) {
for {
creds, err := auth.List(repo, auth.WithTarget(target), auth.WithKind(auth.KindToken))
creds, err := auth.List(repo,
auth.WithTarget(target),
auth.WithKind(auth.KindToken),
auth.WithMeta(auth.MetaKeyLogin, login),
auth.WithMeta(auth.MetaKeyBaseURL, baseUrl),
)
if err != nil {
return nil, err
}
@ -248,7 +265,7 @@ func promptToken(baseUrl string) (*auth.Token, error) {
if !re.MatchString(value) {
return "token has incorrect format", nil
}
login, err = getLoginFromToken(baseUrl, auth.NewToken(value, target))
login, err = getLoginFromToken(baseUrl, auth.NewToken(target, value))
if err != nil {
return fmt.Sprintf("token is invalid: %v", err), nil
}
@ -260,13 +277,14 @@ func promptToken(baseUrl string) (*auth.Token, error) {
return nil, err
}
token := auth.NewToken(rawToken, target)
token := auth.NewToken(target, rawToken)
token.SetMetadata(auth.MetaKeyLogin, login)
token.SetMetadata(auth.MetaKeyBaseURL, baseUrl)
return token, nil
}
func promptURL(repo repository.RepoCommon, baseUrl string) (string, error) {
func promptProjectURL(repo repository.RepoCommon, baseUrl string) (string, error) {
// remote suggestions
remotes, err := repo.GetRemotes()
if err != nil {
@ -317,13 +335,13 @@ func promptURL(repo repository.RepoCommon, baseUrl string) (string, error) {
return "", err
}
url := strings.TrimSpace(line)
if url == "" {
projectURL := strings.TrimSpace(line)
if projectURL == "" {
fmt.Println("URL is empty")
continue
}
return url, nil
return projectURL, nil
}
}

View File

@ -47,7 +47,7 @@ func (ge *gitlabExporter) Init(repo *cache.RepoCache, conf core.Configuration) e
ge.repositoryID = ge.conf[keyProjectID]
// preload all clients
err := ge.cacheAllClient(repo)
err := ge.cacheAllClient(repo, ge.conf[keyGitlabBaseUrl])
if err != nil {
return err
}
@ -55,8 +55,12 @@ func (ge *gitlabExporter) Init(repo *cache.RepoCache, conf core.Configuration) e
return nil
}
func (ge *gitlabExporter) cacheAllClient(repo *cache.RepoCache) error {
creds, err := auth.List(repo, auth.WithTarget(target), auth.WithKind(auth.KindToken))
func (ge *gitlabExporter) cacheAllClient(repo *cache.RepoCache, baseURL string) error {
creds, err := auth.List(repo,
auth.WithTarget(target),
auth.WithKind(auth.KindToken),
auth.WithMeta(auth.MetaKeyBaseURL, baseURL),
)
if err != nil {
return err
}

View File

@ -162,8 +162,9 @@ func TestPushPull(t *testing.T) {
defer backend.Close()
interrupt.RegisterCleaner(backend.Close)
token := auth.NewToken(envToken, target)
token := auth.NewToken(target, envToken)
token.SetMetadata(auth.MetaKeyLogin, login)
token.SetMetadata(auth.MetaKeyBaseURL, defaultBaseURL)
err = auth.Store(repo, token)
require.NoError(t, err)
@ -194,7 +195,7 @@ func TestPushPull(t *testing.T) {
exporter := &gitlabExporter{}
err = exporter.Init(backend, core.Configuration{
keyProjectID: strconv.Itoa(projectID),
keyGitlabBaseUrl: "https://gitlab.com/",
keyGitlabBaseUrl: defaultBaseURL,
})
require.NoError(t, err)
@ -222,7 +223,7 @@ func TestPushPull(t *testing.T) {
importer := &gitlabImporter{}
err = importer.Init(backend, core.Configuration{
keyProjectID: strconv.Itoa(projectID),
keyGitlabBaseUrl: "https://gitlab.com/",
keyGitlabBaseUrl: defaultBaseURL,
})
require.NoError(t, err)
@ -287,7 +288,7 @@ func generateRepoName() string {
// create repository need a token with scope 'repo'
func createRepository(ctx context.Context, name string, token *auth.Token) (int, error) {
client, err := buildClient("https://gitlab.com/", token)
client, err := buildClient(defaultBaseURL, token)
if err != nil {
return 0, err
}
@ -307,7 +308,7 @@ func createRepository(ctx context.Context, name string, token *auth.Token) (int,
// delete repository need a token with scope 'delete_repo'
func deleteRepository(ctx context.Context, project int, token *auth.Token) error {
client, err := buildClient("https://gitlab.com/", token)
client, err := buildClient(defaultBaseURL, token)
if err != nil {
return err
}

View File

@ -33,7 +33,11 @@ type gitlabImporter struct {
func (gi *gitlabImporter) Init(repo *cache.RepoCache, conf core.Configuration) error {
gi.conf = conf
creds, err := auth.List(repo, auth.WithTarget(target), auth.WithKind(auth.KindToken))
creds, err := auth.List(repo,
auth.WithTarget(target),
auth.WithKind(auth.KindToken),
auth.WithMeta(auth.MetaKeyBaseURL, conf[keyGitlabBaseUrl]),
)
if err != nil {
return err
}

View File

@ -98,15 +98,16 @@ func TestImport(t *testing.T) {
login := "test-identity"
author.SetMetadata(metaKeyGitlabLogin, login)
token := auth.NewToken(envToken, target)
token.SetMetadata(metaKeyGitlabLogin, login)
token := auth.NewToken(target, envToken)
token.SetMetadata(auth.MetaKeyLogin, login)
token.SetMetadata(auth.MetaKeyBaseURL, defaultBaseURL)
err = auth.Store(repo, token)
require.NoError(t, err)
importer := &gitlabImporter{}
err = importer.Init(backend, core.Configuration{
keyProjectID: projectID,
keyGitlabBaseUrl: "https://gitlab.com",
keyGitlabBaseUrl: defaultBaseURL,
})
require.NoError(t, err)

12
cache/repo_cache.go vendored
View File

@ -58,6 +58,9 @@ type RepoCache struct {
// the underlying repo
repo repository.ClockedRepo
// the name of the repository, as defined in the MultiRepoCache
name string
muBug sync.RWMutex
// excerpt of bugs data for all bugs
bugExcerpts map[entity.Id]*BugExcerpt
@ -75,8 +78,13 @@ type RepoCache struct {
}
func NewRepoCache(r repository.ClockedRepo) (*RepoCache, error) {
return NewNamedRepoCache(r, "")
}
func NewNamedRepoCache(r repository.ClockedRepo, name string) (*RepoCache, error) {
c := &RepoCache{
repo: r,
name: name,
bugs: make(map[entity.Id]*BugCache),
identities: make(map[entity.Id]*IdentityCache),
}
@ -102,6 +110,10 @@ func NewRepoCache(r repository.ClockedRepo) (*RepoCache, error) {
return c, c.write()
}
func (c *RepoCache) Name() string {
return c.name
}
// LocalConfig give access to the repository scoped configuration
func (c *RepoCache) LocalConfig() repository.Config {
return c.repo.LocalConfig()

View File

@ -86,7 +86,7 @@ func runBridgeTokenAdd(cmd *cobra.Command, args []string) error {
}
}
token := auth.NewToken(value, bridgeAuthAddTokenTarget)
token := auth.NewToken(bridgeAuthAddTokenTarget, value)
token.SetMetadata(auth.MetaKeyLogin, bridgeAuthAddTokenLogin)
if err := token.Validate(); err != nil {

4
go.mod
View File

@ -13,7 +13,7 @@ require (
github.com/dustin/go-humanize v1.0.0
github.com/fatih/color v1.9.0
github.com/go-errors/errors v1.0.1
github.com/gorilla/mux v1.7.3
github.com/gorilla/mux v1.7.4
github.com/hashicorp/golang-lru v0.5.4 // indirect
github.com/icrowley/fake v0.0.0-20180203215853-4178557ae428
github.com/mattn/go-isatty v0.0.12
@ -26,7 +26,7 @@ require (
github.com/stretchr/testify v1.4.0
github.com/theckman/goconstraint v1.11.0
github.com/vektah/gqlparser v1.3.1
github.com/xanzy/go-gitlab v0.24.0
github.com/xanzy/go-gitlab v0.25.0
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550
golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288
golang.org/x/sync v0.0.0-20190423024810-112230192c58

4
go.sum
View File

@ -51,6 +51,8 @@ github.com/gorilla/context v0.0.0-20160226214623-1ea25387ff6f/go.mod h1:kBGZzfjB
github.com/gorilla/mux v1.6.1/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/mux v1.7.3 h1:gnP5JzjVOuiZD07fKKToCAOjS0yOpj/qPETTXCCS6hw=
github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/mux v1.7.4 h1:VuZ8uybHlWmqV03+zRzdwKL4tUnIp1MAQtp1mIFE1bc=
github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/websocket v1.2.0 h1:VJtLvh6VQym50czpZzx07z/kw9EgAxI3x1ZB8taTMQQ=
github.com/gorilla/websocket v1.2.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/hashicorp/golang-lru v0.5.0 h1:CL2msUPvZTLb5O648aiLNJw3hnBxN2+1Jq8rCOH9wdo=
@ -138,6 +140,8 @@ github.com/xanzy/go-gitlab v0.22.1 h1:TVxgHmoa35jQL+9FCkG0nwPDxU9dQZXknBTDtGaSFn
github.com/xanzy/go-gitlab v0.22.1/go.mod h1:t4Bmvnxj7k37S4Y17lfLx+nLqkf/oQwT2HagfWKv5Og=
github.com/xanzy/go-gitlab v0.24.0 h1:zP1zC4K76Gha0coN5GhygOLhsHTCvUjrnqGL3kHXkVU=
github.com/xanzy/go-gitlab v0.24.0/go.mod h1:t4Bmvnxj7k37S4Y17lfLx+nLqkf/oQwT2HagfWKv5Og=
github.com/xanzy/go-gitlab v0.25.0 h1:G5aTZeqZd66Q6qMVieBfmHBsPpF0jY92zCLAMpULe3I=
github.com/xanzy/go-gitlab v0.25.0/go.mod h1:t4Bmvnxj7k37S4Y17lfLx+nLqkf/oQwT2HagfWKv5Og=
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90PveolxSbWFaJdECFbxSq0Mqo2M=

View File

@ -163,16 +163,6 @@ type ComplexityRoot struct {
Message func(childComplexity int) int
}
CommitAsNeededPayload struct {
Bug func(childComplexity int) int
ClientMutationID func(childComplexity int) int
}
CommitPayload struct {
Bug func(childComplexity int) int
ClientMutationID func(childComplexity int) int
}
CreateOperation struct {
Author func(childComplexity int) int
Date func(childComplexity int) int
@ -264,14 +254,12 @@ type ComplexityRoot struct {
}
Mutation 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
Commit func(childComplexity int, input models.CommitInput) int
CommitAsNeeded func(childComplexity int, input models.CommitAsNeededInput) 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
AddComment func(childComplexity int, input models.AddCommentInput) int
ChangeLabels func(childComplexity int, input *models.ChangeLabelInput) int
CloseBug func(childComplexity int, input models.CloseBugInput) 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
}
NewBugPayload struct {
@ -306,8 +294,7 @@ type ComplexityRoot struct {
}
Query struct {
DefaultRepository func(childComplexity int) int
Repository func(childComplexity int, ref string) int
Repository func(childComplexity int, ref *string) int
}
Repository struct {
@ -315,6 +302,7 @@ type ComplexityRoot struct {
AllIdentities func(childComplexity int, after *string, before *string, first *int, last *int) int
Bug func(childComplexity int, prefix string) int
Identity func(childComplexity int, prefix string) int
Name func(childComplexity int) int
UserIdentity func(childComplexity int) int
ValidLabels func(childComplexity int, after *string, before *string, first *int, last *int) int
}
@ -448,14 +436,12 @@ type MutationResolver interface {
OpenBug(ctx context.Context, input models.OpenBugInput) (*models.OpenBugPayload, error)
CloseBug(ctx context.Context, input models.CloseBugInput) (*models.CloseBugPayload, error)
SetTitle(ctx context.Context, input models.SetTitleInput) (*models.SetTitlePayload, error)
Commit(ctx context.Context, input models.CommitInput) (*models.CommitPayload, error)
CommitAsNeeded(ctx context.Context, input models.CommitAsNeededInput) (*models.CommitAsNeededPayload, error)
}
type QueryResolver interface {
DefaultRepository(ctx context.Context) (*models.Repository, error)
Repository(ctx context.Context, ref string) (*models.Repository, error)
Repository(ctx context.Context, ref *string) (*models.Repository, error)
}
type RepositoryResolver interface {
Name(ctx context.Context, obj *models.Repository) (*string, error)
AllBugs(ctx context.Context, obj *models.Repository, after *string, before *string, first *int, last *int, query *string) (*models.BugConnection, error)
Bug(ctx context.Context, obj *models.Repository, prefix string) (models.BugWrapper, error)
AllIdentities(ctx context.Context, obj *models.Repository, after *string, before *string, first *int, last *int) (*models.IdentityConnection, error)
@ -925,34 +911,6 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.CommentHistoryStep.Message(childComplexity), true
case "CommitAsNeededPayload.bug":
if e.complexity.CommitAsNeededPayload.Bug == nil {
break
}
return e.complexity.CommitAsNeededPayload.Bug(childComplexity), true
case "CommitAsNeededPayload.clientMutationId":
if e.complexity.CommitAsNeededPayload.ClientMutationID == nil {
break
}
return e.complexity.CommitAsNeededPayload.ClientMutationID(childComplexity), true
case "CommitPayload.bug":
if e.complexity.CommitPayload.Bug == nil {
break
}
return e.complexity.CommitPayload.Bug(childComplexity), true
case "CommitPayload.clientMutationId":
if e.complexity.CommitPayload.ClientMutationID == nil {
break
}
return e.complexity.CommitPayload.ClientMutationID(childComplexity), true
case "CreateOperation.author":
if e.complexity.CreateOperation.Author == nil {
break
@ -1367,30 +1325,6 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.Mutation.CloseBug(childComplexity, args["input"].(models.CloseBugInput)), true
case "Mutation.commit":
if e.complexity.Mutation.Commit == nil {
break
}
args, err := ec.field_Mutation_commit_args(context.TODO(), rawArgs)
if err != nil {
return 0, false
}
return e.complexity.Mutation.Commit(childComplexity, args["input"].(models.CommitInput)), true
case "Mutation.commitAsNeeded":
if e.complexity.Mutation.CommitAsNeeded == nil {
break
}
args, err := ec.field_Mutation_commitAsNeeded_args(context.TODO(), rawArgs)
if err != nil {
return 0, false
}
return e.complexity.Mutation.CommitAsNeeded(childComplexity, args["input"].(models.CommitAsNeededInput)), true
case "Mutation.newBug":
if e.complexity.Mutation.NewBug == nil {
break
@ -1539,13 +1473,6 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.PageInfo.StartCursor(childComplexity), true
case "Query.defaultRepository":
if e.complexity.Query.DefaultRepository == nil {
break
}
return e.complexity.Query.DefaultRepository(childComplexity), true
case "Query.repository":
if e.complexity.Query.Repository == nil {
break
@ -1556,7 +1483,7 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return 0, false
}
return e.complexity.Query.Repository(childComplexity, args["ref"].(string)), true
return e.complexity.Query.Repository(childComplexity, args["ref"].(*string)), true
case "Repository.allBugs":
if e.complexity.Repository.AllBugs == nil {
@ -1606,6 +1533,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.Repository.Identity(childComplexity, args["prefix"].(string)), true
case "Repository.name":
if e.complexity.Repository.Name == nil {
break
}
return e.complexity.Repository.Name(childComplexity), true
case "Repository.userIdentity":
if e.complexity.Repository.UserIdentity == nil {
break
@ -2184,38 +2118,6 @@ type SetTitlePayload {
"""The resulting operation"""
operation: SetTitleOperation!
}
input CommitInput {
"""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!
}
type CommitPayload {
"""A unique identifier for the client performing the mutation."""
clientMutationId: String
"""The affected bug."""
bug: Bug!
}
input CommitAsNeededInput {
"""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!
}
type CommitAsNeededPayload {
"""A unique identifier for the client performing the mutation."""
clientMutationId: String
"""The affected bug."""
bug: Bug!
}
`, BuiltIn: false},
&ast.Source{Name: "schema/operations.graphql", Input: `"""An operation applied to a bug."""
interface Operation {
@ -2320,6 +2222,9 @@ type LabelChangeOperation implements Operation & Authored {
`, BuiltIn: false},
&ast.Source{Name: "schema/repository.graphql", Input: `
type Repository {
"""The name of the repository"""
name: String
"""All the bugs"""
allBugs(
"""Returns the elements in the list that come after the specified cursor."""
@ -2330,7 +2235,7 @@ type Repository {
first: Int
"""Returns the last _n_ elements from the list."""
last: Int
"""A query to select and order bugs"""
"""A query to select and order bugs."""
query: String
): BugConnection!
@ -2366,12 +2271,8 @@ type Repository {
): LabelConnection!
}`, BuiltIn: false},
&ast.Source{Name: "schema/root.graphql", Input: `type Query {
"""The default unnamend repository."""
defaultRepository: Repository
"""Access a repository by reference/name."""
repository(ref: String!): Repository
#TODO: connection for all repositories
"""Access a repository by reference/name. If no ref is given, the default repository is returned if any."""
repository(ref: String): Repository
}
type Mutation {
@ -2387,10 +2288,6 @@ type Mutation {
closeBug(input: CloseBugInput!): CloseBugPayload!
"""Change a bug's title"""
setTitle(input: SetTitleInput!): SetTitlePayload!
"""Commit write the pending operations into storage. This mutation fail if nothing is pending"""
commit(input: CommitInput!): CommitPayload!
"""Commit write the pending operations into storage. This mutation succed if nothing is pending"""
commitAsNeeded(input: CommitAsNeededInput!): CommitAsNeededPayload!
}
`, BuiltIn: false},
&ast.Source{Name: "schema/timeline.graphql", Input: `"""An item in the timeline of events"""
@ -2750,34 +2647,6 @@ func (ec *executionContext) field_Mutation_closeBug_args(ctx context.Context, ra
return args, nil
}
func (ec *executionContext) field_Mutation_commitAsNeeded_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) {
var err error
args := map[string]interface{}{}
var arg0 models.CommitAsNeededInput
if tmp, ok := rawArgs["input"]; ok {
arg0, err = ec.unmarshalNCommitAsNeededInput2githubᚗcomᚋMichaelMureᚋgitᚑbugᚋgraphqlᚋmodelsᚐCommitAsNeededInput(ctx, tmp)
if err != nil {
return nil, err
}
}
args["input"] = arg0
return args, nil
}
func (ec *executionContext) field_Mutation_commit_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) {
var err error
args := map[string]interface{}{}
var arg0 models.CommitInput
if tmp, ok := rawArgs["input"]; ok {
arg0, err = ec.unmarshalNCommitInput2githubᚗcomᚋMichaelMureᚋgitᚑbugᚋgraphqlᚋmodelsᚐCommitInput(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{}{}
@ -2837,9 +2706,9 @@ func (ec *executionContext) field_Query___type_args(ctx context.Context, rawArgs
func (ec *executionContext) field_Query_repository_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) {
var err error
args := map[string]interface{}{}
var arg0 string
var arg0 *string
if tmp, ok := rawArgs["ref"]; ok {
arg0, err = ec.unmarshalNString2string(ctx, tmp)
arg0, err = ec.unmarshalOString2ᚖstring(ctx, tmp)
if err != nil {
return nil, err
}
@ -4998,136 +4867,6 @@ func (ec *executionContext) _CommentHistoryStep_date(ctx context.Context, field
return ec.marshalNTime2ᚖtimeᚐTime(ctx, field.Selections, res)
}
func (ec *executionContext) _CommitAsNeededPayload_clientMutationId(ctx context.Context, field graphql.CollectedField, obj *models.CommitAsNeededPayload) (ret graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
ec.Error(ctx, ec.Recover(ctx, r))
ret = graphql.Null
}
}()
fc := &graphql.FieldContext{
Object: "CommitAsNeededPayload",
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) _CommitAsNeededPayload_bug(ctx context.Context, field graphql.CollectedField, obj *models.CommitAsNeededPayload) (ret graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
ec.Error(ctx, ec.Recover(ctx, r))
ret = graphql.Null
}
}()
fc := &graphql.FieldContext{
Object: "CommitAsNeededPayload",
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ᚋgraphqlᚋmodelsᚐBugWrapper(ctx, field.Selections, res)
}
func (ec *executionContext) _CommitPayload_clientMutationId(ctx context.Context, field graphql.CollectedField, obj *models.CommitPayload) (ret graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
ec.Error(ctx, ec.Recover(ctx, r))
ret = graphql.Null
}
}()
fc := &graphql.FieldContext{
Object: "CommitPayload",
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) _CommitPayload_bug(ctx context.Context, field graphql.CollectedField, obj *models.CommitPayload) (ret graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
ec.Error(ctx, ec.Recover(ctx, r))
ret = graphql.Null
}
}()
fc := &graphql.FieldContext{
Object: "CommitPayload",
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ᚋgraphqlᚋmodelsᚐBugWrapper(ctx, field.Selections, res)
}
func (ec *executionContext) _CreateOperation_id(ctx context.Context, field graphql.CollectedField, obj *bug.CreateOperation) (ret graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
@ -7201,88 +6940,6 @@ func (ec *executionContext) _Mutation_setTitle(ctx context.Context, field graphq
return ec.marshalNSetTitlePayload2ᚖgithubᚗcomᚋMichaelMureᚋgitᚑbugᚋgraphqlᚋmodelsᚐSetTitlePayload(ctx, field.Selections, res)
}
func (ec *executionContext) _Mutation_commit(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_commit_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().Commit(rctx, args["input"].(models.CommitInput))
})
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.CommitPayload)
fc.Result = res
return ec.marshalNCommitPayload2ᚖgithubᚗcomᚋMichaelMureᚋgitᚑbugᚋgraphqlᚋmodelsᚐCommitPayload(ctx, field.Selections, res)
}
func (ec *executionContext) _Mutation_commitAsNeeded(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_commitAsNeeded_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().CommitAsNeeded(rctx, args["input"].(models.CommitAsNeededInput))
})
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.CommitAsNeededPayload)
fc.Result = res
return ec.marshalNCommitAsNeededPayload2ᚖgithubᚗcomᚋMichaelMureᚋgitᚑbugᚋgraphqlᚋmodelsᚐCommitAsNeededPayload(ctx, field.Selections, res)
}
func (ec *executionContext) _NewBugPayload_clientMutationId(ctx context.Context, field graphql.CollectedField, obj *models.NewBugPayload) (ret graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
@ -7821,37 +7478,6 @@ func (ec *executionContext) _PageInfo_endCursor(ctx context.Context, field graph
return ec.marshalNString2string(ctx, field.Selections, res)
}
func (ec *executionContext) _Query_defaultRepository(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: "Query",
Field: field,
Args: nil,
IsMethod: true,
}
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 ec.resolvers.Query().DefaultRepository(rctx)
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
return graphql.Null
}
res := resTmp.(*models.Repository)
fc.Result = res
return ec.marshalORepository2ᚖgithubᚗcomᚋMichaelMureᚋgitᚑbugᚋgraphqlᚋmodelsᚐRepository(ctx, field.Selections, res)
}
func (ec *executionContext) _Query_repository(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
@ -7876,7 +7502,7 @@ func (ec *executionContext) _Query_repository(ctx context.Context, field graphql
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.Query().Repository(rctx, args["ref"].(string))
return ec.resolvers.Query().Repository(rctx, args["ref"].(*string))
})
if err != nil {
ec.Error(ctx, err)
@ -7959,6 +7585,37 @@ func (ec *executionContext) _Query___schema(ctx context.Context, field graphql.C
return ec.marshalO__Schema2ᚖgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐSchema(ctx, field.Selections, res)
}
func (ec *executionContext) _Repository_name(ctx context.Context, field graphql.CollectedField, obj *models.Repository) (ret graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
ec.Error(ctx, ec.Recover(ctx, r))
ret = graphql.Null
}
}()
fc := &graphql.FieldContext{
Object: "Repository",
Field: field,
Args: nil,
IsMethod: true,
}
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 ec.resolvers.Repository().Name(rctx, obj)
})
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) _Repository_allBugs(ctx context.Context, field graphql.CollectedField, obj *models.Repository) (ret graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
@ -10273,66 +9930,6 @@ func (ec *executionContext) unmarshalInputCloseBugInput(ctx context.Context, obj
return it, nil
}
func (ec *executionContext) unmarshalInputCommitAsNeededInput(ctx context.Context, obj interface{}) (models.CommitAsNeededInput, error) {
var it models.CommitAsNeededInput
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
}
}
}
return it, nil
}
func (ec *executionContext) unmarshalInputCommitInput(ctx context.Context, obj interface{}) (models.CommitInput, error) {
var it models.CommitInput
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
}
}
}
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{})
@ -11346,64 +10943,6 @@ func (ec *executionContext) _CommentHistoryStep(ctx context.Context, sel ast.Sel
return out
}
var commitAsNeededPayloadImplementors = []string{"CommitAsNeededPayload"}
func (ec *executionContext) _CommitAsNeededPayload(ctx context.Context, sel ast.SelectionSet, obj *models.CommitAsNeededPayload) graphql.Marshaler {
fields := graphql.CollectFields(ec.OperationContext, sel, commitAsNeededPayloadImplementors)
out := graphql.NewFieldSet(fields)
var invalids uint32
for i, field := range fields {
switch field.Name {
case "__typename":
out.Values[i] = graphql.MarshalString("CommitAsNeededPayload")
case "clientMutationId":
out.Values[i] = ec._CommitAsNeededPayload_clientMutationId(ctx, field, obj)
case "bug":
out.Values[i] = ec._CommitAsNeededPayload_bug(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 commitPayloadImplementors = []string{"CommitPayload"}
func (ec *executionContext) _CommitPayload(ctx context.Context, sel ast.SelectionSet, obj *models.CommitPayload) graphql.Marshaler {
fields := graphql.CollectFields(ec.OperationContext, sel, commitPayloadImplementors)
out := graphql.NewFieldSet(fields)
var invalids uint32
for i, field := range fields {
switch field.Name {
case "__typename":
out.Values[i] = graphql.MarshalString("CommitPayload")
case "clientMutationId":
out.Values[i] = ec._CommitPayload_clientMutationId(ctx, field, obj)
case "bug":
out.Values[i] = ec._CommitPayload_bug(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 createOperationImplementors = []string{"CreateOperation", "Operation", "Authored"}
func (ec *executionContext) _CreateOperation(ctx context.Context, sel ast.SelectionSet, obj *bug.CreateOperation) graphql.Marshaler {
@ -12172,16 +11711,6 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet)
if out.Values[i] == graphql.Null {
invalids++
}
case "commit":
out.Values[i] = ec._Mutation_commit(ctx, field)
if out.Values[i] == graphql.Null {
invalids++
}
case "commitAsNeeded":
out.Values[i] = ec._Mutation_commitAsNeeded(ctx, field)
if out.Values[i] == graphql.Null {
invalids++
}
default:
panic("unknown field " + strconv.Quote(field.Name))
}
@ -12392,17 +11921,6 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr
switch field.Name {
case "__typename":
out.Values[i] = graphql.MarshalString("Query")
case "defaultRepository":
field := field
out.Concurrently(i, func() (res graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
ec.Error(ctx, ec.Recover(ctx, r))
}
}()
res = ec._Query_defaultRepository(ctx, field)
return res
})
case "repository":
field := field
out.Concurrently(i, func() (res graphql.Marshaler) {
@ -12440,6 +11958,17 @@ func (ec *executionContext) _Repository(ctx context.Context, sel ast.SelectionSe
switch field.Name {
case "__typename":
out.Values[i] = graphql.MarshalString("Repository")
case "name":
field := field
out.Concurrently(i, func() (res graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
ec.Error(ctx, ec.Recover(ctx, r))
}
}()
res = ec._Repository_name(ctx, field, obj)
return res
})
case "allBugs":
field := field
out.Concurrently(i, func() (res graphql.Marshaler) {
@ -13544,42 +13073,6 @@ func (ec *executionContext) marshalNCommentHistoryStep2ᚕgithubᚗcomᚋMichael
return ret
}
func (ec *executionContext) unmarshalNCommitAsNeededInput2githubᚗcomᚋMichaelMureᚋgitᚑbugᚋgraphqlᚋmodelsᚐCommitAsNeededInput(ctx context.Context, v interface{}) (models.CommitAsNeededInput, error) {
return ec.unmarshalInputCommitAsNeededInput(ctx, v)
}
func (ec *executionContext) marshalNCommitAsNeededPayload2githubᚗcomᚋMichaelMureᚋgitᚑbugᚋgraphqlᚋmodelsᚐCommitAsNeededPayload(ctx context.Context, sel ast.SelectionSet, v models.CommitAsNeededPayload) graphql.Marshaler {
return ec._CommitAsNeededPayload(ctx, sel, &v)
}
func (ec *executionContext) marshalNCommitAsNeededPayload2ᚖgithubᚗcomᚋMichaelMureᚋgitᚑbugᚋgraphqlᚋmodelsᚐCommitAsNeededPayload(ctx context.Context, sel ast.SelectionSet, v *models.CommitAsNeededPayload) graphql.Marshaler {
if v == nil {
if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null
}
return ec._CommitAsNeededPayload(ctx, sel, v)
}
func (ec *executionContext) unmarshalNCommitInput2githubᚗcomᚋMichaelMureᚋgitᚑbugᚋgraphqlᚋmodelsᚐCommitInput(ctx context.Context, v interface{}) (models.CommitInput, error) {
return ec.unmarshalInputCommitInput(ctx, v)
}
func (ec *executionContext) marshalNCommitPayload2githubᚗcomᚋMichaelMureᚋgitᚑbugᚋgraphqlᚋmodelsᚐCommitPayload(ctx context.Context, sel ast.SelectionSet, v models.CommitPayload) graphql.Marshaler {
return ec._CommitPayload(ctx, sel, &v)
}
func (ec *executionContext) marshalNCommitPayload2ᚖgithubᚗcomᚋMichaelMureᚋgitᚑbugᚋgraphqlᚋmodelsᚐCommitPayload(ctx context.Context, sel ast.SelectionSet, v *models.CommitPayload) graphql.Marshaler {
if v == nil {
if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null
}
return ec._CommitPayload(ctx, sel, v)
}
func (ec *executionContext) marshalNCreateOperation2githubᚗcomᚋMichaelMureᚋgitᚑbugᚋbugᚐCreateOperation(ctx context.Context, sel ast.SelectionSet, v bug.CreateOperation) graphql.Marshaler {
return ec._CreateOperation(ctx, sel, &v)
}

View File

@ -25,7 +25,7 @@ func TestQueries(t *testing.T) {
query := `
query {
defaultRepository {
repository {
allBugs(first: 2) {
pageInfo {
endCursor
@ -162,7 +162,7 @@ func TestQueries(t *testing.T) {
}
var resp struct {
DefaultRepository struct {
Repository struct {
AllBugs struct {
PageInfo models.PageInfo
Nodes []struct {

View File

@ -111,38 +111,6 @@ type CommentEdge struct {
Node *bug.Comment `json:"node"`
}
type CommitAsNeededInput 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"`
}
type CommitAsNeededPayload struct {
// A unique identifier for the client performing the mutation.
ClientMutationID *string `json:"clientMutationId"`
// The affected bug.
Bug BugWrapper `json:"bug"`
}
type CommitInput 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"`
}
type CommitPayload struct {
// A unique identifier for the client performing the mutation.
ClientMutationID *string `json:"clientMutationId"`
// The affected bug.
Bug BugWrapper `json:"bug"`
}
type IdentityConnection struct {
Edges []*IdentityEdge `json:"edges"`
Nodes []IdentityWrapper `json:"nodes"`

View File

@ -23,6 +23,15 @@ func (r mutationResolver) getRepo(ref *string) (*cache.RepoCache, error) {
return r.cache.DefaultRepo()
}
func (r mutationResolver) getBug(repoRef *string, bugPrefix string) (*cache.BugCache, error) {
repo, err := r.getRepo(repoRef)
if err != nil {
return nil, err
}
return repo.ResolveBugPrefix(bugPrefix)
}
func (r mutationResolver) NewBug(_ context.Context, input models.NewBugInput) (*models.NewBugPayload, error) {
repo, err := r.getRepo(input.RepoRef)
if err != nil {
@ -42,12 +51,7 @@ func (r mutationResolver) NewBug(_ context.Context, input models.NewBugInput) (*
}
func (r mutationResolver) AddComment(_ context.Context, input models.AddCommentInput) (*models.AddCommentPayload, error) {
repo, err := r.getRepo(input.RepoRef)
if err != nil {
return nil, err
}
b, err := repo.ResolveBugPrefix(input.Prefix)
b, err := r.getBug(input.RepoRef, input.Prefix)
if err != nil {
return nil, err
}
@ -57,6 +61,11 @@ func (r mutationResolver) AddComment(_ context.Context, input models.AddCommentI
return nil, err
}
err = b.Commit()
if err != nil {
return nil, err
}
return &models.AddCommentPayload{
ClientMutationID: input.ClientMutationID,
Bug: models.NewLoadedBug(b.Snapshot()),
@ -65,12 +74,7 @@ func (r mutationResolver) AddComment(_ context.Context, input models.AddCommentI
}
func (r mutationResolver) ChangeLabels(_ context.Context, input *models.ChangeLabelInput) (*models.ChangeLabelPayload, error) {
repo, err := r.getRepo(input.RepoRef)
if err != nil {
return nil, err
}
b, err := repo.ResolveBugPrefix(input.Prefix)
b, err := r.getBug(input.RepoRef, input.Prefix)
if err != nil {
return nil, err
}
@ -80,6 +84,11 @@ func (r mutationResolver) ChangeLabels(_ context.Context, input *models.ChangeLa
return nil, err
}
err = b.Commit()
if err != nil {
return nil, err
}
resultsPtr := make([]*bug.LabelChangeResult, len(results))
for i, result := range results {
resultsPtr[i] = &result
@ -94,12 +103,7 @@ func (r mutationResolver) ChangeLabels(_ context.Context, input *models.ChangeLa
}
func (r mutationResolver) OpenBug(_ context.Context, input models.OpenBugInput) (*models.OpenBugPayload, error) {
repo, err := r.getRepo(input.RepoRef)
if err != nil {
return nil, err
}
b, err := repo.ResolveBugPrefix(input.Prefix)
b, err := r.getBug(input.RepoRef, input.Prefix)
if err != nil {
return nil, err
}
@ -109,6 +113,11 @@ func (r mutationResolver) OpenBug(_ context.Context, input models.OpenBugInput)
return nil, err
}
err = b.Commit()
if err != nil {
return nil, err
}
return &models.OpenBugPayload{
ClientMutationID: input.ClientMutationID,
Bug: models.NewLoadedBug(b.Snapshot()),
@ -117,12 +126,7 @@ func (r mutationResolver) OpenBug(_ context.Context, input models.OpenBugInput)
}
func (r mutationResolver) CloseBug(_ context.Context, input models.CloseBugInput) (*models.CloseBugPayload, error) {
repo, err := r.getRepo(input.RepoRef)
if err != nil {
return nil, err
}
b, err := repo.ResolveBugPrefix(input.Prefix)
b, err := r.getBug(input.RepoRef, input.Prefix)
if err != nil {
return nil, err
}
@ -132,6 +136,11 @@ func (r mutationResolver) CloseBug(_ context.Context, input models.CloseBugInput
return nil, err
}
err = b.Commit()
if err != nil {
return nil, err
}
return &models.CloseBugPayload{
ClientMutationID: input.ClientMutationID,
Bug: models.NewLoadedBug(b.Snapshot()),
@ -140,12 +149,7 @@ func (r mutationResolver) CloseBug(_ context.Context, input models.CloseBugInput
}
func (r mutationResolver) SetTitle(_ context.Context, input models.SetTitleInput) (*models.SetTitlePayload, error) {
repo, err := r.getRepo(input.RepoRef)
if err != nil {
return nil, err
}
b, err := repo.ResolveBugPrefix(input.Prefix)
b, err := r.getBug(input.RepoRef, input.Prefix)
if err != nil {
return nil, err
}
@ -155,53 +159,14 @@ func (r mutationResolver) SetTitle(_ context.Context, input models.SetTitleInput
return nil, err
}
err = b.Commit()
if err != nil {
return nil, err
}
return &models.SetTitlePayload{
ClientMutationID: input.ClientMutationID,
Bug: models.NewLoadedBug(b.Snapshot()),
Operation: op,
}, nil
}
func (r mutationResolver) Commit(_ context.Context, input models.CommitInput) (*models.CommitPayload, error) {
repo, err := r.getRepo(input.RepoRef)
if err != nil {
return nil, err
}
b, err := repo.ResolveBugPrefix(input.Prefix)
if err != nil {
return nil, err
}
err = b.Commit()
if err != nil {
return nil, err
}
return &models.CommitPayload{
ClientMutationID: input.ClientMutationID,
Bug: models.NewLoadedBug(b.Snapshot()),
}, nil
}
func (r mutationResolver) CommitAsNeeded(_ context.Context, input models.CommitAsNeededInput) (*models.CommitAsNeededPayload, error) {
repo, err := r.getRepo(input.RepoRef)
if err != nil {
return nil, err
}
b, err := repo.ResolveBugPrefix(input.Prefix)
if err != nil {
return nil, err
}
err = b.CommitAsNeeded()
if err != nil {
return nil, err
}
return &models.CommitAsNeededPayload{
ClientMutationID: input.ClientMutationID,
Bug: models.NewLoadedBug(b.Snapshot()),
}, nil
}

View File

@ -27,8 +27,15 @@ func (r rootQueryResolver) DefaultRepository(_ context.Context) (*models.Reposit
}, nil
}
func (r rootQueryResolver) Repository(_ context.Context, ref string) (*models.Repository, error) {
repo, err := r.cache.ResolveRepo(ref)
func (r rootQueryResolver) Repository(_ context.Context, ref *string) (*models.Repository, error) {
var repo *cache.RepoCache
var err error
if ref == nil {
repo, err = r.cache.DefaultRepo()
} else {
repo, err = r.cache.ResolveRepo(*ref)
}
if err != nil {
return nil, err

View File

@ -15,6 +15,11 @@ var _ graph.RepositoryResolver = &repoResolver{}
type repoResolver struct{}
func (repoResolver) Name(_ context.Context, obj *models.Repository) (*string, error) {
name := obj.Repo.Name()
return &name, nil
}
func (repoResolver) AllBugs(_ context.Context, obj *models.Repository, after *string, before *string, first *int, last *int, queryStr *string) (*models.BugConnection, error) {
input := models.ConnectionInput{
Before: before,
@ -153,7 +158,7 @@ func (repoResolver) UserIdentity(_ context.Context, obj *models.Repository) (mod
return models.NewLazyIdentity(obj.Repo, excerpt), nil
}
func (resolver repoResolver) ValidLabels(_ context.Context, obj *models.Repository, after *string, before *string, first *int, last *int) (*models.LabelConnection, error) {
func (repoResolver) ValidLabels(_ context.Context, obj *models.Repository, after *string, before *string, first *int, last *int) (*models.LabelConnection, error) {
input := models.ConnectionInput{
Before: before,
After: after,

View File

@ -136,35 +136,3 @@ type SetTitlePayload {
"""The resulting operation"""
operation: SetTitleOperation!
}
input CommitInput {
"""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!
}
type CommitPayload {
"""A unique identifier for the client performing the mutation."""
clientMutationId: String
"""The affected bug."""
bug: Bug!
}
input CommitAsNeededInput {
"""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!
}
type CommitAsNeededPayload {
"""A unique identifier for the client performing the mutation."""
clientMutationId: String
"""The affected bug."""
bug: Bug!
}

View File

@ -1,5 +1,8 @@
type Repository {
"""The name of the repository"""
name: String
"""All the bugs"""
allBugs(
"""Returns the elements in the list that come after the specified cursor."""
@ -10,7 +13,7 @@ type Repository {
first: Int
"""Returns the last _n_ elements from the list."""
last: Int
"""A query to select and order bugs"""
"""A query to select and order bugs."""
query: String
): BugConnection!

View File

@ -1,10 +1,6 @@
type Query {
"""The default unnamend repository."""
defaultRepository: Repository
"""Access a repository by reference/name."""
repository(ref: String!): Repository
#TODO: connection for all repositories
"""Access a repository by reference/name. If no ref is given, the default repository is returned if any."""
repository(ref: String): Repository
}
type Mutation {
@ -20,8 +16,4 @@ type Mutation {
closeBug(input: CloseBugInput!): CloseBugPayload!
"""Change a bug's title"""
setTitle(input: SetTitleInput!): SetTitlePayload!
"""Commit write the pending operations into storage. This mutation fail if nothing is pending"""
commit(input: CommitInput!): CommitPayload!
"""Commit write the pending operations into storage. This mutation succed if nothing is pending"""
commitAsNeeded(input: CommitAsNeededInput!): CommitAsNeededPayload!
}

View File

@ -1,4 +0,0 @@
{
"extends": ["react-app", "plugin:prettier/recommended"],
"ignorePatterns": ["src/fragmentTypes.js"]
}

37
webui/.eslintrc.js Normal file
View File

@ -0,0 +1,37 @@
module.exports = {
extends: [
'react-app',
'prettier/@typescript-eslint',
'plugin:prettier/recommended',
],
plugins: ['graphql'],
rules: {
'graphql/template-strings': [
'error',
{
schemaJson: require('./src/schema.json'),
env: 'literal',
},
],
'import/order': [
'error',
{
alphabetize: { order: 'asc' },
pathGroups: [
{
pattern: '@material-ui/**',
group: 'external',
position: 'after',
},
{
pattern: '*.generated',
group: 'sibling',
position: 'after',
},
],
groups: [['builtin', 'external'], 'parent', ['sibling', 'index']],
'newlines-between': 'always',
},
],
},
};

View File

@ -5,7 +5,9 @@ install:
npm install
test:
npm run generate
npm run lint
CI=true npm run test
build:
npm run build

View File

@ -7,10 +7,11 @@
2. Run the GraphQL backend on the port 3001
- `./git-bug webui -p 3001`
3. Run the hot-reloadable development WebUI
- run `npm start` in the **webui** directory
The development version of the WebUI is configured to query the backend on the port 3001. You can now live edit the js code and use the normal backend.
## 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, run `make pack-webui` from the root directory to bundle the compiled js into the go binary.

View File

@ -1,8 +1,32 @@
schema: '../graphql/schema/*.graphql'
overwrite: true
documents: src/**/*.graphql
generates:
./src/fragmentTypes.js:
./src/fragmentTypes.ts:
plugins:
- fragment-matcher
- fragment-matcher
config:
module: es2015
./src/gqlTypes.ts:
plugins:
- typescript
./src/schema.json:
plugins:
- introspection
./src/:
plugins:
- add: '/* eslint-disable @typescript-eslint/no-unused-vars, import/order */'
- typescript-operations
- typescript-react-apollo
preset: near-operation-file
presetConfig:
extension: .generated.tsx
baseTypesPath: gqlTypes.ts
config:
withComponent: false
withHOC: false
withHooks: true
hooks:
afterAllFileWrite:
- prettier --write

8005
webui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -4,28 +4,41 @@
"private": true,
"dependencies": {
"@apollo/react-hooks": "^3.1.3",
"@arrows/composition": "^1.2.2",
"@material-ui/core": "^4.9.0",
"@material-ui/icons": "^4.2.1",
"@material-ui/lab": "^4.0.0-alpha.40",
"@material-ui/styles": "^4.9.0",
"@types/node": "^13.5.3",
"@types/react": "^16.9.19",
"@types/react-dom": "^16.9.5",
"@types/react-router-dom": "^5.1.3",
"apollo-boost": "^0.4.7",
"graphql": "^14.3.0",
"clsx": "^1.1.0",
"graphql": "^14.6.0",
"graphql.macro": "^1.4.2",
"moment": "^2.24.0",
"react": "^16.8.6",
"react-apollo": "^3.1.3",
"react-dom": "^16.8.6",
"react-router": "^5.0.0",
"react-router-dom": "^5.0.0",
"react-scripts": "^3.1.1",
"react-scripts": "^3.3.1",
"remark-html": "^10.0.0",
"remark-parse": "^7.0.2",
"remark-react": "^6.0.0",
"typescript": "^3.7.5",
"unified": "^8.4.2"
},
"devDependencies": {
"@graphql-codegen/cli": "^1.11.2",
"@graphql-codegen/fragment-matcher": "^1.11.2",
"eslint-config-prettier": "^6.9.0",
"@graphql-codegen/cli": "^1.12.1",
"@graphql-codegen/fragment-matcher": "^1.12.1",
"@graphql-codegen/near-operation-file-preset": "^1.12.2-alpha-ea7264f9.15",
"@graphql-codegen/typescript-operations": "^1.12.1",
"@graphql-codegen/typescript-react-apollo": "^1.12.1",
"@graphql-codegen/introspection": "^1.12.2",
"eslint-config-prettier": "^6.10.0",
"eslint-plugin-graphql": "^3.1.1",
"eslint-plugin-prettier": "^3.1.2",
"prettier": "^1.19.1"
},
@ -35,7 +48,7 @@
"test": "react-scripts test --env=jsdom",
"eject": "react-scripts eject",
"generate": "graphql-codegen",
"lint": "eslint src/"
"lint": "eslint src --ext .ts --ext .tsx --ext .js --ext .jsx --ext .graphql"
},
"proxy": "http://localhost:3001",
"browserslist": [

View File

@ -1 +1,4 @@
fragmentTypes.js
fragmentTypes.ts
gqlTypes.ts
*.generated.*
schema.json

View File

@ -1,15 +1,18 @@
import AppBar from '@material-ui/core/AppBar';
import CssBaseline from '@material-ui/core/CssBaseline';
import { createMuiTheme, ThemeProvider } from '@material-ui/core/styles';
import { makeStyles } from '@material-ui/styles';
import Toolbar from '@material-ui/core/Toolbar';
import {
createMuiTheme,
ThemeProvider,
makeStyles,
} from '@material-ui/core/styles';
import React from 'react';
import { Route, Switch } from 'react-router';
import { Link } from 'react-router-dom';
import CurrentIdentity from './CurrentIdentity';
import BugQuery from './bug/BugQuery';
import ListQuery from './list/ListQuery';
import CurrentIdentity from './CurrentIdentity';
const theme = createMuiTheme({
palette: {
@ -20,7 +23,9 @@ const theme = createMuiTheme({
});
const useStyles = makeStyles(theme => ({
offset: theme.mixins.toolbar,
offset: {
...theme.mixins.toolbar,
},
filler: {
flexGrow: 1,
},
@ -46,7 +51,7 @@ export default function App() {
<AppBar position="fixed" color="primary">
<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" />
git-bug
</Link>
<div className={classes.filler}></div>

8
webui/src/Author.graphql Normal file
View File

@ -0,0 +1,8 @@
fragment authored on Authored {
author {
name
email
displayName
avatarUrl
}
}

View File

@ -1,9 +1,15 @@
import gql from 'graphql-tag';
import Tooltip from '@material-ui/core/Tooltip/Tooltip';
import MAvatar from '@material-ui/core/Avatar';
import Tooltip from '@material-ui/core/Tooltip/Tooltip';
import React from 'react';
const Author = ({ author, ...props }) => {
import { AuthoredFragment } from './Author.generated';
type Props = AuthoredFragment & {
className?: string;
bold?: boolean;
};
const Author = ({ author, ...props }: Props) => {
if (!author.email) {
return <span {...props}>{author.displayName}</span>;
}
@ -15,18 +21,7 @@ const Author = ({ author, ...props }) => {
);
};
Author.fragment = gql`
fragment authored on Authored {
author {
name
email
displayName
avatarUrl
}
}
`;
export const Avatar = ({ author, ...props }) => {
export const Avatar = ({ author, ...props }: Props) => {
if (author.avatarUrl) {
return <MAvatar src={author.avatarUrl} {...props} />;
}

View File

@ -1,11 +1,14 @@
import unified from 'unified';
import parse from 'remark-parse';
import React from 'react';
import html from 'remark-html';
import parse from 'remark-parse';
import remark2react from 'remark-react';
import unified from 'unified';
import ImageTag from './tag/ImageTag';
import PreTag from './tag/PreTag';
const Content = ({ markdown }) => {
type Props = { markdown: string };
const Content: React.FC<Props> = ({ markdown }: Props) => {
const processor = unified()
.use(parse)
.use(html)
@ -16,7 +19,8 @@ const Content = ({ markdown }) => {
},
});
return processor.processSync(markdown).contents;
const contents: React.ReactNode = processor.processSync(markdown).contents;
return <>{contents}</>;
};
export default Content;

View File

@ -0,0 +1,8 @@
query CurrentIdentity {
repository {
userIdentity {
displayName
avatarUrl
}
}
}

View File

@ -1,45 +0,0 @@
import React from 'react';
import gql from 'graphql-tag';
import { Query } from 'react-apollo';
import Avatar from '@material-ui/core/Avatar';
import { makeStyles } from '@material-ui/styles';
const useStyles = makeStyles(theme => ({
displayName: {
marginLeft: theme.spacing(2),
},
}));
const QUERY = gql`
{
defaultRepository {
userIdentity {
displayName
avatarUrl
}
}
}
`;
const CurrentIdentity = () => {
const classes = useStyles();
return (
<Query query={QUERY}>
{({ loading, error, data }) => {
if (error || loading || !data.defaultRepository.userIdentity)
return null;
const user = data.defaultRepository.userIdentity;
return (
<>
<Avatar src={user.avatarUrl}>
{user.displayName.charAt(0).toUpperCase()}
</Avatar>
<div className={classes.displayName}>{user.displayName}</div>
</>
);
}}
</Query>
);
};
export default CurrentIdentity;

View File

@ -0,0 +1,30 @@
import Avatar from '@material-ui/core/Avatar';
import { makeStyles } from '@material-ui/core/styles';
import React from 'react';
import { useCurrentIdentityQuery } from './CurrentIdentity.generated';
const useStyles = makeStyles(theme => ({
displayName: {
marginLeft: theme.spacing(2),
},
}));
const CurrentIdentity = () => {
const classes = useStyles();
const { loading, error, data } = useCurrentIdentityQuery();
if (error || loading || !data?.repository?.userIdentity) return null;
const user = data.repository.userIdentity;
return (
<>
<Avatar src={user.avatarUrl ? user.avatarUrl : undefined}>
{user.displayName.charAt(0).toUpperCase()}
</Avatar>
<div className={classes.displayName}>{user.displayName}</div>
</>
);
};
export default CurrentIdentity;

View File

@ -1,8 +1,9 @@
import Tooltip from '@material-ui/core/Tooltip/Tooltip';
import * as moment from 'moment';
import moment from 'moment';
import React from 'react';
const Date = ({ date }) => (
type Props = { date: string };
const Date = ({ date }: Props) => (
<Tooltip title={moment(date).format('MMMM D, YYYY, h:mm a')}>
<span> {moment(date).fromNow()} </span>
</Tooltip>

8
webui/src/Label.graphql Normal file
View File

@ -0,0 +1,8 @@
fragment Label on Label {
name
color {
R
G
B
}
}

View File

@ -1,25 +1,28 @@
import React from 'react';
import gql from 'graphql-tag';
import { makeStyles } from '@material-ui/styles';
import { common } from '@material-ui/core/colors';
import { makeStyles } from '@material-ui/core/styles';
import {
getContrastRatio,
darken,
} from '@material-ui/core/styles/colorManipulator';
import { common } from '@material-ui/core/colors';
import React from 'react';
import { LabelFragment } from './Label.generated';
import { Color } from './gqlTypes';
// Minimum contrast between the background and the text color
const contrastThreshold = 2.5;
// Guess the text color based on the background color
const getTextColor = background =>
const getTextColor = (background: string) =>
getContrastRatio(background, common.white) >= contrastThreshold
? common.white // White on dark backgrounds
: common.black; // And black on light ones
const _rgb = color => 'rgb(' + color.R + ',' + color.G + ',' + color.B + ')';
const _rgb = (color: Color) =>
'rgb(' + color.R + ',' + color.G + ',' + color.B + ')';
// Create a style object from the label RGB colors
const createStyle = color => ({
const createStyle = (color: Color) => ({
backgroundColor: _rgb(color),
color: getTextColor(_rgb(color)),
borderBottomColor: darken(_rgb(color), 0.2),
@ -30,7 +33,7 @@ const useStyles = makeStyles(theme => ({
...theme.typography.body1,
padding: '1px 6px 0.5px',
fontSize: '0.9em',
fontWeight: '500',
fontWeight: 500,
margin: '0.05em 1px calc(-1.5px + 0.05em)',
borderRadius: '3px',
display: 'inline-block',
@ -39,7 +42,8 @@ const useStyles = makeStyles(theme => ({
},
}));
function Label({ label }) {
type Props = { label: LabelFragment };
function Label({ label }: Props) {
const classes = useStyles();
return (
<span className={classes.label} style={createStyle(label.color)}>
@ -48,15 +52,4 @@ function Label({ label }) {
);
}
Label.fragment = gql`
fragment Label on Label {
name
color {
R
G
B
}
}
`;
export default Label;

14
webui/src/bug/Bug.graphql Normal file
View File

@ -0,0 +1,14 @@
#import "../Label.graphql"
#import "../Author.graphql"
fragment Bug on Bug {
id
humanId
status
title
labels {
...Label
}
createdAt
...authored
}

View File

@ -1,12 +1,14 @@
import { makeStyles } from '@material-ui/styles';
import Typography from '@material-ui/core/Typography/Typography';
import gql from 'graphql-tag';
import { makeStyles } from '@material-ui/core/styles';
import React from 'react';
import Author from '../Author';
import Date from '../Date';
import TimelineQuery from './TimelineQuery';
import Label from '../Label';
import { BugFragment } from './Bug.generated';
import TimelineQuery from './TimelineQuery';
const useStyles = makeStyles(theme => ({
main: {
maxWidth: 800,
@ -51,7 +53,11 @@ const useStyles = makeStyles(theme => ({
},
}));
function Bug({ bug }) {
type Props = {
bug: BugFragment;
};
function Bug({ bug }: Props) {
const classes = useStyles();
return (
<main className={classes.main}>
@ -85,20 +91,4 @@ function Bug({ bug }) {
);
}
Bug.fragment = gql`
fragment Bug on Bug {
id
humanId
status
title
labels {
...Label
}
createdAt
...authored
}
${Label.fragment}
${Author.fragment}
`;
export default Bug;

View File

@ -0,0 +1,9 @@
#import "./Bug.graphql"
query GetBug($id: String!) {
repository {
bug(prefix: $id) {
...Bug
}
}
}

View File

@ -1,30 +0,0 @@
import CircularProgress from '@material-ui/core/CircularProgress';
import gql from 'graphql-tag';
import React from 'react';
import { Query } from 'react-apollo';
import Bug from './Bug';
const QUERY = gql`
query GetBug($id: String!) {
defaultRepository {
bug(prefix: $id) {
...Bug
}
}
}
${Bug.fragment}
`;
const BugQuery = ({ match }) => (
<Query query={QUERY} variables={{ id: match.params.id }}>
{({ loading, error, data }) => {
if (loading) return <CircularProgress />;
if (error) return <p>Error: {error}</p>;
return <Bug bug={data.defaultRepository.bug} />;
}}
</Query>
);
export default BugQuery;

View File

@ -0,0 +1,22 @@
import CircularProgress from '@material-ui/core/CircularProgress';
import React from 'react';
import { RouteComponentProps } from 'react-router-dom';
import Bug from './Bug';
import { useGetBugQuery } from './BugQuery.generated';
type Props = RouteComponentProps<{
id: string;
}>;
const BugQuery: React.FC<Props> = ({ match }: Props) => {
const { loading, error, data } = useGetBugQuery({
variables: { id: match.params.id },
});
if (loading) return <CircularProgress />;
if (error) return <p>Error: {error}</p>;
if (!data?.repository?.bug) return <p>404.</p>;
return <Bug bug={data.repository.bug} />;
};
export default BugQuery;

View File

@ -1,10 +1,12 @@
import { makeStyles } from '@material-ui/styles';
import gql from 'graphql-tag';
import { makeStyles } from '@material-ui/core/styles';
import React from 'react';
import Author from '../Author';
import Date from '../Date';
import Label from '../Label';
import { LabelChangeFragment } from './LabelChangeFragment.generated';
const useStyles = makeStyles(theme => ({
main: {
...theme.typography.body1,
@ -15,7 +17,11 @@ const useStyles = makeStyles(theme => ({
},
}));
function LabelChange({ op }) {
type Props = {
op: LabelChangeFragment;
};
function LabelChange({ op }: Props) {
const { added, removed } = op;
const classes = useStyles();
return (
@ -40,22 +46,4 @@ function LabelChange({ op }) {
);
}
LabelChange.fragment = gql`
fragment LabelChange on TimelineItem {
... on LabelChangeTimelineItem {
date
...authored
added {
...Label
}
removed {
...Label
}
}
}
${Label.fragment}
${Author.fragment}
`;
export default LabelChange;

View File

@ -0,0 +1,13 @@
#import "../Author.graphql"
#import "../Label.graphql"
fragment LabelChange on LabelChangeTimelineItem {
date
...authored
added {
...Label
}
removed {
...Label
}
}

View File

@ -1,11 +1,14 @@
import { makeStyles } from '@material-ui/styles';
import Paper from '@material-ui/core/Paper';
import gql from 'graphql-tag';
import { makeStyles } from '@material-ui/core/styles';
import React from 'react';
import Author from '../Author';
import { Avatar } from '../Author';
import Date from '../Date';
import Content from '../Content';
import Date from '../Date';
import { AddCommentFragment } from './MessageCommentFragment.generated';
import { CreateFragment } from './MessageCreateFragment.generated';
const useStyles = makeStyles(theme => ({
author: {
@ -47,7 +50,11 @@ const useStyles = makeStyles(theme => ({
},
}));
function Message({ op }) {
type Props = {
op: AddCommentFragment | CreateFragment;
};
function Message({ op }: Props) {
const classes = useStyles();
return (
<article className={classes.container}>
@ -69,30 +76,4 @@ function Message({ op }) {
);
}
Message.createFragment = gql`
fragment Create on TimelineItem {
... on CreateTimelineItem {
createdAt
...authored
edited
message
}
}
${Author.fragment}
`;
Message.commentFragment = gql`
fragment AddComment on TimelineItem {
... on AddCommentTimelineItem {
createdAt
...authored
edited
message
}
}
${Author.fragment}
`;
export default Message;

View File

@ -0,0 +1,8 @@
#import "../Author.graphql"
fragment AddComment on AddCommentTimelineItem {
createdAt
...authored
edited
message
}

View File

@ -0,0 +1,8 @@
#import "../Author.graphql"
fragment Create on CreateTimelineItem {
createdAt
...authored
edited
message
}

View File

@ -1,9 +1,11 @@
import { makeStyles } from '@material-ui/styles';
import gql from 'graphql-tag';
import { makeStyles } from '@material-ui/core/styles';
import React from 'react';
import Author from '../Author';
import Date from '../Date';
import { SetStatusFragment } from './SetStatusFragment.generated';
const useStyles = makeStyles(theme => ({
main: {
...theme.typography.body1,
@ -11,7 +13,11 @@ const useStyles = makeStyles(theme => ({
},
}));
function SetStatus({ op }) {
type Props = {
op: SetStatusFragment;
};
function SetStatus({ op }: Props) {
const classes = useStyles();
return (
<div className={classes.main}>
@ -22,16 +28,4 @@ function SetStatus({ op }) {
);
}
SetStatus.fragment = gql`
fragment SetStatus on TimelineItem {
... on SetStatusTimelineItem {
date
...authored
status
}
}
${Author.fragment}
`;
export default SetStatus;

View File

@ -0,0 +1,7 @@
#import "../Author.graphql"
fragment SetStatus on SetStatusTimelineItem {
date
...authored
status
}

View File

@ -1,9 +1,11 @@
import { makeStyles } from '@material-ui/styles';
import gql from 'graphql-tag';
import { makeStyles } from '@material-ui/core/styles';
import React from 'react';
import Author from '../Author';
import Date from '../Date';
import { SetTitleFragment } from './SetTitleFragment.generated';
const useStyles = makeStyles(theme => ({
main: {
...theme.typography.body1,
@ -14,7 +16,11 @@ const useStyles = makeStyles(theme => ({
},
}));
function SetTitle({ op }) {
type Props = {
op: SetTitleFragment;
};
function SetTitle({ op }: Props) {
const classes = useStyles();
return (
<div className={classes.main}>
@ -28,17 +34,4 @@ function SetTitle({ op }) {
);
}
SetTitle.fragment = gql`
fragment SetTitle on TimelineItem {
... on SetTitleTimelineItem {
date
...authored
title
was
}
}
${Author.fragment}
`;
export default SetTitle;

View File

@ -0,0 +1,8 @@
#import "../Author.graphql"
fragment SetTitle on SetTitleTimelineItem {
date
...authored
title
was
}

View File

@ -1,43 +0,0 @@
import { makeStyles } from '@material-ui/styles';
import React from 'react';
import LabelChange from './LabelChange';
import Message from './Message';
import SetStatus from './SetStatus';
import SetTitle from './SetTitle';
const useStyles = makeStyles(theme => ({
main: {
'& > *:not(:last-child)': {
marginBottom: theme.spacing(2),
},
},
}));
const componentMap = {
CreateTimelineItem: Message,
AddCommentTimelineItem: Message,
LabelChangeTimelineItem: LabelChange,
SetTitleTimelineItem: SetTitle,
SetStatusTimelineItem: SetStatus,
};
function Timeline({ ops }) {
const classes = useStyles();
return (
<div className={classes.main}>
{ops.map((op, index) => {
const Component = componentMap[op.__typename];
if (!Component) {
console.warn('unsupported operation type ' + op.__typename);
return null;
}
return <Component key={index} op={op} />;
})}
</div>
);
}
export default Timeline;

View File

@ -0,0 +1,48 @@
import { makeStyles } from '@material-ui/core/styles';
import React from 'react';
import LabelChange from './LabelChange';
import Message from './Message';
import SetStatus from './SetStatus';
import SetTitle from './SetTitle';
import { TimelineItemFragment } from './TimelineQuery.generated';
const useStyles = makeStyles(theme => ({
main: {
'& > *:not(:last-child)': {
marginBottom: theme.spacing(2),
},
},
}));
type Props = {
ops: Array<TimelineItemFragment>;
};
function Timeline({ ops }: Props) {
const classes = useStyles();
return (
<div className={classes.main}>
{ops.map((op, index) => {
switch (op.__typename) {
case 'CreateTimelineItem':
return <Message key={index} op={op} />;
case 'AddCommentTimelineItem':
return <Message key={index} op={op} />;
case 'LabelChangeTimelineItem':
return <LabelChange key={index} op={op} />;
case 'SetTitleTimelineItem':
return <SetTitle key={index} op={op} />;
case 'SetStatusTimelineItem':
return <SetStatus key={index} op={op} />;
}
console.warn('unsupported operation type ' + op.__typename);
return null;
})}
</div>
);
}
export default Timeline;

View File

@ -0,0 +1,39 @@
#import "./MessageCreateFragment.graphql"
#import "./MessageCommentFragment.graphql"
#import "./LabelChangeFragment.graphql"
#import "./SetTitleFragment.graphql"
#import "./SetStatusFragment.graphql"
query Timeline($id: String!, $first: Int = 10, $after: String) {
repository {
bug(prefix: $id) {
timeline(first: $first, after: $after) {
nodes {
...TimelineItem
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
}
fragment TimelineItem on TimelineItem {
... on LabelChangeTimelineItem {
...LabelChange
}
... on SetStatusTimelineItem {
...SetStatus
}
... on SetTitleTimelineItem {
...SetTitle
}
... on AddCommentTimelineItem {
...AddComment
}
... on CreateTimelineItem {
...Create
}
}

View File

@ -1,53 +0,0 @@
import CircularProgress from '@material-ui/core/CircularProgress';
import gql from 'graphql-tag';
import React from 'react';
import { Query } from 'react-apollo';
import LabelChange from './LabelChange';
import SetStatus from './SetStatus';
import SetTitle from './SetTitle';
import Timeline from './Timeline';
import Message from './Message';
const QUERY = gql`
query($id: String!, $first: Int = 10, $after: String) {
defaultRepository {
bug(prefix: $id) {
timeline(first: $first, after: $after) {
nodes {
...LabelChange
...SetStatus
...SetTitle
...AddComment
...Create
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
}
${Message.createFragment}
${Message.commentFragment}
${LabelChange.fragment}
${SetTitle.fragment}
${SetStatus.fragment}
`;
const TimelineQuery = ({ id }) => (
<Query query={QUERY} variables={{ id, first: 100 }}>
{({ loading, error, data, fetchMore }) => {
if (loading) return <CircularProgress />;
if (error) return <p>Error: {error}</p>;
return (
<Timeline
ops={data.defaultRepository.bug.timeline.nodes}
fetchMore={fetchMore}
/>
);
}}
</Query>
);
export default TimelineQuery;

View File

@ -0,0 +1,30 @@
import CircularProgress from '@material-ui/core/CircularProgress';
import React from 'react';
import Timeline from './Timeline';
import { useTimelineQuery } from './TimelineQuery.generated';
type Props = {
id: string;
};
const TimelineQuery = ({ id }: Props) => {
const { loading, error, data } = useTimelineQuery({
variables: {
id,
first: 100,
},
});
if (loading) return <CircularProgress />;
if (error) return <p>Error: {error}</p>;
const nodes = data?.repository?.bug?.timeline.nodes;
if (!nodes) {
return null;
}
return <Timeline ops={nodes} />;
};
export default TimelineQuery;

View File

@ -1,5 +1,5 @@
import ThemeProvider from '@material-ui/styles/ThemeProvider';
import { createMuiTheme } from '@material-ui/core/styles';
import ThemeProvider from '@material-ui/styles/ThemeProvider';
import ApolloClient from 'apollo-boost';
import {
IntrospectionFragmentMatcher,
@ -10,8 +10,8 @@ import { ApolloProvider } from 'react-apollo';
import ReactDOM from 'react-dom';
import { BrowserRouter } from 'react-router-dom';
import introspectionQueryResultData from './fragmentTypes';
import App from './App';
import introspectionQueryResultData from './fragmentTypes';
const theme = createMuiTheme();

View File

@ -0,0 +1,14 @@
#import "../Author.graphql"
#import "../Label.graphql"
fragment BugRow on Bug {
id
humanId
title
status
createdAt
labels {
...Label
}
...authored
}

View File

@ -1,36 +1,43 @@
import { makeStyles } from '@material-ui/styles';
import TableCell from '@material-ui/core/TableCell/TableCell';
import TableRow from '@material-ui/core/TableRow/TableRow';
import Tooltip from '@material-ui/core/Tooltip/Tooltip';
import ErrorOutline from '@material-ui/icons/ErrorOutline';
import { makeStyles } from '@material-ui/core/styles';
import CheckCircleOutline from '@material-ui/icons/CheckCircleOutline';
import gql from 'graphql-tag';
import ErrorOutline from '@material-ui/icons/ErrorOutline';
import React from 'react';
import { Link } from 'react-router-dom';
import Date from '../Date';
import Label from '../Label';
import Author from '../Author';
import { Status } from '../gqlTypes';
const Open = ({ className }) => (
import { BugRowFragment } from './BugRow.generated';
type OpenClosedProps = { className: string };
const Open = ({ className }: OpenClosedProps) => (
<Tooltip title="Open">
<ErrorOutline htmlColor="#28a745" className={className} />
</Tooltip>
);
const Closed = ({ className }) => (
const Closed = ({ className }: OpenClosedProps) => (
<Tooltip title="Closed">
<CheckCircleOutline htmlColor="#cb2431" className={className} />
</Tooltip>
);
const Status = ({ status, className }) => {
type StatusProps = { className: string; status: Status };
const BugStatus: React.FC<StatusProps> = ({
status,
className,
}: StatusProps) => {
switch (status) {
case 'OPEN':
return <Open className={className} />;
case 'CLOSED':
return <Closed className={className} />;
default:
return 'unknown status ' + status;
return <p>{'unknown status ' + status}</p>;
}
};
@ -57,7 +64,6 @@ const useStyles = makeStyles(theme => ({
fontWeight: 500,
},
details: {
...theme.typography.textSecondary,
lineHeight: '1.5rem',
color: theme.palette.text.secondary,
},
@ -69,12 +75,16 @@ const useStyles = makeStyles(theme => ({
},
}));
function BugRow({ bug }) {
type Props = {
bug: BugRowFragment;
};
function BugRow({ bug }: Props) {
const classes = useStyles();
return (
<TableRow hover>
<TableCell className={classes.cell}>
<Status status={bug.status} className={classes.status} />
<BugStatus status={bug.status} className={classes.status} />
<div className={classes.expand}>
<Link to={'bug/' + bug.humanId}>
<div className={classes.expand}>
@ -99,21 +109,4 @@ function BugRow({ bug }) {
);
}
BugRow.fragment = gql`
fragment BugRow on Bug {
id
humanId
title
status
createdAt
labels {
...Label
}
...authored
}
${Label.fragment}
${Author.fragment}
`;
export default BugRow;

View File

@ -1,13 +1,18 @@
import React, { useState, useRef } from 'react';
import { Link } from 'react-router-dom';
import { makeStyles } from '@material-ui/styles';
import Menu from '@material-ui/core/Menu';
import MenuItem from '@material-ui/core/MenuItem';
import { SvgIconProps } from '@material-ui/core/SvgIcon';
import { makeStyles } from '@material-ui/core/styles';
import ArrowDropDown from '@material-ui/icons/ArrowDropDown';
import clsx from 'clsx';
import { LocationDescriptor } from 'history';
import React, { useState, useRef } from 'react';
import { Link } from 'react-router-dom';
function parse(query) {
export type Query = { [key: string]: Array<string> };
function parse(query: string): Query {
// TODO: extract the rest of the query?
const params = {};
const params: Query = {};
// TODO: support escaping without quotes
const re = /(\w+):([A-Za-z0-9-]+|(["'])(([^\3]|\\.)*)\3)+/g;
@ -29,7 +34,7 @@ function parse(query) {
return params;
}
function quote(value) {
function quote(value: string): string {
const hasSingle = value.includes("'");
const hasDouble = value.includes('"');
const hasSpaces = value.includes(' ');
@ -49,19 +54,19 @@ function quote(value) {
return `"${value}"`;
}
function stringify(params) {
const parts = Object.entries(params).map(([key, values]) => {
function stringify(params: Query): string {
const parts: string[][] = Object.entries(params).map(([key, values]) => {
return values.map(value => `${key}:${quote(value)}`);
});
return [].concat(...parts).join(' ');
return new Array<string>().concat(...parts).join(' ');
}
const useStyles = makeStyles(theme => ({
element: {
...theme.typography.body2,
color: ({ active }) => (active ? '#333' : '#444'),
color: '#444',
padding: theme.spacing(0, 1),
fontWeight: ({ active }) => (active ? 600 : 400),
fontWeight: 400,
textDecoration: 'none',
display: 'flex',
background: 'none',
@ -69,21 +74,51 @@ const useStyles = makeStyles(theme => ({
},
itemActive: {
fontWeight: 600,
color: '#333',
},
icon: {
paddingRight: theme.spacing(0.5),
},
}));
function Dropdown({ children, dropdown, itemActive, to, ...props }) {
type DropdownTuple = [string, string];
type FilterDropdownProps = {
children: React.ReactNode;
dropdown: DropdownTuple[];
itemActive: (key: string) => boolean;
icon?: React.ComponentType<SvgIconProps>;
to: (key: string) => LocationDescriptor;
} & React.ButtonHTMLAttributes<HTMLButtonElement>;
function FilterDropdown({
children,
dropdown,
itemActive,
icon: Icon,
to,
...props
}: FilterDropdownProps) {
const [open, setOpen] = useState(false);
const buttonRef = useRef();
const classes = useStyles();
const buttonRef = useRef<HTMLButtonElement>(null);
const classes = useStyles({ active: false });
const content = (
<>
{Icon && <Icon fontSize="small" classes={{ root: classes.icon }} />}
<div>{children}</div>
</>
);
return (
<>
<button ref={buttonRef} onClick={() => setOpen(!open)} {...props}>
{children}
<button
ref={buttonRef}
onClick={() => setOpen(!open)}
className={classes.element}
{...props}
>
{content}
<ArrowDropDown fontSize="small" />
</button>
<Menu
@ -104,7 +139,7 @@ function Dropdown({ children, dropdown, itemActive, to, ...props }) {
<MenuItem
component={Link}
to={to(key)}
className={itemActive(key) ? classes.itemActive : null}
className={itemActive(key) ? classes.itemActive : undefined}
onClick={() => setOpen(false)}
key={key}
>
@ -116,8 +151,14 @@ function Dropdown({ children, dropdown, itemActive, to, ...props }) {
);
}
function Filter({ active, to, children, icon: Icon, dropdown, ...props }) {
const classes = useStyles({ active });
export type FilterProps = {
active: boolean;
to: LocationDescriptor;
icon?: React.ComponentType<SvgIconProps>;
children: React.ReactNode;
};
function Filter({ active, to, children, icon: Icon }: FilterProps) {
const classes = useStyles();
const content = (
<>
@ -126,29 +167,23 @@ function Filter({ active, to, children, icon: Icon, dropdown, ...props }) {
</>
);
if (dropdown) {
return (
<Dropdown
{...props}
to={to}
dropdown={dropdown}
className={classes.element}
>
{content}
</Dropdown>
);
}
if (to) {
return (
<Link to={to} {...props} className={classes.element}>
<Link
to={to}
className={clsx(classes.element, active && classes.itemActive)}
>
{content}
</Link>
);
}
return <div className={classes.element}>{content}</div>;
return (
<div className={clsx(classes.element, active && classes.itemActive)}>
{content}
</div>
);
}
export default Filter;
export { parse, stringify, quote };
export { parse, stringify, quote, FilterDropdown, Filter };

View File

@ -0,0 +1,7 @@
query BugCount($query: String) {
repository {
bugs: allBugs(query: $query) {
totalCount
}
}
}

View File

@ -1,16 +1,20 @@
import { makeStyles } from '@material-ui/styles';
import { useQuery } from '@apollo/react-hooks';
import gql from 'graphql-tag';
import React from 'react';
import { pipe } from '@arrows/composition';
import Toolbar from '@material-ui/core/Toolbar';
import ErrorOutline from '@material-ui/icons/ErrorOutline';
import { makeStyles } from '@material-ui/core/styles';
import CheckCircleOutline from '@material-ui/icons/CheckCircleOutline';
import Filter, { parse, stringify } from './Filter';
import ErrorOutline from '@material-ui/icons/ErrorOutline';
import { LocationDescriptor } from 'history';
import React from 'react';
// simple pipe operator
// pipe(o, f, g, h) <=> h(g(f(o)))
// TODO: move this out?
const pipe = (initial, ...funcs) => funcs.reduce((acc, f) => f(acc), initial);
import {
FilterDropdown,
FilterProps,
Filter,
parse,
stringify,
Query,
} from './Filter';
import { useBugCountQuery } from './FilterToolbar.generated';
const useStyles = makeStyles(theme => ({
toolbar: {
@ -25,27 +29,21 @@ const useStyles = makeStyles(theme => ({
},
}));
const BUG_COUNT_QUERY = gql`
query($query: String) {
defaultRepository {
bugs: allBugs(query: $query) {
totalCount
}
}
}
`;
// This prepends the filter text with a count
function CountingFilter({ query, children, ...props }) {
const { data, loading, error } = useQuery(BUG_COUNT_QUERY, {
type CountingFilterProps = {
query: string;
children: React.ReactNode;
} & FilterProps;
function CountingFilter({ query, children, ...props }: CountingFilterProps) {
const { data, loading, error } = useBugCountQuery({
variables: { query },
});
var prefix;
if (loading) prefix = '...';
else if (error) prefix = '???';
else if (error || !data?.repository) prefix = '???';
// TODO: better prefixes & error handling
else prefix = data.defaultRepository.bugs.totalCount;
else prefix = data.repository.bugs.totalCount;
return (
<Filter {...props}>
@ -54,18 +52,26 @@ function CountingFilter({ query, children, ...props }) {
);
}
function FilterToolbar({ query, queryLocation }) {
type Props = {
query: string;
queryLocation: (query: string) => LocationDescriptor;
};
function FilterToolbar({ query, queryLocation }: Props) {
const classes = useStyles();
const params = parse(query);
const params: Query = parse(query);
const hasKey = key => params[key] && params[key].length > 0;
const hasValue = (key, value) => hasKey(key) && params[key].includes(value);
const loc = params => pipe(params, stringify, queryLocation);
const replaceParam = (key, value) => params => ({
const hasKey = (key: string): boolean =>
params[key] && params[key].length > 0;
const hasValue = (key: string, value: string): boolean =>
hasKey(key) && params[key].includes(value);
const loc = pipe(stringify, queryLocation);
const replaceParam = (key: string, value: string) => (
params: Query
): Query => ({
...params,
[key]: [value],
});
const clearParam = key => params => ({
const clearParam = (key: string) => (params: Query): Query => ({
...params,
[key]: [],
});
@ -76,12 +82,11 @@ function FilterToolbar({ query, queryLocation }) {
<CountingFilter
active={hasValue('status', 'open')}
query={pipe(
params,
replaceParam('status', 'open'),
clearParam('sort'),
stringify
)}
to={pipe(params, replaceParam('status', 'open'), loc)}
)(params)}
to={pipe(replaceParam('status', 'open'), loc)(params)}
icon={ErrorOutline}
>
open
@ -89,12 +94,11 @@ function FilterToolbar({ query, queryLocation }) {
<CountingFilter
active={hasValue('status', 'closed')}
query={pipe(
params,
replaceParam('status', 'closed'),
clearParam('sort'),
stringify
)}
to={pipe(params, replaceParam('status', 'closed'), loc)}
)(params)}
to={pipe(replaceParam('status', 'closed'), loc)(params)}
icon={CheckCircleOutline}
>
closed
@ -104,7 +108,7 @@ function FilterToolbar({ query, queryLocation }) {
<Filter active={hasKey('author')}>Author</Filter>
<Filter active={hasKey('label')}>Label</Filter>
*/}
<Filter
<FilterDropdown
dropdown={[
['id', 'ID'],
['creation', 'Newest'],
@ -112,12 +116,11 @@ function FilterToolbar({ query, queryLocation }) {
['edit', 'Recently updated'],
['edit-asc', 'Least recently updated'],
]}
active={hasKey('sort')}
itemActive={key => hasValue('sort', key)}
to={key => pipe(params, replaceParam('sort', key), loc)}
to={key => pipe(replaceParam('sort', key), loc)(params)}
>
Sort
</Filter>
</FilterDropdown>
</Toolbar>
);
}

View File

@ -1,9 +1,12 @@
import Table from '@material-ui/core/Table/Table';
import TableBody from '@material-ui/core/TableBody/TableBody';
import React from 'react';
import BugRow from './BugRow';
function List({ bugs }) {
import BugRow from './BugRow';
import { BugListFragment } from './ListQuery.generated';
type Props = { bugs: BugListFragment };
function List({ bugs }: Props) {
return (
<Table>
<TableBody>

View File

@ -0,0 +1,37 @@
#import "./BugRow.graphql"
query ListBugs(
$first: Int
$last: Int
$after: String
$before: String
$query: String
) {
repository {
bugs: allBugs(
first: $first
last: $last
after: $after
before: $before
query: $query
) {
...BugList
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
}
}
}
fragment BugList on BugConnection {
totalCount
edges {
cursor
node {
...BugRow
}
}
}

View File

@ -1,20 +1,21 @@
import { fade, makeStyles } from '@material-ui/core/styles';
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 ErrorOutline from '@material-ui/icons/ErrorOutline';
import KeyboardArrowLeft from '@material-ui/icons/KeyboardArrowLeft';
import KeyboardArrowRight from '@material-ui/icons/KeyboardArrowRight';
import ErrorOutline from '@material-ui/icons/ErrorOutline';
import Paper from '@material-ui/core/Paper';
import InputBase from '@material-ui/core/InputBase';
import Skeleton from '@material-ui/lab/Skeleton';
import gql from 'graphql-tag';
import { ApolloError } from 'apollo-boost';
import React, { useState, useEffect, useRef } from 'react';
import { useQuery } from '@apollo/react-hooks';
import { useLocation, useHistory, Link } from 'react-router-dom';
import BugRow from './BugRow';
import List from './List';
import FilterToolbar from './FilterToolbar';
const useStyles = makeStyles(theme => ({
import FilterToolbar from './FilterToolbar';
import List from './List';
import { useListBugsQuery } from './ListQuery.generated';
type StylesProps = { searching?: boolean };
const useStyles = makeStyles<Theme, StylesProps>(theme => ({
main: {
maxWidth: 800,
margin: 'auto',
@ -46,7 +47,11 @@ const useStyles = makeStyles(theme => ({
backgroundColor: fade(theme.palette.primary.main, 0.05),
padding: theme.spacing(0, 1),
width: ({ searching }) => (searching ? '20rem' : '15rem'),
transition: theme.transitions.create(),
transition: theme.transitions.create([
'width',
'borderColor',
'backgroundColor',
]),
},
searchFocused: {
borderColor: fade(theme.palette.primary.main, 0.4),
@ -91,51 +96,21 @@ const useStyles = makeStyles(theme => ({
},
}));
const QUERY = gql`
query(
$first: Int
$last: Int
$after: String
$before: String
$query: String
) {
defaultRepository {
bugs: allBugs(
first: $first
last: $last
after: $after
before: $before
query: $query
) {
totalCount
edges {
cursor
node {
...BugRow
}
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
}
}
}
${BugRow.fragment}
`;
function editParams(params, callback) {
function editParams(
params: URLSearchParams,
callback: (params: URLSearchParams) => void
) {
const cloned = new URLSearchParams(params.toString());
callback(cloned);
return cloned;
}
// TODO: factor this out
const Placeholder = ({ count }) => {
const classes = useStyles();
type PlaceholderProps = { count: number };
const Placeholder: React.FC<PlaceholderProps> = ({
count,
}: PlaceholderProps) => {
const classes = useStyles({});
return (
<>
{new Array(count).fill(null).map((_, i) => (
@ -158,7 +133,7 @@ const Placeholder = ({ count }) => {
// TODO: factor this out
const NoBug = () => {
const classes = useStyles();
const classes = useStyles({});
return (
<div className={classes.message}>
<ErrorOutline fontSize="large" />
@ -167,8 +142,9 @@ const NoBug = () => {
);
};
const Error = ({ error }) => {
const classes = useStyles();
type ErrorProps = { error: ApolloError };
const Error: React.FC<ErrorProps> = ({ error }: ErrorProps) => {
const classes = useStyles({});
return (
<div className={[classes.errorBox, classes.message].join(' ')}>
<ErrorOutline fontSize="large" />
@ -194,7 +170,7 @@ function ListQuery() {
const classes = useStyles({ searching: !!input });
// TODO is this the right way to do it?
const lastQuery = useRef();
const lastQuery = useRef<string | null>(null);
useEffect(() => {
if (query !== lastQuery.current) {
setInput(query);
@ -202,9 +178,10 @@ function ListQuery() {
lastQuery.current = query;
}, [query, input, lastQuery]);
const num = (param: string | null) => (param ? parseInt(param) : null);
const page = {
first: params.get('first'),
last: params.get('last'),
first: num(params.get('first')),
last: num(params.get('last')),
after: params.get('after'),
before: params.get('before'),
};
@ -214,9 +191,9 @@ function ListQuery() {
page.first = 10;
}
const perPage = page.first || page.last;
const perPage = (page.first || page.last || 10).toString();
const { loading, error, data } = useQuery(QUERY, {
const { loading, error, data } = useListBugsQuery({
variables: {
...page,
query,
@ -225,34 +202,34 @@ function ListQuery() {
let nextPage = null;
let previousPage = null;
let hasNextPage = false;
let hasPreviousPage = false;
let count = 0;
if (!loading && !error && data.defaultRepository.bugs) {
const bugs = data.defaultRepository.bugs;
hasNextPage = bugs.pageInfo.hasNextPage;
hasPreviousPage = bugs.pageInfo.hasPreviousPage;
if (!loading && !error && data?.repository?.bugs) {
const bugs = data.repository.bugs;
count = bugs.totalCount;
// This computes the URL for the next page
nextPage = {
...location,
search: editParams(params, p => {
p.delete('last');
p.delete('before');
p.set('first', perPage);
p.set('after', bugs.pageInfo.endCursor);
}).toString(),
};
if (bugs.pageInfo.hasNextPage) {
nextPage = {
...location,
search: editParams(params, p => {
p.delete('last');
p.delete('before');
p.set('first', perPage);
p.set('after', bugs.pageInfo.endCursor);
}).toString(),
};
}
// and this for the previous page
previousPage = {
...location,
search: editParams(params, p => {
p.delete('first');
p.delete('after');
p.set('last', perPage);
p.set('before', bugs.pageInfo.startCursor);
}).toString(),
};
if (bugs.pageInfo.hasPreviousPage) {
previousPage = {
...location,
search: editParams(params, p => {
p.delete('first');
p.delete('after');
p.set('last', perPage);
p.set('before', bugs.pageInfo.startCursor);
}).toString(),
};
}
}
// Prepare params without paging for editing filters
@ -263,7 +240,7 @@ function ListQuery() {
p.delete('after');
});
// Returns a new location with the `q` param edited
const queryLocation = query => ({
const queryLocation = (query: string) => ({
...location,
search: editParams(paramsWithoutPaging, p => p.set('q', query)).toString(),
});
@ -273,8 +250,8 @@ function ListQuery() {
content = <Placeholder count={10} />;
} else if (error) {
content = <Error error={error} />;
} else {
const bugs = data.defaultRepository.bugs;
} else if (data?.repository) {
const bugs = data.repository.bugs;
if (bugs.totalCount === 0) {
content = <NoBug />;
@ -283,7 +260,7 @@ function ListQuery() {
}
}
const formSubmit = e => {
const formSubmit = (e: React.FormEvent) => {
e.preventDefault();
history.push(queryLocation(input));
};
@ -296,7 +273,7 @@ function ListQuery() {
<InputBase
placeholder="Filter"
value={input}
onInput={e => setInput(e.target.value)}
onInput={(e: any) => setInput(e.target.value)}
classes={{
root: classes.search,
focused: classes.searchFocused,
@ -310,21 +287,25 @@ function ListQuery() {
<FilterToolbar query={query} queryLocation={queryLocation} />
{content}
<div className={classes.pagination}>
<IconButton
component={hasPreviousPage ? Link : 'button'}
to={previousPage}
disabled={!hasPreviousPage}
>
<KeyboardArrowLeft />
</IconButton>
{previousPage ? (
<IconButton component={Link} to={previousPage}>
<KeyboardArrowLeft />
</IconButton>
) : (
<IconButton disabled>
<KeyboardArrowLeft />
</IconButton>
)}
<div>{loading ? 'Loading' : `Total: ${count}`}</div>
<IconButton
component={hasNextPage ? Link : 'button'}
to={nextPage}
disabled={!hasNextPage}
>
<KeyboardArrowRight />
</IconButton>
{nextPage ? (
<IconButton component={Link} to={nextPage}>
<KeyboardArrowRight />
</IconButton>
) : (
<IconButton disabled>
<KeyboardArrowRight />
</IconButton>
)}
</div>
</Paper>
);

1
webui/src/react-app-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="react-scripts" />

View File

@ -1,5 +1,5 @@
import React from 'react';
import { makeStyles } from '@material-ui/styles';
import React from 'react';
const useStyles = makeStyles({
tag: {
@ -7,7 +7,10 @@ const useStyles = makeStyles({
},
});
const ImageTag = ({ alt, ...props }) => {
const ImageTag = ({
alt,
...props
}: React.ImgHTMLAttributes<HTMLImageElement>) => {
const classes = useStyles();
return (
<a href={props.src} target="_blank" rel="noopener noreferrer nofollow">

View File

@ -1,5 +1,5 @@
import React from 'react';
import { makeStyles } from '@material-ui/styles';
import React from 'react';
const useStyles = makeStyles({
tag: {
@ -8,7 +8,7 @@ const useStyles = makeStyles({
},
});
const PreTag = props => {
const PreTag = (props: React.HTMLProps<HTMLPreElement>) => {
const classes = useStyles();
return <pre className={classes.tag} {...props}></pre>;
};

20
webui/tsconfig.json Normal file
View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react",
"typeRoots": ["node_modules/@types/", "types/"]
},
"include": ["src"]
}

6
webui/types/remark-html/index.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
declare module 'remark-html' {
import { Plugin } from 'unified';
const plugin: Plugin;
export default plugin;
}

6
webui/types/remark-react/index.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
declare module 'remark-react' {
import { Plugin } from 'unified';
const plugin: Plugin;
export default plugin;
}