Skip to main content
Glama
client.go44.3 kB
package teamcity import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "regexp" "strconv" "strings" "time" "go.uber.org/zap" "github.com/itcaat/teamcity-mcp/internal/config" "github.com/itcaat/teamcity-mcp/internal/metrics" ) // Client wraps the TeamCity REST API client type Client struct { httpClient *http.Client baseURL string logger *zap.SugaredLogger cfg config.TeamCityConfig } // Project represents a TeamCity project type Project struct { ID string `json:"id"` Name string `json:"name"` Description string `json:"description"` WebURL string `json:"webUrl"` } // BuildType represents a TeamCity build configuration type BuildType struct { ID string `json:"id"` Name string `json:"name"` Description string `json:"description"` ProjectID string `json:"projectId"` Project Project `json:"project"` } // Build represents a TeamCity build type Build struct { ID int `json:"id"` Number string `json:"number"` Status string `json:"status"` State string `json:"state"` BranchName string `json:"branchName"` BuildTypeID string `json:"buildTypeId"` StartDate string `json:"startDate"` FinishDate string `json:"finishDate"` QueuedDate string `json:"queuedDate"` BuildType BuildType `json:"buildType"` } // Agent represents a TeamCity build agent type Agent struct { ID int `json:"id"` Name string `json:"name"` Connected bool `json:"connected"` Enabled bool `json:"enabled"` WebURL string `json:"webUrl"` } // Parameter represents a TeamCity build configuration parameter type Parameter struct { Name string `json:"name"` Value string `json:"value"` Type string `json:"type,omitempty"` } // BuildStep represents a TeamCity build step type BuildStep struct { ID string `json:"id"` Name string `json:"name"` Type string `json:"type"` Disabled bool `json:"disabled"` Properties map[string]string `json:"properties,omitempty"` } // VCSRoot represents a TeamCity VCS root type VCSRoot struct { ID string `json:"id"` Name string `json:"name"` VcsName string `json:"vcsName"` Properties map[string]string `json:"properties,omitempty"` } // DetailedBuildType represents a TeamCity build configuration with detailed information type DetailedBuildType struct { BuildType Parameters []Parameter `json:"parameters,omitempty"` Steps []BuildStep `json:"steps,omitempty"` VcsRoots []VCSRoot `json:"vcs-roots,omitempty"` Enabled bool `json:"enabled"` Paused bool `json:"paused"` Template bool `json:"template"` } // TestOccurrence represents a TeamCity test occurrence type TestOccurrence struct { ID string `json:"id"` Name string `json:"name"` Status string `json:"status"` Duration int `json:"duration"` Details string `json:"details,omitempty"` Href string `json:"href,omitempty"` Muted bool `json:"muted,omitempty"` } // NewClient creates a new TeamCity client func NewClient(cfg config.TeamCityConfig, logger *zap.SugaredLogger) (*Client, error) { timeout, err := time.ParseDuration(cfg.Timeout) if err != nil { return nil, fmt.Errorf("invalid timeout: %w", err) } httpClient := &http.Client{ Timeout: timeout, } return &Client{ httpClient: httpClient, baseURL: cfg.URL, logger: logger, cfg: cfg, }, nil } // makeRequest makes an authenticated HTTP request to TeamCity func (c *Client) makeRequest(ctx context.Context, method, endpoint string, body []byte) ([]byte, error) { url := c.baseURL + "/app/rest" + endpoint var reqBody io.Reader if body != nil { reqBody = bytes.NewReader(body) } req, err := http.NewRequestWithContext(ctx, method, url, reqBody) if err != nil { return nil, fmt.Errorf("creating request: %w", err) } // Set authentication if c.cfg.Token != "" { req.Header.Set("Authorization", "Bearer "+c.cfg.Token) } req.Header.Set("Accept", "application/json") if body != nil { req.Header.Set("Content-Type", "application/json") } resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("making request: %w", err) } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("reading response: %w", err) } if resp.StatusCode >= 400 { return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(respBody)) } return respBody, nil } // GetResource gets a resource by URI func (c *Client) GetResource(ctx context.Context, uri string) (interface{}, error) { start := time.Now() defer func() { metrics.RecordTeamCityRequest("get_resource", "success", time.Since(start).Seconds()) }() // Parse URI and call appropriate method // This is a simplified implementation return map[string]interface{}{ "uri": uri, "type": "resource", "content": "Resource content for " + uri, }, nil } // ListProjects lists all projects func (c *Client) ListProjects(ctx context.Context) ([]interface{}, error) { start := time.Now() defer func() { metrics.RecordTeamCityRequest("list_projects", "success", time.Since(start).Seconds()) }() respBody, err := c.makeRequest(ctx, "GET", "/projects", nil) if err != nil { return nil, fmt.Errorf("failed to get projects: %w", err) } var response struct { Project []Project `json:"project"` } if err := json.Unmarshal(respBody, &response); err != nil { return nil, fmt.Errorf("failed to parse projects response: %w", err) } result := make([]interface{}, len(response.Project)) for i, project := range response.Project { result[i] = map[string]interface{}{ "uri": fmt.Sprintf("teamcity://projects/%s", project.ID), "name": project.Name, "description": project.Description, "mimeType": "application/json", } } return result, nil } // ListBuildTypes lists all build configurations func (c *Client) ListBuildTypes(ctx context.Context) ([]interface{}, error) { start := time.Now() defer func() { metrics.RecordTeamCityRequest("list_build_types", "success", time.Since(start).Seconds()) }() respBody, err := c.makeRequest(ctx, "GET", "/buildTypes", nil) if err != nil { return nil, fmt.Errorf("failed to get build types: %w", err) } var response struct { BuildType []BuildType `json:"buildType"` } if err := json.Unmarshal(respBody, &response); err != nil { return nil, fmt.Errorf("failed to parse build types response: %w", err) } result := make([]interface{}, len(response.BuildType)) for i, bt := range response.BuildType { result[i] = map[string]interface{}{ "uri": fmt.Sprintf("teamcity://buildTypes/%s", bt.ID), "name": bt.Name, "description": bt.Description, "mimeType": "application/json", } } return result, nil } // ListBuilds lists recent builds func (c *Client) ListBuilds(ctx context.Context) ([]interface{}, error) { start := time.Now() defer func() { metrics.RecordTeamCityRequest("list_builds", "success", time.Since(start).Seconds()) }() respBody, err := c.makeRequest(ctx, "GET", "/builds?locator=count:100", nil) if err != nil { return nil, fmt.Errorf("failed to get builds: %w", err) } var response struct { Build []Build `json:"build"` } if err := json.Unmarshal(respBody, &response); err != nil { return nil, fmt.Errorf("failed to parse builds response: %w", err) } result := make([]interface{}, len(response.Build)) for i, build := range response.Build { result[i] = map[string]interface{}{ "uri": fmt.Sprintf("teamcity://builds/%d", build.ID), "name": fmt.Sprintf("Build #%s", build.Number), "description": fmt.Sprintf("Status: %s", build.Status), "mimeType": "application/json", } } return result, nil } // ListAgents lists all build agents func (c *Client) ListAgents(ctx context.Context) ([]interface{}, error) { start := time.Now() defer func() { metrics.RecordTeamCityRequest("list_agents", "success", time.Since(start).Seconds()) }() respBody, err := c.makeRequest(ctx, "GET", "/agents", nil) if err != nil { return nil, fmt.Errorf("failed to get agents: %w", err) } var response struct { Agent []Agent `json:"agent"` } if err := json.Unmarshal(respBody, &response); err != nil { return nil, fmt.Errorf("failed to parse agents response: %w", err) } result := make([]interface{}, len(response.Agent)) for i, agent := range response.Agent { result[i] = map[string]interface{}{ "uri": fmt.Sprintf("teamcity://agents/%d", agent.ID), "name": agent.Name, "description": fmt.Sprintf("Connected: %t", agent.Connected), "mimeType": "application/json", } } return result, nil } // TriggerBuild triggers a new build func (c *Client) TriggerBuild(ctx context.Context, args json.RawMessage) (string, error) { var req struct { BuildTypeID string `json:"buildTypeId"` BranchName string `json:"branchName,omitempty"` Properties map[string]string `json:"properties,omitempty"` Comment string `json:"comment,omitempty"` } if err := json.Unmarshal(args, &req); err != nil { return "", fmt.Errorf("invalid arguments: %w", err) } start := time.Now() defer func() { metrics.RecordTeamCityRequest("trigger_build", "success", time.Since(start).Seconds()) }() // Create build request buildRequest := map[string]interface{}{ "buildType": map[string]string{ "id": req.BuildTypeID, }, } if req.BranchName != "" { buildRequest["branchName"] = req.BranchName } if req.Comment != "" { buildRequest["comment"] = map[string]string{ "text": req.Comment, } } if req.Properties != nil { properties := make([]map[string]string, 0, len(req.Properties)) for key, value := range req.Properties { properties = append(properties, map[string]string{ "name": key, "value": value, }) } buildRequest["properties"] = map[string]interface{}{ "property": properties, } } reqBody, err := json.Marshal(buildRequest) if err != nil { return "", fmt.Errorf("failed to marshal build request: %w", err) } respBody, err := c.makeRequest(ctx, "POST", "/buildQueue", reqBody) if err != nil { return "", fmt.Errorf("failed to trigger build: %w", err) } var build Build if err := json.Unmarshal(respBody, &build); err != nil { return "", fmt.Errorf("failed to parse trigger response: %w", err) } return fmt.Sprintf("Build #%s queued successfully (ID: %d)", build.Number, build.ID), nil } // CancelBuild cancels a running build func (c *Client) CancelBuild(ctx context.Context, args json.RawMessage) (string, error) { var req struct { BuildID string `json:"buildId"` Comment string `json:"comment,omitempty"` } if err := json.Unmarshal(args, &req); err != nil { return "", fmt.Errorf("invalid arguments: %w", err) } start := time.Now() defer func() { metrics.RecordTeamCityRequest("cancel_build", "success", time.Since(start).Seconds()) }() buildID, err := strconv.Atoi(req.BuildID) if err != nil { return "", fmt.Errorf("invalid build ID: %w", err) } // Get build to get its number for response respBody, err := c.makeRequest(ctx, "GET", fmt.Sprintf("/builds/id:%d", buildID), nil) if err != nil { return "", fmt.Errorf("build not found: %w", err) } var build Build if err := json.Unmarshal(respBody, &build); err != nil { return "", fmt.Errorf("failed to parse build: %w", err) } // Cancel the build cancelRequest := map[string]interface{}{ "comment": req.Comment, } reqBody, err := json.Marshal(cancelRequest) if err != nil { return "", fmt.Errorf("failed to marshal cancel request: %w", err) } _, err = c.makeRequest(ctx, "POST", fmt.Sprintf("/builds/id:%d/cancelRequest", buildID), reqBody) if err != nil { return "", fmt.Errorf("failed to cancel build: %w", err) } return fmt.Sprintf("Build #%s cancelled successfully", build.Number), nil } // PinBuild pins or unpins a build func (c *Client) PinBuild(ctx context.Context, args json.RawMessage) (string, error) { var req struct { BuildID string `json:"buildId"` Pin bool `json:"pin"` Comment string `json:"comment,omitempty"` } if err := json.Unmarshal(args, &req); err != nil { return "", fmt.Errorf("invalid arguments: %w", err) } start := time.Now() defer func() { metrics.RecordTeamCityRequest("pin_build", "success", time.Since(start).Seconds()) }() buildID, err := strconv.Atoi(req.BuildID) if err != nil { return "", fmt.Errorf("invalid build ID: %w", err) } // Get build to get its number for response respBody, err := c.makeRequest(ctx, "GET", fmt.Sprintf("/builds/id:%d", buildID), nil) if err != nil { return "", fmt.Errorf("build not found: %w", err) } var build Build if err := json.Unmarshal(respBody, &build); err != nil { return "", fmt.Errorf("failed to parse build: %w", err) } // Pin or unpin the build pinRequest := map[string]interface{}{ "comment": req.Comment, } reqBody, err := json.Marshal(pinRequest) if err != nil { return "", fmt.Errorf("failed to marshal pin request: %w", err) } if req.Pin { _, err = c.makeRequest(ctx, "PUT", fmt.Sprintf("/builds/id:%d/pin", buildID), reqBody) if err != nil { return "", fmt.Errorf("failed to pin build: %w", err) } return fmt.Sprintf("Build #%s pinned successfully", build.Number), nil } else { _, err = c.makeRequest(ctx, "DELETE", fmt.Sprintf("/builds/id:%d/pin", buildID), nil) if err != nil { return "", fmt.Errorf("failed to unpin build: %w", err) } return fmt.Sprintf("Build #%s unpinned successfully", build.Number), nil } } // SetBuildTag adds or removes build tags func (c *Client) SetBuildTag(ctx context.Context, args json.RawMessage) (string, error) { var req struct { BuildID string `json:"buildId"` Tags []string `json:"tags,omitempty"` RemoveTags []string `json:"removeTags,omitempty"` } if err := json.Unmarshal(args, &req); err != nil { return "", fmt.Errorf("invalid arguments: %w", err) } start := time.Now() defer func() { metrics.RecordTeamCityRequest("set_build_tag", "success", time.Since(start).Seconds()) }() buildID, err := strconv.Atoi(req.BuildID) if err != nil { return "", fmt.Errorf("invalid build ID: %w", err) } // Get build to get its number for response respBody, err := c.makeRequest(ctx, "GET", fmt.Sprintf("/builds/id:%d", buildID), nil) if err != nil { return "", fmt.Errorf("build not found: %w", err) } var build Build if err := json.Unmarshal(respBody, &build); err != nil { return "", fmt.Errorf("failed to parse build: %w", err) } // Add tags for _, tag := range req.Tags { tagData := map[string]string{"name": tag} reqBody, err := json.Marshal(tagData) if err != nil { return "", fmt.Errorf("failed to marshal tag: %w", err) } _, err = c.makeRequest(ctx, "POST", fmt.Sprintf("/builds/id:%d/tags", buildID), reqBody) if err != nil { return "", fmt.Errorf("failed to add tag %s: %w", tag, err) } } // Remove tags for _, tag := range req.RemoveTags { _, err = c.makeRequest(ctx, "DELETE", fmt.Sprintf("/builds/id:%d/tags/%s", buildID, tag), nil) if err != nil { return "", fmt.Errorf("failed to remove tag %s: %w", tag, err) } } return fmt.Sprintf("Tags updated for build #%s", build.Number), nil } // DownloadArtifact downloads build artifacts func (c *Client) DownloadArtifact(ctx context.Context, args json.RawMessage) (string, error) { var req struct { BuildID string `json:"buildId"` ArtifactPath string `json:"artifactPath"` } if err := json.Unmarshal(args, &req); err != nil { return "", fmt.Errorf("invalid arguments: %w", err) } start := time.Now() defer func() { metrics.RecordTeamCityRequest("download_artifact", "success", time.Since(start).Seconds()) }() // This is a simplified implementation // In practice, you would stream the artifact content return fmt.Sprintf("Artifact %s from build %s download initiated", req.ArtifactPath, req.BuildID), nil } // SearchBuilds searches for builds with various filters func (c *Client) SearchBuilds(ctx context.Context, args json.RawMessage) (string, error) { var req struct { BuildTypeID string `json:"buildTypeId"` Status string `json:"status"` State string `json:"state"` Branch string `json:"branch"` Agent string `json:"agent"` User string `json:"user"` SinceBuild string `json:"sinceBuild"` SinceDate string `json:"sinceDate"` UntilDate string `json:"untilDate"` Tags []string `json:"tags"` Personal *bool `json:"personal"` Pinned *bool `json:"pinned"` Count int `json:"count"` } if err := json.Unmarshal(args, &req); err != nil { return "", fmt.Errorf("invalid arguments: %w", err) } start := time.Now() defer func() { metrics.RecordTeamCityRequest("search_builds", "success", time.Since(start).Seconds()) }() // Build query parameters params := make([]string, 0) if req.BuildTypeID != "" { params = append(params, fmt.Sprintf("buildType:%s", req.BuildTypeID)) } if req.Status != "" { params = append(params, fmt.Sprintf("status:%s", req.Status)) } if req.State != "" { params = append(params, fmt.Sprintf("state:%s", req.State)) } if req.Branch != "" { params = append(params, fmt.Sprintf("branch:%s", req.Branch)) } if req.Agent != "" { params = append(params, fmt.Sprintf("agent:%s", req.Agent)) } if req.User != "" { params = append(params, fmt.Sprintf("user:%s", req.User)) } if req.SinceBuild != "" { params = append(params, fmt.Sprintf("sinceBuild:%s", req.SinceBuild)) } if req.SinceDate != "" { params = append(params, fmt.Sprintf("sinceDate:%s", req.SinceDate)) } if req.UntilDate != "" { params = append(params, fmt.Sprintf("untilDate:%s", req.UntilDate)) } if req.Personal != nil { params = append(params, fmt.Sprintf("personal:%t", *req.Personal)) } if req.Pinned != nil { params = append(params, fmt.Sprintf("pinned:%t", *req.Pinned)) } for _, tag := range req.Tags { params = append(params, fmt.Sprintf("tag:%s", tag)) } // Set default count if not specified count := req.Count if count == 0 { count = 100 } // Build endpoint with locator endpoint := "/builds" if len(params) > 0 { locator := fmt.Sprintf("count:%d", count) for _, param := range params { locator += "," + param } endpoint += "?locator=" + locator } else { endpoint += fmt.Sprintf("?locator=count:%d", count) } respBody, err := c.makeRequest(ctx, "GET", endpoint, nil) if err != nil { return "", fmt.Errorf("failed to search builds: %w", err) } var response struct { Count int `json:"count"` Build []Build `json:"build"` } if err := json.Unmarshal(respBody, &response); err != nil { return "", fmt.Errorf("failed to parse builds response: %w", err) } // Format response result := fmt.Sprintf("Found %d builds:\n\n", response.Count) for _, build := range response.Build { result += fmt.Sprintf("Build #%s (ID: %d)\n", build.Number, build.ID) result += fmt.Sprintf(" Status: %s\n", build.Status) result += fmt.Sprintf(" State: %s\n", build.State) result += fmt.Sprintf(" Build Type: %s (%s)\n", build.BuildType.Name, build.BuildTypeID) if build.BranchName != "" { result += fmt.Sprintf(" Branch: %s\n", build.BranchName) } // Enhanced time information with duration calculation if build.QueuedDate != "" { result += fmt.Sprintf(" Queued: %s\n", c.formatTeamCityDate(build.QueuedDate)) } if build.StartDate != "" { result += fmt.Sprintf(" Started: %s\n", c.formatTeamCityDate(build.StartDate)) } if build.FinishDate != "" { result += fmt.Sprintf(" Finished: %s\n", c.formatTeamCityDate(build.FinishDate)) } // Calculate and display durations if build.QueuedDate != "" && build.StartDate != "" { if queueTime := c.calculateDuration(build.QueuedDate, build.StartDate); queueTime != "" { result += fmt.Sprintf(" Queue Time: %s\n", queueTime) } } if build.StartDate != "" && build.FinishDate != "" { if buildTime := c.calculateDuration(build.StartDate, build.FinishDate); buildTime != "" { result += fmt.Sprintf(" Build Time: %s\n", buildTime) } } if build.QueuedDate != "" && build.FinishDate != "" { if totalTime := c.calculateDuration(build.QueuedDate, build.FinishDate); totalTime != "" { result += fmt.Sprintf(" Total Time: %s\n", totalTime) } } result += "\n" } if response.Count == 0 { result = "No builds found matching the specified criteria." } return result, nil } // formatTeamCityDate formats TeamCity date string to a more readable format func (c *Client) formatTeamCityDate(tcDate string) string { // TeamCity format: 20241226T143022+0300 if tcDate == "" { return "" } // Parse TeamCity date format t, err := time.Parse("20060102T150405-0700", tcDate) if err != nil { // Try alternative format without timezone t, err = time.Parse("20060102T150405", tcDate) if err != nil { // If parsing fails, return original return tcDate } } // Return in more readable format return t.Format("2006-01-02 15:04:05") } // calculateDuration calculates duration between two TeamCity date strings func (c *Client) calculateDuration(startDate, endDate string) string { if startDate == "" || endDate == "" { return "" } // Parse start date start, err := time.Parse("20060102T150405-0700", startDate) if err != nil { start, err = time.Parse("20060102T150405", startDate) if err != nil { return "" } } // Parse end date end, err := time.Parse("20060102T150405-0700", endDate) if err != nil { end, err = time.Parse("20060102T150405", endDate) if err != nil { return "" } } duration := end.Sub(start) // Format duration in human-readable format if duration < 0 { return "" } if duration < time.Minute { return fmt.Sprintf("%ds", int(duration.Seconds())) } else if duration < time.Hour { minutes := int(duration.Minutes()) seconds := int(duration.Seconds()) % 60 if seconds == 0 { return fmt.Sprintf("%dm", minutes) } return fmt.Sprintf("%dm %ds", minutes, seconds) } else { hours := int(duration.Hours()) minutes := int(duration.Minutes()) % 60 if minutes == 0 { return fmt.Sprintf("%dh", hours) } return fmt.Sprintf("%dh %dm", hours, minutes) } } // FetchBuildLog fetches the build log for a specific build func (c *Client) FetchBuildLog(ctx context.Context, args json.RawMessage) (string, error) { var req struct { BuildID string `json:"buildId"` Plain *bool `json:"plain,omitempty"` Archived *bool `json:"archived,omitempty"` DateFormat string `json:"dateFormat,omitempty"` MaxLines *int `json:"maxLines,omitempty"` FilterPattern string `json:"filterPattern,omitempty"` Severity string `json:"severity,omitempty"` TailLines *int `json:"tailLines,omitempty"` } if err := json.Unmarshal(args, &req); err != nil { return "", fmt.Errorf("invalid arguments: %w", err) } if req.BuildID == "" { return "", fmt.Errorf("buildId is required") } // Validate severity if provided if req.Severity != "" { validSeverities := map[string]bool{ "error": true, "warning": true, "info": true, } if !validSeverities[strings.ToLower(req.Severity)] { return "", fmt.Errorf("invalid severity: must be 'error', 'warning', or 'info'") } } start := time.Now() defer func() { metrics.RecordTeamCityRequest("fetch_build_log", "success", time.Since(start).Seconds()) }() // Build the log download URL with parameters endpoint := fmt.Sprintf("/downloadBuildLog.html?buildId=%s", req.BuildID) // Add query parameters based on the request params := make([]string, 0) // Default to plain=true unless explicitly set to false plain := true if req.Plain != nil { plain = *req.Plain } if plain { params = append(params, "plain=true") } if req.Archived != nil && *req.Archived { params = append(params, "archived=true") } if req.DateFormat != "" { // URL encode the date format parameter params = append(params, fmt.Sprintf("dateFormat=%s", req.DateFormat)) } // Add parameters to endpoint if len(params) > 0 { endpoint += "&" + strings.Join(params, "&") } // Make the request using the custom endpoint (not REST API) url := c.baseURL + endpoint reqObj, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return "", fmt.Errorf("creating request: %w", err) } // Set authentication if c.cfg.Token != "" { reqObj.Header.Set("Authorization", "Bearer "+c.cfg.Token) } resp, err := c.httpClient.Do(reqObj) if err != nil { return "", fmt.Errorf("making request: %w", err) } defer resp.Body.Close() if resp.StatusCode >= 400 { body, _ := io.ReadAll(resp.Body) return "", fmt.Errorf("API error %d: %s", resp.StatusCode, string(body)) } // Read the response body respBody, err := io.ReadAll(resp.Body) if err != nil { return "", fmt.Errorf("reading response: %w", err) } // If archived, we get binary data - indicate this in the response if req.Archived != nil && *req.Archived { return fmt.Sprintf("Build log for build %s downloaded as archive (%d bytes). Archive content is binary data.", req.BuildID, len(respBody)), nil } // For plain text logs, apply filtering logContent := string(respBody) lines := strings.Split(logContent, "\n") totalLines := len(lines) // Apply filters filteredLines := c.applyBuildLogFilters(lines, req.FilterPattern, req.Severity) // Apply tail if requested if req.TailLines != nil && *req.TailLines > 0 { tailCount := *req.TailLines if tailCount < len(filteredLines) { filteredLines = filteredLines[len(filteredLines)-tailCount:] } } // Apply max lines limit if req.MaxLines != nil && *req.MaxLines > 0 { maxLines := *req.MaxLines if maxLines < len(filteredLines) { filteredLines = filteredLines[:maxLines] } } // Build result result := fmt.Sprintf("Build log for build %s\n", req.BuildID) result += fmt.Sprintf("Total lines: %d", totalLines) if req.FilterPattern != "" || req.Severity != "" || req.TailLines != nil { result += fmt.Sprintf(", Filtered lines: %d", len(filteredLines)) } result += fmt.Sprintf(", Showing: %d lines\n\n", len(filteredLines)) if len(filteredLines) > 0 { result += strings.Join(filteredLines, "\n") } else { result += "(No lines match the specified filters)" } return result, nil } // applyBuildLogFilters applies pattern and severity filters to log lines func (c *Client) applyBuildLogFilters(lines []string, pattern string, severity string) []string { filtered := lines // Apply pattern filter if pattern != "" { matched := make([]string, 0) // Compile regex pattern re, err := regexp.Compile(pattern) if err != nil { // If regex compilation fails, treat as literal string search for _, line := range filtered { if strings.Contains(line, pattern) { matched = append(matched, line) } } } else { for _, line := range filtered { if re.MatchString(line) { matched = append(matched, line) } } } filtered = matched } // Apply severity filter if severity != "" { matched := make([]string, 0) severityLower := strings.ToLower(severity) // Common patterns for different severity levels errorPatterns := []string{"error", "fail", "exception", "fatal", "[e]", "[error]"} warningPatterns := []string{"warn", "warning", "[w]", "[warn]"} var patterns []string switch severityLower { case "error": patterns = errorPatterns case "warning": patterns = warningPatterns case "info": // For info, we exclude errors and warnings for _, line := range filtered { lineLower := strings.ToLower(line) isErrorOrWarning := false for _, p := range append(errorPatterns, warningPatterns...) { if strings.Contains(lineLower, p) { isErrorOrWarning = true break } } if !isErrorOrWarning && strings.TrimSpace(line) != "" { matched = append(matched, line) } } filtered = matched return filtered } // For error and warning filters for _, line := range filtered { lineLower := strings.ToLower(line) for _, p := range patterns { if strings.Contains(lineLower, p) { matched = append(matched, line) break } } } filtered = matched } return filtered } // SearchBuildConfigurations searches for build configurations with comprehensive filters including parameters, steps, and VCS roots func (c *Client) SearchBuildConfigurations(ctx context.Context, args json.RawMessage) (string, error) { var req struct { // Basic filters ProjectID string `json:"projectId"` Name string `json:"name"` Enabled *bool `json:"enabled"` Paused *bool `json:"paused"` Template *bool `json:"template"` Count int `json:"count"` // Advanced filters for detailed search ParameterName string `json:"parameterName"` ParameterValue string `json:"parameterValue"` StepType string `json:"stepType"` StepName string `json:"stepName"` VcsType string `json:"vcsType"` IncludeDetails bool `json:"includeDetails"` // Whether to fetch detailed info } if err := json.Unmarshal(args, &req); err != nil { return "", fmt.Errorf("invalid arguments: %w", err) } start := time.Now() defer func() { metrics.RecordTeamCityRequest("search_build_configurations", "success", time.Since(start).Seconds()) }() // First, get basic build configurations matching basic criteria basicConfigs, err := c.getBasicBuildConfigurations(ctx, req) if err != nil { return "", fmt.Errorf("failed to get basic configurations: %w", err) } var matchingConfigs []DetailedBuildType // For each configuration, check detailed criteria if requested for _, config := range basicConfigs { if req.IncludeDetails || req.ParameterName != "" || req.ParameterValue != "" || req.StepType != "" || req.StepName != "" || req.VcsType != "" { detailed, err := c.getBuildConfigurationDetails(ctx, config.ID) if err != nil { c.logger.Warn("Failed to get details for build configuration", "id", config.ID, "error", err) continue } // Apply detailed filters if c.matchesDetailedCriteria(detailed, req) { matchingConfigs = append(matchingConfigs, *detailed) } } else { // If no detailed criteria, just convert basic to detailed matchingConfigs = append(matchingConfigs, DetailedBuildType{ BuildType: config, }) } } // Format response return c.formatDetailedSearchResults(matchingConfigs, req.IncludeDetails), nil } // getBasicBuildConfigurations gets configurations using basic filters func (c *Client) getBasicBuildConfigurations(ctx context.Context, req struct { ProjectID string `json:"projectId"` Name string `json:"name"` Enabled *bool `json:"enabled"` Paused *bool `json:"paused"` Template *bool `json:"template"` Count int `json:"count"` ParameterName string `json:"parameterName"` ParameterValue string `json:"parameterValue"` StepType string `json:"stepType"` StepName string `json:"stepName"` VcsType string `json:"vcsType"` IncludeDetails bool `json:"includeDetails"` }) ([]BuildType, error) { // Build query parameters params := make([]string, 0) if req.ProjectID != "" { params = append(params, fmt.Sprintf("project:%s", req.ProjectID)) } if req.Name != "" { params = append(params, fmt.Sprintf("name:%s", req.Name)) } if req.Enabled != nil { params = append(params, fmt.Sprintf("enabled:%t", *req.Enabled)) } if req.Paused != nil { params = append(params, fmt.Sprintf("paused:%t", *req.Paused)) } if req.Template != nil { params = append(params, fmt.Sprintf("template:%t", *req.Template)) } // Set default count if not specified count := req.Count if count == 0 { count = 100 } // Build endpoint with locator endpoint := "/buildTypes" if len(params) > 0 { locator := fmt.Sprintf("count:%d", count) for _, param := range params { locator += "," + param } endpoint += "?locator=" + locator } else { endpoint += fmt.Sprintf("?locator=count:%d", count) } respBody, err := c.makeRequest(ctx, "GET", endpoint, nil) if err != nil { return nil, fmt.Errorf("failed to search build configurations: %w", err) } var response struct { Count int `json:"count"` BuildType []BuildType `json:"buildType"` } if err := json.Unmarshal(respBody, &response); err != nil { return nil, fmt.Errorf("failed to parse build configurations response: %w", err) } return response.BuildType, nil } // getBuildConfigurationDetails gets detailed information for a specific build configuration func (c *Client) getBuildConfigurationDetails(ctx context.Context, buildTypeID string) (*DetailedBuildType, error) { // Get basic build type info, excluding parameters/steps/vcs-roots since we fetch them separately respBody, err := c.makeRequest(ctx, "GET", fmt.Sprintf("/buildTypes/id:%s?fields=id,name,projectName,projectId,href,webUrl,enabled,paused,template", buildTypeID), nil) if err != nil { return nil, fmt.Errorf("failed to get build type details: %w", err) } var buildType DetailedBuildType if err := json.Unmarshal(respBody, &buildType); err != nil { return nil, fmt.Errorf("failed to parse build type details: %w", err) } // Get parameters paramResp, err := c.makeRequest(ctx, "GET", fmt.Sprintf("/buildTypes/id:%s/parameters", buildTypeID), nil) if err != nil { c.logger.Warn("Failed to get parameters", "buildTypeId", buildTypeID, "error", err) } else { var paramResponse struct { Property []Parameter `json:"property"` } if err := json.Unmarshal(paramResp, &paramResponse); err == nil { buildType.Parameters = paramResponse.Property } } // Get build steps stepsResp, err := c.makeRequest(ctx, "GET", fmt.Sprintf("/buildTypes/id:%s/steps", buildTypeID), nil) if err != nil { c.logger.Warn("Failed to get steps", "buildTypeId", buildTypeID, "error", err) } else { var stepsResponse struct { Step []BuildStep `json:"step"` } if err := json.Unmarshal(stepsResp, &stepsResponse); err == nil { buildType.Steps = stepsResponse.Step } } // Get VCS roots vcsResp, err := c.makeRequest(ctx, "GET", fmt.Sprintf("/buildTypes/id:%s/vcs-root-entries", buildTypeID), nil) if err != nil { c.logger.Warn("Failed to get VCS roots", "buildTypeId", buildTypeID, "error", err) } else { var vcsResponse struct { VcsRootEntry []struct { VcsRoot VCSRoot `json:"vcs-root"` } `json:"vcs-root-entry"` } if err := json.Unmarshal(vcsResp, &vcsResponse); err == nil { for _, entry := range vcsResponse.VcsRootEntry { buildType.VcsRoots = append(buildType.VcsRoots, entry.VcsRoot) } } } return &buildType, nil } // matchesDetailedCriteria checks if a configuration matches detailed search criteria func (c *Client) matchesDetailedCriteria(config *DetailedBuildType, req struct { ProjectID string `json:"projectId"` Name string `json:"name"` Enabled *bool `json:"enabled"` Paused *bool `json:"paused"` Template *bool `json:"template"` Count int `json:"count"` ParameterName string `json:"parameterName"` ParameterValue string `json:"parameterValue"` StepType string `json:"stepType"` StepName string `json:"stepName"` VcsType string `json:"vcsType"` IncludeDetails bool `json:"includeDetails"` }) bool { // Check parameter criteria if req.ParameterName != "" || req.ParameterValue != "" { paramMatch := false for _, param := range config.Parameters { nameMatch := req.ParameterName == "" || strings.Contains(strings.ToLower(param.Name), strings.ToLower(req.ParameterName)) valueMatch := req.ParameterValue == "" || strings.Contains(strings.ToLower(param.Value), strings.ToLower(req.ParameterValue)) if nameMatch && valueMatch { paramMatch = true break } } if !paramMatch { return false } } // Check step criteria if req.StepType != "" || req.StepName != "" { stepMatch := false for _, step := range config.Steps { typeMatch := req.StepType == "" || strings.Contains(strings.ToLower(step.Type), strings.ToLower(req.StepType)) nameMatch := req.StepName == "" || strings.Contains(strings.ToLower(step.Name), strings.ToLower(req.StepName)) if typeMatch && nameMatch { stepMatch = true break } } if !stepMatch { return false } } // Check VCS criteria if req.VcsType != "" { vcsMatch := false for _, vcs := range config.VcsRoots { if strings.Contains(strings.ToLower(vcs.VcsName), strings.ToLower(req.VcsType)) { vcsMatch = true break } } if !vcsMatch { return false } } return true } // formatDetailedSearchResults formats the search results func (c *Client) formatDetailedSearchResults(configs []DetailedBuildType, includeDetails bool) string { if len(configs) == 0 { return "No build configurations found matching the specified criteria." } result := fmt.Sprintf("Found %d build configurations:\n\n", len(configs)) for _, config := range configs { result += fmt.Sprintf("Configuration: %s (%s)\n", config.Name, config.ID) result += fmt.Sprintf(" Project: %s (%s)\n", config.Project.Name, config.ProjectID) if config.Description != "" { result += fmt.Sprintf(" Description: %s\n", config.Description) } if includeDetails { // Add parameters if len(config.Parameters) > 0 { result += " Parameters:\n" for _, param := range config.Parameters { result += fmt.Sprintf(" %s = %s\n", param.Name, param.Value) } } // Add steps if len(config.Steps) > 0 { result += " Build Steps:\n" for i, step := range config.Steps { status := "" if step.Disabled { status = " (disabled)" } result += fmt.Sprintf(" %d. %s [%s]%s\n", i+1, step.Name, step.Type, status) } } // Add VCS roots if len(config.VcsRoots) > 0 { result += " VCS Roots:\n" for _, vcs := range config.VcsRoots { result += fmt.Sprintf(" %s (%s)\n", vcs.Name, vcs.VcsName) } } } result += "\n" } return result } // GetTestFailures returns failing tests for a specific build func (c *Client) GetTestFailures(ctx context.Context, args json.RawMessage) (string, error) { var req struct { BuildID string `json:"buildId"` } if err := json.Unmarshal(args, &req); err != nil { return "", fmt.Errorf("invalid arguments: %w", err) } if req.BuildID == "" { return "", fmt.Errorf("buildId is required") } start := time.Now() defer func() { metrics.RecordTeamCityRequest("get_test_failures", "success", time.Since(start).Seconds()) }() endpoint := fmt.Sprintf("/testOccurrences?locator=build:(id:%s),status:FAILURE", req.BuildID) respBody, err := c.makeRequest(ctx, "GET", endpoint, nil) if err != nil { return "", fmt.Errorf("failed to get test failures: %w", err) } var response struct { Count int `json:"count"` TestOccurrence []struct { Name string `json:"name"` Status string `json:"status"` Duration int `json:"duration"` Message string `json:"details"` } `json:"testOccurrence"` } if err := json.Unmarshal(respBody, &response); err != nil { return "", fmt.Errorf("failed to parse test failures response: %w", err) } if response.Count == 0 { return "No failing tests found for this build.", nil } result := fmt.Sprintf("%d failing tests:\n", response.Count) for _, test := range response.TestOccurrence { result += fmt.Sprintf("- %s (duration: %d ms)", test.Name, test.Duration) if test.Message != "" { result += fmt.Sprintf(": %s", test.Message) } result += "\n" } return result, nil } // GetTestResults returns test results for a specific build with optional filtering func (c *Client) GetTestResults(ctx context.Context, args json.RawMessage) (string, error) { var req struct { BuildID string `json:"buildId"` Status string `json:"status,omitempty"` IncludeDetails bool `json:"includeDetails,omitempty"` Count int `json:"count,omitempty"` } if err := json.Unmarshal(args, &req); err != nil { return "", fmt.Errorf("invalid arguments: %w", err) } if req.BuildID == "" { return "", fmt.Errorf("buildId is required") } start := time.Now() defer func() { metrics.RecordTeamCityRequest("get_test_results", "success", time.Since(start).Seconds()) }() // Build the locator string (similar to GetTestFailures) locator := fmt.Sprintf("build:(id:%s)", req.BuildID) if req.Status != "" { locator += fmt.Sprintf(",status:%s", req.Status) } // Set default count if not specified count := req.Count if count == 0 { count = 100 } locator += fmt.Sprintf(",count:%d", count) endpoint := fmt.Sprintf("/testOccurrences?locator=%s", locator) // Add fields parameter when details are requested if req.IncludeDetails { endpoint += "&fields=testOccurrence(id,name,status,duration,muted,details)" } c.logger.Debug("Fetching test results", "endpoint", endpoint, "buildId", req.BuildID) respBody, err := c.makeRequest(ctx, "GET", endpoint, nil) if err != nil { return "", fmt.Errorf("failed to get test results: %w", err) } c.logger.Debug("Received test results response", "bodyLength", len(respBody)) // First, try to parse the response to see what structure we have var rawResponse map[string]interface{} if err := json.Unmarshal(respBody, &rawResponse); err != nil { c.logger.Error("Failed to parse test results as map", "error", err, "body", string(respBody)) return "", fmt.Errorf("failed to parse test results response: %w", err) } c.logger.Debug("Raw response keys", "keys", fmt.Sprintf("%v", getKeys(rawResponse))) var response struct { Count int `json:"count"` TestOccurrence []TestOccurrence `json:"testOccurrence"` } if err := json.Unmarshal(respBody, &response); err != nil { c.logger.Error("Failed to parse test results", "error", err, "body", string(respBody)) return "", fmt.Errorf("failed to parse test results response: %w", err) } c.logger.Debug("Parsed test results", "count", response.Count, "occurrences", len(response.TestOccurrence)) // Check if we actually have no tests (use occurrence length, not count field) if len(response.TestOccurrence) == 0 { statusMsg := "any status" if req.Status != "" { statusMsg = fmt.Sprintf("status: %s", req.Status) } return fmt.Sprintf("No tests found for build %s with %s.", req.BuildID, statusMsg), nil } // Format the results (use actual test count, not the count field which may be missing) testCount := len(response.TestOccurrence) result := fmt.Sprintf("Found %d test(s) for build %s", testCount, req.BuildID) if req.Status != "" { result += fmt.Sprintf(" (status: %s)", req.Status) } result += ":\n\n" // Group tests by status for better readability statusGroups := make(map[string][]TestOccurrence) for _, test := range response.TestOccurrence { statusGroups[test.Status] = append(statusGroups[test.Status], test) } // Display tests grouped by status for status, tests := range statusGroups { result += fmt.Sprintf("%s (%d):\n", status, len(tests)) for _, test := range tests { result += fmt.Sprintf(" - %s", test.Name) // Format duration if test.Duration > 0 { if test.Duration < 1000 { result += fmt.Sprintf(" (%d ms)", test.Duration) } else { result += fmt.Sprintf(" (%.2f s)", float64(test.Duration)/1000.0) } } // Show muted status if test.Muted { result += " [MUTED]" } result += "\n" // Add details if requested and available if req.IncludeDetails && test.Details != "" { // Indent the details detailLines := strings.Split(test.Details, "\n") for _, line := range detailLines { if line != "" { result += fmt.Sprintf(" %s\n", line) } } } } result += "\n" } return result, nil } // getKeys returns the keys of a map for debugging func getKeys(m map[string]interface{}) []string { keys := make([]string, 0, len(m)) for k := range m { keys = append(keys, k) } return keys }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/itcaat/teamcity-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server