A terminal client for Tangled.
tui go tangled cli
8

Configure Feed

Select the types of activity you want to include in your feed.

cli: refactor pr patch logic and handle missing repo records

Aly Raffauf (Jul 15, 2026, 1:42 PM EDT) c23c10ea 50d5697c

+67 -40
+1
README.md
··· 67 67 68 68 # Create, comment on, inspect, and update pull requests 69 69 tg pr create --title "Add feature" --base main 70 + # Reconstruct the latest round on the current remote target branch 70 71 tg pr checkout <rkey> 71 72 tg pr diff <rkey> 72 73 tg pr comment <rkey> --body "Looks good"
+18 -25
internal/cli/pr_checkout.go
··· 1 1 package cli 2 2 3 3 import ( 4 - "encoding/json" 5 4 "fmt" 6 5 "os" 7 6 ··· 19 18 var prCheckoutCmd = &cobra.Command{ 20 19 Use: "checkout <rkey>", 21 20 Short: "Check out a pull request in Git", 21 + Long: "Check out the latest pull request round on the current remote target branch.", 22 22 Args: cobra.ExactArgs(1), 23 23 RunE: func(cmd *cobra.Command, args []string) error { 24 24 ctx := cmd.Context() 25 + rkey := args[0] 25 26 repoDir, err := os.Getwd() 26 27 if err != nil { 27 28 return fmt.Errorf("get current directory: %w", err) 28 29 } 29 - local, err := gitutil.DetectRepoFromCWD(ctx) 30 + localRepo, err := gitutil.DetectRepoFromCWD(ctx) 30 31 if err != nil { 31 32 return fmt.Errorf("detect local repository: %w", err) 32 33 } 33 - localRecord, err := resolveRepoRecord(ctx, local.Handle, local.Repo) 34 + localRecord, err := resolveRepoRecord(ctx, localRepo.Handle, localRepo.Repo) 34 35 if err != nil { 35 36 return err 36 37 } 37 38 38 - handle, name := local.Handle, local.Repo 39 + handle, repoName := localRepo.Handle, localRepo.Repo 39 40 if prCheckoutRepo != "" { 40 - handle, name, err = parseHandleRepo(prCheckoutRepo) 41 + handle, repoName, err = parseHandleRepo(prCheckoutRepo) 41 42 if err != nil { 42 43 return err 43 44 } 44 45 } 45 46 targetRecord := localRecord 46 - if handle != local.Handle || name != local.Repo { 47 - targetRecord, err = resolveRepoRecord(ctx, handle, name) 47 + if handle != localRepo.Handle || repoName != localRepo.Repo { 48 + targetRecord, err = resolveRepoRecord(ctx, handle, repoName) 48 49 if err != nil { 49 50 return err 50 51 } 51 52 } 52 53 if targetRecord.Value.RepoDid != localRecord.Value.RepoDid { 53 - return fmt.Errorf("pull request target %s/%s does not match the current repository", handle, name) 54 + return fmt.Errorf("pull request target %s/%s does not match the current repository", handle, repoName) 54 55 } 55 56 56 57 pulls, err := client.ListPulls(ctx, targetRecord.Value.RepoDid, tangled.ListOpts{Limit: defaultListLimit}) 57 58 if err != nil { 58 - return fmt.Errorf("list PRs for %s/%s: %w", handle, name, err) 59 + return fmt.Errorf("list PRs for %s/%s: %w", handle, repoName, err) 59 60 } 60 - pull, err := findByRKey(pulls.Items, args[0], "pull request") 61 + pull, err := findByRKey(pulls.Items, rkey, "pull request") 61 62 if err != nil { 62 63 return err 63 64 } 64 - var record tangled.PullRecord 65 - if err := json.Unmarshal(pull.Value, &record); err != nil { 66 - return fmt.Errorf("decode pull request %q: %w", args[0], err) 67 - } 68 - if len(record.Rounds) == 0 { 69 - return fmt.Errorf("pull request %q has no rounds", args[0]) 65 + record, patchCID, err := latestPullPatch(pull, rkey) 66 + if err != nil { 67 + return err 70 68 } 71 69 if record.Target.Branch == "" { 72 - return fmt.Errorf("pull request %q has no target branch", args[0]) 70 + return fmt.Errorf("pull request %q has no target branch", rkey) 73 71 } 74 72 75 - latestRound := record.Rounds[len(record.Rounds)-1] 76 - cid := latestRound.PatchBlob.Ref.String() 77 - if cid == "" { 78 - return fmt.Errorf("pull request %q has no patch blob", args[0]) 79 - } 80 - patch, err := downloadPullPatch(ctx, extractDID(pull.URI), cid) 73 + patch, err := downloadPullPatch(ctx, extractDID(pull.URI), patchCID) 81 74 if err != nil { 82 75 return err 83 76 } 84 77 branch := prCheckoutBranch 85 78 if branch == "" { 86 - branch = "pr-" + args[0] 79 + branch = "pr-" + rkey 87 80 } 88 81 89 82 if err := gitutil.CheckoutPatch(ctx, gitutil.CheckoutPatchParams{ ··· 95 88 }); err != nil { 96 89 return err 97 90 } 98 - result := prCheckoutResult{Rkey: args[0], Branch: branch} 91 + result := prCheckoutResult{Rkey: rkey, Branch: branch} 99 92 return output(result, func(result prCheckoutResult) { 100 93 fmt.Printf("Checked out pull request %s as branch %s\n", result.Rkey, result.Branch) 101 94 })
+23 -13
internal/cli/pr_diff.go
··· 44 44 if err != nil { 45 45 return err 46 46 } 47 - var record tangled.PullRecord 48 - if err := json.Unmarshal(pull.Value, &record); err != nil { 49 - return fmt.Errorf("decode pull request %q: %w", args[0], err) 50 - } 51 - if len(record.Rounds) == 0 { 52 - return fmt.Errorf("pull request %q has no rounds", args[0]) 53 - } 54 - cid := record.Rounds[len(record.Rounds)-1].PatchBlob.Ref.String() 55 - if cid == "" { 56 - return fmt.Errorf("pull request %q has no patch blob", args[0]) 47 + _, patchCID, err := latestPullPatch(pull, args[0]) 48 + if err != nil { 49 + return err 57 50 } 58 - patch, err := downloadPullPatch(ctx, extractDID(pull.URI), cid) 51 + patch, err := downloadPullPatch(ctx, extractDID(pull.URI), patchCID) 59 52 if err != nil { 60 53 return err 61 54 } 62 - _, err = os.Stdout.Write(patch) 63 - return err 55 + if _, err := os.Stdout.Write(patch); err != nil { 56 + return fmt.Errorf("write patch: %w", err) 57 + } 58 + return nil 64 59 }, 65 60 } 66 61 67 62 func init() { 68 63 prDiffCmd.Flags().StringVarP(&prDiffRepo, "repo", "R", "", "Target repository as handle/repo") 64 + } 65 + 66 + func latestPullPatch(pull *tangled.ListItem, rkey string) (tangled.PullRecord, string, error) { 67 + var record tangled.PullRecord 68 + if err := json.Unmarshal(pull.Value, &record); err != nil { 69 + return record, "", fmt.Errorf("decode pull request %q: %w", rkey, err) 70 + } 71 + if len(record.Rounds) == 0 { 72 + return record, "", fmt.Errorf("pull request %q has no rounds", rkey) 73 + } 74 + patchCID := record.Rounds[len(record.Rounds)-1].PatchBlob.Ref.String() 75 + if patchCID == "" { 76 + return record, "", fmt.Errorf("pull request %q has no patch blob", rkey) 77 + } 78 + return record, patchCID, nil 69 79 } 70 80 71 81 func downloadPullPatch(ctx context.Context, authorDID, cid string) ([]byte, error) {
+24 -2
internal/cli/repo_records.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "errors" 5 6 "fmt" 7 + "net/http" 8 + "strings" 6 9 7 10 "github.com/alyraffauf/tg/tangled" 11 + "github.com/bluesky-social/indigo/atproto/atclient" 8 12 ) 9 13 10 - // resolveRepoRecord finds a repository record, including legacy records whose 11 - // rkey does not match the repository name. 14 + // resolveRepoRecord finds a repository record even when its rkey does not 15 + // match the repository name. 12 16 func resolveRepoRecord(ctx context.Context, handle, name string) (*tangled.Repo, error) { 13 17 ident, err := resolver.ResolveHandle(ctx, handle) 14 18 if err != nil { ··· 21 25 repo.URI = recordURI 22 26 } 23 27 return repo, nil 28 + } else if !shouldListRepoRecords(err) { 29 + return nil, fmt.Errorf("get repository %q: %w", name, err) 24 30 } 25 31 26 32 repos, err := client.ListRepos(ctx, ident.DID.String()) ··· 34 40 } 35 41 } 36 42 return nil, fmt.Errorf("repo %q not found for handle %q", name, handle) 43 + } 44 + 45 + func shouldListRepoRecords(err error) bool { 46 + var apiError *atclient.APIError 47 + if !errors.As(err, &apiError) { 48 + return false 49 + } 50 + if apiError.StatusCode == http.StatusNotFound { 51 + return true 52 + } 53 + 54 + // Bobbin wraps an upstream PDS 400 as a 502 when no record exists at the 55 + // name-derived rkey. Listing is required to find the record's actual rkey. 56 + return apiError.StatusCode == http.StatusBadGateway && 57 + apiError.Name == "UpstreamFailed" && 58 + strings.Contains(apiError.Message, "upstream returned status 400 Bad Request") 37 59 } 38 60 39 61 func requireOwnedRepo(ctx context.Context, handle, name, did string) (*tangled.Repo, error) {
+1
internal/gitutil/checkout.go
··· 8 8 "strings" 9 9 ) 10 10 11 + // CheckoutPatchParams configures a local branch reconstructed from a patch. 11 12 type CheckoutPatchParams struct { 12 13 RepoDir string 13 14 Branch string