From c23c10ea6d266281b92b1899d039ca5b2f06c5d6 Mon Sep 17 00:00:00 2001 From: Aly Raffauf Date: Wed, 15 Jul 2026 13:42:57 -0400 Subject: [PATCH] cli: refactor pr patch logic and handle missing repo records --- README.md | 1 + internal/cli/pr_checkout.go | 43 +++++++++++++++--------------------- internal/cli/pr_diff.go | 36 +++++++++++++++++++----------- internal/cli/repo_records.go | 26 ++++++++++++++++++++-- internal/gitutil/checkout.go | 1 + 5 files changed, 67 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index f96a323..bf38326 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ tg pr list # Create, comment on, inspect, and update pull requests tg pr create --title "Add feature" --base main +# Reconstruct the latest round on the current remote target branch tg pr checkout tg pr diff tg pr comment --body "Looks good" diff --git a/internal/cli/pr_checkout.go b/internal/cli/pr_checkout.go index 7adc038..9650cb8 100644 --- a/internal/cli/pr_checkout.go +++ b/internal/cli/pr_checkout.go @@ -1,7 +1,6 @@ package cli import ( - "encoding/json" "fmt" "os" @@ -19,71 +18,65 @@ var ( var prCheckoutCmd = &cobra.Command{ Use: "checkout ", Short: "Check out a pull request in Git", + Long: "Check out the latest pull request round on the current remote target branch.", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() + rkey := args[0] repoDir, err := os.Getwd() if err != nil { return fmt.Errorf("get current directory: %w", err) } - local, err := gitutil.DetectRepoFromCWD(ctx) + localRepo, err := gitutil.DetectRepoFromCWD(ctx) if err != nil { return fmt.Errorf("detect local repository: %w", err) } - localRecord, err := resolveRepoRecord(ctx, local.Handle, local.Repo) + localRecord, err := resolveRepoRecord(ctx, localRepo.Handle, localRepo.Repo) if err != nil { return err } - handle, name := local.Handle, local.Repo + handle, repoName := localRepo.Handle, localRepo.Repo if prCheckoutRepo != "" { - handle, name, err = parseHandleRepo(prCheckoutRepo) + handle, repoName, err = parseHandleRepo(prCheckoutRepo) if err != nil { return err } } targetRecord := localRecord - if handle != local.Handle || name != local.Repo { - targetRecord, err = resolveRepoRecord(ctx, handle, name) + if handle != localRepo.Handle || repoName != localRepo.Repo { + targetRecord, err = resolveRepoRecord(ctx, handle, repoName) if err != nil { return err } } if targetRecord.Value.RepoDid != localRecord.Value.RepoDid { - return fmt.Errorf("pull request target %s/%s does not match the current repository", handle, name) + return fmt.Errorf("pull request target %s/%s does not match the current repository", handle, repoName) } pulls, err := client.ListPulls(ctx, targetRecord.Value.RepoDid, tangled.ListOpts{Limit: defaultListLimit}) if err != nil { - return fmt.Errorf("list PRs for %s/%s: %w", handle, name, err) + return fmt.Errorf("list PRs for %s/%s: %w", handle, repoName, err) } - pull, err := findByRKey(pulls.Items, args[0], "pull request") + pull, err := findByRKey(pulls.Items, rkey, "pull request") if err != nil { return err } - var record tangled.PullRecord - if err := json.Unmarshal(pull.Value, &record); err != nil { - return fmt.Errorf("decode pull request %q: %w", args[0], err) - } - if len(record.Rounds) == 0 { - return fmt.Errorf("pull request %q has no rounds", args[0]) + record, patchCID, err := latestPullPatch(pull, rkey) + if err != nil { + return err } if record.Target.Branch == "" { - return fmt.Errorf("pull request %q has no target branch", args[0]) + return fmt.Errorf("pull request %q has no target branch", rkey) } - latestRound := record.Rounds[len(record.Rounds)-1] - cid := latestRound.PatchBlob.Ref.String() - if cid == "" { - return fmt.Errorf("pull request %q has no patch blob", args[0]) - } - patch, err := downloadPullPatch(ctx, extractDID(pull.URI), cid) + patch, err := downloadPullPatch(ctx, extractDID(pull.URI), patchCID) if err != nil { return err } branch := prCheckoutBranch if branch == "" { - branch = "pr-" + args[0] + branch = "pr-" + rkey } if err := gitutil.CheckoutPatch(ctx, gitutil.CheckoutPatchParams{ @@ -95,7 +88,7 @@ var prCheckoutCmd = &cobra.Command{ }); err != nil { return err } - result := prCheckoutResult{Rkey: args[0], Branch: branch} + result := prCheckoutResult{Rkey: rkey, Branch: branch} return output(result, func(result prCheckoutResult) { fmt.Printf("Checked out pull request %s as branch %s\n", result.Rkey, result.Branch) }) diff --git a/internal/cli/pr_diff.go b/internal/cli/pr_diff.go index afbf8cd..946a7d2 100644 --- a/internal/cli/pr_diff.go +++ b/internal/cli/pr_diff.go @@ -44,23 +44,18 @@ var prDiffCmd = &cobra.Command{ if err != nil { return err } - var record tangled.PullRecord - if err := json.Unmarshal(pull.Value, &record); err != nil { - return fmt.Errorf("decode pull request %q: %w", args[0], err) - } - if len(record.Rounds) == 0 { - return fmt.Errorf("pull request %q has no rounds", args[0]) - } - cid := record.Rounds[len(record.Rounds)-1].PatchBlob.Ref.String() - if cid == "" { - return fmt.Errorf("pull request %q has no patch blob", args[0]) + _, patchCID, err := latestPullPatch(pull, args[0]) + if err != nil { + return err } - patch, err := downloadPullPatch(ctx, extractDID(pull.URI), cid) + patch, err := downloadPullPatch(ctx, extractDID(pull.URI), patchCID) if err != nil { return err } - _, err = os.Stdout.Write(patch) - return err + if _, err := os.Stdout.Write(patch); err != nil { + return fmt.Errorf("write patch: %w", err) + } + return nil }, } @@ -68,6 +63,21 @@ func init() { prDiffCmd.Flags().StringVarP(&prDiffRepo, "repo", "R", "", "Target repository as handle/repo") } +func latestPullPatch(pull *tangled.ListItem, rkey string) (tangled.PullRecord, string, error) { + var record tangled.PullRecord + if err := json.Unmarshal(pull.Value, &record); err != nil { + return record, "", fmt.Errorf("decode pull request %q: %w", rkey, err) + } + if len(record.Rounds) == 0 { + return record, "", fmt.Errorf("pull request %q has no rounds", rkey) + } + patchCID := record.Rounds[len(record.Rounds)-1].PatchBlob.Ref.String() + if patchCID == "" { + return record, "", fmt.Errorf("pull request %q has no patch blob", rkey) + } + return record, patchCID, nil +} + func downloadPullPatch(ctx context.Context, authorDID, cid string) ([]byte, error) { pdsHost, err := resolver.ResolvePDS(ctx, authorDID) if err != nil { diff --git a/internal/cli/repo_records.go b/internal/cli/repo_records.go index f929d3e..b1a5dec 100644 --- a/internal/cli/repo_records.go +++ b/internal/cli/repo_records.go @@ -2,13 +2,17 @@ package cli import ( "context" + "errors" "fmt" + "net/http" + "strings" "github.com/alyraffauf/tg/tangled" + "github.com/bluesky-social/indigo/atproto/atclient" ) -// resolveRepoRecord finds a repository record, including legacy records whose -// rkey does not match the repository name. +// resolveRepoRecord finds a repository record even when its rkey does not +// match the repository name. func resolveRepoRecord(ctx context.Context, handle, name string) (*tangled.Repo, error) { ident, err := resolver.ResolveHandle(ctx, handle) if err != nil { @@ -21,6 +25,8 @@ func resolveRepoRecord(ctx context.Context, handle, name string) (*tangled.Repo, repo.URI = recordURI } return repo, nil + } else if !shouldListRepoRecords(err) { + return nil, fmt.Errorf("get repository %q: %w", name, err) } repos, err := client.ListRepos(ctx, ident.DID.String()) @@ -36,6 +42,22 @@ func resolveRepoRecord(ctx context.Context, handle, name string) (*tangled.Repo, return nil, fmt.Errorf("repo %q not found for handle %q", name, handle) } +func shouldListRepoRecords(err error) bool { + var apiError *atclient.APIError + if !errors.As(err, &apiError) { + return false + } + if apiError.StatusCode == http.StatusNotFound { + return true + } + + // Bobbin wraps an upstream PDS 400 as a 502 when no record exists at the + // name-derived rkey. Listing is required to find the record's actual rkey. + return apiError.StatusCode == http.StatusBadGateway && + apiError.Name == "UpstreamFailed" && + strings.Contains(apiError.Message, "upstream returned status 400 Bad Request") +} + func requireOwnedRepo(ctx context.Context, handle, name, did string) (*tangled.Repo, error) { repo, err := resolveRepoRecord(ctx, handle, name) if err != nil { diff --git a/internal/gitutil/checkout.go b/internal/gitutil/checkout.go index 384b436..4b0a081 100644 --- a/internal/gitutil/checkout.go +++ b/internal/gitutil/checkout.go @@ -8,6 +8,7 @@ import ( "strings" ) +// CheckoutPatchParams configures a local branch reconstructed from a patch. type CheckoutPatchParams struct { RepoDir string Branch string -- 2.51.2