Skip to content

feat: CP-955 migrate ProjectErrorsTool #3688

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Jul 23, 2025
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
5 changes: 3 additions & 2 deletions cmd/state-mcp/internal/toolregistry/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import (
type Tool struct {
mcp.Tool
Category ToolCategory
Handler func(context.Context, *primer.Values, mcp.CallToolRequest) (*mcp.CallToolResult, error)
Handler func(context.Context, *primer.Values, mcp.CallToolRequest) (*mcp.CallToolResult, error)
}

type Registry struct {
Expand All @@ -24,6 +24,7 @@ func New() *Registry {
}

r.RegisterTool(HelloWorldTool())
r.RegisterTool(ProjectErrorsTool())

return r
}
Expand Down Expand Up @@ -53,4 +54,4 @@ func (r *Registry) GetTools(requestCategories ...string) []Tool {
}
}
return result
}
}
40 changes: 38 additions & 2 deletions cmd/state-mcp/internal/toolregistry/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@ package toolregistry

import (
"context"
"fmt"
"strings"

"github.com/ActiveState/cli/internal/errs"
"github.com/ActiveState/cli/internal/primer"
"github.com/ActiveState/cli/internal/runners/hello"
"github.com/ActiveState/cli/internal/runners/mcp/projecterrors"
"github.com/ActiveState/cli/pkg/project"
"github.com/mark3labs/mcp-go/mcp"
)

Expand All @@ -22,7 +26,7 @@ func HelloWorldTool() Tool {
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}

runner := hello.New(p)
params := hello.NewParams()
params.Name = name
Expand All @@ -37,4 +41,36 @@ func HelloWorldTool() Tool {
), nil
},
}
}
}

func ProjectErrorsTool() Tool {
return Tool{
Category: ToolCategoryDebug,
Tool: mcp.NewTool(
"list_project_build_failures",
mcp.WithDescription("Retrieves all the failed builds for a specific project"),
mcp.WithString("namespace", mcp.Description("Project namespace in format 'owner/project'")),
),
Handler: func(ctx context.Context, p *primer.Values, mcpRequest mcp.CallToolRequest) (*mcp.CallToolResult, error) {
namespace, err := mcpRequest.RequireString("namespace")
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("a project in the format 'owner/project' is required: %s", errs.JoinMessage(err))), nil
}

ns, err := project.ParseNamespace(namespace)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("error parsing project namespace: %s", errs.JoinMessage(err))), nil
}

runner := projecterrors.New(p, ns)
err = runner.Run()
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("error executing GraphQL query: %s", errs.JoinMessage(err))), nil
}

return mcp.NewToolResultText(
strings.Join(p.Output().History().Print, "\n"),
), nil
},
}
}
169 changes: 169 additions & 0 deletions internal/runners/mcp/projecterrors/projecterrors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
package projecterrors

import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"

"github.com/go-openapi/strfmt"

"github.com/ActiveState/cli/internal/output"
"github.com/ActiveState/cli/internal/primer"
"github.com/ActiveState/cli/pkg/buildplan"
"github.com/ActiveState/cli/pkg/platform/authentication"
"github.com/ActiveState/cli/pkg/platform/model"
"github.com/ActiveState/cli/pkg/platform/model/buildplanner"
"github.com/ActiveState/cli/pkg/project"
)

type ProjectErrorsRunner struct {
auth *authentication.Auth
output output.Outputer
svcModel *model.SvcModel
namespace *project.Namespaced
}

func New(p *primer.Values, ns *project.Namespaced) *ProjectErrorsRunner {
return &ProjectErrorsRunner{
auth: p.Auth(),
output: p.Output(),
svcModel: p.SvcModel(),
namespace: ns,
}
}

func (runner *ProjectErrorsRunner) Run() error {
branch, err := model.DefaultBranchForProjectName(runner.namespace.Owner, runner.namespace.Project)
if err != nil {
return fmt.Errorf("error fetching default branch: %w", err)
}

bpm := buildplanner.NewBuildPlannerModel(runner.auth, runner.svcModel)
commit, err := bpm.FetchCommitNoPoll(
*branch.CommitID, runner.namespace.Owner, runner.namespace.Project, nil)
if err != nil {
return fmt.Errorf("error fetching commit: %w", err)
}

bp := commit.BuildPlan()
failedArtifacts := bp.Artifacts(buildplan.FilterFailedArtifacts())

// Check if artifacts have already been fixed by a newer revision.
wasFixed, err := CheckDependencyFixes(runner.auth, failedArtifacts)
if err != nil {
return fmt.Errorf("error checking for fixed artifacts: %w", err)
}

// Check if artifacts are failing due to missing dependencies.
isDependencyError, err := CheckDependencyErrors(failedArtifacts)
if err != nil {
return fmt.Errorf("error checking dependency errors: %w", err)
}

// Define the output structure and print each artifact's Marshalled JSON.
type ArtifactOutput struct {
Name string `json:"name"`
Version string `json:"version"`
Namespace string `json:"namespace"`
IsBuildtimeDependency bool `json:"isBuildtimeDependency"`
IsRuntimeDependency bool `json:"isRuntimeDependency"`
LogURL string `json:"logURL"`
SourceURI string `json:"sourceURI"`
WasFixed bool `json:"wasFixed"`
IsDependencyError bool `json:"isDependencyError"`
}
Comment on lines +68 to +78
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Define types at the top level (outside of the function) so they can be referenced outside of the function, and to keep the function low on boilerplate.

for _, artifact := range failedArtifacts {
jsonBytes, err := json.Marshal(ArtifactOutput{
Name: artifact.Name(),
Version: artifact.Version(),
Namespace: artifact.Ingredients[0].Namespace,
IsBuildtimeDependency: artifact.IsBuildtimeDependency,
IsRuntimeDependency: artifact.IsRuntimeDependency,
LogURL: artifact.LogURL,
SourceURI: artifact.Ingredients[0].IngredientSource.Url.String(),
WasFixed: wasFixed[artifact.ArtifactID],
IsDependencyError: isDependencyError[artifact.ArtifactID],
})
if err != nil {
return fmt.Errorf("error marshaling results: %w", err)
}
runner.output.Print(string(jsonBytes))
Comment on lines +79 to +94
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not asking for changes, just noting; Our output package handles structured output like this. But it's based on us running in either JSON or "plain" mode. ie. it affects ALL output. Not sure what this means for MCP, cause with MCP it's kind of a case by case.

Just noting it for now for awareness. Probably an area of improvement for later.

}
return nil
}

// Check whether a newer revision is available for each artifact version.
// If found, assume the issue is resolved so that a new build could be retried.
func CheckDependencyFixes(auth *authentication.Auth, failedArtifacts []*buildplan.Artifact) (map[strfmt.UUID]bool, error) {
fixed := make(map[strfmt.UUID]bool)
for _, artifact := range failedArtifacts {
// TODO: Query multiple artifacts at once to reduce API calls, improving performance.
latestRevision, err := model.GetIngredientByNameAndVersion(
artifact.Ingredients[0].Namespace, artifact.Name(), artifact.Version(), nil, auth)
if err != nil {
return nil, fmt.Errorf("error searching ingredient: %w", err)
}
fixed[artifact.ArtifactID] = artifact.Revision() < int(*latestRevision.Revision)
}
return fixed, nil
}

// Perform asynchronous checks for dependency errors to improve efficiency in large projects with multiple failures.
func CheckDependencyErrors(failedArtifacts []*buildplan.Artifact) (map[strfmt.UUID]bool, error) {
var wg sync.WaitGroup
var mu sync.Mutex
Comment on lines +117 to +118
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, now you're getting to the good stuff! ;)

var errors []error

client := &http.Client{Timeout: 30 * time.Second}

dependencyErrors := make(map[strfmt.UUID]bool)
for i := range failedArtifacts {
wg.Add(1)
go func(artifact *buildplan.Artifact) {
defer wg.Done()
depError, err := CheckWasDependencyError(client, artifact.LogURL)
if err != nil {
mu.Lock()
errors = append(errors, fmt.Errorf("error checking dependency error for %s: %w", artifact.Name(), err))
mu.Unlock()
return
}
dependencyErrors[artifact.ArtifactID] = depError
}(failedArtifacts[i])
}

wg.Wait()

if len(errors) > 0 {
return nil, fmt.Errorf("multiple errors occurred: %v", errors)
}

return dependencyErrors, nil
}

// For now, dependency errors are detected by downloading the logs and scanning for ModuleNotFoundError.
// Not perfect, but sufficient for Python and prevents sending large logs to the LLM prematurely.
// TODO: Implement a more robust solution that considers other languages. Ideally, this returns an error
// type, were 'dependency' is just one of them; this will give the caller an overview of failure categories.
func CheckWasDependencyError(client *http.Client, logURL string) (bool, error) {
resp, err := client.Get(logURL)
if err != nil {
return false, fmt.Errorf("error checking dependency error for %s: %w", logURL, err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return false, fmt.Errorf("unexpected status code %d for %s", resp.StatusCode, logURL)
}

body, err := io.ReadAll(resp.Body)
if err != nil {
return false, fmt.Errorf("error reading response body for %s: %w", logURL, err)
}

return strings.Contains(string(body), "ModuleNotFoundError"), nil
}
1 change: 1 addition & 0 deletions pkg/buildplan/raw/raw.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ type IngredientSource struct {
Namespace string `json:"namespace"`
Version string `json:"version"`
Licenses []string `json:"licenses"`
Url strfmt.URI `json:"url"`
}

type RawResolvedRequirement struct {
Expand Down
1 change: 1 addition & 0 deletions pkg/platform/api/buildplanner/request/commit.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ query ($commitID: String!, $organization: String!, $project: String!, $target: S
namespace
version
licenses
url
}
}
steps: steps {
Expand Down
1 change: 1 addition & 0 deletions pkg/platform/api/buildplanner/request/stagecommit.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ mutation ($organization: String!, $project: String!, $parentCommit: ID!, $descri
namespace
version
licenses
url
}
}
steps: steps {
Expand Down
Loading