From 20790b04b3de001d648fd5c5ed7889fd52d3b521 Mon Sep 17 00:00:00 2001 From: William Petit Date: Mon, 24 Feb 2025 21:48:16 +0100 Subject: [PATCH] feat: github adapter --- go.mod | 5 +- go.sum | 7 +- internal/adapter/gitea/forge.go | 6 - internal/adapter/github/forge.go | 312 +++++++++++++++++++++++++ internal/core/service/issue_manager.go | 5 + internal/setup/forge_factories.go | 58 +++++ internal/setup/issue_manager.go | 31 +-- 7 files changed, 386 insertions(+), 38 deletions(-) create mode 100644 internal/adapter/github/forge.go create mode 100644 internal/setup/forge_factories.go diff --git a/go.mod b/go.mod index 5e973d7..dc3b7f9 100644 --- a/go.mod +++ b/go.mod @@ -9,13 +9,14 @@ require ( github.com/a-h/templ v0.3.833 github.com/bornholm/genai v0.0.0-20250222092500-1076426da67c github.com/caarlos0/env/v11 v11.2.2 - github.com/davecgh/go-spew v1.1.1 github.com/gabriel-vasile/mimetype v1.4.7 + github.com/google/go-github/v69 v69.2.0 github.com/gorilla/sessions v1.1.1 github.com/markbates/goth v1.80.0 github.com/num30/go-cache v1.0.0 github.com/pkg/errors v0.9.1 github.com/samber/slog-http v1.4.4 + github.com/yuin/goldmark v1.7.8 ) require ( @@ -27,6 +28,7 @@ require ( github.com/davidmz/go-pageant v1.0.2 // indirect github.com/go-fed/httpsig v1.1.0 // indirect github.com/golang/protobuf v1.5.4 // indirect + github.com/google/go-querystring v1.1.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/context v1.1.1 // indirect github.com/gorilla/mux v1.6.2 // indirect @@ -38,7 +40,6 @@ require ( github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect - github.com/yuin/goldmark v1.7.8 // indirect go.opentelemetry.io/otel v1.29.0 // indirect go.opentelemetry.io/otel/trace v1.29.0 // indirect golang.org/x/crypto v0.33.0 // indirect diff --git a/go.sum b/go.sum index cbce944..74724fe 100644 --- a/go.sum +++ b/go.sum @@ -10,8 +10,6 @@ github.com/BurntSushi/toml v1.0.0 h1:dtDWrepsVPfW9H/4y7dDgFc2MBUSeJhlaDtK13CxFlU github.com/BurntSushi/toml v1.0.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/RealAlexandreAI/json-repair v0.0.14 h1:4kTqotVonDVTio5n2yweRUELVcNe2x518wl0bCsw0t0= github.com/RealAlexandreAI/json-repair v0.0.14/go.mod h1:GKJi5borR78O8c7HCVbgqjhoiVibZ6hJldxbc6dGrAI= -github.com/a-h/templ v0.3.819 h1:KDJ5jTFN15FyJnmSmo2gNirIqt7hfvBD2VXVDTySckM= -github.com/a-h/templ v0.3.819/go.mod h1:iDJKJktpttVKdWoTkRNNLcllRI+BlpopJc+8au3gOUo= github.com/a-h/templ v0.3.833 h1:L/KOk/0VvVTBegtE0fp2RJQiBm7/52Zxv5fqlEHiQUU= github.com/a-h/templ v0.3.833/go.mod h1:cAu4AiZhtJfBjMY0HASlyzvkrtjnHWPeEsyGK2YYmfk= github.com/bornholm/genai v0.0.0-20250222092500-1076426da67c h1:bI0ebsgO1/7Jx6+ZQdDF/I6tTZxyB5hODYz7x/XxwK8= @@ -30,9 +28,14 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-github/v69 v69.2.0 h1:wR+Wi/fN2zdUx9YxSmYE0ktiX9IAR/BeePzeaUUbEHE= +github.com/google/go-github/v69 v69.2.0/go.mod h1:xne4jymxLR6Uj9b7J7PyTpkMYstEMMwGZa0Aehh1azM= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8= diff --git a/internal/adapter/gitea/forge.go b/internal/adapter/gitea/forge.go index 2420eb9..a7db510 100644 --- a/internal/adapter/gitea/forge.go +++ b/internal/adapter/gitea/forge.go @@ -4,9 +4,7 @@ import ( "context" "log/slog" "net/http" - "slices" "strconv" - "strings" "code.gitea.io/sdk/gitea" "forge.cadoles.com/wpetit/clearcase/internal/core/model" @@ -164,10 +162,6 @@ func (f *Forge) GetAllProjects(ctx context.Context) ([]*model.Project, error) { } } - slices.SortFunc(projects, func(p1 *model.Project, p2 *model.Project) int { - return strings.Compare(p1.Name, p2.Name) - }) - return projects, nil } diff --git a/internal/adapter/github/forge.go b/internal/adapter/github/forge.go new file mode 100644 index 0000000..4a3b53c --- /dev/null +++ b/internal/adapter/github/forge.go @@ -0,0 +1,312 @@ +package github + +import ( + "context" + "encoding/base64" + "log/slog" + "net/http" + "strconv" + + "forge.cadoles.com/wpetit/clearcase/internal/core/model" + "forge.cadoles.com/wpetit/clearcase/internal/core/port" + "forge.cadoles.com/wpetit/clearcase/internal/core/service" + "github.com/google/go-github/v69/github" + "github.com/pkg/errors" +) + +type Forge struct { + client *github.Client +} + +// CreateIssue implements port.Forge. +func (f *Forge) CreateIssue(ctx context.Context, projectID string, title string, body string) (string, error) { + repo, err := f.getRepository(ctx, projectID) + if err != nil { + return "", errors.WithStack(err) + } + + issue, res, err := f.client.Issues.Create(ctx, *repo.Owner.Login, *repo.Name, &github.IssueRequest{ + Title: &title, + Body: &body, + }) + if res.StatusCode == http.StatusForbidden || res.StatusCode == http.StatusUnauthorized { + slog.ErrorContext(ctx, "could not create issue", slog.Any("error", errors.WithStack(err))) + return "", errors.WithStack(service.ErrForgeNotAvailable) + } + + return *issue.HTMLURL, nil +} + +// GetAllProjects implements port.Forge. +func (f *Forge) GetAllProjects(ctx context.Context) ([]*model.Project, error) { + projects := make([]*model.Project, 0) + + collectUserProjects := func() error { + page := 1 + for { + repos, res, err := f.client.Repositories.ListByAuthenticatedUser(ctx, &github.RepositoryListByAuthenticatedUserOptions{ + Type: "all", + ListOptions: github.ListOptions{ + Page: page, + PerPage: 100, + }, + }) + if err != nil { + if res.StatusCode == http.StatusForbidden || res.StatusCode == http.StatusUnauthorized { + slog.ErrorContext(ctx, "could not retrieve user repositories", slog.Any("error", errors.WithStack(err))) + return errors.WithStack(service.ErrForgeNotAvailable) + } + + return errors.WithStack(err) + } + + for _, r := range repos { + if r.ID == nil || r.FullName == nil { + continue + } + + var projectDescription string + if r.Description != nil { + projectDescription = *r.Description + } + + projects = append(projects, &model.Project{ + ID: strconv.FormatInt(*r.ID, 10), + Name: *r.FullName, + Description: projectDescription, + }) + + } + + if res.NextPage == 0 { + return nil + } + + page = res.NextPage + } + } + + collectOrgProjects := func(owner string) error { + page := 1 + for { + repos, res, err := f.client.Repositories.ListByOrg(ctx, owner, &github.RepositoryListByOrgOptions{ + Type: "all", + ListOptions: github.ListOptions{ + Page: page, + PerPage: 100, + }, + }) + if err != nil { + if res.StatusCode == http.StatusForbidden || res.StatusCode == http.StatusUnauthorized { + slog.ErrorContext(ctx, "could not retrieve org repositories", slog.String("org", owner), slog.Any("error", errors.WithStack(err))) + return errors.WithStack(service.ErrForgeNotAvailable) + } + + return errors.WithStack(err) + } + + for _, r := range repos { + if r.ID == nil || r.FullName == nil { + continue + } + + var projectDescription string + if r.Description != nil { + projectDescription = *r.Description + } + + projects = append(projects, &model.Project{ + ID: strconv.FormatInt(*r.ID, 10), + Name: *r.FullName, + Description: projectDescription, + }) + + } + + if res.NextPage == 0 { + return nil + } + + page = res.NextPage + } + } + + if err := collectUserProjects(); err != nil { + return nil, errors.WithStack(err) + } + + page := 1 + for { + orgs, res, err := f.client.Organizations.ListOrgMemberships(ctx, &github.ListOrgMembershipsOptions{ + State: "active", + ListOptions: github.ListOptions{ + Page: page, + PerPage: 100, + }, + }) + if err != nil { + if res.StatusCode == http.StatusForbidden || res.StatusCode == http.StatusUnauthorized { + slog.ErrorContext(ctx, "could not retrieve user organizations", slog.Any("error", errors.WithStack(err))) + return nil, errors.WithStack(service.ErrForgeNotAvailable) + } + + return nil, errors.WithStack(err) + } + + for _, o := range orgs { + if err := collectOrgProjects(*o.Organization.Login); err != nil { + return nil, errors.WithStack(err) + } + } + + if res.NextPage == 0 { + break + } + + page = res.NextPage + } + + return projects, nil +} + +// GetFile implements port.Forge. +func (f *Forge) GetFile(ctx context.Context, rawProjectID string, path string) ([]byte, error) { + repo, err := f.getRepository(ctx, rawProjectID) + if err != nil { + return nil, errors.WithStack(err) + } + + fileContent, _, res, err := f.client.Repositories.GetContents(ctx, *repo.Owner.Login, *repo.Name, path, nil) + if err != nil { + if res.StatusCode == http.StatusForbidden || res.StatusCode == http.StatusUnauthorized { + slog.ErrorContext(ctx, "could not retrieve file content", slog.Any("error", errors.WithStack(err))) + return nil, errors.WithStack(service.ErrForgeNotAvailable) + } + + if res.StatusCode == http.StatusNotFound { + return nil, errors.WithStack(port.ErrFileNotFound) + } + + return nil, errors.WithStack(err) + } + + decoded, err := base64.StdEncoding.DecodeString(*fileContent.Content) + if err != nil { + return nil, errors.WithStack(err) + } + + return decoded, nil +} + +// GetIssueTemplate implements port.Forge. +func (f *Forge) GetIssueTemplate(ctx context.Context, projectID string) (string, error) { + data, err := f.GetFile(ctx, projectID, ".github/ISSUE_TEMPLATE/bug_report.md") + if err != nil { + return "", errors.WithStack(err) + } + + return string(data), nil +} + +// GetIssues implements port.Forge. +func (f *Forge) GetIssues(ctx context.Context, projectID string, issueIDs ...string) ([]*model.Issue, error) { + repo, err := f.getRepository(ctx, projectID) + if err != nil { + return nil, errors.WithStack(err) + } + + issues := make([]*model.Issue, 0) + for _, rawIssueID := range issueIDs { + issueID, err := strconv.ParseInt(rawIssueID, 10, 64) + if err != nil { + slog.ErrorContext(ctx, "could not parse issue id", slog.Any("error", errors.WithStack(err))) + issues = append(issues, nil) + continue + } + + issue, _, err := f.client.Issues.Get(ctx, *repo.Owner.Login, *repo.Name, int(issueID)) + if err != nil { + slog.ErrorContext(ctx, "could not parse retrieve issue", slog.Int("issueID", int(issueID)), slog.String("repository", *repo.FullName), slog.Any("error", errors.WithStack(err))) + issues = append(issues, nil) + continue + } + + issues = append(issues, &model.Issue{ + ID: strconv.FormatInt(*issue.ID, 10), + Title: *issue.Title, + Body: *issue.Body, + }) + } + + return issues, nil +} + +// GetProject implements port.Forge. +func (f *Forge) GetProject(ctx context.Context, rawProjectID string) (*model.Project, error) { + repo, err := f.getRepository(ctx, rawProjectID) + if err != nil { + return nil, errors.WithStack(err) + } + + var projectDescription string + if repo.Description != nil { + projectDescription = *repo.Description + } + + return &model.Project{ + ID: strconv.FormatInt(*repo.ID, 10), + Name: *repo.Name, + Description: projectDescription, + }, nil +} + +// GetProjectLanguages implements port.Forge. +func (f *Forge) GetProjectLanguages(ctx context.Context, rawProjectID string) ([]string, error) { + repo, err := f.getRepository(ctx, rawProjectID) + if err != nil { + return nil, errors.WithStack(err) + } + + mappedLanguages, res, err := f.client.Repositories.ListLanguages(ctx, *repo.Owner.Login, *repo.Name) + if err != nil { + if res.StatusCode == http.StatusForbidden || res.StatusCode == http.StatusUnauthorized { + slog.ErrorContext(ctx, "could not retrieve repository languages", slog.Any("error", errors.WithStack(err))) + return nil, errors.WithStack(service.ErrForgeNotAvailable) + } + + return nil, errors.WithStack(err) + } + + languages := make([]string, 0, len(mappedLanguages)) + + for l := range mappedLanguages { + languages = append(languages, l) + } + + return languages, nil +} + +func (f *Forge) getRepository(ctx context.Context, rawProjectID string) (*github.Repository, error) { + projectID, err := strconv.ParseInt(rawProjectID, 10, 64) + if err != nil { + return nil, errors.WithStack(err) + } + + repo, res, err := f.client.Repositories.GetByID(ctx, projectID) + if err != nil { + if res.StatusCode == http.StatusForbidden || res.StatusCode == http.StatusUnauthorized { + slog.ErrorContext(ctx, "could not retrieve repository", slog.Any("error", errors.WithStack(err))) + return nil, errors.WithStack(service.ErrForgeNotAvailable) + } + + return nil, errors.WithStack(err) + } + + return repo, nil +} + +func NewForge(client *github.Client) *Forge { + return &Forge{client: client} +} + +var _ port.Forge = &Forge{} diff --git a/internal/core/service/issue_manager.go b/internal/core/service/issue_manager.go index 11ead2c..ddef9f1 100644 --- a/internal/core/service/issue_manager.go +++ b/internal/core/service/issue_manager.go @@ -6,6 +6,7 @@ import ( "log/slog" "path/filepath" "regexp" + "slices" "strings" "time" @@ -72,6 +73,10 @@ func (m *IssueManager) GetUserProjects(ctx context.Context, user *model.User) ([ return nil, errors.WithStack(err) } + slices.SortFunc(refreshedProjects, func(p1 *model.Project, p2 *model.Project) int { + return strings.Compare(p1.Name, p2.Name) + }) + m.projectCache.Set(cacheKey, refreshedProjects, 0) projects = refreshedProjects diff --git a/internal/setup/forge_factories.go b/internal/setup/forge_factories.go new file mode 100644 index 0000000..9f9e738 --- /dev/null +++ b/internal/setup/forge_factories.go @@ -0,0 +1,58 @@ +package setup + +import ( + "context" + "net/url" + + "code.gitea.io/sdk/gitea" + giteaAdapter "forge.cadoles.com/wpetit/clearcase/internal/adapter/gitea" + githubAdapter "forge.cadoles.com/wpetit/clearcase/internal/adapter/github" + + "forge.cadoles.com/wpetit/clearcase/internal/config" + "forge.cadoles.com/wpetit/clearcase/internal/core/model" + "forge.cadoles.com/wpetit/clearcase/internal/core/port" + "forge.cadoles.com/wpetit/clearcase/internal/core/service" + "github.com/google/go-github/v69/github" + "github.com/pkg/errors" +) + +func getForgeFactories(conf *config.Config) ([]service.ForgeFactory, error) { + forgeFactories := make([]service.ForgeFactory, 0) + + if conf.Auth.Providers.Gitea.Key != "" && conf.Auth.Providers.Gitea.Secret != "" { + baseURL, err := url.Parse(conf.Auth.Providers.Gitea.AuthURL) + if err != nil { + return nil, errors.Wrapf(err, "could not parse gitea auth url '%s'", conf.Auth.Providers.Gitea.AuthURL) + } + + baseURL.Path = "" + + forgeFactories = append(forgeFactories, &authProviderBasedForgeFactory{ + provider: "gitea", + create: func(ctx context.Context, user *model.User) (port.Forge, error) { + client, err := gitea.NewClient(baseURL.String(), gitea.SetToken(user.AccessToken)) + if err != nil { + return nil, errors.WithStack(err) + } + + forge := giteaAdapter.NewForge(client) + + return forge, nil + }, + }) + } + + if conf.Auth.Providers.Github.Key != "" && conf.Auth.Providers.Github.Secret != "" { + forgeFactories = append(forgeFactories, &authProviderBasedForgeFactory{ + provider: "github", + create: func(ctx context.Context, user *model.User) (port.Forge, error) { + client := github.NewClient(nil).WithAuthToken(user.AccessToken) + forge := githubAdapter.NewForge(client) + + return forge, nil + }, + }) + } + + return forgeFactories, nil +} diff --git a/internal/setup/issue_manager.go b/internal/setup/issue_manager.go index 7d00210..0a215b6 100644 --- a/internal/setup/issue_manager.go +++ b/internal/setup/issue_manager.go @@ -2,13 +2,8 @@ package setup import ( "context" - "net/url" - "code.gitea.io/sdk/gitea" - giteaAdapter "forge.cadoles.com/wpetit/clearcase/internal/adapter/gitea" "forge.cadoles.com/wpetit/clearcase/internal/config" - "forge.cadoles.com/wpetit/clearcase/internal/core/model" - "forge.cadoles.com/wpetit/clearcase/internal/core/port" "forge.cadoles.com/wpetit/clearcase/internal/core/service" "github.com/bornholm/genai/llm/provider" "github.com/pkg/errors" @@ -29,29 +24,9 @@ func NewIssueManagerFromConfig(ctx context.Context, conf *config.Config) (*servi return nil, errors.Wrapf(err, "could not create llm client '%s'", conf.LLM.Provider.Name) } - forgeFactories := make([]service.ForgeFactory, 0) - - if conf.Auth.Providers.Gitea.Key != "" && conf.Auth.Providers.Gitea.Secret != "" { - baseURL, err := url.Parse(conf.Auth.Providers.Gitea.AuthURL) - if err != nil { - return nil, errors.Wrapf(err, "could not parse gitea auth url '%s'", conf.Auth.Providers.Gitea.AuthURL) - } - - baseURL.Path = "" - - forgeFactories = append(forgeFactories, &authProviderBasedForgeFactory{ - provider: "gitea", - create: func(ctx context.Context, user *model.User) (port.Forge, error) { - client, err := gitea.NewClient(baseURL.String(), gitea.SetToken(user.AccessToken)) - if err != nil { - return nil, errors.WithStack(err) - } - - forge := giteaAdapter.NewForge(client) - - return forge, nil - }, - }) + forgeFactories, err := getForgeFactories(conf) + if err != nil { + return nil, errors.Wrap(err, "could not get forge factories") } issueManager := service.NewIssueManager(client, forgeFactories...)