feat(gitea): add Gitea Go SDK integration and CLI commands (#324)
* feat(gitea): add Gitea Go SDK integration and CLI commands Add `code.gitea.io/sdk/gitea` and create `pkg/gitea/` package for connecting to self-hosted Gitea instances. Wire into CLI as `core gitea` command group with repo, issue, PR, mirror, and sync subcommands. pkg/gitea/: - client.go: thin wrapper around SDK with config-based auth - config.go: env → config file → flags resolution - repos.go: list/get/create/delete repos, create mirrors - issues.go: list/get/create issues and pull requests - meta.go: pipeline MetaReader for structural + content signals internal/cmd/gitea/: - config: set URL/token, test connection - repos: list repos with table output - issues: list/create issues - prs: list pull requests - mirror: create GitHub→Gitea mirrors with auth - sync: upstream/main branch strategy (--setup + ongoing sync) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * style(gitea): fix gofmt formatting Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(gitea): address Copilot review feedback - Use os.UserHomeDir() instead of sh -c "echo $HOME" for home dir expansion - Distinguish "already exists" from real errors in createMainFromUpstream - Fix package docs to match actual config resolution order - Guard token masking against short tokens (< 8 chars) - Paginate ListIssueComments in GetPRMeta and GetCommentBodies - Rename loop variable to avoid shadowing receiver in GetCommentBodies - Move gitea SDK to direct require block in go.mod Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
1dffdd3a1b
commit
89461d12eb
15 changed files with 1468 additions and 0 deletions
5
go.mod
5
go.mod
|
|
@ -3,6 +3,7 @@ module github.com/host-uk/core
|
|||
go 1.25.5
|
||||
|
||||
require (
|
||||
code.gitea.io/sdk/gitea v0.23.2
|
||||
github.com/Snider/Borg v0.2.0
|
||||
github.com/getkin/kin-openapi v0.133.0
|
||||
github.com/host-uk/core/internal/core-ide v0.0.0-20260204004957-989b7e1e6555
|
||||
|
|
@ -29,6 +30,7 @@ require (
|
|||
aead.dev/minisign v0.3.0 // indirect
|
||||
cloud.google.com/go v0.123.0 // indirect
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
github.com/42wim/httpsig v1.2.3 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/ProtonMail/go-crypto v1.3.0 // indirect
|
||||
github.com/Snider/Enchantrix v0.0.2 // indirect
|
||||
|
|
@ -41,9 +43,11 @@ require (
|
|||
github.com/coder/websocket v1.8.14 // indirect
|
||||
github.com/cyphar/filepath-securejoin v0.6.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/ebitengine/purego v0.9.1 // indirect
|
||||
github.com/emirpasic/gods v1.18.1 // indirect
|
||||
github.com/fatih/color v1.18.0 // indirect
|
||||
github.com/go-fed/httpsig v1.1.0 // indirect
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
||||
github.com/go-git/go-billy/v5 v5.7.0 // indirect
|
||||
github.com/go-git/go-git/v5 v5.16.4 // indirect
|
||||
|
|
@ -58,6 +62,7 @@ require (
|
|||
github.com/google/jsonschema-go v0.4.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/hashicorp/go-version v1.7.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
|
||||
|
|
|
|||
13
go.sum
13
go.sum
|
|
@ -3,8 +3,12 @@ aead.dev/minisign v0.3.0 h1:8Xafzy5PEVZqYDNP60yJHARlW1eOQtsKNp/Ph2c0vRA=
|
|||
aead.dev/minisign v0.3.0/go.mod h1:NLvG3Uoq3skkRMDuc3YHpWUTMTrSExqm+Ij73W13F6Y=
|
||||
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
|
||||
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
|
||||
code.gitea.io/sdk/gitea v0.23.2 h1:iJB1FDmLegwfwjX8gotBDHdPSbk/ZR8V9VmEJaVsJYg=
|
||||
code.gitea.io/sdk/gitea v0.23.2/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM=
|
||||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
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/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
|
|
@ -41,6 +45,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
|
|||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
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/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
|
||||
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
|
||||
|
|
@ -53,6 +59,8 @@ github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz
|
|||
github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE=
|
||||
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
||||
github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
|
||||
github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
|
||||
github.com/go-git/go-billy/v5 v5.7.0 h1:83lBUJhGWhYp0ngzCMSgllhUSuoHP1iEWYjsPl9nwqM=
|
||||
|
|
@ -105,6 +113,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
|
||||
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/host-uk/core/internal/core-ide v0.0.0-20260204004957-989b7e1e6555 h1:v5LWtsFypIhFzZpTx+mY64D5TyCI+CqJY8hmqmEx23E=
|
||||
github.com/host-uk/core/internal/core-ide v0.0.0-20260204004957-989b7e1e6555/go.mod h1:YWAcL4vml/IMkYVKqf5J4ukTINVH1zGw0G8vg/qlops=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
|
|
@ -255,7 +265,9 @@ go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42s
|
|||
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=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
|
||||
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
|
||||
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
|
|
@ -277,6 +289,7 @@ golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwE
|
|||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
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-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
|
|
|
|||
106
internal/cmd/gitea/cmd_config.go
Normal file
106
internal/cmd/gitea/cmd_config.go
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
package gitea
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/host-uk/core/pkg/cli"
|
||||
gt "github.com/host-uk/core/pkg/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
internal/cmd/gitea/cmd_gitea.go
Normal file
47
internal/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 (
|
||||
"github.com/host-uk/core/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
internal/cmd/gitea/cmd_issues.go
Normal file
133
internal/cmd/gitea/cmd_issues.go
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
package gitea
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/sdk/gitea"
|
||||
|
||||
"github.com/host-uk/core/pkg/cli"
|
||||
gt "github.com/host-uk/core/pkg/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
internal/cmd/gitea/cmd_mirror.go
Normal file
92
internal/cmd/gitea/cmd_mirror.go
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
package gitea
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/host-uk/core/pkg/cli"
|
||||
gt "github.com/host-uk/core/pkg/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
internal/cmd/gitea/cmd_prs.go
Normal file
98
internal/cmd/gitea/cmd_prs.go
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
package gitea
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
sdk "code.gitea.io/sdk/gitea"
|
||||
|
||||
"github.com/host-uk/core/pkg/cli"
|
||||
gt "github.com/host-uk/core/pkg/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
internal/cmd/gitea/cmd_repos.go
Normal file
125
internal/cmd/gitea/cmd_repos.go
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
package gitea
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/host-uk/core/pkg/cli"
|
||||
gt "github.com/host-uk/core/pkg/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
internal/cmd/gitea/cmd_sync.go
Normal file
353
internal/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"
|
||||
|
||||
"github.com/host-uk/core/pkg/cli"
|
||||
gt "github.com/host-uk/core/pkg/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,6 +20,7 @@
|
|||
// - test: Test runner with coverage
|
||||
// - qa: Quality assurance workflows
|
||||
// - monitor: Security monitoring aggregation
|
||||
// - gitea: Gitea instance management (repos, issues, PRs, mirrors)
|
||||
|
||||
package variants
|
||||
|
||||
|
|
@ -35,6 +36,7 @@ import (
|
|||
_ "github.com/host-uk/core/internal/cmd/docs"
|
||||
_ "github.com/host-uk/core/internal/cmd/doctor"
|
||||
_ "github.com/host-uk/core/internal/cmd/gitcmd"
|
||||
_ "github.com/host-uk/core/internal/cmd/gitea"
|
||||
_ "github.com/host-uk/core/internal/cmd/go"
|
||||
_ "github.com/host-uk/core/internal/cmd/help"
|
||||
_ "github.com/host-uk/core/internal/cmd/monitor"
|
||||
|
|
|
|||
37
pkg/gitea/client.go
Normal file
37
pkg/gitea/client.go
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
// Package gitea provides a thin wrapper around the Gitea Go SDK
|
||||
// for managing repositories, issues, and pull requests on a Gitea instance.
|
||||
//
|
||||
// Authentication is resolved from config file, environment variables, or flag overrides:
|
||||
//
|
||||
// 1. ~/.core/config.yaml keys: gitea.token, gitea.url
|
||||
// 2. GITEA_TOKEN + GITEA_URL environment variables (override config file)
|
||||
// 3. Flag overrides via core gitea config --url/--token (highest priority)
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"code.gitea.io/sdk/gitea"
|
||||
|
||||
"github.com/host-uk/core/pkg/log"
|
||||
)
|
||||
|
||||
// Client wraps the Gitea SDK client with config-based auth.
|
||||
type Client struct {
|
||||
api *gitea.Client
|
||||
url string
|
||||
}
|
||||
|
||||
// New creates a new Gitea API client for the given URL and token.
|
||||
func New(url, token string) (*Client, error) {
|
||||
api, err := gitea.NewClient(url, gitea.SetToken(token))
|
||||
if err != nil {
|
||||
return nil, log.E("gitea.New", "failed to create client", err)
|
||||
}
|
||||
|
||||
return &Client{api: api, url: url}, nil
|
||||
}
|
||||
|
||||
// API exposes the underlying SDK client for direct access.
|
||||
func (c *Client) API() *gitea.Client { return c.api }
|
||||
|
||||
// URL returns the Gitea instance URL.
|
||||
func (c *Client) URL() string { return c.url }
|
||||
92
pkg/gitea/config.go
Normal file
92
pkg/gitea/config.go
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
package gitea
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/host-uk/core/pkg/config"
|
||||
"github.com/host-uk/core/pkg/log"
|
||||
)
|
||||
|
||||
const (
|
||||
// ConfigKeyURL is the config key for the Gitea instance URL.
|
||||
ConfigKeyURL = "gitea.url"
|
||||
// ConfigKeyToken is the config key for the Gitea API token.
|
||||
ConfigKeyToken = "gitea.token"
|
||||
|
||||
// DefaultURL is the default Gitea instance URL.
|
||||
DefaultURL = "https://gitea.snider.dev"
|
||||
)
|
||||
|
||||
// NewFromConfig creates a Gitea client using the standard config resolution:
|
||||
//
|
||||
// 1. ~/.core/config.yaml keys: gitea.token, gitea.url
|
||||
// 2. GITEA_TOKEN + GITEA_URL environment variables (override config file)
|
||||
// 3. Provided flag overrides (highest priority; pass empty to skip)
|
||||
func NewFromConfig(flagURL, flagToken string) (*Client, error) {
|
||||
url, token, err := ResolveConfig(flagURL, flagToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
return nil, log.E("gitea.NewFromConfig", "no API token configured (set GITEA_TOKEN or run: core gitea config --token TOKEN)", nil)
|
||||
}
|
||||
|
||||
return New(url, token)
|
||||
}
|
||||
|
||||
// ResolveConfig resolves the Gitea URL and token from all config sources.
|
||||
// Flag values take highest priority, then env vars, then config file.
|
||||
func ResolveConfig(flagURL, flagToken string) (url, token string, err error) {
|
||||
// Start with config file values
|
||||
cfg, cfgErr := config.New()
|
||||
if cfgErr == nil {
|
||||
_ = cfg.Get(ConfigKeyURL, &url)
|
||||
_ = cfg.Get(ConfigKeyToken, &token)
|
||||
}
|
||||
|
||||
// Overlay environment variables
|
||||
if envURL := os.Getenv("GITEA_URL"); envURL != "" {
|
||||
url = envURL
|
||||
}
|
||||
if envToken := os.Getenv("GITEA_TOKEN"); envToken != "" {
|
||||
token = envToken
|
||||
}
|
||||
|
||||
// Overlay flag values (highest priority)
|
||||
if flagURL != "" {
|
||||
url = flagURL
|
||||
}
|
||||
if flagToken != "" {
|
||||
token = flagToken
|
||||
}
|
||||
|
||||
// Default URL if nothing configured
|
||||
if url == "" {
|
||||
url = DefaultURL
|
||||
}
|
||||
|
||||
return url, token, nil
|
||||
}
|
||||
|
||||
// SaveConfig persists the Gitea URL and/or token to the config file.
|
||||
func SaveConfig(url, token string) error {
|
||||
cfg, err := config.New()
|
||||
if err != nil {
|
||||
return log.E("gitea.SaveConfig", "failed to load config", err)
|
||||
}
|
||||
|
||||
if url != "" {
|
||||
if err := cfg.Set(ConfigKeyURL, url); err != nil {
|
||||
return log.E("gitea.SaveConfig", "failed to save URL", err)
|
||||
}
|
||||
}
|
||||
|
||||
if token != "" {
|
||||
if err := cfg.Set(ConfigKeyToken, token); err != nil {
|
||||
return log.E("gitea.SaveConfig", "failed to save token", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
109
pkg/gitea/issues.go
Normal file
109
pkg/gitea/issues.go
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
package gitea
|
||||
|
||||
import (
|
||||
"code.gitea.io/sdk/gitea"
|
||||
|
||||
"github.com/host-uk/core/pkg/log"
|
||||
)
|
||||
|
||||
// ListIssuesOpts configures issue listing.
|
||||
type ListIssuesOpts struct {
|
||||
State string // "open", "closed", "all"
|
||||
Page int
|
||||
Limit int
|
||||
}
|
||||
|
||||
// ListIssues returns issues for the given repository.
|
||||
func (c *Client) ListIssues(owner, repo string, opts ListIssuesOpts) ([]*gitea.Issue, error) {
|
||||
state := gitea.StateOpen
|
||||
switch opts.State {
|
||||
case "closed":
|
||||
state = gitea.StateClosed
|
||||
case "all":
|
||||
state = gitea.StateAll
|
||||
}
|
||||
|
||||
limit := opts.Limit
|
||||
if limit == 0 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
page := opts.Page
|
||||
if page == 0 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
issues, _, err := c.api.ListRepoIssues(owner, repo, gitea.ListIssueOption{
|
||||
ListOptions: gitea.ListOptions{Page: page, PageSize: limit},
|
||||
State: state,
|
||||
Type: gitea.IssueTypeIssue,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, log.E("gitea.ListIssues", "failed to list issues", err)
|
||||
}
|
||||
|
||||
return issues, nil
|
||||
}
|
||||
|
||||
// GetIssue returns a single issue by number.
|
||||
func (c *Client) GetIssue(owner, repo string, number int64) (*gitea.Issue, error) {
|
||||
issue, _, err := c.api.GetIssue(owner, repo, number)
|
||||
if err != nil {
|
||||
return nil, log.E("gitea.GetIssue", "failed to get issue", err)
|
||||
}
|
||||
|
||||
return issue, nil
|
||||
}
|
||||
|
||||
// CreateIssue creates a new issue in the given repository.
|
||||
func (c *Client) CreateIssue(owner, repo string, opts gitea.CreateIssueOption) (*gitea.Issue, error) {
|
||||
issue, _, err := c.api.CreateIssue(owner, repo, opts)
|
||||
if err != nil {
|
||||
return nil, log.E("gitea.CreateIssue", "failed to create issue", err)
|
||||
}
|
||||
|
||||
return issue, nil
|
||||
}
|
||||
|
||||
// ListPullRequests returns pull requests for the given repository.
|
||||
func (c *Client) ListPullRequests(owner, repo string, state string) ([]*gitea.PullRequest, error) {
|
||||
st := gitea.StateOpen
|
||||
switch state {
|
||||
case "closed":
|
||||
st = gitea.StateClosed
|
||||
case "all":
|
||||
st = gitea.StateAll
|
||||
}
|
||||
|
||||
var all []*gitea.PullRequest
|
||||
page := 1
|
||||
|
||||
for {
|
||||
prs, resp, err := c.api.ListRepoPullRequests(owner, repo, gitea.ListPullRequestsOptions{
|
||||
ListOptions: gitea.ListOptions{Page: page, PageSize: 50},
|
||||
State: st,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, log.E("gitea.ListPullRequests", "failed to list pull requests", err)
|
||||
}
|
||||
|
||||
all = append(all, prs...)
|
||||
|
||||
if resp == nil || page >= resp.LastPage {
|
||||
break
|
||||
}
|
||||
page++
|
||||
}
|
||||
|
||||
return all, nil
|
||||
}
|
||||
|
||||
// GetPullRequest returns a single pull request by number.
|
||||
func (c *Client) GetPullRequest(owner, repo string, number int64) (*gitea.PullRequest, error) {
|
||||
pr, _, err := c.api.GetPullRequest(owner, repo, number)
|
||||
if err != nil {
|
||||
return nil, log.E("gitea.GetPullRequest", "failed to get pull request", err)
|
||||
}
|
||||
|
||||
return pr, nil
|
||||
}
|
||||
146
pkg/gitea/meta.go
Normal file
146
pkg/gitea/meta.go
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
package gitea
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"code.gitea.io/sdk/gitea"
|
||||
|
||||
"github.com/host-uk/core/pkg/log"
|
||||
)
|
||||
|
||||
// PRMeta holds structural signals from a pull request,
|
||||
// used by the pipeline MetaReader for AI-driven workflows.
|
||||
type PRMeta struct {
|
||||
Number int64
|
||||
Title string
|
||||
State string
|
||||
Author string
|
||||
Branch string
|
||||
BaseBranch string
|
||||
Labels []string
|
||||
Assignees []string
|
||||
IsMerged bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
CommentCount int
|
||||
}
|
||||
|
||||
// Comment represents a comment with metadata.
|
||||
type Comment struct {
|
||||
ID int64
|
||||
Author string
|
||||
Body string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
const commentPageSize = 50
|
||||
|
||||
// GetPRMeta returns structural signals for a pull request.
|
||||
// This is the Gitea side of the dual MetaReader described in the pipeline design.
|
||||
func (c *Client) GetPRMeta(owner, repo string, pr int64) (*PRMeta, error) {
|
||||
pull, _, err := c.api.GetPullRequest(owner, repo, pr)
|
||||
if err != nil {
|
||||
return nil, log.E("gitea.GetPRMeta", "failed to get PR metadata", err)
|
||||
}
|
||||
|
||||
meta := &PRMeta{
|
||||
Number: pull.Index,
|
||||
Title: pull.Title,
|
||||
State: string(pull.State),
|
||||
Branch: pull.Head.Ref,
|
||||
BaseBranch: pull.Base.Ref,
|
||||
IsMerged: pull.HasMerged,
|
||||
}
|
||||
|
||||
if pull.Created != nil {
|
||||
meta.CreatedAt = *pull.Created
|
||||
}
|
||||
if pull.Updated != nil {
|
||||
meta.UpdatedAt = *pull.Updated
|
||||
}
|
||||
|
||||
if pull.Poster != nil {
|
||||
meta.Author = pull.Poster.UserName
|
||||
}
|
||||
|
||||
for _, label := range pull.Labels {
|
||||
meta.Labels = append(meta.Labels, label.Name)
|
||||
}
|
||||
|
||||
for _, assignee := range pull.Assignees {
|
||||
meta.Assignees = append(meta.Assignees, assignee.UserName)
|
||||
}
|
||||
|
||||
// Fetch comment count from the issue side (PRs are issues in Gitea).
|
||||
// Paginate to get an accurate count.
|
||||
count := 0
|
||||
page := 1
|
||||
for {
|
||||
comments, _, listErr := c.api.ListIssueComments(owner, repo, pr, gitea.ListIssueCommentOptions{
|
||||
ListOptions: gitea.ListOptions{Page: page, PageSize: commentPageSize},
|
||||
})
|
||||
if listErr != nil {
|
||||
break
|
||||
}
|
||||
count += len(comments)
|
||||
if len(comments) < commentPageSize {
|
||||
break
|
||||
}
|
||||
page++
|
||||
}
|
||||
meta.CommentCount = count
|
||||
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
// GetCommentBodies returns all comment bodies for a pull request.
|
||||
// This reads full content, which is safe on the home lab Gitea instance.
|
||||
func (c *Client) GetCommentBodies(owner, repo string, pr int64) ([]Comment, error) {
|
||||
var comments []Comment
|
||||
page := 1
|
||||
|
||||
for {
|
||||
raw, _, err := c.api.ListIssueComments(owner, repo, pr, gitea.ListIssueCommentOptions{
|
||||
ListOptions: gitea.ListOptions{Page: page, PageSize: commentPageSize},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, log.E("gitea.GetCommentBodies", "failed to get PR comments", err)
|
||||
}
|
||||
|
||||
if len(raw) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
for _, rc := range raw {
|
||||
comment := Comment{
|
||||
ID: rc.ID,
|
||||
Body: rc.Body,
|
||||
CreatedAt: rc.Created,
|
||||
UpdatedAt: rc.Updated,
|
||||
}
|
||||
if rc.Poster != nil {
|
||||
comment.Author = rc.Poster.UserName
|
||||
}
|
||||
comments = append(comments, comment)
|
||||
}
|
||||
|
||||
if len(raw) < commentPageSize {
|
||||
break
|
||||
}
|
||||
page++
|
||||
}
|
||||
|
||||
return comments, nil
|
||||
}
|
||||
|
||||
// GetIssueBody returns the body text of an issue.
|
||||
// This reads full content, which is safe on the home lab Gitea instance.
|
||||
func (c *Client) GetIssueBody(owner, repo string, issue int64) (string, error) {
|
||||
iss, _, err := c.api.GetIssue(owner, repo, issue)
|
||||
if err != nil {
|
||||
return "", log.E("gitea.GetIssueBody", "failed to get issue body", err)
|
||||
}
|
||||
|
||||
return iss.Body, nil
|
||||
}
|
||||
110
pkg/gitea/repos.go
Normal file
110
pkg/gitea/repos.go
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
package gitea
|
||||
|
||||
import (
|
||||
"code.gitea.io/sdk/gitea"
|
||||
|
||||
"github.com/host-uk/core/pkg/log"
|
||||
)
|
||||
|
||||
// ListOrgRepos returns all repositories for the given organisation.
|
||||
func (c *Client) ListOrgRepos(org string) ([]*gitea.Repository, error) {
|
||||
var all []*gitea.Repository
|
||||
page := 1
|
||||
|
||||
for {
|
||||
repos, resp, err := c.api.ListOrgRepos(org, gitea.ListOrgReposOptions{
|
||||
ListOptions: gitea.ListOptions{Page: page, PageSize: 50},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, log.E("gitea.ListOrgRepos", "failed to list org repos", err)
|
||||
}
|
||||
|
||||
all = append(all, repos...)
|
||||
|
||||
if resp == nil || page >= resp.LastPage {
|
||||
break
|
||||
}
|
||||
page++
|
||||
}
|
||||
|
||||
return all, nil
|
||||
}
|
||||
|
||||
// ListUserRepos returns all repositories for the authenticated user.
|
||||
func (c *Client) ListUserRepos() ([]*gitea.Repository, error) {
|
||||
var all []*gitea.Repository
|
||||
page := 1
|
||||
|
||||
for {
|
||||
repos, resp, err := c.api.ListMyRepos(gitea.ListReposOptions{
|
||||
ListOptions: gitea.ListOptions{Page: page, PageSize: 50},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, log.E("gitea.ListUserRepos", "failed to list user repos", err)
|
||||
}
|
||||
|
||||
all = append(all, repos...)
|
||||
|
||||
if resp == nil || page >= resp.LastPage {
|
||||
break
|
||||
}
|
||||
page++
|
||||
}
|
||||
|
||||
return all, nil
|
||||
}
|
||||
|
||||
// GetRepo returns a single repository by owner and name.
|
||||
func (c *Client) GetRepo(owner, name string) (*gitea.Repository, error) {
|
||||
repo, _, err := c.api.GetRepo(owner, name)
|
||||
if err != nil {
|
||||
return nil, log.E("gitea.GetRepo", "failed to get repo", err)
|
||||
}
|
||||
|
||||
return repo, nil
|
||||
}
|
||||
|
||||
// CreateMirror creates a mirror repository on Gitea from a GitHub clone URL.
|
||||
// This uses the Gitea migration API to set up a pull mirror.
|
||||
// If authToken is provided, it is used to authenticate against the source (e.g. for private GitHub repos).
|
||||
func (c *Client) CreateMirror(owner, name, cloneURL, authToken string) (*gitea.Repository, error) {
|
||||
opts := gitea.MigrateRepoOption{
|
||||
RepoName: name,
|
||||
RepoOwner: owner,
|
||||
CloneAddr: cloneURL,
|
||||
Service: gitea.GitServiceGithub,
|
||||
Mirror: true,
|
||||
Description: "Mirror of " + cloneURL,
|
||||
}
|
||||
|
||||
if authToken != "" {
|
||||
opts.AuthToken = authToken
|
||||
}
|
||||
|
||||
repo, _, err := c.api.MigrateRepo(opts)
|
||||
if err != nil {
|
||||
return nil, log.E("gitea.CreateMirror", "failed to create mirror", err)
|
||||
}
|
||||
|
||||
return repo, nil
|
||||
}
|
||||
|
||||
// DeleteRepo deletes a repository from Gitea.
|
||||
func (c *Client) DeleteRepo(owner, name string) error {
|
||||
_, err := c.api.DeleteRepo(owner, name)
|
||||
if err != nil {
|
||||
return log.E("gitea.DeleteRepo", "failed to delete repo", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateOrgRepo creates a new empty repository under an organisation.
|
||||
func (c *Client) CreateOrgRepo(org string, opts gitea.CreateRepoOption) (*gitea.Repository, error) {
|
||||
repo, _, err := c.api.CreateOrgRepo(org, opts)
|
||||
if err != nil {
|
||||
return nil, log.E("gitea.CreateOrgRepo", "failed to create org repo", err)
|
||||
}
|
||||
|
||||
return repo, nil
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue