feat: migrate collect, forge, gitea commands from CLI
Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
5bfafcd6fc
commit
7eb28df79d
29 changed files with 3056 additions and 0 deletions
112
cmd/collect/cmd.go
Normal file
112
cmd/collect/cmd.go
Normal file
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
64
cmd/collect/cmd_bitcointalk.go
Normal file
64
cmd/collect/cmd_bitcointalk.go
Normal file
|
|
@ -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 <topic-id|url>",
|
||||
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
|
||||
}
|
||||
130
cmd/collect/cmd_dispatch.go
Normal file
130
cmd/collect/cmd_dispatch.go
Normal file
|
|
@ -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 <event>",
|
||||
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 <event> <command>",
|
||||
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
|
||||
}
|
||||
103
cmd/collect/cmd_excavate.go
Normal file
103
cmd/collect/cmd_excavate.go
Normal file
|
|
@ -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 <project>",
|
||||
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},
|
||||
}
|
||||
}
|
||||
}
|
||||
78
cmd/collect/cmd_github.go
Normal file
78
cmd/collect/cmd_github.go
Normal file
|
|
@ -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 <org/repo>",
|
||||
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
|
||||
}
|
||||
58
cmd/collect/cmd_market.go
Normal file
58
cmd/collect/cmd_market.go
Normal file
|
|
@ -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 <coin>",
|
||||
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
|
||||
}
|
||||
63
cmd/collect/cmd_papers.go
Normal file
63
cmd/collect/cmd_papers.go
Normal file
|
|
@ -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
|
||||
}
|
||||
48
cmd/collect/cmd_process.go
Normal file
48
cmd/collect/cmd_process.go
Normal file
|
|
@ -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 <source> <dir>",
|
||||
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
|
||||
}
|
||||
86
cmd/forge/cmd_auth.go
Normal file
86
cmd/forge/cmd_auth.go
Normal file
|
|
@ -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 <url>/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
|
||||
}
|
||||
106
cmd/forge/cmd_config.go
Normal file
106
cmd/forge/cmd_config.go
Normal file
|
|
@ -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
|
||||
}
|
||||
53
cmd/forge/cmd_forge.go
Normal file
53
cmd/forge/cmd_forge.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
200
cmd/forge/cmd_issues.go
Normal file
200
cmd/forge/cmd_issues.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
120
cmd/forge/cmd_labels.go
Normal file
120
cmd/forge/cmd_labels.go
Normal file
|
|
@ -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 <org>",
|
||||
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
|
||||
}
|
||||
121
cmd/forge/cmd_migrate.go
Normal file
121
cmd/forge/cmd_migrate.go
Normal file
|
|
@ -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 <clone-url>",
|
||||
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
|
||||
}
|
||||
}
|
||||
66
cmd/forge/cmd_orgs.go
Normal file
66
cmd/forge/cmd_orgs.go
Normal file
|
|
@ -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
|
||||
}
|
||||
98
cmd/forge/cmd_prs.go
Normal file
98
cmd/forge/cmd_prs.go
Normal file
|
|
@ -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 <owner/repo>",
|
||||
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)
|
||||
}
|
||||
94
cmd/forge/cmd_repos.go
Normal file
94
cmd/forge/cmd_repos.go
Normal file
|
|
@ -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
|
||||
}
|
||||
63
cmd/forge/cmd_status.go
Normal file
63
cmd/forge/cmd_status.go
Normal file
|
|
@ -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
|
||||
}
|
||||
334
cmd/forge/cmd_sync.go
Normal file
334
cmd/forge/cmd_sync.go
Normal file
|
|
@ -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> [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
|
||||
}
|
||||
33
cmd/forge/helpers.go
Normal file
33
cmd/forge/helpers.go
Normal file
|
|
@ -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
|
||||
}
|
||||
106
cmd/gitea/cmd_config.go
Normal file
106
cmd/gitea/cmd_config.go
Normal file
|
|
@ -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
|
||||
}
|
||||
47
cmd/gitea/cmd_gitea.go
Normal file
47
cmd/gitea/cmd_gitea.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
133
cmd/gitea/cmd_issues.go
Normal file
133
cmd/gitea/cmd_issues.go
Normal file
|
|
@ -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 <owner/repo>",
|
||||
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
|
||||
}
|
||||
92
cmd/gitea/cmd_mirror.go
Normal file
92
cmd/gitea/cmd_mirror.go
Normal file
|
|
@ -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 <github-owner/repo>",
|
||||
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))
|
||||
}
|
||||
98
cmd/gitea/cmd_prs.go
Normal file
98
cmd/gitea/cmd_prs.go
Normal file
|
|
@ -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 <owner/repo>",
|
||||
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)
|
||||
}
|
||||
125
cmd/gitea/cmd_repos.go
Normal file
125
cmd/gitea/cmd_repos.go
Normal file
|
|
@ -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
|
||||
}
|
||||
353
cmd/gitea/cmd_sync.go
Normal file
353
cmd/gitea/cmd_sync.go
Normal file
|
|
@ -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> [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 }
|
||||
20
go.mod
20
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
|
||||
)
|
||||
|
|
|
|||
52
go.sum
52
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=
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue