cli/cmd/qa/cmd_review.go
Charon 27d5e2e100 refactor: flatten commands, extract php/ci to own repos (#2)
## Summary
- Extract PHP/Laravel commands to `core/php` repo (42 files, standalone module)
- Extract CI/release + SDK commands to `core/ci` repo (10 files)
- Remove `internal/variants/` build tag system entirely
- Move all 30 remaining command packages from `internal/cmd/` to top-level `cmd/`
- Rewrite `main.go` with direct imports — no more variant selection
- PHP and CI are now optional via commented import lines in main.go

Co-authored-by: Claude <developers@lethean.io>
Reviewed-on: #2
Co-authored-by: Charon <charon@lthn.ai>
Co-committed-by: Charon <charon@lthn.ai>
2026-02-16 14:45:06 +00:00

322 lines
8.1 KiB
Go

// cmd_review.go implements the 'qa review' command for PR review status.
//
// Usage:
// core qa review # Show all PRs needing attention
// core qa review --mine # Show status of your open PRs
// core qa review --requested # Show PRs you need to review
package qa
import (
"context"
"encoding/json"
"fmt"
"os/exec"
"strings"
"time"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
"forge.lthn.ai/core/go/pkg/log"
)
// Review command flags
var (
reviewMine bool
reviewRequested bool
reviewRepo string
)
// PullRequest represents a GitHub pull request
type PullRequest struct {
Number int `json:"number"`
Title string `json:"title"`
Author Author `json:"author"`
State string `json:"state"`
IsDraft bool `json:"isDraft"`
Mergeable string `json:"mergeable"`
ReviewDecision string `json:"reviewDecision"`
URL string `json:"url"`
HeadRefName string `json:"headRefName"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
Additions int `json:"additions"`
Deletions int `json:"deletions"`
ChangedFiles int `json:"changedFiles"`
StatusChecks *StatusCheckRollup `json:"statusCheckRollup"`
ReviewRequests ReviewRequests `json:"reviewRequests"`
Reviews []Review `json:"reviews"`
}
// Author represents a GitHub user
type Author struct {
Login string `json:"login"`
}
// StatusCheckRollup contains CI check status
type StatusCheckRollup struct {
Contexts []StatusContext `json:"contexts"`
}
// StatusContext represents a single check
type StatusContext struct {
State string `json:"state"`
Conclusion string `json:"conclusion"`
Name string `json:"name"`
}
// ReviewRequests contains pending review requests
type ReviewRequests struct {
Nodes []ReviewRequest `json:"nodes"`
}
// ReviewRequest represents a review request
type ReviewRequest struct {
RequestedReviewer Author `json:"requestedReviewer"`
}
// Review represents a PR review
type Review struct {
Author Author `json:"author"`
State string `json:"state"`
}
// addReviewCommand adds the 'review' subcommand to the qa command.
func addReviewCommand(parent *cli.Command) {
reviewCmd := &cli.Command{
Use: "review",
Short: i18n.T("cmd.qa.review.short"),
Long: i18n.T("cmd.qa.review.long"),
RunE: func(cmd *cli.Command, args []string) error {
return runReview()
},
}
reviewCmd.Flags().BoolVarP(&reviewMine, "mine", "m", false, i18n.T("cmd.qa.review.flag.mine"))
reviewCmd.Flags().BoolVarP(&reviewRequested, "requested", "r", false, i18n.T("cmd.qa.review.flag.requested"))
reviewCmd.Flags().StringVar(&reviewRepo, "repo", "", i18n.T("cmd.qa.review.flag.repo"))
parent.AddCommand(reviewCmd)
}
func runReview() error {
// Check gh is available
if _, err := exec.LookPath("gh"); err != nil {
return log.E("qa.review", i18n.T("error.gh_not_found"), nil)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Determine repo
repoFullName := reviewRepo
if repoFullName == "" {
var err error
repoFullName, err = detectRepoFromGit()
if err != nil {
return log.E("qa.review", i18n.T("cmd.qa.review.error.no_repo"), nil)
}
}
// Default: show both mine and requested if neither flag is set
showMine := reviewMine || (!reviewMine && !reviewRequested)
showRequested := reviewRequested || (!reviewMine && !reviewRequested)
if showMine {
if err := showMyPRs(ctx, repoFullName); err != nil {
return err
}
}
if showRequested {
if showMine {
cli.Blank()
}
if err := showRequestedReviews(ctx, repoFullName); err != nil {
return err
}
}
return nil
}
// showMyPRs shows the user's open PRs with status
func showMyPRs(ctx context.Context, repo string) error {
prs, err := fetchPRs(ctx, repo, "author:@me")
if err != nil {
return log.E("qa.review", "failed to fetch your PRs", err)
}
if len(prs) == 0 {
cli.Print("%s\n", dimStyle.Render(i18n.T("cmd.qa.review.no_prs")))
return nil
}
cli.Print("%s (%d):\n", i18n.T("cmd.qa.review.your_prs"), len(prs))
for _, pr := range prs {
printPRStatus(pr)
}
return nil
}
// showRequestedReviews shows PRs where user's review is requested
func showRequestedReviews(ctx context.Context, repo string) error {
prs, err := fetchPRs(ctx, repo, "review-requested:@me")
if err != nil {
return log.E("qa.review", "failed to fetch review requests", err)
}
if len(prs) == 0 {
cli.Print("%s\n", dimStyle.Render(i18n.T("cmd.qa.review.no_reviews")))
return nil
}
cli.Print("%s (%d):\n", i18n.T("cmd.qa.review.review_requested"), len(prs))
for _, pr := range prs {
printPRForReview(pr)
}
return nil
}
// fetchPRs fetches PRs matching the search query
func fetchPRs(ctx context.Context, repo, search string) ([]PullRequest, error) {
args := []string{
"pr", "list",
"--state", "open",
"--search", search,
"--json", "number,title,author,state,isDraft,mergeable,reviewDecision,url,headRefName,createdAt,updatedAt,additions,deletions,changedFiles,statusCheckRollup,reviewRequests,reviews",
}
if repo != "" {
args = append(args, "--repo", repo)
}
cmd := exec.CommandContext(ctx, "gh", args...)
output, err := cmd.Output()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
return nil, fmt.Errorf("%s", strings.TrimSpace(string(exitErr.Stderr)))
}
return nil, err
}
var prs []PullRequest
if err := json.Unmarshal(output, &prs); err != nil {
return nil, err
}
return prs, nil
}
// printPRStatus prints a PR with its merge status
func printPRStatus(pr PullRequest) {
// Determine status icon and color
status, style, action := analyzePRStatus(pr)
cli.Print(" %s #%d %s\n",
style.Render(status),
pr.Number,
truncate(pr.Title, 50))
if action != "" {
cli.Print(" %s %s\n", dimStyle.Render("->"), action)
}
}
// printPRForReview prints a PR that needs review
func printPRForReview(pr PullRequest) {
// Show PR info with stats
stats := fmt.Sprintf("+%d/-%d, %d files",
pr.Additions, pr.Deletions, pr.ChangedFiles)
cli.Print(" %s #%d %s\n",
warningStyle.Render("◯"),
pr.Number,
truncate(pr.Title, 50))
cli.Print(" %s @%s, %s\n",
dimStyle.Render("->"),
pr.Author.Login,
stats)
cli.Print(" %s gh pr checkout %d\n",
dimStyle.Render("->"),
pr.Number)
}
// analyzePRStatus determines the status, style, and action for a PR
func analyzePRStatus(pr PullRequest) (status string, style *cli.AnsiStyle, action string) {
// Check if draft
if pr.IsDraft {
return "◯", dimStyle, "Draft - convert to ready when done"
}
// Check CI status
ciPassed := true
ciFailed := false
ciPending := false
var failedCheck string
if pr.StatusChecks != nil {
for _, check := range pr.StatusChecks.Contexts {
switch check.Conclusion {
case "FAILURE", "failure":
ciFailed = true
ciPassed = false
if failedCheck == "" {
failedCheck = check.Name
}
case "PENDING", "pending", "":
if check.State == "PENDING" || check.State == "" {
ciPending = true
ciPassed = false
}
}
}
}
// Check review status
approved := pr.ReviewDecision == "APPROVED"
changesRequested := pr.ReviewDecision == "CHANGES_REQUESTED"
// Check mergeable status
hasConflicts := pr.Mergeable == "CONFLICTING"
// Determine overall status
if hasConflicts {
return "✗", errorStyle, "Needs rebase - has merge conflicts"
}
if ciFailed {
return "✗", errorStyle, fmt.Sprintf("CI failed: %s", failedCheck)
}
if changesRequested {
return "✗", warningStyle, "Changes requested - address review feedback"
}
if ciPending {
return "◯", warningStyle, "CI running..."
}
if !approved && pr.ReviewDecision != "" {
return "◯", warningStyle, "Awaiting review"
}
if approved && ciPassed {
return "✓", successStyle, "Ready to merge"
}
return "◯", dimStyle, ""
}
// truncate shortens a string to max length (rune-safe for UTF-8)
func truncate(s string, max int) string {
runes := []rune(s)
if len(runes) <= max {
return s
}
return string(runes[:max-3]) + "..."
}