Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ type (
ActiveAI AI `json:"active_ai"`
Models map[AI]Model `json:"models"`
}

VCSConfig struct {
Provider string `json:"provider"` // github o gitlab lo que se te cante
Token string `json:"token,omitempty"`
Expand Down
2 changes: 1 addition & 1 deletion internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -404,7 +404,7 @@ func TestValidateConfig(t *testing.T) {
},
wantErr: false,
},

{
name: "DefaultLang vacío",
config: &Config{
Expand Down
7 changes: 3 additions & 4 deletions internal/i18n/i18n.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@ package i18n
import (
"embed"
"fmt"
"os"
"path/filepath"

"github.com/BurntSushi/toml"
"github.com/nicksnyder/go-i18n/v2/i18n"
"golang.org/x/text/language"
"os"
"path/filepath"
)

//go:embed locales/*
Expand All @@ -26,7 +27,6 @@ func NewTranslations(defaultLang string, localesPath string) (*Translations, err
var files []os.DirEntry
var err error

// Si localesPath está vacío, usamos el sistema embebido
if localesPath == "" {
files, err = readEmbeddedLocales()
} else {
Expand All @@ -40,7 +40,6 @@ func NewTranslations(defaultLang string, localesPath string) (*Translations, err
bundle := i18n.NewBundle(language.English)
bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal)

// Cargar archivos de traducción
for _, file := range files {
var data []byte
if localesPath == "" {
Expand Down
23 changes: 23 additions & 0 deletions internal/i18n/locales/active.en.toml
Original file line number Diff line number Diff line change
Expand Up @@ -212,14 +212,37 @@ update_pr = "Error updating PR #{{.pr_number}}"
get_pr = "Error getting PR #{{.pr_number}}"
get_commits = "Error getting commits for PR #{{.pr_number}}"
get_diff = "Error getting diff for PR #{{.pr_number}}"
get_diff_from_commits = "Error obtaining the diff by commits of the PR #{{.pr_number}}"
get_commit_diff = "Error getting the commit diff"
get_repo_labels = "Error getting repository labels"
insufficient_permissions = "❌ Permissions error while updating PR #{{.pr_number}} in {{.owner}}/{{.repo}}"
token_scopes_help = """💡 Solution: Your GitHub token needs additional permissions

To update PRs in personal repositories, your token needs these scopes:
• 'repo' (full access) - for private repositories
• 'public_repo' - for public repositories

📝 How to update your token:
1. Go to: https://github.com/settings/tokens
2. Generate a new token (classic) or edit the existing one
3. Mark the scope 'repo' or 'public_repo'
4. Update your configuration with: matecommit config set-vcs --provider github --token <new-token>

Note: Organization tokens may have different permission requirements.
add_labels = "Error adding labels to PR #{{.pr_number}}"
invalid_repo_format = "Invalid repository format"
pr_summary_error = "Error generating PR summary"
no_repo_configured = "No repository configured. Use --repo or configure an active VCS provider"
vcs_provider_not_configured = "VCS provider '{{.Provider}}' is not configured"
vcs_provider_auto_detected_not_configured = "VCS provider '%s' auto-detected but not configured. Use 'matecommit config set-vcs --provider %s --token <token>' to configure it"

[warning]
pr_too_large = "The PR #{{.pr_number}} is too large (>20,000 lines). We're getting diffs commit by commit..."

[info]
fetching_commit_diffs = "Obtaining diffs from {{.total}} commits..."
processing_commit = "Processing commit {{.sha}}"

[label]
feature = "New features"
fix = "Bug fixes"
Expand Down
23 changes: 23 additions & 0 deletions internal/i18n/locales/active.es.toml
Original file line number Diff line number Diff line change
Expand Up @@ -220,14 +220,37 @@ update_pr = "Error al actualizar el PR #{{.pr_number}}"
get_pr = "Error al obtener el PR #{{.pr_number}}"
get_commits = "Error al obtener los commits del PR #{{.pr_number}}"
get_diff = "Error al obtener el diff del PR #{{.pr_number}}"
get_diff_from_commits = "Error al obtener el diff por commits del PR #{{.pr_number}}"
get_commit_diff = "Error al obtener el diff del commit"
get_repo_labels = "Error al obtener las etiquetas del repo"
insufficient_permissions = "❌ Error de permisos al actualizar el PR #{{.pr_number}} en {{.owner}}/{{.repo}}"
token_scopes_help = """💡 Solución: Tu token de GitHub necesita permisos adicionales

Para actualizar PRs en repositorios personales, tu token necesita estos scopes:
• 'repo' (acceso completo) - para repositorios privados
• 'public_repo' - para repositorios públicos

📝 Cómo actualizar tu token:
1. Ve a: https://github.com/settings/tokens
2. Genera un nuevo token (classic) o edita el existente
3. Marca el scope 'repo' o 'public_repo'
4. Actualiza tu configuración con: matecommit config set-vcs --provider github --token <nuevo-token>

Nota: Los tokens de organizaciones pueden tener diferentes requisitos de permisos."""
add_labels = "Error al añadir las etiquetas al repo #{{.pr_number}}"
invalid_repo_format = "Formato de repositorio inválido"
pr_summary_error = "Error generando el resumen del PR"
no_repo_configured = "No se ha configurado ningún repositorio. Usa --repo o configura un proveedor VCS activo"
vcs_provider_not_configured = "El proveedor VCS '{{.Provider}}' no está configurado"
vcs_provider_auto_detected_not_configured = "Proveedor de VCS '%s' detectado automáticamente pero no configurado. Use 'matecommit config set-vcs --provider %s --token <token>' para configurarlo"

[warning]
pr_too_large = "El PR #{{.pr_number}} es demasiado grande (>20,000 líneas). Obteniendo diff commit por commit..."

[info]
fetching_commit_diffs = "Obteniendo diffs de {{.total}} commits..."
processing_commit = "Procesando commit {{.sha}}"

[label]
feature = "Nuevas funcionalidades"
fix = "Correcciones de errores"
Expand Down
18 changes: 17 additions & 1 deletion internal/infrastructure/git/git_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,13 @@ func (s *GitService) CreateCommit(message string) error {
}

func (s *GitService) AddFileToStaging(file string) error {
cmd := exec.Command("git", "add", "--all", "--", file)
repoRoot, err := s.getRepoRoot()
if err != nil {
return fmt.Errorf("error al obtener la raíz del repositorio: %v", err)
}

cmd := exec.Command("git", "add", "--", file)
cmd.Dir = repoRoot
var stderr strings.Builder
cmd.Stderr = &stderr

Expand All @@ -111,6 +117,16 @@ func (s *GitService) AddFileToStaging(file string) error {
return nil
}

// getRepoRoot obtiene la ruta absoluta de la raíz del repositorio git
func (s *GitService) getRepoRoot() (string, error) {
cmd := exec.Command("git", "rev-parse", "--show-toplevel")
output, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("error al obtener la raíz del repositorio: %v", err)
}
return strings.TrimSpace(string(output)), nil
}

func (s *GitService) GetCurrentBranch() (string, error) {
cmd := exec.Command("git", "branch", "--show-current")
output, err := cmd.Output()
Expand Down
76 changes: 73 additions & 3 deletions internal/infrastructure/vcs/github/github_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,14 @@ type IssuesService interface {
AddLabelsToIssue(ctx context.Context, owner, repo string, number int, labels []string) ([]*github.Label, *github.Response, error)
}

type RepositoriesService interface {
GetCommit(ctx context.Context, owner, repo, sha string) (*github.RepositoryCommit, *github.Response, error)
}

type GitHubClient struct {
prService PullRequestsService
issuesService IssuesService
repoService RepositoriesService
owner string
repo string
trans *i18n.Translations
Expand Down Expand Up @@ -56,6 +61,7 @@ func NewGitHubClient(owner, repo, token string, trans *i18n.Translations) *GitHu
return &GitHubClient{
prService: client.PullRequests,
issuesService: client.Issues,
repoService: client.Repositories,
owner: owner,
repo: repo,
trans: trans,
Expand All @@ -65,13 +71,15 @@ func NewGitHubClient(owner, repo, token string, trans *i18n.Translations) *GitHu
func NewGitHubClientWithServices(
prService PullRequestsService,
issuesService IssuesService,
repoService RepositoriesService,
owner string,
repo string,
trans *i18n.Translations,
) *GitHubClient {
return &GitHubClient{
prService: prService,
issuesService: issuesService,
repoService: repoService,
owner: owner,
repo: repo,
trans: trans,
Expand All @@ -84,8 +92,18 @@ func (ghc *GitHubClient) UpdatePR(ctx context.Context, prNumber int, summary mod
Body: github.String(summary.Body),
}

_, _, err := ghc.prService.Edit(ctx, ghc.owner, ghc.repo, prNumber, pr)
_, resp, err := ghc.prService.Edit(ctx, ghc.owner, ghc.repo, prNumber, pr)
if err != nil {
// Detectar error 403 de permisos insuficientes
if resp != nil && resp.Response != nil && resp.Response.StatusCode == http.StatusForbidden {
return fmt.Errorf("%s\n\n%s",
ghc.trans.GetMessage("error.insufficient_permissions", 0, map[string]interface{}{
"pr_number": prNumber,
"owner": ghc.owner,
"repo": ghc.repo,
}),
ghc.trans.GetMessage("error.token_scopes_help", 0, nil))
}
return fmt.Errorf("%s: %w", ghc.trans.GetMessage("error.update_pr", 0, map[string]interface{}{
"pr_number": prNumber,
}), err)
Expand Down Expand Up @@ -120,10 +138,24 @@ func (ghc *GitHubClient) GetPR(ctx context.Context, prNumber int) (models.PRData
}
}

diff, _, err := ghc.prService.GetRaw(ctx, ghc.owner, ghc.repo, prNumber, github.RawOptions{Type: github.Diff})
diff, resp, err := ghc.prService.GetRaw(ctx, ghc.owner, ghc.repo, prNumber, github.RawOptions{Type: github.Diff})
if err != nil {
return models.PRData{}, fmt.Errorf("%s: %w", ghc.trans.GetMessage("error.get_diff", 0, map[string]interface{}{"pr_number": prNumber}), err)
// Si es error 406 (diff demasiado grande), usar fallback commit por commit
if resp != nil && resp.Response != nil && resp.Response.StatusCode == http.StatusNotAcceptable {
fmt.Printf("%s\n", ghc.trans.GetMessage("warning.pr_too_large", 0, map[string]interface{}{
"pr_number": prNumber,
}))
diff, err = ghc.getDiffFromCommits(ctx, commits)
if err != nil {
return models.PRData{}, fmt.Errorf("%s: %w", ghc.trans.GetMessage("error.get_diff_from_commits", 0, map[string]interface{}{
"pr_number": prNumber,
}), err)
}
} else {
return models.PRData{}, fmt.Errorf("%s: %w", ghc.trans.GetMessage("error.get_diff", 0, map[string]interface{}{"pr_number": prNumber}), err)
}
}

return models.PRData{
ID: prNumber,
Creator: pr.GetUser().GetLogin(),
Expand All @@ -132,6 +164,44 @@ func (ghc *GitHubClient) GetPR(ctx context.Context, prNumber int) (models.PRData
}, nil
}

// getDiffFromCommits obtiene el diff combinado de todos los commits cuando el diff completo del PR es demasiado grande
func (ghc *GitHubClient) getDiffFromCommits(ctx context.Context, commits []*github.RepositoryCommit) (string, error) {
var combinedDiff strings.Builder

fmt.Printf("%s\n", ghc.trans.GetMessage("info.fetching_commit_diffs", 0, map[string]interface{}{
"total": len(commits),
}))

for i, commit := range commits {
sha := commit.GetSHA()
fmt.Printf("%s (%d/%d)\n", ghc.trans.GetMessage("info.processing_commit", 0, map[string]interface{}{
"current": i + 1,
"total": len(commits),
"sha": sha[:8],
}), i+1, len(commits))

fullCommit, _, err := ghc.repoService.GetCommit(ctx, ghc.owner, ghc.repo, sha)
if err != nil {
return "", fmt.Errorf("%s %s: %w", ghc.trans.GetMessage("error.get_commit_diff", 0, nil), sha[:8], err)
}

if fullCommit.GetStats().GetTotal() > 0 {
combinedDiff.WriteString(fmt.Sprintf("\n# Commit: %s\n", sha[:8]))
combinedDiff.WriteString(fmt.Sprintf("# Message: %s\n\n", strings.Split(commit.GetCommit().GetMessage(), "\n")[0]))

for _, file := range fullCommit.Files {
if file.Patch != nil {
combinedDiff.WriteString(fmt.Sprintf("diff --git a/%s b/%s\n", file.GetFilename(), file.GetFilename()))
combinedDiff.WriteString(*file.Patch)
combinedDiff.WriteString("\n")
}
}
}
}

return combinedDiff.String(), nil
}

func (ghc *GitHubClient) validateAndFilterLabels(labels []string) []string {
var validLabels []string
for _, label := range labels {
Expand Down
37 changes: 35 additions & 2 deletions internal/infrastructure/vcs/github/github_service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package github

import (
"context"
"net/http"
"testing"

"github.com/Tomas-vilte/MateCommit/internal/domain/models"
Expand All @@ -14,9 +15,11 @@ import (

func newTestClient(pr *MockPRService, issues *MockIssuesService) *GitHubClient {
trans, _ := i18n.NewTranslations("es", "../../../i18n/locales/")
repo := &MockRepoService{}
return NewGitHubClientWithServices(
pr,
issues,
repo,
"test-owner",
"test-repo",
trans,
Expand Down Expand Up @@ -228,6 +231,36 @@ func TestGitHubClient_UpdatePR_ErrorCases(t *testing.T) {
assert.ErrorContains(t, err, client.trans.GetMessage("error.add_labels", 0, map[string]interface{}{"pr_number": prNumber}))
mockIssues.AssertExpectations(t)
})

t.Run("should return helpful error message for 403 insufficient permissions", func(t *testing.T) {
mockPR := &MockPRService{}
mockIssues := &MockIssuesService{}
client := newTestClient(mockPR, mockIssues)

prNumber := 123
summary := models.PRSummary{Title: "Title", Body: "Body"}

// Simular un error 403
resp403 := &github.Response{
Response: &http.Response{
StatusCode: http.StatusForbidden,
},
}

mockPR.On("Edit", mock.Anything, "test-owner", "test-repo", prNumber, mock.Anything).
Return(&github.PullRequest{}, resp403, assert.AnError)

err := client.UpdatePR(context.Background(), prNumber, summary)

assert.Error(t, err)
assert.ErrorContains(t, err, client.trans.GetMessage("error.insufficient_permissions", 0, map[string]interface{}{
"pr_number": prNumber,
"owner": "test-owner",
"repo": "test-repo",
}))
assert.ErrorContains(t, err, client.trans.GetMessage("error.token_scopes_help", 0, nil))
mockPR.AssertExpectations(t)
})
}

func TestGitHubClient_AddLabelsToPR_ErrorCases(t *testing.T) {
Expand Down Expand Up @@ -310,11 +343,11 @@ func TestGitHubClient_GetPR_ErrorCases(t *testing.T) {
client := newTestClient(mockPR, mockIssues)

mockPR.On("Get", mock.Anything, "test-owner", "test-repo", 123).
Return(&github.PullRequest{}, &github.Response{}, nil)
Return(&github.PullRequest{User: &github.User{Login: github.String("test-user")}}, &github.Response{}, nil)
mockPR.On("ListCommits", mock.Anything, "test-owner", "test-repo", 123, mock.Anything).
Return([]*github.RepositoryCommit{}, &github.Response{}, nil)
mockPR.On("GetRaw", mock.Anything, "test-owner", "test-repo", 123, mock.Anything).
Return("", &github.Response{}, assert.AnError)
Return("", nil, assert.AnError)

_, err := client.GetPR(context.Background(), 123)
assert.ErrorContains(t, err, client.trans.GetMessage("error.get_diff", 0, map[string]interface{}{"pr_number": 123}))
Expand Down
12 changes: 12 additions & 0 deletions internal/infrastructure/vcs/github/mocks.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ func (m *MockPRService) ListCommits(ctx context.Context, owner, repo string, num

func (m *MockPRService) GetRaw(ctx context.Context, owner, repo string, number int, opts github.RawOptions) (string, *github.Response, error) {
args := m.Called(ctx, owner, repo, number, opts)
if args.Get(1) == nil {
return args.String(0), nil, args.Error(2)
}
return args.String(0), args.Get(1).(*github.Response), args.Error(2)
}

Expand All @@ -49,3 +52,12 @@ func (m *MockIssuesService) AddLabelsToIssue(ctx context.Context, owner, repo st
args := m.Called(ctx, owner, repo, number, labels)
return args.Get(0).([]*github.Label), args.Get(1).(*github.Response), args.Error(2)
}

type MockRepoService struct {
mock.Mock
}

func (m *MockRepoService) GetCommit(ctx context.Context, owner, repo, sha string) (*github.RepositoryCommit, *github.Response, error) {
args := m.Called(ctx, owner, repo, sha)
return args.Get(0).(*github.RepositoryCommit), args.Get(1).(*github.Response), args.Error(2)
}
Loading