Skip to content

Commit

Permalink
refactor(origin): tidy up origin package, add tests
Browse files Browse the repository at this point in the history
  • Loading branch information
scottmckendry committed Jan 14, 2025
1 parent e297c7a commit fb5357d
Show file tree
Hide file tree
Showing 4 changed files with 229 additions and 60 deletions.
6 changes: 3 additions & 3 deletions changelog/changelog.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ type Change struct {
type Parser struct {
entries []ChangelogEntry
originUrl string
orignToken string
OriginToken string
IncludeBody bool
FetchItemDetails bool
}
Expand Down Expand Up @@ -142,7 +142,7 @@ func (p *Parser) parseChange(
return nil
}

relatedItems, err := extractRelatedItems(matches[2], p.originUrl, p.orignToken)
relatedItems, err := extractRelatedItems(matches[2], p.originUrl, p.OriginToken)
if err != nil {
return err
}
Expand All @@ -159,7 +159,7 @@ func (p *Parser) parseChange(
return err
}
if change.CommitBody != "" {
bodyItems, err := extractRelatedItems(change.CommitBody, p.originUrl, p.orignToken)
bodyItems, err := extractRelatedItems(change.CommitBody, p.originUrl, p.OriginToken)
if err != nil {
return err
}
Expand Down
2 changes: 2 additions & 0 deletions cmd/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ var cmd = &cobra.Command{
parser := changelog.NewParser()
parser.IncludeBody = opts.includeBody
parser.FetchItemDetails = opts.fetchItemDetails
parser.OriginToken = opts.token

if (parser.IncludeBody || parser.FetchItemDetails) && !git.IsGitRepo(".") {
fmt.Println("Cannot fetch commits: Not a git repository")
os.Exit(1)
Expand Down
171 changes: 114 additions & 57 deletions origin/origin.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,26 @@ import (
"strings"
)

// Issue represents a work item or issue from a Git provider.
type Issue struct {
Number int `json:"number,omitempty"`
Title string `json:"title,omitempty"`
Body string `json:"body,omitempty"`
}

// IssueProvider defines the interface for fetching issue details from Git providers.
type IssueProvider interface {
GetIssue(issueNumber string) (*Issue, error)
}

// Config contains the configuration needed to connect to a Git provider.
type Config struct {
URL string
Token string
URL string // Repository URL
Token string // Authentication token
}

// NewIssueProvider creates an appropriate IssueProvider based on the repository URL.
// Currently supports GitHub and Azure DevOps.
func NewIssueProvider(config Config) (IssueProvider, error) {
if strings.Contains(config.URL, "github.com") {
return NewGitHubProvider(config), nil
Expand All @@ -35,23 +40,61 @@ func NewIssueProvider(config Config) (IssueProvider, error) {
return nil, fmt.Errorf("unsupported git provider for URL: %s", config.URL)
}

type GitHubProvider struct {
// BaseProvider implements common functionality for all Git providers.
type BaseProvider struct {
config Config
owner string
repo string
client *http.Client
}

// NewBaseProvider creates a new BaseProvider with the given configuration.
func NewBaseProvider(config Config) BaseProvider {
return BaseProvider{
config: config,
client: &http.Client{},
}
}

// doRequest performs an HTTP request and handles common response scenarios.
// Returns nil response for 404 status and error for other non-200 statuses.
func (b *BaseProvider) doRequest(req *http.Request) (*http.Response, error) {
resp, err := b.client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to get issue details: %w", err)
}

if resp.StatusCode == http.StatusNotFound {
resp.Body.Close()
return nil, nil
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, fmt.Errorf("failed to get issue details: %s", resp.Status)
}

return resp, nil
}

// GitHubProvider implements IssueProvider for GitHub repositories.
type GitHubProvider struct {
BaseProvider
owner string // GitHub repository owner
repo string // GitHub repository name
}

// NewGitHubProvider creates a new GitHub provider with the given configuration.
func NewGitHubProvider(config Config) *GitHubProvider {
owner, repo := parseGitHubURL(config.URL)
return &GitHubProvider{
config: config,
owner: owner,
repo: repo,
BaseProvider: NewBaseProvider(config),
owner: owner,
repo: repo,
}
}

func (g *GitHubProvider) GetIssue(issueNumber string) (*Issue, error) {
url := fmt.Sprintf("https://api.github.com/repos/%s/%s/issues/%s", g.owner, g.repo, issueNumber)
// createRequest creates a GitHub API request with appropriate headers.
func (g *GitHubProvider) createRequest(issueNumber string) (*http.Request, error) {
url := fmt.Sprintf("https://api.github.com/repos/%s/%s/issues/%s",
g.owner, g.repo, issueNumber)

req, err := http.NewRequest("GET", url, nil)
if err != nil {
Expand All @@ -60,79 +103,90 @@ func (g *GitHubProvider) GetIssue(issueNumber string) (*Issue, error) {

req.Header.Set("Accept", "application/vnd.github.v3+json")
req.Header.Set("User-Agent", "go-changelog")
req.Header.Set("Authorization", "Bearer "+g.config.Token)

return g.doRequest(req)
if g.config.Token != "" {
req.Header.Set("Authorization", "Bearer "+g.config.Token)
}

return req, nil
}

// GetIssue fetches issue details from GitHub.
func (g *GitHubProvider) GetIssue(issueNumber string) (*Issue, error) {
req, err := g.createRequest(issueNumber)
if err != nil {
return nil, err
}

resp, err := g.doRequest(req)
if err != nil {
return nil, err
}
if resp == nil {
return nil, nil
}
defer resp.Body.Close()

var issue Issue
if err := json.NewDecoder(resp.Body).Decode(&issue); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}

return &issue, nil
}

// AzureDevOpsProvider implements IssueProvider for Azure DevOps
// AzureDevOpsProvider implements IssueProvider for Azure DevOps repositories.
type AzureDevOpsProvider struct {
config Config
org string
BaseProvider
org string // Azure DevOps organization
}

// NewAzureDevOpsProvider creates a new Azure DevOps provider with the given configuration.
func NewAzureDevOpsProvider(config Config) *AzureDevOpsProvider {
org := parseAzureDevOpsURL(config.URL)
return &AzureDevOpsProvider{
config: config,
org: org,
BaseProvider: NewBaseProvider(config),
org: org,
}
}

func (a *AzureDevOpsProvider) GetIssue(issueNumber string) (*Issue, error) {
// createRequest creates an Azure DevOps API request with appropriate headers.
func (a *AzureDevOpsProvider) createRequest(issueNumber string) (*http.Request, error) {
url := fmt.Sprintf(
"https://dev.azure.com/%s/_apis/wit/workitems/%s?api-version=7.1",
a.org,
issueNumber,
)

req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}

req.Header.Set("Content-Type", "application/json")
encodedPat := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf(":%s", a.config.Token)))
if a.config.Token != "" {
encodedPat := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf(":%s", a.config.Token)))
req.Header.Set("Content-Type", "application/json")
req.Header.Set(
"Authorization",
fmt.Sprintf("Basic %s", encodedPat),
)
req.Header.Set("Authorization", fmt.Sprintf("Basic %s", encodedPat))
}

return a.doRequest(req)
return req, nil
}

func (g *GitHubProvider) doRequest(req *http.Request) (*Issue, error) {
client := &http.Client{}
resp, err := client.Do(req)
// GetIssue fetches work item details from Azure DevOps.
func (a *AzureDevOpsProvider) GetIssue(issueNumber string) (*Issue, error) {
req, err := a.createRequest(issueNumber)
if err != nil {
return nil, fmt.Errorf("failed to get issue details: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to get issue details: %s", resp.Status)
}

var issue Issue
if err := json.NewDecoder(resp.Body).Decode(&issue); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
return nil, err
}

return &issue, nil
}

func (a *AzureDevOpsProvider) doRequest(req *http.Request) (*Issue, error) {
client := &http.Client{}
resp, err := client.Do(req)
resp, err := a.doRequest(req)
if err != nil {
return nil, fmt.Errorf("failed to get issue details: %w", err)
return nil, err
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to get issue details: %s", resp.Status)
if resp == nil {
return nil, nil
}
defer resp.Body.Close()

var azureResponse struct {
ID int `json:"id"`
Expand All @@ -146,24 +200,27 @@ func (a *AzureDevOpsProvider) doRequest(req *http.Request) (*Issue, error) {
return nil, fmt.Errorf("failed to decode response: %w", err)
}

// Clean up the description
description := azureResponse.Fields.Description
description = html.UnescapeString(description)
description = regexp.MustCompile(`<[^>]*>`).ReplaceAllString(description, "")
description = strings.TrimSpace(description)

return &Issue{
Number: azureResponse.ID,
Title: azureResponse.Fields.Title,
Body: description,
Body: cleanDescription(azureResponse.Fields.Description),
}, nil
}

// cleanDescription removes HTML tags and whitespace from the issue description.
func cleanDescription(description string) string {
description = html.UnescapeString(description)
description = regexp.MustCompile(`<[^>]*>`).ReplaceAllString(description, "")
return strings.TrimSpace(description)
}

// parseGitHubURL extracts owner and repository name from a GitHub URL.
func parseGitHubURL(url string) (owner, repo string) {
parts := strings.Split(url, "/")
return parts[len(parts)-2], parts[len(parts)-1]
}

// parseAzureDevOpsURL extracts organization name from an Azure DevOps URL.
func parseAzureDevOpsURL(url string) (org string) {
parts := strings.Split(url, "/")
return parts[len(parts)-3]
Expand Down
Loading

0 comments on commit fb5357d

Please sign in to comment.