Skip to content

Commit 1b6ba39

Browse files
Merge pull request #3688 from ActiveState/samueld/CP-955
feat: CP-955 migrate ProjectErrorsTool
2 parents af59800 + 3a07e24 commit 1b6ba39

File tree

6 files changed

+213
-4
lines changed

6 files changed

+213
-4
lines changed

cmd/state-mcp/internal/toolregistry/registry.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import (
1111
type Tool struct {
1212
mcp.Tool
1313
Category ToolCategory
14-
Handler func(context.Context, *primer.Values, mcp.CallToolRequest) (*mcp.CallToolResult, error)
14+
Handler func(context.Context, *primer.Values, mcp.CallToolRequest) (*mcp.CallToolResult, error)
1515
}
1616

1717
type Registry struct {
@@ -24,6 +24,7 @@ func New() *Registry {
2424
}
2525

2626
r.RegisterTool(HelloWorldTool())
27+
r.RegisterTool(ProjectErrorsTool())
2728

2829
return r
2930
}
@@ -53,4 +54,4 @@ func (r *Registry) GetTools(requestCategories ...string) []Tool {
5354
}
5455
}
5556
return result
56-
}
57+
}

cmd/state-mcp/internal/toolregistry/tools.go

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,14 @@ package toolregistry
22

33
import (
44
"context"
5+
"fmt"
56
"strings"
67

8+
"github.com/ActiveState/cli/internal/errs"
79
"github.com/ActiveState/cli/internal/primer"
810
"github.com/ActiveState/cli/internal/runners/hello"
11+
"github.com/ActiveState/cli/internal/runners/mcp/projecterrors"
12+
"github.com/ActiveState/cli/pkg/project"
913
"github.com/mark3labs/mcp-go/mcp"
1014
)
1115

