A terminal client for Tangled.
0

Configure Feed

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

cli/pr: add write and diff commands

Aly Raffauf (Jul 15, 2026, 12:13 PM EDT) 616f9bfd cce6d60b

+514
+64
internal/cli/pr_comment.go
··· 1 + package cli 2 + 3 + import ( 4 + "fmt" 5 + 6 + "github.com/alyraffauf/tg/tangled" 7 + "github.com/spf13/cobra" 8 + ) 9 + 10 + var ( 11 + prCommentBody string 12 + prCommentBodyFile string 13 + prCommentRepo string 14 + ) 15 + 16 + var prCommentCmd = &cobra.Command{ 17 + Use: "comment <rkey>", 18 + Short: "Add a comment to a pull request", 19 + Args: cobra.ExactArgs(1), 20 + RunE: func(cmd *cobra.Command, args []string) error { 21 + body, err := commandBody(prCommentBody, prCommentBodyFile) 22 + if err != nil { 23 + return err 24 + } 25 + if body == "" { 26 + return fmt.Errorf("set --body or --body-file") 27 + } 28 + ctx := cmd.Context() 29 + targetArgs := []string{} 30 + if prCommentRepo != "" { 31 + targetArgs = []string{prCommentRepo} 32 + } 33 + handle, name, err := resolveTarget(ctx, targetArgs) 34 + if err != nil { 35 + return err 36 + } 37 + repoDid, err := findRepoDid(ctx, handle, name) 38 + if err != nil { 39 + return err 40 + } 41 + pulls, err := client.ListPulls(ctx, repoDid, tangled.ListOpts{Limit: defaultListLimit}) 42 + if err != nil { 43 + return fmt.Errorf("list PRs for %s/%s: %w", handle, name, err) 44 + } 45 + pull, err := findByRKey(pulls.Items, args[0], "pull request") 46 + if err != nil { 47 + return err 48 + } 49 + 50 + result, err := createPullComment(ctx, pull.URI, body) 51 + if err != nil { 52 + return err 53 + } 54 + return output(result, func(result createdRecordResult) { 55 + fmt.Printf("Added comment %s\n", result.URI) 56 + }) 57 + }, 58 + } 59 + 60 + func init() { 61 + prCommentCmd.Flags().StringVarP(&prCommentBody, "body", "b", "", "Comment body") 62 + prCommentCmd.Flags().StringVarP(&prCommentBodyFile, "body-file", "F", "", "Read comment body from file") 63 + prCommentCmd.Flags().StringVarP(&prCommentRepo, "repo", "R", "", "Target repository as handle/repo") 64 + }
+245
internal/cli/pr_create.go
··· 1 + package cli 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "os" 7 + "strings" 8 + "time" 9 + 10 + "github.com/alyraffauf/tg/atproto" 11 + "github.com/alyraffauf/tg/internal/gitutil" 12 + "github.com/alyraffauf/tg/tangled" 13 + "github.com/bluesky-social/indigo/atproto/syntax" 14 + "github.com/spf13/cobra" 15 + ) 16 + 17 + const patchMimeType = "application/vnd.git.patch+gzip" 18 + 19 + var ( 20 + prCreateTitle string 21 + prCreateBody string 22 + prCreateBodyFile string 23 + prCreateBase string 24 + prCreateHead string 25 + prCreateRepo string 26 + ) 27 + 28 + var prCreateCmd = &cobra.Command{ 29 + Use: "create", 30 + Short: "Create a pull request from the current branch", 31 + Long: "Create a pull request by uploading a gzipped git patch and writing a sh.tangled.repo.pull record. " + 32 + "The source and target repository are the same. By default, the current branch is the source and " + 33 + "origin's default branch is the target. Use --repo to target a different Tangled repository.", 34 + Args: cobra.NoArgs, 35 + RunE: func(cmd *cobra.Command, args []string) error { 36 + ctx := cmd.Context() 37 + if auth == nil || !auth.IsAuthenticated() { 38 + return fmt.Errorf("not logged in; run \"tg auth login\" first") 39 + } 40 + 41 + repoDir, err := os.Getwd() 42 + if err != nil { 43 + return fmt.Errorf("get current directory: %w", err) 44 + } 45 + head, err := prSourceBranch(ctx, repoDir) 46 + if err != nil { 47 + return err 48 + } 49 + base, err := prTargetBranch(ctx, repoDir) 50 + if err != nil { 51 + return err 52 + } 53 + body, err := prBody() 54 + if err != nil { 55 + return err 56 + } 57 + 58 + targetArgs := []string{} 59 + if prCreateRepo != "" { 60 + targetArgs = []string{prCreateRepo} 61 + } 62 + handle, repo, err := resolveTarget(ctx, targetArgs) 63 + if err != nil { 64 + return err 65 + } 66 + target, err := findTargetRepo(ctx, handle, repo) 67 + if err != nil { 68 + return err 69 + } 70 + if !strings.HasPrefix(target.URI, "at://") { 71 + return fmt.Errorf("target repository %q has no strong at:// URI", repo) 72 + } 73 + 74 + patch, err := gitutil.GeneratePatch(ctx, repoDir, base, head) 75 + if err != nil { 76 + return fmt.Errorf("generate pull request patch: %w", err) 77 + } 78 + pds, err := auth.APIClient(ctx) 79 + if err != nil { 80 + return fmt.Errorf("get auth client: %w", err) 81 + } 82 + atClient := &atproto.ATProto{Client: pds} 83 + blob, err := atClient.UploadBlob(ctx, patch, patchMimeType) 84 + if err != nil { 85 + return err 86 + } 87 + 88 + uri, err := createPullRecord(ctx, atClient, auth.CurrentDID().String(), prCreateRecord{ 89 + Title: prCreateTitle, 90 + Body: body, 91 + TargetRepo: target.URI, 92 + Base: base, 93 + Head: head, 94 + Patch: blob, 95 + }) 96 + if err != nil { 97 + return err 98 + } 99 + result := prCreateResult{URI: uri, Title: prCreateTitle, Base: base, Head: head} 100 + return output(result, func(created prCreateResult) { 101 + fmt.Printf("Created pull request %s (%s -> %s)\n", created.URI, created.Head, created.Base) 102 + }) 103 + }, 104 + } 105 + 106 + func init() { 107 + prCreateCmd.Flags().StringVarP(&prCreateTitle, "title", "t", "", "Pull request title") 108 + prCreateCmd.Flags().StringVarP(&prCreateBody, "body", "b", "", "Pull request body") 109 + prCreateCmd.Flags().StringVarP(&prCreateBodyFile, "body-file", "F", "", "Read pull request body from file") 110 + prCreateCmd.Flags().StringVarP(&prCreateBase, "base", "B", "", "Target branch (default: origin's default branch)") 111 + prCreateCmd.Flags().StringVarP(&prCreateHead, "head", "H", "", "Source branch (default: current branch)") 112 + prCreateCmd.Flags().StringVarP(&prCreateRepo, "repo", "R", "", "Target repository as handle/repo") 113 + prCreateCmd.MarkFlagRequired("title") 114 + } 115 + 116 + type prCreateRecord struct { 117 + Title string 118 + Body string 119 + TargetRepo string 120 + Base string 121 + Head string 122 + Patch *atproto.Blob 123 + } 124 + 125 + // pullRecord is the sh.tangled.repo.pull lexicon shape used for record writes. 126 + type pullRecord struct { 127 + Type string `json:"$type"` 128 + Title string `json:"title"` 129 + Body string `json:"body,omitempty"` 130 + CreatedAt string `json:"createdAt"` 131 + Target pullTarget `json:"target"` 132 + Source pullSource `json:"source"` 133 + Rounds []pullRound `json:"rounds"` 134 + } 135 + 136 + type pullTarget struct { 137 + Repo string `json:"repo"` 138 + Branch string `json:"branch"` 139 + } 140 + 141 + type pullSource struct { 142 + Branch string `json:"branch"` 143 + } 144 + 145 + type pullRound struct { 146 + CreatedAt string `json:"createdAt"` 147 + PatchBlob *atproto.Blob `json:"patchBlob"` 148 + } 149 + 150 + type prCreateResult struct { 151 + URI string `json:"uri"` 152 + Title string `json:"title"` 153 + Base string `json:"base"` 154 + Head string `json:"head"` 155 + } 156 + 157 + func prSourceBranch(ctx context.Context, repoDir string) (string, error) { 158 + if prCreateHead != "" { 159 + return prCreateHead, nil 160 + } 161 + branch, err := gitutil.CurrentBranch(ctx, repoDir) 162 + if err != nil { 163 + return "", fmt.Errorf("determine source branch: %w", err) 164 + } 165 + return branch, nil 166 + } 167 + 168 + func prTargetBranch(ctx context.Context, repoDir string) (string, error) { 169 + if prCreateBase != "" { 170 + return prCreateBase, nil 171 + } 172 + branch, err := gitutil.DefaultBranch(ctx, repoDir) 173 + if err != nil { 174 + return "", fmt.Errorf("determine target branch; set --base explicitly: %w", err) 175 + } 176 + return branch, nil 177 + } 178 + 179 + func prBody() (string, error) { 180 + if prCreateBodyFile == "" { 181 + return prCreateBody, nil 182 + } 183 + if prCreateBody != "" { 184 + return "", fmt.Errorf("--body and --body-file cannot be used together") 185 + } 186 + body, err := os.ReadFile(prCreateBodyFile) 187 + if err != nil { 188 + return "", fmt.Errorf("read pull request body: %w", err) 189 + } 190 + return string(body), nil 191 + } 192 + 193 + func findTargetRepo(ctx context.Context, handle, name string) (*tangled.Repo, error) { 194 + ident, err := resolver.ResolveHandle(ctx, handle) 195 + if err != nil { 196 + return nil, fmt.Errorf("resolve handle %q: %w", handle, err) 197 + } 198 + 199 + uri := fmt.Sprintf("at://%s/sh.tangled.repo/%s", ident.DID, name) 200 + if repo, err := client.GetRepo(ctx, uri); err == nil { 201 + if repo.URI == "" { 202 + repo.URI = uri 203 + } 204 + return repo, nil 205 + } else if !isNotFoundError(err) { 206 + return nil, fmt.Errorf("get repository %q: %w", name, err) 207 + } 208 + 209 + repos, err := client.ListRepos(ctx, ident.DID.String()) 210 + if err != nil { 211 + return nil, fmt.Errorf("list repositories for %q: %w", handle, err) 212 + } 213 + for _, repo := range repos.Items { 214 + if repo.Value.Name == name || strings.HasSuffix(repo.URI, "/"+name) { 215 + return &repo, nil 216 + } 217 + } 218 + return nil, fmt.Errorf("repo %q not found for handle %q", name, handle) 219 + } 220 + 221 + func createPullRecord(ctx context.Context, atClient *atproto.ATProto, did string, input prCreateRecord) (string, error) { 222 + now := time.Now().UTC().Format(time.RFC3339) 223 + record := pullRecord{ 224 + Type: "sh.tangled.repo.pull", 225 + Title: input.Title, 226 + Body: input.Body, 227 + CreatedAt: now, 228 + Target: pullTarget{Repo: input.TargetRepo, Branch: input.Base}, 229 + Source: pullSource{Branch: input.Head}, 230 + Rounds: []pullRound{{ 231 + CreatedAt: now, 232 + PatchBlob: input.Patch, 233 + }}, 234 + } 235 + uri, _, err := atClient.PutRecord(ctx, atproto.PutRecordInput{ 236 + Repo: did, 237 + Collection: "sh.tangled.repo.pull", 238 + Rkey: string(syntax.NewTIDNow(0)), 239 + Record: record, 240 + }) 241 + if err != nil { 242 + return "", fmt.Errorf("create pull request record: %w", err) 243 + } 244 + return uri, nil 245 + }
+91
internal/cli/pr_diff.go
··· 1 + package cli 2 + 3 + import ( 4 + "compress/gzip" 5 + "context" 6 + "encoding/json" 7 + "fmt" 8 + "io" 9 + "net/http" 10 + "os" 11 + 12 + "github.com/alyraffauf/tg/tangled" 13 + "github.com/spf13/cobra" 14 + ) 15 + 16 + var prDiffRepo string 17 + 18 + var prDiffCmd = &cobra.Command{ 19 + Use: "diff <rkey>", 20 + Short: "Print the latest patch for a pull request", 21 + Args: cobra.ExactArgs(1), 22 + RunE: func(cmd *cobra.Command, args []string) error { 23 + ctx := cmd.Context() 24 + targetArgs := []string{} 25 + if prDiffRepo != "" { 26 + targetArgs = []string{prDiffRepo} 27 + } 28 + handle, name, err := resolveTarget(ctx, targetArgs) 29 + if err != nil { 30 + return err 31 + } 32 + repoDid, err := findRepoDid(ctx, handle, name) 33 + if err != nil { 34 + return err 35 + } 36 + pulls, err := client.ListPulls(ctx, repoDid, tangled.ListOpts{Limit: defaultListLimit}) 37 + if err != nil { 38 + return fmt.Errorf("list PRs for %s/%s: %w", handle, name, err) 39 + } 40 + pull, err := findByRKey(pulls.Items, args[0], "pull request") 41 + if err != nil { 42 + return err 43 + } 44 + var record tangled.PullRecord 45 + if err := json.Unmarshal(pull.Value, &record); err != nil { 46 + return fmt.Errorf("decode pull request %q: %w", args[0], err) 47 + } 48 + if len(record.Rounds) == 0 { 49 + return fmt.Errorf("pull request %q has no rounds", args[0]) 50 + } 51 + cid := record.Rounds[len(record.Rounds)-1].PatchBlob.Ref.String() 52 + if cid == "" { 53 + return fmt.Errorf("pull request %q has no patch blob", args[0]) 54 + } 55 + return printPullPatch(ctx, extractDID(pull.URI), cid) 56 + }, 57 + } 58 + 59 + func init() { 60 + prDiffCmd.Flags().StringVarP(&prDiffRepo, "repo", "R", "", "Target repository as handle/repo") 61 + } 62 + 63 + func printPullPatch(ctx context.Context, authorDID, cid string) error { 64 + pdsHost, err := resolver.ResolvePDS(ctx, authorDID) 65 + if err != nil { 66 + return fmt.Errorf("resolve PDS for author %q: %w", authorDID, err) 67 + } 68 + url := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s", pdsHost, authorDID, cid) 69 + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 70 + if err != nil { 71 + return fmt.Errorf("build patch download request: %w", err) 72 + } 73 + resp, err := http.DefaultClient.Do(req) 74 + if err != nil { 75 + return fmt.Errorf("download patch: %w", err) 76 + } 77 + defer resp.Body.Close() 78 + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { 79 + return fmt.Errorf("download patch: PDS returned HTTP %d", resp.StatusCode) 80 + } 81 + 82 + patch, err := gzip.NewReader(resp.Body) 83 + if err != nil { 84 + return fmt.Errorf("decompress patch: %w", err) 85 + } 86 + defer patch.Close() 87 + if _, err := io.Copy(os.Stdout, patch); err != nil { 88 + return fmt.Errorf("write patch: %w", err) 89 + } 90 + return nil 91 + }
+114
internal/cli/pr_state.go
··· 1 + package cli 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + 7 + "github.com/alyraffauf/tg/knot" 8 + "github.com/spf13/cobra" 9 + ) 10 + 11 + var ( 12 + prStateRepo string 13 + prEditTitle string 14 + prEditBody string 15 + prMergeRepo string 16 + ) 17 + 18 + var prCloseCmd = newPRStateCmd("close", "closed") 19 + var prReopenCmd = newPRStateCmd("reopen", "open") 20 + 21 + var prEditCmd = &cobra.Command{ 22 + Use: "edit <rkey>", 23 + Short: "Edit a pull request", 24 + Args: cobra.ExactArgs(1), 25 + RunE: func(cmd *cobra.Command, args []string) error { 26 + setTitle := cmd.Flags().Changed("title") 27 + setBody := cmd.Flags().Changed("body") 28 + if !setTitle && !setBody { 29 + return fmt.Errorf("set --title or --body") 30 + } 31 + atClient, did, err := authenticatedATProto(cmd.Context()) 32 + if err != nil { 33 + return err 34 + } 35 + return editRecord(cmd.Context(), atClient, did, pullCollection, args[0], prEditTitle, prEditBody, setTitle, setBody) 36 + }, 37 + } 38 + 39 + var prMergeCmd = &cobra.Command{ 40 + Use: "merge <rkey>", 41 + Short: "Merge a pull request", 42 + Args: cobra.ExactArgs(1), 43 + RunE: func(cmd *cobra.Command, args []string) error { 44 + ctx := cmd.Context() 45 + atClient, did, err := authenticatedATProto(ctx) 46 + if err != nil { 47 + return err 48 + } 49 + pullURI, repoURI, err := targetRecord(ctx, prMergeRepo, pullCollection, args[0]) 50 + if err != nil { 51 + return err 52 + } 53 + knotHost, err := repoKnot(ctx, repoURI) 54 + if err != nil { 55 + return err 56 + } 57 + token, err := atClient.GetServiceAuth(ctx, "did:web:"+knotHost, "sh.tangled.repo.merge") 58 + if err != nil { 59 + return err 60 + } 61 + if err := knot.New(knotHost, token).Merge(ctx, knot.MergeInput{Repo: repoURI, Pull: pullURI}); err != nil { 62 + return err 63 + } 64 + if err := putState(ctx, atClient, did, args[0], pullCollection, pullURI, "merged"); err != nil { 65 + return fmt.Errorf("record merged pull request status: %w", err) 66 + } 67 + return output(stateResult{Rkey: args[0], State: "merged"}, func(result stateResult) { 68 + fmt.Printf("Pull request %s merged\n", result.Rkey) 69 + }) 70 + }, 71 + } 72 + 73 + func newPRStateCmd(use, status string) *cobra.Command { 74 + return &cobra.Command{ 75 + Use: use + " <rkey>", 76 + Short: use + " a pull request", 77 + Args: cobra.ExactArgs(1), 78 + RunE: func(cmd *cobra.Command, args []string) error { 79 + atClient, did, err := authenticatedATProto(cmd.Context()) 80 + if err != nil { 81 + return err 82 + } 83 + target, _, err := targetRecord(cmd.Context(), prStateRepo, pullCollection, args[0]) 84 + if err != nil { 85 + return err 86 + } 87 + if err := putState(cmd.Context(), atClient, did, args[0], pullCollection, target, status); err != nil { 88 + return fmt.Errorf("%s pull request: %w", use, err) 89 + } 90 + return output(stateResult{Rkey: args[0], State: status}, func(result stateResult) { 91 + fmt.Printf("Pull request %s %s\n", result.Rkey, result.State) 92 + }) 93 + }, 94 + } 95 + } 96 + 97 + func repoKnot(ctx context.Context, repoURI string) (string, error) { 98 + repo, err := client.GetRepo(ctx, repoURI) 99 + if err != nil { 100 + return "", fmt.Errorf("get repository: %w", err) 101 + } 102 + if repo.Value.Knot == "" { 103 + return "", fmt.Errorf("repository record has no knot") 104 + } 105 + return repo.Value.Knot, nil 106 + } 107 + 108 + func init() { 109 + prCloseCmd.Flags().StringVarP(&prStateRepo, "repo", "R", "", "Target repository as handle/repo") 110 + prReopenCmd.Flags().StringVarP(&prStateRepo, "repo", "R", "", "Target repository as handle/repo") 111 + prEditCmd.Flags().StringVarP(&prEditTitle, "title", "t", "", "New title") 112 + prEditCmd.Flags().StringVarP(&prEditBody, "body", "b", "", "New body") 113 + prMergeCmd.Flags().StringVarP(&prMergeRepo, "repo", "R", "", "Target repository as handle/repo") 114 + }