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:
parent
481344a066
commit
cbe95aa490
6 changed files with 309 additions and 211 deletions
|
|
@ -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("-")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
73
cmd/dev/forge_client.go
Normal 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
1
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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue