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
33 changes: 32 additions & 1 deletion internal/provider/default_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -514,7 +514,7 @@ func buildAzureIssueList(items []azureWorkItemResponse) []types.RelatedIssue {
ID: strconv.Itoa(item.ID),
Type: "work-item",
Provider: "azure-devops",
URL: strings.TrimSpace(item.URL),
URL: azureWorkItemWebURL(item.URL, item.ID, item.Fields.TeamProject),
Title: strings.TrimSpace(item.Fields.Title),
Body: strings.TrimSpace(item.Fields.Description),
State: strings.TrimSpace(item.Fields.State),
Expand Down Expand Up @@ -562,6 +562,37 @@ func setAzureAuthHeader(req *http.Request, token string) {
req.Header.Set("Authorization", "Basic "+encoded)
}

// azureWorkItemWebURL converts the REST API URL returned by Azure DevOps
// (e.g. https://dev.azure.com/org/project/_apis/wit/workItems/1)
// into the human-facing work item URL
// (e.g. https://dev.azure.com/org/project/_workitems/edit/1).
// When the API URL is org-scoped (no project segment), teamProject is appended.
func azureWorkItemWebURL(apiURL string, id int, teamProject string) string {
apiURL = strings.TrimSpace(apiURL)
parsed, err := url.Parse(apiURL)
if err != nil || parsed.Host == "" {
return apiURL
}

apisIdx := strings.Index(parsed.Path, "/_apis/")
if apisIdx < 0 {
return apiURL
}

basePath := parsed.Path[:apisIdx]

// For dev.azure.com the path is /{org}/{project}. If only /{org} is present
// and we know the project, append it so the link resolves correctly.
if parsed.Host == "dev.azure.com" {
segments := strings.Split(strings.Trim(basePath, "/"), "/")
if len(segments) == 1 && strings.TrimSpace(teamProject) != "" {
basePath = basePath + "/" + url.PathEscape(strings.TrimSpace(teamProject))
}
}

return fmt.Sprintf("%s://%s%s/_workitems/edit/%d", parsed.Scheme, parsed.Host, basePath, id)
}

func azureRepoContext(repoURL string) (orgProjectBase string, repoName string, err error) {
parsed, err := url.Parse(strings.TrimSpace(repoURL))
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
Expand Down
56 changes: 56 additions & 0 deletions internal/provider/enricher_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,10 @@ func TestDiscoverAzureIssuesReturnsNormalizedWorkItemData(t *testing.T) {
if item.Metadata["workItemType"] != "Bug" || item.Metadata["teamProject"] != "project" {
t.Fatalf("unexpected metadata: %+v", item.Metadata)
}
wantURL := "https://dev.azure.com/org/project/_workitems/edit/1001"
if item.URL != wantURL {
t.Fatalf("expected URL %q, got %q", wantURL, item.URL)
}
}

func TestDiscoverGitHubIssuesFallsBackToRESTWhenCLIFails(t *testing.T) {
Expand Down Expand Up @@ -287,6 +291,10 @@ func TestDiscoverAzureIssuesFallsBackToRESTWhenCLIFails(t *testing.T) {
if len(issues) != 1 || issues[0].ID != "1001" {
t.Fatalf("unexpected issues from rest fallback: %+v", issues)
}
wantURL := "https://dev.azure.com/org/project/_workitems/edit/1001"
if issues[0].URL != wantURL {
t.Fatalf("expected URL %q, got %q", wantURL, issues[0].URL)
}
}

func TestDiscoverIssuesRejectsInvalidModeConfiguration(t *testing.T) {
Expand All @@ -310,3 +318,51 @@ func TestDiscoverIssuesRejectsInvalidModeConfiguration(t *testing.T) {
t.Fatalf("expected config error class, got %s", appErr.Class)
}
}

func TestAzureWorkItemWebURL(t *testing.T) {
tests := []struct {
name string
apiURL string
id int
teamProject string
want string
}{
{
name: "project-scoped dev.azure.com URL",
apiURL: "https://dev.azure.com/org/project/_apis/wit/workItems/1001",
id: 1001,
teamProject: "project",
want: "https://dev.azure.com/org/project/_workitems/edit/1001",
},
{
name: "org-scoped dev.azure.com URL with teamProject",
apiURL: "https://dev.azure.com/ensekltd/_apis/wit/workItems/378982",
id: 378982,
teamProject: "blackbird",
want: "https://dev.azure.com/ensekltd/blackbird/_workitems/edit/378982",
},
{
name: "org-scoped dev.azure.com URL without teamProject",
apiURL: "https://dev.azure.com/org/_apis/wit/workItems/42",
id: 42,
teamProject: "",
want: "https://dev.azure.com/org/_workitems/edit/42",
},
{
name: "visualstudio.com URL",
apiURL: "https://org.visualstudio.com/project/_apis/wit/workItems/99",
id: 99,
teamProject: "project",
want: "https://org.visualstudio.com/project/_workitems/edit/99",
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := azureWorkItemWebURL(tc.apiURL, tc.id, tc.teamProject)
if got != tc.want {
t.Fatalf("azureWorkItemWebURL(%q, %d, %q) = %q, want %q", tc.apiURL, tc.id, tc.teamProject, got, tc.want)
}
})
}
}
Loading