f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
112 lines
3.3 KiB
Go
112 lines
3.3 KiB
Go
package git
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
)
|
|
|
|
// PRResult holds pull-request counts for a window. Opened counts PRs created
|
|
// in [since, until]; Merged counts PRs merged in [since, until] regardless of
|
|
// when they were created.
|
|
type PRResult struct {
|
|
Opened int
|
|
Merged int
|
|
}
|
|
|
|
// AggregatePRs queries the `gh` CLI twice per repo — once for PRs created in
|
|
// the window, once for PRs merged in the window — and returns the counts.
|
|
//
|
|
// since/until are formatted into gh's `--search=created:SINCE..UNTIL` (and
|
|
// `merged:...`) range expressions. GitHub search treats `>=A..B` as malformed
|
|
// — the colon-and-double-dot range syntax already implies an inclusive
|
|
// closed window, so the bounds must be plain dates or RFC3339 timestamps.
|
|
// The repo argument sets the working directory for `gh` so it picks up the
|
|
// correct remote.
|
|
//
|
|
// When ghToken is empty this returns (nil, nil): the caller distinguishes
|
|
// "unknown — gh not configured" from a legitimate zero count. GH_TOKEN is
|
|
// injected via the exec environment so it never appears in argv or logs.
|
|
//
|
|
// Any failure to invoke `gh` or parse its output is surfaced as an error;
|
|
// callers should log and continue rather than fail the whole aggregation.
|
|
func AggregatePRs(
|
|
ctx context.Context, repo, since, until, ghToken string,
|
|
) (*PRResult, error) {
|
|
if ghToken == "" {
|
|
return nil, nil
|
|
}
|
|
window := since + ".." + until
|
|
opened, err := countPRs(ctx, repo, ghToken, []string{
|
|
"pr", "list",
|
|
"--state=all",
|
|
"--author=@me",
|
|
"--search=created:" + window,
|
|
"--json", "state",
|
|
"--limit", "500",
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("gh pr list (opened) in %s: %w", repo, err)
|
|
}
|
|
merged, err := countPRs(ctx, repo, ghToken, []string{
|
|
"pr", "list",
|
|
"--state=merged",
|
|
"--author=@me",
|
|
"--search=merged:" + window,
|
|
"--json", "state",
|
|
"--limit", "500",
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("gh pr list (merged) in %s: %w", repo, err)
|
|
}
|
|
return &PRResult{Opened: opened, Merged: merged}, nil
|
|
}
|
|
|
|
// countPRs runs `gh` with the given args inside repo and returns the length of
|
|
// the resulting JSON array. GH_TOKEN is set in the exec env (not argv) so it
|
|
// doesn't leak into process listings.
|
|
func countPRs(
|
|
ctx context.Context, repo, ghToken string, args []string,
|
|
) (int, error) {
|
|
cmd := exec.CommandContext(ctx, "gh", args...)
|
|
cmd.Dir = repo
|
|
cmd.Env = ghEnv(ghToken)
|
|
var stderr bytes.Buffer
|
|
cmd.Stderr = &stderr
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
msg := strings.TrimSpace(stderr.String())
|
|
if msg == "" {
|
|
return 0, err
|
|
}
|
|
return 0, fmt.Errorf("%w: %s", err, msg)
|
|
}
|
|
var rows []struct {
|
|
State string `json:"state"`
|
|
}
|
|
if err := json.Unmarshal(bytes.TrimSpace(out), &rows); err != nil {
|
|
return 0, fmt.Errorf("parse gh json: %w", err)
|
|
}
|
|
return len(rows), nil
|
|
}
|
|
|
|
// ghEnv returns a copy of the current environment with GH_TOKEN set/overridden
|
|
// to the caller-provided token. Any existing GH_TOKEN is replaced so the
|
|
// parent process's credentials don't shadow the injected one.
|
|
func ghEnv(ghToken string) []string {
|
|
base := os.Environ()
|
|
env := make([]string, 0, len(base)+1)
|
|
for _, kv := range base {
|
|
if strings.HasPrefix(kv, "GH_TOKEN=") {
|
|
continue
|
|
}
|
|
env = append(env, kv)
|
|
}
|
|
env = append(env, "GH_TOKEN="+ghToken)
|
|
return env
|
|
}
|