From 36ca991b1d76fc1bf13e7c5e454d12c09e50ed81 Mon Sep 17 00:00:00 2001 From: Aly Raffauf Date: Thu, 30 Jul 2026 23:34:04 -0400 Subject: [PATCH] app: split operations into per-command files --- internal/app/auth.go | 174 +---- internal/app/auth_git_credential.go | 43 ++ internal/app/auth_list.go | 27 + internal/app/auth_login.go | 27 + internal/app/auth_logout.go | 26 + internal/app/auth_status.go | 28 + internal/app/auth_switch.go | 18 + internal/app/auth_token.go | 42 ++ internal/app/{types.go => domain_types.go} | 0 internal/app/issue_comment.go | 54 ++ internal/app/issue_create.go | 40 ++ internal/app/issue_edit.go | 13 + internal/app/issue_list.go | 23 + internal/app/issue_state.go | 20 + internal/app/issue_view.go | 38 + internal/app/issues.go | 188 ----- internal/app/items.go | 33 + internal/app/pipeline.go | 60 ++ internal/app/pipeline_cancel.go | 73 ++ internal/app/pipeline_list.go | 32 + internal/app/pipeline_status.go | 77 ++ internal/app/pipeline_trigger.go | 70 ++ internal/app/pipeline_view.go | 23 + internal/app/pipelines.go | 300 -------- internal/app/pull_checkout.go | 65 ++ internal/app/pull_comment.go | 54 ++ internal/app/pull_create.go | 160 +++++ internal/app/pull_edit.go | 13 + internal/app/pull_list.go | 23 + internal/app/pull_merge.go | 52 ++ internal/app/pull_patch.go | 114 +++ internal/app/pull_state.go | 20 + internal/app/pull_update.go | 51 ++ internal/app/pull_view.go | 40 ++ internal/app/pulls.go | 484 ------------- internal/app/repo.go | 54 ++ internal/app/{git.go => repo_clone.go} | 57 -- internal/app/repo_create.go | 239 +++++++ internal/app/repo_delete.go | 71 ++ internal/app/repo_edit.go | 102 +++ internal/app/repo_fork.go | 111 +++ internal/app/repo_list.go | 76 ++ internal/app/repo_set_default_branch.go | 38 + internal/app/repo_view.go | 24 + internal/app/repos.go | 660 ------------------ .../app/{git_test.go => repos_clone_test.go} | 0 internal/app/sshkey_add.go | 34 + internal/app/sshkey_delete.go | 24 + internal/app/sshkey_list.go | 44 ++ internal/app/sshkeys.go | 85 --- internal/app/string_create.go | 43 ++ internal/app/string_delete.go | 24 + internal/app/string_list.go | 50 ++ internal/app/string_view.go | 46 ++ internal/app/strings.go | 137 ---- 55 files changed, 2345 insertions(+), 2079 deletions(-) create mode 100644 internal/app/auth_git_credential.go create mode 100644 internal/app/auth_list.go create mode 100644 internal/app/auth_login.go create mode 100644 internal/app/auth_logout.go create mode 100644 internal/app/auth_status.go create mode 100644 internal/app/auth_switch.go create mode 100644 internal/app/auth_token.go rename internal/app/{types.go => domain_types.go} (100%) create mode 100644 internal/app/issue_comment.go create mode 100644 internal/app/issue_create.go create mode 100644 internal/app/issue_edit.go create mode 100644 internal/app/issue_list.go create mode 100644 internal/app/issue_state.go create mode 100644 internal/app/issue_view.go delete mode 100644 internal/app/issues.go create mode 100644 internal/app/pipeline.go create mode 100644 internal/app/pipeline_cancel.go create mode 100644 internal/app/pipeline_list.go create mode 100644 internal/app/pipeline_status.go create mode 100644 internal/app/pipeline_trigger.go create mode 100644 internal/app/pipeline_view.go delete mode 100644 internal/app/pipelines.go create mode 100644 internal/app/pull_checkout.go create mode 100644 internal/app/pull_comment.go create mode 100644 internal/app/pull_create.go create mode 100644 internal/app/pull_edit.go create mode 100644 internal/app/pull_list.go create mode 100644 internal/app/pull_merge.go create mode 100644 internal/app/pull_patch.go create mode 100644 internal/app/pull_state.go create mode 100644 internal/app/pull_update.go create mode 100644 internal/app/pull_view.go delete mode 100644 internal/app/pulls.go create mode 100644 internal/app/repo.go rename internal/app/{git.go => repo_clone.go} (54%) create mode 100644 internal/app/repo_create.go create mode 100644 internal/app/repo_delete.go create mode 100644 internal/app/repo_edit.go create mode 100644 internal/app/repo_fork.go create mode 100644 internal/app/repo_list.go create mode 100644 internal/app/repo_set_default_branch.go create mode 100644 internal/app/repo_view.go delete mode 100644 internal/app/repos.go rename internal/app/{git_test.go => repos_clone_test.go} (100%) create mode 100644 internal/app/sshkey_add.go create mode 100644 internal/app/sshkey_delete.go create mode 100644 internal/app/sshkey_list.go delete mode 100644 internal/app/sshkeys.go create mode 100644 internal/app/string_create.go create mode 100644 internal/app/string_delete.go create mode 100644 internal/app/string_list.go create mode 100644 internal/app/string_view.go delete mode 100644 internal/app/strings.go diff --git a/internal/app/auth.go b/internal/app/auth.go index 6df7e3b..6d97268 100644 --- a/internal/app/auth.go +++ b/internal/app/auth.go @@ -4,11 +4,8 @@ import ( "context" "errors" "fmt" - "net/url" - "strings" "github.com/alyraffauf/tg/atproto" - "github.com/bluesky-social/indigo/atproto/atclient" ) var ErrNotAuthenticated = errors.New("not authenticated") @@ -19,10 +16,12 @@ const ( SessionStatusUnknown = atproto.SessionStatusUnknown ) -// LoginWithPassword authenticates an account with an app password. When -// useInsecureFileStore is true, the session is stored in plaintext instead of the keyring. -func (s *Service) LoginWithPassword(ctx context.Context, identifier, password string, useInsecureFileStore bool) error { - return s.auth.LoginWithPassword(ctx, identifier, password, useInsecureFileStore) +func (s *Service) authenticatedPDS(ctx context.Context) (pdsClient, string, error) { + return s.sessions.AuthenticatedPDS(ctx) +} + +func (s *Service) publicPDS(ctx context.Context, handle string) (pdsClient, string, error) { + return s.sessions.PublicPDS(ctx, handle) } // CurrentDID returns the DID for the active account. @@ -34,29 +33,6 @@ func (s *Service) CurrentDID(ctx context.Context) (string, error) { return did.String(), nil } -// StartLogin starts an OAuth login flow. -func (s *Service) StartLogin(ctx context.Context, identifier string) (string, error) { - return s.auth.StartLogin(ctx, identifier) -} - -// FinishLogin completes an OAuth login flow from callback query parameters. -func (s *Service) FinishLogin(ctx context.Context, query url.Values) error { - return s.auth.FinishLogin(ctx, query) -} - -// CancelLogin discards a pending OAuth login flow. -func (s *Service) CancelLogin() { - s.auth.CancelLogin() -} - -func (s *Service) authenticatedPDS(ctx context.Context) (pdsClient, string, error) { - return s.sessions.AuthenticatedPDS(ctx) -} - -func (s *Service) publicPDS(ctx context.Context, handle string) (pdsClient, string, error) { - return s.sessions.PublicPDS(ctx, handle) -} - // HandleOrSelf returns handle when non-empty, otherwise the authenticated // user's handle. func (s *Service) HandleOrSelf(ctx context.Context, handle string) (string, error) { @@ -76,141 +52,3 @@ func (s *Service) HandleOrSelf(ctx context.Context, handle string) (string, erro } return ident.Handle.String(), nil } - -// AuthStatus probes the active session. A missing session is reported as a -// zero AuthStatusResult (Authenticated=false), not an error. -func (s *Service) AuthStatus(ctx context.Context) (*AuthStatusResult, error) { - status, did, err := s.auth.SessionStatus(ctx) - if err != nil { - if errors.Is(err, atproto.ErrNotAuthenticated) { - return &AuthStatusResult{}, nil - } - return nil, fmt.Errorf("check session: %w", err) - } - author := s.resolveAuthor(ctx, did.String()) - return &AuthStatusResult{ - Authenticated: true, - Status: status, - DID: author.DID, - Handle: author.Handle, - }, nil -} - -// AuthAccounts lists all stored accounts, marking the active one. -func (s *Service) AuthAccounts(ctx context.Context) ([]AuthAccountResult, error) { - accounts, activeDID, err := s.auth.Accounts() - if err != nil { - return nil, fmt.Errorf("list accounts: %w", err) - } - results := make([]AuthAccountResult, 0, len(accounts)) - for _, account := range accounts { - handle := account.Handle - resolved := s.resolveAuthor(ctx, account.DID) - if resolved.Handle != account.DID { - handle = resolved.Handle - } - results = append(results, AuthAccountResult{ - Active: account.DID == activeDID, - DID: account.DID, Handle: handle, Method: account.Method, - }) - } - return results, nil -} - -// SwitchAccount selects the active account by handle or DID. -func (s *Service) SwitchAccount(ctx context.Context, selector string) (*AuthAccountResult, error) { - account, err := s.auth.SelectAccount(selector) - if err != nil { - return nil, fmt.Errorf("select account %q: %w", selector, err) - } - resolved := s.resolveAuthor(ctx, account.DID) - return &AuthAccountResult{ - Active: true, DID: account.DID, Handle: resolved.Handle, Method: account.Method, - }, nil -} - -// Logout removes the active account's credentials (or all accounts when all -// is true). A missing session is reported as WasLoggedIn=false, not an error. -func (s *Service) Logout(ctx context.Context, all bool) (*AuthLogoutResult, error) { - var err error - if all { - err = s.auth.LogoutAll(ctx) - } else { - err = s.auth.Logout(ctx) - } - if err != nil { - if errors.Is(err, atproto.ErrNotAuthenticated) { - return &AuthLogoutResult{WasLoggedIn: false}, nil - } - return nil, err - } - return &AuthLogoutResult{WasLoggedIn: true}, nil -} - -// GitPushToken returns credentials only when requestedHost is the current -// repository's recorded Knot. -func (s *Service) GitPushToken(ctx context.Context, requestedHost string) (*GitCredentialResult, error) { - _, repo, err := s.repoFromCWD(ctx) - if err != nil { - return nil, err - } - host, err := parseKnotHostname(repo.Value.Knot) - if err != nil { - return nil, err - } - if !strings.EqualFold(strings.TrimSpace(requestedHost), host) { - return &GitCredentialResult{}, nil - } - hasPushScope, isOAuth, err := s.sessions.OAuthSessionHasScope(ctx, "rpc:sh.tangled.repo.push?aud=*") - if err != nil { - return nil, fmt.Errorf("check OAuth push permission: %w", err) - } - if isOAuth && !hasPushScope { - return nil, fmt.Errorf("your OAuth session does not authorize HTTPS pushes; run \"tg auth login\" again") - } - atClient, did, err := s.authenticatedPDS(ctx) - if err != nil { - return nil, err - } - token, err := atClient.GetServiceAuth(ctx, "did:web:"+host, "sh.tangled.repo.push") - if err != nil { - return nil, fmt.Errorf("mint push token for %q: %w", host, err) - } - return &GitCredentialResult{ - Token: token, - Handle: s.resolveAuthor(ctx, did).Handle, - MatchesRequestedHost: true, - }, nil -} - -// AccessToken returns the current session's access token, whether OAuth or -// app-password. -func (s *Service) AccessToken(ctx context.Context) (string, error) { - session, err := s.auth.CurrentSession(ctx) - if err == nil { - token, _ := session.GetHostAccessData() - if token == "" { - return "", fmt.Errorf("current session has no access token") - } - return token, nil - } - if !errors.Is(err, atproto.ErrNotAuthenticated) { - return "", fmt.Errorf("resume OAuth session: %w", err) - } - client, _, err := s.auth.APIClient(ctx) - if err != nil { - if errors.Is(err, atproto.ErrNotAuthenticated) { - return "", fmt.Errorf("not logged in; run \"tg auth login\" first") - } - return "", fmt.Errorf("resume auth session: %w", err) - } - passwordAuth, ok := client.Auth.(*atclient.PasswordAuth) - if !ok { - return "", fmt.Errorf("not logged in; run \"tg auth login\" first") - } - token, _ := passwordAuth.GetTokens() - if token == "" { - return "", fmt.Errorf("current session has no access token") - } - return token, nil -} diff --git a/internal/app/auth_git_credential.go b/internal/app/auth_git_credential.go new file mode 100644 index 0000000..70970b2 --- /dev/null +++ b/internal/app/auth_git_credential.go @@ -0,0 +1,43 @@ +package app + +import ( + "context" + "fmt" + "strings" +) + +// GitPushToken returns credentials only when requestedHost is the current +// repository's recorded Knot. +func (s *Service) GitPushToken(ctx context.Context, requestedHost string) (*GitCredentialResult, error) { + _, repo, err := s.repoFromCWD(ctx) + if err != nil { + return nil, err + } + host, err := parseKnotHostname(repo.Value.Knot) + if err != nil { + return nil, err + } + if !strings.EqualFold(strings.TrimSpace(requestedHost), host) { + return &GitCredentialResult{}, nil + } + hasPushScope, isOAuth, err := s.sessions.OAuthSessionHasScope(ctx, "rpc:sh.tangled.repo.push?aud=*") + if err != nil { + return nil, fmt.Errorf("check OAuth push permission: %w", err) + } + if isOAuth && !hasPushScope { + return nil, fmt.Errorf("your OAuth session does not authorize HTTPS pushes; run \"tg auth login\" again") + } + atClient, did, err := s.authenticatedPDS(ctx) + if err != nil { + return nil, err + } + token, err := atClient.GetServiceAuth(ctx, "did:web:"+host, "sh.tangled.repo.push") + if err != nil { + return nil, fmt.Errorf("mint push token for %q: %w", host, err) + } + return &GitCredentialResult{ + Token: token, + Handle: s.resolveAuthor(ctx, did).Handle, + MatchesRequestedHost: true, + }, nil +} diff --git a/internal/app/auth_list.go b/internal/app/auth_list.go new file mode 100644 index 0000000..b69acfe --- /dev/null +++ b/internal/app/auth_list.go @@ -0,0 +1,27 @@ +package app + +import ( + "context" + "fmt" +) + +// AuthAccounts lists all stored accounts, marking the active one. +func (s *Service) AuthAccounts(ctx context.Context) ([]AuthAccountResult, error) { + accounts, activeDID, err := s.auth.Accounts() + if err != nil { + return nil, fmt.Errorf("list accounts: %w", err) + } + results := make([]AuthAccountResult, 0, len(accounts)) + for _, account := range accounts { + handle := account.Handle + resolved := s.resolveAuthor(ctx, account.DID) + if resolved.Handle != account.DID { + handle = resolved.Handle + } + results = append(results, AuthAccountResult{ + Active: account.DID == activeDID, + DID: account.DID, Handle: handle, Method: account.Method, + }) + } + return results, nil +} diff --git a/internal/app/auth_login.go b/internal/app/auth_login.go new file mode 100644 index 0000000..ea1964b --- /dev/null +++ b/internal/app/auth_login.go @@ -0,0 +1,27 @@ +package app + +import ( + "context" + "net/url" +) + +// LoginWithPassword authenticates an account with an app password. When +// useInsecureFileStore is true, the session is stored in plaintext instead of the keyring. +func (s *Service) LoginWithPassword(ctx context.Context, identifier, password string, useInsecureFileStore bool) error { + return s.auth.LoginWithPassword(ctx, identifier, password, useInsecureFileStore) +} + +// StartLogin starts an OAuth login flow. +func (s *Service) StartLogin(ctx context.Context, identifier string) (string, error) { + return s.auth.StartLogin(ctx, identifier) +} + +// FinishLogin completes an OAuth login flow from callback query parameters. +func (s *Service) FinishLogin(ctx context.Context, query url.Values) error { + return s.auth.FinishLogin(ctx, query) +} + +// CancelLogin discards a pending OAuth login flow. +func (s *Service) CancelLogin() { + s.auth.CancelLogin() +} diff --git a/internal/app/auth_logout.go b/internal/app/auth_logout.go new file mode 100644 index 0000000..59de67b --- /dev/null +++ b/internal/app/auth_logout.go @@ -0,0 +1,26 @@ +package app + +import ( + "context" + "errors" + + "github.com/alyraffauf/tg/atproto" +) + +// Logout removes the active account's credentials (or all accounts when all +// is true). A missing session is reported as WasLoggedIn=false, not an error. +func (s *Service) Logout(ctx context.Context, all bool) (*AuthLogoutResult, error) { + var err error + if all { + err = s.auth.LogoutAll(ctx) + } else { + err = s.auth.Logout(ctx) + } + if err != nil { + if errors.Is(err, atproto.ErrNotAuthenticated) { + return &AuthLogoutResult{WasLoggedIn: false}, nil + } + return nil, err + } + return &AuthLogoutResult{WasLoggedIn: true}, nil +} diff --git a/internal/app/auth_status.go b/internal/app/auth_status.go new file mode 100644 index 0000000..cc84714 --- /dev/null +++ b/internal/app/auth_status.go @@ -0,0 +1,28 @@ +package app + +import ( + "context" + "errors" + "fmt" + + "github.com/alyraffauf/tg/atproto" +) + +// AuthStatus probes the active session. A missing session is reported as a +// zero AuthStatusResult (Authenticated=false), not an error. +func (s *Service) AuthStatus(ctx context.Context) (*AuthStatusResult, error) { + status, did, err := s.auth.SessionStatus(ctx) + if err != nil { + if errors.Is(err, atproto.ErrNotAuthenticated) { + return &AuthStatusResult{}, nil + } + return nil, fmt.Errorf("check session: %w", err) + } + author := s.resolveAuthor(ctx, did.String()) + return &AuthStatusResult{ + Authenticated: true, + Status: status, + DID: author.DID, + Handle: author.Handle, + }, nil +} diff --git a/internal/app/auth_switch.go b/internal/app/auth_switch.go new file mode 100644 index 0000000..5c95370 --- /dev/null +++ b/internal/app/auth_switch.go @@ -0,0 +1,18 @@ +package app + +import ( + "context" + "fmt" +) + +// SwitchAccount selects the active account by handle or DID. +func (s *Service) SwitchAccount(ctx context.Context, selector string) (*AuthAccountResult, error) { + account, err := s.auth.SelectAccount(selector) + if err != nil { + return nil, fmt.Errorf("select account %q: %w", selector, err) + } + resolved := s.resolveAuthor(ctx, account.DID) + return &AuthAccountResult{ + Active: true, DID: account.DID, Handle: resolved.Handle, Method: account.Method, + }, nil +} diff --git a/internal/app/auth_token.go b/internal/app/auth_token.go new file mode 100644 index 0000000..0669444 --- /dev/null +++ b/internal/app/auth_token.go @@ -0,0 +1,42 @@ +package app + +import ( + "context" + "errors" + "fmt" + + "github.com/alyraffauf/tg/atproto" + "github.com/bluesky-social/indigo/atproto/atclient" +) + +// AccessToken returns the current session's access token, whether OAuth or +// app-password. +func (s *Service) AccessToken(ctx context.Context) (string, error) { + session, err := s.auth.CurrentSession(ctx) + if err == nil { + token, _ := session.GetHostAccessData() + if token == "" { + return "", fmt.Errorf("current session has no access token") + } + return token, nil + } + if !errors.Is(err, atproto.ErrNotAuthenticated) { + return "", fmt.Errorf("resume OAuth session: %w", err) + } + client, _, err := s.auth.APIClient(ctx) + if err != nil { + if errors.Is(err, atproto.ErrNotAuthenticated) { + return "", fmt.Errorf("not logged in; run \"tg auth login\" first") + } + return "", fmt.Errorf("resume auth session: %w", err) + } + passwordAuth, ok := client.Auth.(*atclient.PasswordAuth) + if !ok { + return "", fmt.Errorf("not logged in; run \"tg auth login\" first") + } + token, _ := passwordAuth.GetTokens() + if token == "" { + return "", fmt.Errorf("current session has no access token") + } + return token, nil +} diff --git a/internal/app/types.go b/internal/app/domain_types.go similarity index 100% rename from internal/app/types.go rename to internal/app/domain_types.go diff --git a/internal/app/issue_comment.go b/internal/app/issue_comment.go new file mode 100644 index 0000000..60869d2 --- /dev/null +++ b/internal/app/issue_comment.go @@ -0,0 +1,54 @@ +package app + +import ( + "context" + "fmt" + "time" + + "github.com/alyraffauf/tg/atproto" + "github.com/alyraffauf/tg/internal/tangledlex" + "github.com/alyraffauf/tg/tangled" + "github.com/bluesky-social/indigo/atproto/syntax" +) + +// CommentIssue adds a comment to the issue identified by rkey. +func (s *Service) CommentIssue(ctx context.Context, t Target, rkey, body string) (*CreatedRecordResult, error) { + repoDid, err := s.repoDID(ctx, t) + if err != nil { + return nil, err + } + issues, err := s.appview.ListIssues(ctx, repoDid, tangled.ListOpts{ + Limit: defaultListLimit, + }) + if err != nil { + return nil, fmt.Errorf("list issues for %s: %w", t, err) + } + issue, err := findByRKey(issues.Items, rkey, "issue") + if err != nil { + return nil, err + } + return s.createIssueComment(ctx, issue.URI, body) +} + +func (s *Service) createIssueComment(ctx context.Context, issueURI, body string) (*CreatedRecordResult, error) { + atClient, did, err := s.authenticatedPDS(ctx) + if err != nil { + return nil, err + } + rkey := string(syntax.NewTIDNow(0)) + uri, _, err := atClient.PutRecord(ctx, atproto.PutRecordInput{ + Repo: did, + Collection: issueCollection + ".comment", + Rkey: rkey, + Record: tangledlex.RepoIssueComment{ + LexiconTypeID: issueCollection + ".comment", + Issue: issueURI, + Body: body, + CreatedAt: time.Now().UTC().Format(time.RFC3339), + }, + }) + if err != nil { + return nil, fmt.Errorf("create issue comment: %w", err) + } + return &CreatedRecordResult{Rkey: rkey, URI: uri}, nil +} diff --git a/internal/app/issue_create.go b/internal/app/issue_create.go new file mode 100644 index 0000000..719c875 --- /dev/null +++ b/internal/app/issue_create.go @@ -0,0 +1,40 @@ +package app + +import ( + "context" + "fmt" + "time" + + "github.com/alyraffauf/tg/atproto" + "github.com/alyraffauf/tg/internal/tangledlex" + "github.com/bluesky-social/indigo/atproto/syntax" +) + +// CreateIssue writes a new issue record in the target repository. +func (s *Service) CreateIssue(ctx context.Context, t Target, title, body string) (*CreatedRecordResult, error) { + atClient, did, err := s.authenticatedPDS(ctx) + if err != nil { + return nil, err + } + repoDid, err := s.repoDID(ctx, t) + if err != nil { + return nil, err + } + rkey := string(syntax.NewTIDNow(0)) + uri, _, err := atClient.PutRecord(ctx, atproto.PutRecordInput{ + Repo: did, + Collection: issueCollection, + Rkey: rkey, + Record: tangledlex.RepoIssue{ + LexiconTypeID: issueCollection, + Repo: repoDid, + Title: title, + Body: optionalString(body), + CreatedAt: time.Now().UTC().Format(time.RFC3339), + }, + }) + if err != nil { + return nil, fmt.Errorf("create issue: %w", err) + } + return &CreatedRecordResult{Rkey: rkey, URI: uri}, nil +} diff --git a/internal/app/issue_edit.go b/internal/app/issue_edit.go new file mode 100644 index 0000000..d85fb02 --- /dev/null +++ b/internal/app/issue_edit.go @@ -0,0 +1,13 @@ +package app + +import "context" + +// EditIssue patches an issue's title and/or body. A nil pointer leaves the +// field untouched. +func (s *Service) EditIssue(ctx context.Context, rkey string, title, body *string) error { + atClient, did, err := s.authenticatedPDS(ctx) + if err != nil { + return err + } + return editRecord(ctx, atClient, did, issueCollection, rkey, title, body) +} diff --git a/internal/app/issue_list.go b/internal/app/issue_list.go new file mode 100644 index 0000000..266d82f --- /dev/null +++ b/internal/app/issue_list.go @@ -0,0 +1,23 @@ +package app + +import ( + "context" + "fmt" + + "github.com/alyraffauf/tg/tangled" +) + +// ListIssues lists every issue in the target repository. +func (s *Service) ListIssues(ctx context.Context, t Target) ([]Item, error) { + repoDid, err := s.repoDID(ctx, t) + if err != nil { + return nil, err + } + issues, err := s.appview.ListIssues(ctx, repoDid, tangled.ListOpts{ + Limit: defaultListLimit, + }) + if err != nil { + return nil, fmt.Errorf("list issues for %q: %w", t.Repo, err) + } + return s.buildItems(ctx, issues.Items, decodeIssue), nil +} diff --git a/internal/app/issue_state.go b/internal/app/issue_state.go new file mode 100644 index 0000000..990339b --- /dev/null +++ b/internal/app/issue_state.go @@ -0,0 +1,20 @@ +package app + +import "context" + +// SetIssueState closes or reopens an issue. state is the bare verb +// ("open" or "closed"). +func (s *Service) SetIssueState(ctx context.Context, t Target, rkey, state string) (*StateResult, error) { + atClient, did, err := s.authenticatedPDS(ctx) + if err != nil { + return nil, err + } + target, _, err := s.targetRecord(ctx, t, issueCollection, rkey) + if err != nil { + return nil, err + } + if err := putState(ctx, atClient, did, rkey, issueCollection, target, state); err != nil { + return nil, err + } + return &StateResult{Rkey: rkey, State: state}, nil +} diff --git a/internal/app/issue_view.go b/internal/app/issue_view.go new file mode 100644 index 0000000..adac30a --- /dev/null +++ b/internal/app/issue_view.go @@ -0,0 +1,38 @@ +package app + +import ( + "context" + "fmt" + + "github.com/alyraffauf/tg/tangled" +) + +// ViewIssue finds a single issue by rkey within the target repository. +func (s *Service) ViewIssue(ctx context.Context, t Target, rkey string) (*ViewResult, error) { + repoDid, err := s.repoDID(ctx, t) + if err != nil { + return nil, err + } + issues, err := s.appview.ListIssues(ctx, repoDid, tangled.ListOpts{ + Limit: defaultListLimit, + }) + if err != nil { + return nil, fmt.Errorf("list issues for %s: %w", t, err) + } + found, err := findByRKey(issues.Items, rkey, "issue") + if err != nil { + return nil, err + } + decoded, err := decodeIssue(found.Value) + if err != nil { + return nil, fmt.Errorf("decode issue %q: %w", rkey, err) + } + return &ViewResult{ + Rkey: rkey, + Title: decoded.Title, + State: found.State, + Body: decoded.Body, + Author: s.resolveAuthor(ctx, extractDID(found.URI)), + CreatedAt: decoded.CreatedAt, + }, nil +} diff --git a/internal/app/issues.go b/internal/app/issues.go deleted file mode 100644 index 600ffbe..0000000 --- a/internal/app/issues.go +++ /dev/null @@ -1,188 +0,0 @@ -package app - -import ( - "context" - "fmt" - "time" - - "github.com/alyraffauf/tg/atproto" - "github.com/alyraffauf/tg/internal/tangledlex" - "github.com/alyraffauf/tg/tangled" - "github.com/bluesky-social/indigo/atproto/syntax" -) - -// ListIssues lists every issue in the target repository. -func (s *Service) ListIssues(ctx context.Context, t Target) ([]Item, error) { - repoDid, err := s.repoDID(ctx, t) - if err != nil { - return nil, err - } - issues, err := s.appview.ListIssues(ctx, repoDid, tangled.ListOpts{ - Limit: defaultListLimit, - }) - if err != nil { - return nil, fmt.Errorf("list issues for %q: %w", t.Repo, err) - } - return s.buildItems(ctx, issues.Items, decodeIssue), nil -} - -// ViewIssue finds a single issue by rkey within the target repository. -func (s *Service) ViewIssue(ctx context.Context, t Target, rkey string) (*ViewResult, error) { - repoDid, err := s.repoDID(ctx, t) - if err != nil { - return nil, err - } - issues, err := s.appview.ListIssues(ctx, repoDid, tangled.ListOpts{ - Limit: defaultListLimit, - }) - if err != nil { - return nil, fmt.Errorf("list issues for %s: %w", t, err) - } - found, err := findByRKey(issues.Items, rkey, "issue") - if err != nil { - return nil, err - } - decoded, err := decodeIssue(found.Value) - if err != nil { - return nil, fmt.Errorf("decode issue %q: %w", rkey, err) - } - return &ViewResult{ - Rkey: rkey, - Title: decoded.Title, - State: found.State, - Body: decoded.Body, - Author: s.resolveAuthor(ctx, extractDID(found.URI)), - CreatedAt: decoded.CreatedAt, - }, nil -} - -// CreateIssue writes a new issue record in the target repository. -func (s *Service) CreateIssue(ctx context.Context, t Target, title, body string) (*CreatedRecordResult, error) { - atClient, did, err := s.authenticatedPDS(ctx) - if err != nil { - return nil, err - } - repoDid, err := s.repoDID(ctx, t) - if err != nil { - return nil, err - } - rkey := string(syntax.NewTIDNow(0)) - uri, _, err := atClient.PutRecord(ctx, atproto.PutRecordInput{ - Repo: did, - Collection: issueCollection, - Rkey: rkey, - Record: tangledlex.RepoIssue{ - LexiconTypeID: issueCollection, - Repo: repoDid, - Title: title, - Body: optionalString(body), - CreatedAt: time.Now().UTC().Format(time.RFC3339), - }, - }) - if err != nil { - return nil, fmt.Errorf("create issue: %w", err) - } - return &CreatedRecordResult{Rkey: rkey, URI: uri}, nil -} - -// CommentIssue adds a comment to the issue identified by rkey. -func (s *Service) CommentIssue(ctx context.Context, t Target, rkey, body string) (*CreatedRecordResult, error) { - repoDid, err := s.repoDID(ctx, t) - if err != nil { - return nil, err - } - issues, err := s.appview.ListIssues(ctx, repoDid, tangled.ListOpts{ - Limit: defaultListLimit, - }) - if err != nil { - return nil, fmt.Errorf("list issues for %s: %w", t, err) - } - issue, err := findByRKey(issues.Items, rkey, "issue") - if err != nil { - return nil, err - } - return s.createIssueComment(ctx, issue.URI, body) -} - -func (s *Service) createIssueComment(ctx context.Context, issueURI, body string) (*CreatedRecordResult, error) { - atClient, did, err := s.authenticatedPDS(ctx) - if err != nil { - return nil, err - } - rkey := string(syntax.NewTIDNow(0)) - uri, _, err := atClient.PutRecord(ctx, atproto.PutRecordInput{ - Repo: did, - Collection: issueCollection + ".comment", - Rkey: rkey, - Record: tangledlex.RepoIssueComment{ - LexiconTypeID: issueCollection + ".comment", - Issue: issueURI, - Body: body, - CreatedAt: time.Now().UTC().Format(time.RFC3339), - }, - }) - if err != nil { - return nil, fmt.Errorf("create issue comment: %w", err) - } - return &CreatedRecordResult{Rkey: rkey, URI: uri}, nil -} - -// SetIssueState closes or reopens an issue. state is the bare verb -// ("open" or "closed"). -func (s *Service) SetIssueState(ctx context.Context, t Target, rkey, state string) (*StateResult, error) { - atClient, did, err := s.authenticatedPDS(ctx) - if err != nil { - return nil, err - } - target, _, err := s.targetRecord(ctx, t, issueCollection, rkey) - if err != nil { - return nil, err - } - if err := putState(ctx, atClient, did, rkey, issueCollection, target, state); err != nil { - return nil, err - } - return &StateResult{Rkey: rkey, State: state}, nil -} - -// EditIssue patches an issue's title and/or body. A nil pointer leaves the -// field untouched. -func (s *Service) EditIssue(ctx context.Context, rkey string, title, body *string) error { - atClient, did, err := s.authenticatedPDS(ctx) - if err != nil { - return err - } - return editRecord(ctx, atClient, did, issueCollection, rkey, title, body) -} - -// targetRecord resolves t, finds the issue/pull record rkey, and returns the -// record URI and the repo record URI. collection selects issues or pulls. -func (s *Service) targetRecord(ctx context.Context, t Target, collection, rkey string) (string, string, error) { - repoRecord, err := s.resolveRepo(ctx, t) - if err != nil { - return "", "", err - } - - var items []tangled.ListItem - var recordType string - if collection == issueCollection { - issues, err := s.appview.ListIssues(ctx, stringValue(repoRecord.Value.RepoDid), tangled.ListOpts{Limit: defaultListLimit}) - if err != nil { - return "", "", fmt.Errorf("list issues for %s: %w", t, err) - } - items = issues.Items - recordType = "issue" - } else { - pulls, err := s.appview.ListPulls(ctx, stringValue(repoRecord.Value.RepoDid), tangled.ListOpts{Limit: defaultListLimit}) - if err != nil { - return "", "", fmt.Errorf("list pull requests for %s: %w", t, err) - } - items = pulls.Items - recordType = "pull request" - } - - record, err := findByRKey(items, rkey, recordType) - if err != nil { - return "", "", err - } - return record.URI, repoRecord.URI, nil -} diff --git a/internal/app/items.go b/internal/app/items.go index 8b432ed..dc7db60 100644 --- a/internal/app/items.go +++ b/internal/app/items.go @@ -141,3 +141,36 @@ func findByRKey(items []tangled.ListItem, rkey, what string) (*tangled.ListItem, } return nil, fmt.Errorf("%s %q not found", what, rkey) } + +// targetRecord resolves t, finds the issue/pull record rkey, and returns the +// record URI and the repo record URI. collection selects issues or pulls. +func (s *Service) targetRecord(ctx context.Context, t Target, collection, rkey string) (string, string, error) { + repoRecord, err := s.resolveRepo(ctx, t) + if err != nil { + return "", "", err + } + + var items []tangled.ListItem + var recordType string + if collection == issueCollection { + issues, err := s.appview.ListIssues(ctx, stringValue(repoRecord.Value.RepoDid), tangled.ListOpts{Limit: defaultListLimit}) + if err != nil { + return "", "", fmt.Errorf("list issues for %s: %w", t, err) + } + items = issues.Items + recordType = "issue" + } else { + pulls, err := s.appview.ListPulls(ctx, stringValue(repoRecord.Value.RepoDid), tangled.ListOpts{Limit: defaultListLimit}) + if err != nil { + return "", "", fmt.Errorf("list pull requests for %s: %w", t, err) + } + items = pulls.Items + recordType = "pull request" + } + + record, err := findByRKey(items, rkey, recordType) + if err != nil { + return "", "", err + } + return record.URI, repoRecord.URI, nil +} diff --git a/internal/app/pipeline.go b/internal/app/pipeline.go new file mode 100644 index 0000000..db3226d --- /dev/null +++ b/internal/app/pipeline.go @@ -0,0 +1,60 @@ +package app + +import ( + "context" + "fmt" + + "github.com/alyraffauf/tg/spindle" +) + +const maxPipelinePages = 1000 + +func (s *Service) pipelineClient(ctx context.Context, target Target) (pipelineClient, string, error) { + spindleHost, repoDID, err := s.pipelineTarget(ctx, target) + if err != nil { + return nil, "", err + } + client, err := s.spindle.New(spindleHost) + if err != nil { + return nil, "", fmt.Errorf("connect to pipeline spindle: %w", err) + } + return client, repoDID, nil +} + +func (s *Service) pipelineTarget(ctx context.Context, target Target) (string, string, error) { + repo, err := s.resolveRepo(ctx, target) + if err != nil { + return "", "", err + } + spindleHost := stringValue(repo.Value.Spindle) + if spindleHost == "" { + return "", "", fmt.Errorf("pipelines are not configured for repository %q", target.String()) + } + repoDID := stringValue(repo.Value.RepoDid) + if repoDID == "" { + return "", "", fmt.Errorf("repository %q has no repository DID", target.String()) + } + return spindleHost, repoDID, nil +} + +func pipelineItems(pipelines []spindle.Pipeline) []Pipeline { + items := make([]Pipeline, 0, len(pipelines)) + for _, pipeline := range pipelines { + items = append(items, pipelineItem(pipeline)) + } + return items +} + +func pipelineItem(pipeline spindle.Pipeline) Pipeline { + workflows := make([]PipelineWorkflow, 0, len(pipeline.Workflows)) + for _, workflow := range pipeline.Workflows { + workflows = append(workflows, PipelineWorkflow{ + ID: workflow.ID, Name: workflow.Name, Status: workflow.Status, Error: workflow.Error, + StartedAt: workflow.StartedAt, FinishedAt: workflow.FinishedAt, + }) + } + return Pipeline{ + ID: pipeline.ID, Commit: pipeline.Commit, CreatedAt: pipeline.CreatedAt, + Repo: pipeline.Repo, SourceRepo: pipeline.SourceRepo, Trigger: pipeline.Trigger, Workflows: workflows, + } +} diff --git a/internal/app/pipeline_cancel.go b/internal/app/pipeline_cancel.go new file mode 100644 index 0000000..da3317b --- /dev/null +++ b/internal/app/pipeline_cancel.go @@ -0,0 +1,73 @@ +package app + +import ( + "context" + "fmt" + + "github.com/alyraffauf/tg/spindle" +) + +// CancelPipeline cancels every workflow in a pipeline, or only the selected workflows. +func (s *Service) CancelPipeline(ctx context.Context, target Target, pipelineID string, workflows []string) (*PipelineCancelResult, error) { + spindleHost, repoDID, err := s.pipelineTarget(ctx, target) + if err != nil { + return nil, err + } + client, err := s.spindle.New(spindleHost) + if err != nil { + return nil, fmt.Errorf("connect to pipeline spindle: %w", err) + } + pipeline, err := client.GetPipeline(ctx, pipelineID) + if err != nil { + return nil, err + } + cancellableWorkflows := selectCancellableWorkflows(pipeline.Workflows, workflows) + if len(cancellableWorkflows) == 0 { + return &PipelineCancelResult{Pipeline: pipelineID}, nil + } + + pds, _, err := s.authenticatedPDS(ctx) + if err != nil { + return nil, err + } + audience, err := spindle.ServiceDID(spindleHost) + if err != nil { + return nil, err + } + token, err := pds.GetServiceAuth(ctx, audience, "sh.tangled.ci.cancelPipeline") + if err != nil { + return nil, fmt.Errorf("mint pipeline cancel token: %w", err) + } + authenticatedClient, err := s.spindle.NewWithToken(spindleHost, token) + if err != nil { + return nil, fmt.Errorf("connect to pipeline spindle: %w", err) + } + workflowsForRequest := cancellableWorkflows + if len(workflows) == 0 { + workflowsForRequest = nil + } + if err := authenticatedClient.CancelPipeline(ctx, spindle.CancelPipelineInput{ + Pipeline: pipelineID, Repo: repoDID, Workflows: workflowsForRequest, + }); err != nil { + return nil, err + } + return &PipelineCancelResult{Pipeline: pipelineID, Workflows: cancellableWorkflows, CancellationRequested: true}, nil +} + +func selectCancellableWorkflows(workflows []spindle.Workflow, selected []string) []string { + selectedWorkflows := make(map[string]bool, len(selected)) + for _, workflow := range selected { + selectedWorkflows[workflow] = true + } + + cancellable := make([]string, 0, len(workflows)) + for _, workflow := range workflows { + if workflow.Status != "pending" && workflow.Status != "running" { + continue + } + if len(selectedWorkflows) == 0 || selectedWorkflows[workflow.Name] { + cancellable = append(cancellable, workflow.Name) + } + } + return cancellable +} diff --git a/internal/app/pipeline_list.go b/internal/app/pipeline_list.go new file mode 100644 index 0000000..0f590f1 --- /dev/null +++ b/internal/app/pipeline_list.go @@ -0,0 +1,32 @@ +package app + +import ( + "context" + "fmt" +) + +// ListPipelines returns every pipeline configured for a repository. +func (s *Service) ListPipelines(ctx context.Context, target Target) ([]Pipeline, error) { + client, repoDID, err := s.pipelineClient(ctx, target) + if err != nil { + return nil, err + } + return listPipelinePages(ctx, client, repoDID) +} + +func listPipelinePages(ctx context.Context, client pipelineClient, repoDID string) ([]Pipeline, error) { + var pipelines []Pipeline + cursor := "" + for page := 0; page < maxPipelinePages; page++ { + response, err := client.QueryPipelines(ctx, repoDID, cursor) + if err != nil { + return nil, err + } + pipelines = append(pipelines, pipelineItems(response.Pipelines)...) + if response.Cursor == "" { + return pipelines, nil + } + cursor = response.Cursor + } + return nil, fmt.Errorf("exceeded %d pipeline pages without reaching the end of the list", maxPipelinePages) +} diff --git a/internal/app/pipeline_status.go b/internal/app/pipeline_status.go new file mode 100644 index 0000000..d7f1feb --- /dev/null +++ b/internal/app/pipeline_status.go @@ -0,0 +1,77 @@ +package app + +import ( + "context" + "fmt" + "strings" +) + +// PipelineStatus returns the most recent pipeline for a repository. +func (s *Service) PipelineStatus(ctx context.Context, target Target) (*PipelineStatusResult, error) { + repo, err := s.resolveRepo(ctx, target) + if err != nil { + return nil, err + } + spindleHost := stringValue(repo.Value.Spindle) + repoDID := stringValue(repo.Value.RepoDid) + if spindleHost == "" { + return nil, fmt.Errorf("pipelines are not configured for repository %q", target.String()) + } + if repoDID == "" { + return nil, fmt.Errorf("repository %q has no repository DID", target.String()) + } + branchName := stringValue(repo.Value.Name) + if branchName == "" { + branchName = target.Repo + } + defaultBranch, err := s.knot.NewPublic(repo.Value.Knot).GetDefaultBranch(ctx, extractDID(repo.URI)+"/"+branchName) + if err != nil { + return nil, err + } + client, err := s.spindle.New(spindleHost) + if err != nil { + return nil, fmt.Errorf("connect to pipeline spindle: %w", err) + } + response, err := client.QueryPipelines(ctx, repoDID, "") + if err != nil { + return nil, err + } + pipelines := pipelineItems(response.Pipelines) + pipeline := latestDefaultBranchPipeline(pipelines, defaultBranch.Name, defaultBranch.Hash) + if pipeline == nil { + return nil, fmt.Errorf("no pipeline found for the latest %s commit on the default branch", target.String()) + } + return &PipelineStatusResult{Commit: pipeline.Commit, Pipeline: pipeline, HasFailures: pipelineHasFailures(*pipeline)}, nil +} + +func latestDefaultBranchPipeline(pipelines []Pipeline, branchName, branchHash string) *Pipeline { + for index := range pipelines { + pipeline := &pipelines[index] + if branchHash != "" && pipeline.Commit == branchHash { + return pipeline + } + if branchHash == "" && pipelineTargetsBranch(*pipeline, branchName) { + return pipeline + } + } + return nil +} + +func pipelineTargetsBranch(pipeline Pipeline, branchName string) bool { + triggerType, _ := pipeline.Trigger["$type"].(string) + if triggerType == "sh.tangled.ci.trigger#pullRequest" { + targetBranch, _ := pipeline.Trigger["targetBranch"].(string) + return targetBranch == branchName + } + ref, _ := pipeline.Trigger["ref"].(string) + return strings.TrimPrefix(ref, "refs/heads/") == branchName +} + +func pipelineHasFailures(pipeline Pipeline) bool { + for _, workflow := range pipeline.Workflows { + if workflow.Status == "failed" || workflow.Status == "timeout" { + return true + } + } + return false +} diff --git a/internal/app/pipeline_trigger.go b/internal/app/pipeline_trigger.go new file mode 100644 index 0000000..b356884 --- /dev/null +++ b/internal/app/pipeline_trigger.go @@ -0,0 +1,70 @@ +package app + +import ( + "context" + "fmt" + "strings" + + "github.com/alyraffauf/tg/spindle" +) + +// TriggerPipeline starts a manual pipeline for revision. A full commit SHA can be +// used without a local Git checkout; other revisions are resolved locally. +func (s *Service) TriggerPipeline(ctx context.Context, target Target, revision string, workflows []string) (*PipelineTriggerResult, error) { + commit, ref, err := s.resolvePipelineRevision(ctx, revision) + if err != nil { + return nil, err + } + spindleHost, repoDID, err := s.pipelineTarget(ctx, target) + if err != nil { + return nil, err + } + pds, _, err := s.authenticatedPDS(ctx) + if err != nil { + return nil, err + } + audience, err := spindle.ServiceDID(spindleHost) + if err != nil { + return nil, err + } + token, err := pds.GetServiceAuth(ctx, audience, "sh.tangled.ci.triggerPipeline") + if err != nil { + return nil, fmt.Errorf("mint pipeline trigger token: %w", err) + } + client, err := s.spindle.NewWithToken(spindleHost, token) + if err != nil { + return nil, fmt.Errorf("connect to pipeline spindle: %w", err) + } + response, err := client.TriggerPipeline(ctx, spindle.TriggerPipelineInput{ + Repo: repoDID, Workflows: workflows, + Trigger: spindle.ManualTrigger{LexiconTypeID: "sh.tangled.ci.trigger#manual", SHA: commit, Ref: ref}, + }) + if err != nil { + return nil, err + } + return &PipelineTriggerResult{Pipeline: extractRKey(response.Pipeline), Commit: commit, Workflows: workflows}, nil +} + +func (s *Service) resolvePipelineRevision(ctx context.Context, revision string) (commit, ref string, err error) { + if isFullCommitSHA(revision) { + return revision, "", nil + } + commit, err = s.git.ResolveCommit(ctx, "", revision) + if err != nil { + return "", "", err + } + if revision == "HEAD" { + if branch, branchErr := s.git.CurrentBranch(ctx, ""); branchErr == nil { + return commit, "refs/heads/" + branch, nil + } + return commit, "", nil + } + return commit, revision, nil +} + +func isFullCommitSHA(revision string) bool { + if len(revision) != 40 { + return false + } + return strings.Trim(revision, "0123456789abcdefABCDEF") == "" +} diff --git a/internal/app/pipeline_view.go b/internal/app/pipeline_view.go new file mode 100644 index 0000000..72de15b --- /dev/null +++ b/internal/app/pipeline_view.go @@ -0,0 +1,23 @@ +package app + +import ( + "context" + "fmt" +) + +// ViewPipeline fetches a pipeline by its spindle-local ID. +func (s *Service) ViewPipeline(ctx context.Context, target Target, pipelineID string) (*Pipeline, error) { + client, repoDID, err := s.pipelineClient(ctx, target) + if err != nil { + return nil, err + } + pipeline, err := client.GetPipeline(ctx, pipelineID) + if err != nil { + return nil, err + } + if pipeline.Repo != repoDID { + return nil, fmt.Errorf("pipeline %q does not belong to repository %q", pipelineID, target.String()) + } + item := pipelineItem(*pipeline) + return &item, nil +} diff --git a/internal/app/pipelines.go b/internal/app/pipelines.go deleted file mode 100644 index d118710..0000000 --- a/internal/app/pipelines.go +++ /dev/null @@ -1,300 +0,0 @@ -package app - -import ( - "context" - "fmt" - "strings" - - "github.com/alyraffauf/tg/spindle" -) - -const maxPipelinePages = 1000 - -// ListPipelines returns every pipeline configured for a repository. -func (s *Service) ListPipelines(ctx context.Context, target Target) ([]Pipeline, error) { - client, repoDID, err := s.pipelineClient(ctx, target) - if err != nil { - return nil, err - } - return listPipelinePages(ctx, client, repoDID) -} - -// TriggerPipeline starts a manual pipeline for revision. A full commit SHA can be -// used without a local Git checkout; other revisions are resolved locally. -func (s *Service) TriggerPipeline(ctx context.Context, target Target, revision string, workflows []string) (*PipelineTriggerResult, error) { - commit, ref, err := s.resolvePipelineRevision(ctx, revision) - if err != nil { - return nil, err - } - spindleHost, repoDID, err := s.pipelineTarget(ctx, target) - if err != nil { - return nil, err - } - pds, _, err := s.authenticatedPDS(ctx) - if err != nil { - return nil, err - } - audience, err := spindle.ServiceDID(spindleHost) - if err != nil { - return nil, err - } - token, err := pds.GetServiceAuth(ctx, audience, "sh.tangled.ci.triggerPipeline") - if err != nil { - return nil, fmt.Errorf("mint pipeline trigger token: %w", err) - } - client, err := s.spindle.NewWithToken(spindleHost, token) - if err != nil { - return nil, fmt.Errorf("connect to pipeline spindle: %w", err) - } - response, err := client.TriggerPipeline(ctx, spindle.TriggerPipelineInput{ - Repo: repoDID, Workflows: workflows, - Trigger: spindle.ManualTrigger{LexiconTypeID: "sh.tangled.ci.trigger#manual", SHA: commit, Ref: ref}, - }) - if err != nil { - return nil, err - } - return &PipelineTriggerResult{Pipeline: extractRKey(response.Pipeline), Commit: commit, Workflows: workflows}, nil -} - -func (s *Service) resolvePipelineRevision(ctx context.Context, revision string) (commit, ref string, err error) { - if isFullCommitSHA(revision) { - return revision, "", nil - } - commit, err = s.git.ResolveCommit(ctx, "", revision) - if err != nil { - return "", "", err - } - if revision == "HEAD" { - if branch, branchErr := s.git.CurrentBranch(ctx, ""); branchErr == nil { - return commit, "refs/heads/" + branch, nil - } - return commit, "", nil - } - return commit, revision, nil -} - -func isFullCommitSHA(revision string) bool { - if len(revision) != 40 { - return false - } - return strings.Trim(revision, "0123456789abcdefABCDEF") == "" -} - -// CancelPipeline cancels every workflow in a pipeline, or only the selected workflows. -func (s *Service) CancelPipeline(ctx context.Context, target Target, pipelineID string, workflows []string) (*PipelineCancelResult, error) { - spindleHost, repoDID, err := s.pipelineTarget(ctx, target) - if err != nil { - return nil, err - } - client, err := s.spindle.New(spindleHost) - if err != nil { - return nil, fmt.Errorf("connect to pipeline spindle: %w", err) - } - pipeline, err := client.GetPipeline(ctx, pipelineID) - if err != nil { - return nil, err - } - cancellableWorkflows := selectCancellableWorkflows(pipeline.Workflows, workflows) - if len(cancellableWorkflows) == 0 { - return &PipelineCancelResult{Pipeline: pipelineID}, nil - } - - pds, _, err := s.authenticatedPDS(ctx) - if err != nil { - return nil, err - } - audience, err := spindle.ServiceDID(spindleHost) - if err != nil { - return nil, err - } - token, err := pds.GetServiceAuth(ctx, audience, "sh.tangled.ci.cancelPipeline") - if err != nil { - return nil, fmt.Errorf("mint pipeline cancel token: %w", err) - } - authenticatedClient, err := s.spindle.NewWithToken(spindleHost, token) - if err != nil { - return nil, fmt.Errorf("connect to pipeline spindle: %w", err) - } - workflowsForRequest := cancellableWorkflows - if len(workflows) == 0 { - workflowsForRequest = nil - } - if err := authenticatedClient.CancelPipeline(ctx, spindle.CancelPipelineInput{ - Pipeline: pipelineID, Repo: repoDID, Workflows: workflowsForRequest, - }); err != nil { - return nil, err - } - return &PipelineCancelResult{Pipeline: pipelineID, Workflows: cancellableWorkflows, CancellationRequested: true}, nil -} - -func selectCancellableWorkflows(workflows []spindle.Workflow, selected []string) []string { - selectedWorkflows := make(map[string]bool, len(selected)) - for _, workflow := range selected { - selectedWorkflows[workflow] = true - } - - cancellable := make([]string, 0, len(workflows)) - for _, workflow := range workflows { - if workflow.Status != "pending" && workflow.Status != "running" { - continue - } - if len(selectedWorkflows) == 0 || selectedWorkflows[workflow.Name] { - cancellable = append(cancellable, workflow.Name) - } - } - return cancellable -} - -// PipelineStatus returns the most recent pipeline for a repository. -func (s *Service) PipelineStatus(ctx context.Context, target Target) (*PipelineStatusResult, error) { - repo, err := s.resolveRepo(ctx, target) - if err != nil { - return nil, err - } - spindleHost := stringValue(repo.Value.Spindle) - repoDID := stringValue(repo.Value.RepoDid) - if spindleHost == "" { - return nil, fmt.Errorf("pipelines are not configured for repository %q", target.String()) - } - if repoDID == "" { - return nil, fmt.Errorf("repository %q has no repository DID", target.String()) - } - branchName := stringValue(repo.Value.Name) - if branchName == "" { - branchName = target.Repo - } - defaultBranch, err := s.knot.NewPublic(repo.Value.Knot).GetDefaultBranch(ctx, extractDID(repo.URI)+"/"+branchName) - if err != nil { - return nil, err - } - client, err := s.spindle.New(spindleHost) - if err != nil { - return nil, fmt.Errorf("connect to pipeline spindle: %w", err) - } - response, err := client.QueryPipelines(ctx, repoDID, "") - if err != nil { - return nil, err - } - pipelines := pipelineItems(response.Pipelines) - pipeline := latestDefaultBranchPipeline(pipelines, defaultBranch.Name, defaultBranch.Hash) - if pipeline == nil { - return nil, fmt.Errorf("no pipeline found for the latest %s commit on the default branch", target.String()) - } - return &PipelineStatusResult{Commit: pipeline.Commit, Pipeline: pipeline, HasFailures: pipelineHasFailures(*pipeline)}, nil -} - -func latestDefaultBranchPipeline(pipelines []Pipeline, branchName, branchHash string) *Pipeline { - for index := range pipelines { - pipeline := &pipelines[index] - if branchHash != "" && pipeline.Commit == branchHash { - return pipeline - } - if branchHash == "" && pipelineTargetsBranch(*pipeline, branchName) { - return pipeline - } - } - return nil -} - -func pipelineTargetsBranch(pipeline Pipeline, branchName string) bool { - triggerType, _ := pipeline.Trigger["$type"].(string) - if triggerType == "sh.tangled.ci.trigger#pullRequest" { - targetBranch, _ := pipeline.Trigger["targetBranch"].(string) - return targetBranch == branchName - } - ref, _ := pipeline.Trigger["ref"].(string) - return strings.TrimPrefix(ref, "refs/heads/") == branchName -} - -func (s *Service) pipelineClient(ctx context.Context, target Target) (pipelineClient, string, error) { - spindleHost, repoDID, err := s.pipelineTarget(ctx, target) - if err != nil { - return nil, "", err - } - client, err := s.spindle.New(spindleHost) - if err != nil { - return nil, "", fmt.Errorf("connect to pipeline spindle: %w", err) - } - return client, repoDID, nil -} - -func (s *Service) pipelineTarget(ctx context.Context, target Target) (string, string, error) { - repo, err := s.resolveRepo(ctx, target) - if err != nil { - return "", "", err - } - spindleHost := stringValue(repo.Value.Spindle) - if spindleHost == "" { - return "", "", fmt.Errorf("pipelines are not configured for repository %q", target.String()) - } - repoDID := stringValue(repo.Value.RepoDid) - if repoDID == "" { - return "", "", fmt.Errorf("repository %q has no repository DID", target.String()) - } - return spindleHost, repoDID, nil -} - -// ViewPipeline fetches a pipeline by its spindle-local ID. -func (s *Service) ViewPipeline(ctx context.Context, target Target, pipelineID string) (*Pipeline, error) { - client, repoDID, err := s.pipelineClient(ctx, target) - if err != nil { - return nil, err - } - pipeline, err := client.GetPipeline(ctx, pipelineID) - if err != nil { - return nil, err - } - if pipeline.Repo != repoDID { - return nil, fmt.Errorf("pipeline %q does not belong to repository %q", pipelineID, target.String()) - } - item := pipelineItem(*pipeline) - return &item, nil -} - -func pipelineHasFailures(pipeline Pipeline) bool { - for _, workflow := range pipeline.Workflows { - if workflow.Status == "failed" || workflow.Status == "timeout" { - return true - } - } - return false -} - -func listPipelinePages(ctx context.Context, client pipelineClient, repoDID string) ([]Pipeline, error) { - var pipelines []Pipeline - cursor := "" - for page := 0; page < maxPipelinePages; page++ { - response, err := client.QueryPipelines(ctx, repoDID, cursor) - if err != nil { - return nil, err - } - pipelines = append(pipelines, pipelineItems(response.Pipelines)...) - if response.Cursor == "" { - return pipelines, nil - } - cursor = response.Cursor - } - return nil, fmt.Errorf("exceeded %d pipeline pages without reaching the end of the list", maxPipelinePages) -} - -func pipelineItems(pipelines []spindle.Pipeline) []Pipeline { - items := make([]Pipeline, 0, len(pipelines)) - for _, pipeline := range pipelines { - items = append(items, pipelineItem(pipeline)) - } - return items -} - -func pipelineItem(pipeline spindle.Pipeline) Pipeline { - workflows := make([]PipelineWorkflow, 0, len(pipeline.Workflows)) - for _, workflow := range pipeline.Workflows { - workflows = append(workflows, PipelineWorkflow{ - ID: workflow.ID, Name: workflow.Name, Status: workflow.Status, Error: workflow.Error, - StartedAt: workflow.StartedAt, FinishedAt: workflow.FinishedAt, - }) - } - return Pipeline{ - ID: pipeline.ID, Commit: pipeline.Commit, CreatedAt: pipeline.CreatedAt, - Repo: pipeline.Repo, SourceRepo: pipeline.SourceRepo, Trigger: pipeline.Trigger, Workflows: workflows, - } -} diff --git a/internal/app/pull_checkout.go b/internal/app/pull_checkout.go new file mode 100644 index 0000000..c44cce1 --- /dev/null +++ b/internal/app/pull_checkout.go @@ -0,0 +1,65 @@ +package app + +import ( + "context" + "fmt" + + "github.com/alyraffauf/tg/internal/gitutil" +) + +// CheckoutPullInput configures reconstructing a pull request in a local +// repository. Target may be empty, in which case the current repository is +// used. +type CheckoutPullInput struct { + RepoDir string + Rkey string + Target *Target + Branch string + Force bool +} + +// CheckoutPull downloads and applies the latest pull request patch. +func (s *Service) CheckoutPull(ctx context.Context, in CheckoutPullInput) (*PRCheckoutResult, error) { + localTarget, localRecord, err := s.repoFromCWD(ctx) + if err != nil { + return nil, fmt.Errorf("detect local repository: %w", err) + } + + target := localTarget + if in.Target != nil { + target = *in.Target + } + targetRecord := localRecord + if target != localTarget { + targetRecord, err = s.resolveRepo(ctx, target) + if err != nil { + return nil, err + } + } + if stringValue(targetRecord.Value.RepoDid) != stringValue(localRecord.Value.RepoDid) { + return nil, fmt.Errorf("pull request target %s does not match the current repository", target) + } + + patch, err := s.PullPatch(ctx, target, in.Rkey) + if err != nil { + return nil, err + } + if patch.TargetBranch == "" { + return nil, fmt.Errorf("pull request %q has no target branch", in.Rkey) + } + + branch := in.Branch + if branch == "" { + branch = "pr-" + in.Rkey + } + if err := s.git.CheckoutPatch(ctx, gitutil.CheckoutPatchParams{ + RepoDir: in.RepoDir, + Branch: branch, + TargetBranch: patch.TargetBranch, + Patch: patch.Patch, + Force: in.Force, + }); err != nil { + return nil, err + } + return &PRCheckoutResult{Rkey: in.Rkey, Branch: branch}, nil +} diff --git a/internal/app/pull_comment.go b/internal/app/pull_comment.go new file mode 100644 index 0000000..1a71ddd --- /dev/null +++ b/internal/app/pull_comment.go @@ -0,0 +1,54 @@ +package app + +import ( + "context" + "fmt" + "time" + + "github.com/alyraffauf/tg/atproto" + "github.com/alyraffauf/tg/internal/tangledlex" + "github.com/alyraffauf/tg/tangled" + "github.com/bluesky-social/indigo/atproto/syntax" +) + +// CommentPull adds a comment to the pull request identified by rkey. +func (s *Service) CommentPull(ctx context.Context, t Target, rkey, body string) (*CreatedRecordResult, error) { + repoDid, err := s.repoDID(ctx, t) + if err != nil { + return nil, err + } + pulls, err := s.appview.ListPulls(ctx, repoDid, tangled.ListOpts{ + Limit: defaultListLimit, + }) + if err != nil { + return nil, fmt.Errorf("list PRs for %s: %w", t, err) + } + pull, err := findByRKey(pulls.Items, rkey, "pull request") + if err != nil { + return nil, err + } + return s.createPullComment(ctx, pull.URI, body) +} + +func (s *Service) createPullComment(ctx context.Context, pullURI, body string) (*CreatedRecordResult, error) { + atClient, did, err := s.authenticatedPDS(ctx) + if err != nil { + return nil, err + } + rkey := string(syntax.NewTIDNow(0)) + uri, _, err := atClient.PutRecord(ctx, atproto.PutRecordInput{ + Repo: did, + Collection: pullCollection + ".comment", + Rkey: rkey, + Record: tangledlex.RepoPullComment{ + LexiconTypeID: pullCollection + ".comment", + Pull: pullURI, + Body: body, + CreatedAt: time.Now().UTC().Format(time.RFC3339), + }, + }) + if err != nil { + return nil, fmt.Errorf("create pull request comment: %w", err) + } + return &CreatedRecordResult{Rkey: rkey, URI: uri}, nil +} diff --git a/internal/app/pull_create.go b/internal/app/pull_create.go new file mode 100644 index 0000000..ba34aaf --- /dev/null +++ b/internal/app/pull_create.go @@ -0,0 +1,160 @@ +package app + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/alyraffauf/tg/atproto" + "github.com/alyraffauf/tg/internal/tangledlex" + "github.com/bluesky-social/indigo/atproto/syntax" + lexutil "github.com/bluesky-social/indigo/lex/util" +) + +// CreatePullInput configures pull request creation. +type CreatePullInput struct { + RepoDir string // local git repository (for branch detection + patch) + Title string + Body string + Base string // empty: detect origin's default branch + Head string // empty: current branch + Target Target + Source *Target // nil: same as Target +} + +// pullRecordInput is the write-side input to newPullRecord. +type pullRecordInput struct { + Title string + Body string + TargetRepoDid string + SourceRepoDid string + Base string + Head string + Patch *atproto.Blob +} + +// CreatePull generates a patch from the local repository, uploads it, and +// writes a pull record. +func (s *Service) CreatePull(ctx context.Context, in CreatePullInput) (*PRCreateResult, error) { + atClient, did, err := s.authenticatedPDS(ctx) + if err != nil { + return nil, err + } + + head := in.Head + if head == "" { + head, err = s.git.CurrentBranch(ctx, in.RepoDir) + if err != nil { + return nil, fmt.Errorf("determine source branch: %w", err) + } + } + base := in.Base + if base == "" { + base, err = s.git.DefaultBranch(ctx, in.RepoDir) + if err != nil { + return nil, fmt.Errorf("determine target branch; set --base explicitly: %w", err) + } + } + + target, err := s.resolveRepo(ctx, in.Target) + if err != nil { + return nil, err + } + if !atURIPrefix(target.URI) { + return nil, fmt.Errorf("target repository %q has no strong at:// URI", in.Target.Repo) + } + source := target + if in.Source != nil { + source, err = s.resolveRepo(ctx, *in.Source) + if err != nil { + return nil, fmt.Errorf("resolve source repository: %w", err) + } + } + if stringValue(source.Value.RepoDid) == "" { + return nil, fmt.Errorf("source repository has no repo DID") + } + + patch, err := s.git.GeneratePatch(ctx, in.RepoDir, base, head) + if err != nil { + return nil, fmt.Errorf("generate pull request patch: %w", err) + } + blob, err := atClient.UploadBlob(ctx, patch, patchMimeType) + if err != nil { + return nil, err + } + + uri, err := createPullRecord(ctx, atClient, did, pullRecordInput{ + Title: in.Title, + Body: in.Body, + TargetRepoDid: stringValue(target.Value.RepoDid), + SourceRepoDid: stringValue(source.Value.RepoDid), + Base: base, + Head: head, + Patch: blob, + }) + if err != nil { + return nil, err + } + return &PRCreateResult{URI: uri, Title: in.Title, Base: base, Head: head}, nil +} + +func atURIPrefix(uri string) bool { return len(uri) >= 5 && uri[:5] == "at://" } + +func createPullRecord(ctx context.Context, atClient pdsClient, did string, input pullRecordInput) (string, error) { + record, err := newPullRecord(input, time.Now().UTC()) + if err != nil { + return "", err + } + uri, _, err := atClient.PutRecord(ctx, atproto.PutRecordInput{ + Repo: did, + Collection: pullCollection, + Rkey: string(syntax.NewTIDNow(0)), + Record: record, + }) + if err != nil { + return "", fmt.Errorf("create pull request record: %w", err) + } + return uri, nil +} + +func newPullRecord(input pullRecordInput, createdAt time.Time) (tangledlex.RepoPull, error) { + now := createdAt.Format(time.RFC3339) + patchBlob, err := patchBlob(input.Patch) + if err != nil { + return tangledlex.RepoPull{}, err + } + return tangledlex.RepoPull{ + LexiconTypeID: pullCollection, + Title: input.Title, + Body: optionalString(input.Body), + CreatedAt: now, + Target: &tangledlex.RepoPull_Target{ + Repo: input.TargetRepoDid, + Branch: input.Base, + }, + Source: &tangledlex.RepoPull_Source{ + Repo: optionalString(input.SourceRepoDid), + Branch: input.Head, + }, + Rounds: []*tangledlex.RepoPull_Round{{ + CreatedAt: now, + PatchBlob: &patchBlob, + }}, + }, nil +} + +func patchBlob(blob *atproto.Blob) (lexutil.LexBlob, error) { + if blob == nil || blob.Ref == nil { + return lexutil.LexBlob{}, nil + } + data, err := json.Marshal(blob) + if err != nil { + return lexutil.LexBlob{}, fmt.Errorf("encode pull patch blob: %w", err) + } + var result lexutil.LexBlob + if err := json.Unmarshal(data, &result); err != nil { + return lexutil.LexBlob{}, fmt.Errorf("decode pull patch blob: %w", err) + } + return result, nil +} diff --git a/internal/app/pull_edit.go b/internal/app/pull_edit.go new file mode 100644 index 0000000..5ec3735 --- /dev/null +++ b/internal/app/pull_edit.go @@ -0,0 +1,13 @@ +package app + +import "context" + +// EditPull patches a pull request's title and/or body. A nil pointer leaves +// the field untouched. +func (s *Service) EditPull(ctx context.Context, rkey string, title, body *string) error { + atClient, did, err := s.authenticatedPDS(ctx) + if err != nil { + return err + } + return editRecord(ctx, atClient, did, pullCollection, rkey, title, body) +} diff --git a/internal/app/pull_list.go b/internal/app/pull_list.go new file mode 100644 index 0000000..b651844 --- /dev/null +++ b/internal/app/pull_list.go @@ -0,0 +1,23 @@ +package app + +import ( + "context" + "fmt" + + "github.com/alyraffauf/tg/tangled" +) + +// ListPulls lists every pull request in the target repository. +func (s *Service) ListPulls(ctx context.Context, t Target) ([]Item, error) { + repoDid, err := s.repoDID(ctx, t) + if err != nil { + return nil, err + } + pulls, err := s.appview.ListPulls(ctx, repoDid, tangled.ListOpts{ + Limit: defaultListLimit, + }) + if err != nil { + return nil, fmt.Errorf("list PRs for %q: %w", t.Repo, err) + } + return s.buildItems(ctx, pulls.Items, decodePull), nil +} diff --git a/internal/app/pull_merge.go b/internal/app/pull_merge.go new file mode 100644 index 0000000..8d74d84 --- /dev/null +++ b/internal/app/pull_merge.go @@ -0,0 +1,52 @@ +package app + +import ( + "context" + "fmt" + + "github.com/alyraffauf/tg/knot" +) + +// MergePull applies a pull request on its knot and records the merged status. +func (s *Service) MergePull(ctx context.Context, t Target, rkey string) (*StateResult, error) { + atClient, did, err := s.authenticatedPDS(ctx) + if err != nil { + return nil, err + } + repo, err := s.resolveRepo(ctx, t) + if err != nil { + return nil, err + } + pull, err := s.pullPatch(ctx, repo, rkey) + if err != nil { + return nil, err + } + repoDID := stringValue(repo.Value.RepoDid) + if repoDID == "" { + return nil, fmt.Errorf("repository %q has no repository DID", t.String()) + } + repoName := stringValue(repo.Value.Name) + if repoName == "" { + repoName = t.Repo + } + knotHost := repo.Value.Knot + if knotHost == "" { + return nil, fmt.Errorf("repository %q has no knot", t.String()) + } + token, err := atClient.GetServiceAuth(ctx, "did:web:"+knotHost, "sh.tangled.repo.merge") + if err != nil { + return nil, err + } + commitMessage := pull.Title + commitBody := pull.Body + if err := s.knot.New(knotHost, token).Merge(ctx, knot.MergeInput{ + DID: extractDID(repo.URI), Name: repoName, Repo: repoDID, Branch: pull.TargetBranch, Patch: string(pull.Patch), + CommitMessage: &commitMessage, CommitBody: optionalString(commitBody), + }); err != nil { + return nil, err + } + if err := putState(ctx, atClient, did, rkey, pullCollection, pull.URI, "merged"); err != nil { + return nil, fmt.Errorf("record merged pull request status: %w", err) + } + return &StateResult{Rkey: rkey, State: "merged"}, nil +} diff --git a/internal/app/pull_patch.go b/internal/app/pull_patch.go new file mode 100644 index 0000000..49573df --- /dev/null +++ b/internal/app/pull_patch.go @@ -0,0 +1,114 @@ +package app + +import ( + "bytes" + "compress/gzip" + "context" + "encoding/json" + "fmt" + "io" + + "github.com/alyraffauf/tg/atproto" + "github.com/alyraffauf/tg/internal/tangledlex" + "github.com/alyraffauf/tg/tangled" +) + +// maxPullPatchSize caps a downloaded pull-request patch. +const maxPullPatchSize = 100 << 20 + +// PullPatch contains the latest decompressed patch and its target branch. +type PullPatch struct { + URI string + Title string + Body string + TargetBranch string + Patch []byte +} + +// PullPatch fetches a pull request's latest patch, decompressed and ready to +// apply or stream. +func (s *Service) PullPatch(ctx context.Context, t Target, rkey string) (*PullPatch, error) { + repo, err := s.resolveRepo(ctx, t) + if err != nil { + return nil, err + } + return s.pullPatch(ctx, repo, rkey) +} + +func (s *Service) pullPatch(ctx context.Context, repo *tangled.Repo, rkey string) (*PullPatch, error) { + repoDID := stringValue(repo.Value.RepoDid) + if repoDID == "" { + return nil, fmt.Errorf("repository has no repository DID") + } + pulls, err := s.appview.ListPulls(ctx, repoDID, tangled.ListOpts{Limit: defaultListLimit}) + if err != nil { + return nil, fmt.Errorf("list PRs for repository %q: %w", repoDID, err) + } + pull, err := findByRKey(pulls.Items, rkey, "pull request") + if err != nil { + return nil, err + } + record, patchCID, err := latestPullPatch(pull, rkey) + if err != nil { + return nil, err + } + if record.Target == nil || record.Target.Branch == "" { + return nil, fmt.Errorf("pull request %q has no target branch", rkey) + } + patch, err := s.downloadPullPatch(ctx, extractDID(pull.URI), patchCID) + if err != nil { + return nil, err + } + return &PullPatch{URI: pull.URI, Title: record.Title, Body: stringValue(record.Body), TargetBranch: pullTargetBranch(record.Target), Patch: patch}, nil +} + +func latestPullPatch(pull *tangled.ListItem, rkey string) (tangledlex.RepoPull, string, error) { + var record tangledlex.RepoPull + 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) + } + lastRound := record.Rounds[len(record.Rounds)-1] + if lastRound == nil || lastRound.PatchBlob == nil { + return record, "", fmt.Errorf("pull request %q has no patch blob", rkey) + } + patchCID := lastRound.PatchBlob.Ref.String() + if patchCID == "" { + return record, "", fmt.Errorf("pull request %q has no patch blob", rkey) + } + return record, patchCID, nil +} + +func (s *Service) downloadPullPatch(ctx context.Context, authorDID, cid string) ([]byte, error) { + pdsHost, err := s.resolver.ResolvePDS(ctx, authorDID) + if err != nil { + return nil, fmt.Errorf("resolve PDS for author %q: %w", authorDID, err) + } + compressed, err := atproto.NewPublic(pdsHost, s.httpClient).GetBlob(ctx, authorDID, cid) + if err != nil { + return nil, fmt.Errorf("download patch: %w", err) + } + patch, err := gzip.NewReader(bytes.NewReader(compressed)) + if err != nil { + return nil, fmt.Errorf("decompress patch: %w", err) + } + defer patch.Close() + contents, err := readLimited(patch, maxPullPatchSize) + if err != nil { + return nil, fmt.Errorf("decompress patch: %w", err) + } + return contents, nil +} + +func readLimited(reader io.Reader, limit int64) ([]byte, error) { + contents, err := io.ReadAll(io.LimitReader(reader, limit+1)) + if err != nil { + return nil, err + } + if int64(len(contents)) > limit { + return nil, fmt.Errorf("patch exceeds %d bytes", limit) + } + return contents, nil +} diff --git a/internal/app/pull_state.go b/internal/app/pull_state.go new file mode 100644 index 0000000..560aaf8 --- /dev/null +++ b/internal/app/pull_state.go @@ -0,0 +1,20 @@ +package app + +import "context" + +// SetPullState closes or reopens a pull request. status is the bare verb +// ("open" or "closed"). +func (s *Service) SetPullState(ctx context.Context, t Target, rkey, status string) (*StateResult, error) { + atClient, did, err := s.authenticatedPDS(ctx) + if err != nil { + return nil, err + } + target, _, err := s.targetRecord(ctx, t, pullCollection, rkey) + if err != nil { + return nil, err + } + if err := putState(ctx, atClient, did, rkey, pullCollection, target, status); err != nil { + return nil, err + } + return &StateResult{Rkey: rkey, State: status}, nil +} diff --git a/internal/app/pull_update.go b/internal/app/pull_update.go new file mode 100644 index 0000000..ffae482 --- /dev/null +++ b/internal/app/pull_update.go @@ -0,0 +1,51 @@ +package app + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/alyraffauf/tg/internal/tangledlex" +) + +// UpdatePullRound generates a fresh patch for an existing pull request and +// appends it as a new round, using compare-and-swap on the record CID. +func (s *Service) UpdatePullRound(ctx context.Context, repoDir, rkey string) error { + atClient, did, err := s.authenticatedPDS(ctx) + if err != nil { + return err + } + + return updateRecord(ctx, atClient, did, pullCollection, rkey, func(value any) (tangledlex.RepoPull, error) { + data, err := json.Marshal(value) + if err != nil { + return tangledlex.RepoPull{}, fmt.Errorf("encode existing pull request: %w", err) + } + var record tangledlex.RepoPull + if err := json.Unmarshal(data, &record); err != nil { + return tangledlex.RepoPull{}, fmt.Errorf("decode existing pull request: %w", err) + } + if record.Target == nil || record.Source == nil || record.Target.Branch == "" || record.Source.Branch == "" { + return tangledlex.RepoPull{}, fmt.Errorf("pull request %q has no source and target branches", rkey) + } + + patch, err := s.git.GeneratePatch(ctx, repoDir, record.Target.Branch, record.Source.Branch) + if err != nil { + return tangledlex.RepoPull{}, fmt.Errorf("generate pull request patch: %w", err) + } + blob, err := atClient.UploadBlob(ctx, patch, patchMimeType) + if err != nil { + return tangledlex.RepoPull{}, err + } + patchBlob, err := patchBlob(blob) + if err != nil { + return tangledlex.RepoPull{}, err + } + record.Rounds = append(record.Rounds, &tangledlex.RepoPull_Round{ + CreatedAt: time.Now().UTC().Format(time.RFC3339), + PatchBlob: &patchBlob, + }) + return record, nil + }) +} diff --git a/internal/app/pull_view.go b/internal/app/pull_view.go new file mode 100644 index 0000000..ce91e97 --- /dev/null +++ b/internal/app/pull_view.go @@ -0,0 +1,40 @@ +package app + +import ( + "context" + "fmt" + + "github.com/alyraffauf/tg/tangled" +) + +// ViewPull finds a single pull request by rkey within the target repository. +func (s *Service) ViewPull(ctx context.Context, t Target, rkey string) (*ViewResult, error) { + repoDid, err := s.repoDID(ctx, t) + if err != nil { + return nil, err + } + pulls, err := s.appview.ListPulls(ctx, repoDid, tangled.ListOpts{ + Limit: defaultListLimit, + }) + if err != nil { + return nil, fmt.Errorf("list PRs for %s: %w", t, err) + } + found, err := findByRKey(pulls.Items, rkey, "pull request") + if err != nil { + return nil, err + } + decoded, err := decodePull(found.Value) + if err != nil { + return nil, fmt.Errorf("decode pull request %q: %w", rkey, err) + } + return &ViewResult{ + Rkey: rkey, + Title: decoded.Title, + State: found.State, + Body: decoded.Body, + Author: s.resolveAuthor(ctx, extractDID(found.URI)), + CreatedAt: decoded.CreatedAt, + SourceBranch: decoded.SourceBranch, + TargetBranch: decoded.TargetBranch, + }, nil +} diff --git a/internal/app/pulls.go b/internal/app/pulls.go deleted file mode 100644 index 6f58b67..0000000 --- a/internal/app/pulls.go +++ /dev/null @@ -1,484 +0,0 @@ -package app - -import ( - "bytes" - "compress/gzip" - "context" - "encoding/json" - "fmt" - "io" - "time" - - "github.com/alyraffauf/tg/atproto" - "github.com/alyraffauf/tg/internal/tangledlex" - "github.com/alyraffauf/tg/knot" - "github.com/alyraffauf/tg/tangled" - "github.com/bluesky-social/indigo/atproto/syntax" - lexutil "github.com/bluesky-social/indigo/lex/util" -) - -// maxPullPatchSize caps a downloaded pull-request patch. -const maxPullPatchSize = 100 << 20 - -// PullPatch contains the latest decompressed patch and its target branch. -type PullPatch struct { - URI string - Title string - Body string - TargetBranch string - Patch []byte -} - -// CreatePullInput configures pull request creation. -type CreatePullInput struct { - RepoDir string // local git repository (for branch detection + patch) - Title string - Body string - Base string // empty: detect origin's default branch - Head string // empty: current branch - Target Target - Source *Target // nil: same as Target -} - -// pullRecordInput is the write-side input to newPullRecord. -type pullRecordInput struct { - Title string - Body string - TargetRepoDid string - SourceRepoDid string - Base string - Head string - Patch *atproto.Blob -} - -// ListPulls lists every pull request in the target repository. -func (s *Service) ListPulls(ctx context.Context, t Target) ([]Item, error) { - repoDid, err := s.repoDID(ctx, t) - if err != nil { - return nil, err - } - pulls, err := s.appview.ListPulls(ctx, repoDid, tangled.ListOpts{ - Limit: defaultListLimit, - }) - if err != nil { - return nil, fmt.Errorf("list PRs for %q: %w", t.Repo, err) - } - return s.buildItems(ctx, pulls.Items, decodePull), nil -} - -// ViewPull finds a single pull request by rkey within the target repository. -func (s *Service) ViewPull(ctx context.Context, t Target, rkey string) (*ViewResult, error) { - repoDid, err := s.repoDID(ctx, t) - if err != nil { - return nil, err - } - pulls, err := s.appview.ListPulls(ctx, repoDid, tangled.ListOpts{ - Limit: defaultListLimit, - }) - if err != nil { - return nil, fmt.Errorf("list PRs for %s: %w", t, err) - } - found, err := findByRKey(pulls.Items, rkey, "pull request") - if err != nil { - return nil, err - } - decoded, err := decodePull(found.Value) - if err != nil { - return nil, fmt.Errorf("decode pull request %q: %w", rkey, err) - } - return &ViewResult{ - Rkey: rkey, - Title: decoded.Title, - State: found.State, - Body: decoded.Body, - Author: s.resolveAuthor(ctx, extractDID(found.URI)), - CreatedAt: decoded.CreatedAt, - SourceBranch: decoded.SourceBranch, - TargetBranch: decoded.TargetBranch, - }, nil -} - -// CreatePull generates a patch from the local repository, uploads it, and -// writes a pull record. -func (s *Service) CreatePull(ctx context.Context, in CreatePullInput) (*PRCreateResult, error) { - atClient, did, err := s.authenticatedPDS(ctx) - if err != nil { - return nil, err - } - - head := in.Head - if head == "" { - head, err = s.git.CurrentBranch(ctx, in.RepoDir) - if err != nil { - return nil, fmt.Errorf("determine source branch: %w", err) - } - } - base := in.Base - if base == "" { - base, err = s.git.DefaultBranch(ctx, in.RepoDir) - if err != nil { - return nil, fmt.Errorf("determine target branch; set --base explicitly: %w", err) - } - } - - target, err := s.resolveRepo(ctx, in.Target) - if err != nil { - return nil, err - } - if !atURIPrefix(target.URI) { - return nil, fmt.Errorf("target repository %q has no strong at:// URI", in.Target.Repo) - } - source := target - if in.Source != nil { - source, err = s.resolveRepo(ctx, *in.Source) - if err != nil { - return nil, fmt.Errorf("resolve source repository: %w", err) - } - } - if stringValue(source.Value.RepoDid) == "" { - return nil, fmt.Errorf("source repository has no repo DID") - } - - patch, err := s.git.GeneratePatch(ctx, in.RepoDir, base, head) - if err != nil { - return nil, fmt.Errorf("generate pull request patch: %w", err) - } - blob, err := atClient.UploadBlob(ctx, patch, patchMimeType) - if err != nil { - return nil, err - } - - uri, err := createPullRecord(ctx, atClient, did, pullRecordInput{ - Title: in.Title, - Body: in.Body, - TargetRepoDid: stringValue(target.Value.RepoDid), - SourceRepoDid: stringValue(source.Value.RepoDid), - Base: base, - Head: head, - Patch: blob, - }) - if err != nil { - return nil, err - } - return &PRCreateResult{URI: uri, Title: in.Title, Base: base, Head: head}, nil -} - -func (s *Service) UpdatePullRound(ctx context.Context, repoDir, rkey string) error { - atClient, did, err := s.authenticatedPDS(ctx) - if err != nil { - return err - } - - return updateRecord(ctx, atClient, did, pullCollection, rkey, func(value any) (tangledlex.RepoPull, error) { - data, err := json.Marshal(value) - if err != nil { - return tangledlex.RepoPull{}, fmt.Errorf("encode existing pull request: %w", err) - } - var record tangledlex.RepoPull - if err := json.Unmarshal(data, &record); err != nil { - return tangledlex.RepoPull{}, fmt.Errorf("decode existing pull request: %w", err) - } - if record.Target == nil || record.Source == nil || record.Target.Branch == "" || record.Source.Branch == "" { - return tangledlex.RepoPull{}, fmt.Errorf("pull request %q has no source and target branches", rkey) - } - - patch, err := s.git.GeneratePatch(ctx, repoDir, record.Target.Branch, record.Source.Branch) - if err != nil { - return tangledlex.RepoPull{}, fmt.Errorf("generate pull request patch: %w", err) - } - blob, err := atClient.UploadBlob(ctx, patch, patchMimeType) - if err != nil { - return tangledlex.RepoPull{}, err - } - patchBlob, err := patchBlob(blob) - if err != nil { - return tangledlex.RepoPull{}, err - } - record.Rounds = append(record.Rounds, &tangledlex.RepoPull_Round{ - CreatedAt: time.Now().UTC().Format(time.RFC3339), - PatchBlob: &patchBlob, - }) - return record, nil - }) -} - -func atURIPrefix(uri string) bool { return len(uri) >= 5 && uri[:5] == "at://" } - -func createPullRecord(ctx context.Context, atClient pdsClient, did string, input pullRecordInput) (string, error) { - record, err := newPullRecord(input, time.Now().UTC()) - if err != nil { - return "", err - } - uri, _, err := atClient.PutRecord(ctx, atproto.PutRecordInput{ - Repo: did, - Collection: pullCollection, - Rkey: string(syntax.NewTIDNow(0)), - Record: record, - }) - if err != nil { - return "", fmt.Errorf("create pull request record: %w", err) - } - return uri, nil -} - -func newPullRecord(input pullRecordInput, createdAt time.Time) (tangledlex.RepoPull, error) { - now := createdAt.Format(time.RFC3339) - patchBlob, err := patchBlob(input.Patch) - if err != nil { - return tangledlex.RepoPull{}, err - } - return tangledlex.RepoPull{ - LexiconTypeID: pullCollection, - Title: input.Title, - Body: optionalString(input.Body), - CreatedAt: now, - Target: &tangledlex.RepoPull_Target{ - Repo: input.TargetRepoDid, - Branch: input.Base, - }, - Source: &tangledlex.RepoPull_Source{ - Repo: optionalString(input.SourceRepoDid), - Branch: input.Head, - }, - Rounds: []*tangledlex.RepoPull_Round{{ - CreatedAt: now, - PatchBlob: &patchBlob, - }}, - }, nil -} - -func patchBlob(blob *atproto.Blob) (lexutil.LexBlob, error) { - if blob == nil || blob.Ref == nil { - return lexutil.LexBlob{}, nil - } - data, err := json.Marshal(blob) - if err != nil { - return lexutil.LexBlob{}, fmt.Errorf("encode pull patch blob: %w", err) - } - var result lexutil.LexBlob - if err := json.Unmarshal(data, &result); err != nil { - return lexutil.LexBlob{}, fmt.Errorf("decode pull patch blob: %w", err) - } - return result, nil -} - -// CommentPull adds a comment to the pull request identified by rkey. -func (s *Service) CommentPull(ctx context.Context, t Target, rkey, body string) (*CreatedRecordResult, error) { - repoDid, err := s.repoDID(ctx, t) - if err != nil { - return nil, err - } - pulls, err := s.appview.ListPulls(ctx, repoDid, tangled.ListOpts{ - Limit: defaultListLimit, - }) - if err != nil { - return nil, fmt.Errorf("list PRs for %s: %w", t, err) - } - pull, err := findByRKey(pulls.Items, rkey, "pull request") - if err != nil { - return nil, err - } - return s.createPullComment(ctx, pull.URI, body) -} - -func (s *Service) createPullComment(ctx context.Context, pullURI, body string) (*CreatedRecordResult, error) { - atClient, did, err := s.authenticatedPDS(ctx) - if err != nil { - return nil, err - } - rkey := string(syntax.NewTIDNow(0)) - uri, _, err := atClient.PutRecord(ctx, atproto.PutRecordInput{ - Repo: did, - Collection: pullCollection + ".comment", - Rkey: rkey, - Record: tangledlex.RepoPullComment{ - LexiconTypeID: pullCollection + ".comment", - Pull: pullURI, - Body: body, - CreatedAt: time.Now().UTC().Format(time.RFC3339), - }, - }) - if err != nil { - return nil, fmt.Errorf("create pull request comment: %w", err) - } - return &CreatedRecordResult{Rkey: rkey, URI: uri}, nil -} - -// PullPatch fetches a pull request's latest patch, decompressed and ready to -// apply or stream. -func (s *Service) PullPatch(ctx context.Context, t Target, rkey string) (*PullPatch, error) { - repo, err := s.resolveRepo(ctx, t) - if err != nil { - return nil, err - } - return s.pullPatch(ctx, repo, rkey) -} - -func (s *Service) pullPatch(ctx context.Context, repo *tangled.Repo, rkey string) (*PullPatch, error) { - repoDID := stringValue(repo.Value.RepoDid) - if repoDID == "" { - return nil, fmt.Errorf("repository has no repository DID") - } - pulls, err := s.appview.ListPulls(ctx, repoDID, tangled.ListOpts{ - Limit: defaultListLimit, - }) - if err != nil { - return nil, fmt.Errorf("list PRs for repository %q: %w", repoDID, err) - } - pull, err := findByRKey(pulls.Items, rkey, "pull request") - if err != nil { - return nil, err - } - record, patchCID, err := latestPullPatch(pull, rkey) - if err != nil { - return nil, err - } - if record.Target == nil || record.Target.Branch == "" { - return nil, fmt.Errorf("pull request %q has no target branch", rkey) - } - patch, err := s.downloadPullPatch(ctx, extractDID(pull.URI), patchCID) - if err != nil { - return nil, err - } - return &PullPatch{ - URI: pull.URI, - Title: record.Title, - Body: stringValue(record.Body), - TargetBranch: pullTargetBranch(record.Target), - Patch: patch, - }, nil -} - -func latestPullPatch(pull *tangled.ListItem, rkey string) (tangledlex.RepoPull, string, error) { - var record tangledlex.RepoPull - 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) - } - lastRound := record.Rounds[len(record.Rounds)-1] - if lastRound == nil || lastRound.PatchBlob == nil { - return record, "", fmt.Errorf("pull request %q has no patch blob", rkey) - } - patchCID := lastRound.PatchBlob.Ref.String() - if patchCID == "" { - return record, "", fmt.Errorf("pull request %q has no patch blob", rkey) - } - return record, patchCID, nil -} - -func (s *Service) downloadPullPatch(ctx context.Context, authorDID, cid string) ([]byte, error) { - pdsHost, err := s.resolver.ResolvePDS(ctx, authorDID) - if err != nil { - return nil, fmt.Errorf("resolve PDS for author %q: %w", authorDID, err) - } - compressed, err := atproto.NewPublic(pdsHost, s.httpClient).GetBlob(ctx, authorDID, cid) - if err != nil { - return nil, fmt.Errorf("download patch: %w", err) - } - patch, err := gzip.NewReader(bytes.NewReader(compressed)) - if err != nil { - return nil, fmt.Errorf("decompress patch: %w", err) - } - defer patch.Close() - contents, err := readLimited(patch, maxPullPatchSize) - if err != nil { - return nil, fmt.Errorf("decompress patch: %w", err) - } - return contents, nil -} - -func readLimited(reader io.Reader, limit int64) ([]byte, error) { - contents, err := io.ReadAll(io.LimitReader(reader, limit+1)) - if err != nil { - return nil, err - } - if int64(len(contents)) > limit { - return nil, fmt.Errorf("patch exceeds %d bytes", limit) - } - return contents, nil -} - -// SetPullState closes or reopens a pull request. status is the bare verb -// ("open" or "closed"). -func (s *Service) SetPullState(ctx context.Context, t Target, rkey, status string) (*StateResult, error) { - atClient, did, err := s.authenticatedPDS(ctx) - if err != nil { - return nil, err - } - target, _, err := s.targetRecord(ctx, t, pullCollection, rkey) - if err != nil { - return nil, err - } - if err := putState(ctx, atClient, did, rkey, pullCollection, target, status); err != nil { - return nil, err - } - return &StateResult{Rkey: rkey, State: status}, nil -} - -// MergePull applies a pull request on its knot and records the merged status. -func (s *Service) MergePull(ctx context.Context, t Target, rkey string) (*StateResult, error) { - atClient, did, err := s.authenticatedPDS(ctx) - if err != nil { - return nil, err - } - repo, err := s.resolveRepo(ctx, t) - if err != nil { - return nil, err - } - pull, err := s.pullPatch(ctx, repo, rkey) - if err != nil { - return nil, err - } - repoDID := stringValue(repo.Value.RepoDid) - if repoDID == "" { - return nil, fmt.Errorf("repository %q has no repository DID", t.String()) - } - repoName := stringValue(repo.Value.Name) - if repoName == "" { - repoName = t.Repo - } - knotHost := repo.Value.Knot - if knotHost == "" { - return nil, fmt.Errorf("repository %q has no knot", t.String()) - } - token, err := atClient.GetServiceAuth(ctx, "did:web:"+knotHost, "sh.tangled.repo.merge") - if err != nil { - return nil, err - } - commitMessage := pull.Title - commitBody := pull.Body - if err := s.knot.New(knotHost, token).Merge(ctx, knot.MergeInput{ - DID: extractDID(repo.URI), Name: repoName, Repo: repoDID, Branch: pull.TargetBranch, Patch: string(pull.Patch), - CommitMessage: &commitMessage, CommitBody: optionalString(commitBody), - }); err != nil { - return nil, err - } - if err := putState(ctx, atClient, did, rkey, pullCollection, pull.URI, "merged"); err != nil { - return nil, fmt.Errorf("record merged pull request status: %w", err) - } - return &StateResult{Rkey: rkey, State: "merged"}, nil -} - -// EditPull patches a pull request's title and/or body. A nil pointer leaves -// the field untouched. -func (s *Service) EditPull(ctx context.Context, rkey string, title, body *string) error { - atClient, did, err := s.authenticatedPDS(ctx) - if err != nil { - return err - } - return editRecord(ctx, atClient, did, pullCollection, rkey, title, body) -} - -// repoKnot resolves the knot host for a repository record URI. -func (s *Service) repoKnot(ctx context.Context, repoURI string) (string, error) { - repo, err := s.appview.GetRepo(ctx, repoURI) - if err != nil { - return "", fmt.Errorf("get repository: %w", err) - } - if repo.Value.Knot == "" { - return "", fmt.Errorf("repository record has no knot") - } - return repo.Value.Knot, nil -} diff --git a/internal/app/repo.go b/internal/app/repo.go new file mode 100644 index 0000000..da37ba1 --- /dev/null +++ b/internal/app/repo.go @@ -0,0 +1,54 @@ +package app + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/bluesky-social/indigo/atproto/syntax" +) + +const ( + knotCollection = "sh.tangled.knot" + maxKnotRegistrations = 10 +) + +type knotRegistration struct { + LexiconTypeID string `json:"$type"` + CreatedAt string `json:"createdAt"` +} + +// ownerHandle resolves a DID to its handle, falling back to the raw DID. +func (s *Service) ownerHandle(ctx context.Context, did string) string { + if ident, err := s.resolver.ResolveDID(ctx, did); err == nil { + return ident.Handle.String() + } + return did +} + +// parseKnotHostname normalizes a Knot hostname argument. +func parseKnotHostname(raw string) (string, error) { + hostname, err := syntax.ParseHandle(raw) + if err != nil { + return "", fmt.Errorf("invalid Knot hostname %q: %w", raw, err) + } + return hostname.Normalize().String(), nil +} + +func validateKnotRegistration(value any) error { + data, err := json.Marshal(value) + if err != nil { + return fmt.Errorf("encode record: %w", err) + } + var registration knotRegistration + if err := json.Unmarshal(data, ®istration); err != nil { + return fmt.Errorf("decode record: %w", err) + } + if registration.LexiconTypeID != knotCollection { + return fmt.Errorf("$type must be %q", knotCollection) + } + if _, err := syntax.ParseDatetime(registration.CreatedAt); err != nil { + return fmt.Errorf("invalid createdAt: %w", err) + } + return nil +} diff --git a/internal/app/git.go b/internal/app/repo_clone.go similarity index 54% rename from internal/app/git.go rename to internal/app/repo_clone.go index 30331e5..bee5d01 100644 --- a/internal/app/git.go +++ b/internal/app/repo_clone.go @@ -73,60 +73,3 @@ func validateCloneProtocol(protocol string) (string, error) { } return protocol, nil } - -// CheckoutPullInput configures reconstructing a pull request in a local -// repository. Target may be empty, in which case the current repository is -// used. -type CheckoutPullInput struct { - RepoDir string - Rkey string - Target *Target - Branch string - Force bool -} - -// CheckoutPull downloads and applies the latest pull request patch. -func (s *Service) CheckoutPull(ctx context.Context, in CheckoutPullInput) (*PRCheckoutResult, error) { - localTarget, localRecord, err := s.repoFromCWD(ctx) - if err != nil { - return nil, fmt.Errorf("detect local repository: %w", err) - } - - target := localTarget - if in.Target != nil { - target = *in.Target - } - targetRecord := localRecord - if target != localTarget { - targetRecord, err = s.resolveRepo(ctx, target) - if err != nil { - return nil, err - } - } - if stringValue(targetRecord.Value.RepoDid) != stringValue(localRecord.Value.RepoDid) { - return nil, fmt.Errorf("pull request target %s does not match the current repository", target) - } - - patch, err := s.PullPatch(ctx, target, in.Rkey) - if err != nil { - return nil, err - } - if patch.TargetBranch == "" { - return nil, fmt.Errorf("pull request %q has no target branch", in.Rkey) - } - - branch := in.Branch - if branch == "" { - branch = "pr-" + in.Rkey - } - if err := s.git.CheckoutPatch(ctx, gitutil.CheckoutPatchParams{ - RepoDir: in.RepoDir, - Branch: branch, - TargetBranch: patch.TargetBranch, - Patch: patch.Patch, - Force: in.Force, - }); err != nil { - return nil, err - } - return &PRCheckoutResult{Rkey: in.Rkey, Branch: branch}, nil -} diff --git a/internal/app/repo_create.go b/internal/app/repo_create.go new file mode 100644 index 0000000..dc5c0d9 --- /dev/null +++ b/internal/app/repo_create.go @@ -0,0 +1,239 @@ +package app + +import ( + "context" + "fmt" + "sort" + "strings" + "sync" + "time" + + "github.com/alyraffauf/tg/atproto" + "github.com/alyraffauf/tg/internal/gitutil" + "github.com/alyraffauf/tg/internal/tangledlex" + "github.com/alyraffauf/tg/knot" + "github.com/bluesky-social/indigo/atproto/syntax" +) + +type ProvisionRepoInput struct { + KnotHost string + Name string + Description string +} + +// CreateRepoInput configures provisioning and optional local setup. +type CreateRepoInput struct { + KnotHost string + SSHPort int + Name string + Description string + Clone bool + CloneProtocol string + PushPath string + RemoteName string +} + +// CreateRepo provisions a repository and performs requested local Git setup. +func (s *Service) CreateRepo(ctx context.Context, in CreateRepoInput) (*RepoCreateResult, error) { + if in.Clone { + cloneProtocol, err := validateCloneProtocol(in.CloneProtocol) + if err != nil { + return nil, err + } + in.CloneProtocol = cloneProtocol + } + if ((in.Clone && in.CloneProtocol == "ssh") || in.PushPath != "") && (in.SSHPort < 1 || in.SSHPort > 65535) { + return nil, fmt.Errorf("SSH port must be between 1 and 65535") + } + if in.KnotHost != "" { + knotHost, err := parseKnotHostname(in.KnotHost) + if err != nil { + return nil, err + } + in.KnotHost = knotHost + } + uri, handle, selectedKnot, warnings, err := s.provisionRepo(ctx, ProvisionRepoInput{ + KnotHost: in.KnotHost, Name: in.Name, Description: in.Description, + }) + if err != nil { + return nil, err + } + result := &RepoCreateResult{Handle: handle, Name: in.Name, URI: uri, Knot: selectedKnot, Warnings: warnings} + if in.Clone { + if _, err := s.cloneRepo(ctx, CloneRepoInput{ + KnotHost: selectedKnot, SSHPort: in.SSHPort, + Protocol: in.CloneProtocol, Handle: handle, Repo: in.Name, Destination: in.Name, + }); err != nil { + return nil, fmt.Errorf("clone new repository: %w", err) + } + result.Cloned = true + } + if in.PushPath == "" { + return result, nil + } + pushResult, err := s.pushNewRepo(ctx, PushNewRepoInput{ + KnotHost: selectedKnot, SSHPort: in.SSHPort, RepoURI: uri, Dir: in.PushPath, + Handle: handle, Repo: in.Name, RemoteName: in.RemoteName, + }) + if pushResult.defaultBranchWarning != nil { + result.Warnings = append(result.Warnings, fmt.Sprintf("could not set default branch: %v", pushResult.defaultBranchWarning)) + } + if err != nil { + return nil, err + } + result.Pushed = true + result.DefaultBranch = pushResult.defaultBranch + return result, nil +} + +func (s *Service) provisionRepo(ctx context.Context, in ProvisionRepoInput) (uri, handle, knotHost string, warnings []string, err error) { + atClient, did, err := s.authenticatedPDS(ctx) + if err != nil { + return "", "", "", nil, err + } + knotHost, warnings, err = s.selectCreationKnot(ctx, atClient, did, in.KnotHost) + if err != nil { + return "", "", "", nil, err + } + token, err := atClient.GetServiceAuth(ctx, "did:web:"+knotHost, "sh.tangled.repo.create") + if err != nil { + return "", "", "", nil, err + } + repoDid, err := s.knot.New(knotHost, token).CreateRepo(ctx, knot.CreateRepoInput{ + Name: in.Name, + Rkey: in.Name, + }) + if err != nil { + return "", "", "", nil, err + } + record := tangledlex.Repo{ + LexiconTypeID: repoCollection, + Knot: knotHost, + CreatedAt: time.Now().UTC().Format(time.RFC3339), + RepoDid: optionalString(repoDid), + } + if in.Description != "" { + record.Description = optionalString(in.Description) + } + uri, _, err = atClient.PutRecord(ctx, atproto.PutRecordInput{ + Repo: did, + Collection: repoCollection, + Rkey: in.Name, + Record: record, + }) + if err != nil { + return "", "", "", nil, err + } + return uri, s.ownerHandle(ctx, did), knotHost, warnings, nil +} + +func (s *Service) selectCreationKnot(ctx context.Context, atClient pdsClient, did, configured string) (string, []string, error) { + if configured != "" { + return configured, nil, nil + } + page, err := atClient.ListRecords(ctx, did, knotCollection, atproto.ListRecordsOpts{Limit: maxKnotRegistrations + 1}) + if err != nil { + return "", nil, fmt.Errorf("discover verified Knots: %w", err) + } + records := page.Records + if len(records) == 0 { + return DefaultKnot, nil, nil + } + if len(records) > maxKnotRegistrations { + return "", nil, fmt.Errorf("found more than %d Knot registrations; select one with --knot or set it in the config file", maxKnotRegistrations) + } + hosts := make([]string, 0, len(records)) + warnings := make([]string, 0, len(records)) + seen := make(map[string]bool, len(records)) + for _, record := range records { + uri, err := syntax.ParseATURI(record.URI) + if err != nil || uri.Authority().String() != did || uri.Collection().String() != knotCollection || uri.RecordKey().String() == "" { + warnings = append(warnings, fmt.Sprintf("ignored invalid Knot registration URI %q", record.URI)) + continue + } + if err := validateKnotRegistration(record.Value); err != nil { + warnings = append(warnings, fmt.Sprintf("ignored invalid Knot registration %q: %v", record.URI, err)) + continue + } + host, err := parseKnotHostname(uri.RecordKey().String()) + if err != nil { + warnings = append(warnings, fmt.Sprintf("ignored Knot registration %q: %v", record.URI, err)) + continue + } + if seen[host] { + warnings = append(warnings, fmt.Sprintf("ignored duplicate Knot registration for %s", host)) + continue + } + seen[host] = true + hosts = append(hosts, host) + } + verificationErrors := make([]error, len(hosts)) + var verificationGroup sync.WaitGroup + for index, host := range hosts { + verificationGroup.Add(1) + go func() { + defer verificationGroup.Done() + verificationErrors[index] = s.knotOwnershipVerifier.Verify(ctx, host, did) + }() + } + verificationGroup.Wait() + verified := make([]string, 0, len(hosts)) + for index, host := range hosts { + if err := verificationErrors[index]; err != nil { + warnings = append(warnings, fmt.Sprintf("could not verify Knot registration for %s: %v", host, err)) + continue + } + verified = append(verified, host) + } + if len(verified) == 0 { + return "", nil, fmt.Errorf("no Knot registrations could be verified: %s", strings.Join(warnings, "; ")) + } + if len(verified) > 1 { + sort.Strings(verified) + return "", nil, fmt.Errorf("multiple verified Knots found (%s); select one with --knot or set it in the config file", strings.Join(verified, ", ")) + } + return verified[0], warnings, nil +} + +func (s *Service) setDefaultBranchFromDir(ctx context.Context, knotHost, repoURI, dir string) (string, error) { + atClient, _, err := s.authenticatedPDS(ctx) + if err != nil { + return "", err + } + branch, err := s.git.CurrentBranch(ctx, dir) + if err != nil { + return "", err + } + if err := s.setKnotDefaultBranch(ctx, atClient, knotHost, repoURI, branch); err != nil { + return branch, err + } + return branch, nil +} + +type pushNewRepoResult struct { + defaultBranch string + defaultBranchWarning error +} + +// PushNewRepoInput configures pushing a newly created repository. +type PushNewRepoInput struct { + KnotHost string + SSHPort int + RepoURI string + Dir string + Handle string + Repo string + RemoteName string +} + +func (s *Service) pushNewRepo(ctx context.Context, in PushNewRepoInput) (pushNewRepoResult, error) { + branch, defaultBranchErr := s.setDefaultBranchFromDir(ctx, in.KnotHost, in.RepoURI, in.Dir) + result := pushNewRepoResult{defaultBranch: branch, defaultBranchWarning: defaultBranchErr} + if err := s.git.PushNewRepo(ctx, gitutil.PushNewRepoParams{ + Dir: in.Dir, KnotHost: in.KnotHost, SSHPort: in.SSHPort, + Handle: in.Handle, Repo: in.Repo, RemoteName: in.RemoteName, + }); err != nil { + return result, fmt.Errorf("push to new repository: %w", err) + } + return result, nil +} diff --git a/internal/app/repo_delete.go b/internal/app/repo_delete.go new file mode 100644 index 0000000..c37fe85 --- /dev/null +++ b/internal/app/repo_delete.go @@ -0,0 +1,71 @@ +package app + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/alyraffauf/tg/atproto" + "github.com/alyraffauf/tg/internal/tangledlex" + "github.com/alyraffauf/tg/knot" +) + +func (s *Service) DeleteRepo(ctx context.Context, t Target) (*RepoDeleteResult, error) { + atClient, did, err := s.authenticatedPDS(ctx) + if err != nil { + return nil, err + } + repo, err := s.requireOwnedRepo(ctx, t, did) + if err != nil { + return nil, err + } + if repo.Value.Knot == "" { + return nil, fmt.Errorf("repo %q has no knot", t.String()) + } + rkey := extractRKey(repo.URI) + existingRecord, getErr := atClient.GetRecord(ctx, did, repoCollection, rkey) + var recordToRestore tangledlex.Repo + if getErr == nil { + data, err := json.Marshal(existingRecord.Value) + if err != nil { + return nil, fmt.Errorf("encode repository record for restore: %w", err) + } + if err := json.Unmarshal(data, &recordToRestore); err != nil { + return nil, fmt.Errorf("decode repository record for restore: %w", err) + } + if err := tangledlex.ValidateRecord(repoCollection, recordToRestore); err != nil { + return nil, fmt.Errorf("validate repository record for restore: %w", err) + } + } + // getErr is non-fatal: the record may already be deleted. Only call + // DeleteRecord if it still exists. + + token, err := atClient.GetServiceAuth(ctx, "did:web:"+repo.Value.Knot, "sh.tangled.repo.delete") + if err != nil { + return nil, fmt.Errorf("get knot authorization: %w", err) + } + if getErr == nil { + if err := atClient.DeleteRecord(ctx, atproto.DeleteRecordInput{ + Repo: did, + Collection: repoCollection, + Rkey: rkey, + }); err != nil { + return nil, fmt.Errorf("delete repository record: %w", err) + } + } + if err := s.knot.New(repo.Value.Knot, token).DeleteRepo(ctx, knot.DeleteRepoInput{ + DID: did, + Name: t.Repo, + Rkey: rkey, + }); err != nil { + if getErr == nil { + if _, _, restoreErr := atClient.PutRecord(ctx, atproto.PutRecordInput{ + Repo: did, Collection: repoCollection, Rkey: rkey, Record: recordToRestore, + }); restoreErr != nil { + return nil, fmt.Errorf("delete knot repository: %w; restore repository record: %v", err, restoreErr) + } + } + return nil, err + } + return &RepoDeleteResult{URI: repo.URI}, nil +} diff --git a/internal/app/repo_edit.go b/internal/app/repo_edit.go new file mode 100644 index 0000000..8af2b20 --- /dev/null +++ b/internal/app/repo_edit.go @@ -0,0 +1,102 @@ +package app + +import ( + "context" + "encoding/json" + "fmt" + "sort" +) + +// EditRepoInput configures repository edits. Pointer fields are nil when the +// corresponding flag was not set. +type EditRepoInput struct { + Description *string + Website *string + Spindle *string + AddLabels []string + RemoveLabels []string +} + +// EditRepo patches repository fields on the authenticated user's repo t. +func (s *Service) EditRepo(ctx context.Context, t Target, in EditRepoInput) (*RepoEditResult, error) { + atClient, did, err := s.authenticatedPDS(ctx) + if err != nil { + return nil, err + } + repo, err := s.requireOwnedRepo(ctx, t, did) + if err != nil { + return nil, err + } + rkey := extractRKey(repo.URI) + if err := updateRecord(ctx, atClient, did, repoCollection, rkey, func(value any) (map[string]any, error) { + record, err := repoRecordMap(value) + if err != nil { + return nil, err + } + if in.Description != nil { + record["description"] = *in.Description + } + if in.Website != nil { + record["website"] = *in.Website + } + if in.Spindle != nil { + record["spindle"] = *in.Spindle + } + if len(in.AddLabels) > 0 || len(in.RemoveLabels) > 0 { + labels := labelsFromRecord(record["labels"]) + for _, label := range in.AddLabels { + labels[label] = true + } + for _, label := range in.RemoveLabels { + delete(labels, label) + } + record["labels"] = labelNames(labels) + } + return record, nil + }); err != nil { + return nil, fmt.Errorf("edit repository: %w", err) + } + result := &RepoEditResult{URI: repo.URI} + if in.Description != nil { + result.Description = *in.Description + } + return result, nil +} + +func repoRecordMap(value any) (map[string]any, error) { + data, err := json.Marshal(value) + if err != nil { + return nil, fmt.Errorf("encode repository record: %w", err) + } + var record map[string]any + if err := json.Unmarshal(data, &record); err != nil { + return nil, fmt.Errorf("decode repository record: %w", err) + } + if record == nil { + return nil, fmt.Errorf("repository record is not an object") + } + return record, nil +} + +func labelsFromRecord(value any) map[string]bool { + labels := make(map[string]bool) + values, ok := value.([]any) + if !ok { + return labels + } + for _, value := range values { + if label, ok := value.(string); ok { + labels[label] = true + } + } + return labels +} + +func labelNames(labels map[string]bool) []string { + names := make([]string, 0, len(labels)) + for label := range labels { + names = append(names, label) + } + sort.Strings(names) + return names +} diff --git a/internal/app/repo_fork.go b/internal/app/repo_fork.go new file mode 100644 index 0000000..e7027ea --- /dev/null +++ b/internal/app/repo_fork.go @@ -0,0 +1,111 @@ +package app + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/alyraffauf/tg/atproto" + "github.com/alyraffauf/tg/internal/tangledlex" + "github.com/alyraffauf/tg/knot" +) + +func (s *Service) ForkRepo(ctx context.Context, source Target, name string) (*RepoForkResult, error) { + return s.ForkRepoOnKnot(ctx, source, name, "") +} + +// ForkRepoOnKnot creates a fork on knotHost. When knotHost is empty, it uses +// the source repository's Knot for backwards-compatible same-Knot forks. +func (s *Service) ForkRepoOnKnot(ctx context.Context, source Target, name, knotHost string) (*RepoForkResult, error) { + atClient, ownerDID, err := s.authenticatedPDS(ctx) + if err != nil { + return nil, err + } + + src, err := s.getForkSource(ctx, source) + if err != nil { + return nil, err + } + selectedKnot := src.Knot + if knotHost != "" { + selectedKnot, err = parseKnotHostname(knotHost) + if err != nil { + return nil, err + } + } + token, err := atClient.GetServiceAuth(ctx, "did:web:"+selectedKnot, "sh.tangled.repo.create") + if err != nil { + return nil, fmt.Errorf("get knot service auth: %w", err) + } + repoDID, err := s.knot.New(selectedKnot, token).CreateRepo(ctx, knot.CreateRepoInput{ + Name: name, + Rkey: name, + Source: forkSourceURL(src.Knot, src.RepoDID), + }) + if err != nil { + return nil, err + } + uri, _, err := atClient.PutRecord(ctx, atproto.PutRecordInput{ + Repo: ownerDID, + Collection: repoCollection, + Rkey: name, + Record: tangledlex.Repo{ + LexiconTypeID: repoCollection, + Name: optionalString(name), + Knot: selectedKnot, + CreatedAt: time.Now().UTC().Format(time.RFC3339), + RepoDid: optionalString(repoDID), + Source: optionalString(forkSourceURL(src.Knot, src.RepoDID)), + }, + }) + if err != nil { + cleanupErr := s.deleteFork(ctx, atClient, selectedKnot, ownerDID, name) + if cleanupErr != nil { + return nil, fmt.Errorf("write fork record: %w; delete orphaned fork: %v", err, cleanupErr) + } + return nil, fmt.Errorf("write fork record: %w", err) + } + return &RepoForkResult{Handle: s.ownerHandle(ctx, ownerDID), Name: name, URI: uri, Knot: selectedKnot}, nil +} + +type forkSource struct { + URI string + Knot string + RepoDID string +} + +func forkSourceURL(knotHost, repoDID string) string { + base := strings.TrimRight(knotHost, "/") + if !strings.HasPrefix(base, "http://") && !strings.HasPrefix(base, "https://") { + base = "https://" + base + } + return base + "/" + repoDID +} + +func (s *Service) getForkSource(ctx context.Context, t Target) (forkSource, error) { + // Repository names are not guaranteed to be their ATProto record rkeys. + // Use the common resolver so forks work for repositories with generated rkeys. + repo, err := s.resolveRepo(ctx, t) + if err != nil { + return forkSource{}, fmt.Errorf("resolve source repository %s: %w", t, err) + } + if repo.Value.Knot == "" { + return forkSource{}, fmt.Errorf("source repository %s has no knot", t) + } + if stringValue(repo.Value.RepoDid) == "" { + return forkSource{}, fmt.Errorf("source repository %s has no repo DID", t) + } + return forkSource{URI: repo.URI, Knot: repo.Value.Knot, RepoDID: stringValue(repo.Value.RepoDid)}, nil +} + +func (s *Service) deleteFork(ctx context.Context, atClient pdsClient, knotHost, did, name string) error { + token, err := atClient.GetServiceAuth(ctx, "did:web:"+knotHost, "sh.tangled.repo.delete") + if err != nil { + return fmt.Errorf("get knot authorization: %w", err) + } + if err := s.knot.New(knotHost, token).DeleteRepo(ctx, knot.DeleteRepoInput{DID: did, Name: name, Rkey: name}); err != nil { + return err + } + return nil +} diff --git a/internal/app/repo_list.go b/internal/app/repo_list.go new file mode 100644 index 0000000..17ff700 --- /dev/null +++ b/internal/app/repo_list.go @@ -0,0 +1,76 @@ +package app + +import ( + "context" + "fmt" + "strings" + + "github.com/alyraffauf/tg/tangled" +) + +// ListRepos lists every repository owned by handle. +func (s *Service) ListRepos(ctx context.Context, handle string) ([]RepoItem, error) { + ident, err := s.resolver.ResolveHandle(ctx, handle) + if err != nil { + return nil, fmt.Errorf("resolve handle %q: %w", handle, err) + } + repos, err := s.appview.ListRepos(ctx, ident.DID.String()) + if err != nil { + return nil, fmt.Errorf("list repos for %q: %w", handle, err) + } + return buildRepoItems(repos.Items, handle), nil +} + +func buildRepoItems(items []tangled.Repo, author string) []RepoItem { + canonicalRepos := canonicalRepoItems(items) + result := make([]RepoItem, 0, len(canonicalRepos)) + for _, tangledRepo := range canonicalRepos { + name := stringValue(tangledRepo.Value.Name) + if name == "" { + // Fall back to the rkey segment of the at:// URI. + if idx := strings.LastIndex(tangledRepo.URI, "/"); idx != -1 { + name = tangledRepo.URI[idx+1:] + } + } + result = append(result, RepoItem{ + Name: name, + URI: tangledRepo.URI, + Author: author, + Knot: tangledRepo.Value.Knot, + Description: stringValue(tangledRepo.Value.Description), + CreatedAt: tangledRepo.Value.CreatedAt, + RepoDid: stringValue(tangledRepo.Value.RepoDid), + }) + } + return result +} + +// canonicalRepoItems returns one record per repository DID. Renamed repositories +// retain old records as aliases; the record keyed by its name is current. +func canonicalRepoItems(items []tangled.Repo) []tangled.Repo { + result := make([]tangled.Repo, 0, len(items)) + indexesByRepoDID := make(map[string]int) + for _, repo := range items { + repoDID := stringValue(repo.Value.RepoDid) + if repoDID == "" { + result = append(result, repo) + continue + } + + resultIndex, found := indexesByRepoDID[repoDID] + if !found { + indexesByRepoDID[repoDID] = len(result) + result = append(result, repo) + continue + } + if isCanonicalRepoRecord(repo) && !isCanonicalRepoRecord(result[resultIndex]) { + result[resultIndex] = repo + } + } + return result +} + +func isCanonicalRepoRecord(repo tangled.Repo) bool { + name := stringValue(repo.Value.Name) + return name != "" && extractRKey(repo.URI) == name +} diff --git a/internal/app/repo_set_default_branch.go b/internal/app/repo_set_default_branch.go new file mode 100644 index 0000000..5f685af --- /dev/null +++ b/internal/app/repo_set_default_branch.go @@ -0,0 +1,38 @@ +package app + +import ( + "context" + "fmt" + + "github.com/alyraffauf/tg/knot" +) + +// SetRepoDefaultBranch sets the default branch of the authenticated user's repo. +func (s *Service) SetRepoDefaultBranch(ctx context.Context, t Target, branch string) (*RepoDefaultBranchResult, error) { + atClient, did, err := s.authenticatedPDS(ctx) + if err != nil { + return nil, err + } + repo, err := s.requireOwnedRepo(ctx, t, did) + if err != nil { + return nil, err + } + if repo.Value.Knot == "" { + return nil, fmt.Errorf("repo %q has no knot", t.String()) + } + if err := s.setKnotDefaultBranch(ctx, atClient, repo.Value.Knot, repo.URI, branch); err != nil { + return nil, err + } + return &RepoDefaultBranchResult{URI: repo.URI, Branch: branch}, nil +} + +func (s *Service) setKnotDefaultBranch(ctx context.Context, atClient pdsClient, knotHost, repoURI, branch string) error { + token, err := atClient.GetServiceAuth(ctx, "did:web:"+knotHost, "sh.tangled.repo.setDefaultBranch") + if err != nil { + return fmt.Errorf("get knot authorization: %w", err) + } + return s.knot.New(knotHost, token).SetDefaultBranch(ctx, knot.SetDefaultBranchInput{ + Repo: repoURI, + DefaultBranch: branch, + }) +} diff --git a/internal/app/repo_view.go b/internal/app/repo_view.go new file mode 100644 index 0000000..9d4ded1 --- /dev/null +++ b/internal/app/repo_view.go @@ -0,0 +1,24 @@ +package app + +import "context" + +// ViewRepo fetches a single repository record. +func (s *Service) ViewRepo(ctx context.Context, t Target) (*RepoItem, error) { + tangledRepo, err := s.resolveRepo(ctx, t) + if err != nil { + return nil, err + } + name := stringValue(tangledRepo.Value.Name) + if name == "" { + name = t.Repo + } + return &RepoItem{ + Name: name, + Author: t.Handle, + URI: tangledRepo.URI, + Knot: tangledRepo.Value.Knot, + Description: stringValue(tangledRepo.Value.Description), + CreatedAt: tangledRepo.Value.CreatedAt, + RepoDid: stringValue(tangledRepo.Value.RepoDid), + }, nil +} diff --git a/internal/app/repos.go b/internal/app/repos.go deleted file mode 100644 index ead517f..0000000 --- a/internal/app/repos.go +++ /dev/null @@ -1,660 +0,0 @@ -package app - -import ( - "context" - "encoding/json" - "fmt" - "sort" - "strings" - "sync" - "time" - - "github.com/alyraffauf/tg/atproto" - "github.com/alyraffauf/tg/internal/gitutil" - "github.com/alyraffauf/tg/internal/tangledlex" - "github.com/alyraffauf/tg/knot" - "github.com/alyraffauf/tg/tangled" - "github.com/bluesky-social/indigo/atproto/syntax" -) - -const ( - knotCollection = "sh.tangled.knot" - maxKnotRegistrations = 10 -) - -type knotRegistration struct { - LexiconTypeID string `json:"$type"` - CreatedAt string `json:"createdAt"` -} - -// ViewRepo fetches a single repository record. -func (s *Service) ViewRepo(ctx context.Context, t Target) (*RepoItem, error) { - tangledRepo, err := s.resolveRepo(ctx, t) - if err != nil { - return nil, err - } - name := stringValue(tangledRepo.Value.Name) - if name == "" { - name = t.Repo - } - return &RepoItem{ - Name: name, - Author: t.Handle, - URI: tangledRepo.URI, - Knot: tangledRepo.Value.Knot, - Description: stringValue(tangledRepo.Value.Description), - CreatedAt: tangledRepo.Value.CreatedAt, - RepoDid: stringValue(tangledRepo.Value.RepoDid), - }, nil -} - -// ListRepos lists every repository owned by handle. -func (s *Service) ListRepos(ctx context.Context, handle string) ([]RepoItem, error) { - ident, err := s.resolver.ResolveHandle(ctx, handle) - if err != nil { - return nil, fmt.Errorf("resolve handle %q: %w", handle, err) - } - repos, err := s.appview.ListRepos(ctx, ident.DID.String()) - if err != nil { - return nil, fmt.Errorf("list repos for %q: %w", handle, err) - } - return buildRepoItems(repos.Items, handle), nil -} - -func buildRepoItems(items []tangled.Repo, author string) []RepoItem { - canonicalRepos := canonicalRepoItems(items) - result := make([]RepoItem, 0, len(canonicalRepos)) - for _, tangledRepo := range canonicalRepos { - name := stringValue(tangledRepo.Value.Name) - if name == "" { - // Fall back to the rkey segment of the at:// URI. - if idx := strings.LastIndex(tangledRepo.URI, "/"); idx != -1 { - name = tangledRepo.URI[idx+1:] - } - } - result = append(result, RepoItem{ - Name: name, - URI: tangledRepo.URI, - Author: author, - Knot: tangledRepo.Value.Knot, - Description: stringValue(tangledRepo.Value.Description), - CreatedAt: tangledRepo.Value.CreatedAt, - RepoDid: stringValue(tangledRepo.Value.RepoDid), - }) - } - return result -} - -// canonicalRepoItems returns one record per repository DID. Renamed repositories -// retain old records as aliases; the record keyed by its name is current. -func canonicalRepoItems(items []tangled.Repo) []tangled.Repo { - result := make([]tangled.Repo, 0, len(items)) - indexesByRepoDID := make(map[string]int) - for _, repo := range items { - repoDID := stringValue(repo.Value.RepoDid) - if repoDID == "" { - result = append(result, repo) - continue - } - - resultIndex, found := indexesByRepoDID[repoDID] - if !found { - indexesByRepoDID[repoDID] = len(result) - result = append(result, repo) - continue - } - if isCanonicalRepoRecord(repo) && !isCanonicalRepoRecord(result[resultIndex]) { - result[resultIndex] = repo - } - } - return result -} - -func isCanonicalRepoRecord(repo tangled.Repo) bool { - name := stringValue(repo.Value.Name) - return name != "" && extractRKey(repo.URI) == name -} - -// ProvisionRepoInput configures repository provisioning. -type ProvisionRepoInput struct { - KnotHost string - Name string - Description string -} - -// CreateRepoInput configures provisioning and optional local setup. -type CreateRepoInput struct { - KnotHost string - SSHPort int - Name string - Description string - Clone bool - CloneProtocol string - PushPath string - RemoteName string -} - -// CreateRepo provisions a repository and performs requested local Git setup. -func (s *Service) CreateRepo(ctx context.Context, in CreateRepoInput) (*RepoCreateResult, error) { - if in.Clone { - cloneProtocol, err := validateCloneProtocol(in.CloneProtocol) - if err != nil { - return nil, err - } - in.CloneProtocol = cloneProtocol - } - if ((in.Clone && in.CloneProtocol == "ssh") || in.PushPath != "") && (in.SSHPort < 1 || in.SSHPort > 65535) { - return nil, fmt.Errorf("SSH port must be between 1 and 65535") - } - if in.KnotHost != "" { - knotHost, err := parseKnotHostname(in.KnotHost) - if err != nil { - return nil, err - } - in.KnotHost = knotHost - } - uri, handle, selectedKnot, warnings, err := s.provisionRepo(ctx, ProvisionRepoInput{ - KnotHost: in.KnotHost, Name: in.Name, Description: in.Description, - }) - if err != nil { - return nil, err - } - result := &RepoCreateResult{Handle: handle, Name: in.Name, URI: uri, Knot: selectedKnot, Warnings: warnings} - if in.Clone { - if _, err := s.cloneRepo(ctx, CloneRepoInput{ - KnotHost: selectedKnot, SSHPort: in.SSHPort, - Protocol: in.CloneProtocol, Handle: handle, Repo: in.Name, Destination: in.Name, - }); err != nil { - return nil, fmt.Errorf("clone new repository: %w", err) - } - result.Cloned = true - } - if in.PushPath == "" { - return result, nil - } - pushResult, err := s.pushNewRepo(ctx, PushNewRepoInput{ - KnotHost: selectedKnot, SSHPort: in.SSHPort, RepoURI: uri, Dir: in.PushPath, - Handle: handle, Repo: in.Name, RemoteName: in.RemoteName, - }) - if pushResult.defaultBranchWarning != nil { - result.Warnings = append(result.Warnings, fmt.Sprintf("could not set default branch: %v", pushResult.defaultBranchWarning)) - } - if err != nil { - return nil, err - } - result.Pushed = true - result.DefaultBranch = pushResult.defaultBranch - return result, nil -} - -func (s *Service) provisionRepo(ctx context.Context, in ProvisionRepoInput) (uri, handle, knotHost string, warnings []string, err error) { - atClient, did, err := s.authenticatedPDS(ctx) - if err != nil { - return "", "", "", nil, err - } - knotHost, warnings, err = s.selectCreationKnot(ctx, atClient, did, in.KnotHost) - if err != nil { - return "", "", "", nil, err - } - token, err := atClient.GetServiceAuth(ctx, "did:web:"+knotHost, "sh.tangled.repo.create") - if err != nil { - return "", "", "", nil, err - } - repoDid, err := s.knot.New(knotHost, token).CreateRepo(ctx, knot.CreateRepoInput{ - Name: in.Name, - Rkey: in.Name, - }) - if err != nil { - return "", "", "", nil, err - } - record := tangledlex.Repo{ - LexiconTypeID: repoCollection, - Knot: knotHost, - CreatedAt: time.Now().UTC().Format(time.RFC3339), - RepoDid: optionalString(repoDid), - } - if in.Description != "" { - record.Description = optionalString(in.Description) - } - uri, _, err = atClient.PutRecord(ctx, atproto.PutRecordInput{ - Repo: did, - Collection: repoCollection, - Rkey: in.Name, - Record: record, - }) - if err != nil { - return "", "", "", nil, err - } - return uri, s.ownerHandle(ctx, did), knotHost, warnings, nil -} - -func (s *Service) selectCreationKnot(ctx context.Context, atClient pdsClient, did, configured string) (string, []string, error) { - if configured != "" { - return configured, nil, nil - } - page, err := atClient.ListRecords(ctx, did, knotCollection, atproto.ListRecordsOpts{Limit: maxKnotRegistrations + 1}) - if err != nil { - return "", nil, fmt.Errorf("discover verified Knots: %w", err) - } - records := page.Records - if len(records) == 0 { - return DefaultKnot, nil, nil - } - if len(records) > maxKnotRegistrations { - return "", nil, fmt.Errorf("found more than %d Knot registrations; select one with --knot or set it in the config file", maxKnotRegistrations) - } - hosts := make([]string, 0, len(records)) - warnings := make([]string, 0, len(records)) - seen := make(map[string]bool, len(records)) - for _, record := range records { - uri, err := syntax.ParseATURI(record.URI) - if err != nil || uri.Authority().String() != did || uri.Collection().String() != knotCollection || uri.RecordKey().String() == "" { - warnings = append(warnings, fmt.Sprintf("ignored invalid Knot registration URI %q", record.URI)) - continue - } - if err := validateKnotRegistration(record.Value); err != nil { - warnings = append(warnings, fmt.Sprintf("ignored invalid Knot registration %q: %v", record.URI, err)) - continue - } - host, err := parseKnotHostname(uri.RecordKey().String()) - if err != nil { - warnings = append(warnings, fmt.Sprintf("ignored Knot registration %q: %v", record.URI, err)) - continue - } - if seen[host] { - warnings = append(warnings, fmt.Sprintf("ignored duplicate Knot registration for %s", host)) - continue - } - seen[host] = true - hosts = append(hosts, host) - } - verificationErrors := make([]error, len(hosts)) - var verificationGroup sync.WaitGroup - for index, host := range hosts { - verificationGroup.Add(1) - go func() { - defer verificationGroup.Done() - verificationErrors[index] = s.knotOwnershipVerifier.Verify(ctx, host, did) - }() - } - verificationGroup.Wait() - verified := make([]string, 0, len(hosts)) - for index, host := range hosts { - if err := verificationErrors[index]; err != nil { - warnings = append(warnings, fmt.Sprintf("could not verify Knot registration for %s: %v", host, err)) - continue - } - verified = append(verified, host) - } - if len(verified) == 0 { - return "", nil, fmt.Errorf("no Knot registrations could be verified: %s", strings.Join(warnings, "; ")) - } - if len(verified) > 1 { - sort.Strings(verified) - return "", nil, fmt.Errorf("multiple verified Knots found (%s); select one with --knot or set it in the config file", strings.Join(verified, ", ")) - } - return verified[0], warnings, nil -} - -func validateKnotRegistration(value any) error { - data, err := json.Marshal(value) - if err != nil { - return fmt.Errorf("encode record: %w", err) - } - var registration knotRegistration - if err := json.Unmarshal(data, ®istration); err != nil { - return fmt.Errorf("decode record: %w", err) - } - if registration.LexiconTypeID != knotCollection { - return fmt.Errorf("$type must be %q", knotCollection) - } - if _, err := syntax.ParseDatetime(registration.CreatedAt); err != nil { - return fmt.Errorf("invalid createdAt: %w", err) - } - return nil -} - -func parseKnotHostname(raw string) (string, error) { - hostname, err := syntax.ParseHandle(raw) - if err != nil { - return "", fmt.Errorf("invalid Knot hostname %q: %w", raw, err) - } - return hostname.Normalize().String(), nil -} - -func (s *Service) ownerHandle(ctx context.Context, did string) string { - if ident, err := s.resolver.ResolveDID(ctx, did); err == nil { - return ident.Handle.String() - } - return did -} - -// SetRepoDefaultBranch sets the default branch of the authenticated user's repo. -func (s *Service) SetRepoDefaultBranch(ctx context.Context, t Target, branch string) (*RepoDefaultBranchResult, error) { - atClient, did, err := s.authenticatedPDS(ctx) - if err != nil { - return nil, err - } - repo, err := s.requireOwnedRepo(ctx, t, did) - if err != nil { - return nil, err - } - if repo.Value.Knot == "" { - return nil, fmt.Errorf("repo %q has no knot", t.String()) - } - if err := s.setKnotDefaultBranch(ctx, atClient, repo.Value.Knot, repo.URI, branch); err != nil { - return nil, err - } - return &RepoDefaultBranchResult{URI: repo.URI, Branch: branch}, nil -} - -func (s *Service) setDefaultBranchFromDir(ctx context.Context, knotHost, repoURI, dir string) (string, error) { - atClient, _, err := s.authenticatedPDS(ctx) - if err != nil { - return "", err - } - branch, err := s.git.CurrentBranch(ctx, dir) - if err != nil { - return "", err - } - if err := s.setKnotDefaultBranch(ctx, atClient, knotHost, repoURI, branch); err != nil { - return branch, err - } - return branch, nil -} - -type pushNewRepoResult struct { - defaultBranch string - defaultBranchWarning error -} - -func (s *Service) pushNewRepo(ctx context.Context, in PushNewRepoInput) (pushNewRepoResult, error) { - branch, defaultBranchErr := s.setDefaultBranchFromDir(ctx, in.KnotHost, in.RepoURI, in.Dir) - result := pushNewRepoResult{defaultBranch: branch, defaultBranchWarning: defaultBranchErr} - if err := s.git.PushNewRepo(ctx, gitutil.PushNewRepoParams{ - Dir: in.Dir, KnotHost: in.KnotHost, SSHPort: in.SSHPort, - Handle: in.Handle, Repo: in.Repo, RemoteName: in.RemoteName, - }); err != nil { - return result, fmt.Errorf("push to new repository: %w", err) - } - return result, nil -} - -// PushNewRepoInput configures pushing a newly created repository. -type PushNewRepoInput struct { - KnotHost string - SSHPort int - RepoURI string - Dir string - Handle string - Repo string - RemoteName string -} - -func (s *Service) setKnotDefaultBranch(ctx context.Context, atClient pdsClient, knotHost, repoURI, branch string) error { - token, err := atClient.GetServiceAuth(ctx, "did:web:"+knotHost, "sh.tangled.repo.setDefaultBranch") - if err != nil { - return fmt.Errorf("get knot authorization: %w", err) - } - return s.knot.New(knotHost, token).SetDefaultBranch(ctx, knot.SetDefaultBranchInput{ - Repo: repoURI, - DefaultBranch: branch, - }) -} - -// EditRepoInput configures repository edits. Pointer fields are nil when the -// corresponding flag was not set. -type EditRepoInput struct { - Description *string - Website *string - Spindle *string - AddLabels []string - RemoveLabels []string -} - -// EditRepo patches repository fields on the authenticated user's repo t. -func (s *Service) EditRepo(ctx context.Context, t Target, in EditRepoInput) (*RepoEditResult, error) { - atClient, did, err := s.authenticatedPDS(ctx) - if err != nil { - return nil, err - } - repo, err := s.requireOwnedRepo(ctx, t, did) - if err != nil { - return nil, err - } - rkey := extractRKey(repo.URI) - if err := updateRecord(ctx, atClient, did, repoCollection, rkey, func(value any) (map[string]any, error) { - record, err := repoRecordMap(value) - if err != nil { - return nil, err - } - if in.Description != nil { - record["description"] = *in.Description - } - if in.Website != nil { - record["website"] = *in.Website - } - if in.Spindle != nil { - record["spindle"] = *in.Spindle - } - if len(in.AddLabels) > 0 || len(in.RemoveLabels) > 0 { - labels := labelsFromRecord(record["labels"]) - for _, label := range in.AddLabels { - labels[label] = true - } - for _, label := range in.RemoveLabels { - delete(labels, label) - } - record["labels"] = labelNames(labels) - } - return record, nil - }); err != nil { - return nil, fmt.Errorf("edit repository: %w", err) - } - result := &RepoEditResult{URI: repo.URI} - if in.Description != nil { - result.Description = *in.Description - } - return result, nil -} - -func repoRecordMap(value any) (map[string]any, error) { - data, err := json.Marshal(value) - if err != nil { - return nil, fmt.Errorf("encode repository record: %w", err) - } - var record map[string]any - if err := json.Unmarshal(data, &record); err != nil { - return nil, fmt.Errorf("decode repository record: %w", err) - } - if record == nil { - return nil, fmt.Errorf("repository record is not an object") - } - return record, nil -} - -func labelsFromRecord(value any) map[string]bool { - labels := make(map[string]bool) - values, ok := value.([]any) - if !ok { - return labels - } - for _, value := range values { - if label, ok := value.(string); ok { - labels[label] = true - } - } - return labels -} - -func labelNames(labels map[string]bool) []string { - names := make([]string, 0, len(labels)) - for label := range labels { - names = append(names, label) - } - sort.Strings(names) - return names -} - -// DeleteRepo deletes the repository record and the knot-side repo. If the -// knot deletion fails after the record is deleted, the record is restored. -func (s *Service) DeleteRepo(ctx context.Context, t Target) (*RepoDeleteResult, error) { - atClient, did, err := s.authenticatedPDS(ctx) - if err != nil { - return nil, err - } - repo, err := s.requireOwnedRepo(ctx, t, did) - if err != nil { - return nil, err - } - if repo.Value.Knot == "" { - return nil, fmt.Errorf("repo %q has no knot", t.String()) - } - rkey := extractRKey(repo.URI) - existingRecord, getErr := atClient.GetRecord(ctx, did, repoCollection, rkey) - var recordToRestore tangledlex.Repo - if getErr == nil { - data, err := json.Marshal(existingRecord.Value) - if err != nil { - return nil, fmt.Errorf("encode repository record for restore: %w", err) - } - if err := json.Unmarshal(data, &recordToRestore); err != nil { - return nil, fmt.Errorf("decode repository record for restore: %w", err) - } - if err := tangledlex.ValidateRecord(repoCollection, recordToRestore); err != nil { - return nil, fmt.Errorf("validate repository record for restore: %w", err) - } - } - // getErr is non-fatal: the record may already be deleted. Only call - // DeleteRecord if it still exists. - - token, err := atClient.GetServiceAuth(ctx, "did:web:"+repo.Value.Knot, "sh.tangled.repo.delete") - if err != nil { - return nil, fmt.Errorf("get knot authorization: %w", err) - } - if getErr == nil { - if err := atClient.DeleteRecord(ctx, atproto.DeleteRecordInput{ - Repo: did, - Collection: repoCollection, - Rkey: rkey, - }); err != nil { - return nil, fmt.Errorf("delete repository record: %w", err) - } - } - if err := s.knot.New(repo.Value.Knot, token).DeleteRepo(ctx, knot.DeleteRepoInput{ - DID: did, - Name: t.Repo, - Rkey: rkey, - }); err != nil { - if getErr == nil { - if _, _, restoreErr := atClient.PutRecord(ctx, atproto.PutRecordInput{ - Repo: did, Collection: repoCollection, Rkey: rkey, Record: recordToRestore, - }); restoreErr != nil { - return nil, fmt.Errorf("delete knot repository: %w; restore repository record: %v", err, restoreErr) - } - } - return nil, err - } - return &RepoDeleteResult{URI: repo.URI}, nil -} - -// ForkRepo creates a fork of source on the authenticated user's account, -// named name (defaults to the source repo's name). -func (s *Service) ForkRepo(ctx context.Context, source Target, name string) (*RepoForkResult, error) { - return s.ForkRepoOnKnot(ctx, source, name, "") -} - -// ForkRepoOnKnot creates a fork on knotHost. When knotHost is empty, it uses -// the source repository's Knot for backwards-compatible same-Knot forks. -func (s *Service) ForkRepoOnKnot(ctx context.Context, source Target, name, knotHost string) (*RepoForkResult, error) { - atClient, ownerDID, err := s.authenticatedPDS(ctx) - if err != nil { - return nil, err - } - - src, err := s.getForkSource(ctx, source) - if err != nil { - return nil, err - } - selectedKnot := src.Knot - if knotHost != "" { - selectedKnot, err = parseKnotHostname(knotHost) - if err != nil { - return nil, err - } - } - token, err := atClient.GetServiceAuth(ctx, "did:web:"+selectedKnot, "sh.tangled.repo.create") - if err != nil { - return nil, fmt.Errorf("get knot service auth: %w", err) - } - repoDID, err := s.knot.New(selectedKnot, token).CreateRepo(ctx, knot.CreateRepoInput{ - Name: name, - Rkey: name, - Source: forkSourceURL(src.Knot, src.RepoDID), - }) - if err != nil { - return nil, err - } - uri, _, err := atClient.PutRecord(ctx, atproto.PutRecordInput{ - Repo: ownerDID, - Collection: repoCollection, - Rkey: name, - Record: tangledlex.Repo{ - LexiconTypeID: repoCollection, - Name: optionalString(name), - Knot: selectedKnot, - CreatedAt: time.Now().UTC().Format(time.RFC3339), - RepoDid: optionalString(repoDID), - Source: optionalString(forkSourceURL(src.Knot, src.RepoDID)), - }, - }) - if err != nil { - cleanupErr := s.deleteFork(ctx, atClient, selectedKnot, ownerDID, name) - if cleanupErr != nil { - return nil, fmt.Errorf("write fork record: %w; delete orphaned fork: %v", err, cleanupErr) - } - return nil, fmt.Errorf("write fork record: %w", err) - } - return &RepoForkResult{Handle: s.ownerHandle(ctx, ownerDID), Name: name, URI: uri, Knot: selectedKnot}, nil -} - -type forkSource struct { - URI string - Knot string - RepoDID string -} - -func forkSourceURL(knotHost, repoDID string) string { - base := strings.TrimRight(knotHost, "/") - if !strings.HasPrefix(base, "http://") && !strings.HasPrefix(base, "https://") { - base = "https://" + base - } - return base + "/" + repoDID -} - -func (s *Service) getForkSource(ctx context.Context, t Target) (forkSource, error) { - // Repository names are not guaranteed to be their ATProto record rkeys. - // Use the common resolver so forks work for repositories with generated rkeys. - repo, err := s.resolveRepo(ctx, t) - if err != nil { - return forkSource{}, fmt.Errorf("resolve source repository %s: %w", t, err) - } - if repo.Value.Knot == "" { - return forkSource{}, fmt.Errorf("source repository %s has no knot", t) - } - if stringValue(repo.Value.RepoDid) == "" { - return forkSource{}, fmt.Errorf("source repository %s has no repo DID", t) - } - return forkSource{URI: repo.URI, Knot: repo.Value.Knot, RepoDID: stringValue(repo.Value.RepoDid)}, nil -} - -func (s *Service) deleteFork(ctx context.Context, atClient pdsClient, knotHost, did, name string) error { - token, err := atClient.GetServiceAuth(ctx, "did:web:"+knotHost, "sh.tangled.repo.delete") - if err != nil { - return fmt.Errorf("get knot authorization: %w", err) - } - if err := s.knot.New(knotHost, token).DeleteRepo(ctx, knot.DeleteRepoInput{DID: did, Name: name, Rkey: name}); err != nil { - return err - } - return nil -} diff --git a/internal/app/git_test.go b/internal/app/repos_clone_test.go similarity index 100% rename from internal/app/git_test.go rename to internal/app/repos_clone_test.go diff --git a/internal/app/sshkey_add.go b/internal/app/sshkey_add.go new file mode 100644 index 0000000..26157d1 --- /dev/null +++ b/internal/app/sshkey_add.go @@ -0,0 +1,34 @@ +package app + +import ( + "context" + "fmt" + "time" + + "github.com/alyraffauf/tg/atproto" + "github.com/alyraffauf/tg/internal/tangledlex" + "github.com/bluesky-social/indigo/atproto/syntax" +) + +// AddSSHKey writes a new sh.tangled.publicKey record. +func (s *Service) AddSSHKey(ctx context.Context, name, key string) (*SSHKeyAddResult, error) { + atClient, did, err := s.authenticatedPDS(ctx) + if err != nil { + return nil, err + } + uri, _, err := atClient.PutRecord(ctx, atproto.PutRecordInput{ + Repo: did, + Collection: sshKeyCollection, + Rkey: string(syntax.NewTIDNow(0)), + Record: tangledlex.PublicKey{ + LexiconTypeID: sshKeyCollection, + Key: key, + Name: name, + CreatedAt: time.Now().UTC().Format(time.RFC3339), + }, + }) + if err != nil { + return nil, fmt.Errorf("add SSH key: %w", err) + } + return &SSHKeyAddResult{Name: name, URI: uri}, nil +} diff --git a/internal/app/sshkey_delete.go b/internal/app/sshkey_delete.go new file mode 100644 index 0000000..1471198 --- /dev/null +++ b/internal/app/sshkey_delete.go @@ -0,0 +1,24 @@ +package app + +import ( + "context" + "fmt" + + "github.com/alyraffauf/tg/atproto" +) + +// DeleteSSHKey removes a public key record from the authenticated user's account. +func (s *Service) DeleteSSHKey(ctx context.Context, rkey string) (*DeletedRecordResult, error) { + atClient, did, err := s.authenticatedPDS(ctx) + if err != nil { + return nil, err + } + if err := atClient.DeleteRecord(ctx, atproto.DeleteRecordInput{ + Repo: did, + Collection: sshKeyCollection, + Rkey: rkey, + }); err != nil { + return nil, fmt.Errorf("delete SSH key: %w", err) + } + return &DeletedRecordResult{Rkey: rkey}, nil +} diff --git a/internal/app/sshkey_list.go b/internal/app/sshkey_list.go new file mode 100644 index 0000000..1685f45 --- /dev/null +++ b/internal/app/sshkey_list.go @@ -0,0 +1,44 @@ +package app + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/alyraffauf/tg/atproto" + "github.com/alyraffauf/tg/internal/tangledlex" +) + +// ListSSHKeys lists every public key owned by handle. +func (s *Service) ListSSHKeys(ctx context.Context, handle string) ([]SSHKeyItem, error) { + atClient, did, err := s.publicPDS(ctx, handle) + if err != nil { + return nil, err + } + records, err := atClient.ListAllRecords(ctx, did, sshKeyCollection, atproto.ListRecordsOpts{Limit: defaultListLimit}) + if err != nil { + return nil, fmt.Errorf("list SSH keys for %q: %w", handle, err) + } + return buildSSHKeyItems(records), nil +} + +func buildSSHKeyItems(records []atproto.RecordItem) []SSHKeyItem { + items := make([]SSHKeyItem, 0, len(records)) + for _, rec := range records { + var key tangledlex.PublicKey + data, err := json.Marshal(rec.Value) + if err != nil { + continue + } + if err := json.Unmarshal(data, &key); err != nil { + continue + } + items = append(items, SSHKeyItem{ + Name: key.Name, + Key: key.Key, + CreatedAt: key.CreatedAt, + URI: rec.URI, + }) + } + return items +} diff --git a/internal/app/sshkeys.go b/internal/app/sshkeys.go deleted file mode 100644 index dda8d43..0000000 --- a/internal/app/sshkeys.go +++ /dev/null @@ -1,85 +0,0 @@ -package app - -import ( - "context" - "encoding/json" - "fmt" - "time" - - "github.com/alyraffauf/tg/atproto" - "github.com/alyraffauf/tg/internal/tangledlex" - "github.com/bluesky-social/indigo/atproto/syntax" -) - -// AddSSHKey writes a new sh.tangled.publicKey record. -func (s *Service) AddSSHKey(ctx context.Context, name, key string) (*SSHKeyAddResult, error) { - atClient, did, err := s.authenticatedPDS(ctx) - if err != nil { - return nil, err - } - uri, _, err := atClient.PutRecord(ctx, atproto.PutRecordInput{ - Repo: did, - Collection: sshKeyCollection, - Rkey: string(syntax.NewTIDNow(0)), - Record: tangledlex.PublicKey{ - LexiconTypeID: sshKeyCollection, - Key: key, - Name: name, - CreatedAt: time.Now().UTC().Format(time.RFC3339), - }, - }) - if err != nil { - return nil, fmt.Errorf("add SSH key: %w", err) - } - return &SSHKeyAddResult{Name: name, URI: uri}, nil -} - -// ListSSHKeys lists every public key owned by handle. -func (s *Service) ListSSHKeys(ctx context.Context, handle string) ([]SSHKeyItem, error) { - atClient, did, err := s.publicPDS(ctx, handle) - if err != nil { - return nil, err - } - records, err := atClient.ListAllRecords(ctx, did, sshKeyCollection, atproto.ListRecordsOpts{Limit: defaultListLimit}) - if err != nil { - return nil, fmt.Errorf("list SSH keys for %q: %w", handle, err) - } - return buildSSHKeyItems(records), nil -} - -// DeleteSSHKey removes a public key record from the authenticated user's account. -func (s *Service) DeleteSSHKey(ctx context.Context, rkey string) (*DeletedRecordResult, error) { - atClient, did, err := s.authenticatedPDS(ctx) - if err != nil { - return nil, err - } - if err := atClient.DeleteRecord(ctx, atproto.DeleteRecordInput{ - Repo: did, - Collection: sshKeyCollection, - Rkey: rkey, - }); err != nil { - return nil, fmt.Errorf("delete SSH key: %w", err) - } - return &DeletedRecordResult{Rkey: rkey}, nil -} - -func buildSSHKeyItems(records []atproto.RecordItem) []SSHKeyItem { - items := make([]SSHKeyItem, 0, len(records)) - for _, rec := range records { - var key tangledlex.PublicKey - data, err := json.Marshal(rec.Value) - if err != nil { - continue - } - if err := json.Unmarshal(data, &key); err != nil { - continue - } - items = append(items, SSHKeyItem{ - Name: key.Name, - Key: key.Key, - CreatedAt: key.CreatedAt, - URI: rec.URI, - }) - } - return items -} diff --git a/internal/app/string_create.go b/internal/app/string_create.go new file mode 100644 index 0000000..c0fe2a5 --- /dev/null +++ b/internal/app/string_create.go @@ -0,0 +1,43 @@ +package app + +import ( + "context" + "fmt" + "time" + + "github.com/alyraffauf/tg/atproto" + "github.com/alyraffauf/tg/internal/tangledlex" + "github.com/bluesky-social/indigo/atproto/syntax" +) + +// CreateStringInput configures string creation. +type CreateStringInput struct { + Filename string + Description string + Contents string +} + +// CreateString writes a new sh.tangled.string record. +func (s *Service) CreateString(ctx context.Context, in CreateStringInput) (*CreatedRecordResult, error) { + atClient, did, err := s.authenticatedPDS(ctx) + if err != nil { + return nil, err + } + rkey := string(syntax.NewTIDNow(0)) + uri, _, err := atClient.PutRecord(ctx, atproto.PutRecordInput{ + Repo: did, + Collection: stringCollection, + Rkey: rkey, + Record: tangledlex.String{ + LexiconTypeID: stringCollection, + Filename: in.Filename, + Description: in.Description, + Contents: in.Contents, + CreatedAt: time.Now().UTC().Format(time.RFC3339), + }, + }) + if err != nil { + return nil, fmt.Errorf("create string: %w", err) + } + return &CreatedRecordResult{Rkey: rkey, URI: uri}, nil +} diff --git a/internal/app/string_delete.go b/internal/app/string_delete.go new file mode 100644 index 0000000..7039499 --- /dev/null +++ b/internal/app/string_delete.go @@ -0,0 +1,24 @@ +package app + +import ( + "context" + "fmt" + + "github.com/alyraffauf/tg/atproto" +) + +// DeleteString removes a string record from the authenticated user's account. +func (s *Service) DeleteString(ctx context.Context, rkey string) (*DeletedRecordResult, error) { + atClient, did, err := s.authenticatedPDS(ctx) + if err != nil { + return nil, err + } + if err := atClient.DeleteRecord(ctx, atproto.DeleteRecordInput{ + Repo: did, + Collection: stringCollection, + Rkey: rkey, + }); err != nil { + return nil, fmt.Errorf("delete string: %w", err) + } + return &DeletedRecordResult{Rkey: rkey}, nil +} diff --git a/internal/app/string_list.go b/internal/app/string_list.go new file mode 100644 index 0000000..c8a994c --- /dev/null +++ b/internal/app/string_list.go @@ -0,0 +1,50 @@ +package app + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/alyraffauf/tg/atproto" + "github.com/alyraffauf/tg/internal/tangledlex" +) + +// ListStrings lists every string owned by handle. +func (s *Service) ListStrings(ctx context.Context, handle string) ([]StringItem, error) { + atClient, did, err := s.publicPDS(ctx, handle) + if err != nil { + return nil, err + } + records, err := atClient.ListAllRecords(ctx, did, stringCollection, atproto.ListRecordsOpts{Limit: defaultListLimit}) + if err != nil { + return nil, fmt.Errorf("list strings for %q: %w", handle, err) + } + return buildStringItems(records), nil +} + +func buildStringItems(records []atproto.RecordItem) []StringItem { + items := make([]StringItem, 0, len(records)) + for _, rec := range records { + var str tangledlex.String + data, err := json.Marshal(rec.Value) + if err != nil { + continue + } + if err := json.Unmarshal(data, &str); err != nil { + continue + } + // Records without a filename are not strings; skip them rather + // than rendering a blank row. + if str.Filename == "" { + continue + } + items = append(items, StringItem{ + Rkey: extractRKey(rec.URI), + URI: rec.URI, + Filename: str.Filename, + Description: str.Description, + CreatedAt: str.CreatedAt, + }) + } + return items +} diff --git a/internal/app/string_view.go b/internal/app/string_view.go new file mode 100644 index 0000000..38d4626 --- /dev/null +++ b/internal/app/string_view.go @@ -0,0 +1,46 @@ +package app + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/alyraffauf/tg/internal/tangledlex" +) + +// ViewString fetches a single string by rkey from handle's account. +func (s *Service) ViewString(ctx context.Context, handle, rkey string) (*StringViewResult, error) { + atClient, did, err := s.publicPDS(ctx, handle) + if err != nil { + return nil, err + } + found, err := atClient.GetRecord(ctx, did, stringCollection, rkey) + if err != nil { + return nil, fmt.Errorf("get string %q for %q: %w", rkey, handle, err) + } + record, err := decodeStringRecord(found.Value) + if err != nil { + return nil, fmt.Errorf("decode string %q: %w", rkey, err) + } + return &StringViewResult{ + Rkey: rkey, + URI: found.URI, + Filename: record.Filename, + Author: Author{DID: did, Handle: handle}, + Description: record.Description, + Contents: record.Contents, + CreatedAt: record.CreatedAt, + }, nil +} + +func decodeStringRecord(value any) (tangledlex.String, error) { + data, err := json.Marshal(value) + if err != nil { + return tangledlex.String{}, fmt.Errorf("encode record: %w", err) + } + var record tangledlex.String + if err := json.Unmarshal(data, &record); err != nil { + return tangledlex.String{}, fmt.Errorf("decode record: %w", err) + } + return record, nil +} diff --git a/internal/app/strings.go b/internal/app/strings.go deleted file mode 100644 index 8653357..0000000 --- a/internal/app/strings.go +++ /dev/null @@ -1,137 +0,0 @@ -package app - -import ( - "context" - "encoding/json" - "fmt" - "time" - - "github.com/alyraffauf/tg/atproto" - "github.com/alyraffauf/tg/internal/tangledlex" - "github.com/bluesky-social/indigo/atproto/syntax" -) - -// CreateStringInput configures string creation. -type CreateStringInput struct { - Filename string - Description string - Contents string -} - -// CreateString writes a new sh.tangled.string record. -func (s *Service) CreateString(ctx context.Context, in CreateStringInput) (*CreatedRecordResult, error) { - atClient, did, err := s.authenticatedPDS(ctx) - if err != nil { - return nil, err - } - rkey := string(syntax.NewTIDNow(0)) - uri, _, err := atClient.PutRecord(ctx, atproto.PutRecordInput{ - Repo: did, - Collection: stringCollection, - Rkey: rkey, - Record: tangledlex.String{ - LexiconTypeID: stringCollection, - Filename: in.Filename, - Description: in.Description, - Contents: in.Contents, - CreatedAt: time.Now().UTC().Format(time.RFC3339), - }, - }) - if err != nil { - return nil, fmt.Errorf("create string: %w", err) - } - return &CreatedRecordResult{Rkey: rkey, URI: uri}, nil -} - -// ListStrings lists every string owned by handle. -func (s *Service) ListStrings(ctx context.Context, handle string) ([]StringItem, error) { - atClient, did, err := s.publicPDS(ctx, handle) - if err != nil { - return nil, err - } - records, err := atClient.ListAllRecords(ctx, did, stringCollection, atproto.ListRecordsOpts{Limit: defaultListLimit}) - if err != nil { - return nil, fmt.Errorf("list strings for %q: %w", handle, err) - } - return buildStringItems(records), nil -} - -// ViewString fetches a single string by rkey from handle's account. -func (s *Service) ViewString(ctx context.Context, handle, rkey string) (*StringViewResult, error) { - atClient, did, err := s.publicPDS(ctx, handle) - if err != nil { - return nil, err - } - found, err := atClient.GetRecord(ctx, did, stringCollection, rkey) - if err != nil { - return nil, fmt.Errorf("get string %q for %q: %w", rkey, handle, err) - } - record, err := decodeStringRecord(found.Value) - if err != nil { - return nil, fmt.Errorf("decode string %q: %w", rkey, err) - } - return &StringViewResult{ - Rkey: rkey, - URI: found.URI, - Filename: record.Filename, - Author: Author{DID: did, Handle: handle}, - Description: record.Description, - Contents: record.Contents, - CreatedAt: record.CreatedAt, - }, nil -} - -// DeleteString removes a string record from the authenticated user's account. -func (s *Service) DeleteString(ctx context.Context, rkey string) (*DeletedRecordResult, error) { - atClient, did, err := s.authenticatedPDS(ctx) - if err != nil { - return nil, err - } - if err := atClient.DeleteRecord(ctx, atproto.DeleteRecordInput{ - Repo: did, - Collection: stringCollection, - Rkey: rkey, - }); err != nil { - return nil, fmt.Errorf("delete string: %w", err) - } - return &DeletedRecordResult{Rkey: rkey}, nil -} - -func buildStringItems(records []atproto.RecordItem) []StringItem { - items := make([]StringItem, 0, len(records)) - for _, rec := range records { - var str tangledlex.String - data, err := json.Marshal(rec.Value) - if err != nil { - continue - } - if err := json.Unmarshal(data, &str); err != nil { - continue - } - // Records without a filename are not strings; skip them rather - // than rendering a blank row. - if str.Filename == "" { - continue - } - items = append(items, StringItem{ - Rkey: extractRKey(rec.URI), - URI: rec.URI, - Filename: str.Filename, - Description: str.Description, - CreatedAt: str.CreatedAt, - }) - } - return items -} - -func decodeStringRecord(value any) (tangledlex.String, error) { - data, err := json.Marshal(value) - if err != nil { - return tangledlex.String{}, fmt.Errorf("encode record: %w", err) - } - var record tangledlex.String - if err := json.Unmarshal(data, &record); err != nil { - return tangledlex.String{}, fmt.Errorf("decode record: %w", err) - } - return record, nil -} -- 2.51.2