diff --git a/internal/cli/comment.go b/internal/cli/comment.go new file mode 100644 index 0000000..c24bc6e --- /dev/null +++ b/internal/cli/comment.go @@ -0,0 +1,90 @@ +package cli + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/alyraffauf/tg/atproto" + "github.com/bluesky-social/indigo/atproto/syntax" +) + +type issueCommentRecord struct { + Type string `json:"$type"` + Issue string `json:"issue"` + Body string `json:"body"` + CreatedAt string `json:"createdAt"` +} + +func commandBody(body, bodyFile string) (string, error) { + if bodyFile == "" { + return body, nil + } + if body != "" { + return "", fmt.Errorf("--body and --body-file cannot be used together") + } + data, err := os.ReadFile(bodyFile) + if err != nil { + return "", fmt.Errorf("read body file: %w", err) + } + return string(data), nil +} + +type pullCommentRecord struct { + Type string `json:"$type"` + Pull string `json:"pull"` + Body string `json:"body"` + CreatedAt string `json:"createdAt"` +} + +type createdRecordResult struct { + Rkey string `json:"rkey"` + URI string `json:"uri"` +} + +func createIssueComment(ctx context.Context, issueURI, body string) (createdRecordResult, error) { + atClient, did, err := authenticatedATProto(ctx) + if err != nil { + return createdRecordResult{}, err + } + rkey := string(syntax.NewTIDNow(0)) + uri, _, err := atClient.PutRecord(ctx, atproto.PutRecordInput{ + Repo: did, + Collection: "sh.tangled.repo.issue.comment", + Rkey: rkey, + Record: issueCommentRecord{ + Type: "sh.tangled.repo.issue.comment", + Issue: issueURI, + Body: body, + CreatedAt: time.Now().UTC().Format(time.RFC3339), + }, + }) + if err != nil { + return createdRecordResult{}, fmt.Errorf("create issue comment: %w", err) + } + return createdRecordResult{Rkey: rkey, URI: uri}, nil +} + +func createPullComment(ctx context.Context, pullURI, body string) (createdRecordResult, error) { + atClient, did, err := authenticatedATProto(ctx) + if err != nil { + return createdRecordResult{}, err + } + rkey := string(syntax.NewTIDNow(0)) + uri, _, err := atClient.PutRecord(ctx, atproto.PutRecordInput{ + Repo: did, + Collection: "sh.tangled.repo.pull.comment", + Rkey: rkey, + Record: pullCommentRecord{ + Type: "sh.tangled.repo.pull.comment", + Pull: pullURI, + Body: body, + CreatedAt: time.Now().UTC().Format(time.RFC3339), + }, + }) + if err != nil { + return createdRecordResult{}, fmt.Errorf("create pull request comment: %w", err) + } + return createdRecordResult{Rkey: rkey, URI: uri}, nil +} diff --git a/internal/cli/state.go b/internal/cli/state.go new file mode 100644 index 0000000..3d2b106 --- /dev/null +++ b/internal/cli/state.go @@ -0,0 +1,146 @@ +package cli + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/alyraffauf/tg/atproto" + "github.com/alyraffauf/tg/tangled" +) + +const ( + issueCollection = "sh.tangled.repo.issue" + pullCollection = "sh.tangled.repo.pull" +) + +type issueStateRecord struct { + Type string `json:"$type"` + Issue string `json:"issue"` + State string `json:"state"` +} + +type pullStatusRecord struct { + Type string `json:"$type"` + Pull string `json:"pull"` + Status string `json:"status"` +} + +type stateResult struct { + Rkey string `json:"rkey"` + State string `json:"state"` +} + +func authenticatedATProto(ctx context.Context) (*atproto.ATProto, string, error) { + if auth == nil || !auth.IsAuthenticated() { + return nil, "", fmt.Errorf("not logged in; run \"tg auth login\" first") + } + + pds, err := auth.APIClient(ctx) + if err != nil { + return nil, "", fmt.Errorf("get auth client: %w", err) + } + return &atproto.ATProto{Client: pds}, auth.CurrentDID().String(), nil +} + +func targetRecord(ctx context.Context, repoArg, collection, rkey string) (string, string, error) { + targetArgs := []string{} + if repoArg != "" { + targetArgs = []string{repoArg} + } + handle, repo, err := resolveTarget(ctx, targetArgs) + if err != nil { + return "", "", err + } + repoRecord, err := resolveRepoRecord(ctx, handle, repo) + if err != nil { + return "", "", err + } + + var items []tangled.ListItem + var recordType string + if collection == issueCollection { + issues, err := client.ListIssues(ctx, repoRecord.Value.RepoDid, tangled.ListOpts{Limit: defaultListLimit}) + if err != nil { + return "", "", fmt.Errorf("list issues for %s/%s: %w", handle, repo, err) + } + items = issues.Items + recordType = "issue" + } else { + pulls, err := client.ListPulls(ctx, repoRecord.Value.RepoDid, tangled.ListOpts{Limit: defaultListLimit}) + if err != nil { + return "", "", fmt.Errorf("list pull requests for %s/%s: %w", handle, repo, err) + } + items = pulls.Items + recordType = "pull request" + } + + record, err := findByRKey(items, rkey, recordType) + if err != nil { + return "", "", err + } + return record.URI, repoRecord.URI, nil +} + +func putState(ctx context.Context, atClient *atproto.ATProto, did, rkey, collection, target, state string) error { + if collection == issueCollection { + state = "sh.tangled.repo.issue.state." + state + return putRecord(ctx, atClient, did, "sh.tangled.repo.issue.state", rkey, issueStateRecord{ + Type: "sh.tangled.repo.issue.state", + Issue: target, + State: state, + }) + } + state = "sh.tangled.repo.pull.status." + state + return putRecord(ctx, atClient, did, "sh.tangled.repo.pull.status", rkey, pullStatusRecord{ + Type: "sh.tangled.repo.pull.status", + Pull: target, + Status: state, + }) +} + +func putRecord(ctx context.Context, atClient *atproto.ATProto, did, collection, rkey string, record any) error { + if _, _, err := atClient.PutRecord(ctx, atproto.PutRecordInput{ + Repo: did, Collection: collection, Rkey: rkey, Record: record, + }); err != nil { + return err + } + return nil +} + +func editRecord(ctx context.Context, atClient *atproto.ATProto, did, collection, rkey, title, body string, setTitle, setBody bool) error { + found, err := atClient.GetRecord(ctx, did, collection, rkey) + if err != nil { + return fmt.Errorf("get existing record: %w", err) + } + + record, err := preserveRecord(found.Value) + if err != nil { + return err + } + if setTitle { + record["title"] = title + } + if setBody { + record["body"] = body + } + _, _, err = atClient.PutRecord(ctx, atproto.PutRecordInput{ + Repo: did, Collection: collection, Rkey: rkey, Record: record, + }) + return err +} + +func preserveRecord(value any) (map[string]any, error) { + data, err := json.Marshal(value) + if err != nil { + return nil, fmt.Errorf("encode existing record: %w", err) + } + var record map[string]any + if err := json.Unmarshal(data, &record); err != nil { + return nil, fmt.Errorf("decode existing record: %w", err) + } + if record == nil { + return nil, fmt.Errorf("existing record is not an object") + } + return record, nil +} diff --git a/internal/cli/write.go b/internal/cli/write.go new file mode 100644 index 0000000..50771c1 --- /dev/null +++ b/internal/cli/write.go @@ -0,0 +1,58 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/alyraffauf/tg/tangled" + "github.com/bluesky-social/indigo/atproto/atclient" +) + +// resolveRepoRecord finds a repository record, including legacy records whose +// rkey does not match the repository name. +func resolveRepoRecord(ctx context.Context, handle, name string) (*tangled.Repo, error) { + ident, err := resolver.ResolveHandle(ctx, handle) + if err != nil { + return nil, fmt.Errorf("resolve handle %q: %w", handle, err) + } + + recordURI := fmt.Sprintf("at://%s/sh.tangled.repo/%s", ident.DID, name) + if repo, err := client.GetRepo(ctx, recordURI); err == nil { + if repo.URI == "" { + repo.URI = recordURI + } + return repo, nil + } else if !isNotFoundError(err) { + return nil, err + } + + repos, err := client.ListRepos(ctx, ident.DID.String()) + if err != nil { + return nil, fmt.Errorf("list repos for %q: %w", handle, err) + } + for index := range repos.Items { + repo := &repos.Items[index] + if repo.Value.Name == name || extractRKey(repo.URI) == name { + return repo, nil + } + } + return nil, fmt.Errorf("repo %q not found for handle %q", name, handle) +} + +func isNotFoundError(err error) bool { + var apiError *atclient.APIError + return errors.As(err, &apiError) && apiError.StatusCode == http.StatusNotFound +} + +func requireOwnedRepo(ctx context.Context, handle, name, did string) (*tangled.Repo, error) { + repo, err := resolveRepoRecord(ctx, handle, name) + if err != nil { + return nil, err + } + if extractDID(repo.URI) != did { + return nil, fmt.Errorf("repo %q is not owned by the authenticated user", handle+"/"+name) + } + return repo, nil +}