fix(agentic): release issue lock on dispatch failure

This commit is contained in:
Virgil 2026-04-02 17:18:55 +00:00
parent 1873adb6ae
commit a3c39ccae7
2 changed files with 109 additions and 0 deletions

View file

@ -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)

View file

@ -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