Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

726 lines
23 KiB
Go

package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"text/tabwriter"
"github.com/spf13/cobra"
"github.com/zzet/gortex/internal/churn"
"github.com/zzet/gortex/internal/forge"
"github.com/zzet/gortex/internal/indexer"
)
var (
prsBase string
prsRepo string
prsFormat string
prsWorktrees bool
prsBundleOut string
prsTriage bool
prsConflicts bool
prsUseLLM bool
)
// Seams. The forge free functions and the daemon-tool relay are indirected
// through package vars so a test can inject canned PRs / files and stub the
// daemon call without touching the network or a real daemon.
var (
forgeAvailable = forge.Available
forgeListPRs = forge.ListPRs
forgePRFiles = forge.PRFiles
prsDaemonTool = requireDaemonTool
)
var prsCmd = &cobra.Command{
Use: "prs [number]",
Short: "List open pull requests, or deep-dive a PR's blast radius",
Long: `Without an argument, lists the repository's open pull requests as a table
with each PR's CI rollup, review decision, age, and a one-shot review-state
classification (DRAFT / BASE_MISMATCH / CHANGES_REQUESTED / APPROVED / STALE
/ READY).
With a PR number, deep-dives that PR: fetches its changed files from the
forge and joins them against the knowledge graph (via the daemon) to print
the changed files, blast radius, and risk score.
--triage renders an AI-ranked review queue (highest-risk first) via the
daemon's triage_prs tool; add --use-llm to re-rank it with one LLM pass.
--conflicts renders merge-order conflict clusters — the graph communities
touched by more than one open PR, with a suggested safe merge order — via
the daemon's conflicts_prs tool. Both honour --format json (raw payload).
When both flags are given, triage runs first, then conflicts.
Listing needs a GitHub token (GH_TOKEN or GITHUB_TOKEN). The deep-dive,
--triage, and --conflicts also need a running daemon that tracks the repo.`,
Args: cobra.MaximumNArgs(1),
RunE: runPRs,
}
var prsBundleCmd = &cobra.Command{
Use: "bundle <number>",
Short: "Write a reviewer graph bundle for a PR (impact + receipt + reviewers)",
Long: `Builds a self-contained, reviewer-focused bundle for a pull request and
writes it to a file (--out, default pr-<number>-bundle.json).
The bundle is the PR-review-relevant slice of the knowledge graph: the PR's
changed files, the graph-joined blast radius and PR-risk score (with a
privacy-safe review receipt), and the ranked reviewer suggestions. It joins
the forge's changed-file set against the daemon's graph via the get_pr_impact
and suggest_reviewers tools — no second in-process index.
The bundle is deterministic for an unchanged PR, so it can be uploaded as a CI
artifact and diffed across runs. A committed GitHub Action template that wires
this into per-PR CI lives at .github/workflows/gortex-pr-review.yml.example
(documented in docs/actions/README.md).
Listing needs a GitHub token (GH_TOKEN or GITHUB_TOKEN) to fetch the changed
files; the graph join needs a running daemon that tracks the repo.`,
Args: cobra.ExactArgs(1),
RunE: runPRsBundle,
}
func init() {
prsCmd.Flags().StringVarP(&prsBase, "base", "b", "", "default base branch used to flag BASE_MISMATCH (default: the repo's default branch)")
prsCmd.Flags().StringVar(&prsRepo, "repo", "", "repository path the forge / daemon must own (default: current directory)")
prsCmd.Flags().StringVar(&prsFormat, "format", "text", "output format: text or json")
prsCmd.Flags().BoolVar(&prsWorktrees, "worktrees", false, "annotate each PR whose head branch is checked out in a local worktree")
prsCmd.Flags().BoolVar(&prsTriage, "triage", false, "render an AI-ranked review queue (highest-risk first) via the daemon's triage_prs tool")
prsCmd.Flags().BoolVar(&prsConflicts, "conflicts", false, "render merge-order conflict clusters (PRs sharing a graph community) via the daemon's conflicts_prs tool")
prsCmd.Flags().BoolVar(&prsUseLLM, "use-llm", false, "when used with --triage, re-rank the queue with one LLM pass (passes use_llm:true to triage_prs)")
prsBundleCmd.Flags().StringVar(&prsRepo, "repo", "", "repository path the forge / daemon must own (default: current directory)")
prsBundleCmd.Flags().StringVarP(&prsBundleOut, "out", "o", "", "bundle output file (default: pr-<number>-bundle.json)")
prsCmd.AddCommand(prsBundleCmd)
rootCmd.AddCommand(prsCmd)
}
func runPRs(cmd *cobra.Command, args []string) error {
repoPath := "."
if prsRepo != "" {
repoPath = prsRepo
}
// Deep-dive: `gortex prs <N>`.
if len(args) == 1 {
n, err := strconv.Atoi(strings.TrimSpace(args[0]))
if err != nil || n <= 0 {
return fmt.Errorf("invalid PR number %q", args[0])
}
return runPRDeepDive(cmd, repoPath, n)
}
// Triage / conflicts dashboards route through the daemon tools. When both
// flags are given, run triage first then conflicts (triage takes
// precedence as the primary review-queue view). A missing forge token is
// not an error: print the GH_TOKEN hint and exit 0, like the base
// dashboard, since both daemon tools self-serve from the forge.
if prsTriage || prsConflicts {
if !forgeAvailable(context.Background()) {
fmt.Fprintln(cmd.OutOrStdout(),
"no GitHub token found — set GH_TOKEN (or GITHUB_TOKEN) to triage pull requests")
return nil
}
if prsTriage {
if err := runPRTriage(cmd, repoPath); err != nil {
return err
}
}
if prsConflicts {
if err := runPRConflicts(cmd, repoPath); err != nil {
return err
}
}
return nil
}
// Dashboard: `gortex prs`.
return runPRList(cmd, repoPath)
}
// runPRList prints the open-PR table (or its JSON form). A missing forge
// token is not an error: it prints an actionable GH_TOKEN hint and exits 0.
func runPRList(cmd *cobra.Command, repoPath string) error {
ctx := context.Background()
if !forgeAvailable(ctx) {
fmt.Fprintln(cmd.OutOrStdout(),
"no GitHub token found — set GH_TOKEN (or GITHUB_TOKEN) to list pull requests")
return nil
}
prs, err := forgeListPRs(ctx, repoPath, forge.ListOpts{
State: "open",
Limit: 30,
WithDecision: true,
WithCI: true,
})
if err != nil {
return fmt.Errorf("listing pull requests: %w", err)
}
base := resolvePRBase(repoPath)
var worktreeBranches map[string]bool
if prsWorktrees {
worktreeBranches = localWorktreeBranches(ctx, repoPath)
}
rows := classifyPRRows(prs, base)
if prsFormat == "json" {
return emitPRListJSON(cmd, rows)
}
emitPRListTable(cmd, rows, worktreeBranches)
return nil
}
// prRow is the projection of a classified PR onto the documented wire shape.
type prRow struct {
Number int `json:"number"`
Title string `json:"title"`
Author string `json:"author"`
AgeDays int `json:"age_days"`
CI string `json:"ci"`
Review string `json:"review"`
State string `json:"state"`
Blockers []string `json:"blockers"`
headRef string
}
// classifyPRRows classifies every PR against the resolved default base.
func classifyPRRows(prs []forge.PR, base string) []prRow {
rows := make([]prRow, 0, len(prs))
for _, pr := range prs {
st := forge.ClassifyStatus(pr, base)
blockers := st.Blockers
if blockers == nil {
blockers = []string{}
}
rows = append(rows, prRow{
Number: pr.Number,
Title: pr.Title,
Author: pr.Author,
AgeDays: st.AgeDays,
CI: forge.RollupCI(pr),
Review: pr.ReviewDecision,
State: st.State,
Blockers: blockers,
headRef: pr.HeadRef,
})
}
return rows
}
// emitPRListJSON renders the documented {prs:[…]} shape.
func emitPRListJSON(cmd *cobra.Command, rows []prRow) error {
enc := json.NewEncoder(cmd.OutOrStdout())
enc.SetIndent("", " ")
return enc.Encode(map[string]any{"prs": rows})
}
// emitPRListTable renders the dashboard table. When worktreeBranches is
// non-nil a PR whose head branch is locally checked out is marked.
func emitPRListTable(cmd *cobra.Command, rows []prRow, worktreeBranches map[string]bool) {
out := cmd.OutOrStdout()
if len(rows) == 0 {
fmt.Fprintln(out, "No open pull requests.")
return
}
tw := tabwriter.NewWriter(out, 0, 2, 2, ' ', 0)
header := "#\tSTATE\tCI\tREVIEW\tAGE\tAUTHOR\tTITLE"
if worktreeBranches != nil {
header += "\tWORKTREE"
}
fmt.Fprintln(tw, header)
for _, r := range rows {
review := r.Review
if review == "" {
review = "-"
}
line := fmt.Sprintf("%d\t%s\t%s\t%s\t%dd\t%s\t%s",
r.Number, r.State, r.CI, review, r.AgeDays, r.Author, truncate(r.Title, 50))
if worktreeBranches != nil {
mark := ""
if r.headRef != "" && worktreeBranches[r.headRef] {
mark = "yes"
}
line += "\t" + mark
}
fmt.Fprintln(tw, line)
}
tw.Flush()
}
// runPRDeepDive fetches a PR's changed files from the forge (when a token is
// available) and runs the daemon's get_pr_impact tool, passing the file set
// so the daemon need not refetch. It prints the changed files, blast radius,
// and risk score.
func runPRDeepDive(cmd *cobra.Command, repoPath string, number int) error {
ctx := context.Background()
args := map[string]any{"number": number}
if prsRepo != "" {
args["repo"] = prsRepo
}
// Best-effort: pass the CLI-fetched file set so the daemon skips a
// redundant forge fetch. When no token is resolvable we simply omit
// `files` and let the daemon self-serve (or degrade with its own hint).
if forgeAvailable(ctx) {
files, err := forgePRFiles(ctx, repoPath, number)
if err != nil {
return fmt.Errorf("fetching PR #%d files: %w", number, err)
}
if encoded, merr := json.Marshal(files); merr == nil {
args["files"] = string(encoded)
}
}
raw, err := prsDaemonTool(repoPath, "get_pr_impact", args)
if err != nil {
return err
}
if prsFormat == "json" {
return emitDaemonJSON(cmd, raw)
}
return printPRImpact(cmd, number, raw)
}
// prImpactPayload mirrors the get_pr_impact wire shape the deep-dive renders.
type prImpactPayload struct {
Number int `json:"number"`
Risk string `json:"risk"`
Score float64 `json:"score"`
ReviewPriorities []struct {
Axis string `json:"axis"`
Score float64 `json:"score"`
Reason string `json:"reason"`
} `json:"review_priorities"`
ChangedFiles []string `json:"changed_files"`
ChangedSymbols []struct {
ID string `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"`
File string `json:"file"`
} `json:"changed_symbols"`
Communities []string `json:"communities"`
// degradation shape
Error string `json:"error"`
Hint string `json:"hint"`
}
// printPRImpact renders the deep-dive: changed files, blast radius, and risk.
func printPRImpact(cmd *cobra.Command, number int, raw json.RawMessage) error {
out := cmd.OutOrStdout()
var p prImpactPayload
if err := json.Unmarshal(raw, &p); err != nil {
// Unknown shape — fall back to pretty JSON rather than fail.
return emitDaemonJSON(cmd, raw)
}
if p.Error != "" {
fmt.Fprintf(out, "PR #%d: %s", number, p.Error)
if p.Hint != "" {
fmt.Fprintf(out, " — %s", p.Hint)
}
fmt.Fprintln(out)
return nil
}
fmt.Fprintf(out, "PR #%d — risk %s (score %.1f)\n", p.Number, p.Risk, p.Score)
fmt.Fprintf(out, "\nChanged files (%d):\n", len(p.ChangedFiles))
for _, f := range p.ChangedFiles {
fmt.Fprintf(out, " %s\n", f)
}
fmt.Fprintf(out, "\nBlast radius: %d changed symbol(s), %d communit(ies)\n",
len(p.ChangedSymbols), len(p.Communities))
for _, sym := range p.ChangedSymbols {
fmt.Fprintf(out, " %-8s %s\n", sym.Kind, sym.ID)
}
if len(p.ReviewPriorities) > 0 {
fmt.Fprintln(out, "\nReview priorities:")
for _, pr := range p.ReviewPriorities {
fmt.Fprintf(out, " %-10s %5.1f %s\n", pr.Axis, pr.Score, pr.Reason)
}
}
return nil
}
// runPRTriage calls the daemon's triage_prs tool and renders an AI-ranked
// review queue (highest-risk first). --use-llm passes use_llm:true so the
// daemon re-ranks the deterministic queue with one LLM pass. --format json
// emits the raw tool payload.
func runPRTriage(cmd *cobra.Command, repoPath string) error {
args := map[string]any{}
if prsRepo != "" {
args["repo"] = prsRepo
}
if prsUseLLM {
args["use_llm"] = true
}
raw, err := prsDaemonTool(repoPath, "triage_prs", args)
if err != nil {
return err
}
if prsFormat == "json" {
return emitDaemonJSON(cmd, raw)
}
return printPRTriage(cmd, raw)
}
// runPRConflicts calls the daemon's conflicts_prs tool and renders the
// merge-order conflict clusters: the graph communities touched by more than
// one open PR, the colliding PRs, a suggested safe merge order, and a
// conflict-risk score. --format json emits the raw tool payload.
func runPRConflicts(cmd *cobra.Command, repoPath string) error {
args := map[string]any{}
if prsRepo != "" {
args["repo"] = prsRepo
}
raw, err := prsDaemonTool(repoPath, "conflicts_prs", args)
if err != nil {
return err
}
if prsFormat == "json" {
return emitDaemonJSON(cmd, raw)
}
return printPRConflicts(cmd, raw)
}
// triagePayload mirrors the triage_prs wire shape the queue table renders.
type triagePayload struct {
Ranked []struct {
Number int `json:"number"`
Title string `json:"title"`
Author string `json:"author"`
Risk string `json:"risk"`
Score float64 `json:"score"`
Rationale string `json:"rationale"`
} `json:"ranked"`
Total int `json:"total"`
LLMUsed bool `json:"llm_used"`
// degradation shape
Error string `json:"error"`
Hint string `json:"hint"`
}
// printPRTriage renders the ranked review queue: rank, PR#, risk, score, and
// title (highest-risk first — the daemon already orders the queue). When the
// LLM re-rank ran and annotated a PR, its rationale is printed beneath the row.
func printPRTriage(cmd *cobra.Command, raw json.RawMessage) error {
out := cmd.OutOrStdout()
var p triagePayload
if err := json.Unmarshal(raw, &p); err != nil {
return emitDaemonJSON(cmd, raw)
}
if p.Error != "" {
fmt.Fprintf(out, "triage: %s", p.Error)
if p.Hint != "" {
fmt.Fprintf(out, " — %s", p.Hint)
}
fmt.Fprintln(out)
return nil
}
if len(p.Ranked) == 0 {
fmt.Fprintln(out, "No open pull requests to triage.")
return nil
}
header := "Review queue (highest-risk first)"
if p.LLMUsed {
header += " — LLM-reranked"
}
fmt.Fprintln(out, header)
tw := tabwriter.NewWriter(out, 0, 2, 2, ' ', 0)
fmt.Fprintln(tw, "RANK\t#\tRISK\tSCORE\tTITLE")
for i, r := range p.Ranked {
fmt.Fprintf(tw, "%d\t%d\t%s\t%.1f\t%s\n",
i+1, r.Number, r.Risk, r.Score, truncate(r.Title, 50))
}
tw.Flush()
// Print any LLM rationales below the table so the queue stays scannable.
for _, r := range p.Ranked {
if rat := strings.TrimSpace(r.Rationale); rat != "" {
fmt.Fprintf(out, " PR #%d: %s\n", r.Number, rat)
}
}
return nil
}
// conflictsPayload mirrors the conflicts_prs wire shape the cluster view renders.
type conflictsPayload struct {
Conflicts []struct {
Community string `json:"community"`
Size int `json:"size"`
PRs []int `json:"prs"`
SuggestedOrder []int `json:"suggested_order"`
Risk float64 `json:"risk"`
} `json:"conflicts"`
Total int `json:"total"`
// degradation shape
Error string `json:"error"`
Hint string `json:"hint"`
}
// printPRConflicts renders the merge-order conflict clusters: each shared
// community, the PRs that collide there, a suggested safe merge order, and
// the conflict-risk score (clusters are already ranked highest-risk first).
func printPRConflicts(cmd *cobra.Command, raw json.RawMessage) error {
out := cmd.OutOrStdout()
var p conflictsPayload
if err := json.Unmarshal(raw, &p); err != nil {
return emitDaemonJSON(cmd, raw)
}
if p.Error != "" {
fmt.Fprintf(out, "conflicts: %s", p.Error)
if p.Hint != "" {
fmt.Fprintf(out, " — %s", p.Hint)
}
fmt.Fprintln(out)
return nil
}
if len(p.Conflicts) == 0 {
fmt.Fprintln(out, "No merge-order conflicts: no community is touched by more than one open PR.")
return nil
}
fmt.Fprintf(out, "Merge-order conflicts (%d cluster(s), highest-risk first)\n", len(p.Conflicts))
tw := tabwriter.NewWriter(out, 0, 2, 2, ' ', 0)
fmt.Fprintln(tw, "COMMUNITY\tSIZE\tRISK\tPRS\tMERGE ORDER")
for _, c := range p.Conflicts {
fmt.Fprintf(tw, "%s\t%d\t%.2f\t%s\t%s\n",
c.Community, c.Size, c.Risk, joinPRNumbers(c.PRs), joinPRNumbers(c.SuggestedOrder))
}
tw.Flush()
return nil
}
// joinPRNumbers formats a list of PR numbers as "#1, #2, #3" for the
// conflict-cluster table.
func joinPRNumbers(nums []int) string {
if len(nums) == 0 {
return "-"
}
parts := make([]string, 0, len(nums))
for _, n := range nums {
parts = append(parts, "#"+strconv.Itoa(n))
}
return strings.Join(parts, ", ")
}
// resolvePRBase resolves the default base branch used to flag BASE_MISMATCH:
// the explicit --base flag wins, otherwise the repo's default branch.
func resolvePRBase(repoPath string) string {
if prsBase != "" {
return prsBase
}
abs, err := filepath.Abs(repoPath)
if err != nil {
abs = repoPath
}
return churn.DefaultBranch(abs)
}
// localWorktreeBranches returns the set of branch names currently checked out
// in a local worktree of the repo, so the dashboard can mark a PR whose head
// is already on disk. A failure to enumerate worktrees yields an empty set.
func localWorktreeBranches(ctx context.Context, repoPath string) map[string]bool {
abs, err := filepath.Abs(repoPath)
if err != nil {
abs = repoPath
}
// Anchor to the main checkout so a query from inside a linked worktree
// still enumerates every sibling worktree.
if info := indexer.ResolveWorktree(abs); info.MainRepoPath != "" {
abs = info.MainRepoPath
}
branches := map[string]bool{}
entries, err := forge.LocalWorktrees(ctx, abs)
if err != nil {
return branches
}
for _, e := range entries {
if e.Branch != "" {
branches[e.Branch] = true
}
}
return branches
}
// bundleVersion is the on-the-wire schema version of a reviewer bundle. Bump it
// when the bundle's top-level shape changes incompatibly so consumers can gate.
const bundleVersion = 1
// reviewerBundle is the PR-review-relevant slice of the knowledge graph written
// by `gortex prs bundle`. It pairs the PR's changed file set with the
// graph-joined impact (blast radius + PR-risk score + privacy-safe receipt) and
// the ranked reviewer suggestions, so a CI job can upload one self-contained
// artifact a reviewer (or a downstream agent) consumes without a live daemon.
//
// Impact and Reviewers carry the daemon tools' payloads verbatim (get_pr_impact
// and suggest_reviewers) so the bundle has a single source of truth and no
// re-projection drift. ChangedFiles is lifted out for quick top-level scanning.
type reviewerBundle struct {
BundleVersion int `json:"bundle_version"`
Number int `json:"number"`
ChangedFiles []string `json:"changed_files"`
Impact json.RawMessage `json:"impact"`
Reviewers json.RawMessage `json:"reviewers,omitempty"`
}
// runPRsBundle builds a reviewer bundle for a PR and writes it to --out (or the
// default pr-<number>-bundle.json). It is daemon-first: the forge supplies the
// changed-file set (best-effort), and the daemon joins it against the graph via
// get_pr_impact and suggest_reviewers. The result is deterministic for an
// unchanged PR.
func runPRsBundle(cmd *cobra.Command, args []string) error {
repoPath := "."
if prsRepo != "" {
repoPath = prsRepo
}
n, err := strconv.Atoi(strings.TrimSpace(args[0]))
if err != nil || n <= 0 {
return fmt.Errorf("invalid PR number %q", args[0])
}
ctx := context.Background()
// Best-effort: fetch the changed file set so the daemon tools skip a
// redundant forge fetch. With no token we omit it and let the daemon
// self-serve; a daemon with no token of its own degrades inside the tool.
var files []string
if forgeAvailable(ctx) {
fetched, ferr := forgePRFiles(ctx, repoPath, n)
if ferr != nil {
return fmt.Errorf("fetching PR #%d files: %w", n, ferr)
}
files = fetched
}
impactArgs := map[string]any{"number": n, "receipt": true}
reviewerArgs := map[string]any{"number": n}
if prsRepo != "" {
impactArgs["repo"] = prsRepo
reviewerArgs["repo"] = prsRepo
}
if files != nil {
if encoded, merr := json.Marshal(files); merr == nil {
impactArgs["files"] = string(encoded)
}
}
impact, err := prsDaemonTool(repoPath, "get_pr_impact", impactArgs)
if err != nil {
return err
}
// Reviewers are best-effort: a missing forge token / CODEOWNERS file must
// not sink the whole bundle. On any daemon error we record an empty
// reviewers section and still write the impact slice.
reviewers, rerr := prsDaemonTool(repoPath, "suggest_reviewers", reviewerArgs)
if rerr != nil {
reviewers = nil
}
// Pull the changed files out of the impact payload so the bundle carries an
// authoritative top-level list even when the CLI had no token to fetch its
// own. Fall back to the CLI-fetched set.
changed := changedFilesFromImpact(impact)
if len(changed) == 0 {
changed = files
}
out := prsBundleOut
if out == "" {
out = fmt.Sprintf("pr-%d-bundle.json", n)
}
if err := writeReviewerBundle(out, n, changed, impact, reviewers); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "wrote reviewer bundle for PR #%d to %s\n", n, out)
return nil
}
// changedFilesFromImpact extracts the changed_files list from a get_pr_impact
// payload. Returns nil when the payload is a degradation envelope or otherwise
// lacks the field, so the caller can fall back to its own fetched set.
func changedFilesFromImpact(impact json.RawMessage) []string {
if len(impact) == 0 {
return nil
}
var p struct {
ChangedFiles []string `json:"changed_files"`
}
if err := json.Unmarshal(impact, &p); err != nil {
return nil
}
return p.ChangedFiles
}
// writeReviewerBundle assembles a reviewerBundle from the daemon tool payloads
// and writes it deterministically to path. The changed-file list is sorted and
// the JSON is indented so two runs over an unchanged PR produce byte-identical
// output (suitable for CI artifact diffing). A nil/empty reviewers payload is
// omitted from the bundle rather than written as JSON null.
func writeReviewerBundle(path string, number int, changedFiles []string, impact, reviewers json.RawMessage) error {
if number <= 0 {
return fmt.Errorf("invalid PR number %d", number)
}
if len(impact) == 0 {
return fmt.Errorf("cannot write bundle for PR #%d: empty impact payload", number)
}
files := append([]string(nil), changedFiles...)
sort.Strings(files)
if files == nil {
files = []string{}
}
b := reviewerBundle{
BundleVersion: bundleVersion,
Number: number,
ChangedFiles: files,
Impact: impact,
}
if len(bytes.TrimSpace(reviewers)) > 0 {
b.Reviewers = reviewers
}
data, err := json.MarshalIndent(b, "", " ")
if err != nil {
return fmt.Errorf("encoding reviewer bundle: %w", err)
}
data = append(data, '\n')
if dir := filepath.Dir(path); dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("creating bundle directory %q: %w", dir, err)
}
}
if err := os.WriteFile(path, data, 0o644); err != nil {
return fmt.Errorf("writing reviewer bundle to %q: %w", path, err)
}
return nil
}