Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
fb9ced5
feat(azdo): ✨ add central AzDO URL parser
tmeckel Aug 16, 2026
deb0e19
test(azdo): ✅ add tests for central URL parser
tmeckel Aug 16, 2026
7745615
refactor(azdo): ♻️ delegate repo URL parsing to central parser
tmeckel Aug 16, 2026
3baa084
refactor(auth): ♻️ use central AzDO URL parser in git credential helper
tmeckel Aug 16, 2026
52544ad
test(auth): ✅ add git credential URL extraction tests
tmeckel Aug 16, 2026
55d4d9d
feat(util): ✨ add DisallowOrganization and AllowBareTargets parse opt…
tmeckel Aug 16, 2026
348a9df
test(util): ✅ add parse option tests
tmeckel Aug 16, 2026
c2ad337
feat(boards): ✨ add ParseWorkItemURL shared helper
tmeckel Aug 16, 2026
a69fa5c
test(boards): ✅ add tests for ParseWorkItemURL
tmeckel Aug 16, 2026
ca0e7f6
refactor(boards): ♻️ add relation target resolution helpers
tmeckel Aug 16, 2026
6e88a0b
test(boards): ✅ add relation target resolver tests
tmeckel Aug 16, 2026
7b0fa9b
feat(boards): ✨ support cross-project targets in relation add
tmeckel Aug 16, 2026
ef79940
test(boards): ✅ add cross-project target tests for relation add
tmeckel Aug 16, 2026
29edf1a
feat(boards): ✨ implement work-item relation show command
tmeckel Aug 16, 2026
371b0cd
test(boards): ✅ add tests for work-item relation show
tmeckel Aug 16, 2026
6b179a3
feat(boards): ✨ wire work-item relation show into command tree
tmeckel Aug 16, 2026
8ed6da6
docs: 📚 regenerate docs for relation add cross-project targets
tmeckel Aug 16, 2026
e4003cc
docs: 📚 add generated docs for work-item relation show
tmeckel Aug 16, 2026
a517db7
docs: 📚 document work-item relation show in group index
tmeckel Aug 16, 2026
ecef186
docs: 📚 regenerate help reference with relation show
tmeckel Aug 16, 2026
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
1 change: 1 addition & 0 deletions docs/azdo_boards_work-item_relation.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Work with Azure Boards work item relations.

* [azdo boards work-item relation add](./azdo_boards_work-item_relation_add.md)
* [azdo boards work-item relation remove](./azdo_boards_work-item_relation_remove.md)
* [azdo boards work-item relation show](./azdo_boards_work-item_relation_show.md)

### See also

Expand Down
15 changes: 11 additions & 4 deletions docs/azdo_boards_work-item_relation_add.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ azdo boards work-item relation add [ORG:]PROJECT/ID [flags]

Attach one or more relations to an existing work item. The relation type
must be one of the friendly names returned by 'list-type'. Targets can
be other work items (by ID) or arbitrary artifact URLs.
be other work items (by ID, optionally prefixed with their project) or
arbitrary artifact URLs. Work items in other projects of the same
organization are resolved via 'PROJECT/ID'. Cross-organization links
are not possible by ID; use --target-url with a remote link type such
as 'Remote Related', 'Consumes From' or 'Produces For'.


### Options
Expand All @@ -24,11 +28,11 @@ be other work items (by ID) or arbitrary artifact URLs.

Relation type (friendly name, e.g. parent, child, related).

* `--target-id` `stringArray`
* `-T`, `--target-id` `stringArray`

Target work item ID (repeatable; comma-separated values accepted).
Target work item ID (repeatable; comma-separated; each entry is [PROJECT/]ID; ID-only targets resolve in the current project).

* `--target-url` `stringArray`
* `-u`, `--target-url` `stringArray`

Target artifact URL (repeatable; comma-separated values accepted).

Expand All @@ -51,6 +55,9 @@ be other work items (by ID) or arbitrary artifact URLs.
# Add a parent relation to another work item
azdo boards work-item relation add Fabrikam/1234 --relation-type parent --target-id 5678

