From 7eb28df79d0b8fcea5c4cb089956ffe77339c77c Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 21 Feb 2026 19:38:36 +0000 Subject: [PATCH] feat: migrate collect, forge, gitea commands from CLI Co-Authored-By: Virgil --- cmd/collect/cmd.go | 112 +++++++++++ cmd/collect/cmd_bitcointalk.go | 64 ++++++ cmd/collect/cmd_dispatch.go | 130 ++++++++++++ cmd/collect/cmd_excavate.go | 103 ++++++++++ cmd/collect/cmd_github.go | 78 ++++++++ cmd/collect/cmd_market.go | 58 ++++++ cmd/collect/cmd_papers.go | 63 ++++++ cmd/collect/cmd_process.go | 48 +++++ cmd/forge/cmd_auth.go | 86 ++++++++ cmd/forge/cmd_config.go | 106 ++++++++++ cmd/forge/cmd_forge.go | 53 +++++ cmd/forge/cmd_issues.go | 200 +++++++++++++++++++ cmd/forge/cmd_labels.go | 120 +++++++++++ cmd/forge/cmd_migrate.go | 121 +++++++++++ cmd/forge/cmd_orgs.go | 66 ++++++ cmd/forge/cmd_prs.go | 98 +++++++++ cmd/forge/cmd_repos.go | 94 +++++++++ cmd/forge/cmd_status.go | 63 ++++++ cmd/forge/cmd_sync.go | 334 +++++++++++++++++++++++++++++++ cmd/forge/helpers.go | 33 +++ cmd/gitea/cmd_config.go | 106 ++++++++++ cmd/gitea/cmd_gitea.go | 47 +++++ cmd/gitea/cmd_issues.go | 133 +++++++++++++ cmd/gitea/cmd_mirror.go | 92 +++++++++ cmd/gitea/cmd_prs.go | 98 +++++++++ cmd/gitea/cmd_repos.go | 125 ++++++++++++ cmd/gitea/cmd_sync.go | 353 +++++++++++++++++++++++++++++++++ go.mod | 20 ++ go.sum | 52 +++++ 29 files changed, 3056 insertions(+) create mode 100644 cmd/collect/cmd.go create mode 100644 cmd/collect/cmd_bitcointalk.go create mode 100644 cmd/collect/cmd_dispatch.go create mode 100644 cmd/collect/cmd_excavate.go create mode 100644 cmd/collect/cmd_github.go create mode 100644 cmd/collect/cmd_market.go create mode 100644 cmd/collect/cmd_papers.go create mode 100644 cmd/collect/cmd_process.go create mode 100644 cmd/forge/cmd_auth.go create mode 100644 cmd/forge/cmd_config.go create mode 100644 cmd/forge/cmd_forge.go create mode 100644 cmd/forge/cmd_issues.go create mode 100644 cmd/forge/cmd_labels.go create mode 100644 cmd/forge/cmd_migrate.go create mode 100644 cmd/forge/cmd_orgs.go create mode 100644 cmd/forge/cmd_prs.go create mode 100644 cmd/forge/cmd_repos.go create mode 100644 cmd/forge/cmd_status.go create mode 100644 cmd/forge/cmd_sync.go create mode 100644 cmd/forge/helpers.go create mode 100644 cmd/gitea/cmd_config.go create mode 100644 cmd/gitea/cmd_gitea.go create mode 100644 cmd/gitea/cmd_issues.go create mode 100644 cmd/gitea/cmd_mirror.go create mode 100644 cmd/gitea/cmd_prs.go create mode 100644 cmd/gitea/cmd_repos.go create mode 100644 cmd/gitea/cmd_sync.go diff --git a/cmd/collect/cmd.go b/cmd/collect/cmd.go new file mode 100644 index 0000000..e1a8193 --- /dev/null +++ b/cmd/collect/cmd.go @@ -0,0 +1,112 @@ +package collect + +import ( + "fmt" + + "forge.lthn.ai/core/go/pkg/cli" + "forge.lthn.ai/core/go-scm/collect" + "forge.lthn.ai/core/go/pkg/i18n" + "forge.lthn.ai/core/go/pkg/io" +) + +func init() { + cli.RegisterCommands(AddCollectCommands) +} + +// Style aliases from shared package +var ( + dimStyle = cli.DimStyle + successStyle = cli.SuccessStyle + errorStyle = cli.ErrorStyle +) + +// Shared flags across all collect subcommands +var ( + collectOutputDir string + collectVerbose bool + collectDryRun bool +) + +// AddCollectCommands registers the 'collect' command and all subcommands. +func AddCollectCommands(root *cli.Command) { + collectCmd := &cli.Command{ + Use: "collect", + Short: i18n.T("cmd.collect.short"), + Long: i18n.T("cmd.collect.long"), + } + + // Persistent flags shared across subcommands + cli.PersistentStringFlag(collectCmd, &collectOutputDir, "output", "o", "./collect", i18n.T("cmd.collect.flag.output")) + cli.PersistentBoolFlag(collectCmd, &collectVerbose, "verbose", "v", false, i18n.T("common.flag.verbose")) + cli.PersistentBoolFlag(collectCmd, &collectDryRun, "dry-run", "", false, i18n.T("cmd.collect.flag.dry_run")) + + root.AddCommand(collectCmd) + + addGitHubCommand(collectCmd) + addBitcoinTalkCommand(collectCmd) + addMarketCommand(collectCmd) + addPapersCommand(collectCmd) + addExcavateCommand(collectCmd) + addProcessCommand(collectCmd) + addDispatchCommand(collectCmd) +} + +// newConfig creates a collection Config using the shared persistent flags. +// It uses io.Local for real filesystem access rather than the mock medium. +func newConfig() *collect.Config { + cfg := collect.NewConfigWithMedium(io.Local, collectOutputDir) + cfg.Verbose = collectVerbose + cfg.DryRun = collectDryRun + return cfg +} + +// setupVerboseLogging registers event handlers on the dispatcher for verbose output. +func setupVerboseLogging(cfg *collect.Config) { + if !cfg.Verbose { + return + } + + cfg.Dispatcher.On(collect.EventStart, func(e collect.Event) { + cli.Print("%s %s\n", dimStyle.Render("[start]"), e.Message) + }) + cfg.Dispatcher.On(collect.EventProgress, func(e collect.Event) { + cli.Print("%s %s\n", dimStyle.Render("[progress]"), e.Message) + }) + cfg.Dispatcher.On(collect.EventItem, func(e collect.Event) { + cli.Print("%s %s\n", dimStyle.Render("[item]"), e.Message) + }) + cfg.Dispatcher.On(collect.EventError, func(e collect.Event) { + cli.Print("%s %s\n", errorStyle.Render("[error]"), e.Message) + }) + cfg.Dispatcher.On(collect.EventComplete, func(e collect.Event) { + cli.Print("%s %s\n", successStyle.Render("[complete]"), e.Message) + }) +} + +// printResult prints a formatted summary of a collection result. +func printResult(result *collect.Result) { + if result == nil { + return + } + + if result.Items > 0 { + cli.Success(fmt.Sprintf("Collected %d items from %s", result.Items, result.Source)) + } else { + cli.Dim(fmt.Sprintf("No items collected from %s", result.Source)) + } + + if result.Skipped > 0 { + cli.Dim(fmt.Sprintf(" Skipped: %d", result.Skipped)) + } + + if result.Errors > 0 { + cli.Warn(fmt.Sprintf(" Errors: %d", result.Errors)) + } + + if collectVerbose && len(result.Files) > 0 { + cli.Dim(fmt.Sprintf(" Files: %d", len(result.Files))) + for _, f := range result.Files { + cli.Print(" %s\n", dimStyle.Render(f)) + } + } +} diff --git a/cmd/collect/cmd_bitcointalk.go b/cmd/collect/cmd_bitcointalk.go new file mode 100644 index 0000000..92d9d4c --- /dev/null +++ b/cmd/collect/cmd_bitcointalk.go @@ -0,0 +1,64 @@ +package collect + +import ( + "context" + "strings" + + "forge.lthn.ai/core/go/pkg/cli" + "forge.lthn.ai/core/go-scm/collect" + "forge.lthn.ai/core/go/pkg/i18n" +) + +// BitcoinTalk command flags +var bitcointalkPages int + +// addBitcoinTalkCommand adds the 'bitcointalk' subcommand to the collect parent. +func addBitcoinTalkCommand(parent *cli.Command) { + btcCmd := &cli.Command{ + Use: "bitcointalk ", + Short: i18n.T("cmd.collect.bitcointalk.short"), + Long: i18n.T("cmd.collect.bitcointalk.long"), + Args: cli.ExactArgs(1), + RunE: func(cmd *cli.Command, args []string) error { + return runBitcoinTalk(args[0]) + }, + } + + cli.IntFlag(btcCmd, &bitcointalkPages, "pages", "p", 0, i18n.T("cmd.collect.bitcointalk.flag.pages")) + + parent.AddCommand(btcCmd) +} + +func runBitcoinTalk(target string) error { + var topicID, url string + + // Determine if argument is a URL or topic ID + if strings.HasPrefix(target, "http") { + url = target + } else { + topicID = target + } + + cfg := newConfig() + setupVerboseLogging(cfg) + + collector := &collect.BitcoinTalkCollector{ + TopicID: topicID, + URL: url, + Pages: bitcointalkPages, + } + + if cfg.DryRun { + cli.Info("Dry run: would collect from BitcoinTalk topic " + target) + return nil + } + + ctx := context.Background() + result, err := collector.Collect(ctx, cfg) + if err != nil { + return cli.Wrap(err, "bitcointalk collection failed") + } + + printResult(result) + return nil +} diff --git a/cmd/collect/cmd_dispatch.go b/cmd/collect/cmd_dispatch.go new file mode 100644 index 0000000..09fafe6 --- /dev/null +++ b/cmd/collect/cmd_dispatch.go @@ -0,0 +1,130 @@ +package collect + +import ( + "fmt" + "time" + + "forge.lthn.ai/core/go/pkg/cli" + collectpkg "forge.lthn.ai/core/go-scm/collect" + "forge.lthn.ai/core/go/pkg/i18n" +) + +// addDispatchCommand adds the 'dispatch' subcommand to the collect parent. +func addDispatchCommand(parent *cli.Command) { + dispatchCmd := &cli.Command{ + Use: "dispatch ", + Short: i18n.T("cmd.collect.dispatch.short"), + Long: i18n.T("cmd.collect.dispatch.long"), + Args: cli.MinimumNArgs(1), + RunE: func(cmd *cli.Command, args []string) error { + return runDispatch(args[0]) + }, + } + + // Add hooks subcommand group + hooksCmd := &cli.Command{ + Use: "hooks", + Short: i18n.T("cmd.collect.dispatch.hooks.short"), + } + + addHooksListCommand(hooksCmd) + addHooksRegisterCommand(hooksCmd) + + dispatchCmd.AddCommand(hooksCmd) + parent.AddCommand(dispatchCmd) +} + +func runDispatch(eventType string) error { + cfg := newConfig() + setupVerboseLogging(cfg) + + // Validate event type + switch eventType { + case collectpkg.EventStart, + collectpkg.EventProgress, + collectpkg.EventItem, + collectpkg.EventError, + collectpkg.EventComplete: + // Valid event type + default: + return cli.Err("unknown event type: %s (valid: start, progress, item, error, complete)", eventType) + } + + event := collectpkg.Event{ + Type: eventType, + Source: "cli", + Message: fmt.Sprintf("Manual dispatch of %s event", eventType), + Time: time.Now(), + } + + cfg.Dispatcher.Emit(event) + cli.Success(fmt.Sprintf("Dispatched %s event", eventType)) + + return nil +} + +// addHooksListCommand adds the 'hooks list' subcommand. +func addHooksListCommand(parent *cli.Command) { + listCmd := &cli.Command{ + Use: "list", + Short: i18n.T("cmd.collect.dispatch.hooks.list.short"), + RunE: func(cmd *cli.Command, args []string) error { + return runHooksList() + }, + } + + parent.AddCommand(listCmd) +} + +func runHooksList() error { + eventTypes := []string{ + collectpkg.EventStart, + collectpkg.EventProgress, + collectpkg.EventItem, + collectpkg.EventError, + collectpkg.EventComplete, + } + + table := cli.NewTable("Event", "Status") + for _, et := range eventTypes { + table.AddRow(et, dimStyle.Render("no hooks registered")) + } + + cli.Blank() + cli.Print("%s\n\n", cli.HeaderStyle.Render("Registered Hooks")) + table.Render() + cli.Blank() + + return nil +} + +// addHooksRegisterCommand adds the 'hooks register' subcommand. +func addHooksRegisterCommand(parent *cli.Command) { + registerCmd := &cli.Command{ + Use: "register ", + Short: i18n.T("cmd.collect.dispatch.hooks.register.short"), + Args: cli.ExactArgs(2), + RunE: func(cmd *cli.Command, args []string) error { + return runHooksRegister(args[0], args[1]) + }, + } + + parent.AddCommand(registerCmd) +} + +func runHooksRegister(eventType, command string) error { + // Validate event type + switch eventType { + case collectpkg.EventStart, + collectpkg.EventProgress, + collectpkg.EventItem, + collectpkg.EventError, + collectpkg.EventComplete: + // Valid + default: + return cli.Err("unknown event type: %s (valid: start, progress, item, error, complete)", eventType) + } + + cli.Success(fmt.Sprintf("Registered hook for %s: %s", eventType, command)) + return nil +} diff --git a/cmd/collect/cmd_excavate.go b/cmd/collect/cmd_excavate.go new file mode 100644 index 0000000..5aa4fbd --- /dev/null +++ b/cmd/collect/cmd_excavate.go @@ -0,0 +1,103 @@ +package collect + +import ( + "context" + "fmt" + + "forge.lthn.ai/core/go/pkg/cli" + "forge.lthn.ai/core/go-scm/collect" + "forge.lthn.ai/core/go/pkg/i18n" +) + +// Excavate command flags +var ( + excavateScanOnly bool + excavateResume bool +) + +// addExcavateCommand adds the 'excavate' subcommand to the collect parent. +func addExcavateCommand(parent *cli.Command) { + excavateCmd := &cli.Command{ + Use: "excavate ", + Short: i18n.T("cmd.collect.excavate.short"), + Long: i18n.T("cmd.collect.excavate.long"), + Args: cli.ExactArgs(1), + RunE: func(cmd *cli.Command, args []string) error { + return runExcavate(args[0]) + }, + } + + cli.BoolFlag(excavateCmd, &excavateScanOnly, "scan-only", "", false, i18n.T("cmd.collect.excavate.flag.scan_only")) + cli.BoolFlag(excavateCmd, &excavateResume, "resume", "r", false, i18n.T("cmd.collect.excavate.flag.resume")) + + parent.AddCommand(excavateCmd) +} + +func runExcavate(project string) error { + cfg := newConfig() + setupVerboseLogging(cfg) + + // Load state for resume + if excavateResume { + if err := cfg.State.Load(); err != nil { + return cli.Wrap(err, "failed to load collection state") + } + } + + // Build collectors for the project + collectors := buildProjectCollectors(project) + if len(collectors) == 0 { + return cli.Err("no collectors configured for project: %s", project) + } + + excavator := &collect.Excavator{ + Collectors: collectors, + ScanOnly: excavateScanOnly, + Resume: excavateResume, + } + + if cfg.DryRun { + cli.Info(fmt.Sprintf("Dry run: would excavate project %s with %d collectors", project, len(collectors))) + for _, c := range collectors { + cli.Dim(fmt.Sprintf(" - %s", c.Name())) + } + return nil + } + + ctx := context.Background() + result, err := excavator.Run(ctx, cfg) + if err != nil { + return cli.Wrap(err, "excavation failed") + } + + // Save state for future resume + if err := cfg.State.Save(); err != nil { + cli.Warnf("Failed to save state: %v", err) + } + + printResult(result) + return nil +} + +// buildProjectCollectors creates collectors based on the project name. +// This maps known project names to their collector configurations. +func buildProjectCollectors(project string) []collect.Collector { + switch project { + case "bitcoin": + return []collect.Collector{ + &collect.GitHubCollector{Org: "bitcoin", Repo: "bitcoin"}, + &collect.MarketCollector{CoinID: "bitcoin", Historical: true}, + } + case "ethereum": + return []collect.Collector{ + &collect.GitHubCollector{Org: "ethereum", Repo: "go-ethereum"}, + &collect.MarketCollector{CoinID: "ethereum", Historical: true}, + &collect.PapersCollector{Source: "all", Query: "ethereum"}, + } + default: + // Treat unknown projects as GitHub org/repo + return []collect.Collector{ + &collect.GitHubCollector{Org: project}, + } + } +} diff --git a/cmd/collect/cmd_github.go b/cmd/collect/cmd_github.go new file mode 100644 index 0000000..c71980f --- /dev/null +++ b/cmd/collect/cmd_github.go @@ -0,0 +1,78 @@ +package collect + +import ( + "context" + "strings" + + "forge.lthn.ai/core/go/pkg/cli" + "forge.lthn.ai/core/go-scm/collect" + "forge.lthn.ai/core/go/pkg/i18n" +) + +// GitHub command flags +var ( + githubOrg bool + githubIssuesOnly bool + githubPRsOnly bool +) + +// addGitHubCommand adds the 'github' subcommand to the collect parent. +func addGitHubCommand(parent *cli.Command) { + githubCmd := &cli.Command{ + Use: "github ", + Short: i18n.T("cmd.collect.github.short"), + Long: i18n.T("cmd.collect.github.long"), + Args: cli.MinimumNArgs(1), + RunE: func(cmd *cli.Command, args []string) error { + return runGitHub(args[0]) + }, + } + + cli.BoolFlag(githubCmd, &githubOrg, "org", "", false, i18n.T("cmd.collect.github.flag.org")) + cli.BoolFlag(githubCmd, &githubIssuesOnly, "issues-only", "", false, i18n.T("cmd.collect.github.flag.issues_only")) + cli.BoolFlag(githubCmd, &githubPRsOnly, "prs-only", "", false, i18n.T("cmd.collect.github.flag.prs_only")) + + parent.AddCommand(githubCmd) +} + +func runGitHub(target string) error { + if githubIssuesOnly && githubPRsOnly { + return cli.Err("--issues-only and --prs-only are mutually exclusive") + } + + // Parse org/repo argument + var org, repo string + if strings.Contains(target, "/") { + parts := strings.SplitN(target, "/", 2) + org = parts[0] + repo = parts[1] + } else if githubOrg { + org = target + } else { + return cli.Err("argument must be in org/repo format, or use --org for organisation-wide collection") + } + + cfg := newConfig() + setupVerboseLogging(cfg) + + collector := &collect.GitHubCollector{ + Org: org, + Repo: repo, + IssuesOnly: githubIssuesOnly, + PRsOnly: githubPRsOnly, + } + + if cfg.DryRun { + cli.Info("Dry run: would collect from GitHub " + target) + return nil + } + + ctx := context.Background() + result, err := collector.Collect(ctx, cfg) + if err != nil { + return cli.Wrap(err, "github collection failed") + } + + printResult(result) + return nil +} diff --git a/cmd/collect/cmd_market.go b/cmd/collect/cmd_market.go new file mode 100644 index 0000000..57b874a --- /dev/null +++ b/cmd/collect/cmd_market.go @@ -0,0 +1,58 @@ +package collect + +import ( + "context" + + "forge.lthn.ai/core/go/pkg/cli" + "forge.lthn.ai/core/go-scm/collect" + "forge.lthn.ai/core/go/pkg/i18n" +) + +// Market command flags +var ( + marketHistorical bool + marketFromDate string +) + +// addMarketCommand adds the 'market' subcommand to the collect parent. +func addMarketCommand(parent *cli.Command) { + marketCmd := &cli.Command{ + Use: "market ", + Short: i18n.T("cmd.collect.market.short"), + Long: i18n.T("cmd.collect.market.long"), + Args: cli.ExactArgs(1), + RunE: func(cmd *cli.Command, args []string) error { + return runMarket(args[0]) + }, + } + + cli.BoolFlag(marketCmd, &marketHistorical, "historical", "H", false, i18n.T("cmd.collect.market.flag.historical")) + cli.StringFlag(marketCmd, &marketFromDate, "from", "f", "", i18n.T("cmd.collect.market.flag.from")) + + parent.AddCommand(marketCmd) +} + +func runMarket(coinID string) error { + cfg := newConfig() + setupVerboseLogging(cfg) + + collector := &collect.MarketCollector{ + CoinID: coinID, + Historical: marketHistorical, + FromDate: marketFromDate, + } + + if cfg.DryRun { + cli.Info("Dry run: would collect market data for " + coinID) + return nil + } + + ctx := context.Background() + result, err := collector.Collect(ctx, cfg) + if err != nil { + return cli.Wrap(err, "market collection failed") + } + + printResult(result) + return nil +} diff --git a/cmd/collect/cmd_papers.go b/cmd/collect/cmd_papers.go new file mode 100644 index 0000000..547181c --- /dev/null +++ b/cmd/collect/cmd_papers.go @@ -0,0 +1,63 @@ +package collect + +import ( + "context" + + "forge.lthn.ai/core/go/pkg/cli" + "forge.lthn.ai/core/go-scm/collect" + "forge.lthn.ai/core/go/pkg/i18n" +) + +// Papers command flags +var ( + papersSource string + papersCategory string + papersQuery string +) + +// addPapersCommand adds the 'papers' subcommand to the collect parent. +func addPapersCommand(parent *cli.Command) { + papersCmd := &cli.Command{ + Use: "papers", + Short: i18n.T("cmd.collect.papers.short"), + Long: i18n.T("cmd.collect.papers.long"), + RunE: func(cmd *cli.Command, args []string) error { + return runPapers() + }, + } + + cli.StringFlag(papersCmd, &papersSource, "source", "s", "all", i18n.T("cmd.collect.papers.flag.source")) + cli.StringFlag(papersCmd, &papersCategory, "category", "c", "", i18n.T("cmd.collect.papers.flag.category")) + cli.StringFlag(papersCmd, &papersQuery, "query", "q", "", i18n.T("cmd.collect.papers.flag.query")) + + parent.AddCommand(papersCmd) +} + +func runPapers() error { + if papersQuery == "" { + return cli.Err("--query (-q) is required") + } + + cfg := newConfig() + setupVerboseLogging(cfg) + + collector := &collect.PapersCollector{ + Source: papersSource, + Category: papersCategory, + Query: papersQuery, + } + + if cfg.DryRun { + cli.Info("Dry run: would collect papers from " + papersSource) + return nil + } + + ctx := context.Background() + result, err := collector.Collect(ctx, cfg) + if err != nil { + return cli.Wrap(err, "papers collection failed") + } + + printResult(result) + return nil +} diff --git a/cmd/collect/cmd_process.go b/cmd/collect/cmd_process.go new file mode 100644 index 0000000..c3eb6b4 --- /dev/null +++ b/cmd/collect/cmd_process.go @@ -0,0 +1,48 @@ +package collect + +import ( + "context" + + "forge.lthn.ai/core/go/pkg/cli" + "forge.lthn.ai/core/go-scm/collect" + "forge.lthn.ai/core/go/pkg/i18n" +) + +// addProcessCommand adds the 'process' subcommand to the collect parent. +func addProcessCommand(parent *cli.Command) { + processCmd := &cli.Command{ + Use: "process ", + Short: i18n.T("cmd.collect.process.short"), + Long: i18n.T("cmd.collect.process.long"), + Args: cli.ExactArgs(2), + RunE: func(cmd *cli.Command, args []string) error { + return runProcess(args[0], args[1]) + }, + } + + parent.AddCommand(processCmd) +} + +func runProcess(source, dir string) error { + cfg := newConfig() + setupVerboseLogging(cfg) + + processor := &collect.Processor{ + Source: source, + Dir: dir, + } + + if cfg.DryRun { + cli.Info("Dry run: would process " + source + " data in " + dir) + return nil + } + + ctx := context.Background() + result, err := processor.Process(ctx, cfg) + if err != nil { + return cli.Wrap(err, "processing failed") + } + + printResult(result) + return nil +} diff --git a/cmd/forge/cmd_auth.go b/cmd/forge/cmd_auth.go new file mode 100644 index 0000000..c488a3f --- /dev/null +++ b/cmd/forge/cmd_auth.go @@ -0,0 +1,86 @@ +package forge + +import ( + "fmt" + + "forge.lthn.ai/core/go/pkg/cli" + fg "forge.lthn.ai/core/go-scm/forge" +) + +// Auth command flags. +var ( + authURL string + authToken string +) + +// addAuthCommand adds the 'auth' subcommand for authentication status and login. +func addAuthCommand(parent *cli.Command) { + cmd := &cli.Command{ + Use: "auth", + Short: "Show authentication status", + Long: "Show the current Forgejo authentication status, or log in with a new token.", + RunE: func(cmd *cli.Command, args []string) error { + return runAuth() + }, + } + + cmd.Flags().StringVar(&authURL, "url", "", "Forgejo instance URL") + cmd.Flags().StringVar(&authToken, "token", "", "API token (create at /user/settings/applications)") + + parent.AddCommand(cmd) +} + +func runAuth() error { + // If credentials provided, save them first + if authURL != "" || authToken != "" { + if err := fg.SaveConfig(authURL, authToken); err != nil { + return err + } + if authURL != "" { + cli.Success(fmt.Sprintf("URL set to %s", authURL)) + } + if authToken != "" { + cli.Success("Token saved") + } + } + + // Always show current auth status + url, token, err := fg.ResolveConfig(authURL, authToken) + if err != nil { + return err + } + + if token == "" { + cli.Blank() + cli.Print(" %s %s\n", dimStyle.Render("URL:"), valueStyle.Render(url)) + cli.Print(" %s %s\n", dimStyle.Render("Auth:"), warningStyle.Render("not authenticated")) + cli.Print(" %s %s\n", dimStyle.Render("Hint:"), dimStyle.Render(fmt.Sprintf("core forge auth --token TOKEN (create at %s/user/settings/applications)", url))) + cli.Blank() + return nil + } + + client, err := fg.NewFromConfig(authURL, authToken) + if err != nil { + return err + } + + user, _, err := client.API().GetMyUserInfo() + if err != nil { + cli.Blank() + cli.Print(" %s %s\n", dimStyle.Render("URL:"), valueStyle.Render(url)) + cli.Print(" %s %s\n", dimStyle.Render("Auth:"), errorStyle.Render("token invalid or expired")) + cli.Blank() + return nil + } + + cli.Blank() + cli.Success(fmt.Sprintf("Authenticated to %s", client.URL())) + cli.Print(" %s %s\n", dimStyle.Render("User:"), valueStyle.Render(user.UserName)) + cli.Print(" %s %s\n", dimStyle.Render("Email:"), valueStyle.Render(user.Email)) + if user.IsAdmin { + cli.Print(" %s %s\n", dimStyle.Render("Role:"), infoStyle.Render("admin")) + } + cli.Blank() + + return nil +} diff --git a/cmd/forge/cmd_config.go b/cmd/forge/cmd_config.go new file mode 100644 index 0000000..85749d2 --- /dev/null +++ b/cmd/forge/cmd_config.go @@ -0,0 +1,106 @@ +package forge + +import ( + "fmt" + + "forge.lthn.ai/core/go/pkg/cli" + fg "forge.lthn.ai/core/go-scm/forge" +) + +// Config command flags. +var ( + configURL string + configToken string + configTest bool +) + +// addConfigCommand adds the 'config' subcommand for Forgejo connection setup. +func addConfigCommand(parent *cli.Command) { + cmd := &cli.Command{ + Use: "config", + Short: "Configure Forgejo connection", + Long: "Set the Forgejo instance URL and API token, or test the current connection.", + RunE: func(cmd *cli.Command, args []string) error { + return runConfig() + }, + } + + cmd.Flags().StringVar(&configURL, "url", "", "Forgejo instance URL") + cmd.Flags().StringVar(&configToken, "token", "", "Forgejo API token") + cmd.Flags().BoolVar(&configTest, "test", false, "Test the current connection") + + parent.AddCommand(cmd) +} + +func runConfig() error { + // If setting values, save them first + if configURL != "" || configToken != "" { + if err := fg.SaveConfig(configURL, configToken); err != nil { + return err + } + + if configURL != "" { + cli.Success(fmt.Sprintf("Forgejo URL set to %s", configURL)) + } + if configToken != "" { + cli.Success("Forgejo token saved") + } + } + + // If testing, verify the connection + if configTest { + return runConfigTest() + } + + // If no flags, show current config + if configURL == "" && configToken == "" && !configTest { + return showConfig() + } + + return nil +} + +func showConfig() error { + url, token, err := fg.ResolveConfig("", "") + if err != nil { + return err + } + + cli.Blank() + cli.Print(" %s %s\n", dimStyle.Render("URL:"), valueStyle.Render(url)) + + if token != "" { + masked := token + if len(token) >= 8 { + masked = token[:4] + "..." + token[len(token)-4:] + } + cli.Print(" %s %s\n", dimStyle.Render("Token:"), valueStyle.Render(masked)) + } else { + cli.Print(" %s %s\n", dimStyle.Render("Token:"), warningStyle.Render("not set")) + } + + cli.Blank() + + return nil +} + +func runConfigTest() error { + client, err := fg.NewFromConfig(configURL, configToken) + if err != nil { + return err + } + + user, _, err := client.API().GetMyUserInfo() + if err != nil { + cli.Error("Connection failed") + return cli.WrapVerb(err, "connect to", "Forgejo") + } + + cli.Blank() + cli.Success(fmt.Sprintf("Connected to %s", client.URL())) + cli.Print(" %s %s\n", dimStyle.Render("User:"), valueStyle.Render(user.UserName)) + cli.Print(" %s %s\n", dimStyle.Render("Email:"), valueStyle.Render(user.Email)) + cli.Blank() + + return nil +} diff --git a/cmd/forge/cmd_forge.go b/cmd/forge/cmd_forge.go new file mode 100644 index 0000000..246729e --- /dev/null +++ b/cmd/forge/cmd_forge.go @@ -0,0 +1,53 @@ +// Package forge provides CLI commands for managing a Forgejo instance. +// +// Commands: +// - config: Configure Forgejo connection (URL, token) +// - status: Show instance status and version +// - repos: List repositories +// - issues: List and create issues +// - prs: List pull requests +// - migrate: Migrate repos from external services +// - sync: Sync GitHub repos to Forgejo upstream branches +// - orgs: List organisations +// - labels: List and create labels +package forge + +import ( + "forge.lthn.ai/core/go/pkg/cli" +) + +func init() { + cli.RegisterCommands(AddForgeCommands) +} + +// Style aliases from shared package. +var ( + successStyle = cli.SuccessStyle + errorStyle = cli.ErrorStyle + warningStyle = cli.WarningStyle + dimStyle = cli.DimStyle + valueStyle = cli.ValueStyle + repoStyle = cli.RepoStyle + numberStyle = cli.NumberStyle + infoStyle = cli.InfoStyle +) + +// AddForgeCommands registers the 'forge' command and all subcommands. +func AddForgeCommands(root *cli.Command) { + forgeCmd := &cli.Command{ + Use: "forge", + Short: "Forgejo instance management", + Long: "Manage repositories, issues, pull requests, and organisations on your Forgejo instance.", + } + root.AddCommand(forgeCmd) + + addConfigCommand(forgeCmd) + addStatusCommand(forgeCmd) + addReposCommand(forgeCmd) + addIssuesCommand(forgeCmd) + addPRsCommand(forgeCmd) + addMigrateCommand(forgeCmd) + addSyncCommand(forgeCmd) + addOrgsCommand(forgeCmd) + addLabelsCommand(forgeCmd) +} diff --git a/cmd/forge/cmd_issues.go b/cmd/forge/cmd_issues.go new file mode 100644 index 0000000..6b40644 --- /dev/null +++ b/cmd/forge/cmd_issues.go @@ -0,0 +1,200 @@ +package forge + +import ( + "fmt" + "strings" + + forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2" + + "forge.lthn.ai/core/go/pkg/cli" + fg "forge.lthn.ai/core/go-scm/forge" +) + +// Issues command flags. +var ( + issuesState string + issuesTitle string + issuesBody string +) + +// addIssuesCommand adds the 'issues' subcommand for listing and creating issues. +func addIssuesCommand(parent *cli.Command) { + cmd := &cli.Command{ + Use: "issues [owner/repo]", + Short: "List and manage issues", + Long: "List issues for a repository, or list all open issues across all your repos.", + Args: cli.MaximumNArgs(1), + RunE: func(cmd *cli.Command, args []string) error { + if len(args) == 0 { + return runListAllIssues() + } + + owner, repo, err := splitOwnerRepo(args[0]) + if err != nil { + return err + } + + // If title is set, create an issue instead + if issuesTitle != "" { + return runCreateIssue(owner, repo) + } + + return runListIssues(owner, repo) + }, + } + + cmd.Flags().StringVar(&issuesState, "state", "open", "Filter by state (open, closed, all)") + cmd.Flags().StringVar(&issuesTitle, "title", "", "Create issue with this title") + cmd.Flags().StringVar(&issuesBody, "body", "", "Issue body (used with --title)") + + parent.AddCommand(cmd) +} + +func runListAllIssues() error { + client, err := fg.NewFromConfig("", "") + if err != nil { + return err + } + + // Collect all repos: user repos + all org repos, deduplicated + seen := make(map[string]bool) + var allRepos []*forgejo.Repository + + userRepos, err := client.ListUserRepos() + if err == nil { + for _, r := range userRepos { + if !seen[r.FullName] { + seen[r.FullName] = true + allRepos = append(allRepos, r) + } + } + } + + orgs, err := client.ListMyOrgs() + if err != nil { + return err + } + + for _, org := range orgs { + repos, err := client.ListOrgRepos(org.UserName) + if err != nil { + continue + } + for _, r := range repos { + if !seen[r.FullName] { + seen[r.FullName] = true + allRepos = append(allRepos, r) + } + } + } + + total := 0 + cli.Blank() + + for _, repo := range allRepos { + if repo.OpenIssues == 0 { + continue + } + + owner, name := repo.Owner.UserName, repo.Name + issues, err := client.ListIssues(owner, name, fg.ListIssuesOpts{ + State: issuesState, + }) + if err != nil || len(issues) == 0 { + continue + } + + cli.Print(" %s %s\n", repoStyle.Render(repo.FullName), dimStyle.Render(fmt.Sprintf("(%d)", len(issues)))) + for _, issue := range issues { + printForgeIssue(issue) + } + cli.Blank() + total += len(issues) + } + + if total == 0 { + cli.Text(fmt.Sprintf("No %s issues found.", issuesState)) + } else { + cli.Print(" %s\n", dimStyle.Render(fmt.Sprintf("%d %s issues total", total, issuesState))) + } + cli.Blank() + + return nil +} + +func runListIssues(owner, repo string) error { + client, err := fg.NewFromConfig("", "") + if err != nil { + return err + } + + issues, err := client.ListIssues(owner, repo, fg.ListIssuesOpts{ + State: issuesState, + }) + if err != nil { + return err + } + + if len(issues) == 0 { + cli.Text(fmt.Sprintf("No %s issues in %s/%s.", issuesState, owner, repo)) + return nil + } + + cli.Blank() + cli.Print(" %s\n\n", fmt.Sprintf("%d %s issues in %s/%s", len(issues), issuesState, owner, repo)) + + for _, issue := range issues { + printForgeIssue(issue) + } + + return nil +} + +func runCreateIssue(owner, repo string) error { + client, err := fg.NewFromConfig("", "") + if err != nil { + return err + } + + issue, err := client.CreateIssue(owner, repo, forgejo.CreateIssueOption{ + Title: issuesTitle, + Body: issuesBody, + }) + if err != nil { + return err + } + + cli.Blank() + cli.Success(fmt.Sprintf("Created issue #%d: %s", issue.Index, issue.Title)) + cli.Print(" %s %s\n", dimStyle.Render("URL:"), valueStyle.Render(issue.HTMLURL)) + cli.Blank() + + return nil +} + +func printForgeIssue(issue *forgejo.Issue) { + num := numberStyle.Render(fmt.Sprintf("#%d", issue.Index)) + title := valueStyle.Render(cli.Truncate(issue.Title, 60)) + + line := fmt.Sprintf(" %s %s", num, title) + + // Add labels + if len(issue.Labels) > 0 { + var labels []string + for _, l := range issue.Labels { + labels = append(labels, l.Name) + } + line += " " + warningStyle.Render("["+strings.Join(labels, ", ")+"]") + } + + // Add assignees + if len(issue.Assignees) > 0 { + var assignees []string + for _, a := range issue.Assignees { + assignees = append(assignees, "@"+a.UserName) + } + line += " " + infoStyle.Render(strings.Join(assignees, ", ")) + } + + cli.Text(line) +} diff --git a/cmd/forge/cmd_labels.go b/cmd/forge/cmd_labels.go new file mode 100644 index 0000000..5ad421a --- /dev/null +++ b/cmd/forge/cmd_labels.go @@ -0,0 +1,120 @@ +package forge + +import ( + "fmt" + + forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2" + + "forge.lthn.ai/core/go/pkg/cli" + fg "forge.lthn.ai/core/go-scm/forge" +) + +// Labels command flags. +var ( + labelsCreate string + labelsColor string + labelsRepo string +) + +// addLabelsCommand adds the 'labels' subcommand for listing and creating labels. +func addLabelsCommand(parent *cli.Command) { + cmd := &cli.Command{ + Use: "labels ", + Short: "List and manage labels", + Long: `List labels from an organisation's repos, or create a new label. + +Labels are listed from the first repo in the organisation. Use --repo to target a specific repo. + +Examples: + core forge labels Private-Host-UK + core forge labels Private-Host-UK --create "feature" --color "00aabb" + core forge labels Private-Host-UK --repo Enchantrix`, + Args: cli.ExactArgs(1), + RunE: func(cmd *cli.Command, args []string) error { + if labelsCreate != "" { + return runCreateLabel(args[0]) + } + return runListLabels(args[0]) + }, + } + + cmd.Flags().StringVar(&labelsCreate, "create", "", "Create a label with this name") + cmd.Flags().StringVar(&labelsColor, "color", "0075ca", "Label colour (hex, e.g. 00aabb)") + cmd.Flags().StringVar(&labelsRepo, "repo", "", "Target a specific repo (default: first org repo)") + + parent.AddCommand(cmd) +} + +func runListLabels(org string) error { + client, err := fg.NewFromConfig("", "") + if err != nil { + return err + } + + var labels []*forgejo.Label + if labelsRepo != "" { + labels, err = client.ListRepoLabels(org, labelsRepo) + } else { + labels, err = client.ListOrgLabels(org) + } + if err != nil { + return err + } + + if len(labels) == 0 { + cli.Text("No labels found.") + return nil + } + + cli.Blank() + cli.Print(" %s\n\n", fmt.Sprintf("%d labels", len(labels))) + + table := cli.NewTable("Name", "Color", "Description") + + for _, l := range labels { + table.AddRow( + warningStyle.Render(l.Name), + dimStyle.Render("#"+l.Color), + cli.Truncate(l.Description, 50), + ) + } + + table.Render() + + return nil +} + +func runCreateLabel(org string) error { + client, err := fg.NewFromConfig("", "") + if err != nil { + return err + } + + // Determine target repo + repo := labelsRepo + if repo == "" { + repos, err := client.ListOrgRepos(org) + if err != nil { + return err + } + if len(repos) == 0 { + return cli.Err("no repos in org %s to create label on", org) + } + repo = repos[0].Name + org = repos[0].Owner.UserName + } + + label, err := client.CreateRepoLabel(org, repo, forgejo.CreateLabelOption{ + Name: labelsCreate, + Color: "#" + labelsColor, + }) + if err != nil { + return err + } + + cli.Blank() + cli.Success(fmt.Sprintf("Created label %q on %s/%s", label.Name, org, repo)) + cli.Blank() + + return nil +} diff --git a/cmd/forge/cmd_migrate.go b/cmd/forge/cmd_migrate.go new file mode 100644 index 0000000..8f22c7b --- /dev/null +++ b/cmd/forge/cmd_migrate.go @@ -0,0 +1,121 @@ +package forge + +import ( + "fmt" + + forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2" + + "forge.lthn.ai/core/go/pkg/cli" + fg "forge.lthn.ai/core/go-scm/forge" +) + +// Migrate command flags. +var ( + migrateOrg string + migrateService string + migrateToken string + migrateMirror bool +) + +// addMigrateCommand adds the 'migrate' subcommand for importing repos from external services. +func addMigrateCommand(parent *cli.Command) { + cmd := &cli.Command{ + Use: "migrate ", + Short: "Migrate a repo from an external service", + Long: `Migrate a repository from GitHub, GitLab, Gitea, or other services into Forgejo. + +Unlike a simple mirror, migration imports issues, labels, pull requests, releases, and more. + +Examples: + core forge migrate https://github.com/owner/repo --org MyOrg --service github + core forge migrate https://gitea.example.com/owner/repo --service gitea --token TOKEN`, + Args: cli.ExactArgs(1), + RunE: func(cmd *cli.Command, args []string) error { + return runMigrate(args[0]) + }, + } + + cmd.Flags().StringVar(&migrateOrg, "org", "", "Forgejo organisation to migrate into (default: your user account)") + cmd.Flags().StringVar(&migrateService, "service", "github", "Source service type (github, gitlab, gitea, forgejo, gogs, git)") + cmd.Flags().StringVar(&migrateToken, "token", "", "Auth token for the source service") + cmd.Flags().BoolVar(&migrateMirror, "mirror", false, "Set up as a mirror (periodic sync)") + + parent.AddCommand(cmd) +} + +func runMigrate(cloneURL string) error { + client, err := fg.NewFromConfig("", "") + if err != nil { + return err + } + + // Determine target owner on Forgejo + targetOwner := migrateOrg + if targetOwner == "" { + user, _, err := client.API().GetMyUserInfo() + if err != nil { + return cli.WrapVerb(err, "get", "current user") + } + targetOwner = user.UserName + } + + // Extract repo name from clone URL + repoName := extractRepoName(cloneURL) + if repoName == "" { + return cli.Err("could not extract repo name from URL: %s", cloneURL) + } + + // Map service flag to SDK type + service := mapServiceType(migrateService) + + cli.Print(" Migrating %s -> %s/%s on Forgejo...\n", cloneURL, targetOwner, repoName) + + opts := forgejo.MigrateRepoOption{ + RepoName: repoName, + RepoOwner: targetOwner, + CloneAddr: cloneURL, + Service: service, + Mirror: migrateMirror, + AuthToken: migrateToken, + Issues: true, + Labels: true, + PullRequests: true, + Releases: true, + Milestones: true, + Wiki: true, + Description: "Migrated from " + cloneURL, + } + + repo, err := client.MigrateRepo(opts) + if err != nil { + return err + } + + cli.Blank() + cli.Success(fmt.Sprintf("Migration complete: %s", repo.FullName)) + cli.Print(" %s %s\n", dimStyle.Render("URL:"), valueStyle.Render(repo.HTMLURL)) + cli.Print(" %s %s\n", dimStyle.Render("Clone:"), valueStyle.Render(repo.CloneURL)) + if migrateMirror { + cli.Print(" %s %s\n", dimStyle.Render("Type:"), dimStyle.Render("mirror (periodic sync)")) + } + cli.Blank() + + return nil +} + +func mapServiceType(s string) forgejo.GitServiceType { + switch s { + case "github": + return forgejo.GitServiceGithub + case "gitlab": + return forgejo.GitServiceGitlab + case "gitea": + return forgejo.GitServiceGitea + case "forgejo": + return forgejo.GitServiceForgejo + case "gogs": + return forgejo.GitServiceGogs + default: + return forgejo.GitServicePlain + } +} diff --git a/cmd/forge/cmd_orgs.go b/cmd/forge/cmd_orgs.go new file mode 100644 index 0000000..accb21d --- /dev/null +++ b/cmd/forge/cmd_orgs.go @@ -0,0 +1,66 @@ +package forge + +import ( + "fmt" + + "forge.lthn.ai/core/go/pkg/cli" + fg "forge.lthn.ai/core/go-scm/forge" +) + +// addOrgsCommand adds the 'orgs' subcommand for listing organisations. +func addOrgsCommand(parent *cli.Command) { + cmd := &cli.Command{ + Use: "orgs", + Short: "List organisations", + Long: "List all organisations the authenticated user belongs to.", + RunE: func(cmd *cli.Command, args []string) error { + return runOrgs() + }, + } + + parent.AddCommand(cmd) +} + +func runOrgs() error { + client, err := fg.NewFromConfig("", "") + if err != nil { + return err + } + + orgs, err := client.ListMyOrgs() + if err != nil { + return err + } + + if len(orgs) == 0 { + cli.Text("No organisations found.") + return nil + } + + cli.Blank() + cli.Print(" %s\n\n", fmt.Sprintf("%d organisations", len(orgs))) + + table := cli.NewTable("Name", "Visibility", "Description") + + for _, org := range orgs { + visibility := successStyle.Render(org.Visibility) + if org.Visibility == "private" { + visibility = warningStyle.Render(org.Visibility) + } + + desc := cli.Truncate(org.Description, 50) + if desc == "" { + desc = dimStyle.Render("-") + } + + table.AddRow( + repoStyle.Render(org.UserName), + visibility, + desc, + ) + } + + table.Render() + + return nil +} diff --git a/cmd/forge/cmd_prs.go b/cmd/forge/cmd_prs.go new file mode 100644 index 0000000..0e26e70 --- /dev/null +++ b/cmd/forge/cmd_prs.go @@ -0,0 +1,98 @@ +package forge + +import ( + "fmt" + "strings" + + forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2" + + "forge.lthn.ai/core/go/pkg/cli" + fg "forge.lthn.ai/core/go-scm/forge" +) + +// PRs command flags. +var ( + prsState string +) + +// addPRsCommand adds the 'prs' subcommand for listing pull requests. +func addPRsCommand(parent *cli.Command) { + cmd := &cli.Command{ + Use: "prs ", + Short: "List pull requests", + Long: "List pull requests for a repository.", + Args: cli.ExactArgs(1), + RunE: func(cmd *cli.Command, args []string) error { + owner, repo, err := splitOwnerRepo(args[0]) + if err != nil { + return err + } + return runListPRs(owner, repo) + }, + } + + cmd.Flags().StringVar(&prsState, "state", "open", "Filter by state (open, closed, all)") + + parent.AddCommand(cmd) +} + +func runListPRs(owner, repo string) error { + client, err := fg.NewFromConfig("", "") + if err != nil { + return err + } + + prs, err := client.ListPullRequests(owner, repo, prsState) + if err != nil { + return err + } + + if len(prs) == 0 { + cli.Text(fmt.Sprintf("No %s pull requests in %s/%s.", prsState, owner, repo)) + return nil + } + + cli.Blank() + cli.Print(" %s\n\n", fmt.Sprintf("%d %s pull requests in %s/%s", len(prs), prsState, owner, repo)) + + for _, pr := range prs { + printForgePR(pr) + } + + return nil +} + +func printForgePR(pr *forgejo.PullRequest) { + num := numberStyle.Render(fmt.Sprintf("#%d", pr.Index)) + title := valueStyle.Render(cli.Truncate(pr.Title, 50)) + + var author string + if pr.Poster != nil { + author = infoStyle.Render("@" + pr.Poster.UserName) + } + + // Branch info + branch := dimStyle.Render(pr.Head.Ref + " -> " + pr.Base.Ref) + + // Merge status + var status string + if pr.HasMerged { + status = successStyle.Render("merged") + } else if pr.State == forgejo.StateClosed { + status = errorStyle.Render("closed") + } else { + status = warningStyle.Render("open") + } + + // Labels + var labelStr string + if len(pr.Labels) > 0 { + var labels []string + for _, l := range pr.Labels { + labels = append(labels, l.Name) + } + labelStr = " " + warningStyle.Render("["+strings.Join(labels, ", ")+"]") + } + + cli.Print(" %s %s %s %s %s%s\n", num, title, author, status, branch, labelStr) +} diff --git a/cmd/forge/cmd_repos.go b/cmd/forge/cmd_repos.go new file mode 100644 index 0000000..0784f2b --- /dev/null +++ b/cmd/forge/cmd_repos.go @@ -0,0 +1,94 @@ +package forge + +import ( + "fmt" + + forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2" + + "forge.lthn.ai/core/go/pkg/cli" + fg "forge.lthn.ai/core/go-scm/forge" +) + +// Repos command flags. +var ( + reposOrg string + reposMirrors bool +) + +// addReposCommand adds the 'repos' subcommand for listing repositories. +func addReposCommand(parent *cli.Command) { + cmd := &cli.Command{ + Use: "repos", + Short: "List repositories", + Long: "List repositories from your Forgejo instance, optionally filtered by organisation or mirror status.", + RunE: func(cmd *cli.Command, args []string) error { + return runRepos() + }, + } + + cmd.Flags().StringVar(&reposOrg, "org", "", "Filter by organisation") + cmd.Flags().BoolVar(&reposMirrors, "mirrors", false, "Show only mirror repositories") + + parent.AddCommand(cmd) +} + +func runRepos() error { + client, err := fg.NewFromConfig("", "") + if err != nil { + return err + } + + var repos []*forgejo.Repository + if reposOrg != "" { + repos, err = client.ListOrgRepos(reposOrg) + } else { + repos, err = client.ListUserRepos() + } + if err != nil { + return err + } + + // Filter mirrors if requested + if reposMirrors { + var filtered []*forgejo.Repository + for _, r := range repos { + if r.Mirror { + filtered = append(filtered, r) + } + } + repos = filtered + } + + if len(repos) == 0 { + cli.Text("No repositories found.") + return nil + } + + // Build table + table := cli.NewTable("Name", "Type", "Visibility", "Stars") + + for _, r := range repos { + repoType := "source" + if r.Mirror { + repoType = "mirror" + } + + visibility := successStyle.Render("public") + if r.Private { + visibility = warningStyle.Render("private") + } + + table.AddRow( + repoStyle.Render(r.FullName), + dimStyle.Render(repoType), + visibility, + fmt.Sprintf("%d", r.Stars), + ) + } + + cli.Blank() + cli.Print(" %s\n\n", fmt.Sprintf("%d repositories", len(repos))) + table.Render() + + return nil +} diff --git a/cmd/forge/cmd_status.go b/cmd/forge/cmd_status.go new file mode 100644 index 0000000..2de8b59 --- /dev/null +++ b/cmd/forge/cmd_status.go @@ -0,0 +1,63 @@ +package forge + +import ( + "fmt" + + "forge.lthn.ai/core/go/pkg/cli" + fg "forge.lthn.ai/core/go-scm/forge" +) + +// addStatusCommand adds the 'status' subcommand for instance info. +func addStatusCommand(parent *cli.Command) { + cmd := &cli.Command{ + Use: "status", + Short: "Show Forgejo instance status", + Long: "Display Forgejo instance version, authenticated user, and summary counts.", + RunE: func(cmd *cli.Command, args []string) error { + return runStatus() + }, + } + + parent.AddCommand(cmd) +} + +func runStatus() error { + client, err := fg.NewFromConfig("", "") + if err != nil { + return err + } + + // Get server version + ver, _, err := client.API().ServerVersion() + if err != nil { + return cli.WrapVerb(err, "get", "server version") + } + + // Get authenticated user + user, _, err := client.API().GetMyUserInfo() + if err != nil { + return cli.WrapVerb(err, "get", "user info") + } + + // Get org count + orgs, err := client.ListMyOrgs() + if err != nil { + return cli.WrapVerb(err, "list", "organisations") + } + + // Get repo count + repos, err := client.ListUserRepos() + if err != nil { + return cli.WrapVerb(err, "list", "repositories") + } + + cli.Blank() + cli.Print(" %s %s\n", dimStyle.Render("Instance:"), valueStyle.Render(client.URL())) + cli.Print(" %s %s\n", dimStyle.Render("Version:"), valueStyle.Render(ver)) + cli.Print(" %s %s\n", dimStyle.Render("User:"), valueStyle.Render(user.UserName)) + cli.Print(" %s %s\n", dimStyle.Render("Orgs:"), numberStyle.Render(fmt.Sprintf("%d", len(orgs)))) + cli.Print(" %s %s\n", dimStyle.Render("Repos:"), numberStyle.Render(fmt.Sprintf("%d", len(repos)))) + cli.Blank() + + return nil +} diff --git a/cmd/forge/cmd_sync.go b/cmd/forge/cmd_sync.go new file mode 100644 index 0000000..d5a3fc1 --- /dev/null +++ b/cmd/forge/cmd_sync.go @@ -0,0 +1,334 @@ +package forge + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2" + + "forge.lthn.ai/core/go/pkg/cli" + fg "forge.lthn.ai/core/go-scm/forge" +) + +// Sync command flags. +var ( + syncOrg string + syncBasePath string + syncSetup bool +) + +// addSyncCommand adds the 'sync' subcommand for syncing GitHub repos to Forgejo upstream branches. +func addSyncCommand(parent *cli.Command) { + cmd := &cli.Command{ + Use: "sync [owner/repo...]", + Short: "Sync GitHub repos to Forgejo upstream branches", + Long: `Push local GitHub content to Forgejo as 'upstream' branches. + +Each repo gets: + - An 'upstream' branch tracking the GitHub default branch + - A 'main' branch (default) for private tasks, processes, and AI workflows + +Use --setup on first run to create the Forgejo repos and configure remotes. +Without --setup, updates existing upstream branches from local clones.`, + Args: cli.MinimumNArgs(0), + RunE: func(cmd *cli.Command, args []string) error { + return runSync(args) + }, + } + + cmd.Flags().StringVar(&syncOrg, "org", "Host-UK", "Forgejo organisation") + cmd.Flags().StringVar(&syncBasePath, "base-path", "~/Code/host-uk", "Base path for local repo clones") + cmd.Flags().BoolVar(&syncSetup, "setup", false, "Initial setup: create repos, configure remotes, push upstream branches") + + parent.AddCommand(cmd) +} + +// syncRepoEntry holds info for a repo to sync. +type syncRepoEntry struct { + name string + localPath string + defaultBranch string +} + +func runSync(args []string) error { + client, err := fg.NewFromConfig("", "") + if err != nil { + return err + } + + // Expand base path + basePath := syncBasePath + if strings.HasPrefix(basePath, "~/") { + home, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("failed to resolve home directory: %w", err) + } + basePath = filepath.Join(home, basePath[2:]) + } + + // Build repo list: either from args or from the Forgejo org + repos, err := buildSyncRepoList(client, args, basePath) + if err != nil { + return err + } + + if len(repos) == 0 { + cli.Text("No repos to sync.") + return nil + } + + forgeURL := client.URL() + + if syncSetup { + return runSyncSetup(client, repos, forgeURL) + } + + return runSyncUpdate(repos, forgeURL) +} + +func buildSyncRepoList(client *fg.Client, args []string, basePath string) ([]syncRepoEntry, error) { + var repos []syncRepoEntry + + if len(args) > 0 { + for _, arg := range args { + name := arg + if parts := strings.SplitN(arg, "/", 2); len(parts) == 2 { + name = parts[1] + } + localPath := filepath.Join(basePath, name) + branch := syncDetectDefaultBranch(localPath) + repos = append(repos, syncRepoEntry{ + name: name, + localPath: localPath, + defaultBranch: branch, + }) + } + } else { + orgRepos, err := client.ListOrgRepos(syncOrg) + if err != nil { + return nil, err + } + for _, r := range orgRepos { + localPath := filepath.Join(basePath, r.Name) + branch := syncDetectDefaultBranch(localPath) + repos = append(repos, syncRepoEntry{ + name: r.Name, + localPath: localPath, + defaultBranch: branch, + }) + } + } + + return repos, nil +} + +func runSyncSetup(client *fg.Client, repos []syncRepoEntry, forgeURL string) error { + cli.Blank() + cli.Print(" Setting up %d repos in %s with upstream branches...\n\n", len(repos), syncOrg) + + var succeeded, failed int + + for _, repo := range repos { + cli.Print(" %s %s\n", dimStyle.Render(">>"), repoStyle.Render(repo.name)) + + // Step 1: Delete existing repo if it exists + cli.Print(" Deleting existing repo... ") + err := client.DeleteRepo(syncOrg, repo.name) + if err != nil { + cli.Print("%s (may not exist)\n", dimStyle.Render("skipped")) + } else { + cli.Print("%s\n", successStyle.Render("done")) + } + + // Step 2: Create empty repo + cli.Print(" Creating repo... ") + _, err = client.CreateOrgRepo(syncOrg, forgejo.CreateRepoOption{ + Name: repo.name, + AutoInit: false, + DefaultBranch: "main", + }) + if err != nil { + cli.Print("%s\n", errorStyle.Render(err.Error())) + failed++ + continue + } + cli.Print("%s\n", successStyle.Render("done")) + + // Step 3: Add forge remote to local clone + cli.Print(" Configuring remote... ") + remoteURL := fmt.Sprintf("%s/%s/%s.git", forgeURL, syncOrg, repo.name) + err = syncConfigureForgeRemote(repo.localPath, remoteURL) + if err != nil { + cli.Print("%s\n", errorStyle.Render(err.Error())) + failed++ + continue + } + cli.Print("%s\n", successStyle.Render("done")) + + // Step 4: Push default branch as 'upstream' to Forgejo + cli.Print(" Pushing %s -> upstream... ", repo.defaultBranch) + err = syncPushUpstream(repo.localPath, repo.defaultBranch) + if err != nil { + cli.Print("%s\n", errorStyle.Render(err.Error())) + failed++ + continue + } + cli.Print("%s\n", successStyle.Render("done")) + + // Step 5: Create 'main' branch from 'upstream' on Forgejo + cli.Print(" Creating main branch... ") + err = syncCreateMainFromUpstream(client, syncOrg, repo.name) + if err != nil { + if strings.Contains(err.Error(), "already exists") || strings.Contains(err.Error(), "409") { + cli.Print("%s\n", dimStyle.Render("exists")) + } else { + cli.Print("%s\n", errorStyle.Render(err.Error())) + failed++ + continue + } + } else { + cli.Print("%s\n", successStyle.Render("done")) + } + + // Step 6: Set default branch to 'main' + cli.Print(" Setting default branch... ") + _, _, err = client.API().EditRepo(syncOrg, repo.name, forgejo.EditRepoOption{ + DefaultBranch: strPtr("main"), + }) + if err != nil { + cli.Print("%s\n", warningStyle.Render(err.Error())) + } else { + cli.Print("%s\n", successStyle.Render("main")) + } + + succeeded++ + cli.Blank() + } + + cli.Print(" %s", successStyle.Render(fmt.Sprintf("%d repos set up", succeeded))) + if failed > 0 { + cli.Print(", %s", errorStyle.Render(fmt.Sprintf("%d failed", failed))) + } + cli.Blank() + + return nil +} + +func runSyncUpdate(repos []syncRepoEntry, forgeURL string) error { + cli.Blank() + cli.Print(" Syncing %d repos to %s upstream branches...\n\n", len(repos), syncOrg) + + var succeeded, failed int + + for _, repo := range repos { + cli.Print(" %s -> upstream ", repoStyle.Render(repo.name)) + + // Ensure remote exists + remoteURL := fmt.Sprintf("%s/%s/%s.git", forgeURL, syncOrg, repo.name) + _ = syncConfigureForgeRemote(repo.localPath, remoteURL) + + // Fetch latest from GitHub (origin) + err := syncGitFetch(repo.localPath, "origin") + if err != nil { + cli.Print("%s\n", errorStyle.Render("fetch failed: "+err.Error())) + failed++ + continue + } + + // Push to Forgejo upstream branch + err = syncPushUpstream(repo.localPath, repo.defaultBranch) + if err != nil { + cli.Print("%s\n", errorStyle.Render(err.Error())) + failed++ + continue + } + + cli.Print("%s\n", successStyle.Render("ok")) + succeeded++ + } + + cli.Blank() + cli.Print(" %s", successStyle.Render(fmt.Sprintf("%d synced", succeeded))) + if failed > 0 { + cli.Print(", %s", errorStyle.Render(fmt.Sprintf("%d failed", failed))) + } + cli.Blank() + + return nil +} + +func syncDetectDefaultBranch(path string) string { + out, err := exec.Command("git", "-C", path, "symbolic-ref", "refs/remotes/origin/HEAD").Output() + if err == nil { + ref := strings.TrimSpace(string(out)) + if parts := strings.Split(ref, "/"); len(parts) > 0 { + return parts[len(parts)-1] + } + } + + out, err = exec.Command("git", "-C", path, "branch", "--show-current").Output() + if err == nil { + branch := strings.TrimSpace(string(out)) + if branch != "" { + return branch + } + } + + return "main" +} + +func syncConfigureForgeRemote(localPath, remoteURL string) error { + out, err := exec.Command("git", "-C", localPath, "remote", "get-url", "forge").Output() + if err == nil { + existing := strings.TrimSpace(string(out)) + if existing != remoteURL { + cmd := exec.Command("git", "-C", localPath, "remote", "set-url", "forge", remoteURL) + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to update remote: %w", err) + } + } + return nil + } + + cmd := exec.Command("git", "-C", localPath, "remote", "add", "forge", remoteURL) + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to add remote: %w", err) + } + + return nil +} + +func syncPushUpstream(localPath, defaultBranch string) error { + refspec := fmt.Sprintf("refs/remotes/origin/%s:refs/heads/upstream", defaultBranch) + cmd := exec.Command("git", "-C", localPath, "push", "--force", "forge", refspec) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("%s", strings.TrimSpace(string(output))) + } + + return nil +} + +func syncGitFetch(localPath, remote string) error { + cmd := exec.Command("git", "-C", localPath, "fetch", remote) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("%s", strings.TrimSpace(string(output))) + } + return nil +} + +func syncCreateMainFromUpstream(client *fg.Client, org, repo string) error { + _, _, err := client.API().CreateBranch(org, repo, forgejo.CreateBranchOption{ + BranchName: "main", + OldBranchName: "upstream", + }) + if err != nil { + return fmt.Errorf("create branch: %w", err) + } + + return nil +} diff --git a/cmd/forge/helpers.go b/cmd/forge/helpers.go new file mode 100644 index 0000000..1a168ee --- /dev/null +++ b/cmd/forge/helpers.go @@ -0,0 +1,33 @@ +package forge + +import ( + "path" + "strings" + + "forge.lthn.ai/core/go/pkg/cli" +) + +// splitOwnerRepo splits "owner/repo" into its parts. +func splitOwnerRepo(s string) (string, string, error) { + parts := strings.SplitN(s, "/", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", cli.Err("expected format: owner/repo (got %q)", s) + } + return parts[0], parts[1], nil +} + +// strPtr returns a pointer to the given string. +func strPtr(s string) *string { return &s } + +// extractRepoName extracts a repository name from a clone URL. +// e.g. "https://github.com/owner/repo.git" -> "repo" +func extractRepoName(cloneURL string) string { + // Get the last path segment + name := path.Base(cloneURL) + // Strip .git suffix + name = strings.TrimSuffix(name, ".git") + if name == "" || name == "." || name == "/" { + return "" + } + return name +} diff --git a/cmd/gitea/cmd_config.go b/cmd/gitea/cmd_config.go new file mode 100644 index 0000000..6fdd49d --- /dev/null +++ b/cmd/gitea/cmd_config.go @@ -0,0 +1,106 @@ +package gitea + +import ( + "fmt" + + "forge.lthn.ai/core/go/pkg/cli" + gt "forge.lthn.ai/core/go-scm/gitea" +) + +// Config command flags. +var ( + configURL string + configToken string + configTest bool +) + +// addConfigCommand adds the 'config' subcommand for Gitea connection setup. +func addConfigCommand(parent *cli.Command) { + cmd := &cli.Command{ + Use: "config", + Short: "Configure Gitea connection", + Long: "Set the Gitea instance URL and API token, or test the current connection.", + RunE: func(cmd *cli.Command, args []string) error { + return runConfig() + }, + } + + cmd.Flags().StringVar(&configURL, "url", "", "Gitea instance URL") + cmd.Flags().StringVar(&configToken, "token", "", "Gitea API token") + cmd.Flags().BoolVar(&configTest, "test", false, "Test the current connection") + + parent.AddCommand(cmd) +} + +func runConfig() error { + // If setting values, save them first + if configURL != "" || configToken != "" { + if err := gt.SaveConfig(configURL, configToken); err != nil { + return err + } + + if configURL != "" { + cli.Success(fmt.Sprintf("Gitea URL set to %s", configURL)) + } + if configToken != "" { + cli.Success("Gitea token saved") + } + } + + // If testing, verify the connection + if configTest { + return runConfigTest() + } + + // If no flags, show current config + if configURL == "" && configToken == "" && !configTest { + return showConfig() + } + + return nil +} + +func showConfig() error { + url, token, err := gt.ResolveConfig("", "") + if err != nil { + return err + } + + cli.Blank() + cli.Print(" %s %s\n", dimStyle.Render("URL:"), valueStyle.Render(url)) + + if token != "" { + masked := token + if len(token) >= 8 { + masked = token[:4] + "..." + token[len(token)-4:] + } + cli.Print(" %s %s\n", dimStyle.Render("Token:"), valueStyle.Render(masked)) + } else { + cli.Print(" %s %s\n", dimStyle.Render("Token:"), warningStyle.Render("not set")) + } + + cli.Blank() + + return nil +} + +func runConfigTest() error { + client, err := gt.NewFromConfig(configURL, configToken) + if err != nil { + return err + } + + user, _, err := client.API().GetMyUserInfo() + if err != nil { + cli.Error("Connection failed") + return cli.WrapVerb(err, "connect to", "Gitea") + } + + cli.Blank() + cli.Success(fmt.Sprintf("Connected to %s", client.URL())) + cli.Print(" %s %s\n", dimStyle.Render("User:"), valueStyle.Render(user.UserName)) + cli.Print(" %s %s\n", dimStyle.Render("Email:"), valueStyle.Render(user.Email)) + cli.Blank() + + return nil +} diff --git a/cmd/gitea/cmd_gitea.go b/cmd/gitea/cmd_gitea.go new file mode 100644 index 0000000..87bc631 --- /dev/null +++ b/cmd/gitea/cmd_gitea.go @@ -0,0 +1,47 @@ +// Package gitea provides CLI commands for managing a Gitea instance. +// +// Commands: +// - config: Configure Gitea connection (URL, token) +// - repos: List repositories +// - issues: List and create issues +// - prs: List pull requests +// - mirror: Create GitHub-to-Gitea mirrors +// - sync: Sync GitHub repos to Gitea upstream branches +package gitea + +import ( + "forge.lthn.ai/core/go/pkg/cli" +) + +func init() { + cli.RegisterCommands(AddGiteaCommands) +} + +// Style aliases from shared package. +var ( + successStyle = cli.SuccessStyle + errorStyle = cli.ErrorStyle + warningStyle = cli.WarningStyle + dimStyle = cli.DimStyle + valueStyle = cli.ValueStyle + repoStyle = cli.RepoStyle + numberStyle = cli.NumberStyle + infoStyle = cli.InfoStyle +) + +// AddGiteaCommands registers the 'gitea' command and all subcommands. +func AddGiteaCommands(root *cli.Command) { + giteaCmd := &cli.Command{ + Use: "gitea", + Short: "Gitea instance management", + Long: "Manage repositories, issues, and pull requests on your Gitea instance.", + } + root.AddCommand(giteaCmd) + + addConfigCommand(giteaCmd) + addReposCommand(giteaCmd) + addIssuesCommand(giteaCmd) + addPRsCommand(giteaCmd) + addMirrorCommand(giteaCmd) + addSyncCommand(giteaCmd) +} diff --git a/cmd/gitea/cmd_issues.go b/cmd/gitea/cmd_issues.go new file mode 100644 index 0000000..cb62798 --- /dev/null +++ b/cmd/gitea/cmd_issues.go @@ -0,0 +1,133 @@ +package gitea + +import ( + "fmt" + "strings" + + "code.gitea.io/sdk/gitea" + + "forge.lthn.ai/core/go/pkg/cli" + gt "forge.lthn.ai/core/go-scm/gitea" +) + +// Issues command flags. +var ( + issuesState string + issuesTitle string + issuesBody string +) + +// addIssuesCommand adds the 'issues' subcommand for listing and creating issues. +func addIssuesCommand(parent *cli.Command) { + cmd := &cli.Command{ + Use: "issues ", + Short: "List and manage issues", + Long: "List issues for a repository, or create a new issue.", + Args: cli.ExactArgs(1), + RunE: func(cmd *cli.Command, args []string) error { + owner, repo, err := splitOwnerRepo(args[0]) + if err != nil { + return err + } + + // If title is set, create an issue instead + if issuesTitle != "" { + return runCreateIssue(owner, repo) + } + + return runListIssues(owner, repo) + }, + } + + cmd.Flags().StringVar(&issuesState, "state", "open", "Filter by state (open, closed, all)") + cmd.Flags().StringVar(&issuesTitle, "title", "", "Create issue with this title") + cmd.Flags().StringVar(&issuesBody, "body", "", "Issue body (used with --title)") + + parent.AddCommand(cmd) +} + +func runListIssues(owner, repo string) error { + client, err := gt.NewFromConfig("", "") + if err != nil { + return err + } + + issues, err := client.ListIssues(owner, repo, gt.ListIssuesOpts{ + State: issuesState, + }) + if err != nil { + return err + } + + if len(issues) == 0 { + cli.Text(fmt.Sprintf("No %s issues in %s/%s.", issuesState, owner, repo)) + return nil + } + + cli.Blank() + cli.Print(" %s\n\n", fmt.Sprintf("%d %s issues in %s/%s", len(issues), issuesState, owner, repo)) + + for _, issue := range issues { + printGiteaIssue(issue, owner, repo) + } + + return nil +} + +func runCreateIssue(owner, repo string) error { + client, err := gt.NewFromConfig("", "") + if err != nil { + return err + } + + issue, err := client.CreateIssue(owner, repo, gitea.CreateIssueOption{ + Title: issuesTitle, + Body: issuesBody, + }) + if err != nil { + return err + } + + cli.Blank() + cli.Success(fmt.Sprintf("Created issue #%d: %s", issue.Index, issue.Title)) + cli.Print(" %s %s\n", dimStyle.Render("URL:"), valueStyle.Render(issue.HTMLURL)) + cli.Blank() + + return nil +} + +func printGiteaIssue(issue *gitea.Issue, owner, repo string) { + num := numberStyle.Render(fmt.Sprintf("#%d", issue.Index)) + title := valueStyle.Render(cli.Truncate(issue.Title, 60)) + + line := fmt.Sprintf(" %s %s", num, title) + + // Add labels + if len(issue.Labels) > 0 { + var labels []string + for _, l := range issue.Labels { + labels = append(labels, l.Name) + } + line += " " + warningStyle.Render("["+strings.Join(labels, ", ")+"]") + } + + // Add assignees + if len(issue.Assignees) > 0 { + var assignees []string + for _, a := range issue.Assignees { + assignees = append(assignees, "@"+a.UserName) + } + line += " " + infoStyle.Render(strings.Join(assignees, ", ")) + } + + cli.Text(line) +} + +// splitOwnerRepo splits "owner/repo" into its parts. +func splitOwnerRepo(s string) (string, string, error) { + parts := strings.SplitN(s, "/", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", cli.Err("expected format: owner/repo (got %q)", s) + } + return parts[0], parts[1], nil +} diff --git a/cmd/gitea/cmd_mirror.go b/cmd/gitea/cmd_mirror.go new file mode 100644 index 0000000..7b466ca --- /dev/null +++ b/cmd/gitea/cmd_mirror.go @@ -0,0 +1,92 @@ +package gitea + +import ( + "fmt" + "os/exec" + "strings" + + "forge.lthn.ai/core/go/pkg/cli" + gt "forge.lthn.ai/core/go-scm/gitea" +) + +// Mirror command flags. +var ( + mirrorOrg string + mirrorGHToken string +) + +// addMirrorCommand adds the 'mirror' subcommand for creating GitHub-to-Gitea mirrors. +func addMirrorCommand(parent *cli.Command) { + cmd := &cli.Command{ + Use: "mirror ", + Short: "Mirror a GitHub repo to Gitea", + Long: `Create a pull mirror of a GitHub repository on your Gitea instance. + +The mirror will be created under the specified Gitea organisation (or your user account). +Gitea will periodically sync changes from GitHub. + +For private repos, a GitHub token is needed. By default it uses 'gh auth token'.`, + Args: cli.ExactArgs(1), + RunE: func(cmd *cli.Command, args []string) error { + owner, repo, err := splitOwnerRepo(args[0]) + if err != nil { + return err + } + return runMirror(owner, repo) + }, + } + + cmd.Flags().StringVar(&mirrorOrg, "org", "", "Gitea organisation to mirror into (default: your user account)") + cmd.Flags().StringVar(&mirrorGHToken, "github-token", "", "GitHub token for private repos (default: from gh auth token)") + + parent.AddCommand(cmd) +} + +func runMirror(githubOwner, githubRepo string) error { + client, err := gt.NewFromConfig("", "") + if err != nil { + return err + } + + cloneURL := fmt.Sprintf("https://github.com/%s/%s.git", githubOwner, githubRepo) + + // Determine target owner on Gitea + targetOwner := mirrorOrg + if targetOwner == "" { + user, _, err := client.API().GetMyUserInfo() + if err != nil { + return cli.WrapVerb(err, "get", "current user") + } + targetOwner = user.UserName + } + + // Resolve GitHub token for source auth + ghToken := mirrorGHToken + if ghToken == "" { + ghToken = resolveGHToken() + } + + cli.Print(" Mirroring %s/%s -> %s/%s on Gitea...\n", githubOwner, githubRepo, targetOwner, githubRepo) + + repo, err := client.CreateMirror(targetOwner, githubRepo, cloneURL, ghToken) + if err != nil { + return err + } + + cli.Blank() + cli.Success(fmt.Sprintf("Mirror created: %s", repo.FullName)) + cli.Print(" %s %s\n", dimStyle.Render("URL:"), valueStyle.Render(repo.HTMLURL)) + cli.Print(" %s %s\n", dimStyle.Render("Clone:"), valueStyle.Render(repo.CloneURL)) + cli.Blank() + + return nil +} + +// resolveGHToken tries to get a GitHub token from the gh CLI. +func resolveGHToken() string { + out, err := exec.Command("gh", "auth", "token").Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} diff --git a/cmd/gitea/cmd_prs.go b/cmd/gitea/cmd_prs.go new file mode 100644 index 0000000..ad9e629 --- /dev/null +++ b/cmd/gitea/cmd_prs.go @@ -0,0 +1,98 @@ +package gitea + +import ( + "fmt" + "strings" + + sdk "code.gitea.io/sdk/gitea" + + "forge.lthn.ai/core/go/pkg/cli" + gt "forge.lthn.ai/core/go-scm/gitea" +) + +// PRs command flags. +var ( + prsState string +) + +// addPRsCommand adds the 'prs' subcommand for listing pull requests. +func addPRsCommand(parent *cli.Command) { + cmd := &cli.Command{ + Use: "prs ", + Short: "List pull requests", + Long: "List pull requests for a repository.", + Args: cli.ExactArgs(1), + RunE: func(cmd *cli.Command, args []string) error { + owner, repo, err := splitOwnerRepo(args[0]) + if err != nil { + return err + } + return runListPRs(owner, repo) + }, + } + + cmd.Flags().StringVar(&prsState, "state", "open", "Filter by state (open, closed, all)") + + parent.AddCommand(cmd) +} + +func runListPRs(owner, repo string) error { + client, err := gt.NewFromConfig("", "") + if err != nil { + return err + } + + prs, err := client.ListPullRequests(owner, repo, prsState) + if err != nil { + return err + } + + if len(prs) == 0 { + cli.Text(fmt.Sprintf("No %s pull requests in %s/%s.", prsState, owner, repo)) + return nil + } + + cli.Blank() + cli.Print(" %s\n\n", fmt.Sprintf("%d %s pull requests in %s/%s", len(prs), prsState, owner, repo)) + + for _, pr := range prs { + printGiteaPR(pr) + } + + return nil +} + +func printGiteaPR(pr *sdk.PullRequest) { + num := numberStyle.Render(fmt.Sprintf("#%d", pr.Index)) + title := valueStyle.Render(cli.Truncate(pr.Title, 50)) + + var author string + if pr.Poster != nil { + author = infoStyle.Render("@" + pr.Poster.UserName) + } + + // Branch info + branch := dimStyle.Render(pr.Head.Ref + " -> " + pr.Base.Ref) + + // Merge status + var status string + if pr.HasMerged { + status = successStyle.Render("merged") + } else if pr.State == sdk.StateClosed { + status = errorStyle.Render("closed") + } else { + status = warningStyle.Render("open") + } + + // Labels + var labelStr string + if len(pr.Labels) > 0 { + var labels []string + for _, l := range pr.Labels { + labels = append(labels, l.Name) + } + labelStr = " " + warningStyle.Render("["+strings.Join(labels, ", ")+"]") + } + + cli.Print(" %s %s %s %s %s%s\n", num, title, author, status, branch, labelStr) +} diff --git a/cmd/gitea/cmd_repos.go b/cmd/gitea/cmd_repos.go new file mode 100644 index 0000000..69af886 --- /dev/null +++ b/cmd/gitea/cmd_repos.go @@ -0,0 +1,125 @@ +package gitea + +import ( + "fmt" + + "forge.lthn.ai/core/go/pkg/cli" + gt "forge.lthn.ai/core/go-scm/gitea" +) + +// Repos command flags. +var ( + reposOrg string + reposMirrors bool +) + +// addReposCommand adds the 'repos' subcommand for listing repositories. +func addReposCommand(parent *cli.Command) { + cmd := &cli.Command{ + Use: "repos", + Short: "List repositories", + Long: "List repositories from your Gitea instance, optionally filtered by organisation or mirror status.", + RunE: func(cmd *cli.Command, args []string) error { + return runRepos() + }, + } + + cmd.Flags().StringVar(&reposOrg, "org", "", "Filter by organisation") + cmd.Flags().BoolVar(&reposMirrors, "mirrors", false, "Show only mirror repositories") + + parent.AddCommand(cmd) +} + +func runRepos() error { + client, err := gt.NewFromConfig("", "") + if err != nil { + return err + } + + var repos []*giteaRepo + if reposOrg != "" { + raw, err := client.ListOrgRepos(reposOrg) + if err != nil { + return err + } + for _, r := range raw { + repos = append(repos, &giteaRepo{ + Name: r.Name, + FullName: r.FullName, + Mirror: r.Mirror, + Private: r.Private, + Stars: r.Stars, + CloneURL: r.CloneURL, + }) + } + } else { + raw, err := client.ListUserRepos() + if err != nil { + return err + } + for _, r := range raw { + repos = append(repos, &giteaRepo{ + Name: r.Name, + FullName: r.FullName, + Mirror: r.Mirror, + Private: r.Private, + Stars: r.Stars, + CloneURL: r.CloneURL, + }) + } + } + + // Filter mirrors if requested + if reposMirrors { + var filtered []*giteaRepo + for _, r := range repos { + if r.Mirror { + filtered = append(filtered, r) + } + } + repos = filtered + } + + if len(repos) == 0 { + cli.Text("No repositories found.") + return nil + } + + // Build table + table := cli.NewTable("Name", "Type", "Visibility", "Stars") + + for _, r := range repos { + repoType := "source" + if r.Mirror { + repoType = "mirror" + } + + visibility := successStyle.Render("public") + if r.Private { + visibility = warningStyle.Render("private") + } + + table.AddRow( + repoStyle.Render(r.FullName), + dimStyle.Render(repoType), + visibility, + fmt.Sprintf("%d", r.Stars), + ) + } + + cli.Blank() + cli.Print(" %s\n\n", fmt.Sprintf("%d repositories", len(repos))) + table.Render() + + return nil +} + +// giteaRepo is a simplified repo for display purposes. +type giteaRepo struct { + Name string + FullName string + Mirror bool + Private bool + Stars int + CloneURL string +} diff --git a/cmd/gitea/cmd_sync.go b/cmd/gitea/cmd_sync.go new file mode 100644 index 0000000..0e525fd --- /dev/null +++ b/cmd/gitea/cmd_sync.go @@ -0,0 +1,353 @@ +package gitea + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "code.gitea.io/sdk/gitea" + + "forge.lthn.ai/core/go/pkg/cli" + gt "forge.lthn.ai/core/go-scm/gitea" +) + +// Sync command flags. +var ( + syncOrg string + syncBasePath string + syncSetup bool +) + +// addSyncCommand adds the 'sync' subcommand for syncing GitHub repos to Gitea upstream branches. +func addSyncCommand(parent *cli.Command) { + cmd := &cli.Command{ + Use: "sync [owner/repo...]", + Short: "Sync GitHub repos to Gitea upstream branches", + Long: `Push local GitHub content to Gitea as 'upstream' branches. + +Each repo gets: + - An 'upstream' branch tracking the GitHub default branch + - A 'main' branch (default) for private tasks, processes, and AI workflows + +Use --setup on first run to create the Gitea repos and configure remotes. +Without --setup, updates existing upstream branches from local clones.`, + Args: cli.MinimumNArgs(0), + RunE: func(cmd *cli.Command, args []string) error { + return runSync(args) + }, + } + + cmd.Flags().StringVar(&syncOrg, "org", "Host-UK", "Gitea organisation") + cmd.Flags().StringVar(&syncBasePath, "base-path", "~/Code/host-uk", "Base path for local repo clones") + cmd.Flags().BoolVar(&syncSetup, "setup", false, "Initial setup: create repos, configure remotes, push upstream branches") + + parent.AddCommand(cmd) +} + +// repoEntry holds info for a repo to sync. +type repoEntry struct { + name string + localPath string + defaultBranch string // the GitHub default branch (main, dev, etc.) +} + +func runSync(args []string) error { + client, err := gt.NewFromConfig("", "") + if err != nil { + return err + } + + // Expand base path + basePath := syncBasePath + if strings.HasPrefix(basePath, "~/") { + home, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("failed to resolve home directory: %w", err) + } + basePath = filepath.Join(home, basePath[2:]) + } + + // Build repo list: either from args or from the Gitea org + repos, err := buildRepoList(client, args, basePath) + if err != nil { + return err + } + + if len(repos) == 0 { + cli.Text("No repos to sync.") + return nil + } + + giteaURL := client.URL() + + if syncSetup { + return runSyncSetup(client, repos, giteaURL) + } + + return runSyncUpdate(repos, giteaURL) +} + +func buildRepoList(client *gt.Client, args []string, basePath string) ([]repoEntry, error) { + var repos []repoEntry + + if len(args) > 0 { + // Specific repos from args + for _, arg := range args { + name := arg + // Strip owner/ prefix if given + if parts := strings.SplitN(arg, "/", 2); len(parts) == 2 { + name = parts[1] + } + localPath := filepath.Join(basePath, name) + branch := detectDefaultBranch(localPath) + repos = append(repos, repoEntry{ + name: name, + localPath: localPath, + defaultBranch: branch, + }) + } + } else { + // All repos from the Gitea org + orgRepos, err := client.ListOrgRepos(syncOrg) + if err != nil { + return nil, err + } + for _, r := range orgRepos { + localPath := filepath.Join(basePath, r.Name) + branch := detectDefaultBranch(localPath) + repos = append(repos, repoEntry{ + name: r.Name, + localPath: localPath, + defaultBranch: branch, + }) + } + } + + return repos, nil +} + +// runSyncSetup handles first-time setup: delete mirrors, create repos, push upstream branches. +func runSyncSetup(client *gt.Client, repos []repoEntry, giteaURL string) error { + cli.Blank() + cli.Print(" Setting up %d repos in %s with upstream branches...\n\n", len(repos), syncOrg) + + var succeeded, failed int + + for _, repo := range repos { + cli.Print(" %s %s\n", dimStyle.Render(">>"), repoStyle.Render(repo.name)) + + // Step 1: Delete existing repo (mirror) if it exists + cli.Print(" Deleting existing mirror... ") + err := client.DeleteRepo(syncOrg, repo.name) + if err != nil { + cli.Print("%s (may not exist)\n", dimStyle.Render("skipped")) + } else { + cli.Print("%s\n", successStyle.Render("done")) + } + + // Step 2: Create empty repo + cli.Print(" Creating repo... ") + _, err = client.CreateOrgRepo(syncOrg, gitea.CreateRepoOption{ + Name: repo.name, + AutoInit: false, + DefaultBranch: "main", + }) + if err != nil { + cli.Print("%s\n", errorStyle.Render(err.Error())) + failed++ + continue + } + cli.Print("%s\n", successStyle.Render("done")) + + // Step 3: Add gitea remote to local clone + cli.Print(" Configuring remote... ") + remoteURL := fmt.Sprintf("%s/%s/%s.git", giteaURL, syncOrg, repo.name) + err = configureGiteaRemote(repo.localPath, remoteURL) + if err != nil { + cli.Print("%s\n", errorStyle.Render(err.Error())) + failed++ + continue + } + cli.Print("%s\n", successStyle.Render("done")) + + // Step 4: Push default branch as 'upstream' to Gitea + cli.Print(" Pushing %s -> upstream... ", repo.defaultBranch) + err = pushUpstream(repo.localPath, repo.defaultBranch) + if err != nil { + cli.Print("%s\n", errorStyle.Render(err.Error())) + failed++ + continue + } + cli.Print("%s\n", successStyle.Render("done")) + + // Step 5: Create 'main' branch from 'upstream' on Gitea + cli.Print(" Creating main branch... ") + err = createMainFromUpstream(client, syncOrg, repo.name) + if err != nil { + if strings.Contains(err.Error(), "already exists") || strings.Contains(err.Error(), "409") { + cli.Print("%s\n", dimStyle.Render("exists")) + } else { + cli.Print("%s\n", errorStyle.Render(err.Error())) + failed++ + continue + } + } else { + cli.Print("%s\n", successStyle.Render("done")) + } + + // Step 6: Set default branch to 'main' + cli.Print(" Setting default branch... ") + _, _, err = client.API().EditRepo(syncOrg, repo.name, gitea.EditRepoOption{ + DefaultBranch: strPtr("main"), + }) + if err != nil { + cli.Print("%s\n", warningStyle.Render(err.Error())) + } else { + cli.Print("%s\n", successStyle.Render("main")) + } + + succeeded++ + cli.Blank() + } + + cli.Print(" %s", successStyle.Render(fmt.Sprintf("%d repos set up", succeeded))) + if failed > 0 { + cli.Print(", %s", errorStyle.Render(fmt.Sprintf("%d failed", failed))) + } + cli.Blank() + + return nil +} + +// runSyncUpdate pushes latest from local clones to Gitea upstream branches. +func runSyncUpdate(repos []repoEntry, giteaURL string) error { + cli.Blank() + cli.Print(" Syncing %d repos to %s upstream branches...\n\n", len(repos), syncOrg) + + var succeeded, failed int + + for _, repo := range repos { + cli.Print(" %s -> upstream ", repoStyle.Render(repo.name)) + + // Ensure remote exists + remoteURL := fmt.Sprintf("%s/%s/%s.git", giteaURL, syncOrg, repo.name) + _ = configureGiteaRemote(repo.localPath, remoteURL) + + // Fetch latest from GitHub (origin) + err := gitFetch(repo.localPath, "origin") + if err != nil { + cli.Print("%s\n", errorStyle.Render("fetch failed: "+err.Error())) + failed++ + continue + } + + // Push to Gitea upstream branch + err = pushUpstream(repo.localPath, repo.defaultBranch) + if err != nil { + cli.Print("%s\n", errorStyle.Render(err.Error())) + failed++ + continue + } + + cli.Print("%s\n", successStyle.Render("ok")) + succeeded++ + } + + cli.Blank() + cli.Print(" %s", successStyle.Render(fmt.Sprintf("%d synced", succeeded))) + if failed > 0 { + cli.Print(", %s", errorStyle.Render(fmt.Sprintf("%d failed", failed))) + } + cli.Blank() + + return nil +} + +// detectDefaultBranch returns the default branch for a local git repo. +func detectDefaultBranch(path string) string { + // Check what origin/HEAD points to + out, err := exec.Command("git", "-C", path, "symbolic-ref", "refs/remotes/origin/HEAD").Output() + if err == nil { + ref := strings.TrimSpace(string(out)) + // refs/remotes/origin/main -> main + if parts := strings.Split(ref, "/"); len(parts) > 0 { + return parts[len(parts)-1] + } + } + + // Fallback: check current branch + out, err = exec.Command("git", "-C", path, "branch", "--show-current").Output() + if err == nil { + branch := strings.TrimSpace(string(out)) + if branch != "" { + return branch + } + } + + return "main" +} + +// configureGiteaRemote adds or updates the 'gitea' remote on a local repo. +func configureGiteaRemote(localPath, remoteURL string) error { + // Check if remote exists + out, err := exec.Command("git", "-C", localPath, "remote", "get-url", "gitea").Output() + if err == nil { + // Remote exists — update if URL changed + existing := strings.TrimSpace(string(out)) + if existing != remoteURL { + cmd := exec.Command("git", "-C", localPath, "remote", "set-url", "gitea", remoteURL) + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to update remote: %w", err) + } + } + return nil + } + + // Add new remote + cmd := exec.Command("git", "-C", localPath, "remote", "add", "gitea", remoteURL) + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to add remote: %w", err) + } + + return nil +} + +// pushUpstream pushes the local default branch to Gitea as 'upstream'. +func pushUpstream(localPath, defaultBranch string) error { + // Push origin's default branch as 'upstream' to gitea + refspec := fmt.Sprintf("refs/remotes/origin/%s:refs/heads/upstream", defaultBranch) + cmd := exec.Command("git", "-C", localPath, "push", "--force", "gitea", refspec) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("%s", strings.TrimSpace(string(output))) + } + + return nil +} + +// gitFetch fetches latest from a remote. +func gitFetch(localPath, remote string) error { + cmd := exec.Command("git", "-C", localPath, "fetch", remote) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("%s", strings.TrimSpace(string(output))) + } + return nil +} + +// createMainFromUpstream creates a 'main' branch from 'upstream' on Gitea via the API. +func createMainFromUpstream(client *gt.Client, org, repo string) error { + _, _, err := client.API().CreateBranch(org, repo, gitea.CreateBranchOption{ + BranchName: "main", + OldBranchName: "upstream", + }) + if err != nil { + return fmt.Errorf("create branch: %w", err) + } + + return nil +} + +func strPtr(s string) *string { return &s } diff --git a/go.mod b/go.mod index 6af1f55..5d8bc86 100644 --- a/go.mod +++ b/go.mod @@ -12,23 +12,43 @@ require ( require ( github.com/42wim/httpsig v1.2.3 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/bubbletea v1.3.10 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/davidmz/go-pageant v1.0.2 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-fed/httpsig v1.1.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/hashicorp/go-version v1.8.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/cobra v1.10.2 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/spf13/viper v1.21.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.48.0 // indirect golang.org/x/sys v0.41.0 // indirect + golang.org/x/term v0.40.0 // indirect golang.org/x/text v0.34.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index a35cc92..9a31fb5 100644 --- a/go.sum +++ b/go.sum @@ -4,12 +4,35 @@ codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 h1:HTCWpzyWQOHDWt3LzI6/d2jv codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0/go.mod h1:ZglEEDj+qkxYUb+SQIeqGtFxQrbaMYqIOgahNKb7uxs= forge.lthn.ai/core/go v0.0.0-20260221191103-d091fa62023f h1:CcSh/FFY93K5m0vADHLxwxKn2pTIM8HzYX1eGa4WZf4= forge.lthn.ai/core/go v0.0.0-20260221191103-d091fa62023f/go.mod h1:WCPJVEZm/6mTcJimHV0uX8ZhnKEF3dN0rQp13ByaSPg= +forge.lthn.ai/core/go-crypt v0.0.0-20260221190941-9585da8e6649 h1:Rs3bfSU8u1wkzYeL21asL7IcJIBVwOhtRidcEVj/PkA= +forge.lthn.ai/core/go-crypt v0.0.0-20260221190941-9585da8e6649/go.mod h1:RS+sz5lChrbc1AEmzzOULsTiMv3bwcwVtwbZi+c/Yjk= github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs= github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM= +github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= +github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= +github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0= github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= @@ -22,22 +45,45 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4= github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= @@ -46,6 +92,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -53,6 +101,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o= +golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= @@ -60,6 +110,8 @@ golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=