diff --git a/bridge/bridges.go b/bridge/bridges.go index d74a58fa7b2967c6e4aa834567f8c8e550c86ec4..b72abb32a4c4207a76554c592cd4257a45d188ab 100644 --- a/bridge/bridges.go +++ b/bridge/bridges.go @@ -3,6 +3,7 @@ package bridge import ( "github.com/MichaelMure/git-bug/bridge/core" + "github.com/MichaelMure/git-bug/bridge/gitea" "github.com/MichaelMure/git-bug/bridge/github" "github.com/MichaelMure/git-bug/bridge/gitlab" "github.com/MichaelMure/git-bug/bridge/jira" @@ -12,6 +13,7 @@ import ( ) func init() { + core.Register(&gitea.Gitea{}) core.Register(&github.Github{}) core.Register(&gitlab.Gitlab{}) core.Register(&launchpad.Launchpad{}) diff --git a/bridge/core/params.go b/bridge/core/params.go index e398b81a517cc77725f0c535cea4d1d840b62408..d1d378570a48d1058a8dcbaa8a9fc26d5237b2f7 100644 --- a/bridge/core/params.go +++ b/bridge/core/params.go @@ -5,13 +5,13 @@ import "fmt" // BridgeParams holds parameters to simplify the bridge configuration without // having to make terminal prompts. type BridgeParams struct { - URL string // complete URL of a repo (Github, Gitlab, , Launchpad) - BaseURL string // base URL for self-hosted instance ( Gitlab, Jira, ) - Login string // username for the passed credential (Github, Gitlab, Jira, ) - CredPrefix string // ID prefix of the credential to use (Github, Gitlab, Jira, ) - TokenRaw string // pre-existing token to use (Github, Gitlab, , ) - Owner string // owner of the repo (Github, , , ) - Project string // name of the repo or project key (Github, , Jira, Launchpad) + URL string // complete URL of a repo (Gitea, Github, Gitlab, , Launchpad) + BaseURL string // base URL for self-hosted instance ( , , Gitlab, Jira, ) + Login string // username for the passed credential (Gitea, Github, Gitlab, Jira, ) + CredPrefix string // ID prefix of the credential to use (Gitea, Github, Gitlab, Jira, ) + TokenRaw string // pre-existing token to use (Gitea, Github, Gitlab, , ) + Owner string // owner of the repo ( , Github, , , ) + Project string // name of the repo or project key ( , Github, , Jira, Launchpad) } func (BridgeParams) fieldWarning(field string, target string) string { diff --git a/bridge/gitea/config.go b/bridge/gitea/config.go new file mode 100644 index 0000000000000000000000000000000000000000..7a9f581b2fee2c1072c0d5930270dd811c3eef25 --- /dev/null +++ b/bridge/gitea/config.go @@ -0,0 +1,305 @@ +package gitea + +import ( + "context" + "fmt" + "path" + "regexp" + "sort" + "strings" + + "github.com/pkg/errors" + + "github.com/MichaelMure/git-bug/bridge/core" + "github.com/MichaelMure/git-bug/bridge/core/auth" + "github.com/MichaelMure/git-bug/cache" + "github.com/MichaelMure/git-bug/commands/input" + "github.com/MichaelMure/git-bug/repository" +) + +var ( + ErrBadProjectURL = errors.New("bad project url") +) + +func (g *Gitea) ValidParams() map[string]interface{} { + return map[string]interface{}{ + "URL": nil, + "Login": nil, + "CredPrefix": nil, + "TokenRaw": nil, + } +} + +func (g *Gitea) Configure(repo *cache.RepoCache, params core.BridgeParams, interactive bool) (core.Configuration, error) { + var err error + var baseURL, owner, project string + + // get project url + switch { + case params.URL != "": + baseURL, owner, project, err = splitURL(params.URL) + if err != nil { + return nil, err + } + default: + // terminal prompt + if !interactive { + return nil, fmt.Errorf("Non-interactive-mode is active. Please specify the gitea project URL via the --url option.") + } + baseURL, owner, project, err = promptURL(repo) + if err != nil { + return nil, errors.Wrap(err, "url prompt") + } + } + + var login string + var cred auth.Credential + + switch { + case params.CredPrefix != "": + cred, err = auth.LoadWithPrefix(repo, params.CredPrefix) + 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(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: + if params.Login == "" { + if !interactive { + return nil, fmt.Errorf("Non-interactive-mode is active. Please specify the login name via the --login option.") + } + // TODO: validate username + login, err = input.Prompt("Gitea login", "login", input.Required) + } else { + // TODO: validate username + login = params.Login + } + if err != nil { + return nil, err + } + if !interactive { + return nil, fmt.Errorf("Non-interactive-mode is active. Please specify the access token via the --token option.") + } + cred, err = promptTokenOptions(repo, login, baseURL) + if err != nil { + return nil, err + } + } + + token, ok := cred.(*auth.Token) + if !ok { + return nil, fmt.Errorf("the Gitea bridge only handle token credentials") + } + + // verify access to the repository with token + _, err = validateProject(baseURL, owner, project, token) + if err != nil { + return nil, errors.Wrap(err, "project validation") + } + + conf := make(core.Configuration) + conf[core.ConfigKeyTarget] = target + conf[confKeyBaseURL] = baseURL + conf[confKeyOwner] = owner + conf[confKeyProject] = project + conf[confKeyDefaultLogin] = login + + err = g.ValidateConfig(conf) + if err != nil { + return nil, err + } + + // don't forget to store the now known valid token + if !auth.IdExist(repo, cred.ID()) { + err = auth.Store(repo, cred) + if err != nil { + return nil, err + } + } + + return conf, core.FinishConfig(repo, metaKeyGiteaLogin, login) +} + +func (g *Gitea) ValidateConfig(conf core.Configuration) error { + if v, ok := conf[core.ConfigKeyTarget]; !ok { + return fmt.Errorf("missing %s key", core.ConfigKeyTarget) + } else if v != target { + return fmt.Errorf("unexpected target name: %v", v) + } + if _, ok := conf[confKeyBaseURL]; !ok { + return fmt.Errorf("missing %s key", confKeyBaseURL) + } + if _, ok := conf[confKeyOwner]; !ok { + return fmt.Errorf("missing %s key", confKeyOwner) + } + if _, ok := conf[confKeyProject]; !ok { + return fmt.Errorf("missing %s key", confKeyProject) + } + if _, ok := conf[confKeyDefaultLogin]; !ok { + return fmt.Errorf("missing %s key", confKeyDefaultLogin) + } + + return nil +} + +func promptTokenOptions(repo repository.RepoKeyring, login, baseURL string) (auth.Credential, error) { + 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 + } + + cred, index, err := input.PromptCredential(target, "token", creds, []string{ + "enter my token", + }) + switch { + case err != nil: + return nil, err + case cred != nil: + return cred, nil + case index == 0: + return promptToken(baseURL) + default: + panic("missed case") + } +} + +func promptToken(baseURL string) (*auth.Token, error) { + fmt.Printf("You can generate a new token by visiting %s.\n", path.Join(baseURL, "user/settings/applications")) + fmt.Println() + + re := regexp.MustCompile(`^[a-z0-9]{40}$`) + + var login string + + validator := func(name string, value string) (complaint string, err error) { + if !re.MatchString(value) { + return "token has incorrect format", nil + } + login, err = getLoginFromToken(baseURL, auth.NewToken(target, value)) + if err != nil { + return fmt.Sprintf("token is invalid: %v", err), nil + } + return "", nil + } + + 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) + token.SetMetadata(auth.MetaKeyBaseURL, baseURL) + + return token, nil +} + +func promptURL(repo repository.RepoCommon) (string, string, string, error) { + validRemotes, err := getRemoteURLs(repo) + if err != nil { + return "", "", "", err + } + + validator := func(name, value string) (string, error) { + _, _, _, err := splitURL(value) + if err != nil { + return err.Error(), nil + } + return "", nil + } + + url, err := input.PromptURLWithRemote("Gitea project URL", "URL", validRemotes, input.Required, input.IsURL, validator) + if err != nil { + return "", "", "", err + } + + return splitURL(url) +} + +func splitURL(url string) (baseURL, owner, project string, err error) { + cleanURL := strings.TrimSuffix(url, ".git") + + re := regexp.MustCompile(`(.*)/([a-zA-Z0-9\-_.]+)/([a-zA-Z0-9\-_.]+)$`) + + res := re.FindStringSubmatch(cleanURL) + if res == nil { + return "", "", "", ErrBadProjectURL + } + + baseURL = res[1] + owner = res[2] + project = res[3] + return +} + +func getRemoteURLs(repo repository.RepoCommon) ([]string, error) { + remotes, err := repo.GetRemotes() + if err != nil { + return nil, err + } + + urls := make([]string, 0, len(remotes)) + for _, url := range remotes { + urls = append(urls, url) + } + + sort.Strings(urls) + + return urls, nil +} + +func validateProject(baseURL, owner, project string, token *auth.Token) (bool, error) { + client, err := buildClient(baseURL, token) + if err != nil { + return false, err + } + + ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout) + defer cancel() + client.SetContext(ctx) + + _, _, err = client.GetRepo(owner, project) + if err != nil { + return false, errors.Wrap(err, "wrong token scope or non-existent project") + } + + return true, nil +} + +func getLoginFromToken(baseURL string, token *auth.Token) (string, error) { + client, err := buildClient(baseURL, token) + if err != nil { + return "", err + } + + ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout) + defer cancel() + client.SetContext(ctx) + + user, _, err := client.GetMyUserInfo() + if err != nil { + return "", err + } + if user.UserName == "" { + return "", fmt.Errorf("gitea say username is empty") + } + + return user.UserName, nil +} diff --git a/bridge/gitea/export.go b/bridge/gitea/export.go new file mode 100644 index 0000000000000000000000000000000000000000..75bc2a2bd071863336f0d879625e24d0bd868ee7 --- /dev/null +++ b/bridge/gitea/export.go @@ -0,0 +1,29 @@ +package gitea + +import ( + "context" + "syscall" + "time" + + "github.com/pkg/errors" + + "github.com/MichaelMure/git-bug/bridge/core" + "github.com/MichaelMure/git-bug/cache" +) + +var ( + ErrMissingIdentityToken = errors.New("missing identity token") +) + +// giteaExporter implement the Exporter interface +type giteaExporter struct { +} + +// Init . +func (ge *giteaExporter) Init(_ context.Context, repo *cache.RepoCache, conf core.Configuration) error { + return syscall.ENOSYS +} + +func (ge *giteaExporter) ExportAll(ctx context.Context, repo *cache.RepoCache, since time.Time) (<-chan core.ExportResult, error) { + return nil, syscall.ENOSYS +} diff --git a/bridge/gitea/gitea.go b/bridge/gitea/gitea.go new file mode 100644 index 0000000000000000000000000000000000000000..d64e1d919172021bd9bec599dc8a2db7c02c021d --- /dev/null +++ b/bridge/gitea/gitea.go @@ -0,0 +1,57 @@ +package gitea + +import ( + "time" + + "code.gitea.io/sdk/gitea" + + "github.com/MichaelMure/git-bug/bridge/core" + "github.com/MichaelMure/git-bug/bridge/core/auth" +) + +const ( + target = "gitea-preview" + + metaKeyGiteaID = "gitea-id" + metaKeyGiteaLogin = "gitea-login" + metaKeyGiteaOwner = "gitea-owner" + metaKeyGiteaProject = "gitea-project" + metaKeyGiteaBaseURL = "gitea-base-url" + + confKeyOwner = "owner" + confKeyProject = "project" + confKeyBaseURL = "base-url" + confKeyDefaultLogin = "default-login" + + defaultTimeout = 60 * time.Second +) + +var _ core.BridgeImpl = &Gitea{} + +type Gitea struct{} + +func (Gitea) Target() string { + return target +} + +func (g *Gitea) LoginMetaKey() string { + return metaKeyGiteaLogin +} + +func (Gitea) NewImporter() core.Importer { + return &giteaImporter{} +} + +func (Gitea) NewExporter() core.Exporter { + return nil + // return &giteaExporter{} +} + +func buildClient(baseURL string, token *auth.Token) (*gitea.Client, error) { + giteaClient, err := gitea.NewClient(baseURL, gitea.SetToken(token.Value)) + if err != nil { + return nil, err + } + + return giteaClient, nil +} diff --git a/bridge/gitea/import.go b/bridge/gitea/import.go new file mode 100644 index 0000000000000000000000000000000000000000..ceb1b73a15d34982b1477a0ba80c370022f5e10b --- /dev/null +++ b/bridge/gitea/import.go @@ -0,0 +1,189 @@ +package gitea + +import ( + "context" + "fmt" + "strconv" + "time" + + "code.gitea.io/sdk/gitea" + + "github.com/MichaelMure/git-bug/bridge/core" + "github.com/MichaelMure/git-bug/bridge/core/auth" + "github.com/MichaelMure/git-bug/bridge/gitea/iterator" + "github.com/MichaelMure/git-bug/cache" + "github.com/MichaelMure/git-bug/entities/bug" + "github.com/MichaelMure/git-bug/entity" + "github.com/MichaelMure/git-bug/util/text" +) + +// giteaImporter implement the Importer interface +type giteaImporter struct { + conf core.Configuration + + // default client + client *gitea.Client + + // iterator + iterator *iterator.Iterator + + // send only channel + out chan<- core.ImportResult +} + +func (gi *giteaImporter) Init(_ context.Context, repo *cache.RepoCache, conf core.Configuration) error { + gi.conf = conf + + creds, err := auth.List(repo, + auth.WithTarget(target), + auth.WithKind(auth.KindToken), + auth.WithMeta(auth.MetaKeyBaseURL, conf[confKeyBaseURL]), + auth.WithMeta(auth.MetaKeyLogin, conf[confKeyDefaultLogin]), + ) + if err != nil { + return err + } + + if len(creds) == 0 { + return ErrMissingIdentityToken + } + + gi.client, err = buildClient(conf[confKeyBaseURL], creds[0].(*auth.Token)) + if err != nil { + return err + } + + return nil +} + +// ImportAll iterate over all the configured repository issues (comments) and ensure the creation +// of the missing issues / comments / label events / title changes ... +func (gi *giteaImporter) ImportAll(ctx context.Context, repo *cache.RepoCache, since time.Time) (<-chan core.ImportResult, error) { + gi.iterator = iterator.NewIterator(ctx, gi.client, 10, gi.conf[confKeyOwner], gi.conf[confKeyProject], defaultTimeout, since) + out := make(chan core.ImportResult) + gi.out = out + + go func() { + defer close(gi.out) + + // Loop over all matching issues + for gi.iterator.NextIssue() { + issue := gi.iterator.IssueValue() + + // create issue + b, err := gi.ensureIssue(repo, issue) + if err != nil { + err := fmt.Errorf("issue creation: %v", err) + out <- core.NewImportError(err, "") + return + } + + // Loop over all comments + + // Loop over all label events + + if !b.NeedCommit() { + out <- core.NewImportNothing(b.Id(), "no imported operation") + } else if err := b.Commit(); err != nil { + // commit bug state + err := fmt.Errorf("bug commit: %v", err) + out <- core.NewImportError(err, "") + return + } + } + + if err := gi.iterator.Error(); err != nil { + out <- core.NewImportError(err, "") + } + }() + + return out, nil +} + +func (gi *giteaImporter) ensureIssue(repo *cache.RepoCache, issue *gitea.Issue) (*cache.BugCache, error) { + // ensure issue author + author, err := gi.ensurePerson(repo, issue.Poster.UserName) + if err != nil { + return nil, err + } + + giteaID := strconv.FormatInt(issue.Index, 10) + + // resolve bug + b, err := repo.ResolveBugMatcher(func(excerpt *cache.BugExcerpt) bool { + return excerpt.CreateMetadata[core.MetaKeyOrigin] == target && + excerpt.CreateMetadata[metaKeyGiteaID] == giteaID && + excerpt.CreateMetadata[metaKeyGiteaBaseURL] == gi.conf[confKeyBaseURL] && + excerpt.CreateMetadata[metaKeyGiteaOwner] == gi.conf[confKeyOwner] && + excerpt.CreateMetadata[metaKeyGiteaProject] == gi.conf[confKeyProject] + }) + if err == nil { + return b, nil + } + if err != bug.ErrBugNotExist { + return nil, err + } + + // if bug was never imported, create bug + b, _, err = repo.NewBugRaw( + author, + issue.Created.Unix(), + text.CleanupOneLine(issue.Title), + text.Cleanup(issue.Body), + nil, + map[string]string{ + core.MetaKeyOrigin: target, + metaKeyGiteaID: giteaID, + metaKeyGiteaOwner: gi.conf[confKeyOwner], + metaKeyGiteaProject: gi.conf[confKeyProject], + metaKeyGiteaBaseURL: gi.conf[confKeyBaseURL], + }, + ) + + if err != nil { + return nil, err + } + + // importing a new bug + gi.out <- core.NewImportBug(b.Id()) + + return b, nil +} + +func (gi *giteaImporter) ensurePerson(repo *cache.RepoCache, loginName string) (*cache.IdentityCache, error) { + // Look first in the cache + i, err := repo.ResolveIdentityImmutableMetadata(metaKeyGiteaLogin, loginName) + if err == nil { + return i, nil + } + if entity.IsErrMultipleMatch(err) { + return nil, err + } + + ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout) + defer cancel() + gi.client.SetContext(ctx) + + user, _, err := gi.client.GetUserInfo(loginName) + if err != nil { + return nil, err + } + + i, err = repo.NewIdentityRaw( + user.FullName, + user.Email, + user.UserName, + user.AvatarURL, + nil, + map[string]string{ + // because Gitea + metaKeyGiteaLogin: user.UserName, + }, + ) + if err != nil { + return nil, err + } + + gi.out <- core.NewImportIdentity(i.Id()) + return i, nil +} diff --git a/bridge/gitea/iterator/comment.go b/bridge/gitea/iterator/comment.go new file mode 100644 index 0000000000000000000000000000000000000000..afdcf0d53bb09c3a18e0ef93aa659f30ff70f0d6 --- /dev/null +++ b/bridge/gitea/iterator/comment.go @@ -0,0 +1,87 @@ +package iterator + +import ( + "context" + + "code.gitea.io/sdk/gitea" +) + +type commentIterator struct { + issue int64 + page int + lastPage bool + index int + cache []*gitea.Comment +} + +func newCommentIterator() *commentIterator { + ci := &commentIterator{} + ci.Reset(-1) + return ci +} + +func (ci *commentIterator) Next(ctx context.Context, conf config) (bool, error) { + // first query + if ci.cache == nil { + return ci.getNext(ctx, conf) + } + + // move cursor index + if ci.index < len(ci.cache)-1 { + ci.index++ + return true, nil + } + + return ci.getNext(ctx, conf) +} + +func (ci *commentIterator) Value() *gitea.Comment { + return ci.cache[ci.index] +} + +func (ci *commentIterator) getNext(ctx context.Context, conf config) (bool, error) { + if ci.lastPage { + return false, nil + } + + ctx, cancel := context.WithTimeout(ctx, conf.timeout) + defer cancel() + conf.gc.SetContext(ctx) + + comments, _, err := conf.gc.ListIssueComments( + conf.owner, + conf.project, + ci.issue, + gitea.ListIssueCommentOptions{ + ListOptions: gitea.ListOptions{ + Page: ci.page, + PageSize: conf.capacity, + }, + }, + ) + + if err != nil { + ci.Reset(-1) + return false, err + } + + ci.lastPage = true + + if len(comments) == 0 { + return false, nil + } + + ci.cache = comments + ci.index = 0 + ci.page++ + + return true, nil +} + +func (ci *commentIterator) Reset(issue int64) { + ci.issue = issue + ci.index = -1 + ci.page = 1 + ci.lastPage = false + ci.cache = nil +} diff --git a/bridge/gitea/iterator/issue.go b/bridge/gitea/iterator/issue.go new file mode 100644 index 0000000000000000000000000000000000000000..5937baaa7d096081a42bc59a1fd3b2224e2da288 --- /dev/null +++ b/bridge/gitea/iterator/issue.go @@ -0,0 +1,96 @@ +package iterator + +import ( + "context" + "strconv" + + "code.gitea.io/sdk/gitea" +) + +type issueIterator struct { + page int + lastPage bool + index int + cache []*gitea.Issue +} + +func newIssueIterator() *issueIterator { + ii := &issueIterator{} + ii.Reset() + return ii +} + +func (ii *issueIterator) Next(ctx context.Context, conf config) (bool, error) { + // first query + if ii.cache == nil { + return ii.getNext(ctx, conf) + } + + // move cursor index + if ii.index < len(ii.cache)-1 { + ii.index++ + return true, nil + } + + return ii.getNext(ctx, conf) +} + +func (ii *issueIterator) Value() *gitea.Issue { + return ii.cache[ii.index] +} + +func (ii *issueIterator) getNext(ctx context.Context, conf config) (bool, error) { + if ii.lastPage { + return false, nil + } + + ctx, cancel := context.WithTimeout(ctx, conf.timeout) + defer cancel() + conf.gc.SetContext(ctx) + + issues, resp, err := conf.gc.ListRepoIssues( + conf.owner, + conf.project, + gitea.ListIssueOption{ + ListOptions: gitea.ListOptions{ + Page: ii.page, + PageSize: conf.capacity, + }, + State: gitea.StateAll, + Type: gitea.IssueTypeIssue, + }, + ) + + if err != nil { + ii.Reset() + return false, err + } + + total, err := strconv.Atoi(resp.Header.Get("X-Total-Count")) + if err != nil { + ii.Reset() + return false, err + } + + if total <= ii.page*conf.capacity { + ii.lastPage = true + } + + // if repository doesn't have any issues + if len(issues) == 0 { + return false, nil + } + + ii.cache = issues + ii.index = 0 + ii.page++ + + return true, nil +} + +func (ii *issueIterator) Reset() { + ii.index = -1 + ii.page = 1 + ii.lastPage = false + ii.cache = nil +} diff --git a/bridge/gitea/iterator/iterator.go b/bridge/gitea/iterator/iterator.go new file mode 100644 index 0000000000000000000000000000000000000000..e3c701fa2934a73abd375a7e40474a6d3aff75f5 --- /dev/null +++ b/bridge/gitea/iterator/iterator.go @@ -0,0 +1,142 @@ +package iterator + +import ( + "context" + "time" + + "code.gitea.io/sdk/gitea" +) + +type Iterator struct { + // shared context + ctx context.Context + + // to pass to sub-iterators + conf config + + // sticky error + err error + + // issues iterator + issue *issueIterator + + // comments iterator + comment *commentIterator + + // labels iterator + label *labelIterator +} + +type config struct { + // gitea api v1 client + gc *gitea.Client + + timeout time.Duration + + // if since is given the iterator will query only the issues + // updated after this date + since time.Time + + // name of the repository owner on Gitea + owner string + + // name of the Gitea repository + project string + + // number of issues and notes to query at once + capacity int +} + +// NewIterator create a new iterator +func NewIterator(ctx context.Context, client *gitea.Client, capacity int, owner, project string, timeout time.Duration, since time.Time) *Iterator { + return &Iterator{ + ctx: ctx, + conf: config{ + gc: client, + timeout: timeout, + since: since, + owner: owner, + project: project, + capacity: capacity, + }, + issue: newIssueIterator(), + comment: newCommentIterator(), + label: newLabelIterator(), + } +} + +// Error return last encountered error +func (i *Iterator) Error() error { + return i.err +} + +func (i *Iterator) NextIssue() bool { + if i.err != nil { + return false + } + + if i.ctx.Err() != nil { + return false + } + + more, err := i.issue.Next(i.ctx, i.conf) + if err != nil { + i.err = err + return false + } + + // Also reset the other sub iterators as they would + // no longer be valid + i.comment.Reset(i.issue.Value().Index) + i.label.Reset(i.issue.Value().Index) + + return more +} + +func (i *Iterator) IssueValue() *gitea.Issue { + return i.issue.Value() +} + +func (i *Iterator) NextComment() bool { + if i.err != nil { + return false + } + + if i.ctx.Err() != nil { + return false + } + + more, err := i.comment.Next(i.ctx, i.conf) + if err != nil { + i.err = err + return false + } + + return more +} + +func (i *Iterator) CommentValue() *gitea.Comment { + return i.comment.Value() +} + +func (i *Iterator) NextLabel() bool { + if i.err != nil { + return false + } + + if i.ctx.Err() != nil { + return false + } + + more, err := i.label.Next(i.ctx, i.conf) + if err != nil { + i.err = err + return false + } + + return more +} + +func (i *Iterator) LabelValue() *gitea.Label { + return i.label.Value() +} diff --git a/bridge/gitea/iterator/label.go b/bridge/gitea/iterator/label.go new file mode 100644 index 0000000000000000000000000000000000000000..f0ad21840c46348f20dec5cbe3e6d5528b4d3ae7 --- /dev/null +++ b/bridge/gitea/iterator/label.go @@ -0,0 +1,86 @@ +package iterator + +import ( + "context" + + "code.gitea.io/sdk/gitea" +) + +type labelIterator struct { + issue int64 + page int + lastPage bool + index int + cache []*gitea.Label +} + +func newLabelIterator() *labelIterator { + li := &labelIterator{} + li.Reset(-1) + return li +} + +func (li *labelIterator) Next(ctx context.Context, conf config) (bool, error) { + // first query + if li.cache == nil { + return li.getNext(ctx, conf) + } + + // move cursor index + if li.index < len(li.cache)-1 { + li.index++ + return true, nil + } + + return li.getNext(ctx, conf) +} + +func (li *labelIterator) Value() *gitea.Label { + return li.cache[li.index] +} + +func (li *labelIterator) getNext(ctx context.Context, conf config) (bool, error) { + if li.lastPage { + return false, nil + } + + ctx, cancel := context.WithTimeout(ctx, conf.timeout) + defer cancel() + conf.gc.SetContext(ctx) + + labels, _, err := conf.gc.GetIssueLabels( + conf.owner, + conf.project, + li.issue, + gitea.ListLabelsOptions{ + ListOptions: gitea.ListOptions{ + Page: li.page, + PageSize: conf.capacity, + }, + }, + ) + if err != nil { + li.Reset(-1) + return false, err + } + + li.lastPage = true + + if len(labels) == 0 { + return false, nil + } + + li.cache = labels + li.index = 0 + li.page++ + + return true, nil +} + +func (li *labelIterator) Reset(issue int64) { + li.issue = issue + li.index = -1 + li.page = 1 + li.lastPage = false + li.cache = nil +} diff --git a/doc/man/git-bug-bridge-auth-add-token.1 b/doc/man/git-bug-bridge-auth-add-token.1 index dda8839fdc709783d5ed059db9dcd96c7322d2d8..2dbf741d74732c628e1624a31d72cfd5fe5cbaab 100644 --- a/doc/man/git-bug-bridge-auth-add-token.1 +++ b/doc/man/git-bug-bridge-auth-add-token.1 @@ -19,7 +19,7 @@ Store a new token .SH OPTIONS .PP \fB-t\fP, \fB--target\fP="" - The target of the bridge. Valid values are [github,gitlab,jira,launchpad-preview] + The target of the bridge. Valid values are [gitea-preview,github,gitlab,jira,launchpad-preview] .PP \fB-l\fP, \fB--login\fP="" diff --git a/doc/man/git-bug-bridge-configure.1 b/doc/man/git-bug-bridge-configure.1 index ae01bf14e630cf1607ea70428e0930b8464099a7..ae34170f7d123d597df9131f0593f7f11130953e 100644 --- a/doc/man/git-bug-bridge-configure.1 +++ b/doc/man/git-bug-bridge-configure.1 @@ -29,7 +29,7 @@ Configure a new bridge by passing flags or/and using interactive terminal prompt .PP \fB-t\fP, \fB--target\fP="" - The target of the bridge. Valid values are [github,gitlab,jira,launchpad-preview] + The target of the bridge. Valid values are [gitea-preview,github,gitlab,jira,launchpad-preview] .PP \fB-u\fP, \fB--url\fP="" diff --git a/doc/md/git-bug_bridge_auth_add-token.md b/doc/md/git-bug_bridge_auth_add-token.md index faafaf61cdec4572deea09702594a2f5bcaab2d5..ded286a3eb5b19eaa3bca7317b2ba484642d5aa7 100644 --- a/doc/md/git-bug_bridge_auth_add-token.md +++ b/doc/md/git-bug_bridge_auth_add-token.md @@ -9,7 +9,7 @@ git-bug bridge auth add-token [TOKEN] [flags] ### Options ``` - -t, --target string The target of the bridge. Valid values are [github,gitlab,jira,launchpad-preview] + -t, --target string The target of the bridge. Valid values are [gitea-preview,github,gitlab,jira,launchpad-preview] -l, --login string The login in the remote bug-tracker -u, --user string The user to add the token to. Default is the current user -h, --help help for add-token diff --git a/doc/md/git-bug_bridge_configure.md b/doc/md/git-bug_bridge_configure.md index f71e294d5d158cf421f0be1e216e0930f2483434..18ac8ae2e7c190cf69cc92b4bd56fd4779010300 100644 --- a/doc/md/git-bug_bridge_configure.md +++ b/doc/md/git-bug_bridge_configure.md @@ -72,7 +72,7 @@ git bug bridge configure \ ``` -n, --name string A distinctive name to identify the bridge - -t, --target string The target of the bridge. Valid values are [github,gitlab,jira,launchpad-preview] + -t, --target string The target of the bridge. Valid values are [gitea-preview,github,gitlab,jira,launchpad-preview] -u, --url string The URL of the remote repository -b, --base-url string The base URL of your remote issue tracker -l, --login string The login on your remote issue tracker diff --git a/go.mod b/go.mod index 09782bee7cf8afc7c0400ececc8432a31d3de350..06f214bcd91daa910eea8cad62076b00b4aceb1c 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/MichaelMure/git-bug go 1.18 require ( + code.gitea.io/sdk/gitea v0.14.0 github.com/99designs/gqlgen v0.17.17 github.com/99designs/keyring v1.2.1 github.com/MichaelMure/go-term-text v0.3.1 @@ -35,6 +36,7 @@ require ( ) require ( + github.com/hashicorp/go-version v1.2.1 // indirect github.com/lithammer/dedent v1.1.0 // indirect github.com/owenrumney/go-sarif v1.0.11 // indirect github.com/segmentio/fasthash v1.0.3 // indirect diff --git a/go.sum b/go.sum index f49a999d0ad6197c9560b57f731b15857aac9e68..165325ea083653b64bbb6eca2b2bb0dc921c8b2b 100644 --- a/go.sum +++ b/go.sum @@ -36,6 +36,8 @@ cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0Zeo cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= +code.gitea.io/sdk/gitea v0.14.0 h1:m4J352I3p9+bmJUfS+g0odeQzBY/5OXP91Gv6D4fnJ0= +code.gitea.io/sdk/gitea v0.14.0/go.mod h1:89WiyOX1KEcvjP66sRHdu0RafojGo60bT9UqW17VbWs= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMbk2FiG/kXiLl8BRyzTWDw7gX/Hz7Dd5eDMs= github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN7oaIRCjzsZ2dE+yG5k+rsdt3qcwykqK6HVGcKwsw4= @@ -295,6 +297,8 @@ github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerX github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-version v1.2.1 h1:zEfKbn2+PDgroKdiOzqiE8rsmLqU2uwi5PB5pBJ3TkI= +github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=