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: extract shared resource helpers

Aly Raffauf (Jul 15, 2026, 12:38 PM EDT) e49b37f5 8d1dd6c3

+187 -166
+20
internal/cli/atproto_auth.go
··· 1 + package cli 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + 7 + "github.com/alyraffauf/tg/atproto" 8 + ) 9 + 10 + func authenticatedATProto(ctx context.Context) (*atproto.ATProto, string, error) { 11 + if auth == nil || !auth.IsAuthenticated() { 12 + return nil, "", fmt.Errorf("not logged in; run \"tg auth login\" first") 13 + } 14 + 15 + pds, err := auth.APIClient(ctx) 16 + if err != nil { 17 + return nil, "", fmt.Errorf("get auth client: %w", err) 18 + } 19 + return &atproto.ATProto{Client: pds}, auth.CurrentDID().String(), nil 20 + }
+20
internal/cli/command_body.go
··· 1 + package cli 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + ) 7 + 8 + func commandBody(body, bodyFile string) (string, error) { 9 + if bodyFile == "" { 10 + return body, nil 11 + } 12 + if body != "" { 13 + return "", fmt.Errorf("--body and --body-file cannot be used together") 14 + } 15 + data, err := os.ReadFile(bodyFile) 16 + if err != nil { 17 + return "", fmt.Errorf("read body file: %w", err) 18 + } 19 + return string(data), nil 20 + }
-20
internal/cli/comment.go internal/cli/resource_comments.go
··· 3 3 import ( 4 4 "context" 5 5 "fmt" 6 - "os" 7 6 "time" 8 7 9 8 "github.com/alyraffauf/tg/atproto" ··· 17 16 CreatedAt string `json:"createdAt"` 18 17 } 19 18 20 - func commandBody(body, bodyFile string) (string, error) { 21 - if bodyFile == "" { 22 - return body, nil 23 - } 24 - if body != "" { 25 - return "", fmt.Errorf("--body and --body-file cannot be used together") 26 - } 27 - data, err := os.ReadFile(bodyFile) 28 - if err != nil { 29 - return "", fmt.Errorf("read body file: %w", err) 30 - } 31 - return string(data), nil 32 - } 33 - 34 19 type pullCommentRecord struct { 35 20 Type string `json:"$type"` 36 21 Pull string `json:"pull"` 37 22 Body string `json:"body"` 38 23 CreatedAt string `json:"createdAt"` 39 - } 40 - 41 - type createdRecordResult struct { 42 - Rkey string `json:"rkey"` 43 - URI string `json:"uri"` 44 24 } 45 25 46 26 func createIssueComment(ctx context.Context, issueURI, body string) (createdRecordResult, error) {
+60
internal/cli/record_mutations.go
··· 1 + package cli 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + 8 + "github.com/alyraffauf/tg/atproto" 9 + ) 10 + 11 + type createdRecordResult struct { 12 + Rkey string `json:"rkey"` 13 + URI string `json:"uri"` 14 + } 15 + 16 + func putRecord(ctx context.Context, atClient *atproto.ATProto, did, collection, rkey string, record any) error { 17 + if _, _, err := atClient.PutRecord(ctx, atproto.PutRecordInput{ 18 + Repo: did, Collection: collection, Rkey: rkey, Record: record, 19 + }); err != nil { 20 + return err 21 + } 22 + return nil 23 + } 24 + 25 + func editRecord(ctx context.Context, atClient *atproto.ATProto, did, collection, rkey, title, body string, setTitle, setBody bool) error { 26 + found, err := atClient.GetRecord(ctx, did, collection, rkey) 27 + if err != nil { 28 + return fmt.Errorf("get existing record: %w", err) 29 + } 30 + 31 + record, err := preserveRecord(found.Value) 32 + if err != nil { 33 + return err 34 + } 35 + if setTitle { 36 + record["title"] = title 37 + } 38 + if setBody { 39 + record["body"] = body 40 + } 41 + _, _, err = atClient.PutRecord(ctx, atproto.PutRecordInput{ 42 + Repo: did, Collection: collection, Rkey: rkey, Record: record, 43 + }) 44 + return err 45 + } 46 + 47 + func preserveRecord(value any) (map[string]any, error) { 48 + data, err := json.Marshal(value) 49 + if err != nil { 50 + return nil, fmt.Errorf("encode existing record: %w", err) 51 + } 52 + var record map[string]any 53 + if err := json.Unmarshal(data, &record); err != nil { 54 + return nil, fmt.Errorf("decode existing record: %w", err) 55 + } 56 + if record == nil { 57 + return nil, fmt.Errorf("existing record is not an object") 58 + } 59 + return record, nil 60 + }
+87
internal/cli/resource_states.go
··· 1 + package cli 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + 7 + "github.com/alyraffauf/tg/atproto" 8 + "github.com/alyraffauf/tg/tangled" 9 + ) 10 + 11 + const ( 12 + issueCollection = "sh.tangled.repo.issue" 13 + pullCollection = "sh.tangled.repo.pull" 14 + ) 15 + 16 + type issueStateRecord struct { 17 + Type string `json:"$type"` 18 + Issue string `json:"issue"` 19 + State string `json:"state"` 20 + } 21 + 22 + type pullStatusRecord struct { 23 + Type string `json:"$type"` 24 + Pull string `json:"pull"` 25 + Status string `json:"status"` 26 + } 27 + 28 + type stateResult struct { 29 + Rkey string `json:"rkey"` 30 + State string `json:"state"` 31 + } 32 + 33 + func targetRecord(ctx context.Context, repoArg, collection, rkey string) (string, string, error) { 34 + targetArgs := []string{} 35 + if repoArg != "" { 36 + targetArgs = []string{repoArg} 37 + } 38 + handle, repo, err := resolveTarget(ctx, targetArgs) 39 + if err != nil { 40 + return "", "", err 41 + } 42 + repoRecord, err := resolveRepoRecord(ctx, handle, repo) 43 + if err != nil { 44 + return "", "", err 45 + } 46 + 47 + var items []tangled.ListItem 48 + var recordType string 49 + if collection == issueCollection { 50 + issues, err := client.ListIssues(ctx, repoRecord.Value.RepoDid, tangled.ListOpts{Limit: defaultListLimit}) 51 + if err != nil { 52 + return "", "", fmt.Errorf("list issues for %s/%s: %w", handle, repo, err) 53 + } 54 + items = issues.Items 55 + recordType = "issue" 56 + } else { 57 + pulls, err := client.ListPulls(ctx, repoRecord.Value.RepoDid, tangled.ListOpts{Limit: defaultListLimit}) 58 + if err != nil { 59 + return "", "", fmt.Errorf("list pull requests for %s/%s: %w", handle, repo, err) 60 + } 61 + items = pulls.Items 62 + recordType = "pull request" 63 + } 64 + 65 + record, err := findByRKey(items, rkey, recordType) 66 + if err != nil { 67 + return "", "", err 68 + } 69 + return record.URI, repoRecord.URI, nil 70 + } 71 + 72 + func putState(ctx context.Context, atClient *atproto.ATProto, did, rkey, collection, target, state string) error { 73 + if collection == issueCollection { 74 + state = "sh.tangled.repo.issue.state." + state 75 + return putRecord(ctx, atClient, did, "sh.tangled.repo.issue.state", rkey, issueStateRecord{ 76 + Type: "sh.tangled.repo.issue.state", 77 + Issue: target, 78 + State: state, 79 + }) 80 + } 81 + state = "sh.tangled.repo.pull.status." + state 82 + return putRecord(ctx, atClient, did, "sh.tangled.repo.pull.status", rkey, pullStatusRecord{ 83 + Type: "sh.tangled.repo.pull.status", 84 + Pull: target, 85 + Status: state, 86 + }) 87 + }
-146
internal/cli/state.go
··· 1 - package cli 2 - 3 - import ( 4 - "context" 5 - "encoding/json" 6 - "fmt" 7 - 8 - "github.com/alyraffauf/tg/atproto" 9 - "github.com/alyraffauf/tg/tangled" 10 - ) 11 - 12 - const ( 13 - issueCollection = "sh.tangled.repo.issue" 14 - pullCollection = "sh.tangled.repo.pull" 15 - ) 16 - 17 - type issueStateRecord struct { 18 - Type string `json:"$type"` 19 - Issue string `json:"issue"` 20 - State string `json:"state"` 21 - } 22 - 23 - type pullStatusRecord struct { 24 - Type string `json:"$type"` 25 - Pull string `json:"pull"` 26 - Status string `json:"status"` 27 - } 28 - 29 - type stateResult struct { 30 - Rkey string `json:"rkey"` 31 - State string `json:"state"` 32 - } 33 - 34 - func authenticatedATProto(ctx context.Context) (*atproto.ATProto, string, error) { 35 - if auth == nil || !auth.IsAuthenticated() { 36 - return nil, "", fmt.Errorf("not logged in; run \"tg auth login\" first") 37 - } 38 - 39 - pds, err := auth.APIClient(ctx) 40 - if err != nil { 41 - return nil, "", fmt.Errorf("get auth client: %w", err) 42 - } 43 - return &atproto.ATProto{Client: pds}, auth.CurrentDID().String(), nil 44 - } 45 - 46 - func targetRecord(ctx context.Context, repoArg, collection, rkey string) (string, string, error) { 47 - targetArgs := []string{} 48 - if repoArg != "" { 49 - targetArgs = []string{repoArg} 50 - } 51 - handle, repo, err := resolveTarget(ctx, targetArgs) 52 - if err != nil { 53 - return "", "", err 54 - } 55 - repoRecord, err := resolveRepoRecord(ctx, handle, repo) 56 - if err != nil { 57 - return "", "", err 58 - } 59 - 60 - var items []tangled.ListItem 61 - var recordType string 62 - if collection == issueCollection { 63 - issues, err := client.ListIssues(ctx, repoRecord.Value.RepoDid, tangled.ListOpts{Limit: defaultListLimit}) 64 - if err != nil { 65 - return "", "", fmt.Errorf("list issues for %s/%s: %w", handle, repo, err) 66 - } 67 - items = issues.Items 68 - recordType = "issue" 69 - } else { 70 - pulls, err := client.ListPulls(ctx, repoRecord.Value.RepoDid, tangled.ListOpts{Limit: defaultListLimit}) 71 - if err != nil { 72 - return "", "", fmt.Errorf("list pull requests for %s/%s: %w", handle, repo, err) 73 - } 74 - items = pulls.Items 75 - recordType = "pull request" 76 - } 77 - 78 - record, err := findByRKey(items, rkey, recordType) 79 - if err != nil { 80 - return "", "", err 81 - } 82 - return record.URI, repoRecord.URI, nil 83 - } 84 - 85 - func putState(ctx context.Context, atClient *atproto.ATProto, did, rkey, collection, target, state string) error { 86 - if collection == issueCollection { 87 - state = "sh.tangled.repo.issue.state." + state 88 - return putRecord(ctx, atClient, did, "sh.tangled.repo.issue.state", rkey, issueStateRecord{ 89 - Type: "sh.tangled.repo.issue.state", 90 - Issue: target, 91 - State: state, 92 - }) 93 - } 94 - state = "sh.tangled.repo.pull.status." + state 95 - return putRecord(ctx, atClient, did, "sh.tangled.repo.pull.status", rkey, pullStatusRecord{ 96 - Type: "sh.tangled.repo.pull.status", 97 - Pull: target, 98 - Status: state, 99 - }) 100 - } 101 - 102 - func putRecord(ctx context.Context, atClient *atproto.ATProto, did, collection, rkey string, record any) error { 103 - if _, _, err := atClient.PutRecord(ctx, atproto.PutRecordInput{ 104 - Repo: did, Collection: collection, Rkey: rkey, Record: record, 105 - }); err != nil { 106 - return err 107 - } 108 - return nil 109 - } 110 - 111 - func editRecord(ctx context.Context, atClient *atproto.ATProto, did, collection, rkey, title, body string, setTitle, setBody bool) error { 112 - found, err := atClient.GetRecord(ctx, did, collection, rkey) 113 - if err != nil { 114 - return fmt.Errorf("get existing record: %w", err) 115 - } 116 - 117 - record, err := preserveRecord(found.Value) 118 - if err != nil { 119 - return err 120 - } 121 - if setTitle { 122 - record["title"] = title 123 - } 124 - if setBody { 125 - record["body"] = body 126 - } 127 - _, _, err = atClient.PutRecord(ctx, atproto.PutRecordInput{ 128 - Repo: did, Collection: collection, Rkey: rkey, Record: record, 129 - }) 130 - return err 131 - } 132 - 133 - func preserveRecord(value any) (map[string]any, error) { 134 - data, err := json.Marshal(value) 135 - if err != nil { 136 - return nil, fmt.Errorf("encode existing record: %w", err) 137 - } 138 - var record map[string]any 139 - if err := json.Unmarshal(data, &record); err != nil { 140 - return nil, fmt.Errorf("decode existing record: %w", err) 141 - } 142 - if record == nil { 143 - return nil, fmt.Errorf("existing record is not an object") 144 - } 145 - return record, nil 146 - }