2026-03-22 09:43:45 +00:00
|
|
|
package azure
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"encoding/base64"
|
|
|
|
|
"fmt"
|
|
|
|
|
"net/http"
|
2026-03-22 13:27:32 +02:00
|
|
|
"strings"
|
2026-03-22 09:43:45 +00:00
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/go-resty/resty/v2"
|
|
|
|
|
)
|
|
|
|
|
|
2026-03-22 13:27:32 +02:00
|
|
|
type Pipeline struct {
|
|
|
|
|
ID int `json:"id"`
|
|
|
|
|
Name string `json:"name"`
|
|
|
|
|
Folder string `json:"folder"`
|
|
|
|
|
URL string `json:"url"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type BuildDefinition struct {
|
|
|
|
|
ID int `json:"id"`
|
|
|
|
|
Name string `json:"name"`
|
|
|
|
|
Process struct {
|
|
|
|
|
Type int `json:"type"`
|
|
|
|
|
YamlFilename string `json:"yamlFilename"` // for YAML pipelines
|
|
|
|
|
} `json:"process"`
|
|
|
|
|
Variables map[string]struct {
|
|
|
|
|
Value string `json:"value"`
|
|
|
|
|
IsSecret bool `json:"isSecret"`
|
|
|
|
|
} `json:"variables"`
|
|
|
|
|
Repository struct {
|
|
|
|
|
URL string `json:"url"`
|
|
|
|
|
Name string `json:"name"`
|
|
|
|
|
} `json:"repository"`
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-22 09:43:45 +00:00
|
|
|
type Client struct {
|
|
|
|
|
client *resty.Client
|
|
|
|
|
organization string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type Repository struct {
|
|
|
|
|
ID string `json:"id"`
|
|
|
|
|
Name string `json:"name"`
|
|
|
|
|
Project struct {
|
|
|
|
|
Name string `json:"name"`
|
|
|
|
|
} `json:"project"`
|
|
|
|
|
RemoteURL string `json:"remoteUrl"`
|
|
|
|
|
SSHURL string `json:"sshUrl"`
|
|
|
|
|
WebURL string `json:"webUrl"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type RepositoriesResponse struct {
|
|
|
|
|
Count int `json:"count"`
|
|
|
|
|
Value []Repository `json:"value"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func NewClient(org, pat string) *Client {
|
|
|
|
|
c := resty.New()
|
|
|
|
|
c.SetTimeout(30 * time.Second)
|
|
|
|
|
|
|
|
|
|
// Azure DevOps uses Basic auth with empty username and PAT as password
|
|
|
|
|
auth := base64.StdEncoding.EncodeToString([]byte(":" + pat))
|
|
|
|
|
c.SetHeader("Authorization", "Basic "+auth)
|
|
|
|
|
c.SetHeader("Content-Type", "application/json")
|
|
|
|
|
|
|
|
|
|
return &Client{
|
|
|
|
|
client: c,
|
|
|
|
|
organization: org,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *Client) GetRepositories() ([]Repository, error) {
|
|
|
|
|
url := fmt.Sprintf("https://dev.azure.com/%s/_apis/git/repositories", c.organization)
|
|
|
|
|
|
|
|
|
|
var result RepositoriesResponse
|
|
|
|
|
resp, err := c.client.R().
|
|
|
|
|
SetQueryParam("api-version", "7.0").
|
|
|
|
|
SetResult(&result).
|
|
|
|
|
Get(url)
|
|
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to fetch repositories: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode() != http.StatusOK {
|
|
|
|
|
return nil, fmt.Errorf("Azure API returned status %d: %s", resp.StatusCode(), resp.String())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Handle pagination if needed (continuationToken)
|
|
|
|
|
// For simplicity, this example handles single page
|
|
|
|
|
if result.Count != len(result.Value) {
|
|
|
|
|
fmt.Printf("Warning: Repository count mismatch, expected %d got %d\n", result.Count, len(result.Value))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return result.Value, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *Client) GetAuthenticatedURL(remoteURL, pat string) string {
|
|
|
|
|
// Convert https://dev.azure.com/org/project/_git/repo
|
|
|
|
|
// to https://pat@dev.azure.com/org/project/_git/repo
|
|
|
|
|
if len(remoteURL) > 8 && remoteURL[:8] == "https://" {
|
|
|
|
|
return "https://:" + pat + "@" + remoteURL[8:]
|
|
|
|
|
}
|
|
|
|
|
return remoteURL
|
|
|
|
|
}
|
2026-03-22 13:27:32 +02:00
|
|
|
|
|
|
|
|
// GetBuildDefinitions gets pipeline definitions for a project
|
|
|
|
|
func (c *Client) GetBuildDefinitions(project string) ([]BuildDefinition, error) {
|
|
|
|
|
url := fmt.Sprintf("https://dev.azure.com/%s/%s/_apis/build/definitions", c.organization, project)
|
|
|
|
|
|
|
|
|
|
var result struct {
|
|
|
|
|
Count int `json:"count"`
|
|
|
|
|
Value []BuildDefinition `json:"value"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resp, err := c.client.R().
|
|
|
|
|
SetQueryParam("api-version", "7.0").
|
|
|
|
|
SetQueryParam("includeAll", "true").
|
|
|
|
|
SetResult(&result).
|
|
|
|
|
Get(url)
|
|
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to fetch build definitions: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode() != 200 {
|
|
|
|
|
return nil, fmt.Errorf("Azure API returned %d: %s", resp.StatusCode(), resp.String())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return result.Value, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// GetPipelineContent fetches the azure-pipelines.yml content from repository
|
|
|
|
|
func (c *Client) GetPipelineContent(repoURL, branch, filePath string) (string, error) {
|
|
|
|
|
// Azure DevOps Repos API for file content
|
|
|
|
|
// Extract project and repo name from URL
|
|
|
|
|
// URL format: https://dev.azure.com/org/project/_git/repo
|
|
|
|
|
|
|
|
|
|
// For this example, construct the items API URL
|
|
|
|
|
parts := strings.Split(repoURL, "/_git/")
|
|
|
|
|
if len(parts) != 2 {
|
|
|
|
|
return "", fmt.Errorf("cannot parse repo URL: %s", repoURL)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
base := parts[0]
|
|
|
|
|
repoName := parts[1]
|
|
|
|
|
|
|
|
|
|
url := fmt.Sprintf("%s/_apis/sourceProviders/TfsGit/filecontents?repository=%s&commitOrBranch=%s&path=%s&api-version=7.0",
|
|
|
|
|
base, repoName, branch, filePath)
|
|
|
|
|
|
|
|
|
|
resp, err := c.client.R().
|
|
|
|
|
Get(url)
|
|
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode() != 200 {
|
|
|
|
|
// Try alternative: get via Git API (Items endpoint)
|
|
|
|
|
return "", fmt.Errorf("file not found or access denied")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return string(resp.Body()), nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// GetPipelineVariables gets variables for a specific build definition
|
|
|
|
|
func (c *Client) GetPipelineVariables(project string, definitionID int) (map[string]string, error) {
|
|
|
|
|
url := fmt.Sprintf("https://dev.azure.com/%s/%s/_apis/build/definitions/%d/variables",
|
|
|
|
|
c.organization, project, definitionID)
|
|
|
|
|
|
|
|
|
|
var result struct {
|
|
|
|
|
Count int `json:"count"`
|
|
|
|
|
Value map[string]struct {
|
|
|
|
|
Value string `json:"value"`
|
|
|
|
|
IsSecret bool `json:"isSecret"`
|
|
|
|
|
} `json:"value"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resp, err := c.client.R().
|
|
|
|
|
SetQueryParam("api-version", "7.0").
|
|
|
|
|
SetResult(&result).
|
|
|
|
|
Get(url)
|
|
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode() != 200 {
|
|
|
|
|
return nil, fmt.Errorf("failed to get variables: %d", resp.StatusCode())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
vars := make(map[string]string)
|
|
|
|
|
for k, v := range result.Value {
|
|
|
|
|
vars[k] = v.Value // Note: secrets won't have values via API unless explicitly allowed
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return vars, nil
|
|
|
|
|
}
|