From cbe95aa490e1d864d4f35cd234519d0ceb72960f Mon Sep 17 00:00:00 2001 From: Snider Date: Thu, 12 Mar 2026 19:04:41 +0000 Subject: [PATCH] feat: migrate ci/issues/reviews from gh CLI to Forgejo SDK Replace shell-outs to `gh` with native Gitea SDK calls via shared forge_client.go helper. Supports both ListRepoActionRuns (1.25+) and ListRepoActionTasks (older Forgejo) for CI status. Issues and reviews now use SDK list endpoints with proper filtering. Co-Authored-By: Virgil --- cmd/dev/cmd_ci.go | 150 ++++++++++++++++++++----------------- cmd/dev/cmd_dev.go | 4 +- cmd/dev/cmd_issues.go | 131 +++++++++++++++----------------- cmd/dev/cmd_reviews.go | 161 +++++++++++++++++++++++----------------- cmd/dev/forge_client.go | 73 ++++++++++++++++++ go.mod | 1 + 6 files changed, 309 insertions(+), 211 deletions(-) create mode 100644 cmd/dev/forge_client.go diff --git a/cmd/dev/cmd_ci.go b/cmd/dev/cmd_ci.go index 99afc3d..dd2dd67 100644 --- a/cmd/dev/cmd_ci.go +++ b/cmd/dev/cmd_ci.go @@ -1,17 +1,14 @@ package dev import ( - "encoding/json" - "errors" - "os" - "os/exec" + "path/filepath" "strings" "time" + "code.gitea.io/sdk/gitea" + "forge.lthn.ai/core/cli/pkg/cli" "forge.lthn.ai/core/go-i18n" - "forge.lthn.ai/core/go-io" - "forge.lthn.ai/core/go-scm/repos" ) // CI-specific styles (aliases to shared) @@ -22,18 +19,16 @@ var ( ciSkippedStyle = cli.DimStyle ) -// WorkflowRun represents a GitHub Actions workflow run +// WorkflowRun represents a CI workflow run. type WorkflowRun struct { - Name string `json:"name"` - Status string `json:"status"` - Conclusion string `json:"conclusion"` - HeadBranch string `json:"headBranch"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` - URL string `json:"url"` - - // Added by us - RepoName string `json:"-"` + Name string + Status string + Conclusion string + HeadBranch string + CreatedAt time.Time + UpdatedAt time.Time + URL string + RepoName string } // CI command flags @@ -66,34 +61,15 @@ func addCICommand(parent *cli.Command) { } func runCI(registryPath string, branch string, failedOnly bool) error { - // Check gh is available - if _, err := exec.LookPath("gh"); err != nil { - return errors.New(i18n.T("error.gh_not_found")) + client, err := forgeAPIClient() + if err != nil { + return err } // Find or use provided registry - var reg *repos.Registry - var err error - - if registryPath != "" { - reg, err = repos.LoadRegistry(io.Local, registryPath) - if err != nil { - return cli.Wrap(err, "failed to load registry") - } - } else { - registryPath, err = repos.FindRegistry(io.Local) - if err == nil { - reg, err = repos.LoadRegistry(io.Local, registryPath) - if err != nil { - return cli.Wrap(err, "failed to load registry") - } - } else { - cwd, _ := os.Getwd() - reg, err = repos.ScanDirectory(io.Local, cwd) - if err != nil { - return cli.Wrap(err, "failed to scan directory") - } - } + reg, _, err := loadRegistryWithConfig(registryPath) + if err != nil { + return err } // Fetch CI status sequentially @@ -103,12 +79,12 @@ func runCI(registryPath string, branch string, failedOnly bool) error { repoList := reg.List() for i, repo := range repoList { - repoFullName := cli.Sprintf("%s/%s", reg.Org, repo.Name) cli.Print("\033[2K\r%s %d/%d %s", dimStyle.Render(i18n.T("i18n.progress.check")), i+1, len(repoList), repo.Name) - runs, err := fetchWorkflowRuns(repoFullName, repo.Name, branch) + owner, apiRepo := forgeRepoIdentity(repo.Path, reg.Org, repo.Name) + runs, err := fetchWorkflowRuns(client, owner, apiRepo, repo.Name, branch) if err != nil { - if strings.Contains(err.Error(), "no workflows") { + if strings.Contains(err.Error(), "404") || strings.Contains(err.Error(), "no workflows") { noCI = append(noCI, repo.Name) } else { fetchErrors = append(fetchErrors, cli.Wrap(err, repo.Name)) @@ -134,7 +110,8 @@ func runCI(registryPath string, branch string, failedOnly bool) error { case "failure": failed++ case "": - if run.Status == "in_progress" || run.Status == "queued" { + if run.Status == "running" || run.Status == "waiting" || + run.Status == "in_progress" || run.Status == "queued" { pending++ } else { other++ @@ -189,33 +166,68 @@ func runCI(registryPath string, branch string, failedOnly bool) error { return nil } -func fetchWorkflowRuns(repoFullName, repoName string, branch string) ([]WorkflowRun, error) { - args := []string{ - "run", "list", - "--repo", repoFullName, - "--branch", branch, - "--limit", "1", - "--json", "name,status,conclusion,headBranch,createdAt,updatedAt,url", +func fetchWorkflowRuns(client *gitea.Client, owner, apiRepo, displayName string, branch string) ([]WorkflowRun, error) { + // Try ListRepoActionRuns first (Gitea 1.25+ / modern Forgejo) + resp, _, err := client.ListRepoActionRuns(owner, apiRepo, gitea.ListRepoActionRunsOptions{ + ListOptions: gitea.ListOptions{Page: 1, PageSize: 5}, + Branch: branch, + }) + if err == nil && resp != nil && len(resp.WorkflowRuns) > 0 { + var runs []WorkflowRun + for _, r := range resp.WorkflowRuns { + name := r.DisplayTitle + if r.Path != "" { + // Use workflow filename as name: ".forgejo/workflows/ci.yml" → "ci" + name = strings.TrimSuffix(filepath.Base(r.Path), filepath.Ext(r.Path)) + } + updated := r.CompletedAt + if updated.IsZero() { + updated = r.StartedAt + } + runs = append(runs, WorkflowRun{ + Name: name, + Status: r.Status, + Conclusion: r.Conclusion, + HeadBranch: r.HeadBranch, + CreatedAt: r.StartedAt, + UpdatedAt: updated, + URL: r.HTMLURL, + RepoName: displayName, + }) + } + return runs, nil } - cmd := exec.Command("gh", args...) - output, err := cmd.Output() - if err != nil { - if exitErr, ok := err.(*exec.ExitError); ok { - stderr := string(exitErr.Stderr) - return nil, cli.Err("%s", strings.TrimSpace(stderr)) + // Fallback: ListRepoActionTasks (older API, no version check) + taskResp, _, taskErr := client.ListRepoActionTasks(owner, apiRepo, gitea.ListOptions{Page: 1, PageSize: 10}) + if taskErr != nil { + if err != nil { + return nil, err } - return nil, err + return nil, taskErr } var runs []WorkflowRun - if err := json.Unmarshal(output, &runs); err != nil { - return nil, err - } - - // Tag with repo name - for i := range runs { - runs[i].RepoName = repoName + for _, t := range taskResp.WorkflowRuns { + if branch != "" && t.HeadBranch != branch { + continue + } + // ActionTask has single Status field — map to conclusion for completed runs + conclusion := "" + switch t.Status { + case "success", "failure", "skipped", "cancelled": + conclusion = t.Status + } + runs = append(runs, WorkflowRun{ + Name: t.Name, + Status: t.Status, + Conclusion: conclusion, + HeadBranch: t.HeadBranch, + CreatedAt: t.CreatedAt, + UpdatedAt: t.UpdatedAt, + URL: t.URL, + RepoName: displayName, + }) } return runs, nil @@ -231,9 +243,9 @@ func printWorkflowRun(run WorkflowRun) { status = ciFailureStyle.Render("x") case "": switch run.Status { - case "in_progress": + case "running", "in_progress": status = ciPendingStyle.Render("*") - case "queued": + case "waiting", "queued": status = ciPendingStyle.Render("o") default: status = ciSkippedStyle.Render("-") diff --git a/cmd/dev/cmd_dev.go b/cmd/dev/cmd_dev.go index b3d6812..5fa39e6 100644 --- a/cmd/dev/cmd_dev.go +++ b/cmd/dev/cmd_dev.go @@ -7,10 +7,10 @@ // - push: Push repos with unpushed commits // - pull: Pull repos that are behind remote // -// GitHub Integration (requires gh CLI): +// Forge Integration (uses Forgejo/Gitea API): // - issues: List open issues across repos // - reviews: List PRs needing review -// - ci: Check GitHub Actions CI status +// - ci: Check CI workflow status // - impact: Analyse dependency impact of changes // // CI/Workflow Management: diff --git a/cmd/dev/cmd_issues.go b/cmd/dev/cmd_issues.go index 5c5af52..c9a6a78 100644 --- a/cmd/dev/cmd_issues.go +++ b/cmd/dev/cmd_issues.go @@ -1,13 +1,12 @@ package dev import ( - "encoding/json" - "errors" - "os/exec" "slices" "strings" "time" + "code.gitea.io/sdk/gitea" + "forge.lthn.ai/core/cli/pkg/cli" "forge.lthn.ai/core/go-i18n" ) @@ -22,29 +21,16 @@ var ( issueAgeStyle = cli.DimStyle ) -// GitHubIssue represents a GitHub issue from the API. -type GitHubIssue struct { - Number int `json:"number"` - Title string `json:"title"` - State string `json:"state"` - CreatedAt time.Time `json:"createdAt"` - Author struct { - Login string `json:"login"` - } `json:"author"` - Assignees struct { - Nodes []struct { - Login string `json:"login"` - } `json:"nodes"` - } `json:"assignees"` - Labels struct { - Nodes []struct { - Name string `json:"name"` - } `json:"nodes"` - } `json:"labels"` - URL string `json:"url"` - - // Added by us - RepoName string `json:"-"` +// ForgeIssue holds display data for an issue. +type ForgeIssue struct { + Number int64 + Title string + Author string + Assignees []string + Labels []string + CreatedAt time.Time + URL string + RepoName string } // Issues command flags @@ -77,9 +63,9 @@ func addIssuesCommand(parent *cli.Command) { } func runIssues(registryPath string, limit int, assignee string) error { - // Check gh is available - if _, err := exec.LookPath("gh"); err != nil { - return errors.New(i18n.T("error.gh_not_found")) + client, err := forgeAPIClient() + if err != nil { + return err } // Find or use provided registry @@ -88,16 +74,16 @@ func runIssues(registryPath string, limit int, assignee string) error { return err } - // Fetch issues sequentially (avoid GitHub rate limits) - var allIssues []GitHubIssue + // Fetch issues sequentially + var allIssues []ForgeIssue var fetchErrors []error repoList := reg.List() for i, repo := range repoList { - repoFullName := cli.Sprintf("%s/%s", reg.Org, repo.Name) cli.Print("\033[2K\r%s %d/%d %s", dimStyle.Render(i18n.T("i18n.progress.fetch")), i+1, len(repoList), repo.Name) - issues, err := fetchIssues(repoFullName, repo.Name, limit, assignee) + owner, apiRepo := forgeRepoIdentity(repo.Path, reg.Org, repo.Name) + issues, err := fetchIssues(client, owner, apiRepo, repo.Name, limit, assignee) if err != nil { fetchErrors = append(fetchErrors, cli.Wrap(err, repo.Name)) continue @@ -107,7 +93,7 @@ func runIssues(registryPath string, limit int, assignee string) error { cli.Print("\033[2K\r") // Clear progress line // Sort by created date (newest first) - slices.SortFunc(allIssues, func(a, b GitHubIssue) int { + slices.SortFunc(allIssues, func(a, b ForgeIssue) int { return b.CreatedAt.Compare(a.CreatedAt) }) @@ -134,47 +120,50 @@ func runIssues(registryPath string, limit int, assignee string) error { return nil } -func fetchIssues(repoFullName, repoName string, limit int, assignee string) ([]GitHubIssue, error) { - args := []string{ - "issue", "list", - "--repo", repoFullName, - "--state", "open", - "--limit", cli.Sprintf("%d", limit), - "--json", "number,title,state,createdAt,author,assignees,labels,url", +func fetchIssues(client *gitea.Client, owner, apiRepo, displayName string, limit int, assignee string) ([]ForgeIssue, error) { + opts := gitea.ListIssueOption{ + ListOptions: gitea.ListOptions{Page: 1, PageSize: limit}, + State: gitea.StateOpen, + Type: gitea.IssueTypeIssue, } - if assignee != "" { - args = append(args, "--assignee", assignee) + opts.AssignedBy = assignee } - cmd := exec.Command("gh", args...) - output, err := cmd.Output() + issues, _, err := client.ListRepoIssues(owner, apiRepo, opts) if err != nil { - // Check if it's just "no issues" vs actual error - if exitErr, ok := err.(*exec.ExitError); ok { - stderr := string(exitErr.Stderr) - if strings.Contains(stderr, "no issues") || strings.Contains(stderr, "Could not resolve") { - return nil, nil - } - return nil, cli.Err("%s", stderr) + errMsg := err.Error() + if strings.Contains(errMsg, "404") || strings.Contains(errMsg, "Not Found") { + return nil, nil } return nil, err } - var issues []GitHubIssue - if err := json.Unmarshal(output, &issues); err != nil { - return nil, err + var result []ForgeIssue + for _, issue := range issues { + fi := ForgeIssue{ + Number: issue.Index, + Title: issue.Title, + CreatedAt: issue.Created, + URL: issue.HTMLURL, + RepoName: displayName, + } + if issue.Poster != nil { + fi.Author = issue.Poster.UserName + } + for _, a := range issue.Assignees { + fi.Assignees = append(fi.Assignees, a.UserName) + } + for _, l := range issue.Labels { + fi.Labels = append(fi.Labels, l.Name) + } + result = append(result, fi) } - // Tag with repo name - for i := range issues { - issues[i].RepoName = repoName - } - - return issues, nil + return result, nil } -func printIssue(issue GitHubIssue) { +func printIssue(issue ForgeIssue) { // #42 [core-bio] Fix avatar upload num := issueNumberStyle.Render(cli.Sprintf("#%d", issue.Number)) repo := issueRepoStyle.Render(cli.Sprintf("[%s]", issue.RepoName)) @@ -183,21 +172,17 @@ func printIssue(issue GitHubIssue) { line := cli.Sprintf(" %s %s %s", num, repo, title) // Add labels if any - if len(issue.Labels.Nodes) > 0 { - var labels []string - for _, l := range issue.Labels.Nodes { - labels = append(labels, l.Name) - } - line += " " + issueLabelStyle.Render("["+strings.Join(labels, ", ")+"]") + if len(issue.Labels) > 0 { + line += " " + issueLabelStyle.Render("["+strings.Join(issue.Labels, ", ")+"]") } // Add assignee if any - if len(issue.Assignees.Nodes) > 0 { - var assignees []string - for _, a := range issue.Assignees.Nodes { - assignees = append(assignees, "@"+a.Login) + if len(issue.Assignees) > 0 { + var tagged []string + for _, a := range issue.Assignees { + tagged = append(tagged, "@"+a) } - line += " " + issueAssigneeStyle.Render(strings.Join(assignees, ", ")) + line += " " + issueAssigneeStyle.Render(strings.Join(tagged, ", ")) } // Add age diff --git a/cmd/dev/cmd_reviews.go b/cmd/dev/cmd_reviews.go index 1c56053..ab289af 100644 --- a/cmd/dev/cmd_reviews.go +++ b/cmd/dev/cmd_reviews.go @@ -1,13 +1,12 @@ package dev import ( - "encoding/json" - "errors" - "os/exec" "slices" "strings" "time" + "code.gitea.io/sdk/gitea" + "forge.lthn.ai/core/cli/pkg/cli" "forge.lthn.ai/core/go-i18n" ) @@ -23,29 +22,16 @@ var ( prDraftStyle = cli.DimStyle ) -// GitHubPR represents a GitHub pull request. -type GitHubPR struct { - Number int `json:"number"` - Title string `json:"title"` - State string `json:"state"` - IsDraft bool `json:"isDraft"` - CreatedAt time.Time `json:"createdAt"` - Author struct { - Login string `json:"login"` - } `json:"author"` - ReviewDecision string `json:"reviewDecision"` - Reviews struct { - Nodes []struct { - State string `json:"state"` - Author struct { - Login string `json:"login"` - } `json:"author"` - } `json:"nodes"` - } `json:"reviews"` - URL string `json:"url"` - - // Added by us - RepoName string `json:"-"` +// ForgePR holds display data for a pull request. +type ForgePR struct { + Number int64 + Title string + Draft bool + Author string + ReviewDecision string // "APPROVED", "CHANGES_REQUESTED", or "" + CreatedAt time.Time + URL string + RepoName string } // Reviews command flags @@ -74,9 +60,9 @@ func addReviewsCommand(parent *cli.Command) { } func runReviews(registryPath string, author string, showAll bool) error { - // Check gh is available - if _, err := exec.LookPath("gh"); err != nil { - return errors.New(i18n.T("error.gh_not_found")) + client, err := forgeAPIClient() + if err != nil { + return err } // Find or use provided registry @@ -85,16 +71,16 @@ func runReviews(registryPath string, author string, showAll bool) error { return err } - // Fetch PRs sequentially (avoid GitHub rate limits) - var allPRs []GitHubPR + // Fetch PRs sequentially + var allPRs []ForgePR var fetchErrors []error repoList := reg.List() for i, repo := range repoList { - repoFullName := cli.Sprintf("%s/%s", reg.Org, repo.Name) cli.Print("\033[2K\r%s %d/%d %s", dimStyle.Render(i18n.T("i18n.progress.fetch")), i+1, len(repoList), repo.Name) - prs, err := fetchPRs(repoFullName, repo.Name, author) + owner, apiRepo := forgeRepoIdentity(repo.Path, reg.Org, repo.Name) + prs, err := fetchPRs(client, owner, apiRepo, repo.Name, author) if err != nil { fetchErrors = append(fetchErrors, cli.Wrap(err, repo.Name)) continue @@ -102,7 +88,7 @@ func runReviews(registryPath string, author string, showAll bool) error { for _, pr := range prs { // Filter drafts unless --all - if !showAll && pr.IsDraft { + if !showAll && pr.Draft { continue } allPRs = append(allPRs, pr) @@ -111,7 +97,7 @@ func runReviews(registryPath string, author string, showAll bool) error { cli.Print("\033[2K\r") // Clear progress line // Sort: pending review first, then by date - slices.SortFunc(allPRs, func(a, b GitHubPR) int { + slices.SortFunc(allPRs, func(a, b ForgePR) int { // Pending reviews come first aPending := a.ReviewDecision == "" || a.ReviewDecision == "REVIEW_REQUIRED" bPending := b.ReviewDecision == "" || b.ReviewDecision == "REVIEW_REQUIRED" @@ -172,50 +158,91 @@ func runReviews(registryPath string, author string, showAll bool) error { return nil } -func fetchPRs(repoFullName, repoName string, author string) ([]GitHubPR, error) { - args := []string{ - "pr", "list", - "--repo", repoFullName, - "--state", "open", - "--json", "number,title,state,isDraft,createdAt,author,reviewDecision,reviews,url", - } - - if author != "" { - args = append(args, "--author", author) - } - - cmd := exec.Command("gh", args...) - output, err := cmd.Output() +func fetchPRs(client *gitea.Client, owner, apiRepo, displayName string, author string) ([]ForgePR, error) { + prs, _, err := client.ListRepoPullRequests(owner, apiRepo, gitea.ListPullRequestsOptions{ + ListOptions: gitea.ListOptions{Page: 1, PageSize: 50}, + State: gitea.StateOpen, + }) if err != nil { - if exitErr, ok := err.(*exec.ExitError); ok { - stderr := string(exitErr.Stderr) - if strings.Contains(stderr, "no pull requests") || strings.Contains(stderr, "Could not resolve") { - return nil, nil - } - return nil, cli.Err("%s", stderr) + errMsg := err.Error() + if strings.Contains(errMsg, "404") || strings.Contains(errMsg, "Not Found") { + return nil, nil } return nil, err } - var prs []GitHubPR - if err := json.Unmarshal(output, &prs); err != nil { - return nil, err + var result []ForgePR + for _, pr := range prs { + // Filter by author if specified + if author != "" && pr.Poster != nil && pr.Poster.UserName != author { + continue + } + + fp := ForgePR{ + Number: pr.Index, + Title: pr.Title, + Draft: pr.Draft, + URL: pr.HTMLURL, + RepoName: displayName, + } + if pr.Created != nil { + fp.CreatedAt = *pr.Created + } + if pr.Poster != nil { + fp.Author = pr.Poster.UserName + } + + // Determine review status + fp.ReviewDecision = determineReviewDecision(client, owner, apiRepo, pr.Index) + + result = append(result, fp) } - // Tag with repo name - for i := range prs { - prs[i].RepoName = repoName - } - - return prs, nil + return result, nil } -func printPR(pr GitHubPR) { +// determineReviewDecision fetches reviews for a PR and determines the overall status. +func determineReviewDecision(client *gitea.Client, owner, repo string, prIndex int64) string { + reviews, _, err := client.ListPullReviews(owner, repo, prIndex, gitea.ListPullReviewsOptions{ + ListOptions: gitea.ListOptions{Page: 1, PageSize: 50}, + }) + if err != nil || len(reviews) == 0 { + return "" // No reviews = pending + } + + // Track latest actionable review per reviewer + latestByReviewer := make(map[string]gitea.ReviewStateType) + for _, review := range reviews { + if review.Reviewer == nil { + continue + } + // Only consider approval and change-request reviews (not comments) + if review.State == gitea.ReviewStateApproved || review.State == gitea.ReviewStateRequestChanges { + latestByReviewer[review.Reviewer.UserName] = review.State + } + } + + if len(latestByReviewer) == 0 { + return "" + } + + // If any reviewer requested changes, overall = CHANGES_REQUESTED + for _, state := range latestByReviewer { + if state == gitea.ReviewStateRequestChanges { + return "CHANGES_REQUESTED" + } + } + + // All reviewers approved + return "APPROVED" +} + +func printPR(pr ForgePR) { // #12 [core-php] Webhook validation num := prNumberStyle.Render(cli.Sprintf("#%d", pr.Number)) repo := issueRepoStyle.Render(cli.Sprintf("[%s]", pr.RepoName)) title := prTitleStyle.Render(cli.Truncate(pr.Title, 50)) - author := prAuthorStyle.Render("@" + pr.Author.Login) + author := prAuthorStyle.Render("@" + pr.Author) // Review status var status string @@ -230,7 +257,7 @@ func printPR(pr GitHubPR) { // Draft indicator draft := "" - if pr.IsDraft { + if pr.Draft { draft = prDraftStyle.Render(" " + i18n.T("cmd.dev.reviews.draft")) } diff --git a/cmd/dev/forge_client.go b/cmd/dev/forge_client.go new file mode 100644 index 0000000..7571802 --- /dev/null +++ b/cmd/dev/forge_client.go @@ -0,0 +1,73 @@ +package dev + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "code.gitea.io/sdk/gitea" + + "forge.lthn.ai/core/go-scm/forge" +) + +// forgeAPIClient creates a Gitea SDK client configured for the Forge instance. +// Forgejo is API-compatible with Gitea, so the Gitea SDK works directly. +func forgeAPIClient() (*gitea.Client, error) { + forgeURL, token, err := forge.ResolveConfig("", "") + if err != nil { + return nil, err + } + if token == "" { + return nil, fmt.Errorf("no Forge API token configured (set FORGE_TOKEN or run: core forge config --token TOKEN)") + } + return gitea.NewClient(forgeURL, gitea.SetToken(token)) +} + +// forgeRepoIdentity extracts the Forge owner/repo from a repo's git remote. +// Falls back to fallbackOrg/repoName if no forge.lthn.ai remote is found. +func forgeRepoIdentity(repoPath, fallbackOrg, repoName string) (owner, repo string) { + configPath := filepath.Join(repoPath, ".git", "config") + content, err := os.ReadFile(configPath) + if err != nil { + return fallbackOrg, repoName + } + + for _, line := range strings.Split(string(content), "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "url = ") { + continue + } + remoteURL := strings.TrimPrefix(line, "url = ") + + if !strings.Contains(remoteURL, "forge.lthn.ai") { + continue + } + + // ssh://git@forge.lthn.ai:2223/core/go-devops.git + // https://forge.lthn.ai/core/go-devops.git + parts := strings.SplitN(remoteURL, "forge.lthn.ai", 2) + if len(parts) < 2 { + continue + } + path := parts[1] + + // Remove port if present (e.g., ":2223/") + if strings.HasPrefix(path, ":") { + idx := strings.Index(path[1:], "/") + if idx >= 0 { + path = path[idx+1:] + } + } + + path = strings.TrimPrefix(path, "/") + path = strings.TrimSuffix(path, ".git") + + ownerRepo := strings.SplitN(path, "/", 2) + if len(ownerRepo) == 2 { + return ownerRepo[0], ownerRepo[1] + } + } + + return fallbackOrg, repoName +} diff --git a/go.mod b/go.mod index 6d0934b..2eb7c25 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module forge.lthn.ai/core/go-devops go 1.26.0 require ( + code.gitea.io/sdk/gitea v0.23.2 forge.lthn.ai/core/cli v0.1.0 forge.lthn.ai/core/agent v0.1.0 forge.lthn.ai/core/go-ansible v0.1.0