From a3c39ccae7920c7fdad66e41e2d14f714881269b Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 17:18:55 +0000 Subject: [PATCH] fix(agentic): release issue lock on dispatch failure --- pkg/mcp/agentic/issue.go | 49 ++++++++++++++++++++++++++++ pkg/mcp/agentic/issue_test.go | 60 +++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+) diff --git a/pkg/mcp/agentic/issue.go b/pkg/mcp/agentic/issue.go index fbd895d..088fddb 100644 --- a/pkg/mcp/agentic/issue.go +++ b/pkg/mcp/agentic/issue.go @@ -78,6 +78,27 @@ func (s *PrepSubsystem) dispatchIssue(ctx context.Context, req *mcp.CallToolRequ if err := s.lockIssue(ctx, input.Org, input.Repo, input.Issue, input.Agent); err != nil { return nil, DispatchOutput{}, err } + + var dispatchErr error + defer func() { + if dispatchErr != nil { + _ = s.unlockIssue(ctx, input.Org, input.Repo, input.Issue) + } + }() + + result, out, dispatchErr := s.dispatch(ctx, req, DispatchInput{ + Repo: input.Repo, + Org: input.Org, + Issue: input.Issue, + Task: issue.Title, + Agent: input.Agent, + Template: input.Template, + DryRun: input.DryRun, + }) + if dispatchErr != nil { + return nil, DispatchOutput{}, dispatchErr + } + return result, out, nil } return s.dispatch(ctx, req, DispatchInput{ @@ -91,6 +112,34 @@ func (s *PrepSubsystem) dispatchIssue(ctx context.Context, req *mcp.CallToolRequ }) } +func (s *PrepSubsystem) unlockIssue(ctx context.Context, org, repo string, issue int) error { + updateURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d", s.forgeURL, org, repo, issue) + payload, err := json.Marshal(map[string]any{ + "assignees": []string{}, + }) + if err != nil { + return coreerr.E("unlockIssue", "failed to encode issue unlock", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPatch, updateURL, bytes.NewReader(payload)) + if err != nil { + return coreerr.E("unlockIssue", "failed to build unlock request", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "token "+s.forgeToken) + + resp, err := s.client.Do(req) + if err != nil { + return coreerr.E("unlockIssue", "failed to update issue", err) + } + defer resp.Body.Close() + if resp.StatusCode >= http.StatusBadRequest { + return coreerr.E("unlockIssue", fmt.Sprintf("issue unlock returned %d", resp.StatusCode), nil) + } + + return nil +} + func (s *PrepSubsystem) fetchIssue(ctx context.Context, org, repo string, issue int) (*forgeIssue, error) { url := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d", s.forgeURL, org, repo, issue) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) diff --git a/pkg/mcp/agentic/issue_test.go b/pkg/mcp/agentic/issue_test.go index b9f323c..8eb74c6 100644 --- a/pkg/mcp/agentic/issue_test.go +++ b/pkg/mcp/agentic/issue_test.go @@ -90,6 +90,66 @@ func TestDispatchIssue_Bad_AssignedIssue(t *testing.T) { } } +func TestDispatchIssue_Good_UnlocksOnPrepFailure(t *testing.T) { + var methods []string + var bodies []string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + methods = append(methods, r.Method) + bodies = append(bodies, string(body)) + + switch r.Method { + case http.MethodGet: + _ = json.NewEncoder(w).Encode(map[string]any{ + "title": "Fix login crash", + "body": "details", + "state": "open", + }) + case http.MethodPatch: + w.WriteHeader(http.StatusOK) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + })) + defer srv.Close() + + s := &PrepSubsystem{ + forgeURL: srv.URL, + forgeToken: "token", + client: srv.Client(), + codePath: t.TempDir(), + } + + _, _, err := s.dispatchIssue(context.Background(), nil, IssueDispatchInput{ + Repo: "demo", + Org: "core", + Issue: 42, + }) + if err == nil { + t.Fatal("expected dispatch to fail when the repo clone is missing") + } + + if got, want := len(methods), 3; got != want { + t.Fatalf("expected %d requests, got %d (%v)", want, got, methods) + } + if methods[0] != http.MethodGet { + t.Fatalf("expected first request to fetch issue, got %s", methods[0]) + } + if methods[1] != http.MethodPatch { + t.Fatalf("expected second request to lock issue, got %s", methods[1]) + } + if methods[2] != http.MethodPatch { + t.Fatalf("expected third request to unlock issue, got %s", methods[2]) + } + if !strings.Contains(bodies[1], `"assignees":["claude"]`) { + t.Fatalf("expected lock request to assign claude, got %s", bodies[1]) + } + if !strings.Contains(bodies[2], `"assignees":[]`) { + t.Fatalf("expected unlock request to clear assignees, got %s", bodies[2]) + } +} + func TestLockIssue_Good_RequestBody(t *testing.T) { var gotMethod string var gotPath string