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 <virgil@lethean.io>
This commit is contained in:
Snider 2026-03-12 19:04:41 +00:00
parent 481344a066
commit cbe95aa490
6 changed files with 309 additions and 211 deletions

View file

@ -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("-")

View file

@ -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:

View file

@ -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

View file

@ -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"))
}

73
cmd/dev/forge_client.go Normal file
View file

@ -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
}

1
go.mod
View file

@ -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