349 lines
8.4 KiB
Go
349 lines
8.4 KiB
Go
package gh
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const Repo = "homarr-labs/homarr"
|
|
const prCacheTTL = 5 * time.Minute
|
|
|
|
var prListCache = struct {
|
|
sync.Mutex
|
|
entries map[int]cachedPRs
|
|
}{entries: make(map[int]cachedPRs)}
|
|
|
|
type cachedPRs struct {
|
|
prs []PR
|
|
fetchedAt time.Time
|
|
}
|
|
|
|
func isBot(login string) bool {
|
|
return strings.HasSuffix(login, "[bot]") || strings.HasPrefix(login, "app/")
|
|
}
|
|
|
|
type PR struct {
|
|
Number int `json:"number"`
|
|
Title string `json:"title"`
|
|
Author string `json:"author"`
|
|
HeadRef string `json:"headRefName"`
|
|
HeadSHA string `json:"headRefOid"`
|
|
CIState string `json:"ciState"`
|
|
IsDraft bool `json:"isDraft"`
|
|
UpdatedAt string `json:"updatedAt"`
|
|
}
|
|
|
|
type rawPR struct {
|
|
Number int `json:"number"`
|
|
Title string `json:"title"`
|
|
IsDraft bool `json:"isDraft"`
|
|
Author struct {
|
|
Login string `json:"login"`
|
|
} `json:"author"`
|
|
HeadRefName string `json:"headRefName"`
|
|
HeadRefOID string `json:"headRefOid"`
|
|
UpdatedAt string `json:"updatedAt"`
|
|
StatusCheckRollup []rawCheck `json:"statusCheckRollup"`
|
|
}
|
|
|
|
type rawCheck struct {
|
|
Status string `json:"status"`
|
|
Conclusion string `json:"conclusion"`
|
|
State string `json:"state"`
|
|
}
|
|
|
|
func rollupState(checks []rawCheck) string {
|
|
if len(checks) == 0 {
|
|
return "NONE"
|
|
}
|
|
failed, pending := 0, 0
|
|
for _, c := range checks {
|
|
if c.State != "" {
|
|
switch c.State {
|
|
case "SUCCESS":
|
|
case "PENDING", "EXPECTED":
|
|
pending++
|
|
default:
|
|
failed++
|
|
}
|
|
continue
|
|
}
|
|
if c.Status != "COMPLETED" {
|
|
pending++
|
|
continue
|
|
}
|
|
switch c.Conclusion {
|
|
case "SUCCESS", "SKIPPED", "NEUTRAL":
|
|
default:
|
|
failed++
|
|
}
|
|
}
|
|
if failed > 0 {
|
|
return "FAILURE"
|
|
}
|
|
if pending > 0 {
|
|
return "PENDING"
|
|
}
|
|
return "SUCCESS"
|
|
}
|
|
|
|
func ListPRs(ctx context.Context, limit int, includeBots bool) ([]PR, error) {
|
|
return listPRs(ctx, limit, includeBots, false)
|
|
}
|
|
|
|
func RefreshPRs(ctx context.Context, limit int, includeBots bool) ([]PR, error) {
|
|
return listPRs(ctx, limit, includeBots, true)
|
|
}
|
|
|
|
func listPRs(ctx context.Context, limit int, includeBots, refresh bool) ([]PR, error) {
|
|
cached, fetchedAt := cachedPRList(limit)
|
|
if !refresh && !fetchedAt.IsZero() && time.Since(fetchedAt) < prCacheTTL {
|
|
return filterPRs(cached, includeBots), nil
|
|
}
|
|
|
|
out, err := exec.CommandContext(ctx, "gh", "pr", "list",
|
|
"--repo", Repo,
|
|
"--state", "open",
|
|
"--limit", fmt.Sprint(limit),
|
|
"--json", "number,title,author,headRefName,headRefOid,updatedAt,isDraft,statusCheckRollup",
|
|
).CombinedOutput()
|
|
if err != nil {
|
|
err = commandError(err, out)
|
|
if len(cached) > 0 {
|
|
return filterPRs(cached, includeBots), fmt.Errorf("%w; showing cached PRs from %s", err, fetchedAt.Format(time.Kitchen))
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
var raws []rawPR
|
|
if err := json.Unmarshal(out, &raws); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
prs := make([]PR, 0, len(raws))
|
|
for _, r := range raws {
|
|
prs = append(prs, PR{
|
|
Number: r.Number,
|
|
Title: r.Title,
|
|
Author: r.Author.Login,
|
|
HeadRef: r.HeadRefName,
|
|
HeadSHA: r.HeadRefOID,
|
|
CIState: rollupState(r.StatusCheckRollup),
|
|
IsDraft: r.IsDraft,
|
|
UpdatedAt: r.UpdatedAt,
|
|
})
|
|
}
|
|
storePRList(limit, prs)
|
|
return filterPRs(prs, includeBots), nil
|
|
}
|
|
|
|
func cachedPRList(limit int) ([]PR, time.Time) {
|
|
prListCache.Lock()
|
|
defer prListCache.Unlock()
|
|
entry := prListCache.entries[limit]
|
|
return append([]PR(nil), entry.prs...), entry.fetchedAt
|
|
}
|
|
|
|
func storePRList(limit int, prs []PR) {
|
|
prListCache.Lock()
|
|
defer prListCache.Unlock()
|
|
prListCache.entries[limit] = cachedPRs{prs: append([]PR(nil), prs...), fetchedAt: time.Now()}
|
|
}
|
|
|
|
func filterPRs(prs []PR, includeBots bool) []PR {
|
|
filtered := make([]PR, 0, len(prs))
|
|
for _, pr := range prs {
|
|
if includeBots || !isBot(pr.Author) {
|
|
filtered = append(filtered, pr)
|
|
}
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
func commandError(err error, output []byte) error {
|
|
detail := strings.TrimSpace(string(output))
|
|
if detail == "" {
|
|
return err
|
|
}
|
|
return fmt.Errorf("%s: %w", detail, err)
|
|
}
|
|
|
|
func GetPR(ctx context.Context, number int) (*PR, error) {
|
|
out, err := exec.CommandContext(ctx, "gh", "pr", "view", fmt.Sprint(number),
|
|
"--repo", Repo,
|
|
"--json", "number,title,author,headRefName,headRefOid,updatedAt,isDraft,statusCheckRollup",
|
|
).CombinedOutput()
|
|
if err != nil {
|
|
return nil, commandError(err, out)
|
|
}
|
|
|
|
var r rawPR
|
|
if err := json.Unmarshal(out, &r); err != nil {
|
|
return nil, err
|
|
}
|
|
return &PR{
|
|
Number: r.Number,
|
|
Title: r.Title,
|
|
Author: r.Author.Login,
|
|
HeadRef: r.HeadRefName,
|
|
HeadSHA: r.HeadRefOID,
|
|
CIState: rollupState(r.StatusCheckRollup),
|
|
IsDraft: r.IsDraft,
|
|
UpdatedAt: r.UpdatedAt,
|
|
}, nil
|
|
}
|
|
|
|
type Check struct {
|
|
Name string `json:"name"`
|
|
State string `json:"state"`
|
|
Bucket string `json:"bucket"`
|
|
Workflow string `json:"workflow"`
|
|
Link string `json:"link"`
|
|
StartedAt string `json:"startedAt"`
|
|
CompletedAt string `json:"completedAt"`
|
|
Description string `json:"description"`
|
|
Event string `json:"event"`
|
|
}
|
|
|
|
var checkBucketKeys = map[string]string{
|
|
"pass": "pass",
|
|
"fail": "fail",
|
|
"pending": "pending",
|
|
"skipping": "skipping",
|
|
"cancel": "cancel",
|
|
}
|
|
|
|
func (c Check) BucketKey() string {
|
|
if key, found := checkBucketKeys[strings.ToLower(c.Bucket)]; found {
|
|
return key
|
|
}
|
|
return "pending"
|
|
}
|
|
|
|
func (c Check) IsPending() bool {
|
|
return c.Bucket == "pending" || c.State == "PENDING" || c.State == "IN_PROGRESS" || c.State == "STARTING"
|
|
}
|
|
|
|
func (c Check) IsSuccess() bool {
|
|
return c.Bucket == "pass" || c.State == "SUCCESS"
|
|
}
|
|
|
|
func (c Check) IsFailure() bool {
|
|
return c.Bucket == "fail" || c.State == "FAILURE" || c.State == "ERROR"
|
|
}
|
|
|
|
func (c Check) IsSkipped() bool {
|
|
return c.Bucket == "skipping" || c.Bucket == "cancel" || c.State == "SKIPPED" || c.State == "CANCELLED"
|
|
}
|
|
|
|
func (c Check) Duration() string {
|
|
if c.StartedAt == "" {
|
|
return ""
|
|
}
|
|
start, err := time.Parse(time.RFC3339, c.StartedAt)
|
|
if err != nil || start.IsZero() {
|
|
return ""
|
|
}
|
|
end := time.Now()
|
|
if c.CompletedAt != "" {
|
|
if tEnd, err := time.Parse(time.RFC3339, c.CompletedAt); err == nil && !tEnd.IsZero() {
|
|
end = tEnd
|
|
}
|
|
}
|
|
d := end.Sub(start).Round(time.Second)
|
|
if d < 0 {
|
|
return ""
|
|
}
|
|
if d < time.Minute {
|
|
return fmt.Sprintf("%ds", int(d.Seconds()))
|
|
}
|
|
if d < time.Hour {
|
|
return fmt.Sprintf("%dm%02ds", int(d.Minutes()), int(d.Seconds())%60)
|
|
}
|
|
return fmt.Sprintf("%dh%02dm", int(d.Hours()), int(d.Minutes())%60)
|
|
}
|
|
|
|
var prChecksCache = struct {
|
|
sync.Mutex
|
|
entries map[int]cachedChecks
|
|
}{entries: make(map[int]cachedChecks)}
|
|
|
|
type cachedChecks struct {
|
|
checks []Check
|
|
fetchedAt time.Time
|
|
}
|
|
|
|
func GetPRChecks(ctx context.Context, number int, refresh bool) ([]Check, error) {
|
|
if number <= 0 {
|
|
return nil, fmt.Errorf("invalid PR number: %d", number)
|
|
}
|
|
prChecksCache.Lock()
|
|
entry, found := prChecksCache.entries[number]
|
|
prChecksCache.Unlock()
|
|
if !refresh && found && time.Since(entry.fetchedAt) < 15*time.Second {
|
|
return append([]Check(nil), entry.checks...), nil
|
|
}
|
|
|
|
cmd := exec.CommandContext(ctx, "gh", "pr", "checks", fmt.Sprint(number),
|
|
"--repo", Repo,
|
|
"--json", "name,state,bucket,workflow,link,startedAt,completedAt,description,event",
|
|
)
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil && len(out) == 0 {
|
|
return nil, commandError(err, out)
|
|
}
|
|
|
|
var checks []Check
|
|
if jsonErr := json.Unmarshal(out, &checks); jsonErr != nil {
|
|
if err != nil {
|
|
return nil, commandError(err, out)
|
|
}
|
|
return nil, fmt.Errorf("parse checks JSON: %w", jsonErr)
|
|
}
|
|
|
|
prChecksCache.Lock()
|
|
prChecksCache.entries[number] = cachedChecks{checks: checks, fetchedAt: time.Now()}
|
|
prChecksCache.Unlock()
|
|
|
|
return checks, nil
|
|
}
|
|
|
|
func WatchPRChecks(ctx context.Context, number int) error {
|
|
cmd := exec.CommandContext(ctx, "gh", "pr", "checks", fmt.Sprint(number), "--repo", Repo, "--watch")
|
|
cmd.Stdin = os.Stdin
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
return cmd.Run()
|
|
}
|
|
|
|
func CurrentBranchPR(ctx context.Context) (*PR, error) {
|
|
out, err := exec.CommandContext(ctx, "gh", "pr", "view",
|
|
"--repo", Repo,
|
|
"--json", "number,title,author,headRefName,headRefOid,updatedAt,isDraft,statusCheckRollup",
|
|
).CombinedOutput()
|
|
if err != nil {
|
|
return nil, commandError(err, out)
|
|
}
|
|
|
|
var r rawPR
|
|
if err := json.Unmarshal(out, &r); err != nil {
|
|
return nil, err
|
|
}
|
|
return &PR{
|
|
Number: r.Number,
|
|
Title: r.Title,
|
|
Author: r.Author.Login,
|
|
HeadRef: r.HeadRefName,
|
|
HeadSHA: r.HeadRefOID,
|
|
CIState: rollupState(r.StatusCheckRollup),
|
|
IsDraft: r.IsDraft,
|
|
UpdatedAt: r.UpdatedAt,
|
|
}, nil
|
|
}
|