aboutsummaryrefslogtreecommitdiffstats
path: root/commands/bridge
diff options
context:
space:
mode:
Diffstat (limited to 'commands/bridge')
-rw-r--r--commands/bridge/bridge.go43
-rw-r--r--commands/bridge/bridge_auth.go67
-rw-r--r--commands/bridge/bridge_auth_addtoken.go133
-rw-r--r--commands/bridge/bridge_auth_rm.go41
-rw-r--r--commands/bridge/bridge_auth_show.go60
-rw-r--r--commands/bridge/bridge_new.go234
-rw-r--r--commands/bridge/bridge_pull.go155
-rw-r--r--commands/bridge/bridge_push.go99
-rw-r--r--commands/bridge/bridge_rm.go36
9 files changed, 868 insertions, 0 deletions
diff --git a/commands/bridge/bridge.go b/commands/bridge/bridge.go
new file mode 100644
index 00000000..980a38e2
--- /dev/null
+++ b/commands/bridge/bridge.go
@@ -0,0 +1,43 @@
+package bridgecmd
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/MichaelMure/git-bug/bridge"
+ "github.com/MichaelMure/git-bug/commands/execenv"
+)
+
+func NewBridgeCommand() *cobra.Command {
+ env := execenv.NewEnv()
+
+ cmd := &cobra.Command{
+ Use: "bridge",
+ Short: "List bridges to other bug trackers",
+ PreRunE: execenv.LoadBackend(env),
+ RunE: execenv.CloseBackend(env, func(cmd *cobra.Command, args []string) error {
+ return runBridge(env)
+ }),
+ Args: cobra.NoArgs,
+ }
+
+ cmd.AddCommand(newBridgeAuthCommand())
+ cmd.AddCommand(newBridgeNewCommand())
+ cmd.AddCommand(newBridgePullCommand())
+ cmd.AddCommand(newBridgePushCommand())
+ cmd.AddCommand(newBridgeRm())
+
+ return cmd
+}
+
+func runBridge(env *execenv.Env) error {
+ configured, err := bridge.ConfiguredBridges(env.Backend)
+ if err != nil {
+ return err
+ }
+
+ for _, c := range configured {
+ env.Out.Println(c)
+ }
+
+ return nil
+}
diff --git a/commands/bridge/bridge_auth.go b/commands/bridge/bridge_auth.go
new file mode 100644
index 00000000..52e063e6
--- /dev/null
+++ b/commands/bridge/bridge_auth.go
@@ -0,0 +1,67 @@
+package bridgecmd
+
+import (
+ "sort"
+ "strings"
+
+ text "github.com/MichaelMure/go-term-text"
+ "github.com/spf13/cobra"
+
+ "github.com/MichaelMure/git-bug/bridge/core/auth"
+ "github.com/MichaelMure/git-bug/commands/execenv"
+ "github.com/MichaelMure/git-bug/util/colors"
+)
+
+func newBridgeAuthCommand() *cobra.Command {
+ env := execenv.NewEnv()
+
+ cmd := &cobra.Command{
+ Use: "auth",
+ Short: "List all known bridge authentication credentials",
+ PreRunE: execenv.LoadBackend(env),
+ RunE: execenv.CloseBackend(env, func(cmd *cobra.Command, args []string) error {
+ return runBridgeAuth(env)
+ }),
+ Args: cobra.NoArgs,
+ }
+
+ cmd.AddCommand(newBridgeAuthAddTokenCommand())
+ cmd.AddCommand(newBridgeAuthRm())
+ cmd.AddCommand(newBridgeAuthShow())
+
+ return cmd
+}
+
+func runBridgeAuth(env *execenv.Env) error {
+ creds, err := auth.List(env.Backend)
+ if err != nil {
+ return err
+ }
+
+ for _, cred := range creds {
+ targetFmt := text.LeftPadMaxLine(cred.Target(), 10, 0)
+
+ var value string
+ switch cred := cred.(type) {
+ case *auth.Token:
+ value = cred.Value
+ }
+
+ meta := make([]string, 0, len(cred.Metadata()))
+ for k, v := range cred.Metadata() {
+ meta = append(meta, k+":"+v)
+ }
+ sort.Strings(meta)
+ metaFmt := strings.Join(meta, ",")
+
+ env.Out.Printf("%s %s %s %s %s\n",
+ colors.Cyan(cred.ID().Human()),
+ colors.Yellow(targetFmt),
+ colors.Magenta(cred.Kind()),
+ value,
+ metaFmt,
+ )
+ }
+
+ return nil
+}
diff --git a/commands/bridge/bridge_auth_addtoken.go b/commands/bridge/bridge_auth_addtoken.go
new file mode 100644
index 00000000..bcab7fc3
--- /dev/null
+++ b/commands/bridge/bridge_auth_addtoken.go
@@ -0,0 +1,133 @@
+package bridgecmd
+
+import (
+ "bufio"
+ "fmt"
+ "os"
+ "strings"
+
+ "github.com/mattn/go-isatty"
+ "github.com/pkg/errors"
+ "github.com/spf13/cobra"
+
+ "github.com/MichaelMure/git-bug/bridge"
+ "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/completion"
+ "github.com/MichaelMure/git-bug/commands/execenv"
+)
+
+type bridgeAuthAddTokenOptions struct {
+ target string
+ login string
+ user string
+}
+
+func newBridgeAuthAddTokenCommand() *cobra.Command {
+ env := execenv.NewEnv()
+ options := bridgeAuthAddTokenOptions{}
+
+ cmd := &cobra.Command{
+ Use: "add-token [TOKEN]",
+ Short: "Store a new token",
+ PreRunE: execenv.LoadBackendEnsureUser(env),
+ RunE: execenv.CloseBackend(env, func(cmd *cobra.Command, args []string) error {
+ return runBridgeAuthAddToken(env, options, args)
+ }),
+ Args: cobra.MaximumNArgs(1),
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ flags.StringVarP(&options.target, "target", "t", "",
+ fmt.Sprintf("The target of the bridge. Valid values are [%s]", strings.Join(bridge.Targets(), ",")))
+ cmd.RegisterFlagCompletionFunc("target", completion.From(bridge.Targets()))
+ flags.StringVarP(&options.login,
+ "login", "l", "", "The login in the remote bug-tracker")
+ flags.StringVarP(&options.user,
+ "user", "u", "", "The user to add the token to. Default is the current user")
+ cmd.RegisterFlagCompletionFunc("user", completion.User(env))
+
+ return cmd
+}
+
+func runBridgeAuthAddToken(env *execenv.Env, opts bridgeAuthAddTokenOptions, args []string) error {
+ // Note: as bridgeAuthAddTokenLogin is not checked against the remote bug-tracker,
+ // it's possible to register a credential with an incorrect login (including bad case).
+ // The consequence is that it will not get picked later by the bridge. I find that
+ // checking it would require a cumbersome UX (need to provide a base URL for some bridges, ...)
+ // so it's probably not worth it, unless we refactor that entirely.
+
+ if opts.target == "" {
+ return fmt.Errorf("flag --target is required")
+ }
+ if opts.login == "" {
+ return fmt.Errorf("flag --login is required")
+ }
+
+ if !core.TargetExist(opts.target) {
+ return fmt.Errorf("unknown target")
+ }
+
+ var value string
+
+ if len(args) == 1 {
+ value = args[0]
+ } else {
+ // Read from Stdin
+ if isatty.IsTerminal(os.Stdin.Fd()) {
+ env.Err.Println("Enter the token:")
+ }
+ reader := bufio.NewReader(os.Stdin)
+ raw, err := reader.ReadString('\n')
+ if err != nil {
+ return fmt.Errorf("reading from stdin: %v", err)
+ }
+ value = strings.TrimSuffix(raw, "\n")
+ }
+
+ var user *cache.IdentityCache
+ var err error
+
+ if opts.user == "" {
+ user, err = env.Backend.GetUserIdentity()
+ } else {
+ user, err = env.Backend.ResolveIdentityPrefix(opts.user)
+ }
+ if err != nil {
+ return err
+ }
+
+ metaKey, _ := bridge.LoginMetaKey(opts.target)
+ login, ok := user.ImmutableMetadata()[metaKey]
+
+ switch {
+ case ok && login == opts.login:
+ // nothing to do
+ case ok && login != opts.login:
+ return fmt.Errorf("this user is already tagged with a different %s login", opts.target)
+ default:
+ user.SetMetadata(metaKey, opts.login)
+ err = user.Commit()
+ if err != nil {
+ return err
+ }
+ }
+
+ token := auth.NewToken(opts.target, value)
+ token.SetMetadata(auth.MetaKeyLogin, opts.login)
+
+ if err := token.Validate(); err != nil {
+ return errors.Wrap(err, "invalid token")
+ }
+
+ err = auth.Store(env.Repo, token)
+ if err != nil {
+ return err
+ }
+
+ env.Out.Printf("token %s added\n", token.ID())
+ return nil
+}
diff --git a/commands/bridge/bridge_auth_rm.go b/commands/bridge/bridge_auth_rm.go
new file mode 100644
index 00000000..d58ca63e
--- /dev/null
+++ b/commands/bridge/bridge_auth_rm.go
@@ -0,0 +1,41 @@
+package bridgecmd
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/MichaelMure/git-bug/bridge/core/auth"
+ "github.com/MichaelMure/git-bug/commands/completion"
+ "github.com/MichaelMure/git-bug/commands/execenv"
+)
+
+func newBridgeAuthRm() *cobra.Command {
+ env := execenv.NewEnv()
+
+ cmd := &cobra.Command{
+ Use: "rm BRIDGE_ID",
+ Short: "Remove a credential",
+ PreRunE: execenv.LoadRepo(env),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ return runBridgeAuthRm(env, args)
+ },
+ Args: cobra.ExactArgs(1),
+ ValidArgsFunction: completion.BridgeAuth(env),
+ }
+
+ return cmd
+}
+
+func runBridgeAuthRm(env *execenv.Env, args []string) error {
+ cred, err := auth.LoadWithPrefix(env.Repo, args[0])
+ if err != nil {
+ return err
+ }
+
+ err = auth.Remove(env.Repo, cred.ID())
+ if err != nil {
+ return err
+ }
+
+ env.Out.Printf("credential %s removed\n", cred.ID())
+ return nil
+}
diff --git a/commands/bridge/bridge_auth_show.go b/commands/bridge/bridge_auth_show.go
new file mode 100644
index 00000000..d373273d
--- /dev/null
+++ b/commands/bridge/bridge_auth_show.go
@@ -0,0 +1,60 @@
+package bridgecmd
+
+import (
+ "fmt"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/spf13/cobra"
+
+ "github.com/MichaelMure/git-bug/bridge/core/auth"
+ "github.com/MichaelMure/git-bug/commands/completion"
+ "github.com/MichaelMure/git-bug/commands/execenv"
+)
+
+func newBridgeAuthShow() *cobra.Command {
+ env := execenv.NewEnv()
+
+ cmd := &cobra.Command{
+ Use: "show",
+ Short: "Display an authentication credential",
+ PreRunE: execenv.LoadBackend(env),
+ RunE: execenv.CloseBackend(env, func(cmd *cobra.Command, args []string) error {
+ return runBridgeAuthShow(env, args)
+ }),
+ Args: cobra.ExactArgs(1),
+ ValidArgsFunction: completion.BridgeAuth(env),
+ }
+
+ return cmd
+}
+
+func runBridgeAuthShow(env *execenv.Env, args []string) error {
+ cred, err := auth.LoadWithPrefix(env.Repo, args[0])
+ if err != nil {
+ return err
+ }
+
+ env.Out.Printf("Id: %s\n", cred.ID())
+ env.Out.Printf("Target: %s\n", cred.Target())
+ env.Out.Printf("Kind: %s\n", cred.Kind())
+ env.Out.Printf("Creation: %s\n", cred.CreateTime().Format(time.RFC822))
+
+ switch cred := cred.(type) {
+ case *auth.Token:
+ env.Out.Printf("Value: %s\n", cred.Value)
+ }
+
+ env.Out.Println("Metadata:")
+
+ meta := make([]string, 0, len(cred.Metadata()))
+ for key, value := range cred.Metadata() {
+ meta = append(meta, fmt.Sprintf(" %s --> %s\n", key, value))
+ }
+ sort.Strings(meta)
+
+ env.Out.Print(strings.Join(meta, ""))
+
+ return nil
+}
diff --git a/commands/bridge/bridge_new.go b/commands/bridge/bridge_new.go
new file mode 100644
index 00000000..4cfc903d
--- /dev/null
+++ b/commands/bridge/bridge_new.go
@@ -0,0 +1,234 @@
+package bridgecmd
+
+import (
+ "bufio"
+ "fmt"
+ "os"
+ "strconv"
+ "strings"
+
+ "github.com/spf13/cobra"
+
+ "github.com/MichaelMure/git-bug/bridge"
+ "github.com/MichaelMure/git-bug/bridge/core"
+ "github.com/MichaelMure/git-bug/bridge/core/auth"
+ "github.com/MichaelMure/git-bug/commands/completion"
+ "github.com/MichaelMure/git-bug/commands/execenv"
+ "github.com/MichaelMure/git-bug/repository"
+)
+
+type bridgeNewOptions struct {
+ name string
+ target string
+ params core.BridgeParams
+ token string
+ tokenStdin bool
+ nonInteractive bool
+}
+
+func newBridgeNewCommand() *cobra.Command {
+ env := execenv.NewEnv()
+ options := bridgeNewOptions{}
+
+ cmd := &cobra.Command{
+ Use: "new",
+ Short: "Configure a new bridge",
+ Long: ` Configure a new bridge by passing flags or/and using interactive terminal prompts. You can avoid all the terminal prompts by passing all the necessary flags to configure your bridge.`,
+ Example: `# Interactive example
+[1]: github
+[2]: gitlab
+[3]: jira
+[4]: launchpad-preview
+
+target: 1
+name [default]: default
+
+Detected projects:
+[1]: github.com/a-hilaly/git-bug
+[2]: github.com/MichaelMure/git-bug
+
+[0]: Another project
+
+Select option: 1
+
+[1]: user provided token
+[2]: interactive token creation
+Select option: 1
+
+You can generate a new token by visiting https://github.com/settings/tokens.
+Choose 'Generate new token' and set the necessary access scope for your repository.
+
+The access scope depend on the type of repository.
+Public:
+ - 'public_repo': to be able to read public repositories
+Private:
+ - 'repo' : to be able to read private repositories
+
+Enter token: 87cf5c03b64029f18ea5f9ca5679daa08ccbd700
+Successfully configured bridge: default
+
+# For GitHub
+git bug bridge new \
+ --name=default \
+ --target=github \
+ --owner=$(OWNER) \
+ --project=$(PROJECT) \
+ --token=$(TOKEN)
+
+# For Launchpad
+git bug bridge new \
+ --name=default \
+ --target=launchpad-preview \
+ --url=https://bugs.launchpad.net/ubuntu/
+
+# For Gitlab
+git bug bridge new \
+ --name=default \
+ --target=github \
+ --url=https://github.com/michaelmure/git-bug \
+ --token=$(TOKEN)`,
+ PreRunE: execenv.LoadBackend(env),
+ RunE: execenv.CloseBackend(env, func(cmd *cobra.Command, args []string) error {
+ return runBridgeNew(env, options)
+ }),
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ flags.StringVarP(&options.name, "name", "n", "", "A distinctive name to identify the bridge")
+ flags.StringVarP(&options.target, "target", "t", "",
+ fmt.Sprintf("The target of the bridge. Valid values are [%s]", strings.Join(bridge.Targets(), ",")))
+ cmd.RegisterFlagCompletionFunc("target", completion.From(bridge.Targets()))
+ flags.StringVarP(&options.params.URL, "url", "u", "", "The URL of the remote repository")
+ flags.StringVarP(&options.params.BaseURL, "base-url", "b", "", "The base URL of your remote issue tracker")
+ flags.StringVarP(&options.params.Login, "login", "l", "", "The login on your remote issue tracker")
+ flags.StringVarP(&options.params.CredPrefix, "credential", "c", "", "The identifier or prefix of an already known credential for your remote issue tracker (see \"git-bug bridge auth\")")
+ flags.StringVar(&options.token, "token", "", "A raw authentication token for the remote issue tracker")
+ flags.BoolVar(&options.tokenStdin, "token-stdin", false, "Will read the token from stdin and ignore --token")
+ flags.StringVarP(&options.params.Owner, "owner", "o", "", "The owner of the remote repository")
+ flags.StringVarP(&options.params.Project, "project", "p", "", "The name of the remote repository")
+ flags.BoolVar(&options.nonInteractive, "non-interactive", false, "Do not ask for user input")
+
+ return cmd
+}
+
+func runBridgeNew(env *execenv.Env, opts bridgeNewOptions) error {
+ var err error
+
+ if (opts.tokenStdin || opts.token != "" || opts.params.CredPrefix != "") &&
+ (opts.name == "" || opts.target == "") {
+ return fmt.Errorf("you must provide a bridge name and target to configure a bridge with a credential")
+ }
+
+ // early fail
+ if opts.params.CredPrefix != "" {
+ if _, err := auth.LoadWithPrefix(env.Repo, opts.params.CredPrefix); err != nil {
+ return err
+ }
+ }
+
+ switch {
+ case opts.tokenStdin:
+ reader := bufio.NewReader(os.Stdin)
+ token, err := reader.ReadString('\n')
+ if err != nil {
+ return fmt.Errorf("reading from stdin: %v", err)
+ }
+ opts.params.TokenRaw = strings.TrimSpace(token)
+ case opts.token != "":
+ opts.params.TokenRaw = opts.token
+ }
+
+ if !opts.nonInteractive && opts.target == "" {
+ opts.target, err = promptTarget()
+ if err != nil {
+ return err
+ }
+ }
+
+ if !opts.nonInteractive && opts.name == "" {
+ opts.name, err = promptName(env.Repo)
+ if err != nil {
+ return err
+ }
+ }
+
+ b, err := bridge.NewBridge(env.Backend, opts.target, opts.name)
+ if err != nil {
+ return err
+ }
+
+ err = b.Configure(opts.params, !opts.nonInteractive)
+ if err != nil {
+ return err
+ }
+
+ env.Out.Printf("Successfully configured bridge: %s\n", opts.name)
+ return nil
+}
+
+func promptTarget() (string, error) {
+ // TODO: use the reusable prompt from the input package
+ targets := bridge.Targets()
+
+ for {
+ for i, target := range targets {
+ fmt.Printf("[%d]: %s\n", i+1, target)
+ }
+ fmt.Printf("target: ")
+
+ line, err := bufio.NewReader(os.Stdin).ReadString('\n')
+
+ if err != nil {
+ return "", err
+ }
+
+ line = strings.TrimSpace(line)
+
+ index, err := strconv.Atoi(line)
+ if err != nil || index <= 0 || index > len(targets) {
+ fmt.Println("invalid input")
+ continue
+ }
+
+ return targets[index-1], nil
+ }
+}
+
+func promptName(repo repository.RepoConfig) (string, error) {
+ // TODO: use the reusable prompt from the input package
+ const defaultName = "default"
+
+ defaultExist := core.BridgeExist(repo, defaultName)
+
+ for {
+ if defaultExist {
+ fmt.Printf("name: ")
+ } else {
+ fmt.Printf("name [%s]: ", defaultName)
+ }
+
+ line, err := bufio.NewReader(os.Stdin).ReadString('\n')
+ if err != nil {
+ return "", err
+ }
+
+ line = strings.TrimSpace(line)
+
+ name := line
+ if defaultExist && name == "" {
+ continue
+ }
+
+ if name == "" {
+ name = defaultName
+ }
+
+ if !core.BridgeExist(repo, name) {
+ return name, nil
+ }
+
+ fmt.Println("a bridge with the same name already exist")
+ }
+}
diff --git a/commands/bridge/bridge_pull.go b/commands/bridge/bridge_pull.go
new file mode 100644
index 00000000..d1fc279a
--- /dev/null
+++ b/commands/bridge/bridge_pull.go
@@ -0,0 +1,155 @@
+package bridgecmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "sync"
+ "time"
+
+ "github.com/araddon/dateparse"
+ "github.com/pkg/errors"
+ "github.com/spf13/cobra"
+
+ "github.com/MichaelMure/git-bug/bridge"
+ "github.com/MichaelMure/git-bug/bridge/core"
+ "github.com/MichaelMure/git-bug/commands/completion"
+ "github.com/MichaelMure/git-bug/commands/execenv"
+ "github.com/MichaelMure/git-bug/util/interrupt"
+)
+
+type bridgePullOptions struct {
+ importSince string
+ noResume bool
+}
+
+func newBridgePullCommand() *cobra.Command {
+ env := execenv.NewEnv()
+ options := bridgePullOptions{}
+
+ cmd := &cobra.Command{
+ Use: "pull [NAME]",
+ Short: "Pull updates from a remote bug tracker",
+ PreRunE: execenv.LoadBackend(env),
+ RunE: execenv.CloseBackend(env, func(cmd *cobra.Command, args []string) error {
+ return runBridgePull(env, options, args)
+ }),
+ Args: cobra.MaximumNArgs(1),
+ ValidArgsFunction: completion.Bridge(env),
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ flags.BoolVarP(&options.noResume, "no-resume", "n", false, "force importing all bugs")
+ flags.StringVarP(&options.importSince, "since", "s", "", "import only bugs updated after the given date (ex: \"200h\" or \"june 2 2019\")")
+
+ return cmd
+}
+
+func runBridgePull(env *execenv.Env, opts bridgePullOptions, args []string) error {
+ if opts.noResume && opts.importSince != "" {
+ return fmt.Errorf("only one of --no-resume and --since flags should be used")
+ }
+
+ var b *core.Bridge
+ var err error
+
+ if len(args) == 0 {
+ b, err = bridge.DefaultBridge(env.Backend)
+ } else {
+ b, err = bridge.LoadBridge(env.Backend, args[0])
+ }
+
+ if err != nil {
+ return err
+ }
+
+ parentCtx := context.Background()
+ ctx, cancel := context.WithCancel(parentCtx)
+ defer cancel()
+
+ // buffered channel to avoid send block at the end
+ done := make(chan struct{}, 1)
+
+ var mu sync.Mutex
+ interruptCount := 0
+ interrupt.RegisterCleaner(func() error {
+ mu.Lock()
+ if interruptCount > 0 {
+ env.Err.Println("Received another interrupt before graceful stop, terminating...")
+ os.Exit(0)
+ }
+
+ interruptCount++
+ mu.Unlock()
+
+ env.Err.Println("Received interrupt signal, stopping the import...\n(Hit ctrl-c again to kill the process.)")
+
+ // send signal to stop the importer
+ cancel()
+
+ // block until importer gracefully shutdown
+ <-done
+ return nil
+ })
+
+ var events <-chan core.ImportResult
+ switch {
+ case opts.noResume:
+ events, err = b.ImportAllSince(ctx, time.Time{})
+ case opts.importSince != "":
+ since, err2 := parseSince(opts.importSince)
+ if err2 != nil {
+ return errors.Wrap(err2, "import time parsing")
+ }
+ events, err = b.ImportAllSince(ctx, since)
+ default:
+ events, err = b.ImportAll(ctx)
+ }
+
+ if err != nil {
+ return err
+ }
+
+ importedIssues := 0
+ importedIdentities := 0
+ for result := range events {
+ switch result.Event {
+ case core.ImportEventNothing:
+ // filtered
+
+ case core.ImportEventBug:
+ importedIssues++
+ env.Out.Println(result.String())
+
+ case core.ImportEventIdentity:
+ importedIdentities++
+ env.Out.Println(result.String())
+
+ case core.ImportEventError:
+ if result.Err != context.Canceled {
+ env.Out.Println(result.String())
+ }
+
+ default:
+ env.Out.Println(result.String())
+ }
+ }
+
+ env.Out.Printf("imported %d issues and %d identities with %s bridge\n", importedIssues, importedIdentities, b.Name)
+
+ // send done signal
+ close(done)
+
+ return nil
+}
+
+func parseSince(since string) (time.Time, error) {
+ duration, err := time.ParseDuration(since)
+ if err == nil {
+ return time.Now().Add(-duration), nil
+ }
+
+ return dateparse.ParseLocal(since)
+}
diff --git a/commands/bridge/bridge_push.go b/commands/bridge/bridge_push.go
new file mode 100644
index 00000000..51baed4d
--- /dev/null
+++ b/commands/bridge/bridge_push.go
@@ -0,0 +1,99 @@
+package bridgecmd
+
+import (
+ "context"
+ "os"
+ "sync"
+ "time"
+
+ "github.com/spf13/cobra"
+
+ "github.com/MichaelMure/git-bug/bridge"
+ "github.com/MichaelMure/git-bug/bridge/core"
+ "github.com/MichaelMure/git-bug/commands/completion"
+ "github.com/MichaelMure/git-bug/commands/execenv"
+ "github.com/MichaelMure/git-bug/util/interrupt"
+)
+
+func newBridgePushCommand() *cobra.Command {
+ env := execenv.NewEnv()
+
+ cmd := &cobra.Command{
+ Use: "push [NAME]",
+ Short: "Push updates to remote bug tracker",
+ PreRunE: execenv.LoadBackendEnsureUser(env),
+ RunE: execenv.CloseBackend(env, func(cmd *cobra.Command, args []string) error {
+ return runBridgePush(env, args)
+ }),
+ Args: cobra.MaximumNArgs(1),
+ ValidArgsFunction: completion.Bridge(env),
+ }
+
+ return cmd
+}
+
+func runBridgePush(env *execenv.Env, args []string) error {
+ var b *core.Bridge
+ var err error
+
+ if len(args) == 0 {
+ b, err = bridge.DefaultBridge(env.Backend)
+ } else {
+ b, err = bridge.LoadBridge(env.Backend, args[0])
+ }
+
+ if err != nil {
+ return err
+ }
+
+ parentCtx := context.Background()
+ ctx, cancel := context.WithCancel(parentCtx)
+ defer cancel()
+
+ done := make(chan struct{}, 1)
+
+ var mu sync.Mutex
+ interruptCount := 0
+ interrupt.RegisterCleaner(func() error {
+ mu.Lock()
+ if interruptCount > 0 {
+ env.Err.Println("Received another interrupt before graceful stop, terminating...")
+ os.Exit(0)
+ }
+
+ interruptCount++
+ mu.Unlock()
+
+ env.Err.Println("Received interrupt signal, stopping the import...\n(Hit ctrl-c again to kill the process.)")
+
+ // send signal to stop the importer
+ cancel()
+
+ // block until importer gracefully shutdown
+ <-done
+ return nil
+ })
+
+ events, err := b.ExportAll(ctx, time.Time{})
+ if err != nil {
+ return err
+ }
+
+ exportedIssues := 0
+ for result := range events {
+ if result.Event != core.ExportEventNothing {
+ env.Out.Println(result.String())
+ }
+
+ switch result.Event {
+ case core.ExportEventBug:
+ exportedIssues++
+ }
+ }
+
+ env.Out.Printf("exported %d issues with %s bridge\n", exportedIssues, b.Name)
+
+ // send done signal
+ close(done)
+ return nil
+}
diff --git a/commands/bridge/bridge_rm.go b/commands/bridge/bridge_rm.go
new file mode 100644
index 00000000..5d8d23c5
--- /dev/null
+++ b/commands/bridge/bridge_rm.go
@@ -0,0 +1,36 @@
+package bridgecmd
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/MichaelMure/git-bug/bridge"
+ "github.com/MichaelMure/git-bug/commands/completion"
+ "github.com/MichaelMure/git-bug/commands/execenv"
+)
+
+func newBridgeRm() *cobra.Command {
+ env := execenv.NewEnv()
+
+ cmd := &cobra.Command{
+ Use: "rm NAME",
+ Short: "Delete a configured bridge",
+ PreRunE: execenv.LoadBackend(env),
+ RunE: execenv.CloseBackend(env, func(cmd *cobra.Command, args []string) error {
+ return runBridgeRm(env, args)
+ }),
+ Args: cobra.ExactArgs(1),
+ ValidArgsFunction: completion.Bridge(env),
+ }
+
+ return cmd
+}
+
+func runBridgeRm(env *execenv.Env, args []string) error {
+ err := bridge.RemoveBridge(env.Backend, args[0])
+ if err != nil {
+ return err
+ }
+
+ env.Out.Printf("Successfully removed bridge configuration %v\n", args[0])
+ return nil
+}