# Add a parent relation to a work item in another project of the same organization
azdo boards work-item relation add Fabrikam/1234 --relation-type parent --target-id Contoso/77

# Add a relation to multiple work items
azdo boards work-item relation add Fabrikam/1234 --relation-type related --target-id 5678,5679

Expand Down
44 changes: 44 additions & 0 deletions docs/azdo_boards_work-item_relation_show.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
## Command `azdo boards work-item relation show`

```
azdo boards work-item relation show [ORG:]PROJECT/ID [flags]
```

List all relations of an existing work item. Relation types are
displayed by their friendly name.


### Options


* `-q`, `--jq` `expression`

Filter JSON output using a jq expression

* `--json` `fields`

Output JSON with the specified fields. Prefix a field with '-' to exclude it.

* `-t`, `--template` `string`

Format JSON output using a Go template; see "azdo help formatting"


### ALIASES

- `s`

### JSON Fields

`_links`, `commentVersionRef`, `fields`, `id`, `relations`, `rev`, `url`

### Examples

```bash
# List the relations of a work item
azdo boards work-item relation show Fabrikam/1234
```

### See also

* [azdo boards work-item relation](./azdo_boards_work-item_relation.md)
20 changes: 18 additions & 2 deletions docs/azdo_help_reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -325,8 +325,8 @@ Add a relation(s) to a work item.
-q, --jq expression Filter JSON output using a jq expression
--json fields[=*] Output JSON with the specified fields. Prefix a field with '-' to exclude it.
--relation-type string Relation type (friendly name, e.g. parent, child, related).
--target-id stringArray Target work item ID (repeatable; comma-separated values accepted).
--target-url stringArray Target artifact URL (repeatable; comma-separated values accepted).
-T, --target-id stringArray Target work item ID (repeatable; comma-separated; each entry is [PROJECT/]ID; ID-only targets resolve in the current project).
-u, --target-url stringArray Target artifact URL (repeatable; comma-separated values accepted).
-t, --template string Format JSON output using a Go template; see "azdo help formatting"
```

Expand Down Expand Up @@ -355,6 +355,22 @@ Aliases
r, rm
```

##### `azdo boards work-item relation show [ORG:]PROJECT/ID [flags]`

List the relations of a work item.

```
-q, --jq expression Filter JSON output using a jq expression
--json fields[=*] Output JSON with the specified fields. Prefix a field with '-' to exclude it.
-t, --template string Format JSON output using a Go template; see "azdo help formatting"
```

Aliases

```
s
```

#### `azdo boards work-item show [ORG:]PROJECT/ID [flags]`

Show work item details
Expand Down
71 changes: 24 additions & 47 deletions internal/azdo/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,40 +182,31 @@ func ProjectFromURL(u *url.URL) (ProjectName, error) {
}

parts := strings.Split(strings.Trim(u.Path, "/"), "/")
orgInHost := strings.HasSuffix(strings.ToLower(u.Hostname()), ".visualstudio.com")

for _, part := range parts {
if len(strings.TrimSpace(part)) == 0 {
return nil, fmt.Errorf("invalid path %q", u.Path)
}
}

var organization string
var project string
if orgInHost {
if len(parts) < 1 {
return nil, fmt.Errorf("invalid path %q", u.Path)
}
organization = strings.ToLower(strings.SplitN(u.Hostname(), ".", 2)[0])
project = parts[0]
} else {
if len(parts) < 2 {
return nil, fmt.Errorf("invalid path %q", u.Path)
}
organization = strings.ToLower(parts[0])
project = parts[1]
id, err := ParseURL(u, false)
if err != nil {
return nil, err
}
if id.Project == "" {
return nil, fmt.Errorf("invalid path %q", u.Path)
}

hostname, err := getHostnameFromOrganization(organization)
hostname, err := getHostnameFromOrganization(id.Organization)
if err != nil {
return nil, err
}

if !strings.EqualFold(hostname, u.Hostname()) {
return nil, fmt.Errorf("hostname %q of URL does not match configured hostname %q of organization %q", u.Hostname(), hostname, organization)
return nil, fmt.Errorf("hostname %q of URL does not match configured hostname %q of organization %q", u.Hostname(), hostname, id.Organization)
}

return ProjectFromName(organization + ":" + project)
return ProjectFromName(id.Organization + ":" + id.Project)
}

// OrganizationFromURL extracts the Azure DevOps organization from a validated URL.
Expand All @@ -234,24 +225,11 @@ func OrganizationFromURL(u *url.URL) (string, error) {
return "", fmt.Errorf("url %s is not a valid AzDO remote URL", u.String())
}

lowerHostname := strings.ToLower(u.Hostname())
if strings.HasSuffix(lowerHostname, ".visualstudio.com") {
return strings.SplitN(lowerHostname, ".", 2)[0], nil
}

parts := strings.Split(strings.Trim(u.Path, "/"), "/")
if len(parts) == 0 || strings.TrimSpace(parts[0]) == "" {
return "", fmt.Errorf("invalid path %q", u.Path)
}

if strings.EqualFold(u.Scheme, "ssh") {
if len(parts) < 2 || strings.TrimSpace(parts[1]) == "" {
return "", fmt.Errorf("invalid path %q", u.Path)
}
return strings.ToLower(parts[1]), nil
id, err := ParseURL(u, false)
if err != nil {
return "", err
}

return strings.ToLower(parts[0]), nil
return id.Organization, nil
}

type RepositoryName interface {
Expand Down Expand Up @@ -497,7 +475,10 @@ func RepositoryFromURL(u *url.URL) (Repository, error) {
zap.L().Debug("validated as AzDO remote URL", zap.String("hostname", u.Hostname()), zap.String("scheme", u.Scheme), zap.String("path", u.Path))
parts := strings.SplitN(strings.Trim(u.Path, "/"), "/", 5)
zap.L().Debug("split path into parts", zap.Strings("parts", parts))
orgInHost := strings.HasSuffix(strings.ToLower(u.Hostname()), ".visualstudio.com")
// The host-style form ({org}.visualstudio.com/{project}/...) leads the
// path with the project, so its repository URLs carry one fewer segment
// than the dev.azure.com/{org}/{project}/... form.
orgInHost := IsVisualStudioHost(u.Hostname())

for _, part := range parts {
if len(strings.TrimSpace(part)) == 0 {
Expand Down Expand Up @@ -544,17 +525,13 @@ func RepositoryFromURL(u *url.URL) (Repository, error) {
return nil, fmt.Errorf("unsupported scheme %q", u.Scheme)
}

var organization string
var project string
if orgInHost {
organization = strings.ToLower(strings.SplitN(u.Hostname(), ".", 2)[0])
project = parts[0]
zap.L().Debug("extracted organization/project from host style url", zap.String("organization", organization), zap.String("project", project))
} else {
organization = strings.ToLower(parts[0])
project = parts[1]
zap.L().Debug("extracted organization/project from path style url", zap.String("organization", organization), zap.String("project", project))
id, err := ParseURL(u, false)
if err != nil {
return nil, err
}
organization := id.Organization
project := id.Project
zap.L().Debug("extracted organization/project from url", zap.String("organization", organization), zap.String("project", project))

hostname, err := getHostnameFromOrganization(organization)
if err != nil {
Expand All @@ -564,7 +541,7 @@ func RepositoryFromURL(u *url.URL) (Repository, error) {

if !strings.EqualFold(hostname, strings.TrimPrefix(u.Hostname(), "ssh.")) {
zap.L().Debug("hostname mismatch detected", zap.String("url_hostname", u.Hostname()), zap.String("configured_hostname", hostname), zap.String("organization", organization))
return nil, fmt.Errorf("hostname %q of URL does not match configured hostname %q of organization %q", u.Hostname(), hostname, parts[0])
return nil, fmt.Errorf("hostname %q of URL does not match configured hostname %q of organization %q", u.Hostname(), hostname, organization)
}
zap.L().Debug("creating repository object", zap.String("organization", organization), zap.String("project", project), zap.String("repo", strings.TrimSuffix(parts[projectNameIdx], ".git")))

Expand Down
118 changes: 118 additions & 0 deletions internal/azdo/url.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package azdo

import (
"errors"
"fmt"
"net/url"
"strings"
)

// Sentinel errors reported by ParseURL for degenerate inputs. Callers
// classify them with errors.Is instead of comparing error text.
var (
// ErrNotAzDO reports a URL whose hostname is neither dev.azure.com,
// ssh.dev.azure.com, nor a *.visualstudio.com subdomain.
ErrNotAzDO = errors.New("not an Azure DevOps host")

// ErrInvalidPath reports an Azure DevOps URL whose path lacks the
// segments required to identify an organization.
ErrInvalidPath = errors.New("invalid Azure DevOps URL path")
)

// IsVisualStudioHost reports whether hostname is a *.visualstudio.com
// subdomain (the classic DevOps host style, e.g.
// https://{organization}.visualstudio.com). The match is case-insensitive
// and mirrors the suffix check that ParseURL and RepositoryFromURL share.
func IsVisualStudioHost(hostname string) bool {
return strings.HasSuffix(strings.ToLower(hostname), ".visualstudio.com")
}

// URLIdentity captures the organization and optional project carried by an
// Azure DevOps URL.
type URLIdentity struct {
// Organization is the organization identified by the URL. It is empty
// only for degenerate paths (with lax parsing) or unparsable URLs.
Organization string
// Project is empty when the URL carries no project segment.
Project string
}

// invalidPathError renders like the legacy "invalid path %q" message so
// existing error-string comparisons keep passing, while Unwrap lets callers
// classify it with errors.Is(err, ErrInvalidPath).
type invalidPathError struct {
path string
}

func (e *invalidPathError) Error() string {
return fmt.Sprintf("invalid path %q", e.path)
}

func (e *invalidPathError) Unwrap() error {
return ErrInvalidPath
}

// ParseURL extracts the organization and project identity from an Azure
// DevOps URL. It understands the three canonical host styles:
//
// https://{organization}.visualstudio.com/{project}/...
// https://dev.azure.com/{organization}/{project}/...
// ssh://ssh.dev.azure.com/v3/{organization}/{project}/...
//
// With lax=false, non-Azure hosts yield an error wrapping ErrNotAzDO and
// paths without an organization segment yield an error wrapping
// ErrInvalidPath. With lax=true the parser is best-effort: any hostname is
// accepted as an organization and missing segments leave the corresponding
// field empty. Strict validation of repository paths, schemes, and configured
// hostnames stays with the callers (RepositoryFromURL, ProjectFromURL, ...).
func ParseURL(u *url.URL, lax bool) (URLIdentity, error) {
if u == nil {
return URLIdentity{}, fmt.Errorf("url must not be nil")
}
hostname := strings.ToLower(u.Hostname())
if hostname == "" {
return URLIdentity{}, fmt.Errorf("url must have a hostname")
}
parts := strings.Split(strings.Trim(u.Path, "/"), "/")

var id URLIdentity
switch {
case IsVisualStudioHost(hostname):
// {org}.visualstudio.com/{project}/... carries the organization in
// the subdomain.
id.Organization = strings.SplitN(hostname, ".", 2)[0]
if len(parts) > 0 {
id.Project = parts[0]
}
case hostname == "dev.azure.com":
// dev.azure.com/{org}/{project}/... carries segments in the path.
if len(parts) > 0 {
id.Organization = strings.ToLower(strings.TrimSpace(parts[0]))
}
if len(parts) > 1 {
id.Project = parts[1]
}
case hostname == "ssh.dev.azure.com":
// ssh.dev.azure.com/v3/{org}/{project}/.../ skips the protocol
// version segment.
if len(parts) > 1 {
id.Organization = strings.ToLower(strings.TrimSpace(parts[1]))
}
if len(parts) > 2 {
id.Project = parts[2]
}
default:
if !lax {
return URLIdentity{}, fmt.Errorf("not an Azure DevOps host %q: %w", u.Host, ErrNotAzDO)
}
id.Organization = hostname
if len(parts) > 0 {
id.Project = parts[0]
}
}

if !lax && id.Organization == "" {
return URLIdentity{}, &invalidPathError{path: u.Path}
}
return id, nil
}
Loading
Loading