diff --git a/README.md b/README.md index 87fbfd6..2222c01 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # forge -Go library and CLI for working with git forges. Supports GitHub, GitLab, Gitea/Forgejo, and Bitbucket Cloud through a single interface. +Go library and CLI for working with git forges. Supports GitHub, GitLab, Gitea/Forgejo, Bitbucket Cloud, and Tangled through a single interface. ## CLI @@ -46,7 +46,7 @@ forge auth login --domain gitea.example.com --token abc123 --type gitea Check what's configured with `forge auth status`. -Tokens are resolved in this order: CLI flags, environment variables (`FORGE_TOKEN`, `GITHUB_TOKEN`/`GH_TOKEN`, `GITLAB_TOKEN`, `FORGEJO_TOKEN`/`GITEA_TOKEN`, `BITBUCKET_TOKEN`), then the config file at `~/.config/forge/config`. The target host is inferred from the current directory's git remote; use `--host` or `FORGE_HOST` to override it (for example `forge --host gitea.com repo list someone`). +Tokens are resolved in this order: CLI flags, environment variables (`FORGE_TOKEN`, `GITHUB_TOKEN`/`GH_TOKEN`, `GITLAB_TOKEN`, `FORGEJO_TOKEN`/`GITEA_TOKEN`, `BITBUCKET_TOKEN`, `TANGLED_TOKEN`), then the config file at `~/.config/forge/config`. The target host is inferred from the current directory's git remote; use `--host` or `FORGE_HOST` to override it (for example `forge --host gitea.com repo list someone`). ### Configuration @@ -109,6 +109,7 @@ Self-hosted instances can be registered explicitly or detected automatically: import ( "github.com/git-pkgs/forge/gitea" "github.com/git-pkgs/forge/gitlab" + "github.com/git-pkgs/forge/tangled" ) client := forges.NewClient( @@ -121,6 +122,7 @@ err := client.RegisterDomain(ctx, "git.example.com", token, forges.ForgeBuilders GitHub: github.NewWithBase, GitLab: gitlab.New, Gitea: gitea.New, + Tangled: tangled.New, }) ``` diff --git a/detect.go b/detect.go index f227281..3c6abfe 100644 --- a/detect.go +++ b/detect.go @@ -73,6 +73,11 @@ func detectFromAPI(ctx context.Context, client *http.Client, baseURL string) (Fo return GitHub, nil } + // Try Tangled knot/appview XRPC endpoint. + if ok, err := probeURL(ctx, client, baseURL+"/xrpc/sh.tangled.knot.version"); err == nil && ok { + return Tangled, nil + } + return Unknown, fmt.Errorf("could not detect forge type for %s", baseURL) } diff --git a/forge.go b/forge.go index dbef2d4..e6d8e2b 100644 --- a/forge.go +++ b/forge.go @@ -141,6 +141,8 @@ func (c *Client) RegisterDomain(ctx context.Context, domain, token string, build c.forges[domain] = builders.GitLab(baseURL, token, c.httpClient) case Gitea, Forgejo: c.forges[domain] = builders.Gitea(baseURL, token, c.httpClient) + case Tangled: + c.forges[domain] = builders.Tangled(baseURL, token, c.httpClient) default: return fmt.Errorf("unsupported forge type %q for %s", ft, domain) } @@ -150,9 +152,10 @@ func (c *Client) RegisterDomain(ctx context.Context, domain, token string, build // ForgeBuilders holds constructor functions for each forge type. // Used by RegisterDomain to create the right forge after detection. type ForgeBuilders struct { - GitHub func(baseURL, token string, hc *http.Client) Forge - GitLab func(baseURL, token string, hc *http.Client) Forge - Gitea func(baseURL, token string, hc *http.Client) Forge + GitHub func(baseURL, token string, hc *http.Client) Forge + GitLab func(baseURL, token string, hc *http.Client) Forge + Gitea func(baseURL, token string, hc *http.Client) Forge + Tangled func(baseURL, token string, hc *http.Client) Forge } func (c *Client) forgeFor(domain string) (Forge, error) { diff --git a/forges_test.go b/forges_test.go index 7329fbd..a0fbb5a 100644 --- a/forges_test.go +++ b/forges_test.go @@ -322,6 +322,36 @@ func TestDetectForgeTypeGitHubAPI(t *testing.T) { } } +func TestDetectForgeTypeTangledAPI(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + mux.HandleFunc("GET /api/v1/version", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + mux.HandleFunc("GET /api/v4/version", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + mux.HandleFunc("GET /api/v3/meta", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + mux.HandleFunc("GET /xrpc/sh.tangled.knot.version", func(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprintf(w, `{"version":"0.1.0"}`) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + ft, err := detectFromAPI(context.Background(), http.DefaultClient, srv.URL) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ft != Tangled { + t.Errorf("want Tangled, got %s", ft) + } +} + func TestClientListRepositoriesRoutes(t *testing.T) { mock := &mockForge{ repoService: &mockRepoService{ diff --git a/internal/cli/auth.go b/internal/cli/auth.go index 0d3b78f..d8e8cca 100644 --- a/internal/cli/auth.go +++ b/internal/cli/auth.go @@ -76,7 +76,7 @@ func authLoginCmd() *cobra.Command { cmd.Flags().StringVar(&domain, "domain", "", "Forge domain (e.g. github.com, gitea.example.com)") cmd.Flags().StringVar(&token, "token", "", "API token") - cmd.Flags().StringVar(&forgeType, "type", "", "Forge type: github, gitlab, gitea, forgejo, bitbucket") + cmd.Flags().StringVar(&forgeType, "type", "", "Forge type: github, gitlab, gitea, forgejo, bitbucket, tangled") return cmd } @@ -91,7 +91,7 @@ func authStatusCmd() *cobra.Command { } // Known domains to check for env var tokens - knownDomains := []string{"github.com", "gitlab.com", "codeberg.org", "bitbucket.org"} + knownDomains := []string{"github.com", "gitlab.com", "codeberg.org", "bitbucket.org", "tangled.org"} // Collect all unique domains domains := make(map[string]bool) diff --git a/internal/cli/repo_test.go b/internal/cli/repo_test.go index 3babd20..6bc53e8 100644 --- a/internal/cli/repo_test.go +++ b/internal/cli/repo_test.go @@ -97,6 +97,7 @@ func TestDomainFromFlags(t *testing.T) { {"gitea", "codeberg.org"}, {"forgejo", "codeberg.org"}, {"bitbucket", "bitbucket.org"}, + {"tangled", "tangled.org"}, } for _, tt := range tests { diff --git a/internal/cli/root.go b/internal/cli/root.go index 431be8e..b1da5b7 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -23,7 +23,7 @@ var ( var rootCmd = &cobra.Command{ Use: "forge", Short: "Work with git forges from the command line", - Long: "Supports GitHub, GitLab, Gitea, Forgejo, and Bitbucket Cloud through a single interface.", + Long: "Supports GitHub, GitLab, Gitea, Forgejo, Bitbucket Cloud, and Tangled through a single interface.", SilenceUsage: true, PersistentPreRun: func(cmd *cobra.Command, args []string) { if !cmd.Flags().Changed("output") { @@ -45,7 +45,7 @@ func Execute() error { func init() { rootCmd.PersistentFlags().StringVarP(&flagRepo, "repo", "R", "", "Select a repository (OWNER/REPO or HOST/OWNER/REPO)") - rootCmd.PersistentFlags().StringVar(&flagForgeType, "forge-type", "", "Force forge type: github, gitlab, gitea, forgejo, bitbucket") + rootCmd.PersistentFlags().StringVar(&flagForgeType, "forge-type", "", "Force forge type: github, gitlab, gitea, forgejo, bitbucket, tangled") rootCmd.PersistentFlags().StringVar(&flagHost, "host", "", "Force forge host (e.g. gitea.com); overrides FORGE_HOST and remote detection") rootCmd.PersistentFlags().StringVarP(&flagOutput, "output", "o", "table", "Output format: table, json, plain") rootCmd.PersistentFlags().StringVar(&flagRemote, "remote", "", "Git remote to use when not specifying -R (default origin)") diff --git a/internal/config/config.go b/internal/config/config.go index d7b9085..8b1207d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -29,7 +29,7 @@ type DefaultSection struct { } type DomainSection struct { - Type string // github, gitlab, gitea, forgejo, bitbucket + Type string // github, gitlab, gitea, forgejo, bitbucket, tangled Token string // only from user config, never .forge SSHHost string // alternate host for git-over-ssh; the section name remains the API host GitProtocol string // https or ssh; overrides default diff --git a/internal/resolve/resolve.go b/internal/resolve/resolve.go index 93c7191..8993a04 100644 --- a/internal/resolve/resolve.go +++ b/internal/resolve/resolve.go @@ -15,6 +15,7 @@ import ( ghforge "github.com/git-pkgs/forge/github" glforge "github.com/git-pkgs/forge/gitlab" "github.com/git-pkgs/forge/internal/config" + "github.com/git-pkgs/forge/tangled" ) var ( @@ -82,9 +83,10 @@ func ResetTestForge() { } var builders = forges.ForgeBuilders{ - GitHub: ghforge.NewWithBase, - GitLab: glforge.New, - Gitea: gitea.New, + GitHub: ghforge.NewWithBase, + GitLab: glforge.New, + Gitea: gitea.New, + Tangled: tangled.New, } // Repo figures out the forge, owner, and repo name from flags or the current @@ -225,6 +227,7 @@ func newClient(domain string) *forges.Client { "gitlab.com": glforge.New("https://gitlab.com", TokenForDomain("gitlab.com"), hc), "codeberg.org": gitea.New("https://codeberg.org", TokenForDomain("codeberg.org"), hc), "bitbucket.org": bitbucket.New(TokenForDomain("bitbucket.org"), hc), + "tangled.org": tangled.New("https://tangled.org", TokenForDomain("tangled.org"), hc), } for d, f := range defaults { opts = append(opts, forges.WithForge(d, f)) @@ -251,6 +254,8 @@ func forgeForType(forgeType, baseURL, token string, hc *http.Client) forges.Forg return glforge.New(baseURL, token, hc) case "github": return ghforge.NewWithBase(baseURL, token, hc) + case "tangled": + return tangled.New(baseURL, token, hc) } return nil } @@ -323,6 +328,10 @@ func TokenForDomainEnv(domain string) string { if t := os.Getenv("BITBUCKET_TOKEN"); t != "" { return t } + case "tangled.org": + if t := os.Getenv("TANGLED_TOKEN"); t != "" { + return t + } } // FORGE_TOKEN is a fallback for any domain without a specific token. @@ -367,6 +376,8 @@ func defaultDomainForType(forgeType string) string { return "codeberg.org" case "bitbucket": return "bitbucket.org" + case "tangled": + return "tangled.org" default: return "github.com" } diff --git a/internal/resolve/resolve_test.go b/internal/resolve/resolve_test.go index 24ad32b..d4fd254 100644 --- a/internal/resolve/resolve_test.go +++ b/internal/resolve/resolve_test.go @@ -60,7 +60,7 @@ func clearTokenEnv(t *testing.T) { for _, v := range []string{ "GITHUB_TOKEN", "GH_TOKEN", "GITLAB_TOKEN", "GLAB_TOKEN", - "FORGEJO_TOKEN", "GITEA_TOKEN", "BITBUCKET_TOKEN", + "FORGEJO_TOKEN", "GITEA_TOKEN", "BITBUCKET_TOKEN", "TANGLED_TOKEN", "FORGE_TOKEN", } { t.Setenv(v, "") @@ -129,6 +129,14 @@ func TestTokenForDomain(t *testing.T) { } t.Setenv("FORGEJO_TOKEN", "") t.Setenv("GITEA_TOKEN", "") + + // Tangled + t.Setenv("TANGLED_TOKEN", "tangled-tok") + got = TokenForDomain("tangled.org") + if got != "tangled-tok" { + t.Errorf("expected tangled-tok, got %q", got) + } + t.Setenv("TANGLED_TOKEN", "") } func TestTokenForDomainEnvSpecificOverridesFallback(t *testing.T) { @@ -175,6 +183,7 @@ func TestDomain(t *testing.T) { {"gitea", "codeberg.org"}, {"forgejo", "codeberg.org"}, {"bitbucket", "bitbucket.org"}, + {"tangled", "tangled.org"}, {"unknown", "github.com"}, } diff --git a/tangled/branches.go b/tangled/branches.go new file mode 100644 index 0000000..ab4b25e --- /dev/null +++ b/tangled/branches.go @@ -0,0 +1,69 @@ +package tangled + +import ( + "context" + "net/url" + "strings" + + forges "github.com/git-pkgs/forge" +) + +type branchService struct { + f *tangledForge +} + +func (s *branchService) List(ctx context.Context, owner, repo string, opts forges.ListBranchOpts) ([]forges.Branch, error) { + repoDID, err := s.f.repoDID(ctx, owner, repo) + if err != nil { + return nil, err + } + + var branches []forges.Branch + cursor := "" + for { + params := url.Values{} + params.Set("repo", repoDID) + addLimit(params, perPage(opts.Limit, opts.PerPage)) + if cursor != "" { + params.Set("cursor", cursor) + } + + var raw any + if err := s.f.xrpc(ctx, xrpcListBranches, params, &raw); err != nil { + return nil, err + } + items, next := collection(raw, "branches", "refs", "values") + for _, item := range items { + switch v := item.(type) { + case string: + branches = append(branches, forges.Branch{Name: strings.TrimPrefix(v, "refs/heads/")}) + case map[string]any: + name := stringField(v, "name", "ref") + if name == "" { + continue + } + branches = append(branches, forges.Branch{ + Name: strings.TrimPrefix(name, "refs/heads/"), + SHA: stringField(v, "sha", "oid", "target", "commit", "hash"), + Default: boolField(v, "default", "isDefault"), + Protected: boolField(v, "protected"), + }) + } + if limitReached(len(branches), opts.Limit) { + return branches, nil + } + } + if next == "" { + return branches, nil + } + cursor = next + } +} + +func (s *branchService) Create(context.Context, string, string, string, string) (*forges.Branch, error) { + return nil, forges.ErrNotSupported +} + +func (s *branchService) Delete(context.Context, string, string, string) error { + return forges.ErrNotSupported +} diff --git a/tangled/files.go b/tangled/files.go new file mode 100644 index 0000000..4cc514e --- /dev/null +++ b/tangled/files.go @@ -0,0 +1,74 @@ +package tangled + +import ( + "context" + "net/url" + "path" + "strings" + + forges "github.com/git-pkgs/forge" +) + +type fileService struct { + f *tangledForge +} + +func (s *fileService) Get(context.Context, string, string, string, string) (*forges.FileContent, error) { + return nil, forges.ErrNotSupported +} + +func (s *fileService) List(ctx context.Context, owner, repo, filePath, ref string) ([]forges.FileEntry, error) { + repoDID, err := s.f.repoDID(ctx, owner, repo) + if err != nil { + return nil, err + } + if ref == "" { + ref = "HEAD" + } + + params := url.Values{} + params.Set("repo", repoDID) + params.Set("ref", ref) + params.Set("path", strings.Trim(filePath, "/")) + + var raw any + if err := s.f.xrpc(ctx, xrpcGetTree, params, &raw); err != nil { + return nil, err + } + + items, _ := collection(raw, "tree", "entries", "values") + entries := make([]forges.FileEntry, 0, len(items)) + for _, item := range items { + v, ok := item.(map[string]any) + if !ok { + continue + } + name := stringField(v, "name") + entryPath := strings.TrimPrefix(stringField(v, "path"), "/") + if entryPath == "" { + entryPath = strings.TrimPrefix(path.Join(filePath, name), "/") + } + entryType := stringField(v, "type") + if entryType == "" { + entryType = typeFromMode(stringField(v, "mode")) + } + entries = append(entries, forges.FileEntry{ + Name: name, + Path: entryPath, + Type: entryType, + Size: int64Field(v, "size"), + }) + } + return entries, nil +} + +func typeFromMode(mode string) string { + switch { + case strings.HasPrefix(mode, "04") || mode == "tree" || mode == "dir": + return "dir" + case mode == "symlink" || mode == "120000": + return "symlink" + default: + return "file" + } +} diff --git a/tangled/repos.go b/tangled/repos.go new file mode 100644 index 0000000..662e6fa --- /dev/null +++ b/tangled/repos.go @@ -0,0 +1,129 @@ +package tangled + +import ( + "context" + "net/url" + "strings" + + forges "github.com/git-pkgs/forge" +) + +type repoService struct { + f *tangledForge +} + +func (s *repoService) Get(ctx context.Context, owner, repo string) (*forges.Repository, error) { + meta, err := s.f.repoMeta(ctx, owner, repo) + if err != nil { + return nil, err + } + cloneURL := meta.CloneURL + if cloneURL == "" { + cloneURL = s.f.repoURL(owner, repo) + } + result := &forges.Repository{ + FullName: owner + "/" + repo, + Owner: owner, + Name: repo, + Description: meta.Description, + HTMLURL: s.f.repoURL(owner, repo), + CloneURL: cloneURL, + HasIssues: true, + PullRequestsEnabled: true, + } + if branches, err := s.f.Branches().List(ctx, owner, repo, forges.ListBranchOpts{Limit: 1}); err == nil && len(branches) > 0 { + result.DefaultBranch = branches[0].Name + } + return result, nil +} + +func (s *repoService) List(context.Context, string, forges.ListRepoOpts) ([]forges.Repository, error) { + return nil, forges.ErrNotSupported +} + +func (s *repoService) Create(context.Context, forges.CreateRepoOpts) (*forges.Repository, error) { + return nil, forges.ErrNotSupported +} + +func (s *repoService) Edit(context.Context, string, string, forges.EditRepoOpts) (*forges.Repository, error) { + return nil, forges.ErrNotSupported +} + +func (s *repoService) Delete(context.Context, string, string) error { + return forges.ErrNotSupported +} + +func (s *repoService) Fork(context.Context, string, string, forges.ForkRepoOpts) (*forges.Repository, error) { + return nil, forges.ErrNotSupported +} + +func (s *repoService) ListForks(context.Context, string, string, forges.ListForksOpts) ([]forges.Repository, error) { + return nil, forges.ErrNotSupported +} + +func (s *repoService) ListTags(ctx context.Context, owner, repo string) ([]forges.Tag, error) { + repoDID, err := s.f.repoDID(ctx, owner, repo) + if err != nil { + return nil, err + } + + var tags []forges.Tag + cursor := "" + for { + params := url.Values{} + params.Set("repo", repoDID) + addLimit(params, 100) + if cursor != "" { + params.Set("cursor", cursor) + } + + var raw any + if err := s.f.xrpc(ctx, xrpcListTags, params, &raw); err != nil { + return nil, err + } + items, next := collection(raw, "tags", "refs", "values") + for _, item := range items { + switch v := item.(type) { + case string: + tags = append(tags, forges.Tag{Name: v}) + case map[string]any: + name := stringField(v, "name", "ref") + if name == "" { + continue + } + tags = append(tags, forges.Tag{ + Name: strings.TrimPrefix(name, "refs/tags/"), + Commit: stringField(v, "sha", "oid", "target", "commit", "hash"), + }) + } + } + if next == "" { + return tags, nil + } + cursor = next + } +} + +func (s *repoService) ListContributors(context.Context, string, string) ([]forges.Contributor, error) { + return nil, forges.ErrNotSupported +} + +func (s *repoService) Search(context.Context, forges.SearchRepoOpts) ([]forges.Repository, error) { + return nil, forges.ErrNotSupported +} + +func (s *repoService) SettingsURL(repoHTMLURL string) string { + return strings.TrimRight(repoHTMLURL, "/") + "/settings" +} +func (s *repoService) WikiURL(repoHTMLURL string) string { + return strings.TrimRight(repoHTMLURL, "/") + "/wiki" +} +func (s *repoService) ActionsURL(repoHTMLURL string) string { + return strings.TrimRight(repoHTMLURL, "/") + "/pipelines" +} +func (s *repoService) ReleasesURL(repoHTMLURL string) string { + return strings.TrimRight(repoHTMLURL, "/") + "/releases" +} +func (s *repoService) BlobURL(repoHTMLURL, ref, filePath string) string { + return strings.TrimRight(repoHTMLURL, "/") + "/blob/" + url.PathEscape(ref) + "/" + strings.TrimLeft(filePath, "/") +} diff --git a/tangled/tangled.go b/tangled/tangled.go new file mode 100644 index 0000000..4179988 --- /dev/null +++ b/tangled/tangled.go @@ -0,0 +1,301 @@ +package tangled + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "html" + "io" + "net/http" + "net/url" + "path" + "regexp" + "strconv" + "strings" + + forges "github.com/git-pkgs/forge" +) + +const ( + xrpcListBranches = "sh.tangled.git.temp.listBranches" + xrpcListTags = "sh.tangled.git.temp.listTags" + xrpcGetTree = "sh.tangled.git.temp.getTree" +) + +type tangledForge struct { + baseURL string + token string + httpClient *http.Client +} + +// New creates a Tangled forge backend. +func New(baseURL, token string, hc *http.Client) forges.Forge { + if hc == nil { + hc = http.DefaultClient + } + return &tangledForge{ + baseURL: strings.TrimRight(baseURL, "/"), + token: token, + httpClient: hc, + } +} + +func (f *tangledForge) Repos() forges.RepoService { return &repoService{f: f} } +func (f *tangledForge) Branches() forges.BranchService { return &branchService{f: f} } +func (f *tangledForge) Files() forges.FileService { return &fileService{f: f} } +func (f *tangledForge) Issues() forges.IssueService { return unsupportedIssueService{} } +func (f *tangledForge) PullRequests() forges.PullRequestService { return unsupportedPRService{} } +func (f *tangledForge) Labels() forges.LabelService { return unsupportedLabelService{} } +func (f *tangledForge) Milestones() forges.MilestoneService { return unsupportedMilestoneService{} } +func (f *tangledForge) Releases() forges.ReleaseService { return unsupportedReleaseService{} } +func (f *tangledForge) CI() forges.CIService { return unsupportedCIService{} } +func (f *tangledForge) DeployKeys() forges.DeployKeyService { return unsupportedDeployKeyService{} } +func (f *tangledForge) Secrets() forges.SecretService { return unsupportedSecretService{} } +func (f *tangledForge) Notifications() forges.NotificationService { + return unsupportedNotificationService{} +} +func (f *tangledForge) Reviews() forges.ReviewService { return unsupportedReviewService{} } +func (f *tangledForge) Collaborators() forges.CollaboratorService { + return unsupportedCollaboratorService{} +} +func (f *tangledForge) CommitStatuses() forges.CommitStatusService { + return unsupportedCommitStatusService{} +} + +func (f *tangledForge) GetRateLimit(context.Context) (*forges.RateLimit, error) { + return nil, forges.ErrNotSupported +} + +func (f *tangledForge) xrpcURL(nsid string, params url.Values) string { + u := f.baseURL + "/xrpc/" + nsid + if len(params) > 0 { + u += "?" + params.Encode() + } + return u +} + +func (f *tangledForge) doJSON(ctx context.Context, method, url string, body any, v any) error { + var bodyReader io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return err + } + bodyReader = bytes.NewReader(b) + } + + req, err := http.NewRequestWithContext(ctx, method, url, bodyReader) + if err != nil { + return err + } + if f.token != "" { + req.Header.Set("Authorization", "Bearer "+f.token) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := f.httpClient.Do(req) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode == http.StatusNotFound { + return forges.ErrNotFound + } + if resp.StatusCode == http.StatusNoContent { + return nil + } + if resp.StatusCode >= http.StatusBadRequest { + respBody, _ := io.ReadAll(resp.Body) + return &forges.HTTPError{StatusCode: resp.StatusCode, URL: url, Body: string(respBody)} + } + if v == nil { + return nil + } + return json.NewDecoder(resp.Body).Decode(v) +} + +func (f *tangledForge) xrpc(ctx context.Context, nsid string, params url.Values, v any) error { + return f.doJSON(ctx, http.MethodGet, f.xrpcURL(nsid, params), nil, v) +} + +func (f *tangledForge) repoURL(owner, repo string) string { + return f.baseURL + "/" + strings.Trim(path.Join(owner, repo), "/") +} + +type repoMeta struct { + DID string + CloneURL string + Description string +} + +func (f *tangledForge) repoMeta(ctx context.Context, owner, repo string) (*repoMeta, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, f.repoURL(owner, repo), nil) + if err != nil { + return nil, err + } + if f.token != "" { + req.Header.Set("Authorization", "Bearer "+f.token) + } + resp, err := f.httpClient.Do(req) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode == http.StatusNotFound { + return nil, forges.ErrNotFound + } + if resp.StatusCode >= http.StatusBadRequest { + body, _ := io.ReadAll(resp.Body) + return nil, &forges.HTTPError{StatusCode: resp.StatusCode, URL: req.URL.String(), Body: string(body)} + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + return parseRepoMeta(string(body)), nil +} + +func (f *tangledForge) repoDID(ctx context.Context, owner, repo string) (string, error) { + if strings.HasPrefix(owner, "did:") { + return owner, nil + } + meta, err := f.repoMeta(ctx, owner, repo) + if err != nil { + return "", err + } + if meta.DID == "" { + return "", fmt.Errorf("tangled repository DID not found for %s/%s", owner, repo) + } + return meta.DID, nil +} + +var ( + metaTagRE = regexp.MustCompile(`(?is)]*>`) + attrRE = regexp.MustCompile(`(?is)\b([a-z0-9:_-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')`) + tangledRepoRE = regexp.MustCompile(`at://([^/]+)/sh\.tangled\.repo/[^"'\s>]+`) +) + +func parseRepoMeta(body string) *repoMeta { + meta := &repoMeta{} + for _, tag := range metaTagRE.FindAllString(body, -1) { + attrs := attrsFromTag(tag) + name := strings.ToLower(attrs["name"]) + property := strings.ToLower(attrs["property"]) + content := html.UnescapeString(attrs["content"]) + switch { + case name == "vcs:clone": + meta.CloneURL = content + case name == "description" || property == "og:description": + meta.Description = content + } + } + if match := tangledRepoRE.FindStringSubmatch(body); len(match) == 2 { + meta.DID = html.UnescapeString(match[1]) + } + return meta +} + +func attrsFromTag(tag string) map[string]string { + attrs := make(map[string]string) + for _, match := range attrRE.FindAllStringSubmatch(tag, -1) { + value := match[2] + if value == "" { + value = match[3] + } + attrs[strings.ToLower(match[1])] = value + } + return attrs +} + +func perPage(limit, perPage int) int { + if perPage > 0 { + return perPage + } + if limit > 0 && limit < 100 { + return limit + } + return 100 +} + +func limitReached(n, limit int) bool { + return limit > 0 && n >= limit +} + +func addLimit(params url.Values, limit int) { + if limit > 0 { + params.Set("limit", strconv.Itoa(limit)) + } +} + +func collection(raw any, names ...string) ([]any, string) { + switch v := raw.(type) { + case []any: + return v, "" + case map[string]any: + cursor := stringField(v, "cursor", "nextCursor", "next_cursor", "next") + for _, name := range names { + if items, ok := v[name]; ok { + switch vv := items.(type) { + case []any: + return vv, cursor + case map[string]any: + out := make([]any, 0, len(vv)) + for key, value := range vv { + if m, ok := value.(map[string]any); ok { + m["name"] = key + out = append(out, m) + } + } + return out, cursor + } + } + } + } + return nil, "" +} + +func stringField(m map[string]any, keys ...string) string { + for _, key := range keys { + if v, ok := m[key]; ok { + switch vv := v.(type) { + case string: + return vv + case map[string]any: + if s := stringField(vv, "sha", "oid", "id", "hash"); s != "" { + return s + } + } + } + } + return "" +} + +func boolField(m map[string]any, keys ...string) bool { + for _, key := range keys { + if v, ok := m[key].(bool); ok { + return v + } + } + return false +} + +func int64Field(m map[string]any, keys ...string) int64 { + for _, key := range keys { + switch v := m[key].(type) { + case float64: + return int64(v) + case int64: + return v + case json.Number: + n, _ := v.Int64() + return n + } + } + return 0 +} diff --git a/tangled/tangled_test.go b/tangled/tangled_test.go new file mode 100644 index 0000000..7133522 --- /dev/null +++ b/tangled/tangled_test.go @@ -0,0 +1,128 @@ +package tangled + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + forges "github.com/git-pkgs/forge" +) + +func TestRepoGetUsesAppviewMetadataAndBranches(t *testing.T) { + srv := tangledTestServer(t) + f := New(srv.URL, "", srv.Client()) + + repo, err := f.Repos().Get(context.Background(), "tangled.org", "core") + if err != nil { + t.Fatalf("Get returned error: %v", err) + } + if repo.FullName != "tangled.org/core" { + t.Errorf("FullName = %q", repo.FullName) + } + if repo.CloneURL != srv.URL+"/tangled.org/core" { + t.Errorf("CloneURL = %q", repo.CloneURL) + } + if repo.Description != "Tangled core app" { + t.Errorf("Description = %q", repo.Description) + } + if repo.DefaultBranch != "master" { + t.Errorf("DefaultBranch = %q", repo.DefaultBranch) + } +} + +func TestBranchListUsesTangledXRPC(t *testing.T) { + srv := tangledTestServer(t) + f := New(srv.URL, "", srv.Client()) + + branches, err := f.Branches().List(context.Background(), "tangled.org", "core", forges.ListBranchOpts{Limit: 1}) + if err != nil { + t.Fatalf("List returned error: %v", err) + } + if len(branches) != 1 { + t.Fatalf("len(branches) = %d", len(branches)) + } + if branches[0].Name != "master" || branches[0].SHA != "abc123" || !branches[0].Default { + t.Errorf("unexpected branch: %+v", branches[0]) + } +} + +func TestRepoListTagsUsesTangledXRPC(t *testing.T) { + srv := tangledTestServer(t) + f := New(srv.URL, "", srv.Client()) + + tags, err := f.Repos().ListTags(context.Background(), "tangled.org", "core") + if err != nil { + t.Fatalf("ListTags returned error: %v", err) + } + if len(tags) != 1 { + t.Fatalf("len(tags) = %d", len(tags)) + } + if tags[0].Name != "v0.1.0" || tags[0].Commit != "def456" { + t.Errorf("unexpected tag: %+v", tags[0]) + } +} + +func TestFileListUsesTangledTreeXRPC(t *testing.T) { + srv := tangledTestServer(t) + f := New(srv.URL, "", srv.Client()) + + entries, err := f.Files().List(context.Background(), "tangled.org", "core", "api", "master") + if err != nil { + t.Fatalf("List returned error: %v", err) + } + if len(entries) != 2 { + t.Fatalf("len(entries) = %d", len(entries)) + } + if entries[0].Path != "api/tangled" || entries[0].Type != "dir" { + t.Errorf("unexpected first entry: %+v", entries[0]) + } + if entries[1].Path != "api/defs.go" || entries[1].Type != "file" || entries[1].Size != 1234 { + t.Errorf("unexpected second entry: %+v", entries[1]) + } +} + +func TestParsePath(t *testing.T) { + f := New("https://tangled.org", "", nil) + ref, err := f.ParsePath([]string{"did:plc:abc", "core", "issues", "42"}) + if err != nil { + t.Fatalf("ParsePath returned error: %v", err) + } + if ref.Owner != "did:plc:abc" || ref.Repo != "core" || ref.Type != forges.ResourceTypeIssue || ref.Number != 42 { + t.Errorf("unexpected ref: %+v", ref) + } +} + +func tangledTestServer(t *testing.T) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("GET /tangled.org/core", func(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprintf(w, `
+ + +`, "http://"+r.Host) + }) + mux.HandleFunc("GET /xrpc/sh.tangled.git.temp.listBranches", func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Query().Get("repo"); got != "did:plc:owner" { + t.Errorf("repo query = %q", got) + } + _, _ = fmt.Fprint(w, `{"branches":[{"name":"refs/heads/master","target":{"hash":"abc123"},"default":true},{"name":"next","sha":"999"}]}`) + }) + mux.HandleFunc("GET /xrpc/sh.tangled.git.temp.listTags", func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Query().Get("repo"); got != "did:plc:owner" { + t.Errorf("repo query = %q", got) + } + _, _ = fmt.Fprint(w, `{"tags":[{"name":"refs/tags/v0.1.0","target":{"hash":"def456"}}]}`) + }) + mux.HandleFunc("GET /xrpc/sh.tangled.git.temp.getTree", func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Query().Get("path"); got != "api" { + t.Errorf("path query = %q", got) + } + if got := r.URL.Query().Get("ref"); got != "master" { + t.Errorf("ref query = %q", got) + } + _, _ = fmt.Fprint(w, `{"tree":[{"name":"tangled","mode":"040000"},{"name":"defs.go","mode":"100644","size":1234}]}`) + }) + return httptest.NewServer(mux) +} diff --git a/tangled/unsupported.go b/tangled/unsupported.go new file mode 100644 index 0000000..93a9190 --- /dev/null +++ b/tangled/unsupported.go @@ -0,0 +1,252 @@ +package tangled + +import ( + "context" + "io" + "os" + + forges "github.com/git-pkgs/forge" +) + +type unsupportedIssueService struct{} + +func (unsupportedIssueService) Get(context.Context, string, string, int) (*forges.Issue, error) { + return nil, forges.ErrNotSupported +} +func (unsupportedIssueService) List(context.Context, string, string, forges.ListIssueOpts) ([]forges.Issue, error) { + return nil, forges.ErrNotSupported +} +func (unsupportedIssueService) Create(context.Context, string, string, forges.CreateIssueOpts) (*forges.Issue, error) { + return nil, forges.ErrNotSupported +} +func (unsupportedIssueService) Update(context.Context, string, string, int, forges.UpdateIssueOpts) (*forges.Issue, error) { + return nil, forges.ErrNotSupported +} +func (unsupportedIssueService) Close(context.Context, string, string, int) error { + return forges.ErrNotSupported +} +func (unsupportedIssueService) Reopen(context.Context, string, string, int) error { + return forges.ErrNotSupported +} +func (unsupportedIssueService) Delete(context.Context, string, string, int) error { + return forges.ErrNotSupported +} +func (unsupportedIssueService) CreateComment(context.Context, string, string, int, string) (*forges.Comment, error) { + return nil, forges.ErrNotSupported +} +func (unsupportedIssueService) ListComments(context.Context, string, string, int) ([]forges.Comment, error) { + return nil, forges.ErrNotSupported +} +func (unsupportedIssueService) ListReactions(context.Context, string, string, int, int64) ([]forges.Reaction, error) { + return nil, forges.ErrNotSupported +} +func (unsupportedIssueService) AddReaction(context.Context, string, string, int, int64, string) (*forges.Reaction, error) { + return nil, forges.ErrNotSupported +} +func (unsupportedIssueService) ListURL(repoHTMLURL string) string { return repoHTMLURL + "/issues" } + +type unsupportedPRService struct{} + +func (unsupportedPRService) Get(context.Context, string, string, int) (*forges.PullRequest, error) { + return nil, forges.ErrNotSupported +} +func (unsupportedPRService) List(context.Context, string, string, forges.ListPROpts) ([]forges.PullRequest, error) { + return nil, forges.ErrNotSupported +} +func (unsupportedPRService) Create(context.Context, string, string, forges.CreatePROpts) (*forges.PullRequest, error) { + return nil, forges.ErrNotSupported +} +func (unsupportedPRService) Update(context.Context, string, string, int, forges.UpdatePROpts) (*forges.PullRequest, error) { + return nil, forges.ErrNotSupported +} +func (unsupportedPRService) Close(context.Context, string, string, int) error { + return forges.ErrNotSupported +} +func (unsupportedPRService) Reopen(context.Context, string, string, int) error { + return forges.ErrNotSupported +} +func (unsupportedPRService) Merge(context.Context, string, string, int, forges.MergePROpts) error { + return forges.ErrNotSupported +} +func (unsupportedPRService) Diff(context.Context, string, string, int) (string, error) { + return "", forges.ErrNotSupported +} +func (unsupportedPRService) CreateComment(context.Context, string, string, int, string) (*forges.Comment, error) { + return nil, forges.ErrNotSupported +} +func (unsupportedPRService) ListComments(context.Context, string, string, int) ([]forges.Comment, error) { + return nil, forges.ErrNotSupported +} +func (unsupportedPRService) ListReactions(context.Context, string, string, int, int64) ([]forges.Reaction, error) { + return nil, forges.ErrNotSupported +} +func (unsupportedPRService) AddReaction(context.Context, string, string, int, int64, string) (*forges.Reaction, error) { + return nil, forges.ErrNotSupported +} +func (unsupportedPRService) ListURL(repoHTMLURL string) string { return repoHTMLURL + "/pulls" } + +type unsupportedLabelService struct{} + +func (unsupportedLabelService) List(context.Context, string, string, forges.ListLabelOpts) ([]forges.Label, error) { + return nil, forges.ErrNotSupported +} +func (unsupportedLabelService) Get(context.Context, string, string, string) (*forges.Label, error) { + return nil, forges.ErrNotSupported +} +func (unsupportedLabelService) Create(context.Context, string, string, forges.CreateLabelOpts) (*forges.Label, error) { + return nil, forges.ErrNotSupported +} +func (unsupportedLabelService) Update(context.Context, string, string, string, forges.UpdateLabelOpts) (*forges.Label, error) { + return nil, forges.ErrNotSupported +} +func (unsupportedLabelService) Delete(context.Context, string, string, string) error { + return forges.ErrNotSupported +} +func (unsupportedLabelService) ListURL(repoHTMLURL string) string { return repoHTMLURL + "/labels" } + +type unsupportedMilestoneService struct{} + +func (unsupportedMilestoneService) List(context.Context, string, string, forges.ListMilestoneOpts) ([]forges.Milestone, error) { + return nil, forges.ErrNotSupported +} +func (unsupportedMilestoneService) Get(context.Context, string, string, int) (*forges.Milestone, error) { + return nil, forges.ErrNotSupported +} +func (unsupportedMilestoneService) Create(context.Context, string, string, forges.CreateMilestoneOpts) (*forges.Milestone, error) { + return nil, forges.ErrNotSupported +} +func (unsupportedMilestoneService) Update(context.Context, string, string, int, forges.UpdateMilestoneOpts) (*forges.Milestone, error) { + return nil, forges.ErrNotSupported +} +func (unsupportedMilestoneService) Close(context.Context, string, string, int) error { + return forges.ErrNotSupported +} +func (unsupportedMilestoneService) Reopen(context.Context, string, string, int) error { + return forges.ErrNotSupported +} +func (unsupportedMilestoneService) Delete(context.Context, string, string, int) error { + return forges.ErrNotSupported +} + +type unsupportedReleaseService struct{} + +func (unsupportedReleaseService) List(context.Context, string, string, forges.ListReleaseOpts) ([]forges.Release, error) { + return nil, forges.ErrNotSupported +} +func (unsupportedReleaseService) Get(context.Context, string, string, string) (*forges.Release, error) { + return nil, forges.ErrNotSupported +} +func (unsupportedReleaseService) GetLatest(context.Context, string, string) (*forges.Release, error) { + return nil, forges.ErrNotSupported +} +func (unsupportedReleaseService) Create(context.Context, string, string, forges.CreateReleaseOpts) (*forges.Release, error) { + return nil, forges.ErrNotSupported +} +func (unsupportedReleaseService) Update(context.Context, string, string, string, forges.UpdateReleaseOpts) (*forges.Release, error) { + return nil, forges.ErrNotSupported +} +func (unsupportedReleaseService) Delete(context.Context, string, string, string) error { + return forges.ErrNotSupported +} +func (unsupportedReleaseService) UploadAsset(context.Context, string, string, string, *os.File) (*forges.ReleaseAsset, error) { + return nil, forges.ErrNotSupported +} +func (unsupportedReleaseService) DownloadAsset(context.Context, string, string, int64) (io.ReadCloser, error) { + return nil, forges.ErrNotSupported +} + +type unsupportedCIService struct{} + +func (unsupportedCIService) ListRuns(context.Context, string, string, forges.ListCIRunOpts) ([]forges.CIRun, error) { + return nil, forges.ErrNotSupported +} +func (unsupportedCIService) GetRun(context.Context, string, string, int64) (*forges.CIRun, error) { + return nil, forges.ErrNotSupported +} +func (unsupportedCIService) TriggerRun(context.Context, string, string, forges.TriggerCIRunOpts) error { + return forges.ErrNotSupported +} +func (unsupportedCIService) CancelRun(context.Context, string, string, int64) error { + return forges.ErrNotSupported +} +func (unsupportedCIService) RetryRun(context.Context, string, string, int64) error { + return forges.ErrNotSupported +} +func (unsupportedCIService) GetJobLog(context.Context, string, string, int64) (io.ReadCloser, error) { + return nil, forges.ErrNotSupported +} + +type unsupportedDeployKeyService struct{} + +func (unsupportedDeployKeyService) List(context.Context, string, string, forges.ListDeployKeyOpts) ([]forges.DeployKey, error) { + return nil, forges.ErrNotSupported +} +func (unsupportedDeployKeyService) Get(context.Context, string, string, int64) (*forges.DeployKey, error) { + return nil, forges.ErrNotSupported +} +func (unsupportedDeployKeyService) Create(context.Context, string, string, forges.CreateDeployKeyOpts) (*forges.DeployKey, error) { + return nil, forges.ErrNotSupported +} +func (unsupportedDeployKeyService) Delete(context.Context, string, string, int64) error { + return forges.ErrNotSupported +} + +type unsupportedSecretService struct{} + +func (unsupportedSecretService) List(context.Context, string, string, forges.ListSecretOpts) ([]forges.Secret, error) { + return nil, forges.ErrNotSupported +} +func (unsupportedSecretService) Set(context.Context, string, string, forges.SetSecretOpts) error { + return forges.ErrNotSupported +} +func (unsupportedSecretService) Delete(context.Context, string, string, string) error { + return forges.ErrNotSupported +} + +type unsupportedNotificationService struct{} + +func (unsupportedNotificationService) List(context.Context, forges.ListNotificationOpts) ([]forges.Notification, error) { + return nil, forges.ErrNotSupported +} +func (unsupportedNotificationService) MarkRead(context.Context, forges.MarkNotificationOpts) error { + return forges.ErrNotSupported +} +func (unsupportedNotificationService) Get(context.Context, string) (*forges.Notification, error) { + return nil, forges.ErrNotSupported +} + +type unsupportedReviewService struct{} + +func (unsupportedReviewService) List(context.Context, string, string, int, forges.ListReviewOpts) ([]forges.Review, error) { + return nil, forges.ErrNotSupported +} +func (unsupportedReviewService) Submit(context.Context, string, string, int, forges.SubmitReviewOpts) (*forges.Review, error) { + return nil, forges.ErrNotSupported +} +func (unsupportedReviewService) RequestReviewers(context.Context, string, string, int, []string) error { + return forges.ErrNotSupported +} +func (unsupportedReviewService) RemoveReviewers(context.Context, string, string, int, []string) error { + return forges.ErrNotSupported +} + +type unsupportedCollaboratorService struct{} + +func (unsupportedCollaboratorService) List(context.Context, string, string, forges.ListCollaboratorOpts) ([]forges.Collaborator, error) { + return nil, forges.ErrNotSupported +} +func (unsupportedCollaboratorService) Add(context.Context, string, string, string, forges.AddCollaboratorOpts) error { + return forges.ErrNotSupported +} +func (unsupportedCollaboratorService) Remove(context.Context, string, string, string) error { + return forges.ErrNotSupported +} + +type unsupportedCommitStatusService struct{} + +func (unsupportedCommitStatusService) List(context.Context, string, string, string) ([]forges.CommitStatus, error) { + return nil, forges.ErrNotSupported +} +func (unsupportedCommitStatusService) Set(context.Context, string, string, string, forges.SetCommitStatusOpts) (*forges.CommitStatus, error) { + return nil, forges.ErrNotSupported +} diff --git a/tangled/url.go b/tangled/url.go new file mode 100644 index 0000000..59f1bc4 --- /dev/null +++ b/tangled/url.go @@ -0,0 +1,37 @@ +package tangled + +import ( + "fmt" + "strconv" + + forges "github.com/git-pkgs/forge" +) + +// ParsePath implements Forge.ParsePath for Tangled URLs. +func (f *tangledForge) ParsePath(parts []string) (*forges.ResourceRef, error) { + if len(parts) < 2 { + return nil, fmt.Errorf("URL path must contain owner/repo") + } + + ref := &forges.ResourceRef{ + Owner: parts[0], + Repo: parts[1], + } + + if len(parts) >= 4 { + num, err := strconv.Atoi(parts[3]) + if err != nil { + return nil, fmt.Errorf("invalid number %q", parts[3]) + } + ref.Number = num + + switch parts[2] { + case "pulls", "pull": + ref.Type = forges.ResourceTypePR + case "issues": + ref.Type = forges.ResourceTypeIssue + } + } + + return ref, nil +} diff --git a/types.go b/types.go index 8e55e19..faf21a7 100644 --- a/types.go +++ b/types.go @@ -11,6 +11,7 @@ const ( Gitea ForgeType = "gitea" Forgejo ForgeType = "forgejo" Bitbucket ForgeType = "bitbucket" + Tangled ForgeType = "tangled" Unknown ForgeType = "unknown" )