close
An unofficial terminal client for Tangled, optimized for humans and agents. tgcli.wisp.place
cli atproto go tangled
164

Configure Feed

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

Add persistent app-password auth and fix repository forking #3

Merged opened by okami.mom targeting master from [deleted fork]: master

This adds a headless authentication path for tg while preserving the existing browser-based OAuth flow.

tg auth login <handle> <app-password> now authenticates through the AT Protocol password-session API and persists the resulting access and refresh tokens with user-only file permissions. Persisted sessions resume across invocations, refresh-token updates are saved automatically, and tg auth token, tg auth status, and tg auth logout work with both authentication methods.

The README now documents OAuth login, app-password login, credential storage, and logout behavior.

This also fixes tg repo fork. The command previously passed an at:// record URI as the knotโ€™s clone source, causing the knot to reject it as an invalid URL. It now sends the expected https://<knot>/<repoDid> source URL and records the upstream AT URI as fork metadata. A regression test covers source URL construction.

Validation:

  • go test ./...
  • go build ./cmd/tg
  • Live app-password login, persistence, token retrieval, and logout
  • Live fork of aly.codes/tg to okami.mom/tg-app-password-auth

this pr was written with 5.6-luna as a way to live test PRing with this PR

Labels

None yet.

assignee

None yet.

Participants 2
AT URI
at://did:plc:3rwz3xfw2crswgifqgc3g7zh/sh.tangled.repo.pull/3mqsltshjyt22
+522 -68
Diff #1
+26
README.md
··· 18 18 19 19 ## Usage 20 20 21 + ### Authentication 22 + 23 + Log in interactively with OAuth: 24 + 25 + ```bash 26 + tg auth login alice.example.com 27 + ``` 28 + 29 + For headless use, pass an atproto app password as the second argument: 30 + 31 + ```bash 32 + tg auth login alice.example.com xxxx-xxxx-xxxx-xxxx 33 + ``` 34 + 35 + To avoid exposing the app password in shell history, pass it on standard input: 36 + 37 + ```bash 38 + printf '%s\n' "$ATPROTO_APP_PASSWORD" | tg auth login alice.example.com --password-stdin 39 + ``` 40 + 41 + Authentication is persisted locally. The current account is recorded in 42 + `~/.config/tg/auth.json` (or `$XDG_CONFIG_HOME/tg/auth.json`); OAuth session 43 + credentials are stored under `~/.config/tg/oauth/`, and app-password sessions 44 + are stored in `~/.config/tg/password-session.json`. These files are created 45 + with user-only permissions. Use `tg auth logout` to remove the active login. 46 + 21 47 `tg` auto-detects the repository from the `origin` remote when run inside a cloned Tangled repo. For now, only ssh origins are supported. You can also pass a fully-qualified `handle/repo` argument. 22 48 23 49 ### Repositories
+141 -10
atproto/auth.go
··· 11 11 12 12 "github.com/bluesky-social/indigo/atproto/atclient" 13 13 "github.com/bluesky-social/indigo/atproto/auth/oauth" 14 + "github.com/bluesky-social/indigo/atproto/identity" 14 15 "github.com/bluesky-social/indigo/atproto/syntax" 15 16 ) 16 17 ··· 41 42 } 42 43 43 44 type AuthManager struct { 44 - App *oauth.ClientApp 45 - Store *FileStore 46 - state authState 47 - statePath string 45 + App *oauth.ClientApp 46 + Store *FileStore 47 + state authState 48 + statePath string 49 + passwordPath string 50 + passwordSession *atclient.PasswordSessionData 48 51 } 49 52 50 53 type authState struct { 51 54 CurrentDID string `json:"current_did,omitempty"` 52 55 CurrentSession string `json:"current_session,omitempty"` 56 + Method string `json:"method,omitempty"` 53 57 } 54 58 55 59 // ConfigDir returns the configuration directory for tg. ··· 76 80 77 81 store := NewFileStore(filepath.Join(dir, "oauth")) 78 82 manager := &AuthManager{ 79 - App: oauth.NewClientApp(&config, store), 80 - Store: store, 81 - statePath: filepath.Join(dir, "auth.json"), 83 + App: oauth.NewClientApp(&config, store), 84 + Store: store, 85 + statePath: filepath.Join(dir, "auth.json"), 86 + passwordPath: filepath.Join(dir, "password-session.json"), 82 87 } 83 88 if err := manager.loadState(); err != nil { 84 89 return nil, fmt.Errorf("load auth state: %w", err) 85 90 } 91 + if manager.state.Method == "password" { 92 + data, err := os.ReadFile(manager.passwordPath) 93 + if err != nil { 94 + manager.state = authState{} 95 + if err := manager.saveState(); err != nil { 96 + return nil, fmt.Errorf("clear unusable password auth state: %w", err) 97 + } 98 + return manager, nil 99 + } 100 + var session atclient.PasswordSessionData 101 + if err := json.Unmarshal(data, &session); err != nil { 102 + if removeErr := os.Remove(manager.passwordPath); removeErr != nil && !os.IsNotExist(removeErr) { 103 + return nil, fmt.Errorf("remove unusable password session: %w", removeErr) 104 + } 105 + manager.state = authState{} 106 + if err := manager.saveState(); err != nil { 107 + return nil, fmt.Errorf("clear unusable password auth state: %w", err) 108 + } 109 + return manager, nil 110 + } 111 + if session.AccountDID == "" || session.Host == "" || session.RefreshToken == "" { 112 + if err := os.Remove(manager.passwordPath); err != nil && !os.IsNotExist(err) { 113 + return nil, fmt.Errorf("remove incomplete password session: %w", err) 114 + } 115 + manager.state = authState{} 116 + if err := manager.saveState(); err != nil { 117 + return nil, fmt.Errorf("clear incomplete password auth state: %w", err) 118 + } 119 + return manager, nil 120 + } 121 + manager.passwordSession = &session 122 + } 86 123 return manager, nil 87 124 } 88 125 126 + // LoginWithPassword authenticates with an atproto app password and persists 127 + // the resulting access/refresh token pair for subsequent invocations. 128 + func (m *AuthManager) LoginWithPassword(ctx context.Context, identifier, password string) error { 129 + atid, err := syntax.ParseAtIdentifier(identifier) 130 + if err != nil { 131 + return err 132 + } 133 + persistSession := func(_ context.Context, data atclient.PasswordSessionData) { 134 + _ = m.savePasswordSession(&data) 135 + } 136 + client, err := atclient.LoginWithPassword( 137 + ctx, 138 + identity.DefaultDirectory(), 139 + atid, 140 + password, 141 + "", 142 + persistSession, 143 + ) 144 + if err != nil { 145 + return err 146 + } 147 + if client.Auth == nil { 148 + return errors.New("password login returned no auth session") 149 + } 150 + passwordAuth, ok := client.Auth.(*atclient.PasswordAuth) 151 + if !ok { 152 + return errors.New("password login returned an unexpected auth type") 153 + } 154 + if m.IsAuthenticated() { 155 + if err := m.Logout(ctx); err != nil { 156 + return fmt.Errorf("replace current login: %w", err) 157 + } 158 + } 159 + if err := m.savePasswordSession(&passwordAuth.Session); err != nil { 160 + return err 161 + } 162 + m.state = authState{ 163 + CurrentDID: passwordAuth.Session.AccountDID.String(), 164 + Method: "password", 165 + } 166 + return m.saveState() 167 + } 168 + 169 + func (m *AuthManager) savePasswordSession(session *atclient.PasswordSessionData) error { 170 + snapshot := *session 171 + m.passwordSession = &snapshot 172 + if err := os.MkdirAll(filepath.Dir(m.passwordPath), 0o700); err != nil { 173 + return err 174 + } 175 + data, err := json.MarshalIndent(&snapshot, "", " ") 176 + if err != nil { 177 + return err 178 + } 179 + return os.WriteFile(m.passwordPath, data, 0o600) 180 + } 181 + 89 182 func (m *AuthManager) StartLogin(ctx context.Context, identifier string) (string, error) { 90 183 return m.App.StartAuthFlow(ctx, identifier) 91 184 } ··· 95 188 if err != nil { 96 189 return err 97 190 } 191 + if m.IsAuthenticated() { 192 + if err := m.Logout(ctx); err != nil { 193 + return fmt.Errorf("replace current login: %w", err) 194 + } 195 + } 98 196 99 - m.state.CurrentDID = session.AccountDID.String() 100 - m.state.CurrentSession = session.SessionID 197 + return m.activateOAuthSession(session.AccountDID.String(), session.SessionID) 198 + } 199 + 200 + func (m *AuthManager) activateOAuthSession(did, sessionID string) error { 201 + if err := os.Remove(m.passwordPath); err != nil && !os.IsNotExist(err) { 202 + return fmt.Errorf("remove previous password session: %w", err) 203 + } 204 + m.passwordSession = nil 205 + m.state = authState{ 206 + CurrentDID: did, 207 + CurrentSession: sessionID, 208 + Method: "oauth", 209 + } 101 210 return m.saveState() 102 211 } 103 212 ··· 113 222 } 114 223 115 224 func (m *AuthManager) IsAuthenticated() bool { 116 - return m.state.CurrentDID != "" && m.state.CurrentSession != "" 225 + return m.state.CurrentDID != "" && (m.state.CurrentSession != "" || m.passwordSession != nil) 117 226 } 118 227 119 228 func (m *AuthManager) CurrentSession(ctx context.Context) (*oauth.ClientSession, error) { ··· 124 233 } 125 234 126 235 func (m *AuthManager) APIClient(ctx context.Context) (*atclient.APIClient, error) { 236 + if m.state.Method == "password" && m.passwordSession != nil { 237 + persistSession := func(_ context.Context, data atclient.PasswordSessionData) { 238 + _ = m.savePasswordSession(&data) 239 + } 240 + return atclient.ResumePasswordSession(*m.passwordSession, persistSession), nil 241 + } 127 242 session, err := m.CurrentSession(ctx) 128 243 if err != nil { 129 244 return nil, err ··· 135 250 if !m.IsAuthenticated() { 136 251 return nil 137 252 } 253 + if m.state.Method == "password" { 254 + client := atclient.ResumePasswordSession(*m.passwordSession, nil) 255 + passwordAuth, ok := client.Auth.(*atclient.PasswordAuth) 256 + if !ok { 257 + return errors.New("password session has an unexpected auth type") 258 + } 259 + if err := passwordAuth.Logout(ctx, client.Client); err != nil { 260 + return fmt.Errorf("revoke password session: %w", err) 261 + } 262 + if err := os.Remove(m.passwordPath); err != nil && !os.IsNotExist(err) { 263 + return fmt.Errorf("remove password session: %w", err) 264 + } 265 + m.passwordSession = nil 266 + m.state = authState{} 267 + return m.saveState() 268 + } 138 269 if err := m.App.Logout(ctx, m.CurrentDID(), m.state.CurrentSession); err != nil { 139 270 return err 140 271 }
+128
atproto/auth_test.go
··· 1 + package atproto 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "net/http" 7 + "net/http/httptest" 8 + "os" 9 + "path/filepath" 10 + "testing" 11 + 12 + "github.com/bluesky-social/indigo/atproto/atclient" 13 + "github.com/bluesky-social/indigo/atproto/syntax" 14 + ) 15 + 16 + func TestActivateOAuthSessionClearsPasswordAuth(t *testing.T) { 17 + dir := t.TempDir() 18 + m, err := NewAuthManager("http://127.0.0.1/callback", dir) 19 + if err != nil { 20 + t.Fatal(err) 21 + } 22 + did := syntax.DID("did:plc:password") 23 + if err := m.savePasswordSession(&atclient.PasswordSessionData{AccountDID: did, AccessToken: "access", RefreshToken: "refresh", Host: "https://pds.example"}); err != nil { 24 + t.Fatal(err) 25 + } 26 + m.state = authState{CurrentDID: did.String(), Method: "password"} 27 + 28 + if err := m.activateOAuthSession("did:plc:oauth", "oauth-session"); err != nil { 29 + t.Fatal(err) 30 + } 31 + if m.state.Method != "oauth" || m.state.CurrentDID != "did:plc:oauth" || m.state.CurrentSession != "oauth-session" { 32 + t.Fatalf("unexpected OAuth state: %+v", m.state) 33 + } 34 + if m.passwordSession != nil { 35 + t.Fatal("password session remained loaded") 36 + } 37 + if _, err := os.Stat(m.passwordPath); !os.IsNotExist(err) { 38 + t.Fatalf("password session file was not removed: %v", err) 39 + } 40 + } 41 + 42 + func TestNewAuthManagerRecoversFromMissingPasswordSession(t *testing.T) { 43 + dir := t.TempDir() 44 + data, _ := json.Marshal(authState{CurrentDID: "did:plc:stale", Method: "password"}) 45 + if err := os.WriteFile(filepath.Join(dir, "auth.json"), data, 0o600); err != nil { 46 + t.Fatal(err) 47 + } 48 + m, err := NewAuthManager("http://127.0.0.1/callback", dir) 49 + if err != nil { 50 + t.Fatal(err) 51 + } 52 + if m.IsAuthenticated() { 53 + t.Fatal("stale password state should be cleared") 54 + } 55 + } 56 + 57 + func TestNewAuthManagerRecoversFromCorruptPasswordSession(t *testing.T) { 58 + dir := t.TempDir() 59 + state, _ := json.Marshal(authState{CurrentDID: "did:plc:stale", Method: "password"}) 60 + if err := os.WriteFile(filepath.Join(dir, "auth.json"), state, 0o600); err != nil { 61 + t.Fatal(err) 62 + } 63 + passwordPath := filepath.Join(dir, "password-session.json") 64 + if err := os.WriteFile(passwordPath, []byte("not json"), 0o600); err != nil { 65 + t.Fatal(err) 66 + } 67 + m, err := NewAuthManager("http://127.0.0.1/callback", dir) 68 + if err != nil { 69 + t.Fatal(err) 70 + } 71 + if m.IsAuthenticated() { 72 + t.Fatal("corrupt password state should be cleared") 73 + } 74 + if _, err := os.Stat(passwordPath); !os.IsNotExist(err) { 75 + t.Fatalf("corrupt password session was not removed: %v", err) 76 + } 77 + } 78 + 79 + func TestNewAuthManagerRecoversFromIncompletePasswordSession(t *testing.T) { 80 + dir := t.TempDir() 81 + state, _ := json.Marshal(authState{CurrentDID: "did:plc:stale", Method: "password"}) 82 + if err := os.WriteFile(filepath.Join(dir, "auth.json"), state, 0o600); err != nil { 83 + t.Fatal(err) 84 + } 85 + session, _ := json.Marshal(atclient.PasswordSessionData{AccountDID: syntax.DID("did:plc:stale")}) 86 + passwordPath := filepath.Join(dir, "password-session.json") 87 + if err := os.WriteFile(passwordPath, session, 0o600); err != nil { 88 + t.Fatal(err) 89 + } 90 + m, err := NewAuthManager("http://127.0.0.1/callback", dir) 91 + if err != nil { 92 + t.Fatal(err) 93 + } 94 + if m.IsAuthenticated() { 95 + t.Fatal("incomplete password state should be cleared") 96 + } 97 + } 98 + 99 + func TestPasswordLogoutRevokesAndRemovesSession(t *testing.T) { 100 + var authorization string 101 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 102 + if r.URL.Path != "/xrpc/com.atproto.server.deleteSession" { 103 + t.Errorf("unexpected path %q", r.URL.Path) 104 + } 105 + authorization = r.Header.Get("Authorization") 106 + w.WriteHeader(http.StatusOK) 107 + })) 108 + defer server.Close() 109 + 110 + dir := t.TempDir() 111 + did := syntax.DID("did:plc:test") 112 + m := &AuthManager{statePath: filepath.Join(dir, "auth.json"), passwordPath: filepath.Join(dir, "password-session.json"), state: authState{CurrentDID: did.String(), Method: "password"}} 113 + if err := m.savePasswordSession(&atclient.PasswordSessionData{AccountDID: did, AccessToken: "access", RefreshToken: "refresh", Host: server.URL}); err != nil { 114 + t.Fatal(err) 115 + } 116 + if err := m.Logout(context.Background()); err != nil { 117 + t.Fatal(err) 118 + } 119 + if authorization != "Bearer refresh" { 120 + t.Fatalf("authorization = %q", authorization) 121 + } 122 + if m.IsAuthenticated() { 123 + t.Fatal("manager remained authenticated") 124 + } 125 + if _, err := os.Stat(m.passwordPath); !os.IsNotExist(err) { 126 + t.Fatalf("password session file was not removed: %v", err) 127 + } 128 + }
+43 -9
internal/cli/auth_login.go
··· 3 3 import ( 4 4 "context" 5 5 "fmt" 6 + "io" 6 7 "net/http" 7 8 "os/exec" 8 9 "runtime" 10 + "strings" 9 11 10 12 "github.com/spf13/cobra" 11 13 ) 12 14 15 + var authLoginPasswordStdin bool 16 + 13 17 var authLoginCmd = &cobra.Command{ 14 - Use: "login [handle]", 15 - Short: "Log in to atproto via OAuth", 16 - Long: `Log in to atproto via OAuth using a local browser callback.`, 17 - Args: cobra.MaximumNArgs(1), 18 + Use: "login <handle> [app-password]", 19 + Short: "Log in to atproto via OAuth or an app password", 20 + Long: `Log in with OAuth, or use an app password as the second argument for headless login.`, 21 + Args: cobra.RangeArgs(1, 2), 18 22 RunE: func(cmd *cobra.Command, args []string) error { 19 23 if auth == nil { 20 24 return fmt.Errorf("auth is not available") 21 25 } 22 26 23 - identifier := "" 24 - if len(args) == 1 { 25 - identifier = args[0] 27 + identifier := args[0] 28 + password, usePassword, err := loginPassword(args, authLoginPasswordStdin, cmd.InOrStdin()) 29 + if err != nil { 30 + return err 26 31 } 27 - if identifier == "" { 28 - return fmt.Errorf("handle or DID required") 32 + if usePassword { 33 + if err := auth.LoginWithPassword(cmd.Context(), identifier, password); err != nil { 34 + return err 35 + } 36 + fmt.Printf("Logged in as %s\n", auth.CurrentDID()) 37 + return nil 29 38 } 30 39 31 40 server, resultChannel, err := runCallbackServer() ··· 58 67 }, 59 68 } 60 69 70 + func init() { 71 + authLoginCmd.Flags().BoolVar(&authLoginPasswordStdin, "password-stdin", false, "Read the app password from standard input") 72 + } 73 + 74 + func loginPassword(args []string, fromStdin bool, stdin io.Reader) (string, bool, error) { 75 + if !fromStdin { 76 + if len(args) < 2 { 77 + return "", false, nil 78 + } 79 + return args[1], true, nil 80 + } 81 + if len(args) == 2 { 82 + return "", false, fmt.Errorf("app password argument and --password-stdin cannot be used together") 83 + } 84 + data, err := io.ReadAll(stdin) 85 + if err != nil { 86 + return "", false, fmt.Errorf("read app password from stdin: %w", err) 87 + } 88 + password := strings.TrimSpace(string(data)) 89 + if password == "" { 90 + return "", false, fmt.Errorf("app password from stdin is empty") 91 + } 92 + return password, true, nil 93 + } 94 + 61 95 // runCallbackServer starts the local HTTP server that receives the OAuth 62 96 // redirect after the user approves the login in their browser. 63 97 func runCallbackServer() (*http.Server, <-chan error, error) {
+36
internal/cli/auth_login_test.go
··· 1 + package cli 2 + 3 + import ( 4 + "strings" 5 + "testing" 6 + ) 7 + 8 + func TestLoginPassword(t *testing.T) { 9 + tests := []struct { 10 + name string 11 + args []string 12 + stdinFlag bool 13 + stdin string 14 + want string 15 + wantUse bool 16 + wantErr bool 17 + }{ 18 + {"oauth", []string{"alice.example"}, false, "", "", false, false}, 19 + {"argument", []string{"alice.example", "app-pass"}, false, "", "app-pass", true, false}, 20 + {"stdin", []string{"alice.example"}, true, "app-pass\n", "app-pass", true, false}, 21 + {"both", []string{"alice.example", "app-pass"}, true, "other", "", false, true}, 22 + {"empty stdin", []string{"alice.example"}, true, "\n", "", false, true}, 23 + } 24 + 25 + for _, tt := range tests { 26 + t.Run(tt.name, func(t *testing.T) { 27 + got, use, err := loginPassword(tt.args, tt.stdinFlag, strings.NewReader(tt.stdin)) 28 + if (err != nil) != tt.wantErr { 29 + t.Fatalf("error = %v, wantErr %v", err, tt.wantErr) 30 + } 31 + if got != tt.want || use != tt.wantUse { 32 + t.Fatalf("got (%q, %v), want (%q, %v)", got, use, tt.want, tt.wantUse) 33 + } 34 + }) 35 + } 36 + }
+17 -1
internal/cli/auth_token.go
··· 3 3 import ( 4 4 "fmt" 5 5 6 + "github.com/bluesky-social/indigo/atproto/atclient" 6 7 "github.com/spf13/cobra" 7 8 ) 8 9 9 10 var authTokenCmd = &cobra.Command{ 10 11 Use: "token", 11 - Short: "Print the current OAuth access token", 12 + Short: "Print the current access token", 12 13 Args: cobra.NoArgs, 13 14 RunE: func(cmd *cobra.Command, _ []string) error { 14 15 if auth == nil || !auth.IsAuthenticated() { 15 16 return fmt.Errorf("not logged in; run \"tg auth login\" first") 16 17 } 17 18 19 + if auth.CurrentDID().String() != "" { 20 + client, err := auth.APIClient(cmd.Context()) 21 + if err != nil { 22 + return fmt.Errorf("resume auth session: %w", err) 23 + } 24 + if passwordAuth, ok := client.Auth.(*atclient.PasswordAuth); ok { 25 + token, _ := passwordAuth.GetTokens() 26 + if token == "" { 27 + return fmt.Errorf("current session has no access token") 28 + } 29 + fmt.Fprintln(cmd.OutOrStdout(), token) 30 + return nil 31 + } 32 + } 33 + 18 34 session, err := auth.CurrentSession(cmd.Context()) 19 35 if err != nil { 20 36 return fmt.Errorf("resume OAuth session: %w", err)
+62 -44
internal/cli/pr_create.go
··· 16 16 const patchMimeType = "application/gzip" 17 17 18 18 var ( 19 - prCreateTitle string 20 - prCreateBody string 21 - prCreateBodyFile string 22 - prCreateBase string 23 - prCreateHead string 24 - prCreateRepo string 19 + prCreateTitle string 20 + prCreateBody string 21 + prCreateBodyFile string 22 + prCreateBase string 23 + prCreateHead string 24 + prCreateRepo string 25 + prCreateSourceRepo string 25 26 ) 26 27 27 28 var prCreateCmd = &cobra.Command{ 28 29 Use: "create", 29 30 Short: "Create a pull request from the current branch", 30 31 Long: "Create a pull request by uploading a gzipped git patch and writing a sh.tangled.repo.pull record. " + 31 - "The source and target repository are the same. By default, the current branch is the source and " + 32 - "origin's default branch is the target. Use --repo to target a different Tangled repository.", 32 + "By default, the current repository and branch are both the source and target repository, and origin's " + 33 + "default branch is the target branch. Use --repo and --source-repo for a fork-based pull request.", 33 34 Args: cobra.NoArgs, 34 35 RunE: func(cmd *cobra.Command, args []string) error { 35 36 ctx := cmd.Context() ··· 69 70 if !strings.HasPrefix(target.URI, "at://") { 70 71 return fmt.Errorf("target repository %q has no strong at:// URI", repo) 71 72 } 73 + source := target 74 + if prCreateSourceRepo != "" { 75 + sourceHandle, sourceName, err := parseHandleRepo(prCreateSourceRepo) 76 + if err != nil { 77 + return err 78 + } 79 + source, err = resolveRepoRecord(ctx, sourceHandle, sourceName) 80 + if err != nil { 81 + return fmt.Errorf("resolve source repository: %w", err) 82 + } 83 + } 84 + if source.Value.RepoDid == "" { 85 + return fmt.Errorf("source repository has no repo DID") 86 + } 72 87 73 88 patch, err := gitutil.GeneratePatch(ctx, repoDir, base, head) 74 89 if err != nil { ··· 85 100 } 86 101 87 102 uri, err := createPullRecord(ctx, atClient, auth.CurrentDID().String(), prCreateRecord{ 88 - Title: prCreateTitle, 89 - Body: body, 90 - RepoDid: target.Value.RepoDid, 91 - Base: base, 92 - Head: head, 93 - Patch: blob, 103 + Title: prCreateTitle, 104 + Body: body, 105 + TargetRepoDid: target.Value.RepoDid, 106 + SourceRepoDid: source.Value.RepoDid, 107 + Base: base, 108 + Head: head, 109 + Patch: blob, 94 110 }) 95 111 if err != nil { 96 112 return err ··· 109 125 prCreateCmd.Flags().StringVarP(&prCreateBase, "base", "B", "", "Target branch (default: origin's default branch)") 110 126 prCreateCmd.Flags().StringVarP(&prCreateHead, "head", "H", "", "Source branch (default: current branch)") 111 127 prCreateCmd.Flags().StringVarP(&prCreateRepo, "repo", "R", "", "Target repository as handle/repo") 128 + prCreateCmd.Flags().StringVar(&prCreateSourceRepo, "source-repo", "", "Source repository as handle/repo (for fork-based pull requests)") 112 129 prCreateCmd.MarkFlagRequired("title") 113 130 } 114 131 115 132 type prCreateRecord struct { 116 - Title string 117 - Body string 118 - RepoDid string 119 - Base string 120 - Head string 121 - Patch *atproto.Blob 133 + Title string 134 + Body string 135 + TargetRepoDid string 136 + SourceRepoDid string 137 + Base string 138 + Head string 139 + Patch *atproto.Blob 122 140 } 123 141 124 142 // pullRecord is the sh.tangled.repo.pull lexicon shape used for record writes. ··· 133 151 } 134 152 135 153 type pullTarget struct { 136 - Repo string `json:"repo"` 137 - RepoDid string `json:"repoDid"` 138 - Branch string `json:"branch"` 154 + Repo string `json:"repo"` 155 + Branch string `json:"branch"` 139 156 } 140 157 141 158 type pullSource struct { 142 - Repo string `json:"repo"` 143 - RepoDid string `json:"repoDid"` 144 - Branch string `json:"branch"` 159 + Repo string `json:"repo,omitempty"` 160 + Branch string `json:"branch"` 145 161 } 146 162 147 163 type pullRound struct { ··· 179 195 } 180 196 181 197 func createPullRecord(ctx context.Context, atClient *atproto.ATProto, did string, input prCreateRecord) (string, error) { 182 - now := time.Now().UTC().Format(time.RFC3339) 183 - record := pullRecord{ 198 + record := newPullRecord(input, time.Now().UTC()) 199 + uri, _, err := atClient.PutRecord(ctx, atproto.PutRecordInput{ 200 + Repo: did, 201 + Collection: "sh.tangled.repo.pull", 202 + Rkey: string(syntax.NewTIDNow(0)), 203 + Record: record, 204 + }) 205 + if err != nil { 206 + return "", fmt.Errorf("create pull request record: %w", err) 207 + } 208 + return uri, nil 209 + } 210 + 211 + func newPullRecord(input prCreateRecord, createdAt time.Time) pullRecord { 212 + now := createdAt.Format(time.RFC3339) 213 + return pullRecord{ 184 214 Type: "sh.tangled.repo.pull", 185 215 Title: input.Title, 186 216 Body: input.Body, 187 217 CreatedAt: now, 188 218 Target: pullTarget{ 189 - Repo: input.RepoDid, 190 - RepoDid: input.RepoDid, 191 - Branch: input.Base, 219 + Repo: input.TargetRepoDid, 220 + Branch: input.Base, 192 221 }, 193 222 Source: pullSource{ 194 - Repo: input.RepoDid, 195 - RepoDid: input.RepoDid, 196 - Branch: input.Head, 223 + Repo: input.SourceRepoDid, 224 + Branch: input.Head, 197 225 }, 198 226 Rounds: []pullRound{{ 199 227 CreatedAt: now, 200 228 PatchBlob: input.Patch, 201 229 }}, 202 230 } 203 - uri, _, err := atClient.PutRecord(ctx, atproto.PutRecordInput{ 204 - Repo: did, 205 - Collection: "sh.tangled.repo.pull", 206 - Rkey: string(syntax.NewTIDNow(0)), 207 - Record: record, 208 - }) 209 - if err != nil { 210 - return "", fmt.Errorf("create pull request record: %w", err) 211 - } 212 - return uri, nil 213 231 }
+26
internal/cli/pr_create_test.go
··· 1 + package cli 2 + 3 + import ( 4 + "testing" 5 + "time" 6 + 7 + "github.com/alyraffauf/tg/atproto" 8 + ) 9 + 10 + func TestNewPullRecordUsesDistinctSourceAndTarget(t *testing.T) { 11 + record := newPullRecord(prCreateRecord{ 12 + Title: "Cross-repo change", 13 + TargetRepoDid: "did:plc:upstream", 14 + SourceRepoDid: "did:plc:fork", 15 + Base: "main", 16 + Head: "feature", 17 + Patch: &atproto.Blob{}, 18 + }, time.Date(2026, 7, 17, 0, 0, 0, 0, time.UTC)) 19 + 20 + if record.Target.Repo != "did:plc:upstream" { 21 + t.Fatalf("unexpected target: %+v", record.Target) 22 + } 23 + if record.Source.Repo != "did:plc:fork" { 24 + t.Fatalf("unexpected source: %+v", record.Source) 25 + } 26 + }
+18 -4
internal/cli/repo_fork.go
··· 3 3 import ( 4 4 "context" 5 5 "fmt" 6 + "strings" 6 7 "time" 7 8 8 9 "github.com/alyraffauf/tg/atproto" ··· 48 49 repoDID, err := knot.New(source.Knot, token).CreateRepo(ctx, knot.CreateRepoInput{ 49 50 Name: name, 50 51 Rkey: name, 51 - Source: source.URI, 52 + Source: forkSourceURL(source.Knot, source.RepoDID), 52 53 }) 53 54 if err != nil { 54 55 return err ··· 63 64 Knot: source.Knot, 64 65 CreatedAt: time.Now().UTC().Format(time.RFC3339), 65 66 RepoDid: repoDID, 67 + Source: source.URI, 66 68 }, 67 69 }) 68 70 if err != nil { ··· 91 93 } 92 94 93 95 type forkSource struct { 94 - URI string 95 - Knot string 96 + URI string 97 + Knot string 98 + RepoDID string 99 + } 100 + 101 + func forkSourceURL(knotHost, repoDID string) string { 102 + base := strings.TrimRight(knotHost, "/") 103 + if !strings.HasPrefix(base, "http://") && !strings.HasPrefix(base, "https://") { 104 + base = "https://" + base 105 + } 106 + return base + "/" + repoDID 96 107 } 97 108 98 109 func getForkSource(ctx context.Context, handle, name string) (forkSource, error) { ··· 108 119 if repo.Value.Knot == "" { 109 120 return forkSource{}, fmt.Errorf("source repository %s/%s has no knot", handle, name) 110 121 } 122 + if repo.Value.RepoDid == "" { 123 + return forkSource{}, fmt.Errorf("source repository %s/%s has no repo DID", handle, name) 124 + } 111 125 if repo.URI != "" { 112 126 uri = repo.URI 113 127 } 114 - return forkSource{URI: uri, Knot: repo.Value.Knot}, nil 128 + return forkSource{URI: uri, Knot: repo.Value.Knot, RepoDID: repo.Value.RepoDid}, nil 115 129 } 116 130 117 131 type repoForkResult struct {
+24
internal/cli/repo_fork_test.go
··· 1 + package cli 2 + 3 + import "testing" 4 + 5 + func TestForkSourceURL(t *testing.T) { 6 + tests := []struct { 7 + name string 8 + knot string 9 + repoDID string 10 + want string 11 + }{ 12 + {"bare host", "knot.gaze.systems", "did:plc:abc", "https://knot.gaze.systems/did:plc:abc"}, 13 + {"https host", "https://knot.gaze.systems", "did:plc:abc", "https://knot.gaze.systems/did:plc:abc"}, 14 + {"trailing slash", "https://knot.gaze.systems/", "did:plc:abc", "https://knot.gaze.systems/did:plc:abc"}, 15 + } 16 + 17 + for _, tt := range tests { 18 + t.Run(tt.name, func(t *testing.T) { 19 + if got := forkSourceURL(tt.knot, tt.repoDID); got != tt.want { 20 + t.Fatalf("forkSourceURL() = %q, want %q", got, tt.want) 21 + } 22 + }) 23 + } 24 + }
+1
tangled/get_repo.go
··· 17 17 Owner string `json:"owner,omitempty"` 18 18 AddedAt string `json:"addedAt,omitempty"` 19 19 RepoDid string `json:"repoDid,omitempty"` 20 + Source string `json:"source,omitempty"` 20 21 Spindle string `json:"spindle,omitempty"` 21 22 Website string `json:"website,omitempty"` 22 23 Labels []string `json:"labels,omitempty"`

History

2 rounds 2 comments
Sign up or Login to add to the discussion
1 commit
Expand
56724462
README, atproto, internal/cli, tangled: add app-password auth and fix repository forking
Expand 1 comment

ty ana!

Pull request successfully merged
3 commits
Expand
922e0d4d
Add app-password authentication
2083405c
Fix fork source URLs
29e06b36
Harden auth and fork PR handling
Expand 1 comment

human note: i did review the code and I stand by it