Skip to content

Commit

Permalink
feat(origin): add support for Azure DevOps workitems
Browse files Browse the repository at this point in the history
resolves #2
  • Loading branch information
scottmckendry committed Jan 14, 2025
1 parent 539c4cd commit e297c7a
Show file tree
Hide file tree
Showing 3 changed files with 166 additions and 29 deletions.
38 changes: 25 additions & 13 deletions changelog/changelog.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ type Change struct {
type Parser struct {
entries []ChangelogEntry
originUrl string
orignToken string
IncludeBody bool
FetchItemDetails bool
}
Expand Down Expand Up @@ -141,7 +142,7 @@ func (p *Parser) parseChange(
return nil
}

relatedItems, err := extractRelatedItems(matches[2], p.originUrl)
relatedItems, err := extractRelatedItems(matches[2], p.originUrl, p.orignToken)
if err != nil {
return err
}
Expand All @@ -158,7 +159,7 @@ func (p *Parser) parseChange(
return err
}
if change.CommitBody != "" {
bodyItems, err := extractRelatedItems(change.CommitBody, p.originUrl)
bodyItems, err := extractRelatedItems(change.CommitBody, p.originUrl, p.orignToken)
if err != nil {
return err
}
Expand Down Expand Up @@ -207,26 +208,37 @@ func parseCommitHashFromLink(link string) string {
return ""
}

func extractRelatedItems(text string, repoUrl string) ([]*origin.Issue, error) {
func extractRelatedItems(text string, repoUrl string, token string) ([]*origin.Issue, error) {
regex := regexp.MustCompile(`#(\d+)`)
matches := regex.FindAllStringSubmatch(text, -1)

seen := make(map[string]bool)
var items []*origin.Issue

for _, match := range matches {
if !seen[match[1]] {
number, _ := strconv.Atoi(match[1])
issue := &origin.Issue{
Number: number,
}
if repoUrl != "" {
if err := origin.GetIssueDetails(issue, repoUrl, match[1]); err != nil {
if repoUrl != "" {
provider, err := origin.NewIssueProvider(origin.Config{URL: repoUrl, Token: token})
if err != nil {
return nil, fmt.Errorf("failed to create issue provider: %w", err)
}

for _, match := range matches {
if !seen[match[1]] {
issue, err := provider.GetIssue(match[1])
if err != nil {
return nil, fmt.Errorf("failed to get issue details for #%s: %w", match[1], err)
}
items = append(items, issue)
seen[match[1]] = true
}
}
} else {
// When repoUrl is empty, just create basic Issue objects with numbers
for _, match := range matches {
num, _ := strconv.Atoi(match[1])
if !seen[match[1]] {
items = append(items, &origin.Issue{Number: num})
seen[match[1]] = true
}
items = append(items, issue)
seen[match[1]] = true
}
}

Expand Down
4 changes: 4 additions & 0 deletions cmd/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ type options struct {
release string
includeBody bool
fetchItemDetails bool
token string
format string
}

Expand Down Expand Up @@ -91,6 +92,7 @@ func init() {
cmd.Flags().Bool("include-body", false, "include the full commit body in changelog entry")
cmd.Flags().
Bool("fetch-item-details", false, "fetch details for related items (e.g. GitHub issues & PRs)")
cmd.Flags().String("token", "", "token for fetching related items")
cmd.Flags().StringP("format", "f", "json", "output format (json, yaml, or toml)")
}

Expand All @@ -100,6 +102,7 @@ func getOptions(cmd *cobra.Command) options {
release, _ := cmd.Flags().GetString("release")
includeBody, _ := cmd.Flags().GetBool("include-body")
fetchItemDetails, _ := cmd.Flags().GetBool("fetch-item-details")
token, _ := cmd.Flags().GetString("token")
format, _ := cmd.Flags().GetString("format")

return options{
Expand All @@ -108,6 +111,7 @@ func getOptions(cmd *cobra.Command) options {
release: release,
includeBody: includeBody,
fetchItemDetails: fetchItemDetails,
token: token,
format: format,
}
}
Expand Down
153 changes: 137 additions & 16 deletions origin/origin.go
Original file line number Diff line number Diff line change
@@ -1,49 +1,170 @@
package origin

import (
"encoding/base64"
"encoding/json"
"fmt"
"html"
"net/http"
"regexp"
"strings"
)

type Issue struct {
Number int `json:"number"`
Title string `json:"title"`
Body string `json:"body"`
Number int `json:"number,omitempty"`
Title string `json:"title,omitempty"`
Body string `json:"body,omitempty"`
}

func GetIssueDetails(issue *Issue, repoUrl, issueNumber string) error {
owner, repo := getOwnerAndRepo(repoUrl)
url := fmt.Sprintf("https://api.github.com/repos/%s/%s/issues/%s", owner, repo, issueNumber)
type IssueProvider interface {
GetIssue(issueNumber string) (*Issue, error)
}

type Config struct {
URL string
Token string
}

func NewIssueProvider(config Config) (IssueProvider, error) {
if strings.Contains(config.URL, "github.com") {
return NewGitHubProvider(config), nil
}
if strings.Contains(config.URL, "dev.azure.com") {
return NewAzureDevOpsProvider(config), nil
}
return nil, fmt.Errorf("unsupported git provider for URL: %s", config.URL)
}

type GitHubProvider struct {
config Config
owner string
repo string
}

func NewGitHubProvider(config Config) *GitHubProvider {
owner, repo := parseGitHubURL(config.URL)
return &GitHubProvider{
config: 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)

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

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

// AzureDevOpsProvider implements IssueProvider for Azure DevOps
type AzureDevOpsProvider struct {
config Config
org string
}

func NewAzureDevOpsProvider(config Config) *AzureDevOpsProvider {
org := parseAzureDevOpsURL(config.URL)
return &AzureDevOpsProvider{
config: config,
org: org,
}
}

func (a *AzureDevOpsProvider) GetIssue(issueNumber string) (*Issue, 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)
}

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),
)
}

return a.doRequest(req)
}

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

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

if err := json.NewDecoder(resp.Body).Decode(issue); err != nil {
return fmt.Errorf("failed to decode response: %w", err)
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
return &issue, nil
}

func getOwnerAndRepo(repoUrl string) (owner string, repo string) {
// parse repoUrl to get owner and repo
parts := strings.Split(repoUrl, "/")
func (a *AzureDevOpsProvider) doRequest(req *http.Request) (*Issue, error) {
client := &http.Client{}
resp, err := client.Do(req)
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 azureResponse struct {
ID int `json:"id"`
Fields struct {
Title string `json:"System.Title"`
Description string `json:"System.Description"`
} `json:"fields"`
}

if err := json.NewDecoder(resp.Body).Decode(&azureResponse); err != nil {
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,
}, nil
}

func parseGitHubURL(url string) (owner, repo string) {
parts := strings.Split(url, "/")
return parts[len(parts)-2], parts[len(parts)-1]
}

func parseAzureDevOpsURL(url string) (org string) {
parts := strings.Split(url, "/")
return parts[len(parts)-3]
}

0 comments on commit e297c7a

Please sign in to comment.