feat: github adapter

This commit is contained in:
wpetit 2025-02-24 21:48:16 +01:00
parent 5606e658a2
commit 20790b04b3
7 changed files with 386 additions and 38 deletions

5
go.mod
View File

@ -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

7
go.sum
View File

@ -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=

View File

@ -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
}

View File

@ -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{}

View File

@ -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

View File

@ -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
}

View File

@ -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...)