@@ -22,7 +26,7 @@ func HelloWorldTool() Tool {
2226
if err != nil {
2327
return mcp.NewToolResultError(err.Error()), nil
2428
}
25-
29+
2630
runner := hello.New(p)
2731
params := hello.NewParams()
2832
params.Name = name
@@ -37,4 +41,36 @@ func HelloWorldTool() Tool {
3741
), nil
3842
},
3943
}
40-
}
44+
}
45+
46+
func ProjectErrorsTool() Tool {
47+
return Tool{
48+
Category: ToolCategoryDebug,
49+
Tool: mcp.NewTool(
50+
"list_project_build_failures",
51+
mcp.WithDescription("Retrieves all the failed builds for a specific project"),
52+
mcp.WithString("namespace", mcp.Description("Project namespace in format 'owner/project'")),
53+
),
54+
Handler: func(ctx context.Context, p *primer.Values, mcpRequest mcp.CallToolRequest) (*mcp.CallToolResult, error) {
55+
namespace, err := mcpRequest.RequireString("namespace")
56+
if err != nil {
57+
return mcp.NewToolResultError(fmt.Sprintf("a project in the format 'owner/project' is required: %s", errs.JoinMessage(err))), nil
58+
}
59+
60+
ns, err := project.ParseNamespace(namespace)
61+
if err != nil {
62+
return mcp.NewToolResultError(fmt.Sprintf("error parsing project namespace: %s", errs.JoinMessage(err))), nil
63+
}
64+
65+
runner := projecterrors.New(p, ns)
66+
err = runner.Run()
67+
if err != nil {
68+
return mcp.NewToolResultError(fmt.Sprintf("error executing GraphQL query: %s", errs.JoinMessage(err))), nil
69+
}
70+
71+
return mcp.NewToolResultText(
72+
strings.Join(p.Output().History().Print, "\n"),
73+
), nil
74+
},
75+
}
76+
}
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
package projecterrors
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"io"
7+
"net/http"
8+
"strings"
9+
"sync"
10+
"time"
11+
12+
"github.com/go-openapi/strfmt"
13+
14+
"github.com/ActiveState/cli/internal/output"
15+
"github.com/ActiveState/cli/internal/primer"
16+
"github.com/ActiveState/cli/pkg/buildplan"
17+
"github.com/ActiveState/cli/pkg/platform/authentication"
18+
"github.com/ActiveState/cli/pkg/platform/model"
19+
"github.com/ActiveState/cli/pkg/platform/model/buildplanner"
20+
"github.com/ActiveState/cli/pkg/project"
21+
)
22+
23+
type ProjectErrorsRunner struct {
24+
auth *authentication.Auth
25+
output output.Outputer
26+
svcModel *model.SvcModel
27+
namespace *project.Namespaced
28+
}
29+
30+
func New(p *primer.Values, ns *project.Namespaced) *ProjectErrorsRunner {
31+
return &ProjectErrorsRunner{
32+
auth: p.Auth(),
33+
output: p.Output(),
34+
svcModel: p.SvcModel(),
35+
namespace: ns,
36+
}
37+
}
38+
39+
func (runner *ProjectErrorsRunner) Run() error {
40+
branch, err := model.DefaultBranchForProjectName(runner.namespace.Owner, runner.namespace.Project)
41+
if err != nil {
42+
return fmt.Errorf("error fetching default branch: %w", err)
43+
}
44+
45+
bpm := buildplanner.NewBuildPlannerModel(runner.auth, runner.svcModel)
46+
commit, err := bpm.FetchCommitNoPoll(
47+
*branch.CommitID, runner.namespace.Owner, runner.namespace.Project, nil)
48+
if err != nil {
49+
return fmt.Errorf("error fetching commit: %w", err)
50+
}
51+
52+
bp := commit.BuildPlan()
53+
failedArtifacts := bp.Artifacts(buildplan.FilterFailedArtifacts())
54+
55+
// Check if artifacts have already been fixed by a newer revision.
56+
wasFixed, err := CheckDependencyFixes(runner.auth, failedArtifacts)
57+
if err != nil {
58+
return fmt.Errorf("error checking for fixed artifacts: %w", err)
59+
}
60+
61+
// Check if artifacts are failing due to missing dependencies.
62+
isDependencyError, err := CheckDependencyErrors(failedArtifacts)
63+
if err != nil {
64+
return fmt.Errorf("error checking dependency errors: %w", err)
65+
}
66+
67+
// Define the output structure and print each artifact's Marshalled JSON.
68+
type ArtifactOutput struct {
69+
Name string `json:"name"`
70+
Version string `json:"version"`
71+
Namespace string `json:"namespace"`
72+
IsBuildtimeDependency bool `json:"isBuildtimeDependency"`
73+
IsRuntimeDependency bool `json:"isRuntimeDependency"`
74+
LogURL string `json:"logURL"`
75+
SourceURI string `json:"sourceURI"`
76+
WasFixed bool `json:"wasFixed"`
77+
IsDependencyError bool `json:"isDependencyError"`
78+
}
79+
for _, artifact := range failedArtifacts {
80+
jsonBytes, err := json.Marshal(ArtifactOutput{
81+
Name: artifact.Name(),
82+
Version: artifact.Version(),
83+
Namespace: artifact.Ingredients[0].Namespace,
84+
IsBuildtimeDependency: artifact.IsBuildtimeDependency,
85+
IsRuntimeDependency: artifact.IsRuntimeDependency,
86+
LogURL: artifact.LogURL,
87+
SourceURI: artifact.Ingredients[0].IngredientSource.Url.String(),
88+
WasFixed: wasFixed[artifact.ArtifactID],
89+
IsDependencyError: isDependencyError[artifact.ArtifactID],
90+
})
91+
if err != nil {
92+
return fmt.Errorf("error marshaling results: %w", err)
93+
}
94+
runner.output.Print(string(jsonBytes))
95+
}
96+
return nil
97+
}
98+
99+
// Check whether a newer revision is available for each artifact version.
100+
// If found, assume the issue is resolved so that a new build could be retried.
101+
func CheckDependencyFixes(auth *authentication.Auth, failedArtifacts []*buildplan.Artifact) (map[strfmt.UUID]bool, error) {
102+
fixed := make(map[strfmt.UUID]bool)
103+
for _, artifact := range failedArtifacts {
104+
// TODO: Query multiple artifacts at once to reduce API calls, improving performance.
105+
latestRevision, err := model.GetIngredientByNameAndVersion(
106+
artifact.Ingredients[0].Namespace, artifact.Name(), artifact.Version(), nil, auth)
107+
if err != nil {
108+
return nil, fmt.Errorf("error searching ingredient: %w", err)
109+
}
110+
fixed[artifact.ArtifactID] = artifact.Revision() < int(*latestRevision.Revision)
111+
}
112+
return fixed, nil
113+
}
114+
115+
// Perform asynchronous checks for dependency errors to improve efficiency in large projects with multiple failures.
116+
func CheckDependencyErrors(failedArtifacts []*buildplan.Artifact) (map[strfmt.UUID]bool, error) {
117+
var wg sync.WaitGroup
118+
var mu sync.Mutex
119+
var errors []error
120+
121+
client := &http.Client{Timeout: 30 * time.Second}
122+
123+
dependencyErrors := make(map[strfmt.UUID]bool)
124+
for i := range failedArtifacts {
125+
wg.Add(1)
126+
go func(artifact *buildplan.Artifact) {
127+
defer wg.Done()
128+
depError, err := CheckWasDependencyError(client, artifact.LogURL)
129+
if err != nil {
130+
mu.Lock()
131+
errors = append(errors, fmt.Errorf("error checking dependency error for %s: %w", artifact.Name(), err))
132+
mu.Unlock()
133+
return
134+
}
135+
dependencyErrors[artifact.ArtifactID] = depError
136+
}(failedArtifacts[i])
137+
}
138+
139+
wg.Wait()
140+
141+
if len(errors) > 0 {
142+
return nil, fmt.Errorf("multiple errors occurred: %v", errors)
143+
}
144+
145+
return dependencyErrors, nil
146+
}
147+
148+
// For now, dependency errors are detected by downloading the logs and scanning for ModuleNotFoundError.
149+
// Not perfect, but sufficient for Python and prevents sending large logs to the LLM prematurely.
150+
// TODO: Implement a more robust solution that considers other languages. Ideally, this returns an error
151+
// type, were 'dependency' is just one of them; this will give the caller an overview of failure categories.
152+
func CheckWasDependencyError(client *http.Client, logURL string) (bool, error) {
153+
resp, err := client.Get(logURL)
154+
if err != nil {
155+
return false, fmt.Errorf("error checking dependency error for %s: %w", logURL, err)
156+
}
157+
defer resp.Body.Close()
158+
159+
if resp.StatusCode != http.StatusOK {
160+
return false, fmt.Errorf("unexpected status code %d for %s", resp.StatusCode, logURL)
161+
}
162+
163+
body, err := io.ReadAll(resp.Body)
164+
if err != nil {
165+
return false, fmt.Errorf("error reading response body for %s: %w", logURL, err)
166+
}
167+
168+
return strings.Contains(string(body), "ModuleNotFoundError"), nil
169+
}

pkg/buildplan/raw/raw.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ type IngredientSource struct {
8989
Namespace string `json:"namespace"`
9090
Version string `json:"version"`
9191
Licenses []string `json:"licenses"`
92+
Url strfmt.URI `json:"url"`
9293
}
9394

9495
type RawResolvedRequirement struct {

pkg/platform/api/buildplanner/request/commit.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ query ($commitID: String!, $organization: String!, $project: String!, $target: S
6363
namespace
6464
version
6565
licenses
66+
url
6667
}
6768
}
6869
steps: steps {

pkg/platform/api/buildplanner/request/stagecommit.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ mutation ($organization: String!, $project: String!, $parentCommit: ID!, $descri
6767
namespace
6868
version
6969
licenses
70+
url
7071
}
7172
}
7273
steps: steps {

0 commit comments

Comments
 (0)