From 0336faec568ce6ff636333aa8ccbf0fcbf34f4bc Mon Sep 17 00:00:00 2001 From: Thomas Vilte Date: Thu, 30 Jul 2026 17:20:04 -0300 Subject: [PATCH] refactor(github): split monolithic client.go into domain-specific files --- internal/vcs/github/client.go | 948 -------------------------- internal/vcs/github/client_issues.go | 253 +++++++ internal/vcs/github/client_labels.go | 141 ++++ internal/vcs/github/client_pr.go | 242 +++++++ internal/vcs/github/client_release.go | 363 ++++++++++ 5 files changed, 999 insertions(+), 948 deletions(-) create mode 100644 internal/vcs/github/client_issues.go create mode 100644 internal/vcs/github/client_labels.go create mode 100644 internal/vcs/github/client_pr.go create mode 100644 internal/vcs/github/client_release.go diff --git a/internal/vcs/github/client.go b/internal/vcs/github/client.go index 54bf1b4..8fb94f1 100644 --- a/internal/vcs/github/client.go +++ b/internal/vcs/github/client.go @@ -5,19 +5,12 @@ import ( "fmt" "net/http" "os" - "path/filepath" - "regexp" - "sort" - "strconv" "strings" - "time" "github.com/google/go-github/v80/github" "github.com/thomas-vilte/matecommit/internal/builder" domainErrors "github.com/thomas-vilte/matecommit/internal/errors" - "github.com/thomas-vilte/matecommit/internal/logger" "github.com/thomas-vilte/matecommit/internal/models" - "github.com/thomas-vilte/matecommit/internal/regex" "github.com/thomas-vilte/matecommit/internal/vcs" "golang.org/x/oauth2" ) @@ -92,27 +85,6 @@ type GitHubClient struct { binaryBuilderFactory binaryBuilderFactory } -var allowedLabels = map[string]struct { - Color string - Key string -}{ - "feature": {"00FF00", "label.feature"}, - "fix": {"FF0000", "label.fix"}, - "refactor": {"FFA500", "label.refactor"}, - "docs": {"0075CA", "label.docs"}, - "infra": {"808080", "label.infra"}, - "test": {"8A2BE2", "label.test"}, -} - -var labelDescriptions = map[string]string{ - "feature": "New feature", - "fix": "Bug fix", - "refactor": "Code refactor", - "docs": "Documentation", - "infra": "Infrastructure", - "test": "Test", -} - func NewGitHubClient(owner, repo, token string) *GitHubClient { token = strings.TrimSpace(token) var httpClient *http.Client @@ -167,639 +139,6 @@ func (ghc *GitHubClient) SetMainPath(path string) { } } -func (ghc *GitHubClient) UpdatePR(ctx context.Context, prNumber int, summary models.PRSummary) error { - pr := &github.PullRequest{ - Title: github.Ptr(summary.Title), - Body: github.Ptr(summary.Body), - } - - _, resp, err := ghc.prService.Edit(ctx, ghc.owner, ghc.repo, prNumber, pr) - if err != nil { - if resp != nil { - if resp.StatusCode == http.StatusTooManyRequests { - return domainErrors.ErrGitHubRateLimit. - WithContext("retry_after", resp.Header.Get("Retry-After")). - WithContext("operation", "update PR") - } - if resp.StatusCode == http.StatusForbidden { - return domainErrors.ErrGitHubInsufficientPerms. - WithContext("operation", "update PR"). - WithContext("pr_number", prNumber). - WithContext("repo", fmt.Sprintf("%s/%s", ghc.owner, ghc.repo)) - } - if resp.StatusCode == http.StatusNotFound { - return domainErrors.ErrRepositoryNotFound. - WithContext("operation", "update PR"). - WithContext("repo", fmt.Sprintf("%s/%s", ghc.owner, ghc.repo)) - } - } - return fmt.Errorf("failed to update PR #%d: %w", prNumber, err) - } - - if len(summary.Labels) > 0 { - if err := ghc.AddLabelsToPR(ctx, prNumber, summary.Labels); err != nil { - return fmt.Errorf("failed to add labels to PR #%d: %w", prNumber, err) - } - } - - return nil -} - -func (ghc *GitHubClient) GetPR(ctx context.Context, prNumber int) (models.PRData, error) { - log := logger.FromContext(ctx) - - log.Debug("fetching github pull request", - "owner", ghc.owner, - "repo", ghc.repo, - "pr_number", prNumber) - - pr, resp, err := ghc.prService.Get(ctx, ghc.owner, ghc.repo, prNumber) - if err != nil { - if resp != nil { - if resp.StatusCode == http.StatusUnauthorized { - return models.PRData{}, domainErrors.ErrGitHubTokenInvalid. - WithContext("operation", "get PR"). - WithContext("pr_number", prNumber) - } - if resp.StatusCode == http.StatusNotFound { - return models.PRData{}, domainErrors.ErrRepositoryNotFound. - WithContext("operation", "get PR"). - WithContext("pr_number", prNumber). - WithContext("repo", fmt.Sprintf("%s/%s", ghc.owner, ghc.repo)) - } - } - log.Error("failed to fetch github PR", - "error", err, - "owner", ghc.owner, - "repo", ghc.repo, - "pr_number", prNumber) - return models.PRData{}, fmt.Errorf("failed to get PR #%d: %w", prNumber, err) - } - - commits, _, err := ghc.prService.ListCommits(ctx, ghc.owner, ghc.repo, prNumber, &github.ListOptions{}) - if err != nil { - return models.PRData{}, fmt.Errorf("failed to get commits for PR #%d: %w", prNumber, err) - } - - prCommits := make([]models.Commit, len(commits)) - for i, commit := range commits { - prCommits[i] = models.Commit{ - Message: commit.GetCommit().GetMessage(), - } - } - - prLabels := make([]string, len(pr.Labels)) - for i, label := range pr.Labels { - prLabels[i] = label.GetName() - } - - diff, resp, err := ghc.prService.GetRaw(ctx, ghc.owner, ghc.repo, prNumber, github.RawOptions{Type: github.Diff}) - if err != nil { - // If 406 error (diff too large), use fallback commit by commit - if resp != nil && resp.StatusCode == http.StatusNotAcceptable { - log.Warn("PR diff too large, fetching diffs commit by commit", - "pr_number", prNumber, - "commits_count", len(commits)) - diff, err = ghc.getDiffFromCommits(ctx, commits) - if err != nil { - return models.PRData{}, fmt.Errorf("failed to get diff from commits for PR #%d: %w", prNumber, err) - } - } else { - return models.PRData{}, fmt.Errorf("failed to get diff for PR #%d: %w", prNumber, err) - } - } - - prData := models.PRData{ - ID: prNumber, - Title: pr.GetTitle(), - Creator: pr.GetUser().GetLogin(), - Commits: prCommits, - Diff: diff, - BranchName: pr.GetHead().GetRef(), - Description: pr.GetBody(), - Labels: prLabels, - } - - log.Debug("github PR fetched successfully", - "pr_number", prNumber, - "title", prData.Title, - "commits_count", len(prCommits), - "diff_size", len(diff)) - - return prData, nil - -} - -func (ghc *GitHubClient) AddLabelsToPR(ctx context.Context, prNumber int, labels []string) error { - validLabels := ghc.validateAndFilterLabels(labels) - if len(validLabels) == 0 { - return nil - } - - existingLabels, err := ghc.GetRepoLabels(ctx) - if err != nil { - return fmt.Errorf("failed to get repository labels: %w", err) - } - - if err := ghc.ensureLabelsExist(ctx, existingLabels, validLabels); err != nil { - return err - } - - return ghc.addLabelsToIssue(ctx, prNumber, validLabels) -} - -func (ghc *GitHubClient) GetRepoLabels(ctx context.Context) ([]string, error) { - labels, err := ghc.GetRepoLabelsWithDescriptions(ctx) - if err != nil { - return nil, err - } - - labelNames := make([]string, len(labels)) - for i, label := range labels { - labelNames[i] = label.Name - } - return labelNames, nil -} - -func (ghc *GitHubClient) GetRepoLabelsWithDescriptions(ctx context.Context) ([]models.RepoLabel, error) { - labels, _, err := ghc.issuesService.ListLabels(ctx, ghc.owner, ghc.repo, &github.ListOptions{PerPage: 100}) - if err != nil { - return nil, fmt.Errorf("failed to list repository labels: %w", err) - } - - result := make([]models.RepoLabel, len(labels)) - for i, label := range labels { - result[i] = models.RepoLabel{Name: label.GetName(), Description: label.GetDescription()} - } - return result, nil -} - -func (ghc *GitHubClient) CreateLabel(ctx context.Context, name, color, description string) error { - _, _, err := ghc.issuesService.CreateLabel(ctx, ghc.owner, ghc.repo, &github.Label{ - Name: github.Ptr(name), - Color: github.Ptr(color), - Description: github.Ptr(description), - }) - return err -} - -func (ghc *GitHubClient) CreateRelease(ctx context.Context, release *models.Release, notes *models.ReleaseNotes, draft bool, buildBinaries bool, progressCh chan<- models.BuildProgress) error { - body := notes.Changelog - if body == "" { - body = fmt.Sprintf("%s\n\n", notes.Summary) - if len(notes.Highlights) > 0 { - body += "## Highlights\n\n" - for _, h := range notes.Highlights { - body += fmt.Sprintf("- %s\n", h) - } - } - } - - releaseRequest := &github.RepositoryRelease{ - TagName: github.Ptr(release.Version), - Name: github.Ptr(notes.Title), - Body: github.Ptr(body), - Draft: github.Ptr(draft), - Prerelease: github.Ptr(false), - MakeLatest: github.Ptr("true"), - } - - createdRelease, resp, err := ghc.releaseService.CreateRelease(ctx, ghc.owner, ghc.repo, releaseRequest) - if err != nil { - if resp != nil { - if resp.StatusCode == http.StatusUnauthorized { - return domainErrors.ErrGitHubTokenInvalid. - WithContext("operation", "create release"). - WithContext("version", release.Version) - } - if resp.StatusCode == http.StatusUnprocessableEntity { - return domainErrors.ErrCreateRelease. - WithContext("version", release.Version). - WithContext("reason", "release already exists") - } - if resp.StatusCode == http.StatusNotFound { - return domainErrors.ErrRepositoryNotFound. - WithContext("operation", "create release"). - WithContext("version", release.Version). - WithContext("repo", fmt.Sprintf("%s/%s", ghc.owner, ghc.repo)) - } - if resp.StatusCode == http.StatusForbidden { - return domainErrors.ErrGitHubInsufficientPerms. - WithContext("operation", "create release"). - WithContext("version", release.Version) - } - } - return domainErrors.ErrCreateRelease.WithError(err).WithContext("version", release.Version) - } - - if buildBinaries { - if err := ghc.uploadBinaries(ctx, createdRelease.GetID(), release.Version, progressCh); err != nil { - return fmt.Errorf("failed to upload binaries: %w", err) - } - } - - return nil -} - -func (ghc *GitHubClient) GetRelease(ctx context.Context, version string) (*models.VCSRelease, error) { - release, resp, err := ghc.releaseService.GetReleaseByTag(ctx, ghc.owner, ghc.repo, version) - if err != nil { - if resp != nil { - if resp.StatusCode == http.StatusUnauthorized { - return nil, domainErrors.ErrGitHubTokenInvalid. - WithContext("operation", "get release"). - WithContext("version", version) - } - if resp.StatusCode == http.StatusNotFound { - return nil, domainErrors.ErrRepositoryNotFound. - WithContext("operation", "get release"). - WithContext("version", version). - WithContext("repo", fmt.Sprintf("%s/%s", ghc.owner, ghc.repo)) - } - return nil, domainErrors.ErrGetRelease. - WithContext("version", version). - WithContext("status_code", resp.StatusCode) - } - return nil, domainErrors.ErrGetRelease.WithError(err).WithContext("version", version) - } - - return &models.VCSRelease{ - TagName: release.GetTagName(), - Name: release.GetName(), - Body: release.GetBody(), - Draft: release.GetDraft(), - URL: release.GetHTMLURL(), - }, nil -} - -func (ghc *GitHubClient) UpdateRelease(ctx context.Context, version, body string) error { - release, resp, err := ghc.releaseService.GetReleaseByTag(ctx, ghc.owner, ghc.repo, version) - if err != nil { - if resp != nil { - if resp.StatusCode == http.StatusUnauthorized { - return domainErrors.ErrGitHubTokenInvalid. - WithContext("operation", "update release"). - WithContext("version", version) - } - if resp.StatusCode == http.StatusNotFound { - return domainErrors.ErrRepositoryNotFound. - WithContext("operation", "update release"). - WithContext("version", version). - WithContext("repo", fmt.Sprintf("%s/%s", ghc.owner, ghc.repo)) - } - return domainErrors.ErrUpdateRelease. - WithContext("version", version). - WithContext("status_code", resp.StatusCode) - } - return domainErrors.ErrUpdateRelease.WithError(err).WithContext("version", version) - } - - releaseUpdate := &github.RepositoryRelease{ - Body: github.Ptr(body), - } - - _, _, err = ghc.releaseService.EditRelease(ctx, ghc.owner, ghc.repo, release.GetID(), releaseUpdate) - if err != nil { - return domainErrors.ErrUpdateRelease.WithError(err).WithContext("version", version) - } - return nil -} - -func (ghc *GitHubClient) GetClosedIssuesBetweenTags(ctx context.Context, previousTag, _ string) ([]models.Issue, error) { - prevRelease, _, err := ghc.releaseService.GetReleaseByTag(ctx, ghc.owner, ghc.repo, previousTag) - if err != nil { - return nil, err - } - - opts := &github.IssueListByRepoOptions{ - State: "closed", - Since: prevRelease.GetCreatedAt().Time, - Sort: "updated", - Direction: "desc", - ListOptions: github.ListOptions{ - PerPage: 100, - }, - } - - var allIssues []models.Issue - for { - issues, resp, err := ghc.issuesService.ListByRepo(ctx, ghc.owner, ghc.repo, opts) - if err != nil { - return nil, err - } - - for _, issue := range issues { - if issue.PullRequestLinks == nil { - labels := make([]string, 0, len(issue.Labels)) - for _, label := range issue.Labels { - labels = append(labels, label.GetName()) - } - - allIssues = append(allIssues, models.Issue{ - Number: issue.GetNumber(), - Title: issue.GetTitle(), - Labels: labels, - Author: issue.GetUser().GetLogin(), - URL: issue.GetHTMLURL(), - }) - } - } - - if resp.NextPage == 0 { - break - } - opts.ListOptions.Page = resp.NextPage - } - - return allIssues, nil -} - -// ListOpenIssues fetches currently open issues, most recently updated -// first, capped to a single page so a duplicate check stays cheap and fast. -func (ghc *GitHubClient) ListOpenIssues(ctx context.Context) ([]models.Issue, error) { - opts := &github.IssueListByRepoOptions{ - State: "open", - Sort: "updated", - Direction: "desc", - ListOptions: github.ListOptions{ - PerPage: 100, - }, - } - - issues, _, err := ghc.issuesService.ListByRepo(ctx, ghc.owner, ghc.repo, opts) - if err != nil { - return nil, err - } - - result := make([]models.Issue, 0, len(issues)) - for _, issue := range issues { - if issue.PullRequestLinks != nil { - continue - } - labels := make([]string, 0, len(issue.Labels)) - for _, label := range issue.Labels { - labels = append(labels, label.GetName()) - } - result = append(result, models.Issue{ - Number: issue.GetNumber(), - Title: issue.GetTitle(), - Labels: labels, - Author: issue.GetUser().GetLogin(), - URL: issue.GetHTMLURL(), - }) - } - - return result, nil -} - -func (ghc *GitHubClient) GetMergedPRsBetweenTags(ctx context.Context, previousTag, _ string) ([]models.PullRequest, error) { - prevRelease, _, err := ghc.releaseService.GetReleaseByTag(ctx, ghc.owner, ghc.repo, previousTag) - if err != nil { - return nil, err - } - - opts := &github.PullRequestListOptions{ - State: "closed", - Sort: "updated", - Direction: "desc", - ListOptions: github.ListOptions{ - PerPage: 100, - }, - } - var allPRs []models.PullRequest - for { - prs, resp, err := ghc.prService.List(ctx, ghc.owner, ghc.repo, opts) - if err != nil { - return nil, err - } - - for _, pr := range prs { - // The list endpoint never populates the "merged" boolean field - // (only the single-PR endpoint does) — merged_at is the only - // reliable signal here, and a zero value means "not merged". - if !pr.GetMergedAt().IsZero() && pr.GetMergedAt().After(prevRelease.GetCreatedAt().Time) { - labels := make([]string, 0, len(pr.Labels)) - for _, label := range pr.Labels { - labels = append(labels, label.GetName()) - } - - allPRs = append(allPRs, models.PullRequest{ - Number: pr.GetNumber(), - Title: pr.GetTitle(), - Description: pr.GetBody(), - Author: pr.GetUser().GetLogin(), - Labels: labels, - URL: pr.GetHTMLURL(), - }) - } - } - - if resp.NextPage == 0 { - break - } - opts.Page = resp.NextPage - } - return allPRs, nil -} - -func (ghc *GitHubClient) GetContributorsBetweenTags(ctx context.Context, previousTag, currentTag string) ([]string, error) { - comparison, _, err := ghc.repoService.CompareCommits(ctx, ghc.owner, ghc.repo, previousTag, currentTag, &github.ListOptions{ - PerPage: 100, - }) - if err != nil { - return nil, err - } - - contributorsMap := make(map[string]struct{}) - for _, commit := range comparison.Commits { - if author := commit.GetAuthor(); author != nil { - contributorsMap[author.GetLogin()] = struct{}{} - } - } - - contributors := make([]string, 0, len(contributorsMap)) - for contributor := range contributorsMap { - contributors = append(contributors, contributor) - } - return contributors, nil -} - -func (ghc *GitHubClient) GetFileStatsBetweenTags(ctx context.Context, previousTag, currentTag string) (*models.FileStatistics, error) { - comparison, _, err := ghc.repoService.CompareCommits(ctx, ghc.owner, ghc.repo, previousTag, currentTag, &github.ListOptions{ - PerPage: 100, - }) - if err != nil { - return nil, err - } - - stats := &models.FileStatistics{ - FilesChanged: len(comparison.Files), - Insertions: 0, - Deletions: 0, - TopFiles: make([]models.FileChange, 0), - } - - fileChanges := make([]models.FileChange, 0, len(comparison.Files)) - for _, file := range comparison.Files { - stats.Insertions += file.GetAdditions() - stats.Deletions += file.GetDeletions() - - fileChanges = append(fileChanges, models.FileChange{ - Path: file.GetFilename(), - Additions: file.GetAdditions(), - Deletions: file.GetDeletions(), - }) - } - - sort.Slice(fileChanges, func(i, j int) bool { - totalI := fileChanges[i].Additions + fileChanges[i].Deletions - totalJ := fileChanges[j].Additions + fileChanges[j].Deletions - return totalI > totalJ - }) - - if len(fileChanges) > 5 { - stats.TopFiles = fileChanges[:5] - } else { - stats.TopFiles = fileChanges - } - return stats, nil -} - -func (ghc *GitHubClient) GetIssue(ctx context.Context, issueNumber int) (*models.Issue, error) { - log := logger.FromContext(ctx) - - log.Debug("fetching github issue", - "owner", ghc.owner, - "repo", ghc.repo, - "issue_number", issueNumber) - - issue, _, err := ghc.issuesService.Get(ctx, ghc.owner, ghc.repo, issueNumber) - if err != nil { - log.Error("failed to fetch github issue", - "error", err, - "owner", ghc.owner, - "repo", ghc.repo, - "issue_number", issueNumber) - return nil, fmt.Errorf("error getting issue #%d: %w", issueNumber, err) - } - - labels := make([]string, 0, len(issue.Labels)) - for _, label := range issue.Labels { - if label.Name != nil { - labels = append(labels, label.GetName()) - } - } - - var author string - if issue.User != nil && issue.User.Login != nil { - author = *issue.User.Login - } - - var description string - if issue.Body != nil { - description = *issue.Body - } - - var state string - if issue.State != nil { - state = *issue.State - } - - var url string - if issue.HTMLURL != nil { - url = *issue.HTMLURL - } - - criteria := extractAcceptanceCriteria(description) - - log.Debug("github issue fetched successfully", - "issue_number", issueNumber, - "title", issue.GetTitle(), - "state", state, - "labels_count", len(labels), - "criteria_count", len(criteria)) - - return &models.Issue{ - ID: int(issue.GetID()), - Number: issue.GetNumber(), - Title: issue.GetTitle(), - Description: description, - State: state, - Labels: labels, - Author: author, - URL: url, - Criteria: criteria, - }, nil -} - -func (ghc *GitHubClient) CreateIssue(ctx context.Context, title string, body string, labels []string, assignees []string) (*models.Issue, error) { - log := logger.FromContext(ctx) - - log.Info("creating github issue", - "owner", ghc.owner, - "repo", ghc.repo, - "title", title, - "labels_count", len(labels), - "assignees_count", len(assignees)) - - if labels == nil { - labels = []string{} - } - if assignees == nil { - assignees = []string{} - } - - issueRequest := &github.IssueRequest{ - Title: github.Ptr(title), - Body: github.Ptr(body), - Labels: &labels, - Assignees: &assignees, - } - - ghIssue, resp, err := ghc.issuesService.Create(ctx, ghc.owner, ghc.repo, issueRequest) - if err != nil { - if resp != nil { - if resp.StatusCode == http.StatusUnauthorized { - return nil, domainErrors.ErrGitHubTokenInvalid. - WithContext("operation", "create issue") - } - if resp.StatusCode == http.StatusNotFound { - return nil, domainErrors.ErrRepositoryNotFound. - WithContext("operation", "create issue"). - WithContext("repo", fmt.Sprintf("%s/%s", ghc.owner, ghc.repo)) - } - } - log.Error("failed to create github issue", - "error", err, - "owner", ghc.owner, - "repo", ghc.repo) - return nil, fmt.Errorf("error creating issue: %w", err) - } - - issue := &models.Issue{ - ID: int(*ghIssue.ID), - Number: *ghIssue.Number, - Title: *ghIssue.Title, - Description: getStringValue(ghIssue.Body), - State: *ghIssue.State, - Author: *ghIssue.User.Login, - URL: *ghIssue.HTMLURL, - Labels: make([]string, 0), - } - - for _, label := range ghIssue.Labels { - if label.Name != nil { - issue.Labels = append(issue.Labels, label.GetName()) - } - } - - log.Info("github issue created successfully", - "issue_number", issue.Number, - "issue_url", issue.URL) - - return issue, nil -} - func (ghc *GitHubClient) GetAuthenticatedUser(ctx context.Context) (string, error) { user, resp, err := ghc.usersService.Get(ctx, "") if err != nil { @@ -817,23 +156,6 @@ func (ghc *GitHubClient) GetAuthenticatedUser(ctx context.Context) (string, erro return *user.Login, nil } -func extractAcceptanceCriteria(body string) []string { - var criteria []string - lines := strings.Split(body, "\n") - - for _, line := range lines { - matches := regex.MarkdownCheckbox.FindStringSubmatch(line) - if len(matches) > 2 { - criterion := strings.TrimSpace(matches[2]) - if criterion != "" { - criteria = append(criteria, criterion) - } - } - } - - return criteria -} - func (ghc *GitHubClient) GetFileAtTag(ctx context.Context, tag, filepath string) (string, error) { opts := &github.RepositoryContentGetOptions{ Ref: tag, @@ -856,276 +178,6 @@ func (ghc *GitHubClient) GetFileAtTag(ctx context.Context, tag, filepath string) return content, nil } -// addIssueNumberMatches finds every match of re in text and adds the -// captured issue number (submatch group 1) to dest. -func addIssueNumberMatches(re *regexp.Regexp, text string, dest map[int]bool) { - for _, match := range re.FindAllStringSubmatch(text, -1) { - if len(match) > 1 { - if num, err := strconv.Atoi(match[1]); err == nil { - dest[num] = true - } - } - } -} - -func (ghc *GitHubClient) GetPRIssues(ctx context.Context, branchName string, commits []string, prDescription string) ([]models.Issue, error) { - issueNumbers := make(map[int]bool) - for _, re := range []*regexp.Regexp{ - regex.BranchIssueSharp, - regex.BranchIssueName, - regex.BranchIssueStart, - regex.BranchIssueFolder, - regex.BranchIssueMid, - } { - addIssueNumberMatches(re, branchName, issueNumbers) - } - - if prDescription != "" { - addIssueNumberMatches(regex.GitHubClosedLink, prDescription, issueNumbers) - addIssueNumberMatches(regex.BranchIssueSharp, prDescription, issueNumbers) - } - - for _, commit := range commits { - addIssueNumberMatches(regex.GitHubClosedLink, commit, issueNumbers) - addIssueNumberMatches(regex.GitHubPR, commit, issueNumbers) - addIssueNumberMatches(regex.BranchIssueSharp, commit, issueNumbers) - } - - var issues []models.Issue - for issueNum := range issueNumbers { - issue, err := ghc.GetIssue(ctx, issueNum) - if err != nil { - continue - } - issues = append(issues, *issue) - } - - return issues, nil -} - -func (ghc *GitHubClient) labelExists(existingLabels []string, target string) bool { - for _, l := range existingLabels { - if strings.EqualFold(l, target) { - return true - } - } - return false -} - -func (ghc *GitHubClient) addLabelsToIssue(ctx context.Context, prNumber int, labels []string) error { - _, _, err := ghc.issuesService.AddLabelsToIssue(ctx, ghc.owner, ghc.repo, prNumber, labels) - if err != nil { - return fmt.Errorf("failed to add labels to PR #%d: %w", prNumber, err) - } - return nil -} - -func (ghc *GitHubClient) ensureLabelsExist(ctx context.Context, existingLabels []string, requiredLabels []string) error { - log := logger.FromContext(ctx) - - for _, label := range requiredLabels { - if !ghc.labelExists(existingLabels, label) { - meta := allowedLabels[label] - - description := labelDescriptions[label] - if err := ghc.CreateLabel(ctx, label, meta.Color, description); err != nil { - if !strings.Contains(err.Error(), "already_exists") && !strings.Contains(err.Error(), "422") { - return fmt.Errorf("failed to create label '%s': %w", label, err) - } - log.Debug("label already exists, skipping creation", - "label", label, - "owner", ghc.owner, - "repo", ghc.repo) - } - } - } - return nil -} - -// getDiffFromCommits gets the combined diff of all commits when the total PR diff is too large -func (ghc *GitHubClient) getDiffFromCommits(ctx context.Context, commits []*github.RepositoryCommit) (string, error) { - log := logger.FromContext(ctx) - var combinedDiff strings.Builder - - log.Info("fetching diffs from commits", - "commits_count", len(commits), - "owner", ghc.owner, - "repo", ghc.repo) - - for i, commit := range commits { - sha := commit.GetSHA() - log.Debug("processing commit", - "current", i+1, - "total", len(commits), - "sha", sha[:8]) - fullCommit, _, err := ghc.repoService.GetCommit(ctx, ghc.owner, ghc.repo, sha, nil) - if err != nil { - return "", fmt.Errorf("failed to get diff for commit %s: %w", 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 -} - -var labelAliases = map[string]string{ - "bug": "fix", - "enhancement": "feature", - "documentation": "docs", - "infrastructure": "infra", - "testing": "test", -} - -func (ghc *GitHubClient) validateAndFilterLabels(labels []string) []string { - var validLabels []string - seen := make(map[string]bool) - - for _, label := range labels { - cleaned := strings.ToLower(strings.TrimSpace(label)) - if cleaned == "" { - continue - } - - if mapped, ok := labelAliases[cleaned]; ok { - cleaned = mapped - } - - if ghc.isAllowedLabel(cleaned) && !seen[cleaned] { - validLabels = append(validLabels, cleaned) - seen[cleaned] = true - } - } - return validLabels -} - -func (ghc *GitHubClient) isAllowedLabel(label string) bool { - _, exists := allowedLabels[label] - return exists -} - -func (ghc *GitHubClient) uploadBinaries(ctx context.Context, releaseID int64, version string, progressCh chan<- models.BuildProgress) error { - tempDir, err := os.MkdirTemp("", "matecommit-build-*") - if err != nil { - return fmt.Errorf("failed to create temporary directory for build: %w", err) - } - defer func() { - if err := os.RemoveAll(tempDir); err != nil { - return - } - }() - - commit, err := ghc.getCommitSHA(ctx) - if err != nil { - commit = "unknown" - } - date := time.Now().Format(time.RFC3339) - - log := logger.FromContext(ctx) - - log.Debug("creating binary builder", - "repo", ghc.repo, - "main_path", ghc.mainPath, - "version", version) - - builderBinary := ghc.binaryBuilderFactory.NewBuilder( - ghc.mainPath, - ghc.repo, - builder.WithVersion(version), - builder.WithCommit(commit), - builder.WithDate(date), - builder.WithBuildDir(tempDir), - ) - - log.Info("compiling binaries for release", - "version", version, - "build_dir", tempDir) - - archives, err := builderBinary.BuildAndPackageAll(ctx, progressCh) - if err != nil { - return fmt.Errorf("failed to build binaries: %w", err) - } - - log.Info("uploading binaries to release", - "archives_count", len(archives), - "release_id", releaseID, - "version", version) - - if progressCh != nil { - progressCh <- models.BuildProgress{ - Type: models.UploadProgressStart, - Total: len(archives), - } - } - - for i, archivePath := range archives { - archiveName := filepath.Base(archivePath) - - if progressCh != nil { - progressCh <- models.BuildProgress{ - Type: models.UploadProgressAsset, - Asset: archiveName, - Current: i + 1, - Total: len(archives), - } - } - - log.Info("uploading asset", - "asset", archiveName, - "progress", fmt.Sprintf("%d/%d", i+1, len(archives))) - - file, err := os.Open(archivePath) - if err != nil { - return fmt.Errorf("failed to open archive %s: %w", archivePath, err) - } - - uploadOpts := &github.UploadOptions{ - Name: archiveName, - Label: archiveName, - } - - _, _, err = ghc.releaseService.UploadReleaseAsset(ctx, ghc.owner, ghc.repo, releaseID, uploadOpts, file) - _ = file.Close() - if err != nil { - return domainErrors.ErrUploadAsset.WithError(err). - WithContext("asset_path", archivePath). - WithContext("release_id", releaseID) - } - - log.Info("asset uploaded successfully", - "asset", archiveName, - "progress", fmt.Sprintf("%d/%d", i+1, len(archives))) - } - - if progressCh != nil { - progressCh <- models.BuildProgress{ - Type: models.UploadProgressComplete, - Total: len(archives), - } - } - - return nil -} - -func (ghc *GitHubClient) getCommitSHA(ctx context.Context) (string, error) { - ref, _, err := ghc.repoService.GetCommit(ctx, ghc.owner, ghc.repo, "HEAD", nil) - if err != nil { - return "", err - } - return ref.GetSHA(), nil -} - func getStringValue(s *string) string { if s == nil { return "" diff --git a/internal/vcs/github/client_issues.go b/internal/vcs/github/client_issues.go new file mode 100644 index 0000000..8288e4a --- /dev/null +++ b/internal/vcs/github/client_issues.go @@ -0,0 +1,253 @@ +package github + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/google/go-github/v80/github" + domainErrors "github.com/thomas-vilte/matecommit/internal/errors" + "github.com/thomas-vilte/matecommit/internal/logger" + "github.com/thomas-vilte/matecommit/internal/models" + "github.com/thomas-vilte/matecommit/internal/regex" +) + +func (ghc *GitHubClient) GetClosedIssuesBetweenTags(ctx context.Context, previousTag, _ string) ([]models.Issue, error) { + prevRelease, _, err := ghc.releaseService.GetReleaseByTag(ctx, ghc.owner, ghc.repo, previousTag) + if err != nil { + return nil, err + } + + opts := &github.IssueListByRepoOptions{ + State: "closed", + Since: prevRelease.GetCreatedAt().Time, + Sort: "updated", + Direction: "desc", + ListOptions: github.ListOptions{ + PerPage: 100, + }, + } + + var allIssues []models.Issue + for { + issues, resp, err := ghc.issuesService.ListByRepo(ctx, ghc.owner, ghc.repo, opts) + if err != nil { + return nil, err + } + + for _, issue := range issues { + if issue.PullRequestLinks == nil { + labels := make([]string, 0, len(issue.Labels)) + for _, label := range issue.Labels { + labels = append(labels, label.GetName()) + } + + allIssues = append(allIssues, models.Issue{ + Number: issue.GetNumber(), + Title: issue.GetTitle(), + Labels: labels, + Author: issue.GetUser().GetLogin(), + URL: issue.GetHTMLURL(), + }) + } + } + + if resp.NextPage == 0 { + break + } + opts.ListOptions.Page = resp.NextPage + } + + return allIssues, nil +} + +// ListOpenIssues fetches currently open issues, most recently updated +// first, capped to a single page so a duplicate check stays cheap and fast. +func (ghc *GitHubClient) ListOpenIssues(ctx context.Context) ([]models.Issue, error) { + opts := &github.IssueListByRepoOptions{ + State: "open", + Sort: "updated", + Direction: "desc", + ListOptions: github.ListOptions{ + PerPage: 100, + }, + } + + issues, _, err := ghc.issuesService.ListByRepo(ctx, ghc.owner, ghc.repo, opts) + if err != nil { + return nil, err + } + + result := make([]models.Issue, 0, len(issues)) + for _, issue := range issues { + if issue.PullRequestLinks != nil { + continue + } + labels := make([]string, 0, len(issue.Labels)) + for _, label := range issue.Labels { + labels = append(labels, label.GetName()) + } + result = append(result, models.Issue{ + Number: issue.GetNumber(), + Title: issue.GetTitle(), + Labels: labels, + Author: issue.GetUser().GetLogin(), + URL: issue.GetHTMLURL(), + }) + } + + return result, nil +} + +func (ghc *GitHubClient) GetIssue(ctx context.Context, issueNumber int) (*models.Issue, error) { + log := logger.FromContext(ctx) + + log.Debug("fetching github issue", + "owner", ghc.owner, + "repo", ghc.repo, + "issue_number", issueNumber) + + issue, _, err := ghc.issuesService.Get(ctx, ghc.owner, ghc.repo, issueNumber) + if err != nil { + log.Error("failed to fetch github issue", + "error", err, + "owner", ghc.owner, + "repo", ghc.repo, + "issue_number", issueNumber) + return nil, fmt.Errorf("error getting issue #%d: %w", issueNumber, err) + } + + labels := make([]string, 0, len(issue.Labels)) + for _, label := range issue.Labels { + if label.Name != nil { + labels = append(labels, label.GetName()) + } + } + + var author string + if issue.User != nil && issue.User.Login != nil { + author = *issue.User.Login + } + + var description string + if issue.Body != nil { + description = *issue.Body + } + + var state string + if issue.State != nil { + state = *issue.State + } + + var url string + if issue.HTMLURL != nil { + url = *issue.HTMLURL + } + + criteria := extractAcceptanceCriteria(description) + + log.Debug("github issue fetched successfully", + "issue_number", issueNumber, + "title", issue.GetTitle(), + "state", state, + "labels_count", len(labels), + "criteria_count", len(criteria)) + + return &models.Issue{ + ID: int(issue.GetID()), + Number: issue.GetNumber(), + Title: issue.GetTitle(), + Description: description, + State: state, + Labels: labels, + Author: author, + URL: url, + Criteria: criteria, + }, nil +} + +func (ghc *GitHubClient) CreateIssue(ctx context.Context, title string, body string, labels []string, assignees []string) (*models.Issue, error) { + log := logger.FromContext(ctx) + + log.Info("creating github issue", + "owner", ghc.owner, + "repo", ghc.repo, + "title", title, + "labels_count", len(labels), + "assignees_count", len(assignees)) + + if labels == nil { + labels = []string{} + } + if assignees == nil { + assignees = []string{} + } + + issueRequest := &github.IssueRequest{ + Title: github.Ptr(title), + Body: github.Ptr(body), + Labels: &labels, + Assignees: &assignees, + } + + ghIssue, resp, err := ghc.issuesService.Create(ctx, ghc.owner, ghc.repo, issueRequest) + if err != nil { + if resp != nil { + if resp.StatusCode == http.StatusUnauthorized { + return nil, domainErrors.ErrGitHubTokenInvalid. + WithContext("operation", "create issue") + } + if resp.StatusCode == http.StatusNotFound { + return nil, domainErrors.ErrRepositoryNotFound. + WithContext("operation", "create issue"). + WithContext("repo", fmt.Sprintf("%s/%s", ghc.owner, ghc.repo)) + } + } + log.Error("failed to create github issue", + "error", err, + "owner", ghc.owner, + "repo", ghc.repo) + return nil, fmt.Errorf("error creating issue: %w", err) + } + + issue := &models.Issue{ + ID: int(*ghIssue.ID), + Number: *ghIssue.Number, + Title: *ghIssue.Title, + Description: getStringValue(ghIssue.Body), + State: *ghIssue.State, + Author: *ghIssue.User.Login, + URL: *ghIssue.HTMLURL, + Labels: make([]string, 0), + } + + for _, label := range ghIssue.Labels { + if label.Name != nil { + issue.Labels = append(issue.Labels, label.GetName()) + } + } + + log.Info("github issue created successfully", + "issue_number", issue.Number, + "issue_url", issue.URL) + + return issue, nil +} + +func extractAcceptanceCriteria(body string) []string { + var criteria []string + lines := strings.Split(body, "\n") + + for _, line := range lines { + matches := regex.MarkdownCheckbox.FindStringSubmatch(line) + if len(matches) > 2 { + criterion := strings.TrimSpace(matches[2]) + if criterion != "" { + criteria = append(criteria, criterion) + } + } + } + + return criteria +} diff --git a/internal/vcs/github/client_labels.go b/internal/vcs/github/client_labels.go new file mode 100644 index 0000000..69b3dcd --- /dev/null +++ b/internal/vcs/github/client_labels.go @@ -0,0 +1,141 @@ +package github + +import ( + "context" + "fmt" + "strings" + + "github.com/google/go-github/v80/github" + "github.com/thomas-vilte/matecommit/internal/logger" + "github.com/thomas-vilte/matecommit/internal/models" +) + +var allowedLabels = map[string]struct { + Color string + Key string +}{ + "feature": {"00FF00", "label.feature"}, + "fix": {"FF0000", "label.fix"}, + "refactor": {"FFA500", "label.refactor"}, + "docs": {"0075CA", "label.docs"}, + "infra": {"808080", "label.infra"}, + "test": {"8A2BE2", "label.test"}, +} + +var labelDescriptions = map[string]string{ + "feature": "New feature", + "fix": "Bug fix", + "refactor": "Code refactor", + "docs": "Documentation", + "infra": "Infrastructure", + "test": "Test", +} + +var labelAliases = map[string]string{ + "bug": "fix", + "enhancement": "feature", + "documentation": "docs", + "infrastructure": "infra", + "testing": "test", +} + +func (ghc *GitHubClient) GetRepoLabels(ctx context.Context) ([]string, error) { + labels, err := ghc.GetRepoLabelsWithDescriptions(ctx) + if err != nil { + return nil, err + } + + labelNames := make([]string, len(labels)) + for i, label := range labels { + labelNames[i] = label.Name + } + return labelNames, nil +} + +func (ghc *GitHubClient) GetRepoLabelsWithDescriptions(ctx context.Context) ([]models.RepoLabel, error) { + labels, _, err := ghc.issuesService.ListLabels(ctx, ghc.owner, ghc.repo, &github.ListOptions{PerPage: 100}) + if err != nil { + return nil, fmt.Errorf("failed to list repository labels: %w", err) + } + + result := make([]models.RepoLabel, len(labels)) + for i, label := range labels { + result[i] = models.RepoLabel{Name: label.GetName(), Description: label.GetDescription()} + } + return result, nil +} + +func (ghc *GitHubClient) CreateLabel(ctx context.Context, name, color, description string) error { + _, _, err := ghc.issuesService.CreateLabel(ctx, ghc.owner, ghc.repo, &github.Label{ + Name: github.Ptr(name), + Color: github.Ptr(color), + Description: github.Ptr(description), + }) + return err +} + +func (ghc *GitHubClient) labelExists(existingLabels []string, target string) bool { + for _, l := range existingLabels { + if strings.EqualFold(l, target) { + return true + } + } + return false +} + +func (ghc *GitHubClient) addLabelsToIssue(ctx context.Context, prNumber int, labels []string) error { + _, _, err := ghc.issuesService.AddLabelsToIssue(ctx, ghc.owner, ghc.repo, prNumber, labels) + if err != nil { + return fmt.Errorf("failed to add labels to PR #%d: %w", prNumber, err) + } + return nil +} + +func (ghc *GitHubClient) ensureLabelsExist(ctx context.Context, existingLabels []string, requiredLabels []string) error { + log := logger.FromContext(ctx) + + for _, label := range requiredLabels { + if !ghc.labelExists(existingLabels, label) { + meta := allowedLabels[label] + + description := labelDescriptions[label] + if err := ghc.CreateLabel(ctx, label, meta.Color, description); err != nil { + if !strings.Contains(err.Error(), "already_exists") && !strings.Contains(err.Error(), "422") { + return fmt.Errorf("failed to create label '%s': %w", label, err) + } + log.Debug("label already exists, skipping creation", + "label", label, + "owner", ghc.owner, + "repo", ghc.repo) + } + } + } + return nil +} + +func (ghc *GitHubClient) validateAndFilterLabels(labels []string) []string { + var validLabels []string + seen := make(map[string]bool) + + for _, label := range labels { + cleaned := strings.ToLower(strings.TrimSpace(label)) + if cleaned == "" { + continue + } + + if mapped, ok := labelAliases[cleaned]; ok { + cleaned = mapped + } + + if ghc.isAllowedLabel(cleaned) && !seen[cleaned] { + validLabels = append(validLabels, cleaned) + seen[cleaned] = true + } + } + return validLabels +} + +func (ghc *GitHubClient) isAllowedLabel(label string) bool { + _, exists := allowedLabels[label] + return exists +} diff --git a/internal/vcs/github/client_pr.go b/internal/vcs/github/client_pr.go new file mode 100644 index 0000000..fb20f8c --- /dev/null +++ b/internal/vcs/github/client_pr.go @@ -0,0 +1,242 @@ +package github + +import ( + "context" + "fmt" + "net/http" + "regexp" + "strconv" + "strings" + + "github.com/google/go-github/v80/github" + domainErrors "github.com/thomas-vilte/matecommit/internal/errors" + "github.com/thomas-vilte/matecommit/internal/logger" + "github.com/thomas-vilte/matecommit/internal/models" + "github.com/thomas-vilte/matecommit/internal/regex" +) + +func (ghc *GitHubClient) UpdatePR(ctx context.Context, prNumber int, summary models.PRSummary) error { + pr := &github.PullRequest{ + Title: github.Ptr(summary.Title), + Body: github.Ptr(summary.Body), + } + + _, resp, err := ghc.prService.Edit(ctx, ghc.owner, ghc.repo, prNumber, pr) + if err != nil { + if resp != nil { + if resp.StatusCode == http.StatusTooManyRequests { + return domainErrors.ErrGitHubRateLimit. + WithContext("retry_after", resp.Header.Get("Retry-After")). + WithContext("operation", "update PR") + } + if resp.StatusCode == http.StatusForbidden { + return domainErrors.ErrGitHubInsufficientPerms. + WithContext("operation", "update PR"). + WithContext("pr_number", prNumber). + WithContext("repo", fmt.Sprintf("%s/%s", ghc.owner, ghc.repo)) + } + if resp.StatusCode == http.StatusNotFound { + return domainErrors.ErrRepositoryNotFound. + WithContext("operation", "update PR"). + WithContext("repo", fmt.Sprintf("%s/%s", ghc.owner, ghc.repo)) + } + } + return fmt.Errorf("failed to update PR #%d: %w", prNumber, err) + } + + if len(summary.Labels) > 0 { + if err := ghc.AddLabelsToPR(ctx, prNumber, summary.Labels); err != nil { + return fmt.Errorf("failed to add labels to PR #%d: %w", prNumber, err) + } + } + + return nil +} + +func (ghc *GitHubClient) GetPR(ctx context.Context, prNumber int) (models.PRData, error) { + log := logger.FromContext(ctx) + + log.Debug("fetching github pull request", + "owner", ghc.owner, + "repo", ghc.repo, + "pr_number", prNumber) + + pr, resp, err := ghc.prService.Get(ctx, ghc.owner, ghc.repo, prNumber) + if err != nil { + if resp != nil { + if resp.StatusCode == http.StatusUnauthorized { + return models.PRData{}, domainErrors.ErrGitHubTokenInvalid. + WithContext("operation", "get PR"). + WithContext("pr_number", prNumber) + } + if resp.StatusCode == http.StatusNotFound { + return models.PRData{}, domainErrors.ErrRepositoryNotFound. + WithContext("operation", "get PR"). + WithContext("pr_number", prNumber). + WithContext("repo", fmt.Sprintf("%s/%s", ghc.owner, ghc.repo)) + } + } + log.Error("failed to fetch github PR", + "error", err, + "owner", ghc.owner, + "repo", ghc.repo, + "pr_number", prNumber) + return models.PRData{}, fmt.Errorf("failed to get PR #%d: %w", prNumber, err) + } + + commits, _, err := ghc.prService.ListCommits(ctx, ghc.owner, ghc.repo, prNumber, &github.ListOptions{}) + if err != nil { + return models.PRData{}, fmt.Errorf("failed to get commits for PR #%d: %w", prNumber, err) + } + + prCommits := make([]models.Commit, len(commits)) + for i, commit := range commits { + prCommits[i] = models.Commit{ + Message: commit.GetCommit().GetMessage(), + } + } + + prLabels := make([]string, len(pr.Labels)) + for i, label := range pr.Labels { + prLabels[i] = label.GetName() + } + + diff, resp, err := ghc.prService.GetRaw(ctx, ghc.owner, ghc.repo, prNumber, github.RawOptions{Type: github.Diff}) + if err != nil { + // If 406 error (diff too large), use fallback commit by commit + if resp != nil && resp.StatusCode == http.StatusNotAcceptable { + log.Warn("PR diff too large, fetching diffs commit by commit", + "pr_number", prNumber, + "commits_count", len(commits)) + diff, err = ghc.getDiffFromCommits(ctx, commits) + if err != nil { + return models.PRData{}, fmt.Errorf("failed to get diff from commits for PR #%d: %w", prNumber, err) + } + } else { + return models.PRData{}, fmt.Errorf("failed to get diff for PR #%d: %w", prNumber, err) + } + } + + prData := models.PRData{ + ID: prNumber, + Title: pr.GetTitle(), + Creator: pr.GetUser().GetLogin(), + Commits: prCommits, + Diff: diff, + BranchName: pr.GetHead().GetRef(), + Description: pr.GetBody(), + Labels: prLabels, + } + + log.Debug("github PR fetched successfully", + "pr_number", prNumber, + "title", prData.Title, + "commits_count", len(prCommits), + "diff_size", len(diff)) + + return prData, nil + +} + +func (ghc *GitHubClient) AddLabelsToPR(ctx context.Context, prNumber int, labels []string) error { + validLabels := ghc.validateAndFilterLabels(labels) + if len(validLabels) == 0 { + return nil + } + + existingLabels, err := ghc.GetRepoLabels(ctx) + if err != nil { + return fmt.Errorf("failed to get repository labels: %w", err) + } + + if err := ghc.ensureLabelsExist(ctx, existingLabels, validLabels); err != nil { + return err + } + + return ghc.addLabelsToIssue(ctx, prNumber, validLabels) +} + +// addIssueNumberMatches finds every match of re in text and adds the +// captured issue number (submatch group 1) to dest. +func addIssueNumberMatches(re *regexp.Regexp, text string, dest map[int]bool) { + for _, match := range re.FindAllStringSubmatch(text, -1) { + if len(match) > 1 { + if num, err := strconv.Atoi(match[1]); err == nil { + dest[num] = true + } + } + } +} + +func (ghc *GitHubClient) GetPRIssues(ctx context.Context, branchName string, commits []string, prDescription string) ([]models.Issue, error) { + issueNumbers := make(map[int]bool) + for _, re := range []*regexp.Regexp{ + regex.BranchIssueSharp, + regex.BranchIssueName, + regex.BranchIssueStart, + regex.BranchIssueFolder, + regex.BranchIssueMid, + } { + addIssueNumberMatches(re, branchName, issueNumbers) + } + + if prDescription != "" { + addIssueNumberMatches(regex.GitHubClosedLink, prDescription, issueNumbers) + addIssueNumberMatches(regex.BranchIssueSharp, prDescription, issueNumbers) + } + + for _, commit := range commits { + addIssueNumberMatches(regex.GitHubClosedLink, commit, issueNumbers) + addIssueNumberMatches(regex.GitHubPR, commit, issueNumbers) + addIssueNumberMatches(regex.BranchIssueSharp, commit, issueNumbers) + } + + var issues []models.Issue + for issueNum := range issueNumbers { + issue, err := ghc.GetIssue(ctx, issueNum) + if err != nil { + continue + } + issues = append(issues, *issue) + } + + return issues, nil +} + +// getDiffFromCommits gets the combined diff of all commits when the total PR diff is too large +func (ghc *GitHubClient) getDiffFromCommits(ctx context.Context, commits []*github.RepositoryCommit) (string, error) { + log := logger.FromContext(ctx) + var combinedDiff strings.Builder + + log.Info("fetching diffs from commits", + "commits_count", len(commits), + "owner", ghc.owner, + "repo", ghc.repo) + + for i, commit := range commits { + sha := commit.GetSHA() + log.Debug("processing commit", + "current", i+1, + "total", len(commits), + "sha", sha[:8]) + fullCommit, _, err := ghc.repoService.GetCommit(ctx, ghc.owner, ghc.repo, sha, nil) + if err != nil { + return "", fmt.Errorf("failed to get diff for commit %s: %w", 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 +} diff --git a/internal/vcs/github/client_release.go b/internal/vcs/github/client_release.go new file mode 100644 index 0000000..2507b73 --- /dev/null +++ b/internal/vcs/github/client_release.go @@ -0,0 +1,363 @@ +package github + +import ( + "context" + "fmt" + "net/http" + "os" + "path/filepath" + "sort" + "time" + + "github.com/google/go-github/v80/github" + "github.com/thomas-vilte/matecommit/internal/builder" + domainErrors "github.com/thomas-vilte/matecommit/internal/errors" + "github.com/thomas-vilte/matecommit/internal/logger" + "github.com/thomas-vilte/matecommit/internal/models" +) + +func (ghc *GitHubClient) CreateRelease(ctx context.Context, release *models.Release, notes *models.ReleaseNotes, draft bool, buildBinaries bool, progressCh chan<- models.BuildProgress) error { + body := notes.Changelog + if body == "" { + body = fmt.Sprintf("%s\n\n", notes.Summary) + if len(notes.Highlights) > 0 { + body += "## Highlights\n\n" + for _, h := range notes.Highlights { + body += fmt.Sprintf("- %s\n", h) + } + } + } + + releaseRequest := &github.RepositoryRelease{ + TagName: github.Ptr(release.Version), + Name: github.Ptr(notes.Title), + Body: github.Ptr(body), + Draft: github.Ptr(draft), + Prerelease: github.Ptr(false), + MakeLatest: github.Ptr("true"), + } + + createdRelease, resp, err := ghc.releaseService.CreateRelease(ctx, ghc.owner, ghc.repo, releaseRequest) + if err != nil { + if resp != nil { + if resp.StatusCode == http.StatusUnauthorized { + return domainErrors.ErrGitHubTokenInvalid. + WithContext("operation", "create release"). + WithContext("version", release.Version) + } + if resp.StatusCode == http.StatusUnprocessableEntity { + return domainErrors.ErrCreateRelease. + WithContext("version", release.Version). + WithContext("reason", "release already exists") + } + if resp.StatusCode == http.StatusNotFound { + return domainErrors.ErrRepositoryNotFound. + WithContext("operation", "create release"). + WithContext("version", release.Version). + WithContext("repo", fmt.Sprintf("%s/%s", ghc.owner, ghc.repo)) + } + if resp.StatusCode == http.StatusForbidden { + return domainErrors.ErrGitHubInsufficientPerms. + WithContext("operation", "create release"). + WithContext("version", release.Version) + } + } + return domainErrors.ErrCreateRelease.WithError(err).WithContext("version", release.Version) + } + + if buildBinaries { + if err := ghc.uploadBinaries(ctx, createdRelease.GetID(), release.Version, progressCh); err != nil { + return fmt.Errorf("failed to upload binaries: %w", err) + } + } + + return nil +} + +func (ghc *GitHubClient) GetRelease(ctx context.Context, version string) (*models.VCSRelease, error) { + release, resp, err := ghc.releaseService.GetReleaseByTag(ctx, ghc.owner, ghc.repo, version) + if err != nil { + if resp != nil { + if resp.StatusCode == http.StatusUnauthorized { + return nil, domainErrors.ErrGitHubTokenInvalid. + WithContext("operation", "get release"). + WithContext("version", version) + } + if resp.StatusCode == http.StatusNotFound { + return nil, domainErrors.ErrRepositoryNotFound. + WithContext("operation", "get release"). + WithContext("version", version). + WithContext("repo", fmt.Sprintf("%s/%s", ghc.owner, ghc.repo)) + } + return nil, domainErrors.ErrGetRelease. + WithContext("version", version). + WithContext("status_code", resp.StatusCode) + } + return nil, domainErrors.ErrGetRelease.WithError(err).WithContext("version", version) + } + + return &models.VCSRelease{ + TagName: release.GetTagName(), + Name: release.GetName(), + Body: release.GetBody(), + Draft: release.GetDraft(), + URL: release.GetHTMLURL(), + }, nil +} + +func (ghc *GitHubClient) UpdateRelease(ctx context.Context, version, body string) error { + release, resp, err := ghc.releaseService.GetReleaseByTag(ctx, ghc.owner, ghc.repo, version) + if err != nil { + if resp != nil { + if resp.StatusCode == http.StatusUnauthorized { + return domainErrors.ErrGitHubTokenInvalid. + WithContext("operation", "update release"). + WithContext("version", version) + } + if resp.StatusCode == http.StatusNotFound { + return domainErrors.ErrRepositoryNotFound. + WithContext("operation", "update release"). + WithContext("version", version). + WithContext("repo", fmt.Sprintf("%s/%s", ghc.owner, ghc.repo)) + } + return domainErrors.ErrUpdateRelease. + WithContext("version", version). + WithContext("status_code", resp.StatusCode) + } + return domainErrors.ErrUpdateRelease.WithError(err).WithContext("version", version) + } + + releaseUpdate := &github.RepositoryRelease{ + Body: github.Ptr(body), + } + + _, _, err = ghc.releaseService.EditRelease(ctx, ghc.owner, ghc.repo, release.GetID(), releaseUpdate) + if err != nil { + return domainErrors.ErrUpdateRelease.WithError(err).WithContext("version", version) + } + return nil +} + +func (ghc *GitHubClient) GetMergedPRsBetweenTags(ctx context.Context, previousTag, _ string) ([]models.PullRequest, error) { + prevRelease, _, err := ghc.releaseService.GetReleaseByTag(ctx, ghc.owner, ghc.repo, previousTag) + if err != nil { + return nil, err + } + + opts := &github.PullRequestListOptions{ + State: "closed", + Sort: "updated", + Direction: "desc", + ListOptions: github.ListOptions{ + PerPage: 100, + }, + } + var allPRs []models.PullRequest + for { + prs, resp, err := ghc.prService.List(ctx, ghc.owner, ghc.repo, opts) + if err != nil { + return nil, err + } + + for _, pr := range prs { + // The list endpoint never populates the "merged" boolean field + // (only the single-PR endpoint does) — merged_at is the only + // reliable signal here, and a zero value means "not merged". + if !pr.GetMergedAt().IsZero() && pr.GetMergedAt().After(prevRelease.GetCreatedAt().Time) { + labels := make([]string, 0, len(pr.Labels)) + for _, label := range pr.Labels { + labels = append(labels, label.GetName()) + } + + allPRs = append(allPRs, models.PullRequest{ + Number: pr.GetNumber(), + Title: pr.GetTitle(), + Description: pr.GetBody(), + Author: pr.GetUser().GetLogin(), + Labels: labels, + URL: pr.GetHTMLURL(), + }) + } + } + + if resp.NextPage == 0 { + break + } + opts.Page = resp.NextPage + } + return allPRs, nil +} + +func (ghc *GitHubClient) GetContributorsBetweenTags(ctx context.Context, previousTag, currentTag string) ([]string, error) { + comparison, _, err := ghc.repoService.CompareCommits(ctx, ghc.owner, ghc.repo, previousTag, currentTag, &github.ListOptions{ + PerPage: 100, + }) + if err != nil { + return nil, err + } + + contributorsMap := make(map[string]struct{}) + for _, commit := range comparison.Commits { + if author := commit.GetAuthor(); author != nil { + contributorsMap[author.GetLogin()] = struct{}{} + } + } + + contributors := make([]string, 0, len(contributorsMap)) + for contributor := range contributorsMap { + contributors = append(contributors, contributor) + } + return contributors, nil +} + +func (ghc *GitHubClient) GetFileStatsBetweenTags(ctx context.Context, previousTag, currentTag string) (*models.FileStatistics, error) { + comparison, _, err := ghc.repoService.CompareCommits(ctx, ghc.owner, ghc.repo, previousTag, currentTag, &github.ListOptions{ + PerPage: 100, + }) + if err != nil { + return nil, err + } + + stats := &models.FileStatistics{ + FilesChanged: len(comparison.Files), + Insertions: 0, + Deletions: 0, + TopFiles: make([]models.FileChange, 0), + } + + fileChanges := make([]models.FileChange, 0, len(comparison.Files)) + for _, file := range comparison.Files { + stats.Insertions += file.GetAdditions() + stats.Deletions += file.GetDeletions() + + fileChanges = append(fileChanges, models.FileChange{ + Path: file.GetFilename(), + Additions: file.GetAdditions(), + Deletions: file.GetDeletions(), + }) + } + + sort.Slice(fileChanges, func(i, j int) bool { + totalI := fileChanges[i].Additions + fileChanges[i].Deletions + totalJ := fileChanges[j].Additions + fileChanges[j].Deletions + return totalI > totalJ + }) + + if len(fileChanges) > 5 { + stats.TopFiles = fileChanges[:5] + } else { + stats.TopFiles = fileChanges + } + return stats, nil +} + +func (ghc *GitHubClient) uploadBinaries(ctx context.Context, releaseID int64, version string, progressCh chan<- models.BuildProgress) error { + tempDir, err := os.MkdirTemp("", "matecommit-build-*") + if err != nil { + return fmt.Errorf("failed to create temporary directory for build: %w", err) + } + defer func() { + if err := os.RemoveAll(tempDir); err != nil { + return + } + }() + + commit, err := ghc.getCommitSHA(ctx) + if err != nil { + commit = "unknown" + } + date := time.Now().Format(time.RFC3339) + + log := logger.FromContext(ctx) + + log.Debug("creating binary builder", + "repo", ghc.repo, + "main_path", ghc.mainPath, + "version", version) + + builderBinary := ghc.binaryBuilderFactory.NewBuilder( + ghc.mainPath, + ghc.repo, + builder.WithVersion(version), + builder.WithCommit(commit), + builder.WithDate(date), + builder.WithBuildDir(tempDir), + ) + + log.Info("compiling binaries for release", + "version", version, + "build_dir", tempDir) + + archives, err := builderBinary.BuildAndPackageAll(ctx, progressCh) + if err != nil { + return fmt.Errorf("failed to build binaries: %w", err) + } + + log.Info("uploading binaries to release", + "archives_count", len(archives), + "release_id", releaseID, + "version", version) + + if progressCh != nil { + progressCh <- models.BuildProgress{ + Type: models.UploadProgressStart, + Total: len(archives), + } + } + + for i, archivePath := range archives { + archiveName := filepath.Base(archivePath) + + if progressCh != nil { + progressCh <- models.BuildProgress{ + Type: models.UploadProgressAsset, + Asset: archiveName, + Current: i + 1, + Total: len(archives), + } + } + + log.Info("uploading asset", + "asset", archiveName, + "progress", fmt.Sprintf("%d/%d", i+1, len(archives))) + + file, err := os.Open(archivePath) + if err != nil { + return fmt.Errorf("failed to open archive %s: %w", archivePath, err) + } + + uploadOpts := &github.UploadOptions{ + Name: archiveName, + Label: archiveName, + } + + _, _, err = ghc.releaseService.UploadReleaseAsset(ctx, ghc.owner, ghc.repo, releaseID, uploadOpts, file) + _ = file.Close() + if err != nil { + return domainErrors.ErrUploadAsset.WithError(err). + WithContext("asset_path", archivePath). + WithContext("release_id", releaseID) + } + + log.Info("asset uploaded successfully", + "asset", archiveName, + "progress", fmt.Sprintf("%d/%d", i+1, len(archives))) + } + + if progressCh != nil { + progressCh <- models.BuildProgress{ + Type: models.UploadProgressComplete, + Total: len(archives), + } + } + + return nil +} + +func (ghc *GitHubClient) getCommitSHA(ctx context.Context) (string, error) { + ref, _, err := ghc.repoService.GetCommit(ctx, ghc.owner, ghc.repo, "HEAD", nil) + if err != nil { + return "", err + } + return ref.GetSHA(), nil +}