From f47fdddfb550c7894454ae13acf8f108ccad480d Mon Sep 17 00:00:00 2001 From: mintaka Date: Mon, 7 Sep 2026 22:31:14 -0400 Subject: [PATCH] feat(forge): forge state-transition wire arms and provider methods (RIG-3331) --- go/internal/forge/fake.go | 22 + go/internal/forge/github.go | 66 ++- go/internal/forge/github_test.go | 40 ++ go/internal/forge/golden_capture_test.go | 9 +- go/internal/forge/golden_test.go | 142 +++-- go/internal/forge/linear.go | 342 ++++++++++- go/internal/forge/linear_test.go | 233 ++++++++ go/internal/forge/provider.go | 19 + .../transition_issue_close_default.json | 43 ++ .../github/transition_issue_close_reason.json | 45 ++ .../github/transition_issue_reopen.json | 44 ++ .../github/transition_pull_request_close.json | 53 ++ .../transition_pull_request_reopen.json | 53 ++ ...transition_pull_request_reopen_merged.json | 37 ++ .../transition_issue_ambiguous_default.json | 44 ++ .../transition_issue_close_by_name.json | 84 +++ .../transition_issue_close_default.json | 89 +++ .../transition_issue_duplicate_name.json | 43 ++ .../transition_issue_reopen_default.json | 79 +++ .../transition_issue_type_contradiction.json | 38 ++ .../linear/transition_issue_unknown_name.json | 37 ++ .../gen/compass/v1/agent_gateway.pb.go | 552 ++++++++++++------ .../src/gen/compass/v1/agent_gateway_pb.ts | 142 ++++- proto/compass/v1/agent_gateway.proto | 38 +- 24 files changed, 2030 insertions(+), 264 deletions(-) create mode 100644 go/internal/forge/testdata/github/transition_issue_close_default.json create mode 100644 go/internal/forge/testdata/github/transition_issue_close_reason.json create mode 100644 go/internal/forge/testdata/github/transition_issue_reopen.json create mode 100644 go/internal/forge/testdata/github/transition_pull_request_close.json create mode 100644 go/internal/forge/testdata/github/transition_pull_request_reopen.json create mode 100644 go/internal/forge/testdata/github/transition_pull_request_reopen_merged.json create mode 100644 go/internal/forge/testdata/linear/transition_issue_ambiguous_default.json create mode 100644 go/internal/forge/testdata/linear/transition_issue_close_by_name.json create mode 100644 go/internal/forge/testdata/linear/transition_issue_close_default.json create mode 100644 go/internal/forge/testdata/linear/transition_issue_duplicate_name.json create mode 100644 go/internal/forge/testdata/linear/transition_issue_reopen_default.json create mode 100644 go/internal/forge/testdata/linear/transition_issue_type_contradiction.json create mode 100644 go/internal/forge/testdata/linear/transition_issue_unknown_name.json diff --git a/go/internal/forge/fake.go b/go/internal/forge/fake.go index d4c5aa903..cdf32239d 100644 --- a/go/internal/forge/fake.go +++ b/go/internal/forge/fake.go @@ -55,6 +55,12 @@ type FakeProvider struct { ChecksResult Checks // SubmitReviewResult is returned by SubmitReview when no error is scripted. SubmitReviewResult SubmittedReview + // TransitionIssueResult is returned by TransitionIssueState when no error is + // scripted. + TransitionIssueResult Issue + // TransitionPRResult is returned by TransitionPullRequestState when no error + // is scripted. + TransitionPRResult PullRequest // BodyLimitResult is returned by BodyLimit; 0 (the default) means unlimited. BodyLimitResult int @@ -175,6 +181,22 @@ func (f *FakeProvider) Checks(_ context.Context, repo string, number uint64) (Ch return f.ChecksResult, nil } +// TransitionIssueState records the call and returns the scripted result or error. +func (f *FakeProvider) TransitionIssueState(_ context.Context, repo string, number uint64, in TransitionState) (Issue, error) { + if err := f.record(Call{Method: "TransitionIssueState", Repo: repo, Number: number, Payload: in}); err != nil { + return Issue{}, err + } + return f.TransitionIssueResult, nil +} + +// TransitionPullRequestState records the call and returns the scripted result or error. +func (f *FakeProvider) TransitionPullRequestState(_ context.Context, repo string, number uint64, in TransitionState) (PullRequest, error) { + if err := f.record(Call{Method: "TransitionPullRequestState", Repo: repo, Number: number, Payload: in}); err != nil { + return PullRequest{}, err + } + return f.TransitionPRResult, nil +} + // record appends a call and returns any error scripted for its method. The // caller holds no lock; record takes it. func (f *FakeProvider) record(c Call) error { diff --git a/go/internal/forge/github.go b/go/internal/forge/github.go index fd15f1b97..298132ec8 100644 --- a/go/internal/forge/github.go +++ b/go/internal/forge/github.go @@ -326,7 +326,7 @@ func (g *GitHub) CreateIssue(ctx context.Context, repo string, in CreateIssue) ( Labels []string `json:"labels,omitempty"` }{Title: in.Title, Body: in.Body, Labels: in.Labels} var out ghIssue - if err := g.doJSON(ctx, g.apiBase()+"/repos/"+repo+"/issues", body, &out); err != nil { + if err := g.doJSON(ctx, http.MethodPost, g.apiBase()+"/repos/"+repo+"/issues", body, &out); err != nil { return Issue{}, fmt.Errorf("forge: github create issue %q: %w", repo, err) } return out.toIssue(), nil @@ -339,7 +339,7 @@ func (g *GitHub) CommentOnIssue(ctx context.Context, repo string, number uint64, }{Body: body} url := g.apiBase() + "/repos/" + repo + "/issues/" + strconv.FormatUint(number, 10) + "/comments" var out ghComment - if err := g.doJSON(ctx, url, in, &out); err != nil { + if err := g.doJSON(ctx, http.MethodPost, url, in, &out); err != nil { return Comment{}, fmt.Errorf("forge: github comment on issue %q#%d: %w", repo, number, err) } return out.toComment(), nil @@ -355,7 +355,7 @@ func (g *GitHub) CreatePullRequest(ctx context.Context, repo string, in CreatePR Draft bool `json:"draft"` }{Title: in.Title, Body: in.Body, Head: in.HeadRef, Base: in.BaseRef, Draft: in.Draft} var out ghPull - if err := g.doJSON(ctx, g.apiBase()+"/repos/"+repo+"/pulls", body, &out); err != nil { + if err := g.doJSON(ctx, http.MethodPost, g.apiBase()+"/repos/"+repo+"/pulls", body, &out); err != nil { return PullRequest{}, fmt.Errorf("forge: github create pull request %q: %w", repo, err) } return out.toPullRequest(), nil @@ -370,12 +370,55 @@ func (g *GitHub) CommentOnPullRequest(ctx context.Context, repo string, number u }{Body: body} url := g.apiBase() + "/repos/" + repo + "/issues/" + strconv.FormatUint(number, 10) + "/comments" var out ghComment - if err := g.doJSON(ctx, url, in, &out); err != nil { + if err := g.doJSON(ctx, http.MethodPost, url, in, &out); err != nil { return Comment{}, fmt.Errorf("forge: github comment on pull request %q#%d: %w", repo, number, err) } return out.toComment(), nil } +// TransitionIssueState moves issue number in repo to in.State via +// PATCH /repos/{repo}/issues/{number}, returning the UPDATED issue. The PATCH +// response IS the new truth, so nothing is re-read; it decodes through the same +// ghIssue wire struct the read path uses. in.CloseReason rides as GitHub's +// state_reason ONLY when closing with one — an empty reason leaves the key off +// so GitHub applies its own default, and a reason on a reopen is meaningless to +// the API and never sent. in.WorkflowState is the Linear refinement, screened at +// the server arm and ignored here. +func (g *GitHub) TransitionIssueState(ctx context.Context, repo string, number uint64, in TransitionState) (Issue, error) { + body := struct { + State string `json:"state"` + StateReason string `json:"state_reason,omitempty"` + }{State: in.State} + if in.State == stateClosed { + body.StateReason = in.CloseReason + } + url := g.apiBase() + "/repos/" + repo + "/issues/" + strconv.FormatUint(number, 10) + var out ghIssue + if err := g.doJSON(ctx, http.MethodPatch, url, body, &out); err != nil { + return Issue{}, fmt.Errorf("forge: github transition issue %q#%d: %w", repo, number, err) + } + return out.toIssue(), nil +} + +// TransitionPullRequestState moves PR number in repo to in.State via +// PATCH /repos/{repo}/pulls/{number}. Only `state` is sent: merge is a separate +// operation the transition never expresses, and GitHub's issue-only +// state_reason has no pulls counterpart. The response decodes through +// ghPullDetail, so State folds the merged bool exactly as every read does — +// reopening a merged PR is refused by the forge itself (a 422 the existing +// mapping surfaces as the forge's own validation message). +func (g *GitHub) TransitionPullRequestState(ctx context.Context, repo string, number uint64, in TransitionState) (PullRequest, error) { + body := struct { + State string `json:"state"` + }{State: in.State} + url := g.apiBase() + "/repos/" + repo + "/pulls/" + strconv.FormatUint(number, 10) + var out ghPullDetail + if err := g.doJSON(ctx, http.MethodPatch, url, body, &out); err != nil { + return PullRequest{}, fmt.Errorf("forge: github transition pull request %q#%d: %w", repo, number, err) + } + return out.toPullRequest(), nil +} + // reviewEvent maps a write-side verdict to its GitHub reviews-POST event token // and whether GitHub requires a non-empty body for it. An unknown verdict is // absent from reviewEvents and rejected before any wire call (design §T3); @@ -458,7 +501,7 @@ func (g *GitHub) SubmitReview(ctx context.Context, repo string, number uint64, i url := g.apiBase() + "/repos/" + repo + "/pulls/" + strconv.FormatUint(number, 10) + "/reviews" var out ghReview - if err := g.doJSON(ctx, url, body, &out); err != nil { + if err := g.doJSON(ctx, http.MethodPost, url, body, &out); err != nil { return SubmittedReview{}, fmt.Errorf("forge: github submit review %q#%d: %w", repo, number, err) } return SubmittedReview{ID: out.ID, URL: out.HTMLURL, Verdict: in.Verdict}, nil @@ -862,17 +905,22 @@ func (g *GitHub) gateBlocked() (time.Duration, bool) { return 0, false } -// doJSON carries the write-path plumbing once for all four write methods: the +// doJSON carries the write-path plumbing once for every write method: the // resetAt fail-fast gate (a write burst respects the same reserve as the poll // driver, so it cannot starve it), token auth, budget recording, and error // mapping. It marshals in to a JSON request body and decodes a 2xx response // into out. The read path (ListIssuesPage) is intentionally NOT refactored onto // this in this slice (no RIG-1728 rework). -func (g *GitHub) doJSON(ctx context.Context, url string, in, out any) error { +// +// method is the HTTP verb: the create/comment/review writes POST, and the state +// transitions PATCH. Everything else about the exchange is identical — writes +// are unconditional (no If-None-Match), so the verb is the only axis that +// varies and one helper still carries the whole write path. +func (g *GitHub) doJSON(ctx context.Context, method, url string, in, out any) error { // Gate check mirrors ListIssuesPage: an armed gate short-circuits without a // request until the injected clock passes resetAt, then re-opens. if hint, blocked := g.gateBlocked(); blocked { - return fmt.Errorf("POST %s: %w", url, &RateLimitError{RetryAfter: hint}) + return fmt.Errorf("%s %s: %w", method, url, &RateLimitError{RetryAfter: hint}) } token, err := g.token.Token(ctx) @@ -885,7 +933,7 @@ func (g *GitHub) doJSON(ctx context.Context, url string, in, out any) error { return fmt.Errorf("marshal request body: %w", err) } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) + req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(payload)) if err != nil { return fmt.Errorf("build request: %w", err) } diff --git a/go/internal/forge/github_test.go b/go/internal/forge/github_test.go index 722912839..7724a8d48 100644 --- a/go/internal/forge/github_test.go +++ b/go/internal/forge/github_test.go @@ -1031,6 +1031,46 @@ func TestGitHubDoJSONTokenError(t *testing.T) { } } +// The PATCH transitions ride the SAME fail-fast budget gate as every other +// write: an armed gate short-circuits both before any request, with the retry +// hint recoverable. A transition that bypassed the gate could starve the poll +// driver of the tail of the rate window — the reason doJSON owns the check. +func TestGitHubTransitionRespectsBudgetGate(t *testing.T) { + for _, tc := range []struct { + name string + call func(*GitHub) error + }{ + {"issue", func(g *GitHub) error { + _, err := g.TransitionIssueState(context.Background(), "org/repo", 42, TransitionState{State: "closed"}) + return err + }}, + {"pull_request", func(g *GitHub) error { + _, err := g.TransitionPullRequestState(context.Background(), "org/repo", 7, TransitionState{State: "closed"}) + return err + }}, + } { + t.Run(tc.name, func(t *testing.T) { + rt := &scriptedRoundTripper{} + g := newTestGitHub(rt, &fakeTokenSource{token: "t"}) + now := time.Now() + g.now = func() time.Time { return now } + g.resetAt = now.Add(90 * time.Second) + + err := tc.call(g) + var rle *RateLimitError + if !errors.As(err, &rle) { + t.Fatalf("err = %v, want *RateLimitError", err) + } + if rle.RetryAfter != 90*time.Second { + t.Errorf("RetryAfter = %v, want 90s", rle.RetryAfter) + } + if rt.calls != 0 { + t.Errorf("issued a request despite the armed gate: calls = %d", rt.calls) + } + }) + } +} + // concurrentRoundTripper is a race-safe transport for the concurrency test: it // serves a fixed benign response and guards its call counter with a mutex, so // the only unsynchronized shared state under test is the client's resetAt gate. diff --git a/go/internal/forge/golden_capture_test.go b/go/internal/forge/golden_capture_test.go index a5524dda8..d932ed378 100644 --- a/go/internal/forge/golden_capture_test.go +++ b/go/internal/forge/golden_capture_test.go @@ -312,7 +312,14 @@ func deriveFixtureHalves(t *testing.T, provider string, f fixture) fixture { rt := &scriptedRoundTripper{responses: responses} ts := &fakeTokenSource{token: "test-token"} - got := invoke(t, provider, rt, ts, f.Request) + // Capture is a SUCCESS path: it derives a fixture from a live exchange that + // worked. A rejection fixture (Response.WantError) has no live capture — it + // is hand-written, which for the multi-candidate arm is the only coverage + // there can be, since no board reproduces it. + got, err := invoke(t, provider, rt, ts, f.Request) + if err != nil { + t.Fatalf("derive %s/%s: replay failed: %v", provider, f.Name, err) + } f.Response.Want = mustMarshal(t, got) // Guard that replay consumed EXACTLY every scripted response — the same diff --git a/go/internal/forge/golden_test.go b/go/internal/forge/golden_test.go index be370a48c..17c225412 100644 --- a/go/internal/forge/golden_test.go +++ b/go/internal/forge/golden_test.go @@ -21,6 +21,7 @@ package forge import ( "context" "encoding/json" + "errors" "flag" "log/slog" "net/http" @@ -28,9 +29,14 @@ import ( "path/filepath" "reflect" "sort" + "strings" "testing" ) +// errUnknownFixtureOp is returned by invoke on an op no provider arm serves. +// Only ever reached if t.Fatalf stopped short of terminating the goroutine. +var errUnknownFixtureOp = errors.New("forge: fixture op unknown to invoke") + // update regenerates the testdata fixtures from the live throwaway repo. The // live-capture path is T2's (//go:build livegithub) concern; T1 defines the // flag so the seam is stable and exercises only writeFixture's serialization. @@ -76,15 +82,18 @@ type fixtureRequest struct { Body json.RawMessage `json:"body,omitempty"` } -// fixtureInput is the create-op input payload (a superset across issue/PR/ -// comment; only the fields an op reads are populated). +// fixtureInput is the write-op input payload (a superset across issue/PR/ +// comment/transition; only the fields an op reads are populated). type fixtureInput struct { - Title string `json:"title,omitempty"` - Body string `json:"body,omitempty"` - Labels []string `json:"labels,omitempty"` - HeadRef string `json:"headRef,omitempty"` - BaseRef string `json:"baseRef,omitempty"` - Draft bool `json:"draft,omitempty"` + Title string `json:"title,omitempty"` + Body string `json:"body,omitempty"` + Labels []string `json:"labels,omitempty"` + HeadRef string `json:"headRef,omitempty"` + BaseRef string `json:"baseRef,omitempty"` + Draft bool `json:"draft,omitempty"` + State string `json:"state,omitempty"` // transition: the portable "open"|"closed" target + CloseReason string `json:"closeReason,omitempty"` // transition: the GitHub-issue refinement + WorkflowState string `json:"workflowState,omitempty"` // transition: the Linear refinement (state NAME) } // fixtureFilter is the list_issues narrowing. @@ -93,6 +102,17 @@ type fixtureFilter struct { Labels []string `json:"labels,omitempty"` } +// fixtureError is the expected FAILURE of an op — the only expectation a +// rejection arm can carry, since it decodes no domain value. Status is the +// *StatusError status the provider returns (422 is what the Service's +// flattening turns into an in-band invalid_argument carrying the message), and +// Contains pins the substrings the message must name, so a rejection that stops +// telling the caller WHICH states collided reddens. +type fixtureError struct { + Status int `json:"status"` + Contains []string `json:"contains,omitempty"` +} + // fixtureStep is one scripted HTTP response served by the replay transport. type fixtureStep struct { Status int `json:"status"` @@ -112,7 +132,10 @@ type fixtureResponse struct { Body json.RawMessage `json:"body,omitempty"` // verbatim provider JSON Prelude []fixtureStep `json:"prelude,omitempty"` Extra []fixtureStep `json:"extra,omitempty"` - Want json.RawMessage `json:"want"` // expected decoded forge domain value + Want json.RawMessage `json:"want,omitempty"` // expected decoded forge domain value + // WantError is set INSTEAD of Want on a rejection fixture: the op must fail, + // and the failure itself is the captured truth. + WantError *fixtureError `json:"wantError,omitempty"` } // loadFixtures reads every *.json in dir (one fixture per file) and returns them @@ -206,7 +229,27 @@ func replayFixture(t *testing.T, provider string, f fixture) { rt := &scriptedRoundTripper{responses: responses} ts := &fakeTokenSource{token: "test-token"} - got := invoke(t, provider, rt, ts, f.Request) + got, err := invoke(t, provider, rt, ts, f.Request) + + // A rejection fixture asserts the FAILURE instead of a decoded value. Its + // Prelude holds the legs that DID run and Status/Body the last of them, so + // the same exact-count check still applies — and it is load-bearing here: + // without it a rejection that fires BEFORE reaching the wire (or one that + // runs the mutation anyway and fails after) would pass on the error alone. + // The transition path's whole point is that a rejection lands after the + // resolve and before the write. + if f.Response.WantError != nil { + wantN := len(f.Response.Prelude) + 1 + if n := len(rt.requests); n != wantN { + t.Fatalf("rejected op %q emitted %d requests, want exactly %d (the rejection must land after the resolve and before the mutation)", + f.Request.Op, n, wantN) + } + assertFixtureError(t, f.Request.Op, err, *f.Response.WantError) + return + } + if err != nil { + t.Fatalf("op %q: %v", f.Request.Op, err) + } // (a) Request half: assert the client emitted EXACTLY the scripted number // of requests — prelude probes + the asserted request + composite extras. @@ -226,9 +269,33 @@ func replayFixture(t *testing.T, provider string, f fixture) { assertJSONEqual(t, "decoded value", mustMarshal(t, got), f.Response.Want) } +// assertFixtureError asserts a rejection fixture's captured failure: the op must +// have failed with a *StatusError of the pinned status, whose message names each +// pinned substring. The substrings are the point — a rejection that stops naming +// the colliding states leaves the caller with no way to pick one. +func assertFixtureError(t *testing.T, op string, err error, want fixtureError) { + t.Helper() + if err == nil { + t.Fatalf("op %q succeeded, want a rejection with status %d", op, want.Status) + } + var se *StatusError + if !errors.As(err, &se) { + t.Fatalf("op %q error = %v, want a *StatusError", op, err) + } + if se.Status != want.Status { + t.Errorf("op %q error status = %d, want %d (message %q)", op, se.Status, want.Status, se.Message) + } + for _, sub := range want.Contains { + if !strings.Contains(se.Message, sub) { + t.Errorf("op %q error message %q does not name %q", op, se.Message, sub) + } + } +} + // invoke calls the client method the fixture names and returns the decoded -// domain value for the value-half assertion. -func invoke(t *testing.T, provider string, rt *scriptedRoundTripper, ts *fakeTokenSource, req fixtureRequest) any { +// domain value (for the value-half assertion) or the op's error (for a rejection +// fixture). An unknown provider/op is a hard failure — never a silent pass. +func invoke(t *testing.T, provider string, rt *scriptedRoundTripper, ts *fakeTokenSource, req fixtureRequest) (any, error) { t.Helper() ctx := context.Background() in := req.Input @@ -239,49 +306,48 @@ func invoke(t *testing.T, provider string, rt *scriptedRoundTripper, ts *fakeTok if req.Filter != nil { filter = IssueFilter{State: req.Filter.State, Labels: req.Filter.Labels} } + transition := TransitionState{State: in.State, CloseReason: in.CloseReason, WorkflowState: in.WorkflowState} switch provider { case providerGitHub: g := newTestGitHub(rt, ts) switch req.Op { case "create_issue": - v, err := g.CreateIssue(ctx, req.Repo, CreateIssue{Title: in.Title, Body: in.Body, Labels: in.Labels}) - return must(t, v, err) + return g.CreateIssue(ctx, req.Repo, CreateIssue{Title: in.Title, Body: in.Body, Labels: in.Labels}) case "comment_on_issue": - v, err := g.CommentOnIssue(ctx, req.Repo, req.Number, in.Body) - return must(t, v, err) + return g.CommentOnIssue(ctx, req.Repo, req.Number, in.Body) case "get_issue": - v, err := g.GetIssue(ctx, req.Repo, req.Number) - return must(t, v, err) + return g.GetIssue(ctx, req.Repo, req.Number) case "list_issues": - v, err := g.ListIssues(ctx, req.Repo, filter) - return must(t, v, err) + return g.ListIssues(ctx, req.Repo, filter) case "create_pull_request": - v, err := g.CreatePullRequest(ctx, req.Repo, CreatePR{Title: in.Title, Body: in.Body, HeadRef: in.HeadRef, BaseRef: in.BaseRef, Draft: in.Draft}) - return must(t, v, err) + return g.CreatePullRequest(ctx, req.Repo, CreatePR{Title: in.Title, Body: in.Body, HeadRef: in.HeadRef, BaseRef: in.BaseRef, Draft: in.Draft}) case "get_pull_request": - v, err := g.GetPullRequest(ctx, req.Repo, req.Number) - return must(t, v, err) + return g.GetPullRequest(ctx, req.Repo, req.Number) + case "transition_issue_state": + return g.TransitionIssueState(ctx, req.Repo, req.Number, transition) + case "transition_pull_request_state": + return g.TransitionPullRequestState(ctx, req.Repo, req.Number, transition) } case providerLinear: l := newTestLinear(rt, ts, slog.New(&capturingHandler{})) switch req.Op { case "create_issue": - v, err := l.CreateIssue(ctx, req.Repo, CreateIssue{Title: in.Title, Body: in.Body, Labels: in.Labels}) - return must(t, v, err) + return l.CreateIssue(ctx, req.Repo, CreateIssue{Title: in.Title, Body: in.Body, Labels: in.Labels}) case "comment_on_issue": - v, err := l.CommentOnIssue(ctx, req.Repo, req.Number, in.Body) - return must(t, v, err) + return l.CommentOnIssue(ctx, req.Repo, req.Number, in.Body) case "get_issue": - v, err := l.GetIssue(ctx, req.Repo, req.Number) - return must(t, v, err) + return l.GetIssue(ctx, req.Repo, req.Number) case "list_issues": - v, err := l.ListIssues(ctx, req.Repo, filter) - return must(t, v, err) + return l.ListIssues(ctx, req.Repo, filter) + case "transition_issue_state": + return l.TransitionIssueState(ctx, req.Repo, req.Number, transition) } } + // Unreachable: t.Fatalf stops this goroutine. The error keeps the return + // honest for the linter rather than handing back a nil value AND a nil error. t.Fatalf("unknown provider/op: %s/%s", provider, req.Op) - return nil + return nil, errUnknownFixtureOp } // assertRequest checks the emitted request against the fixture's expectation: @@ -352,16 +418,6 @@ func mustMarshal(t *testing.T, v any) []byte { return raw } -// must fails the test on a client-method error and returns the value for the -// value-half assertion. -func must[T any](t *testing.T, v T, err error) T { - t.Helper() - if err != nil { - t.Fatalf("client method: %v", err) - } - return v -} - // TestFixtureRoundTrip proves the schema round-trips through writeFixture and // loadFixtures (the seam T2's -update path writes through). func TestFixtureRoundTrip(t *testing.T) { diff --git a/go/internal/forge/linear.go b/go/internal/forge/linear.go index 5a3b22363..62f24bda3 100644 --- a/go/internal/forge/linear.go +++ b/go/internal/forge/linear.go @@ -28,6 +28,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "log/slog" @@ -44,6 +45,28 @@ const ( // overrides it (the whole endpoint URL, not just a hostname). linearDefaultEndpoint = "https://api.linear.app/graphql" + // gqlInputKey is the GraphQL variable name every Linear mutation in this + // file binds its input object to. Extracted because goconst flags the + // third occurrence; applied at ALL of them, so a raw "input" appearing + // here later is a different key rather than a missed conversion. + gqlInputKey = "input" + + // linearStaleStateMarker is the substring Linear's GraphQL error message + // carries when a mutation names a workflow state that no longer exists. + // It is the ONE HTTP-200 rejection invalidate-and-retry-once can fix, so + // TransitionIssueState's retry gate keys on it rather than on any + // GraphQL-level rejection. + linearStaleStateMarker = "Entity not found: WorkflowState" + + // workflowStatePageCap bounds the single unpaginated workflowStates query. + // 250 is far above any real board (Rigel has eight), so a FULL page is + // read as truncation and rejected rather than resolved against: a + // truncated list would make a by-name resolve reject a state that really + // exists, and could make the default map resolve a candidate that is only + // apparently sole — the silent wrong-pick §The cross-provider state model + // exists to make structurally impossible. + workflowStatePageCap = 250 + // linearBodyLimit is the max issue/comment body size (BYTES) the Service // enforces before a Linear write. Linear does not publish a single pinned // GraphQL body cap, so this is a CONSERVATIVE constant: 65536 bytes matches @@ -68,6 +91,21 @@ const ( varTeam = "team" varFilter = "filter" varNumber = "number" + + // Linear workflow-state `type` values the default mapping resolves + // against. They are a subset of the SDL list quoted at + // linearClosedStateTypes; only these three are default-map targets. + linearTypeCompleted = "completed" + linearTypeUnstarted = "unstarted" + linearTypeBacklog = "backlog" + + // workflowStateTTL bounds how long a team's workflow-state list is reused. + // Unlike a team UUID, a workflow state is renamed, reordered and deleted + // from the Linear UI, so this cache expires where teamIDs never does. The + // TTL is a bound on how stale a resolution may be BEFORE the + // invalidate-and-retry-once path recovers it, not the only recovery — so a + // few minutes trades a rare extra query against a long staleness window. + workflowStateTTL = 5 * time.Minute ) // linearClosedStateTypes are the Linear workflow-state `type` values that map @@ -93,10 +131,10 @@ type Linear struct { client *http.Client log *slog.Logger - // mu guards resetAt, teamIDs, and the actor-probe fields. The client may be - // shared between the poll driver and write-RPC goroutines (OQ-6), so all - // three are concurrent read-modify-write; mu is held only around the fast - // state touches, never across an HTTP round-trip. + // mu guards resetAt, teamIDs, workflowStates, and the actor-probe fields. + // The client may be shared between the poll driver and write-RPC goroutines + // (OQ-6), so all are concurrent read-modify-write; mu is held only around + // the fast state touches, never across an HTTP round-trip. mu sync.Mutex // resetAt is the rate-budget gate (see GitHub.resetAt). Non-zero and before @@ -107,6 +145,14 @@ type Linear struct { // teams query and reused for every subsequent CreateIssue. teamIDs map[string]string + // workflowStates caches a team key -> that team's workflow states, with a + // TTL. It is DELIBERATELY separate from teamIDs: a team UUID is immutable, + // so teamIDs never invalidates, while a workflow state is renamed, + // reordered and deleted from the Linear UI. Reusing the invalidation-free + // cache would wedge every later transition to a renamed state until the + // process restarts. + workflowStates map[string]workflowStateCacheEntry + // probeDone/actorCapable cache the one-time actor-capability probe (A4). // Once probeDone, actorCapable governs whether writes set createAsUser. probeDone bool @@ -130,12 +176,13 @@ func NewLinear(cfg LinearConfig) *Linear { log = slog.Default() } return &Linear{ - host: cfg.Host, - token: cfg.Token, - client: client, - log: log, - teamIDs: make(map[string]string), - now: time.Now, + host: cfg.Host, + token: cfg.Token, + client: client, + log: log, + teamIDs: make(map[string]string), + workflowStates: make(map[string]workflowStateCacheEntry), + now: time.Now, } } @@ -170,7 +217,7 @@ func (l *Linear) CreateIssue(ctx context.Context, repo string, in CreateIssue) ( Issue linearIssue `json:"issue"` } `json:"issueCreate"` } - if err := l.doGraphQL(ctx, query, map[string]any{"input": input}, &out); err != nil { + if err := l.doGraphQL(ctx, query, map[string]any{gqlInputKey: input}, &out); err != nil { return Issue{}, fmt.Errorf("forge: linear create issue %q: %w", repo, err) } return out.IssueCreate.Issue.toIssue(), nil @@ -197,7 +244,7 @@ func (l *Linear) CommentOnIssue(ctx context.Context, repo string, number uint64, Comment linearComment `json:"comment"` } `json:"commentCreate"` } - if err := l.doGraphQL(ctx, query, map[string]any{"input": input}, &out); err != nil { + if err := l.doGraphQL(ctx, query, map[string]any{gqlInputKey: input}, &out); err != nil { return Comment{}, fmt.Errorf("forge: linear comment on issue %q#%d: %w", repo, number, err) } return out.CommentCreate.Comment.toComment(), nil @@ -275,6 +322,75 @@ func (l *Linear) ListIssues(ctx context.Context, repo string, f IssueFilter) ([] return all, nil } +// TransitionIssueState moves issue number in the team keyed by repo to the +// workflow state in resolves to, returning the UPDATED issue (the mutation +// response IS the new truth). Resolution order is team -> the team's workflow +// states -> the target state (by NAME when in.WorkflowState is set, by the +// default mapping otherwise) -> the issue UUID -> the issueUpdate mutation, so +// every rejection arm fails BEFORE the issue is touched. in.CloseReason is the +// GitHub refinement, screened at the server arm and ignored here. +// +// A mutation that fails against a state list served from cache is retried ONCE +// against a freshly fetched list: a state renamed or deleted in the Linear UI +// between the resolve and the write is exactly the staleness the TTL cache +// cannot rule out, and re-resolving recovers it in-flight. +func (l *Linear) TransitionIssueState(ctx context.Context, repo string, number uint64, in TransitionState) (Issue, error) { + fail := func(err error) (Issue, error) { + return Issue{}, fmt.Errorf("forge: linear transition issue %q#%d: %w", repo, number, err) + } + + states, cached, err := l.workflowStatesFor(ctx, repo) + if err != nil { + return fail(err) + } + stateID, err := resolveWorkflowState(repo, states, in) + if err != nil { + return fail(err) + } + issueID, err := l.resolveIssueID(ctx, repo, number) + if err != nil { + return fail(err) + } + + issue, err := l.issueUpdateState(ctx, issueID, stateID) + if err == nil { + return issue, nil + } + // Staleness recovery: the ONLY rejection this retry can fix is a state id + // Linear no longer knows, against a CACHED resolution. Linear answers that + // on HTTP 200 with linearStaleStateMarker in the message; every OTHER + // HTTP-200 GraphQL rejection (a permission denial, an issue-validation + // error, a Linear-side internal error) is refused for a reason a refetch + // cannot change, and — like a rate limit, an auth failure or a transport + // fault — must never burn the one retry on a re-issued mutation Linear + // already declined. + se, isStatus := errors.AsType[*StatusError](err) + if !cached || !isStatus || se.Status != http.StatusOK || + !strings.Contains(se.Message, linearStaleStateMarker) { + return fail(err) + } + l.invalidateWorkflowStates(repo) + fresh, _, err := l.workflowStatesFor(ctx, repo) + if err != nil { + return fail(err) + } + stateID, err = resolveWorkflowState(repo, fresh, in) + if err != nil { + return fail(err) + } + issue, err = l.issueUpdateState(ctx, issueID, stateID) + if err != nil { + return fail(err) + } + return issue, nil +} + +// TransitionPullRequestState is unsupported (Linear has no PRs) — the +// issues-only-forge case ErrUnsupported was minted for. +func (l *Linear) TransitionPullRequestState(ctx context.Context, repo string, number uint64, in TransitionState) (PullRequest, error) { + return PullRequest{}, ErrUnsupported +} + // CreatePullRequest is unsupported: Linear has no pull-request concept // (design.md §5). The Service maps ErrUnsupported to the in-band `unimplemented`. func (l *Linear) CreatePullRequest(ctx context.Context, repo string, in CreatePR) (PullRequest, error) { @@ -551,6 +667,208 @@ func (l *Linear) resolveIssueID(ctx context.Context, repo string, number uint64) return out.Issues.Nodes[0].ID, nil } +// workflowState is one of a team's workflow states, as the transition path +// needs it: the id to write, the name a caller may target, and the type the +// default mapping and the consistency check read. +type workflowState struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` +} + +// workflowStateCacheEntry is one team's cached state list plus the instant it +// expires. Expiry is stored (not the fetch time) so a read is one comparison. +type workflowStateCacheEntry struct { + states []workflowState + expiresAt time.Time +} + +// workflowStatesQuery is the team workflow-state selection, built once so the +// page cap has ONE source (workflowStatePageCap) shared by the query and the +// truncation guard below rather than a literal repeated in both. +var workflowStatesQuery = fmt.Sprintf(`query CompassTeamWorkflowStates($team: String!) { + workflowStates(filter: {team: {id: {eq: $team}}}, first: %d) { + nodes { id name type } + } +}`, workflowStatePageCap) + +// workflowStatesFor returns the workflow states of the team keyed by repo, and +// whether they came from cache (the discriminator TransitionIssueState's +// retry-once arm keys on — a FRESH list that a mutation still rejects is not a +// staleness the cache can fix). An expired or absent entry is refetched. The +// cache is read and written under mu without holding it across the query; a +// concurrent miss issues a redundant (idempotent) fetch at worst. +func (l *Linear) workflowStatesFor(ctx context.Context, repo string) ([]workflowState, bool, error) { + l.mu.Lock() + entry, ok := l.workflowStates[repo] + fresh := ok && l.now().Before(entry.expiresAt) + l.mu.Unlock() + if fresh { + return entry.states, true, nil + } + + // The states are filtered by team UUID off the existing (immutable, + // invalidation-free) teamIDs cache, so only the mutable half — the state + // list itself — rides the TTL. + teamID, err := l.resolveTeamID(ctx, repo) + if err != nil { + return nil, false, err + } + + var out struct { + WorkflowStates struct { + Nodes []workflowState `json:"nodes"` + } `json:"workflowStates"` + } + if err := l.doGraphQL(ctx, workflowStatesQuery, map[string]any{varTeam: teamID}, &out); err != nil { + return nil, false, err + } + states := out.WorkflowStates.Nodes + if len(states) == 0 { + return nil, false, &StatusError{Status: http.StatusNotFound, Message: fmt.Sprintf("no workflow states on team %q", repo)} + } + // A FULL page is read as truncation: the query is unpaginated, so a list at + // the cap may be missing states, and resolving against it would reject a + // name that really exists or default-map to an only-apparently-sole + // candidate. Fail loud instead — the cap is far above any real board, so + // hitting it is a Linear-side surprise a caller must be told about, not a + // pagination loop worth carrying. + if len(states) >= workflowStatePageCap { + return nil, false, invalidWorkflowState( + "team %q returned %d workflow states, the %d-state page cap: the list may be truncated, so no state can be resolved safely; pass an explicit workflow state", + repo, len(states), workflowStatePageCap) + } + + l.mu.Lock() + l.workflowStates[repo] = workflowStateCacheEntry{states: states, expiresAt: l.now().Add(workflowStateTTL)} + l.mu.Unlock() + return states, false, nil +} + +// invalidateWorkflowStates drops a team's cached state list so the next +// resolution refetches. It is the recovery half of the TTL cache: a state +// renamed or deleted between resolve and write is corrected in-flight rather +// than wedging every transition until the TTL lapses. +func (l *Linear) invalidateWorkflowStates(repo string) { + l.mu.Lock() + delete(l.workflowStates, repo) + l.mu.Unlock() +} + +// invalidWorkflowState builds the rejection every workflow-state resolution +// failure returns. The status is 422 because that is the ONE status the +// Service's flattening maps to an in-band `invalid_argument` carrying the +// message (server/forge.go mapForgeError) — the code §The cross-provider state +// model requires for an unknown name, an ambiguous name, a type contradiction +// and a multi-candidate default. Every message names the team and what the +// caller must do differently, since the caller cannot see the board. +func invalidWorkflowState(format string, args ...any) error { + return &StatusError{Status: http.StatusUnprocessableEntity, Message: fmt.Sprintf(format, args...)} +} + +// resolveWorkflowState picks the target workflow-state id from a team's states, +// per §The cross-provider state model. It never touches the network, so every +// rejection lands before the mutation. +// +// With in.WorkflowState set it resolves BY NAME: an unknown name, a name +// matching two states on the one team (Linear does not enforce name uniqueness), +// and a named state whose type contradicts the portable in.State target are each +// a rejection, never a guess. With it empty it default-maps to the SOLE state of +// the target type, and rejects when the team has more than one — naming every +// candidate, so the caller knows exactly which name to pass. +func resolveWorkflowState(repo string, states []workflowState, in TransitionState) (string, error) { + if in.WorkflowState == "" { + return defaultWorkflowState(repo, states, in.State) + } + + matches := make([]workflowState, 0, 1) + for _, s := range states { + if s.Name == in.WorkflowState { + matches = append(matches, s) + } + } + switch len(matches) { + case 0: + return "", invalidWorkflowState("team %q has no workflow state named %q", repo, in.WorkflowState) + case 1: + default: + return "", invalidWorkflowState("team %q has %d workflow states named %q; the name does not identify one", + repo, len(matches), in.WorkflowState) + } + + // Consistency: the named state's type must agree with the portable target, + // so `state: closed` can never land on an open-typed column (or the reverse) + // just because the caller named it. + if got := mapLinearState(matches[0].Type); got != in.State { + return "", invalidWorkflowState("workflow state %q on team %q is of type %q (a %s state), which contradicts the requested state %q", + in.WorkflowState, repo, matches[0].Type, got, in.State) + } + return matches[0].ID, nil +} + +// defaultWorkflowState maps the portable target to the team's sole state of the +// corresponding type. A close targets `completed` — NEVER `canceled`, the +// deliberate asymmetry against the read-side fold: an agent closing its issue +// means "done", and canceled stays reachable only by naming it. An open targets +// `unstarted`, falling back to `backlog` for a team with no unstarted state. +// +// Two or more candidates is a rejection naming every one of them, not a +// positional guess: silently picking a human-visible board column is the +// behaviour this rule exists to make structurally impossible. +func defaultWorkflowState(repo string, states []workflowState, target string) (string, error) { + types := []string{linearTypeUnstarted, linearTypeBacklog} + if target == stateClosed { + types = []string{linearTypeCompleted} + } + + for _, want := range types { + candidates := make([]workflowState, 0, 1) + for _, s := range states { + if s.Type == want { + candidates = append(candidates, s) + } + } + switch len(candidates) { + case 0: + continue // an open target falls back from unstarted to backlog + case 1: + return candidates[0].ID, nil + default: + names := make([]string, 0, len(candidates)) + for _, c := range candidates { + names = append(names, strconv.Quote(c.Name)) + } + return "", invalidWorkflowState("team %q has %d workflow states of type %q (%s); pass an explicit workflow state to choose one", + repo, len(candidates), want, strings.Join(names, ", ")) + } + } + return "", invalidWorkflowState("team %q has no workflow state of type %s to map the requested state %q onto", + repo, strings.Join(types, " or "), target) +} + +// issueUpdateState runs the issueUpdate mutation moving issueID to stateID and +// decodes the updated issue through the shared issueFieldsFragment — the same +// decode every read uses, so the returned truth is shaped identically. No +// attribution is applied: createAsUser attributes AUTHORSHIP of created content +// (an issue, a comment), and a transition creates none. +func (l *Linear) issueUpdateState(ctx context.Context, issueID, stateID string) (Issue, error) { + const query = `mutation CompassIssueStateUpdate($id: String!, $input: IssueUpdateInput!) { + issueUpdate(id: $id, input: $input) { + issue { ...CompassIssueFields } + } +}` + issueFieldsFragment + var out struct { + IssueUpdate struct { + Issue linearIssue `json:"issue"` + } `json:"issueUpdate"` + } + vars := map[string]any{"id": issueID, gqlInputKey: map[string]any{"stateId": stateID}} + if err := l.doGraphQL(ctx, query, vars, &out); err != nil { + return Issue{}, err + } + return out.IssueUpdate.Issue.toIssue(), nil +} + // actorAttribution reports whether writes may set createAsUser, running the // capability probe on first call and caching an AUTHORITATIVE result. The probe // queries `viewer { app }`: an actor=app OAuth token authenticates AS the app, diff --git a/go/internal/forge/linear_test.go b/go/internal/forge/linear_test.go index a464cd213..e5a123828 100644 --- a/go/internal/forge/linear_test.go +++ b/go/internal/forge/linear_test.go @@ -762,3 +762,236 @@ func TestLinearActorProbeTransientErrorReprobed(t *testing.T) { attributionUser, vars2["input"]) } } + +// --- workflow-state cache: TTL reuse + invalidate-and-retry-once ------------- + +// statesResp is a scripted team workflow-states response holding one state of +// each type the default mapping cares about. +var statesResp = scriptedResponse{status: 200, body: `{"data":{"workflowStates":{"nodes":[ + {"id":"state-todo","name":"Todo","type":"unstarted"}, + {"id":"state-done","name":"Done","type":"completed"}]}}}`} + +// issueIDResp is a scripted (team, number) -> issue UUID resolution. +var issueIDResp = scriptedResponse{status: 200, body: `{"data":{"issues":{"nodes":[{"id":"issue-uuid-1"}]}}}`} + +// updatedIssueResp is a scripted successful issueUpdate returning the issue in +// its post-transition (completed) state. +var updatedIssueResp = scriptedResponse{status: 200, body: `{"data":{"issueUpdate":{"issue":{ + "number":42,"title":"a bug","description":"body","url":"https://linear.app/x/issue/RIG-42", + "state":{"name":"Done","type":"completed"},"labels":{"nodes":[]},"creator":null, + "updatedAt":"2026-08-01T12:30:00Z"}}}}`} + +// A second transition inside the TTL reuses the cached state list: the teams and +// workflowStates queries run ONCE across two transitions, so the second spends +// only its issue-id resolve + mutation. Without the cache the second transition +// would re-query both. +func TestLinearTransitionCachesWorkflowStatesWithinTTL(t *testing.T) { + rt := &scriptedRoundTripper{responses: []scriptedResponse{ + teamResp, statesResp, issueIDResp, updatedIssueResp, + issueIDResp, updatedIssueResp, + }} + l := newTestLinear(rt, &fakeTokenSource{token: "t"}, slog.New(&capturingHandler{})) + + for i := range 2 { + if _, err := l.TransitionIssueState(context.Background(), "RIG", 42, TransitionState{State: stateClosed}); err != nil { + t.Fatalf("transition %d: %v", i+1, err) + } + } + if got := len(rt.requests); got != 6 { + t.Fatalf("two transitions emitted %d requests, want 6 (the second must reuse the cached team + state list)", got) + } +} + +// A state list that has gone stale past the TTL is refetched, not reused: the +// second transition re-runs the workflowStates query and writes the id the FRESH +// list carries. Without the TTL a state renamed in the Linear UI would resolve to +// the dead id for the process lifetime. +func TestLinearTransitionRefetchesWorkflowStatesAfterTTL(t *testing.T) { + renamed := scriptedResponse{status: 200, body: `{"data":{"workflowStates":{"nodes":[ + {"id":"state-shipped","name":"Shipped","type":"completed"}]}}}`} + rt := &scriptedRoundTripper{responses: []scriptedResponse{ + teamResp, statesResp, issueIDResp, updatedIssueResp, + renamed, issueIDResp, updatedIssueResp, + }} + l := newTestLinear(rt, &fakeTokenSource{token: "t"}, slog.New(&capturingHandler{})) + now := time.Now() + l.now = func() time.Time { return now } + + if _, err := l.TransitionIssueState(context.Background(), "RIG", 42, TransitionState{State: stateClosed}); err != nil { + t.Fatalf("first transition: %v", err) + } + now = now.Add(workflowStateTTL + time.Second) + if _, err := l.TransitionIssueState(context.Background(), "RIG", 42, TransitionState{State: stateClosed}); err != nil { + t.Fatalf("post-TTL transition: %v", err) + } + + if got := len(rt.requests); got != 7 { + t.Fatalf("post-TTL transition emitted %d requests total, want 7 (the state list must be refetched)", got) + } + _, vars := decodeGraphQLReq(t, readReqBody(t, rt.requests[6])) + if got := vars["input"].(map[string]any)["stateId"]; got != "state-shipped" { + t.Errorf("post-TTL mutation stateId = %v, want state-shipped (the refetched list's id)", got) + } +} + +// A mutation rejected against a CACHED state id is retried ONCE against a freshly +// fetched list, and the retry writes the new id. This is the resolve-then-write +// staleness the TTL alone cannot close: a state deleted between the resolve and +// the write would otherwise fail every transition until the TTL lapsed. +func TestLinearTransitionInvalidatesAndRetriesOnceOnStaleState(t *testing.T) { + staleReject := scriptedResponse{status: 200, body: `{"errors":[{"message":"Entity not found: WorkflowState"}]}`} + renamed := scriptedResponse{status: 200, body: `{"data":{"workflowStates":{"nodes":[ + {"id":"state-shipped","name":"Shipped","type":"completed"}]}}}`} + rt := &scriptedRoundTripper{responses: []scriptedResponse{ + // Warm the cache with a successful transition. + teamResp, statesResp, issueIDResp, updatedIssueResp, + // Second transition: resolves off cache, the mutation is rejected, + // the list is refetched and the mutation retried against the new id. + issueIDResp, staleReject, renamed, updatedIssueResp, + }} + l := newTestLinear(rt, &fakeTokenSource{token: "t"}, slog.New(&capturingHandler{})) + + if _, err := l.TransitionIssueState(context.Background(), "RIG", 42, TransitionState{State: stateClosed}); err != nil { + t.Fatalf("warm-up transition: %v", err) + } + got, err := l.TransitionIssueState(context.Background(), "RIG", 42, TransitionState{State: stateClosed}) + if err != nil { + t.Fatalf("stale-state transition: %v", err) + } + if got.State != stateClosed { + t.Errorf("retried transition State = %q, want %q", got.State, stateClosed) + } + if n := len(rt.requests); n != 8 { + t.Fatalf("stale-state transition emitted %d requests total, want 8 (reject -> refetch -> retry)", n) + } + _, vars := decodeGraphQLReq(t, readReqBody(t, rt.requests[7])) + if id := vars["input"].(map[string]any)["stateId"]; id != "state-shipped" { + t.Errorf("retry mutation stateId = %v, want state-shipped (the refetched id)", id) + } +} + +// The retry is ONCE: a second rejection against the FRESH list surfaces, rather +// than looping. A genuinely bad target must fail, not spin. +func TestLinearTransitionRetriesAtMostOnce(t *testing.T) { + reject := scriptedResponse{status: 200, body: `{"errors":[{"message":"Entity not found: WorkflowState"}]}`} + rt := &scriptedRoundTripper{responses: []scriptedResponse{ + teamResp, statesResp, issueIDResp, updatedIssueResp, + issueIDResp, reject, statesResp, reject, + }} + l := newTestLinear(rt, &fakeTokenSource{token: "t"}, slog.New(&capturingHandler{})) + + if _, err := l.TransitionIssueState(context.Background(), "RIG", 42, TransitionState{State: stateClosed}); err != nil { + t.Fatalf("warm-up transition: %v", err) + } + _, err := l.TransitionIssueState(context.Background(), "RIG", 42, TransitionState{State: stateClosed}) + if err == nil { + t.Fatal("a mutation rejected twice must fail, not retry again") + } + if n := len(rt.requests); n != 8 { + t.Fatalf("twice-rejected transition emitted %d requests total, want 8 (exactly one retry)", n) + } +} + +// A rate limit on the mutation is NOT a staleness signal and must not burn the +// retry: the gate is armed, the error surfaces, and no refetch happens. +func TestLinearTransitionDoesNotRetryOnRateLimit(t *testing.T) { + limited := scriptedResponse{ + status: 429, + body: `{"errors":[{"message":"rate limited"}]}`, + headers: map[string]string{"Retry-After": "30"}, + } + rt := &scriptedRoundTripper{responses: []scriptedResponse{ + teamResp, statesResp, issueIDResp, updatedIssueResp, + issueIDResp, limited, + }} + l := newTestLinear(rt, &fakeTokenSource{token: "t"}, slog.New(&capturingHandler{})) + + if _, err := l.TransitionIssueState(context.Background(), "RIG", 42, TransitionState{State: stateClosed}); err != nil { + t.Fatalf("warm-up transition: %v", err) + } + _, err := l.TransitionIssueState(context.Background(), "RIG", 42, TransitionState{State: stateClosed}) + if !errors.Is(err, ErrBudgetExhausted) { + t.Fatalf("err = %v, want ErrBudgetExhausted", err) + } + if n := len(rt.requests); n != 6 { + t.Fatalf("rate-limited transition emitted %d requests total, want 6 (a rate limit must not burn the retry)", n) + } +} + +// A non-staleness HTTP-200 GraphQL rejection (here a permission denial) is NOT +// a staleness signal either, even though it arrives on the same 200 the stale +// rejection does. Only linearStaleStateMarker earns the retry: any other reason +// Linear refuses the mutation is one a refetch cannot change, so burning the +// retry would cost a workflowStates query plus a re-issued mutation Linear has +// already declined. The sibling of the rate-limit test above, at the arm the +// status check alone cannot separate. +func TestLinearTransitionDoesNotRetryOnNonStaleness200(t *testing.T) { + denied := scriptedResponse{ + status: 200, + body: `{"errors":[{"message":"You do not have permission to update this issue"}]}`, + } + rt := &scriptedRoundTripper{responses: []scriptedResponse{ + teamResp, statesResp, issueIDResp, updatedIssueResp, + issueIDResp, denied, + }} + l := newTestLinear(rt, &fakeTokenSource{token: "t"}, slog.New(&capturingHandler{})) + + if _, err := l.TransitionIssueState(context.Background(), "RIG", 42, TransitionState{State: stateClosed}); err != nil { + t.Fatalf("warm-up transition: %v", err) + } + _, err := l.TransitionIssueState(context.Background(), "RIG", 42, TransitionState{State: stateClosed}) + var se *StatusError + if !errors.As(err, &se) { + t.Fatalf("err = %v, want *StatusError", err) + } + if !strings.Contains(se.Message, "do not have permission") { + t.Errorf("Message = %q, want the permission denial surfaced verbatim", se.Message) + } + if n := len(rt.requests); n != 6 { + t.Fatalf("permission-denied transition emitted %d requests total, want 6 (a non-staleness 200 must not burn the retry on a refetch + re-issued mutation)", n) + } +} + +// A workflow-state page returned FULL is treated as truncated and rejected at +// 422, not resolved against: the query is unpaginated, so a list at the cap may +// be missing states, and resolving would either reject a name that exists or +// default-map to an only-apparently-sole candidate. The rejection must land +// BEFORE the issue-id resolve and the mutation, so the request count is the +// proof: team + states only. +func TestLinearTransitionRejectsTruncatedWorkflowStatePage(t *testing.T) { + nodes := make([]string, 0, workflowStatePageCap) + for i := range workflowStatePageCap { + nodes = append(nodes, `{"id":"state-`+strconv.Itoa(i)+`","name":"S`+strconv.Itoa(i)+`","type":"completed"}`) + } + full := scriptedResponse{ + status: 200, + body: `{"data":{"workflowStates":{"nodes":[` + strings.Join(nodes, ",") + `]}}}`, + } + rt := &scriptedRoundTripper{responses: []scriptedResponse{teamResp, full}} + l := newTestLinear(rt, &fakeTokenSource{token: "t"}, slog.New(&capturingHandler{})) + + _, err := l.TransitionIssueState(context.Background(), "RIG", 42, TransitionState{State: stateClosed, WorkflowState: "S0"}) + var se *StatusError + if !errors.As(err, &se) { + t.Fatalf("err = %v, want *StatusError", err) + } + if se.Status != http.StatusUnprocessableEntity { + t.Errorf("Status = %d, want 422 (the in-band invalid_argument the Service flattens to)", se.Status) + } + for _, want := range []string{"RIG", "truncated", "explicit workflow state"} { + if !strings.Contains(se.Message, want) { + t.Errorf("Message = %q, want it to contain %q", se.Message, want) + } + } + if n := len(rt.requests); n != 2 { + t.Fatalf("truncated-page transition emitted %d requests total, want 2 (team + states; the rejection must land before the issue-id resolve and the mutation)", n) + } +} + +// The PR half is unsupported — Linear has no pull requests. +func TestLinearTransitionPullRequestStateUnsupported(t *testing.T) { + l := newTestLinear(&scriptedRoundTripper{}, &fakeTokenSource{token: "t"}, slog.New(&capturingHandler{})) + if _, err := l.TransitionPullRequestState(context.Background(), "RIG", 1, TransitionState{State: stateClosed}); !errors.Is(err, ErrUnsupported) { + t.Fatalf("err = %v, want ErrUnsupported", err) + } +} diff --git a/go/internal/forge/provider.go b/go/internal/forge/provider.go index 5e0cb2386..76df45be5 100644 --- a/go/internal/forge/provider.go +++ b/go/internal/forge/provider.go @@ -228,6 +228,17 @@ type IssueFilter struct { Labels []string } +// TransitionState is the input to Provider.TransitionIssueState / +// TransitionPullRequestState. State is the portable target ("open"|"closed"); +// CloseReason and WorkflowState are the per-provider refinements. A provider +// receiving a refinement it cannot express has already been screened at the +// server arm, so it may ignore the foreign field. +type TransitionState struct { + State string // "open" | "closed" + CloseReason string // GitHub issues: "completed" | "not_planned"; "" = default + WorkflowState string // Linear: target workflow state name; "" = default mapping +} + // Provider is one forge backend. Every method is a network call against the // provider's API using the Server-held credential; none accept a credential // argument (the provider closes over its own). Body handling is the PROVIDER'S @@ -249,6 +260,14 @@ type Provider interface { //nolint:interfacebloat // one method per forge operat // Checks returns the rolled-up CI/status state for a PR head. Separated from // GetPullRequest because the subscription poller needs it alone (#995 Decision 5). Checks(ctx context.Context, repo string, number uint64) (Checks, error) + // TransitionIssueState moves an existing issue between forge states, + // returning the UPDATED issue — the write response IS the new truth, so no + // implementation re-reads. in.State is the portable target; the refinements + // are screened at the server arm, so a provider may ignore a foreign one. + TransitionIssueState(ctx context.Context, repo string, number uint64, in TransitionState) (Issue, error) + // TransitionPullRequestState is the PR twin. A provider with no PR model + // returns ErrUnsupported. + TransitionPullRequestState(ctx context.Context, repo string, number uint64, in TransitionState) (PullRequest, error) // BodyLimit is the maximum body size (in BYTES) the Service enforces before // a write. Zero means unlimited (the fake's default). See GitHub.BodyLimit // for the byte-vs-character-cap rationale (A9). diff --git a/go/internal/forge/testdata/github/transition_issue_close_default.json b/go/internal/forge/testdata/github/transition_issue_close_default.json new file mode 100644 index 000000000..3f6d289c6 --- /dev/null +++ b/go/internal/forge/testdata/github/transition_issue_close_default.json @@ -0,0 +1,43 @@ +{ + "name": "transition_issue_close_default", + "request": { + "op": "transition_issue_state", + "repo": "org/repo", + "number": 42, + "input": { + "state": "closed" + }, + "method": "PATCH", + "path": "/repos/org/repo/issues/42", + "headers": { + "Content-Type": "application/json", + "Accept": "application/vnd.github+json" + }, + "body": { + "state": "closed" + } + }, + "response": { + "status": 200, + "body": { + "number": 42, + "title": "a bug", + "body": "stamped body", + "state": "closed", + "html_url": "https://github.com/org/repo/issues/42", + "user": { "login": "octocat" }, + "labels": [], + "updated_at": "2026-08-01T12:31:00Z" + }, + "want": { + "Number": 42, + "Title": "a bug", + "Body": "stamped body", + "State": "closed", + "URL": "https://github.com/org/repo/issues/42", + "ForgeAccount": "octocat", + "Labels": [], + "UpdatedAt": "2026-08-01T12:31:00Z" + } + } +} diff --git a/go/internal/forge/testdata/github/transition_issue_close_reason.json b/go/internal/forge/testdata/github/transition_issue_close_reason.json new file mode 100644 index 000000000..89c5311e9 --- /dev/null +++ b/go/internal/forge/testdata/github/transition_issue_close_reason.json @@ -0,0 +1,45 @@ +{ + "name": "transition_issue_close_reason", + "request": { + "op": "transition_issue_state", + "repo": "org/repo", + "number": 42, + "input": { + "state": "closed", + "closeReason": "not_planned" + }, + "method": "PATCH", + "path": "/repos/org/repo/issues/42", + "headers": { + "Content-Type": "application/json", + "Accept": "application/vnd.github+json" + }, + "body": { + "state": "closed", + "state_reason": "not_planned" + } + }, + "response": { + "status": 200, + "body": { + "number": 42, + "title": "a bug", + "body": "stamped body", + "state": "closed", + "html_url": "https://github.com/org/repo/issues/42", + "user": { "login": "octocat" }, + "labels": [{ "name": "bug" }], + "updated_at": "2026-08-01T12:30:00Z" + }, + "want": { + "Number": 42, + "Title": "a bug", + "Body": "stamped body", + "State": "closed", + "URL": "https://github.com/org/repo/issues/42", + "ForgeAccount": "octocat", + "Labels": ["bug"], + "UpdatedAt": "2026-08-01T12:30:00Z" + } + } +} diff --git a/go/internal/forge/testdata/github/transition_issue_reopen.json b/go/internal/forge/testdata/github/transition_issue_reopen.json new file mode 100644 index 000000000..345cc15cb --- /dev/null +++ b/go/internal/forge/testdata/github/transition_issue_reopen.json @@ -0,0 +1,44 @@ +{ + "name": "transition_issue_reopen", + "request": { + "op": "transition_issue_state", + "repo": "org/repo", + "number": 42, + "input": { + "state": "open", + "closeReason": "completed" + }, + "method": "PATCH", + "path": "/repos/org/repo/issues/42", + "headers": { + "Content-Type": "application/json", + "Accept": "application/vnd.github+json" + }, + "body": { + "state": "open" + } + }, + "response": { + "status": 200, + "body": { + "number": 42, + "title": "a bug", + "body": "stamped body", + "state": "open", + "html_url": "https://github.com/org/repo/issues/42", + "user": { "login": "octocat" }, + "labels": [], + "updated_at": "2026-08-01T12:32:00Z" + }, + "want": { + "Number": 42, + "Title": "a bug", + "Body": "stamped body", + "State": "open", + "URL": "https://github.com/org/repo/issues/42", + "ForgeAccount": "octocat", + "Labels": [], + "UpdatedAt": "2026-08-01T12:32:00Z" + } + } +} diff --git a/go/internal/forge/testdata/github/transition_pull_request_close.json b/go/internal/forge/testdata/github/transition_pull_request_close.json new file mode 100644 index 000000000..f3625d67a --- /dev/null +++ b/go/internal/forge/testdata/github/transition_pull_request_close.json @@ -0,0 +1,53 @@ +{ + "name": "transition_pull_request_close", + "request": { + "op": "transition_pull_request_state", + "repo": "org/repo", + "number": 7, + "input": { + "state": "closed" + }, + "method": "PATCH", + "path": "/repos/org/repo/pulls/7", + "headers": { + "Content-Type": "application/json", + "Accept": "application/vnd.github+json" + }, + "body": { + "state": "closed" + } + }, + "response": { + "status": 200, + "body": { + "number": 7, + "title": "a change", + "body": "stamped body", + "state": "closed", + "html_url": "https://github.com/org/repo/pull/7", + "draft": false, + "additions": 10, + "deletions": 2, + "changed_files": 3, + "merged": false, + "head": { "ref": "feature", "sha": "abc123" }, + "base": { "ref": "main" }, + "user": { "login": "octocat" } + }, + "want": { + "Number": 7, + "Title": "a change", + "Body": "stamped body", + "State": "closed", + "URL": "https://github.com/org/repo/pull/7", + "HeadRef": "feature", + "BaseRef": "main", + "ForgeAccount": "octocat", + "Draft": false, + "Changed": { "Files": 3, "Additions": 10, "Deletions": 2 }, + "Checks": { "HeadSHA": "", "State": "", "Checks": null }, + "Reviews": null, + "Threads": null + } + } +} diff --git a/go/internal/forge/testdata/github/transition_pull_request_reopen.json b/go/internal/forge/testdata/github/transition_pull_request_reopen.json new file mode 100644 index 000000000..f7626a2cd --- /dev/null +++ b/go/internal/forge/testdata/github/transition_pull_request_reopen.json @@ -0,0 +1,53 @@ +{ + "name": "transition_pull_request_reopen", + "request": { + "op": "transition_pull_request_state", + "repo": "org/repo", + "number": 7, + "input": { + "state": "open" + }, + "method": "PATCH", + "path": "/repos/org/repo/pulls/7", + "headers": { + "Content-Type": "application/json", + "Accept": "application/vnd.github+json" + }, + "body": { + "state": "open" + } + }, + "response": { + "status": 200, + "body": { + "number": 7, + "title": "a change", + "body": "stamped body", + "state": "open", + "html_url": "https://github.com/org/repo/pull/7", + "draft": false, + "additions": 10, + "deletions": 2, + "changed_files": 3, + "merged": false, + "head": { "ref": "feature", "sha": "abc123" }, + "base": { "ref": "main" }, + "user": { "login": "octocat" } + }, + "want": { + "Number": 7, + "Title": "a change", + "Body": "stamped body", + "State": "open", + "URL": "https://github.com/org/repo/pull/7", + "HeadRef": "feature", + "BaseRef": "main", + "ForgeAccount": "octocat", + "Draft": false, + "Changed": { "Files": 3, "Additions": 10, "Deletions": 2 }, + "Checks": { "HeadSHA": "", "State": "", "Checks": null }, + "Reviews": null, + "Threads": null + } + } +} diff --git a/go/internal/forge/testdata/github/transition_pull_request_reopen_merged.json b/go/internal/forge/testdata/github/transition_pull_request_reopen_merged.json new file mode 100644 index 000000000..2185f637c --- /dev/null +++ b/go/internal/forge/testdata/github/transition_pull_request_reopen_merged.json @@ -0,0 +1,37 @@ +{ + "name": "transition_pull_request_reopen_merged", + "request": { + "op": "transition_pull_request_state", + "repo": "org/repo", + "number": 7, + "input": { + "state": "open" + }, + "method": "PATCH", + "path": "/repos/org/repo/pulls/7", + "headers": { + "Content-Type": "application/json", + "Accept": "application/vnd.github+json" + }, + "body": { + "state": "open" + } + }, + "response": { + "status": 422, + "body": { + "message": "Validation Failed", + "errors": [ + { + "resource": "PullRequest", + "code": "custom", + "message": "State cannot be changed. The pull request is already merged." + } + ] + }, + "wantError": { + "status": 422, + "contains": ["Validation Failed"] + } + } +} diff --git a/go/internal/forge/testdata/linear/transition_issue_ambiguous_default.json b/go/internal/forge/testdata/linear/transition_issue_ambiguous_default.json new file mode 100644 index 000000000..ecde77dfc --- /dev/null +++ b/go/internal/forge/testdata/linear/transition_issue_ambiguous_default.json @@ -0,0 +1,44 @@ +{ + "name": "transition_issue_ambiguous_default", + "request": { + "op": "transition_issue_state", + "repo": "RIG", + "number": 42, + "input": { + "state": "closed" + }, + "method": "POST", + "path": "/graphql" + }, + "response": { + "prelude": [ + { + "status": 200, + "body": { "data": { "teams": { "nodes": [{ "id": "team-uuid-1" }] } } } + } + ], + "status": 200, + "body": { + "data": { + "workflowStates": { + "nodes": [ + { "id": "state-todo", "name": "Todo", "type": "unstarted" }, + { "id": "state-done", "name": "Done", "type": "completed" }, + { "id": "state-shipped", "name": "Shipped", "type": "completed" } + ] + } + } + }, + "wantError": { + "status": 422, + "contains": [ + "RIG", + "2 workflow states of type", + "completed", + "\"Done\"", + "\"Shipped\"", + "pass an explicit workflow state" + ] + } + } +} diff --git a/go/internal/forge/testdata/linear/transition_issue_close_by_name.json b/go/internal/forge/testdata/linear/transition_issue_close_by_name.json new file mode 100644 index 000000000..19aa1dd71 --- /dev/null +++ b/go/internal/forge/testdata/linear/transition_issue_close_by_name.json @@ -0,0 +1,84 @@ +{ + "name": "transition_issue_close_by_name", + "request": { + "op": "transition_issue_state", + "repo": "RIG", + "number": 42, + "input": { + "state": "closed", + "workflowState": "Canceled" + }, + "method": "POST", + "path": "/graphql", + "headers": { + "Content-Type": "application/json", + "Accept": "application/json" + }, + "body": { + "query": "mutation CompassIssueStateUpdate($id: String!, $input: IssueUpdateInput!) {\n issueUpdate(id: $id, input: $input) {\n issue { ...CompassIssueFields }\n }\n}\nfragment CompassIssueFields on Issue {\n number\n title\n description\n url\n state { name type }\n labels { nodes { name } }\n creator { displayName }\n updatedAt\n}", + "variables": { + "id": "issue-uuid-1", + "input": { "stateId": "state-canceled" } + } + } + }, + "response": { + "prelude": [ + { + "status": 200, + "body": { "data": { "teams": { "nodes": [{ "id": "team-uuid-1" }] } } } + }, + { + "status": 200, + "body": { + "data": { + "workflowStates": { + "nodes": [ + { "id": "state-todo", "name": "Todo", "type": "unstarted" }, + { "id": "state-done", "name": "Done", "type": "completed" }, + { + "id": "state-canceled", + "name": "Canceled", + "type": "canceled" + } + ] + } + } + } + }, + { + "status": 200, + "body": { + "data": { "issues": { "nodes": [{ "id": "issue-uuid-1" }] } } + } + } + ], + "status": 200, + "body": { + "data": { + "issueUpdate": { + "issue": { + "number": 42, + "title": "a bug", + "description": "stamped body", + "url": "https://linear.app/x/issue/RIG-42", + "state": { "name": "Canceled", "type": "canceled" }, + "labels": { "nodes": [] }, + "creator": null, + "updatedAt": "2026-08-01T12:33:00Z" + } + } + } + }, + "want": { + "Number": 42, + "Title": "a bug", + "Body": "stamped body", + "State": "closed", + "URL": "https://linear.app/x/issue/RIG-42", + "ForgeAccount": "", + "Labels": [], + "UpdatedAt": "2026-08-01T12:33:00Z" + } + } +} diff --git a/go/internal/forge/testdata/linear/transition_issue_close_default.json b/go/internal/forge/testdata/linear/transition_issue_close_default.json new file mode 100644 index 000000000..9185378bf --- /dev/null +++ b/go/internal/forge/testdata/linear/transition_issue_close_default.json @@ -0,0 +1,89 @@ +{ + "name": "transition_issue_close_default", + "request": { + "op": "transition_issue_state", + "repo": "RIG", + "number": 42, + "input": { + "state": "closed" + }, + "method": "POST", + "path": "/graphql", + "headers": { + "Content-Type": "application/json", + "Accept": "application/json" + }, + "body": { + "query": "mutation CompassIssueStateUpdate($id: String!, $input: IssueUpdateInput!) {\n issueUpdate(id: $id, input: $input) {\n issue { ...CompassIssueFields }\n }\n}\nfragment CompassIssueFields on Issue {\n number\n title\n description\n url\n state { name type }\n labels { nodes { name } }\n creator { displayName }\n updatedAt\n}", + "variables": { + "id": "issue-uuid-1", + "input": { "stateId": "state-done" } + } + } + }, + "response": { + "prelude": [ + { + "status": 200, + "body": { "data": { "teams": { "nodes": [{ "id": "team-uuid-1" }] } } } + }, + { + "status": 200, + "body": { + "data": { + "workflowStates": { + "nodes": [ + { "id": "state-backlog", "name": "Backlog", "type": "backlog" }, + { "id": "state-todo", "name": "Todo", "type": "unstarted" }, + { + "id": "state-doing", + "name": "In Progress", + "type": "started" + }, + { "id": "state-done", "name": "Done", "type": "completed" }, + { + "id": "state-canceled", + "name": "Canceled", + "type": "canceled" + } + ] + } + } + } + }, + { + "status": 200, + "body": { + "data": { "issues": { "nodes": [{ "id": "issue-uuid-1" }] } } + } + } + ], + "status": 200, + "body": { + "data": { + "issueUpdate": { + "issue": { + "number": 42, + "title": "a bug", + "description": "stamped body", + "url": "https://linear.app/x/issue/RIG-42", + "state": { "name": "Done", "type": "completed" }, + "labels": { "nodes": [] }, + "creator": null, + "updatedAt": "2026-08-01T12:30:00Z" + } + } + } + }, + "want": { + "Number": 42, + "Title": "a bug", + "Body": "stamped body", + "State": "closed", + "URL": "https://linear.app/x/issue/RIG-42", + "ForgeAccount": "", + "Labels": [], + "UpdatedAt": "2026-08-01T12:30:00Z" + } + } +} diff --git a/go/internal/forge/testdata/linear/transition_issue_duplicate_name.json b/go/internal/forge/testdata/linear/transition_issue_duplicate_name.json new file mode 100644 index 000000000..63b72737d --- /dev/null +++ b/go/internal/forge/testdata/linear/transition_issue_duplicate_name.json @@ -0,0 +1,43 @@ +{ + "name": "transition_issue_duplicate_name", + "request": { + "op": "transition_issue_state", + "repo": "RIG", + "number": 42, + "input": { + "state": "closed", + "workflowState": "Done" + }, + "method": "POST", + "path": "/graphql" + }, + "response": { + "prelude": [ + { + "status": 200, + "body": { "data": { "teams": { "nodes": [{ "id": "team-uuid-1" }] } } } + } + ], + "status": 200, + "body": { + "data": { + "workflowStates": { + "nodes": [ + { "id": "state-todo", "name": "Todo", "type": "unstarted" }, + { "id": "state-done-a", "name": "Done", "type": "completed" }, + { "id": "state-done-b", "name": "Done", "type": "completed" } + ] + } + } + }, + "wantError": { + "status": 422, + "contains": [ + "RIG", + "2 workflow states named", + "Done", + "does not identify one" + ] + } + } +} diff --git a/go/internal/forge/testdata/linear/transition_issue_reopen_default.json b/go/internal/forge/testdata/linear/transition_issue_reopen_default.json new file mode 100644 index 000000000..0a6c7b16a --- /dev/null +++ b/go/internal/forge/testdata/linear/transition_issue_reopen_default.json @@ -0,0 +1,79 @@ +{ + "name": "transition_issue_reopen_default", + "request": { + "op": "transition_issue_state", + "repo": "RIG", + "number": 42, + "input": { + "state": "open" + }, + "method": "POST", + "path": "/graphql", + "headers": { + "Content-Type": "application/json", + "Accept": "application/json" + }, + "body": { + "query": "mutation CompassIssueStateUpdate($id: String!, $input: IssueUpdateInput!) {\n issueUpdate(id: $id, input: $input) {\n issue { ...CompassIssueFields }\n }\n}\nfragment CompassIssueFields on Issue {\n number\n title\n description\n url\n state { name type }\n labels { nodes { name } }\n creator { displayName }\n updatedAt\n}", + "variables": { + "id": "issue-uuid-1", + "input": { "stateId": "state-todo" } + } + } + }, + "response": { + "prelude": [ + { + "status": 200, + "body": { "data": { "teams": { "nodes": [{ "id": "team-uuid-1" }] } } } + }, + { + "status": 200, + "body": { + "data": { + "workflowStates": { + "nodes": [ + { "id": "state-backlog", "name": "Backlog", "type": "backlog" }, + { "id": "state-todo", "name": "Todo", "type": "unstarted" }, + { "id": "state-done", "name": "Done", "type": "completed" } + ] + } + } + } + }, + { + "status": 200, + "body": { + "data": { "issues": { "nodes": [{ "id": "issue-uuid-1" }] } } + } + } + ], + "status": 200, + "body": { + "data": { + "issueUpdate": { + "issue": { + "number": 42, + "title": "a bug", + "description": "stamped body", + "url": "https://linear.app/x/issue/RIG-42", + "state": { "name": "Todo", "type": "unstarted" }, + "labels": { "nodes": [] }, + "creator": null, + "updatedAt": "2026-08-01T12:34:00Z" + } + } + } + }, + "want": { + "Number": 42, + "Title": "a bug", + "Body": "stamped body", + "State": "open", + "URL": "https://linear.app/x/issue/RIG-42", + "ForgeAccount": "", + "Labels": [], + "UpdatedAt": "2026-08-01T12:34:00Z" + } + } +} diff --git a/go/internal/forge/testdata/linear/transition_issue_type_contradiction.json b/go/internal/forge/testdata/linear/transition_issue_type_contradiction.json new file mode 100644 index 000000000..31e89a22c --- /dev/null +++ b/go/internal/forge/testdata/linear/transition_issue_type_contradiction.json @@ -0,0 +1,38 @@ +{ + "name": "transition_issue_type_contradiction", + "request": { + "op": "transition_issue_state", + "repo": "RIG", + "number": 42, + "input": { + "state": "closed", + "workflowState": "In Progress" + }, + "method": "POST", + "path": "/graphql" + }, + "response": { + "prelude": [ + { + "status": 200, + "body": { "data": { "teams": { "nodes": [{ "id": "team-uuid-1" }] } } } + } + ], + "status": 200, + "body": { + "data": { + "workflowStates": { + "nodes": [ + { "id": "state-todo", "name": "Todo", "type": "unstarted" }, + { "id": "state-doing", "name": "In Progress", "type": "started" }, + { "id": "state-done", "name": "Done", "type": "completed" } + ] + } + } + }, + "wantError": { + "status": 422, + "contains": ["In Progress", "RIG", "started", "contradicts", "closed"] + } + } +} diff --git a/go/internal/forge/testdata/linear/transition_issue_unknown_name.json b/go/internal/forge/testdata/linear/transition_issue_unknown_name.json new file mode 100644 index 000000000..8482b0388 --- /dev/null +++ b/go/internal/forge/testdata/linear/transition_issue_unknown_name.json @@ -0,0 +1,37 @@ +{ + "name": "transition_issue_unknown_name", + "request": { + "op": "transition_issue_state", + "repo": "RIG", + "number": 42, + "input": { + "state": "closed", + "workflowState": "Shipped" + }, + "method": "POST", + "path": "/graphql" + }, + "response": { + "prelude": [ + { + "status": 200, + "body": { "data": { "teams": { "nodes": [{ "id": "team-uuid-1" }] } } } + } + ], + "status": 200, + "body": { + "data": { + "workflowStates": { + "nodes": [ + { "id": "state-todo", "name": "Todo", "type": "unstarted" }, + { "id": "state-done", "name": "Done", "type": "completed" } + ] + } + } + }, + "wantError": { + "status": 422, + "contains": ["RIG", "no workflow state named", "Shipped"] + } + } +} diff --git a/go/internal/gen/compass/v1/agent_gateway.pb.go b/go/internal/gen/compass/v1/agent_gateway.pb.go index fb75e1537..b907eb95b 100644 --- a/go/internal/gen/compass/v1/agent_gateway.pb.go +++ b/go/internal/gen/compass/v1/agent_gateway.pb.go @@ -1214,6 +1214,8 @@ type ForgeCallRequest struct { // *ForgeCallRequest_Subscribe // *ForgeCallRequest_Unsubscribe // *ForgeCallRequest_SubmitReview + // *ForgeCallRequest_TransitionIssueState + // *ForgeCallRequest_TransitionPullRequestState Call isForgeCallRequest_Call `protobuf_oneof:"call"` // Which forge the call addresses. UNSET selects the default (configured // GitHub) forge — additive, existing callers unchanged. An unknown/unconfigured @@ -1227,7 +1229,7 @@ type ForgeCallRequest struct { // join + Provision dedup)"). A retried create with the same key returns the // ORIGINAL artifact, never a duplicate. Distinct from call_id, which is // correlation-only. Ignored on non-create arms. Field 13 is collision-free: - // call_id=1, oneof arms 2-11, forge=12. + // call_id=1, oneof arms 2-11 and 14-15, forge=12. ClientRequestId string `protobuf:"bytes,13,opt,name=client_request_id,json=clientRequestId,proto3" json:"client_request_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -1367,6 +1369,24 @@ func (x *ForgeCallRequest) GetSubmitReview() *SubmitReviewRequest { return nil } +func (x *ForgeCallRequest) GetTransitionIssueState() *TransitionIssueStateRequest { + if x != nil { + if x, ok := x.Call.(*ForgeCallRequest_TransitionIssueState); ok { + return x.TransitionIssueState + } + } + return nil +} + +func (x *ForgeCallRequest) GetTransitionPullRequestState() *TransitionPullRequestStateRequest { + if x != nil { + if x, ok := x.Call.(*ForgeCallRequest_TransitionPullRequestState); ok { + return x.TransitionPullRequestState + } + } + return nil +} + func (x *ForgeCallRequest) GetForge() *v1.ForgeRef { if x != nil { return x.Forge @@ -1425,6 +1445,14 @@ type ForgeCallRequest_SubmitReview struct { SubmitReview *SubmitReviewRequest `protobuf:"bytes,11,opt,name=submit_review,json=submitReview,proto3,oneof"` } +type ForgeCallRequest_TransitionIssueState struct { + TransitionIssueState *TransitionIssueStateRequest `protobuf:"bytes,14,opt,name=transition_issue_state,json=transitionIssueState,proto3,oneof"` +} + +type ForgeCallRequest_TransitionPullRequestState struct { + TransitionPullRequestState *TransitionPullRequestStateRequest `protobuf:"bytes,15,opt,name=transition_pull_request_state,json=transitionPullRequestState,proto3,oneof"` +} + func (*ForgeCallRequest_CreateIssue) isForgeCallRequest_Call() {} func (*ForgeCallRequest_CommentOnIssue) isForgeCallRequest_Call() {} @@ -1445,6 +1473,10 @@ func (*ForgeCallRequest_Unsubscribe) isForgeCallRequest_Call() {} func (*ForgeCallRequest_SubmitReview) isForgeCallRequest_Call() {} +func (*ForgeCallRequest_TransitionIssueState) isForgeCallRequest_Call() {} + +func (*ForgeCallRequest_TransitionPullRequestState) isForgeCallRequest_Call() {} + // The result of one forge call, correlated by `call_id`. Per DL-069 no raw forge // shape is a wire type: the domain result arms retype to the canonical compass.v1 // types (DL-092 supersedes #995's forge-shaped Issue/PullRequest domain messages), @@ -1600,7 +1632,7 @@ type isForgeCallResult_Result interface { } type ForgeCallResult_Issue struct { - Issue *v1.Issue `protobuf:"bytes,2,opt,name=issue,proto3,oneof"` // create_issue / get_issue + Issue *v1.Issue `protobuf:"bytes,2,opt,name=issue,proto3,oneof"` // create_issue / get_issue / transition_issue_state } type ForgeCallResult_IssueComment struct { @@ -1612,7 +1644,7 @@ type ForgeCallResult_Issues struct { } type ForgeCallResult_PullRequest struct { - PullRequest *v1.PullRequest `protobuf:"bytes,5,opt,name=pull_request,json=pullRequest,proto3,oneof"` // create_pull_request / get_pull_request + PullRequest *v1.PullRequest `protobuf:"bytes,5,opt,name=pull_request,json=pullRequest,proto3,oneof"` // create_pull_request / get_pull_request / transition_pull_request_state } type ForgeCallResult_PrComment struct { @@ -1716,7 +1748,7 @@ func (x *ForgeCallError) GetRetryAfterMs() uint32 { return 0 } -// The seven forge operation requests. Every field is a scalar — no forge domain +// The forge operation requests. Every field is a scalar — no forge domain // type appears in any request shape, so the request wire is identical under // either forge read model. `repo` is "/" on GitHub and the team // key on Linear, REQUIRED on every call — an empty `repo` is an invalid_argument @@ -2356,6 +2388,156 @@ func (x *ReviewCommentInput) GetBody() string { return "" } +// Move an existing artifact between forge states. Mutates a coordinate and +// mints none, so neither create-only mechanism applies: no client_request_id +// (a repeated transition to the same state is already idempotent at the forge) +// and no owner stamp (there is no body to stamp). `state` is the raw forge +// state string the whole read path already speaks (forge.Issue.State, +// ForgeNotification.state), not a new enum — DL-069's no-forge-shape rule +// concerns message types, which these add none of. Named on the *transition* +// stem because the board lane already owns SetIssueStateRequest, which +// operates on the Compass-local Issue.id + compass.v1.IssueState instead. +// A successful transition returns the UPDATED artifact on the existing +// ForgeCallResult.issue / .pull_request arms, so the caller sees +// post-transition truth exactly as a create's caller sees the created one. +type TransitionIssueStateRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Repo string `protobuf:"bytes,1,opt,name=repo,proto3" json:"repo,omitempty"` // REQUIRED; "/" on GitHub, team key on Linear + IssueNumber uint64 `protobuf:"varint,2,opt,name=issue_number,json=issueNumber,proto3" json:"issue_number,omitempty"` + State string `protobuf:"bytes,3,opt,name=state,proto3" json:"state,omitempty"` // REQUIRED: "open" | "closed" (the forge.Issue.State domain) + CloseReason string `protobuf:"bytes,4,opt,name=close_reason,json=closeReason,proto3" json:"close_reason,omitempty"` // GitHub only: "completed" | "not_planned"; "" = provider default + WorkflowState string `protobuf:"bytes,5,opt,name=workflow_state,json=workflowState,proto3" json:"workflow_state,omitempty"` // Linear only: target workflow state NAME; "" = default mapping + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TransitionIssueStateRequest) Reset() { + *x = TransitionIssueStateRequest{} + mi := &file_compass_v1_agent_gateway_proto_msgTypes[25] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TransitionIssueStateRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TransitionIssueStateRequest) ProtoMessage() {} + +func (x *TransitionIssueStateRequest) ProtoReflect() protoreflect.Message { + mi := &file_compass_v1_agent_gateway_proto_msgTypes[25] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TransitionIssueStateRequest.ProtoReflect.Descriptor instead. +func (*TransitionIssueStateRequest) Descriptor() ([]byte, []int) { + return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{25} +} + +func (x *TransitionIssueStateRequest) GetRepo() string { + if x != nil { + return x.Repo + } + return "" +} + +func (x *TransitionIssueStateRequest) GetIssueNumber() uint64 { + if x != nil { + return x.IssueNumber + } + return 0 +} + +func (x *TransitionIssueStateRequest) GetState() string { + if x != nil { + return x.State + } + return "" +} + +func (x *TransitionIssueStateRequest) GetCloseReason() string { + if x != nil { + return x.CloseReason + } + return "" +} + +func (x *TransitionIssueStateRequest) GetWorkflowState() string { + if x != nil { + return x.WorkflowState + } + return "" +} + +// The PR twin. No refinement fields: `close_reason` is a GitHub *issue* +// concept, and merge is a separate concern never expressed as a transition. +type TransitionPullRequestStateRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Repo string `protobuf:"bytes,1,opt,name=repo,proto3" json:"repo,omitempty"` // REQUIRED + PrNumber uint64 `protobuf:"varint,2,opt,name=pr_number,json=prNumber,proto3" json:"pr_number,omitempty"` + State string `protobuf:"bytes,3,opt,name=state,proto3" json:"state,omitempty"` // "open" | "closed" + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TransitionPullRequestStateRequest) Reset() { + *x = TransitionPullRequestStateRequest{} + mi := &file_compass_v1_agent_gateway_proto_msgTypes[26] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TransitionPullRequestStateRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TransitionPullRequestStateRequest) ProtoMessage() {} + +func (x *TransitionPullRequestStateRequest) ProtoReflect() protoreflect.Message { + mi := &file_compass_v1_agent_gateway_proto_msgTypes[26] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TransitionPullRequestStateRequest.ProtoReflect.Descriptor instead. +func (*TransitionPullRequestStateRequest) Descriptor() ([]byte, []int) { + return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{26} +} + +func (x *TransitionPullRequestStateRequest) GetRepo() string { + if x != nil { + return x.Repo + } + return "" +} + +func (x *TransitionPullRequestStateRequest) GetPrNumber() uint64 { + if x != nil { + return x.PrNumber + } + return 0 +} + +func (x *TransitionPullRequestStateRequest) GetState() string { + if x != nil { + return x.State + } + return "" +} + type SubscribeForgeRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Repo string `protobuf:"bytes,1,opt,name=repo,proto3" json:"repo,omitempty"` // GitHub owner/name; Linear team key @@ -2369,7 +2551,7 @@ type SubscribeForgeRequest struct { func (x *SubscribeForgeRequest) Reset() { *x = SubscribeForgeRequest{} - mi := &file_compass_v1_agent_gateway_proto_msgTypes[25] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2381,7 +2563,7 @@ func (x *SubscribeForgeRequest) String() string { func (*SubscribeForgeRequest) ProtoMessage() {} func (x *SubscribeForgeRequest) ProtoReflect() protoreflect.Message { - mi := &file_compass_v1_agent_gateway_proto_msgTypes[25] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2394,7 +2576,7 @@ func (x *SubscribeForgeRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SubscribeForgeRequest.ProtoReflect.Descriptor instead. func (*SubscribeForgeRequest) Descriptor() ([]byte, []int) { - return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{25} + return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{27} } func (x *SubscribeForgeRequest) GetRepo() string { @@ -2441,7 +2623,7 @@ type SubscribeForgeResponse struct { func (x *SubscribeForgeResponse) Reset() { *x = SubscribeForgeResponse{} - mi := &file_compass_v1_agent_gateway_proto_msgTypes[26] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2453,7 +2635,7 @@ func (x *SubscribeForgeResponse) String() string { func (*SubscribeForgeResponse) ProtoMessage() {} func (x *SubscribeForgeResponse) ProtoReflect() protoreflect.Message { - mi := &file_compass_v1_agent_gateway_proto_msgTypes[26] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[28] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2466,7 +2648,7 @@ func (x *SubscribeForgeResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SubscribeForgeResponse.ProtoReflect.Descriptor instead. func (*SubscribeForgeResponse) Descriptor() ([]byte, []int) { - return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{26} + return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{28} } func (x *SubscribeForgeResponse) GetSubscriptionId() string { @@ -2485,7 +2667,7 @@ type UnsubscribeForgeRequest struct { func (x *UnsubscribeForgeRequest) Reset() { *x = UnsubscribeForgeRequest{} - mi := &file_compass_v1_agent_gateway_proto_msgTypes[27] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[29] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2497,7 +2679,7 @@ func (x *UnsubscribeForgeRequest) String() string { func (*UnsubscribeForgeRequest) ProtoMessage() {} func (x *UnsubscribeForgeRequest) ProtoReflect() protoreflect.Message { - mi := &file_compass_v1_agent_gateway_proto_msgTypes[27] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[29] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2510,7 +2692,7 @@ func (x *UnsubscribeForgeRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UnsubscribeForgeRequest.ProtoReflect.Descriptor instead. func (*UnsubscribeForgeRequest) Descriptor() ([]byte, []int) { - return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{27} + return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{29} } func (x *UnsubscribeForgeRequest) GetSubscriptionId() string { @@ -2528,7 +2710,7 @@ type UnsubscribeForgeResponse struct { func (x *UnsubscribeForgeResponse) Reset() { *x = UnsubscribeForgeResponse{} - mi := &file_compass_v1_agent_gateway_proto_msgTypes[28] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[30] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2540,7 +2722,7 @@ func (x *UnsubscribeForgeResponse) String() string { func (*UnsubscribeForgeResponse) ProtoMessage() {} func (x *UnsubscribeForgeResponse) ProtoReflect() protoreflect.Message { - mi := &file_compass_v1_agent_gateway_proto_msgTypes[28] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[30] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2553,7 +2735,7 @@ func (x *UnsubscribeForgeResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UnsubscribeForgeResponse.ProtoReflect.Descriptor instead. func (*UnsubscribeForgeResponse) Descriptor() ([]byte, []int) { - return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{28} + return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{30} } // One agent-initiated board call. `call_id` is the agent-minted correlation id @@ -2574,7 +2756,7 @@ type BoardCallRequest struct { func (x *BoardCallRequest) Reset() { *x = BoardCallRequest{} - mi := &file_compass_v1_agent_gateway_proto_msgTypes[29] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[31] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2586,7 +2768,7 @@ func (x *BoardCallRequest) String() string { func (*BoardCallRequest) ProtoMessage() {} func (x *BoardCallRequest) ProtoReflect() protoreflect.Message { - mi := &file_compass_v1_agent_gateway_proto_msgTypes[29] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[31] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2599,7 +2781,7 @@ func (x *BoardCallRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use BoardCallRequest.ProtoReflect.Descriptor instead. func (*BoardCallRequest) Descriptor() ([]byte, []int) { - return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{29} + return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{31} } func (x *BoardCallRequest) GetCallId() string { @@ -2652,7 +2834,7 @@ type SetIssueStateRequest struct { func (x *SetIssueStateRequest) Reset() { *x = SetIssueStateRequest{} - mi := &file_compass_v1_agent_gateway_proto_msgTypes[30] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[32] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2664,7 +2846,7 @@ func (x *SetIssueStateRequest) String() string { func (*SetIssueStateRequest) ProtoMessage() {} func (x *SetIssueStateRequest) ProtoReflect() protoreflect.Message { - mi := &file_compass_v1_agent_gateway_proto_msgTypes[30] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[32] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2677,7 +2859,7 @@ func (x *SetIssueStateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SetIssueStateRequest.ProtoReflect.Descriptor instead. func (*SetIssueStateRequest) Descriptor() ([]byte, []int) { - return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{30} + return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{32} } func (x *SetIssueStateRequest) GetIssueId() string { @@ -2704,7 +2886,7 @@ type SetIssueStateResponse struct { func (x *SetIssueStateResponse) Reset() { *x = SetIssueStateResponse{} - mi := &file_compass_v1_agent_gateway_proto_msgTypes[31] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[33] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2716,7 +2898,7 @@ func (x *SetIssueStateResponse) String() string { func (*SetIssueStateResponse) ProtoMessage() {} func (x *SetIssueStateResponse) ProtoReflect() protoreflect.Message { - mi := &file_compass_v1_agent_gateway_proto_msgTypes[31] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[33] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2729,7 +2911,7 @@ func (x *SetIssueStateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SetIssueStateResponse.ProtoReflect.Descriptor instead. func (*SetIssueStateResponse) Descriptor() ([]byte, []int) { - return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{31} + return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{33} } func (x *SetIssueStateResponse) GetIssue() *v1.Issue { @@ -2757,7 +2939,7 @@ type BoardCallResult struct { func (x *BoardCallResult) Reset() { *x = BoardCallResult{} - mi := &file_compass_v1_agent_gateway_proto_msgTypes[32] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[34] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2769,7 +2951,7 @@ func (x *BoardCallResult) String() string { func (*BoardCallResult) ProtoMessage() {} func (x *BoardCallResult) ProtoReflect() protoreflect.Message { - mi := &file_compass_v1_agent_gateway_proto_msgTypes[32] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[34] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2782,7 +2964,7 @@ func (x *BoardCallResult) ProtoReflect() protoreflect.Message { // Deprecated: Use BoardCallResult.ProtoReflect.Descriptor instead. func (*BoardCallResult) Descriptor() ([]byte, []int) { - return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{32} + return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{34} } func (x *BoardCallResult) GetCallId() string { @@ -2846,7 +3028,7 @@ type BoardCallError struct { func (x *BoardCallError) Reset() { *x = BoardCallError{} - mi := &file_compass_v1_agent_gateway_proto_msgTypes[33] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[35] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2858,7 +3040,7 @@ func (x *BoardCallError) String() string { func (*BoardCallError) ProtoMessage() {} func (x *BoardCallError) ProtoReflect() protoreflect.Message { - mi := &file_compass_v1_agent_gateway_proto_msgTypes[33] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[35] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2871,7 +3053,7 @@ func (x *BoardCallError) ProtoReflect() protoreflect.Message { // Deprecated: Use BoardCallError.ProtoReflect.Descriptor instead. func (*BoardCallError) Descriptor() ([]byte, []int) { - return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{33} + return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{35} } func (x *BoardCallError) GetCode() string { @@ -2901,7 +3083,7 @@ type PublishFrameRequest struct { func (x *PublishFrameRequest) Reset() { *x = PublishFrameRequest{} - mi := &file_compass_v1_agent_gateway_proto_msgTypes[34] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[36] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2913,7 +3095,7 @@ func (x *PublishFrameRequest) String() string { func (*PublishFrameRequest) ProtoMessage() {} func (x *PublishFrameRequest) ProtoReflect() protoreflect.Message { - mi := &file_compass_v1_agent_gateway_proto_msgTypes[34] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[36] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2926,7 +3108,7 @@ func (x *PublishFrameRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use PublishFrameRequest.ProtoReflect.Descriptor instead. func (*PublishFrameRequest) Descriptor() ([]byte, []int) { - return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{34} + return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{36} } func (x *PublishFrameRequest) GetFrame() *AgentFrame { @@ -2945,7 +3127,7 @@ type PublishFrameResponse struct { func (x *PublishFrameResponse) Reset() { *x = PublishFrameResponse{} - mi := &file_compass_v1_agent_gateway_proto_msgTypes[35] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[37] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2957,7 +3139,7 @@ func (x *PublishFrameResponse) String() string { func (*PublishFrameResponse) ProtoMessage() {} func (x *PublishFrameResponse) ProtoReflect() protoreflect.Message { - mi := &file_compass_v1_agent_gateway_proto_msgTypes[35] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[37] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2970,7 +3152,7 @@ func (x *PublishFrameResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use PublishFrameResponse.ProtoReflect.Descriptor instead. func (*PublishFrameResponse) Descriptor() ([]byte, []int) { - return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{35} + return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{37} } // The durable-frame unary carries the SAME AgentFrame message, constrained by @@ -2992,7 +3174,7 @@ type PostConversationFrameRequest struct { func (x *PostConversationFrameRequest) Reset() { *x = PostConversationFrameRequest{} - mi := &file_compass_v1_agent_gateway_proto_msgTypes[36] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[38] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3004,7 +3186,7 @@ func (x *PostConversationFrameRequest) String() string { func (*PostConversationFrameRequest) ProtoMessage() {} func (x *PostConversationFrameRequest) ProtoReflect() protoreflect.Message { - mi := &file_compass_v1_agent_gateway_proto_msgTypes[36] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[38] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3017,7 +3199,7 @@ func (x *PostConversationFrameRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use PostConversationFrameRequest.ProtoReflect.Descriptor instead. func (*PostConversationFrameRequest) Descriptor() ([]byte, []int) { - return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{36} + return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{38} } func (x *PostConversationFrameRequest) GetFrame() *AgentFrame { @@ -3043,7 +3225,7 @@ type PostConversationFrameResponse struct { func (x *PostConversationFrameResponse) Reset() { *x = PostConversationFrameResponse{} - mi := &file_compass_v1_agent_gateway_proto_msgTypes[37] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[39] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3055,7 +3237,7 @@ func (x *PostConversationFrameResponse) String() string { func (*PostConversationFrameResponse) ProtoMessage() {} func (x *PostConversationFrameResponse) ProtoReflect() protoreflect.Message { - mi := &file_compass_v1_agent_gateway_proto_msgTypes[37] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[39] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3068,7 +3250,7 @@ func (x *PostConversationFrameResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use PostConversationFrameResponse.ProtoReflect.Descriptor instead. func (*PostConversationFrameResponse) Descriptor() ([]byte, []int) { - return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{37} + return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{39} } // The Control subscribe request carries no session id: the per-container socket @@ -3081,7 +3263,7 @@ type ControlSubscribeRequest struct { func (x *ControlSubscribeRequest) Reset() { *x = ControlSubscribeRequest{} - mi := &file_compass_v1_agent_gateway_proto_msgTypes[38] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[40] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3093,7 +3275,7 @@ func (x *ControlSubscribeRequest) String() string { func (*ControlSubscribeRequest) ProtoMessage() {} func (x *ControlSubscribeRequest) ProtoReflect() protoreflect.Message { - mi := &file_compass_v1_agent_gateway_proto_msgTypes[38] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[40] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3106,7 +3288,7 @@ func (x *ControlSubscribeRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ControlSubscribeRequest.ProtoReflect.Descriptor instead. func (*ControlSubscribeRequest) Descriptor() ([]byte, []int) { - return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{38} + return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{40} } var File_compass_v1_agent_gateway_proto protoreflect.FileDescriptor @@ -3179,7 +3361,7 @@ const file_compass_v1_agent_gateway_proto_rawDesc = "" + "\x06result\"B\n" + "\x12LifecycleCallError\x12\x12\n" + "\x04code\x18\x01 \x01(\tR\x04code\x12\x18\n" + - "\amessage\x18\x02 \x01(\tR\amessage\"\xfa\x06\n" + + "\amessage\x18\x02 \x01(\tR\amessage\"\xcf\b\n" + "\x10ForgeCallRequest\x12\x17\n" + "\acall_id\x18\x01 \x01(\tR\x06callId\x12C\n" + "\fcreate_issue\x18\x02 \x01(\v2\x1e.compass.v1.CreateIssueRequestH\x00R\vcreateIssue\x12M\n" + @@ -3193,7 +3375,9 @@ const file_compass_v1_agent_gateway_proto_rawDesc = "" + "\tsubscribe\x18\t \x01(\v2!.compass.v1.SubscribeForgeRequestH\x00R\tsubscribe\x12G\n" + "\vunsubscribe\x18\n" + " \x01(\v2#.compass.v1.UnsubscribeForgeRequestH\x00R\vunsubscribe\x12F\n" + - "\rsubmit_review\x18\v \x01(\v2\x1f.compass.v1.SubmitReviewRequestH\x00R\fsubmitReview\x12*\n" + + "\rsubmit_review\x18\v \x01(\v2\x1f.compass.v1.SubmitReviewRequestH\x00R\fsubmitReview\x12_\n" + + "\x16transition_issue_state\x18\x0e \x01(\v2'.compass.v1.TransitionIssueStateRequestH\x00R\x14transitionIssueState\x12r\n" + + "\x1dtransition_pull_request_state\x18\x0f \x01(\v2-.compass.v1.TransitionPullRequestStateRequestH\x00R\x1atransitionPullRequestState\x12*\n" + "\x05forge\x18\f \x01(\v2\x14.compass.v1.ForgeRefR\x05forge\x12*\n" + "\x11client_request_id\x18\r \x01(\tR\x0fclientRequestIdB\x06\n" + "\x04call\"\xc6\x04\n" + @@ -3263,7 +3447,17 @@ const file_compass_v1_agent_gateway_proto_rawDesc = "" + "\x04path\x18\x01 \x01(\tR\x04path\x12\x12\n" + "\x04line\x18\x02 \x01(\rR\x04line\x12\x12\n" + "\x04side\x18\x03 \x01(\tR\x04side\x12\x12\n" + - "\x04body\x18\x04 \x01(\tR\x04body\"\xca\x01\n" + + "\x04body\x18\x04 \x01(\tR\x04body\"\xb4\x01\n" + + "\x1bTransitionIssueStateRequest\x12\x12\n" + + "\x04repo\x18\x01 \x01(\tR\x04repo\x12!\n" + + "\fissue_number\x18\x02 \x01(\x04R\vissueNumber\x12\x14\n" + + "\x05state\x18\x03 \x01(\tR\x05state\x12!\n" + + "\fclose_reason\x18\x04 \x01(\tR\vcloseReason\x12%\n" + + "\x0eworkflow_state\x18\x05 \x01(\tR\rworkflowState\"j\n" + + "!TransitionPullRequestStateRequest\x12\x12\n" + + "\x04repo\x18\x01 \x01(\tR\x04repo\x12\x1b\n" + + "\tpr_number\x18\x02 \x01(\x04R\bprNumber\x12\x14\n" + + "\x05state\x18\x03 \x01(\tR\x05state\"\xca\x01\n" + "\x15SubscribeForgeRequest\x12\x12\n" + "\x04repo\x18\x01 \x01(\tR\x04repo\x121\n" + "\x04kind\x18\x02 \x01(\x0e2\x1d.compass.v1.ForgeArtifactKindR\x04kind\x12\x16\n" + @@ -3326,94 +3520,96 @@ func file_compass_v1_agent_gateway_proto_rawDescGZIP() []byte { } var file_compass_v1_agent_gateway_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_compass_v1_agent_gateway_proto_msgTypes = make([]protoimpl.MessageInfo, 39) +var file_compass_v1_agent_gateway_proto_msgTypes = make([]protoimpl.MessageInfo, 41) var file_compass_v1_agent_gateway_proto_goTypes = []any{ - (ForgeSubscriptionScope)(0), // 0: compass.v1.ForgeSubscriptionScope - (*CommsCallRequest)(nil), // 1: compass.v1.CommsCallRequest - (*CommsCallResult)(nil), // 2: compass.v1.CommsCallResult - (*CommsCallError)(nil), // 3: compass.v1.CommsCallError - (*SetAgentStatusRequest)(nil), // 4: compass.v1.SetAgentStatusRequest - (*SetAgentStatusResponse)(nil), // 5: compass.v1.SetAgentStatusResponse - (*LifecycleCallRequest)(nil), // 6: compass.v1.LifecycleCallRequest - (*SpawnPeerRequest)(nil), // 7: compass.v1.SpawnPeerRequest - (*SpawnPeerResponse)(nil), // 8: compass.v1.SpawnPeerResponse - (*DespawnPeerRequest)(nil), // 9: compass.v1.DespawnPeerRequest - (*DespawnPeerResponse)(nil), // 10: compass.v1.DespawnPeerResponse - (*LifecycleCallResult)(nil), // 11: compass.v1.LifecycleCallResult - (*LifecycleCallError)(nil), // 12: compass.v1.LifecycleCallError - (*ForgeCallRequest)(nil), // 13: compass.v1.ForgeCallRequest - (*ForgeCallResult)(nil), // 14: compass.v1.ForgeCallResult - (*ForgeCallError)(nil), // 15: compass.v1.ForgeCallError - (*CreateIssueRequest)(nil), // 16: compass.v1.CreateIssueRequest - (*CommentOnIssueRequest)(nil), // 17: compass.v1.CommentOnIssueRequest - (*GetIssueRequest)(nil), // 18: compass.v1.GetIssueRequest - (*ListIssuesRequest)(nil), // 19: compass.v1.ListIssuesRequest - (*ListIssuesResponse)(nil), // 20: compass.v1.ListIssuesResponse - (*CreatePullRequestRequest)(nil), // 21: compass.v1.CreatePullRequestRequest - (*CommentOnPullRequestRequest)(nil), // 22: compass.v1.CommentOnPullRequestRequest - (*GetPullRequestRequest)(nil), // 23: compass.v1.GetPullRequestRequest - (*SubmitReviewRequest)(nil), // 24: compass.v1.SubmitReviewRequest - (*ReviewCommentInput)(nil), // 25: compass.v1.ReviewCommentInput - (*SubscribeForgeRequest)(nil), // 26: compass.v1.SubscribeForgeRequest - (*SubscribeForgeResponse)(nil), // 27: compass.v1.SubscribeForgeResponse - (*UnsubscribeForgeRequest)(nil), // 28: compass.v1.UnsubscribeForgeRequest - (*UnsubscribeForgeResponse)(nil), // 29: compass.v1.UnsubscribeForgeResponse - (*BoardCallRequest)(nil), // 30: compass.v1.BoardCallRequest - (*SetIssueStateRequest)(nil), // 31: compass.v1.SetIssueStateRequest - (*SetIssueStateResponse)(nil), // 32: compass.v1.SetIssueStateResponse - (*BoardCallResult)(nil), // 33: compass.v1.BoardCallResult - (*BoardCallError)(nil), // 34: compass.v1.BoardCallError - (*PublishFrameRequest)(nil), // 35: compass.v1.PublishFrameRequest - (*PublishFrameResponse)(nil), // 36: compass.v1.PublishFrameResponse - (*PostConversationFrameRequest)(nil), // 37: compass.v1.PostConversationFrameRequest - (*PostConversationFrameResponse)(nil), // 38: compass.v1.PostConversationFrameResponse - (*ControlSubscribeRequest)(nil), // 39: compass.v1.ControlSubscribeRequest - (*v1.PostMessageRequest)(nil), // 40: compass.v1.PostMessageRequest - (*v1.ListMessagesRequest)(nil), // 41: compass.v1.ListMessagesRequest - (*v1.GetRosterRequest)(nil), // 42: compass.v1.GetRosterRequest - (*v1.UpdatePinnedBoardRequest)(nil), // 43: compass.v1.UpdatePinnedBoardRequest - (*v1.CreateChannelRequest)(nil), // 44: compass.v1.CreateChannelRequest - (*v1.UpdateChannelMembersRequest)(nil), // 45: compass.v1.UpdateChannelMembersRequest - (*v1.CreateChannelGroupRequest)(nil), // 46: compass.v1.CreateChannelGroupRequest - (*v1.OpenDMRequest)(nil), // 47: compass.v1.OpenDMRequest - (*v1.PostMessageResponse)(nil), // 48: compass.v1.PostMessageResponse - (*v1.ListMessagesResponse)(nil), // 49: compass.v1.ListMessagesResponse - (*v1.GetRosterResponse)(nil), // 50: compass.v1.GetRosterResponse - (*v1.UpdatePinnedBoardResponse)(nil), // 51: compass.v1.UpdatePinnedBoardResponse - (*v1.CreateChannelResponse)(nil), // 52: compass.v1.CreateChannelResponse - (*v1.UpdateChannelMembersResponse)(nil), // 53: compass.v1.UpdateChannelMembersResponse - (*v1.CreateChannelGroupResponse)(nil), // 54: compass.v1.CreateChannelGroupResponse - (*v1.OpenDMResponse)(nil), // 55: compass.v1.OpenDMResponse - (*v1.ForgeRef)(nil), // 56: compass.v1.ForgeRef - (*v1.Issue)(nil), // 57: compass.v1.Issue - (*CommentRef)(nil), // 58: compass.v1.CommentRef - (*v1.PullRequest)(nil), // 59: compass.v1.PullRequest - (*ReviewRef)(nil), // 60: compass.v1.ReviewRef - (ForgeArtifactKind)(0), // 61: compass.v1.ForgeArtifactKind - (v1.IssueState)(0), // 62: compass.v1.IssueState - (*AgentFrame)(nil), // 63: compass.v1.AgentFrame - (*AgentControl)(nil), // 64: compass.v1.AgentControl + (ForgeSubscriptionScope)(0), // 0: compass.v1.ForgeSubscriptionScope + (*CommsCallRequest)(nil), // 1: compass.v1.CommsCallRequest + (*CommsCallResult)(nil), // 2: compass.v1.CommsCallResult + (*CommsCallError)(nil), // 3: compass.v1.CommsCallError + (*SetAgentStatusRequest)(nil), // 4: compass.v1.SetAgentStatusRequest + (*SetAgentStatusResponse)(nil), // 5: compass.v1.SetAgentStatusResponse + (*LifecycleCallRequest)(nil), // 6: compass.v1.LifecycleCallRequest + (*SpawnPeerRequest)(nil), // 7: compass.v1.SpawnPeerRequest + (*SpawnPeerResponse)(nil), // 8: compass.v1.SpawnPeerResponse + (*DespawnPeerRequest)(nil), // 9: compass.v1.DespawnPeerRequest + (*DespawnPeerResponse)(nil), // 10: compass.v1.DespawnPeerResponse + (*LifecycleCallResult)(nil), // 11: compass.v1.LifecycleCallResult + (*LifecycleCallError)(nil), // 12: compass.v1.LifecycleCallError + (*ForgeCallRequest)(nil), // 13: compass.v1.ForgeCallRequest + (*ForgeCallResult)(nil), // 14: compass.v1.ForgeCallResult + (*ForgeCallError)(nil), // 15: compass.v1.ForgeCallError + (*CreateIssueRequest)(nil), // 16: compass.v1.CreateIssueRequest + (*CommentOnIssueRequest)(nil), // 17: compass.v1.CommentOnIssueRequest + (*GetIssueRequest)(nil), // 18: compass.v1.GetIssueRequest + (*ListIssuesRequest)(nil), // 19: compass.v1.ListIssuesRequest + (*ListIssuesResponse)(nil), // 20: compass.v1.ListIssuesResponse + (*CreatePullRequestRequest)(nil), // 21: compass.v1.CreatePullRequestRequest + (*CommentOnPullRequestRequest)(nil), // 22: compass.v1.CommentOnPullRequestRequest + (*GetPullRequestRequest)(nil), // 23: compass.v1.GetPullRequestRequest + (*SubmitReviewRequest)(nil), // 24: compass.v1.SubmitReviewRequest + (*ReviewCommentInput)(nil), // 25: compass.v1.ReviewCommentInput + (*TransitionIssueStateRequest)(nil), // 26: compass.v1.TransitionIssueStateRequest + (*TransitionPullRequestStateRequest)(nil), // 27: compass.v1.TransitionPullRequestStateRequest + (*SubscribeForgeRequest)(nil), // 28: compass.v1.SubscribeForgeRequest + (*SubscribeForgeResponse)(nil), // 29: compass.v1.SubscribeForgeResponse + (*UnsubscribeForgeRequest)(nil), // 30: compass.v1.UnsubscribeForgeRequest + (*UnsubscribeForgeResponse)(nil), // 31: compass.v1.UnsubscribeForgeResponse + (*BoardCallRequest)(nil), // 32: compass.v1.BoardCallRequest + (*SetIssueStateRequest)(nil), // 33: compass.v1.SetIssueStateRequest + (*SetIssueStateResponse)(nil), // 34: compass.v1.SetIssueStateResponse + (*BoardCallResult)(nil), // 35: compass.v1.BoardCallResult + (*BoardCallError)(nil), // 36: compass.v1.BoardCallError + (*PublishFrameRequest)(nil), // 37: compass.v1.PublishFrameRequest + (*PublishFrameResponse)(nil), // 38: compass.v1.PublishFrameResponse + (*PostConversationFrameRequest)(nil), // 39: compass.v1.PostConversationFrameRequest + (*PostConversationFrameResponse)(nil), // 40: compass.v1.PostConversationFrameResponse + (*ControlSubscribeRequest)(nil), // 41: compass.v1.ControlSubscribeRequest + (*v1.PostMessageRequest)(nil), // 42: compass.v1.PostMessageRequest + (*v1.ListMessagesRequest)(nil), // 43: compass.v1.ListMessagesRequest + (*v1.GetRosterRequest)(nil), // 44: compass.v1.GetRosterRequest + (*v1.UpdatePinnedBoardRequest)(nil), // 45: compass.v1.UpdatePinnedBoardRequest + (*v1.CreateChannelRequest)(nil), // 46: compass.v1.CreateChannelRequest + (*v1.UpdateChannelMembersRequest)(nil), // 47: compass.v1.UpdateChannelMembersRequest + (*v1.CreateChannelGroupRequest)(nil), // 48: compass.v1.CreateChannelGroupRequest + (*v1.OpenDMRequest)(nil), // 49: compass.v1.OpenDMRequest + (*v1.PostMessageResponse)(nil), // 50: compass.v1.PostMessageResponse + (*v1.ListMessagesResponse)(nil), // 51: compass.v1.ListMessagesResponse + (*v1.GetRosterResponse)(nil), // 52: compass.v1.GetRosterResponse + (*v1.UpdatePinnedBoardResponse)(nil), // 53: compass.v1.UpdatePinnedBoardResponse + (*v1.CreateChannelResponse)(nil), // 54: compass.v1.CreateChannelResponse + (*v1.UpdateChannelMembersResponse)(nil), // 55: compass.v1.UpdateChannelMembersResponse + (*v1.CreateChannelGroupResponse)(nil), // 56: compass.v1.CreateChannelGroupResponse + (*v1.OpenDMResponse)(nil), // 57: compass.v1.OpenDMResponse + (*v1.ForgeRef)(nil), // 58: compass.v1.ForgeRef + (*v1.Issue)(nil), // 59: compass.v1.Issue + (*CommentRef)(nil), // 60: compass.v1.CommentRef + (*v1.PullRequest)(nil), // 61: compass.v1.PullRequest + (*ReviewRef)(nil), // 62: compass.v1.ReviewRef + (ForgeArtifactKind)(0), // 63: compass.v1.ForgeArtifactKind + (v1.IssueState)(0), // 64: compass.v1.IssueState + (*AgentFrame)(nil), // 65: compass.v1.AgentFrame + (*AgentControl)(nil), // 66: compass.v1.AgentControl } var file_compass_v1_agent_gateway_proto_depIdxs = []int32{ - 40, // 0: compass.v1.CommsCallRequest.post:type_name -> compass.v1.PostMessageRequest - 41, // 1: compass.v1.CommsCallRequest.list:type_name -> compass.v1.ListMessagesRequest - 42, // 2: compass.v1.CommsCallRequest.roster:type_name -> compass.v1.GetRosterRequest + 42, // 0: compass.v1.CommsCallRequest.post:type_name -> compass.v1.PostMessageRequest + 43, // 1: compass.v1.CommsCallRequest.list:type_name -> compass.v1.ListMessagesRequest + 44, // 2: compass.v1.CommsCallRequest.roster:type_name -> compass.v1.GetRosterRequest 4, // 3: compass.v1.CommsCallRequest.set_status:type_name -> compass.v1.SetAgentStatusRequest - 43, // 4: compass.v1.CommsCallRequest.pin:type_name -> compass.v1.UpdatePinnedBoardRequest - 44, // 5: compass.v1.CommsCallRequest.create_channel:type_name -> compass.v1.CreateChannelRequest - 45, // 6: compass.v1.CommsCallRequest.update_members:type_name -> compass.v1.UpdateChannelMembersRequest - 46, // 7: compass.v1.CommsCallRequest.create_channel_group:type_name -> compass.v1.CreateChannelGroupRequest - 47, // 8: compass.v1.CommsCallRequest.open_dm:type_name -> compass.v1.OpenDMRequest - 48, // 9: compass.v1.CommsCallResult.post:type_name -> compass.v1.PostMessageResponse - 49, // 10: compass.v1.CommsCallResult.list:type_name -> compass.v1.ListMessagesResponse + 45, // 4: compass.v1.CommsCallRequest.pin:type_name -> compass.v1.UpdatePinnedBoardRequest + 46, // 5: compass.v1.CommsCallRequest.create_channel:type_name -> compass.v1.CreateChannelRequest + 47, // 6: compass.v1.CommsCallRequest.update_members:type_name -> compass.v1.UpdateChannelMembersRequest + 48, // 7: compass.v1.CommsCallRequest.create_channel_group:type_name -> compass.v1.CreateChannelGroupRequest + 49, // 8: compass.v1.CommsCallRequest.open_dm:type_name -> compass.v1.OpenDMRequest + 50, // 9: compass.v1.CommsCallResult.post:type_name -> compass.v1.PostMessageResponse + 51, // 10: compass.v1.CommsCallResult.list:type_name -> compass.v1.ListMessagesResponse 3, // 11: compass.v1.CommsCallResult.error:type_name -> compass.v1.CommsCallError - 50, // 12: compass.v1.CommsCallResult.roster:type_name -> compass.v1.GetRosterResponse + 52, // 12: compass.v1.CommsCallResult.roster:type_name -> compass.v1.GetRosterResponse 5, // 13: compass.v1.CommsCallResult.set_status:type_name -> compass.v1.SetAgentStatusResponse - 51, // 14: compass.v1.CommsCallResult.pin:type_name -> compass.v1.UpdatePinnedBoardResponse - 52, // 15: compass.v1.CommsCallResult.create_channel:type_name -> compass.v1.CreateChannelResponse - 53, // 16: compass.v1.CommsCallResult.update_members:type_name -> compass.v1.UpdateChannelMembersResponse - 54, // 17: compass.v1.CommsCallResult.create_channel_group:type_name -> compass.v1.CreateChannelGroupResponse - 55, // 18: compass.v1.CommsCallResult.open_dm:type_name -> compass.v1.OpenDMResponse + 53, // 14: compass.v1.CommsCallResult.pin:type_name -> compass.v1.UpdatePinnedBoardResponse + 54, // 15: compass.v1.CommsCallResult.create_channel:type_name -> compass.v1.CreateChannelResponse + 55, // 16: compass.v1.CommsCallResult.update_members:type_name -> compass.v1.UpdateChannelMembersResponse + 56, // 17: compass.v1.CommsCallResult.create_channel_group:type_name -> compass.v1.CreateChannelGroupResponse + 57, // 18: compass.v1.CommsCallResult.open_dm:type_name -> compass.v1.OpenDMResponse 7, // 19: compass.v1.LifecycleCallRequest.spawn:type_name -> compass.v1.SpawnPeerRequest 9, // 20: compass.v1.LifecycleCallRequest.despawn:type_name -> compass.v1.DespawnPeerRequest 8, // 21: compass.v1.LifecycleCallResult.spawn:type_name -> compass.v1.SpawnPeerResponse @@ -3426,49 +3622,51 @@ var file_compass_v1_agent_gateway_proto_depIdxs = []int32{ 21, // 28: compass.v1.ForgeCallRequest.create_pull_request:type_name -> compass.v1.CreatePullRequestRequest 22, // 29: compass.v1.ForgeCallRequest.comment_on_pull_request:type_name -> compass.v1.CommentOnPullRequestRequest 23, // 30: compass.v1.ForgeCallRequest.get_pull_request:type_name -> compass.v1.GetPullRequestRequest - 26, // 31: compass.v1.ForgeCallRequest.subscribe:type_name -> compass.v1.SubscribeForgeRequest - 28, // 32: compass.v1.ForgeCallRequest.unsubscribe:type_name -> compass.v1.UnsubscribeForgeRequest + 28, // 31: compass.v1.ForgeCallRequest.subscribe:type_name -> compass.v1.SubscribeForgeRequest + 30, // 32: compass.v1.ForgeCallRequest.unsubscribe:type_name -> compass.v1.UnsubscribeForgeRequest 24, // 33: compass.v1.ForgeCallRequest.submit_review:type_name -> compass.v1.SubmitReviewRequest - 56, // 34: compass.v1.ForgeCallRequest.forge:type_name -> compass.v1.ForgeRef - 57, // 35: compass.v1.ForgeCallResult.issue:type_name -> compass.v1.Issue - 58, // 36: compass.v1.ForgeCallResult.issue_comment:type_name -> compass.v1.CommentRef - 20, // 37: compass.v1.ForgeCallResult.issues:type_name -> compass.v1.ListIssuesResponse - 59, // 38: compass.v1.ForgeCallResult.pull_request:type_name -> compass.v1.PullRequest - 58, // 39: compass.v1.ForgeCallResult.pr_comment:type_name -> compass.v1.CommentRef - 27, // 40: compass.v1.ForgeCallResult.subscribed:type_name -> compass.v1.SubscribeForgeResponse - 29, // 41: compass.v1.ForgeCallResult.unsubscribed:type_name -> compass.v1.UnsubscribeForgeResponse - 15, // 42: compass.v1.ForgeCallResult.error:type_name -> compass.v1.ForgeCallError - 60, // 43: compass.v1.ForgeCallResult.review:type_name -> compass.v1.ReviewRef - 57, // 44: compass.v1.ListIssuesResponse.issues:type_name -> compass.v1.Issue - 25, // 45: compass.v1.SubmitReviewRequest.comments:type_name -> compass.v1.ReviewCommentInput - 61, // 46: compass.v1.SubscribeForgeRequest.kind:type_name -> compass.v1.ForgeArtifactKind - 0, // 47: compass.v1.SubscribeForgeRequest.scope:type_name -> compass.v1.ForgeSubscriptionScope - 31, // 48: compass.v1.BoardCallRequest.set_issue_state:type_name -> compass.v1.SetIssueStateRequest - 62, // 49: compass.v1.SetIssueStateRequest.state:type_name -> compass.v1.IssueState - 57, // 50: compass.v1.SetIssueStateResponse.issue:type_name -> compass.v1.Issue - 32, // 51: compass.v1.BoardCallResult.set_issue_state:type_name -> compass.v1.SetIssueStateResponse - 34, // 52: compass.v1.BoardCallResult.error:type_name -> compass.v1.BoardCallError - 63, // 53: compass.v1.PublishFrameRequest.frame:type_name -> compass.v1.AgentFrame - 63, // 54: compass.v1.PostConversationFrameRequest.frame:type_name -> compass.v1.AgentFrame - 1, // 55: compass.v1.AgentGateway.Comms:input_type -> compass.v1.CommsCallRequest - 6, // 56: compass.v1.AgentGateway.Lifecycle:input_type -> compass.v1.LifecycleCallRequest - 35, // 57: compass.v1.AgentGateway.Publish:input_type -> compass.v1.PublishFrameRequest - 37, // 58: compass.v1.AgentGateway.PostConversationFrame:input_type -> compass.v1.PostConversationFrameRequest - 39, // 59: compass.v1.AgentGateway.Control:input_type -> compass.v1.ControlSubscribeRequest - 13, // 60: compass.v1.AgentGateway.Forge:input_type -> compass.v1.ForgeCallRequest - 30, // 61: compass.v1.AgentGateway.Board:input_type -> compass.v1.BoardCallRequest - 2, // 62: compass.v1.AgentGateway.Comms:output_type -> compass.v1.CommsCallResult - 11, // 63: compass.v1.AgentGateway.Lifecycle:output_type -> compass.v1.LifecycleCallResult - 36, // 64: compass.v1.AgentGateway.Publish:output_type -> compass.v1.PublishFrameResponse - 38, // 65: compass.v1.AgentGateway.PostConversationFrame:output_type -> compass.v1.PostConversationFrameResponse - 64, // 66: compass.v1.AgentGateway.Control:output_type -> compass.v1.AgentControl - 14, // 67: compass.v1.AgentGateway.Forge:output_type -> compass.v1.ForgeCallResult - 33, // 68: compass.v1.AgentGateway.Board:output_type -> compass.v1.BoardCallResult - 62, // [62:69] is the sub-list for method output_type - 55, // [55:62] is the sub-list for method input_type - 55, // [55:55] is the sub-list for extension type_name - 55, // [55:55] is the sub-list for extension extendee - 0, // [0:55] is the sub-list for field type_name + 26, // 34: compass.v1.ForgeCallRequest.transition_issue_state:type_name -> compass.v1.TransitionIssueStateRequest + 27, // 35: compass.v1.ForgeCallRequest.transition_pull_request_state:type_name -> compass.v1.TransitionPullRequestStateRequest + 58, // 36: compass.v1.ForgeCallRequest.forge:type_name -> compass.v1.ForgeRef + 59, // 37: compass.v1.ForgeCallResult.issue:type_name -> compass.v1.Issue + 60, // 38: compass.v1.ForgeCallResult.issue_comment:type_name -> compass.v1.CommentRef + 20, // 39: compass.v1.ForgeCallResult.issues:type_name -> compass.v1.ListIssuesResponse + 61, // 40: compass.v1.ForgeCallResult.pull_request:type_name -> compass.v1.PullRequest + 60, // 41: compass.v1.ForgeCallResult.pr_comment:type_name -> compass.v1.CommentRef + 29, // 42: compass.v1.ForgeCallResult.subscribed:type_name -> compass.v1.SubscribeForgeResponse + 31, // 43: compass.v1.ForgeCallResult.unsubscribed:type_name -> compass.v1.UnsubscribeForgeResponse + 15, // 44: compass.v1.ForgeCallResult.error:type_name -> compass.v1.ForgeCallError + 62, // 45: compass.v1.ForgeCallResult.review:type_name -> compass.v1.ReviewRef + 59, // 46: compass.v1.ListIssuesResponse.issues:type_name -> compass.v1.Issue + 25, // 47: compass.v1.SubmitReviewRequest.comments:type_name -> compass.v1.ReviewCommentInput + 63, // 48: compass.v1.SubscribeForgeRequest.kind:type_name -> compass.v1.ForgeArtifactKind + 0, // 49: compass.v1.SubscribeForgeRequest.scope:type_name -> compass.v1.ForgeSubscriptionScope + 33, // 50: compass.v1.BoardCallRequest.set_issue_state:type_name -> compass.v1.SetIssueStateRequest + 64, // 51: compass.v1.SetIssueStateRequest.state:type_name -> compass.v1.IssueState + 59, // 52: compass.v1.SetIssueStateResponse.issue:type_name -> compass.v1.Issue + 34, // 53: compass.v1.BoardCallResult.set_issue_state:type_name -> compass.v1.SetIssueStateResponse + 36, // 54: compass.v1.BoardCallResult.error:type_name -> compass.v1.BoardCallError + 65, // 55: compass.v1.PublishFrameRequest.frame:type_name -> compass.v1.AgentFrame + 65, // 56: compass.v1.PostConversationFrameRequest.frame:type_name -> compass.v1.AgentFrame + 1, // 57: compass.v1.AgentGateway.Comms:input_type -> compass.v1.CommsCallRequest + 6, // 58: compass.v1.AgentGateway.Lifecycle:input_type -> compass.v1.LifecycleCallRequest + 37, // 59: compass.v1.AgentGateway.Publish:input_type -> compass.v1.PublishFrameRequest + 39, // 60: compass.v1.AgentGateway.PostConversationFrame:input_type -> compass.v1.PostConversationFrameRequest + 41, // 61: compass.v1.AgentGateway.Control:input_type -> compass.v1.ControlSubscribeRequest + 13, // 62: compass.v1.AgentGateway.Forge:input_type -> compass.v1.ForgeCallRequest + 32, // 63: compass.v1.AgentGateway.Board:input_type -> compass.v1.BoardCallRequest + 2, // 64: compass.v1.AgentGateway.Comms:output_type -> compass.v1.CommsCallResult + 11, // 65: compass.v1.AgentGateway.Lifecycle:output_type -> compass.v1.LifecycleCallResult + 38, // 66: compass.v1.AgentGateway.Publish:output_type -> compass.v1.PublishFrameResponse + 40, // 67: compass.v1.AgentGateway.PostConversationFrame:output_type -> compass.v1.PostConversationFrameResponse + 66, // 68: compass.v1.AgentGateway.Control:output_type -> compass.v1.AgentControl + 14, // 69: compass.v1.AgentGateway.Forge:output_type -> compass.v1.ForgeCallResult + 35, // 70: compass.v1.AgentGateway.Board:output_type -> compass.v1.BoardCallResult + 64, // [64:71] is the sub-list for method output_type + 57, // [57:64] is the sub-list for method input_type + 57, // [57:57] is the sub-list for extension type_name + 57, // [57:57] is the sub-list for extension extendee + 0, // [0:57] is the sub-list for field type_name } func init() { file_compass_v1_agent_gateway_proto_init() } @@ -3521,6 +3719,8 @@ func file_compass_v1_agent_gateway_proto_init() { (*ForgeCallRequest_Subscribe)(nil), (*ForgeCallRequest_Unsubscribe)(nil), (*ForgeCallRequest_SubmitReview)(nil), + (*ForgeCallRequest_TransitionIssueState)(nil), + (*ForgeCallRequest_TransitionPullRequestState)(nil), } file_compass_v1_agent_gateway_proto_msgTypes[13].OneofWrappers = []any{ (*ForgeCallResult_Issue)(nil), @@ -3533,10 +3733,10 @@ func file_compass_v1_agent_gateway_proto_init() { (*ForgeCallResult_Error)(nil), (*ForgeCallResult_Review)(nil), } - file_compass_v1_agent_gateway_proto_msgTypes[29].OneofWrappers = []any{ + file_compass_v1_agent_gateway_proto_msgTypes[31].OneofWrappers = []any{ (*BoardCallRequest_SetIssueState)(nil), } - file_compass_v1_agent_gateway_proto_msgTypes[32].OneofWrappers = []any{ + file_compass_v1_agent_gateway_proto_msgTypes[34].OneofWrappers = []any{ (*BoardCallResult_SetIssueState)(nil), (*BoardCallResult_Error)(nil), } @@ -3546,7 +3746,7 @@ func file_compass_v1_agent_gateway_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_compass_v1_agent_gateway_proto_rawDesc), len(file_compass_v1_agent_gateway_proto_rawDesc)), NumEnums: 1, - NumMessages: 39, + NumMessages: 41, NumExtensions: 0, NumServices: 1, }, diff --git a/packages/compass-agent/src/gen/compass/v1/agent_gateway_pb.ts b/packages/compass-agent/src/gen/compass/v1/agent_gateway_pb.ts index e85a1d354..54ab2c754 100644 --- a/packages/compass-agent/src/gen/compass/v1/agent_gateway_pb.ts +++ b/packages/compass-agent/src/gen/compass/v1/agent_gateway_pb.ts @@ -48,7 +48,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file compass/v1/agent_gateway.proto. */ export const file_compass_v1_agent_gateway: GenFile = /*@__PURE__*/ - fileDesc("Ch5jb21wYXNzL3YxL2FnZW50X2dhdGV3YXkucHJvdG8SCmNvbXBhc3MudjEiuwQKEENvbW1zQ2FsbFJlcXVlc3QSDwoHY2FsbF9pZBgBIAEoCRIuCgRwb3N0GAIgASgLMh4uY29tcGFzcy52MS5Qb3N0TWVzc2FnZVJlcXVlc3RIABIvCgRsaXN0GAMgASgLMh8uY29tcGFzcy52MS5MaXN0TWVzc2FnZXNSZXF1ZXN0SAASLgoGcm9zdGVyGAQgASgLMhwuY29tcGFzcy52MS5HZXRSb3N0ZXJSZXF1ZXN0SAASNwoKc2V0X3N0YXR1cxgFIAEoCzIhLmNvbXBhc3MudjEuU2V0QWdlbnRTdGF0dXNSZXF1ZXN0SAASMwoDcGluGAYgASgLMiQuY29tcGFzcy52MS5VcGRhdGVQaW5uZWRCb2FyZFJlcXVlc3RIABI6Cg5jcmVhdGVfY2hhbm5lbBgHIAEoCzIgLmNvbXBhc3MudjEuQ3JlYXRlQ2hhbm5lbFJlcXVlc3RIABJBCg51cGRhdGVfbWVtYmVycxgIIAEoCzInLmNvbXBhc3MudjEuVXBkYXRlQ2hhbm5lbE1lbWJlcnNSZXF1ZXN0SAASRQoUY3JlYXRlX2NoYW5uZWxfZ3JvdXAYCSABKAsyJS5jb21wYXNzLnYxLkNyZWF0ZUNoYW5uZWxHcm91cFJlcXVlc3RIABIsCgdvcGVuX2RtGAsgASgLMhkuY29tcGFzcy52MS5PcGVuRE1SZXF1ZXN0SAASGwoTdHJpZ2dlcl90cmFjZXBhcmVudBgKIAEoCUIGCgRjYWxsItUECg9Db21tc0NhbGxSZXN1bHQSDwoHY2FsbF9pZBgBIAEoCRIvCgRwb3N0GAIgASgLMh8uY29tcGFzcy52MS5Qb3N0TWVzc2FnZVJlc3BvbnNlSAASMAoEbGlzdBgDIAEoCzIgLmNvbXBhc3MudjEuTGlzdE1lc3NhZ2VzUmVzcG9uc2VIABIrCgVlcnJvchgEIAEoCzIaLmNvbXBhc3MudjEuQ29tbXNDYWxsRXJyb3JIABIvCgZyb3N0ZXIYBSABKAsyHS5jb21wYXNzLnYxLkdldFJvc3RlclJlc3BvbnNlSAASOAoKc2V0X3N0YXR1cxgGIAEoCzIiLmNvbXBhc3MudjEuU2V0QWdlbnRTdGF0dXNSZXNwb25zZUgAEjQKA3BpbhgHIAEoCzIlLmNvbXBhc3MudjEuVXBkYXRlUGlubmVkQm9hcmRSZXNwb25zZUgAEjsKDmNyZWF0ZV9jaGFubmVsGAggASgLMiEuY29tcGFzcy52MS5DcmVhdGVDaGFubmVsUmVzcG9uc2VIABJCCg51cGRhdGVfbWVtYmVycxgJIAEoCzIoLmNvbXBhc3MudjEuVXBkYXRlQ2hhbm5lbE1lbWJlcnNSZXNwb25zZUgAEkYKFGNyZWF0ZV9jaGFubmVsX2dyb3VwGAogASgLMiYuY29tcGFzcy52MS5DcmVhdGVDaGFubmVsR3JvdXBSZXNwb25zZUgAEi0KB29wZW5fZG0YCyABKAsyGi5jb21wYXNzLnYxLk9wZW5ETVJlc3BvbnNlSABCCAoGcmVzdWx0Ii8KDkNvbW1zQ2FsbEVycm9yEgwKBGNvZGUYASABKAkSDwoHbWVzc2FnZRgCIAEoCSIpChVTZXRBZ2VudFN0YXR1c1JlcXVlc3QSEAoIYWN0aXZpdHkYASABKAkiGAoWU2V0QWdlbnRTdGF0dXNSZXNwb25zZSKRAQoUTGlmZWN5Y2xlQ2FsbFJlcXVlc3QSDwoHY2FsbF9pZBgBIAEoCRItCgVzcGF3bhgCIAEoCzIcLmNvbXBhc3MudjEuU3Bhd25QZWVyUmVxdWVzdEgAEjEKB2Rlc3Bhd24YAyABKAsyHi5jb21wYXNzLnYxLkRlc3Bhd25QZWVyUmVxdWVzdEgAQgYKBGNhbGwiiAEKEFNwYXduUGVlclJlcXVlc3QSDgoGaGFuZGxlGAEgASgJEhQKDGRpc3BsYXlfbmFtZRgCIAEoCRIZChFjbGllbnRfcmVxdWVzdF9pZBgEIAEoCRIMCgRyb2xlGAUgASgJEg8KB3BlcnNvbmEYBiABKAlKBAgDEARSDmluaXRpYWxfcHJvbXB0InIKEVNwYXduUGVlclJlc3BvbnNlEhgKEGFnZW50X2FjY291bnRfaWQYASABKAkSFgoOY29udGFpbmVyX25hbWUYAiABKAkSEgoKc2Vzc2lvbl9pZBgDIAEoCRIXCg9kbV9jaGFubmVsX25hbWUYBCABKAkiKgoSRGVzcGF3blBlZXJSZXF1ZXN0EhQKDGFnZW50X2hhbmRsZRgBIAEoCSIVChNEZXNwYXduUGVlclJlc3BvbnNlIsUBChNMaWZlY3ljbGVDYWxsUmVzdWx0Eg8KB2NhbGxfaWQYASABKAkSLgoFc3Bhd24YAiABKAsyHS5jb21wYXNzLnYxLlNwYXduUGVlclJlc3BvbnNlSAASMgoHZGVzcGF3bhgDIAEoCzIfLmNvbXBhc3MudjEuRGVzcGF3blBlZXJSZXNwb25zZUgAEi8KBWVycm9yGAQgASgLMh4uY29tcGFzcy52MS5MaWZlY3ljbGVDYWxsRXJyb3JIAEIICgZyZXN1bHQiMwoSTGlmZWN5Y2xlQ2FsbEVycm9yEgwKBGNvZGUYASABKAkSDwoHbWVzc2FnZRgCIAEoCSLIBQoQRm9yZ2VDYWxsUmVxdWVzdBIPCgdjYWxsX2lkGAEgASgJEjYKDGNyZWF0ZV9pc3N1ZRgCIAEoCzIeLmNvbXBhc3MudjEuQ3JlYXRlSXNzdWVSZXF1ZXN0SAASPQoQY29tbWVudF9vbl9pc3N1ZRgDIAEoCzIhLmNvbXBhc3MudjEuQ29tbWVudE9uSXNzdWVSZXF1ZXN0SAASMAoJZ2V0X2lzc3VlGAQgASgLMhsuY29tcGFzcy52MS5HZXRJc3N1ZVJlcXVlc3RIABI0CgtsaXN0X2lzc3VlcxgFIAEoCzIdLmNvbXBhc3MudjEuTGlzdElzc3Vlc1JlcXVlc3RIABJDChNjcmVhdGVfcHVsbF9yZXF1ZXN0GAYgASgLMiQuY29tcGFzcy52MS5DcmVhdGVQdWxsUmVxdWVzdFJlcXVlc3RIABJKChdjb21tZW50X29uX3B1bGxfcmVxdWVzdBgHIAEoCzInLmNvbXBhc3MudjEuQ29tbWVudE9uUHVsbFJlcXVlc3RSZXF1ZXN0SAASPQoQZ2V0X3B1bGxfcmVxdWVzdBgIIAEoCzIhLmNvbXBhc3MudjEuR2V0UHVsbFJlcXVlc3RSZXF1ZXN0SAASNgoJc3Vic2NyaWJlGAkgASgLMiEuY29tcGFzcy52MS5TdWJzY3JpYmVGb3JnZVJlcXVlc3RIABI6Cgt1bnN1YnNjcmliZRgKIAEoCzIjLmNvbXBhc3MudjEuVW5zdWJzY3JpYmVGb3JnZVJlcXVlc3RIABI4Cg1zdWJtaXRfcmV2aWV3GAsgASgLMh8uY29tcGFzcy52MS5TdWJtaXRSZXZpZXdSZXF1ZXN0SAASIwoFZm9yZ2UYDCABKAsyFC5jb21wYXNzLnYxLkZvcmdlUmVmEhkKEWNsaWVudF9yZXF1ZXN0X2lkGA0gASgJQgYKBGNhbGwi4AMKD0ZvcmdlQ2FsbFJlc3VsdBIPCgdjYWxsX2lkGAEgASgJEiIKBWlzc3VlGAIgASgLMhEuY29tcGFzcy52MS5Jc3N1ZUgAEi8KDWlzc3VlX2NvbW1lbnQYAyABKAsyFi5jb21wYXNzLnYxLkNvbW1lbnRSZWZIABIwCgZpc3N1ZXMYBCABKAsyHi5jb21wYXNzLnYxLkxpc3RJc3N1ZXNSZXNwb25zZUgAEi8KDHB1bGxfcmVxdWVzdBgFIAEoCzIXLmNvbXBhc3MudjEuUHVsbFJlcXVlc3RIABIsCgpwcl9jb21tZW50GAYgASgLMhYuY29tcGFzcy52MS5Db21tZW50UmVmSAASOAoKc3Vic2NyaWJlZBgHIAEoCzIiLmNvbXBhc3MudjEuU3Vic2NyaWJlRm9yZ2VSZXNwb25zZUgAEjwKDHVuc3Vic2NyaWJlZBgIIAEoCzIkLmNvbXBhc3MudjEuVW5zdWJzY3JpYmVGb3JnZVJlc3BvbnNlSAASKwoFZXJyb3IYCSABKAsyGi5jb21wYXNzLnYxLkZvcmdlQ2FsbEVycm9ySAASJwoGcmV2aWV3GAogASgLMhUuY29tcGFzcy52MS5SZXZpZXdSZWZIAEIICgZyZXN1bHQiRwoORm9yZ2VDYWxsRXJyb3ISDAoEY29kZRgBIAEoCRIPCgdtZXNzYWdlGAIgASgJEhYKDnJldHJ5X2FmdGVyX21zGAMgASgNIk8KEkNyZWF0ZUlzc3VlUmVxdWVzdBIMCgRyZXBvGAEgASgJEg0KBXRpdGxlGAIgASgJEgwKBGJvZHkYAyABKAkSDgoGbGFiZWxzGAQgAygJIkkKFUNvbW1lbnRPbklzc3VlUmVxdWVzdBIMCgRyZXBvGAEgASgJEhQKDGlzc3VlX251bWJlchgCIAEoBBIMCgRib2R5GAMgASgJIjUKD0dldElzc3VlUmVxdWVzdBIMCgRyZXBvGAEgASgJEhQKDGlzc3VlX251bWJlchgCIAEoBCJPChFMaXN0SXNzdWVzUmVxdWVzdBIMCgRyZXBvGAEgASgJEg0KBXN0YXRlGAIgASgJEg4KBmxhYmVscxgDIAMoCRINCgVsaW1pdBgEIAEoDSI3ChJMaXN0SXNzdWVzUmVzcG9uc2USIQoGaXNzdWVzGAEgAygLMhEuY29tcGFzcy52MS5Jc3N1ZSJ4ChhDcmVhdGVQdWxsUmVxdWVzdFJlcXVlc3QSDAoEcmVwbxgBIAEoCRINCgV0aXRsZRgCIAEoCRIMCgRib2R5GAMgASgJEhAKCGhlYWRfcmVmGAQgASgJEhAKCGJhc2VfcmVmGAUgASgJEg0KBWRyYWZ0GAYgASgIIk4KG0NvbW1lbnRPblB1bGxSZXF1ZXN0UmVxdWVzdBIMCgRyZXBvGAEgASgJEhMKC3B1bGxfbnVtYmVyGAIgASgEEgwKBGJvZHkYAyABKAkiOgoVR2V0UHVsbFJlcXVlc3RSZXF1ZXN0EgwKBHJlcG8YASABKAkSEwoLcHVsbF9udW1iZXIYAiABKAQiiQEKE1N1Ym1pdFJldmlld1JlcXVlc3QSDAoEcmVwbxgBIAEoCRITCgtwdWxsX251bWJlchgCIAEoBBIPCgd2ZXJkaWN0GAMgASgJEgwKBGJvZHkYBCABKAkSMAoIY29tbWVudHMYBSADKAsyHi5jb21wYXNzLnYxLlJldmlld0NvbW1lbnRJbnB1dCJMChJSZXZpZXdDb21tZW50SW5wdXQSDAoEcGF0aBgBIAEoCRIMCgRsaW5lGAIgASgNEgwKBHNpZGUYAyABKAkSDAoEYm9keRgEIAEoCSKmAQoVU3Vic2NyaWJlRm9yZ2VSZXF1ZXN0EgwKBHJlcG8YASABKAkSKwoEa2luZBgCIAEoDjIdLmNvbXBhc3MudjEuRm9yZ2VBcnRpZmFjdEtpbmQSDgoGbnVtYmVyGAMgASgEEjEKBXNjb3BlGAQgASgOMiIuY29tcGFzcy52MS5Gb3JnZVN1YnNjcmlwdGlvblNjb3BlEg8KB3Byb2plY3QYBSABKAkiMQoWU3Vic2NyaWJlRm9yZ2VSZXNwb25zZRIXCg9zdWJzY3JpcHRpb25faWQYASABKAkiMgoXVW5zdWJzY3JpYmVGb3JnZVJlcXVlc3QSFwoPc3Vic2NyaXB0aW9uX2lkGAEgASgJIhoKGFVuc3Vic2NyaWJlRm9yZ2VSZXNwb25zZSJoChBCb2FyZENhbGxSZXF1ZXN0Eg8KB2NhbGxfaWQYASABKAkSOwoPc2V0X2lzc3VlX3N0YXRlGAIgASgLMiAuY29tcGFzcy52MS5TZXRJc3N1ZVN0YXRlUmVxdWVzdEgAQgYKBGNhbGwiTwoUU2V0SXNzdWVTdGF0ZVJlcXVlc3QSEAoIaXNzdWVfaWQYASABKAkSJQoFc3RhdGUYAiABKA4yFi5jb21wYXNzLnYxLklzc3VlU3RhdGUiOQoVU2V0SXNzdWVTdGF0ZVJlc3BvbnNlEiAKBWlzc3VlGAEgASgLMhEuY29tcGFzcy52MS5Jc3N1ZSKXAQoPQm9hcmRDYWxsUmVzdWx0Eg8KB2NhbGxfaWQYASABKAkSPAoPc2V0X2lzc3VlX3N0YXRlGAIgASgLMiEuY29tcGFzcy52MS5TZXRJc3N1ZVN0YXRlUmVzcG9uc2VIABIrCgVlcnJvchgDIAEoCzIaLmNvbXBhc3MudjEuQm9hcmRDYWxsRXJyb3JIAEIICgZyZXN1bHQiLwoOQm9hcmRDYWxsRXJyb3ISDAoEY29kZRgBIAEoCRIPCgdtZXNzYWdlGAIgASgJIjwKE1B1Ymxpc2hGcmFtZVJlcXVlc3QSJQoFZnJhbWUYASABKAsyFi5jb21wYXNzLnYxLkFnZW50RnJhbWUiFgoUUHVibGlzaEZyYW1lUmVzcG9uc2UiXgocUG9zdENvbnZlcnNhdGlvbkZyYW1lUmVxdWVzdBIlCgVmcmFtZRgBIAEoCzIWLmNvbXBhc3MudjEuQWdlbnRGcmFtZRIXCg9pZGVtcG90ZW5jeV9rZXkYAiABKAkiHwodUG9zdENvbnZlcnNhdGlvbkZyYW1lUmVzcG9uc2UiGQoXQ29udHJvbFN1YnNjcmliZVJlcXVlc3QqkQEKFkZvcmdlU3Vic2NyaXB0aW9uU2NvcGUSKAokRk9SR0VfU1VCU0NSSVBUSU9OX1NDT1BFX1VOU1BFQ0lGSUVEEAASJQohRk9SR0VfU1VCU0NSSVBUSU9OX1NDT1BFX0FSVElGQUNUEAESJgoiRk9SR0VfU1VCU0NSSVBUSU9OX1NDT1BFX0NPTlRBSU5FUhACMrQECgxBZ2VudEdhdGV3YXkSQgoFQ29tbXMSHC5jb21wYXNzLnYxLkNvbW1zQ2FsbFJlcXVlc3QaGy5jb21wYXNzLnYxLkNvbW1zQ2FsbFJlc3VsdBJOCglMaWZlY3ljbGUSIC5jb21wYXNzLnYxLkxpZmVjeWNsZUNhbGxSZXF1ZXN0Gh8uY29tcGFzcy52MS5MaWZlY3ljbGVDYWxsUmVzdWx0Ek4KB1B1Ymxpc2gSHy5jb21wYXNzLnYxLlB1Ymxpc2hGcmFtZVJlcXVlc3QaIC5jb21wYXNzLnYxLlB1Ymxpc2hGcmFtZVJlc3BvbnNlKAESbAoVUG9zdENvbnZlcnNhdGlvbkZyYW1lEiguY29tcGFzcy52MS5Qb3N0Q29udmVyc2F0aW9uRnJhbWVSZXF1ZXN0GikuY29tcGFzcy52MS5Qb3N0Q29udmVyc2F0aW9uRnJhbWVSZXNwb25zZRJKCgdDb250cm9sEiMuY29tcGFzcy52MS5Db250cm9sU3Vic2NyaWJlUmVxdWVzdBoYLmNvbXBhc3MudjEuQWdlbnRDb250cm9sMAESQgoFRm9yZ2USHC5jb21wYXNzLnYxLkZvcmdlQ2FsbFJlcXVlc3QaGy5jb21wYXNzLnYxLkZvcmdlQ2FsbFJlc3VsdBJCCgVCb2FyZBIcLmNvbXBhc3MudjEuQm9hcmRDYWxsUmVxdWVzdBobLmNvbXBhc3MudjEuQm9hcmRDYWxsUmVzdWx0YgZwcm90bzM", [file_compass_v1_comms, file_compass_v1_agent, file_compass_v1_compass, file_compass_v1_forge]); + fileDesc("Ch5jb21wYXNzL3YxL2FnZW50X2dhdGV3YXkucHJvdG8SCmNvbXBhc3MudjEiuwQKEENvbW1zQ2FsbFJlcXVlc3QSDwoHY2FsbF9pZBgBIAEoCRIuCgRwb3N0GAIgASgLMh4uY29tcGFzcy52MS5Qb3N0TWVzc2FnZVJlcXVlc3RIABIvCgRsaXN0GAMgASgLMh8uY29tcGFzcy52MS5MaXN0TWVzc2FnZXNSZXF1ZXN0SAASLgoGcm9zdGVyGAQgASgLMhwuY29tcGFzcy52MS5HZXRSb3N0ZXJSZXF1ZXN0SAASNwoKc2V0X3N0YXR1cxgFIAEoCzIhLmNvbXBhc3MudjEuU2V0QWdlbnRTdGF0dXNSZXF1ZXN0SAASMwoDcGluGAYgASgLMiQuY29tcGFzcy52MS5VcGRhdGVQaW5uZWRCb2FyZFJlcXVlc3RIABI6Cg5jcmVhdGVfY2hhbm5lbBgHIAEoCzIgLmNvbXBhc3MudjEuQ3JlYXRlQ2hhbm5lbFJlcXVlc3RIABJBCg51cGRhdGVfbWVtYmVycxgIIAEoCzInLmNvbXBhc3MudjEuVXBkYXRlQ2hhbm5lbE1lbWJlcnNSZXF1ZXN0SAASRQoUY3JlYXRlX2NoYW5uZWxfZ3JvdXAYCSABKAsyJS5jb21wYXNzLnYxLkNyZWF0ZUNoYW5uZWxHcm91cFJlcXVlc3RIABIsCgdvcGVuX2RtGAsgASgLMhkuY29tcGFzcy52MS5PcGVuRE1SZXF1ZXN0SAASGwoTdHJpZ2dlcl90cmFjZXBhcmVudBgKIAEoCUIGCgRjYWxsItUECg9Db21tc0NhbGxSZXN1bHQSDwoHY2FsbF9pZBgBIAEoCRIvCgRwb3N0GAIgASgLMh8uY29tcGFzcy52MS5Qb3N0TWVzc2FnZVJlc3BvbnNlSAASMAoEbGlzdBgDIAEoCzIgLmNvbXBhc3MudjEuTGlzdE1lc3NhZ2VzUmVzcG9uc2VIABIrCgVlcnJvchgEIAEoCzIaLmNvbXBhc3MudjEuQ29tbXNDYWxsRXJyb3JIABIvCgZyb3N0ZXIYBSABKAsyHS5jb21wYXNzLnYxLkdldFJvc3RlclJlc3BvbnNlSAASOAoKc2V0X3N0YXR1cxgGIAEoCzIiLmNvbXBhc3MudjEuU2V0QWdlbnRTdGF0dXNSZXNwb25zZUgAEjQKA3BpbhgHIAEoCzIlLmNvbXBhc3MudjEuVXBkYXRlUGlubmVkQm9hcmRSZXNwb25zZUgAEjsKDmNyZWF0ZV9jaGFubmVsGAggASgLMiEuY29tcGFzcy52MS5DcmVhdGVDaGFubmVsUmVzcG9uc2VIABJCCg51cGRhdGVfbWVtYmVycxgJIAEoCzIoLmNvbXBhc3MudjEuVXBkYXRlQ2hhbm5lbE1lbWJlcnNSZXNwb25zZUgAEkYKFGNyZWF0ZV9jaGFubmVsX2dyb3VwGAogASgLMiYuY29tcGFzcy52MS5DcmVhdGVDaGFubmVsR3JvdXBSZXNwb25zZUgAEi0KB29wZW5fZG0YCyABKAsyGi5jb21wYXNzLnYxLk9wZW5ETVJlc3BvbnNlSABCCAoGcmVzdWx0Ii8KDkNvbW1zQ2FsbEVycm9yEgwKBGNvZGUYASABKAkSDwoHbWVzc2FnZRgCIAEoCSIpChVTZXRBZ2VudFN0YXR1c1JlcXVlc3QSEAoIYWN0aXZpdHkYASABKAkiGAoWU2V0QWdlbnRTdGF0dXNSZXNwb25zZSKRAQoUTGlmZWN5Y2xlQ2FsbFJlcXVlc3QSDwoHY2FsbF9pZBgBIAEoCRItCgVzcGF3bhgCIAEoCzIcLmNvbXBhc3MudjEuU3Bhd25QZWVyUmVxdWVzdEgAEjEKB2Rlc3Bhd24YAyABKAsyHi5jb21wYXNzLnYxLkRlc3Bhd25QZWVyUmVxdWVzdEgAQgYKBGNhbGwiiAEKEFNwYXduUGVlclJlcXVlc3QSDgoGaGFuZGxlGAEgASgJEhQKDGRpc3BsYXlfbmFtZRgCIAEoCRIZChFjbGllbnRfcmVxdWVzdF9pZBgEIAEoCRIMCgRyb2xlGAUgASgJEg8KB3BlcnNvbmEYBiABKAlKBAgDEARSDmluaXRpYWxfcHJvbXB0InIKEVNwYXduUGVlclJlc3BvbnNlEhgKEGFnZW50X2FjY291bnRfaWQYASABKAkSFgoOY29udGFpbmVyX25hbWUYAiABKAkSEgoKc2Vzc2lvbl9pZBgDIAEoCRIXCg9kbV9jaGFubmVsX25hbWUYBCABKAkiKgoSRGVzcGF3blBlZXJSZXF1ZXN0EhQKDGFnZW50X2hhbmRsZRgBIAEoCSIVChNEZXNwYXduUGVlclJlc3BvbnNlIsUBChNMaWZlY3ljbGVDYWxsUmVzdWx0Eg8KB2NhbGxfaWQYASABKAkSLgoFc3Bhd24YAiABKAsyHS5jb21wYXNzLnYxLlNwYXduUGVlclJlc3BvbnNlSAASMgoHZGVzcGF3bhgDIAEoCzIfLmNvbXBhc3MudjEuRGVzcGF3blBlZXJSZXNwb25zZUgAEi8KBWVycm9yGAQgASgLMh4uY29tcGFzcy52MS5MaWZlY3ljbGVDYWxsRXJyb3JIAEIICgZyZXN1bHQiMwoSTGlmZWN5Y2xlQ2FsbEVycm9yEgwKBGNvZGUYASABKAkSDwoHbWVzc2FnZRgCIAEoCSLrBgoQRm9yZ2VDYWxsUmVxdWVzdBIPCgdjYWxsX2lkGAEgASgJEjYKDGNyZWF0ZV9pc3N1ZRgCIAEoCzIeLmNvbXBhc3MudjEuQ3JlYXRlSXNzdWVSZXF1ZXN0SAASPQoQY29tbWVudF9vbl9pc3N1ZRgDIAEoCzIhLmNvbXBhc3MudjEuQ29tbWVudE9uSXNzdWVSZXF1ZXN0SAASMAoJZ2V0X2lzc3VlGAQgASgLMhsuY29tcGFzcy52MS5HZXRJc3N1ZVJlcXVlc3RIABI0CgtsaXN0X2lzc3VlcxgFIAEoCzIdLmNvbXBhc3MudjEuTGlzdElzc3Vlc1JlcXVlc3RIABJDChNjcmVhdGVfcHVsbF9yZXF1ZXN0GAYgASgLMiQuY29tcGFzcy52MS5DcmVhdGVQdWxsUmVxdWVzdFJlcXVlc3RIABJKChdjb21tZW50X29uX3B1bGxfcmVxdWVzdBgHIAEoCzInLmNvbXBhc3MudjEuQ29tbWVudE9uUHVsbFJlcXVlc3RSZXF1ZXN0SAASPQoQZ2V0X3B1bGxfcmVxdWVzdBgIIAEoCzIhLmNvbXBhc3MudjEuR2V0UHVsbFJlcXVlc3RSZXF1ZXN0SAASNgoJc3Vic2NyaWJlGAkgASgLMiEuY29tcGFzcy52MS5TdWJzY3JpYmVGb3JnZVJlcXVlc3RIABI6Cgt1bnN1YnNjcmliZRgKIAEoCzIjLmNvbXBhc3MudjEuVW5zdWJzY3JpYmVGb3JnZVJlcXVlc3RIABI4Cg1zdWJtaXRfcmV2aWV3GAsgASgLMh8uY29tcGFzcy52MS5TdWJtaXRSZXZpZXdSZXF1ZXN0SAASSQoWdHJhbnNpdGlvbl9pc3N1ZV9zdGF0ZRgOIAEoCzInLmNvbXBhc3MudjEuVHJhbnNpdGlvbklzc3VlU3RhdGVSZXF1ZXN0SAASVgoddHJhbnNpdGlvbl9wdWxsX3JlcXVlc3Rfc3RhdGUYDyABKAsyLS5jb21wYXNzLnYxLlRyYW5zaXRpb25QdWxsUmVxdWVzdFN0YXRlUmVxdWVzdEgAEiMKBWZvcmdlGAwgASgLMhQuY29tcGFzcy52MS5Gb3JnZVJlZhIZChFjbGllbnRfcmVxdWVzdF9pZBgNIAEoCUIGCgRjYWxsIuADCg9Gb3JnZUNhbGxSZXN1bHQSDwoHY2FsbF9pZBgBIAEoCRIiCgVpc3N1ZRgCIAEoCzIRLmNvbXBhc3MudjEuSXNzdWVIABIvCg1pc3N1ZV9jb21tZW50GAMgASgLMhYuY29tcGFzcy52MS5Db21tZW50UmVmSAASMAoGaXNzdWVzGAQgASgLMh4uY29tcGFzcy52MS5MaXN0SXNzdWVzUmVzcG9uc2VIABIvCgxwdWxsX3JlcXVlc3QYBSABKAsyFy5jb21wYXNzLnYxLlB1bGxSZXF1ZXN0SAASLAoKcHJfY29tbWVudBgGIAEoCzIWLmNvbXBhc3MudjEuQ29tbWVudFJlZkgAEjgKCnN1YnNjcmliZWQYByABKAsyIi5jb21wYXNzLnYxLlN1YnNjcmliZUZvcmdlUmVzcG9uc2VIABI8Cgx1bnN1YnNjcmliZWQYCCABKAsyJC5jb21wYXNzLnYxLlVuc3Vic2NyaWJlRm9yZ2VSZXNwb25zZUgAEisKBWVycm9yGAkgASgLMhouY29tcGFzcy52MS5Gb3JnZUNhbGxFcnJvckgAEicKBnJldmlldxgKIAEoCzIVLmNvbXBhc3MudjEuUmV2aWV3UmVmSABCCAoGcmVzdWx0IkcKDkZvcmdlQ2FsbEVycm9yEgwKBGNvZGUYASABKAkSDwoHbWVzc2FnZRgCIAEoCRIWCg5yZXRyeV9hZnRlcl9tcxgDIAEoDSJPChJDcmVhdGVJc3N1ZVJlcXVlc3QSDAoEcmVwbxgBIAEoCRINCgV0aXRsZRgCIAEoCRIMCgRib2R5GAMgASgJEg4KBmxhYmVscxgEIAMoCSJJChVDb21tZW50T25Jc3N1ZVJlcXVlc3QSDAoEcmVwbxgBIAEoCRIUCgxpc3N1ZV9udW1iZXIYAiABKAQSDAoEYm9keRgDIAEoCSI1Cg9HZXRJc3N1ZVJlcXVlc3QSDAoEcmVwbxgBIAEoCRIUCgxpc3N1ZV9udW1iZXIYAiABKAQiTwoRTGlzdElzc3Vlc1JlcXVlc3QSDAoEcmVwbxgBIAEoCRINCgVzdGF0ZRgCIAEoCRIOCgZsYWJlbHMYAyADKAkSDQoFbGltaXQYBCABKA0iNwoSTGlzdElzc3Vlc1Jlc3BvbnNlEiEKBmlzc3VlcxgBIAMoCzIRLmNvbXBhc3MudjEuSXNzdWUieAoYQ3JlYXRlUHVsbFJlcXVlc3RSZXF1ZXN0EgwKBHJlcG8YASABKAkSDQoFdGl0bGUYAiABKAkSDAoEYm9keRgDIAEoCRIQCghoZWFkX3JlZhgEIAEoCRIQCghiYXNlX3JlZhgFIAEoCRINCgVkcmFmdBgGIAEoCCJOChtDb21tZW50T25QdWxsUmVxdWVzdFJlcXVlc3QSDAoEcmVwbxgBIAEoCRITCgtwdWxsX251bWJlchgCIAEoBBIMCgRib2R5GAMgASgJIjoKFUdldFB1bGxSZXF1ZXN0UmVxdWVzdBIMCgRyZXBvGAEgASgJEhMKC3B1bGxfbnVtYmVyGAIgASgEIokBChNTdWJtaXRSZXZpZXdSZXF1ZXN0EgwKBHJlcG8YASABKAkSEwoLcHVsbF9udW1iZXIYAiABKAQSDwoHdmVyZGljdBgDIAEoCRIMCgRib2R5GAQgASgJEjAKCGNvbW1lbnRzGAUgAygLMh4uY29tcGFzcy52MS5SZXZpZXdDb21tZW50SW5wdXQiTAoSUmV2aWV3Q29tbWVudElucHV0EgwKBHBhdGgYASABKAkSDAoEbGluZRgCIAEoDRIMCgRzaWRlGAMgASgJEgwKBGJvZHkYBCABKAkifgobVHJhbnNpdGlvbklzc3VlU3RhdGVSZXF1ZXN0EgwKBHJlcG8YASABKAkSFAoMaXNzdWVfbnVtYmVyGAIgASgEEg0KBXN0YXRlGAMgASgJEhQKDGNsb3NlX3JlYXNvbhgEIAEoCRIWCg53b3JrZmxvd19zdGF0ZRgFIAEoCSJTCiFUcmFuc2l0aW9uUHVsbFJlcXVlc3RTdGF0ZVJlcXVlc3QSDAoEcmVwbxgBIAEoCRIRCglwcl9udW1iZXIYAiABKAQSDQoFc3RhdGUYAyABKAkipgEKFVN1YnNjcmliZUZvcmdlUmVxdWVzdBIMCgRyZXBvGAEgASgJEisKBGtpbmQYAiABKA4yHS5jb21wYXNzLnYxLkZvcmdlQXJ0aWZhY3RLaW5kEg4KBm51bWJlchgDIAEoBBIxCgVzY29wZRgEIAEoDjIiLmNvbXBhc3MudjEuRm9yZ2VTdWJzY3JpcHRpb25TY29wZRIPCgdwcm9qZWN0GAUgASgJIjEKFlN1YnNjcmliZUZvcmdlUmVzcG9uc2USFwoPc3Vic2NyaXB0aW9uX2lkGAEgASgJIjIKF1Vuc3Vic2NyaWJlRm9yZ2VSZXF1ZXN0EhcKD3N1YnNjcmlwdGlvbl9pZBgBIAEoCSIaChhVbnN1YnNjcmliZUZvcmdlUmVzcG9uc2UiaAoQQm9hcmRDYWxsUmVxdWVzdBIPCgdjYWxsX2lkGAEgASgJEjsKD3NldF9pc3N1ZV9zdGF0ZRgCIAEoCzIgLmNvbXBhc3MudjEuU2V0SXNzdWVTdGF0ZVJlcXVlc3RIAEIGCgRjYWxsIk8KFFNldElzc3VlU3RhdGVSZXF1ZXN0EhAKCGlzc3VlX2lkGAEgASgJEiUKBXN0YXRlGAIgASgOMhYuY29tcGFzcy52MS5Jc3N1ZVN0YXRlIjkKFVNldElzc3VlU3RhdGVSZXNwb25zZRIgCgVpc3N1ZRgBIAEoCzIRLmNvbXBhc3MudjEuSXNzdWUilwEKD0JvYXJkQ2FsbFJlc3VsdBIPCgdjYWxsX2lkGAEgASgJEjwKD3NldF9pc3N1ZV9zdGF0ZRgCIAEoCzIhLmNvbXBhc3MudjEuU2V0SXNzdWVTdGF0ZVJlc3BvbnNlSAASKwoFZXJyb3IYAyABKAsyGi5jb21wYXNzLnYxLkJvYXJkQ2FsbEVycm9ySABCCAoGcmVzdWx0Ii8KDkJvYXJkQ2FsbEVycm9yEgwKBGNvZGUYASABKAkSDwoHbWVzc2FnZRgCIAEoCSI8ChNQdWJsaXNoRnJhbWVSZXF1ZXN0EiUKBWZyYW1lGAEgASgLMhYuY29tcGFzcy52MS5BZ2VudEZyYW1lIhYKFFB1Ymxpc2hGcmFtZVJlc3BvbnNlIl4KHFBvc3RDb252ZXJzYXRpb25GcmFtZVJlcXVlc3QSJQoFZnJhbWUYASABKAsyFi5jb21wYXNzLnYxLkFnZW50RnJhbWUSFwoPaWRlbXBvdGVuY3lfa2V5GAIgASgJIh8KHVBvc3RDb252ZXJzYXRpb25GcmFtZVJlc3BvbnNlIhkKF0NvbnRyb2xTdWJzY3JpYmVSZXF1ZXN0KpEBChZGb3JnZVN1YnNjcmlwdGlvblNjb3BlEigKJEZPUkdFX1NVQlNDUklQVElPTl9TQ09QRV9VTlNQRUNJRklFRBAAEiUKIUZPUkdFX1NVQlNDUklQVElPTl9TQ09QRV9BUlRJRkFDVBABEiYKIkZPUkdFX1NVQlNDUklQVElPTl9TQ09QRV9DT05UQUlORVIQAjK0BAoMQWdlbnRHYXRld2F5EkIKBUNvbW1zEhwuY29tcGFzcy52MS5Db21tc0NhbGxSZXF1ZXN0GhsuY29tcGFzcy52MS5Db21tc0NhbGxSZXN1bHQSTgoJTGlmZWN5Y2xlEiAuY29tcGFzcy52MS5MaWZlY3ljbGVDYWxsUmVxdWVzdBofLmNvbXBhc3MudjEuTGlmZWN5Y2xlQ2FsbFJlc3VsdBJOCgdQdWJsaXNoEh8uY29tcGFzcy52MS5QdWJsaXNoRnJhbWVSZXF1ZXN0GiAuY29tcGFzcy52MS5QdWJsaXNoRnJhbWVSZXNwb25zZSgBEmwKFVBvc3RDb252ZXJzYXRpb25GcmFtZRIoLmNvbXBhc3MudjEuUG9zdENvbnZlcnNhdGlvbkZyYW1lUmVxdWVzdBopLmNvbXBhc3MudjEuUG9zdENvbnZlcnNhdGlvbkZyYW1lUmVzcG9uc2USSgoHQ29udHJvbBIjLmNvbXBhc3MudjEuQ29udHJvbFN1YnNjcmliZVJlcXVlc3QaGC5jb21wYXNzLnYxLkFnZW50Q29udHJvbDABEkIKBUZvcmdlEhwuY29tcGFzcy52MS5Gb3JnZUNhbGxSZXF1ZXN0GhsuY29tcGFzcy52MS5Gb3JnZUNhbGxSZXN1bHQSQgoFQm9hcmQSHC5jb21wYXNzLnYxLkJvYXJkQ2FsbFJlcXVlc3QaGy5jb21wYXNzLnYxLkJvYXJkQ2FsbFJlc3VsdGIGcHJvdG8z", [file_compass_v1_comms, file_compass_v1_agent, file_compass_v1_compass, file_compass_v1_forge]); /** * One agent-initiated comms call. `call_id` is the agent-minted correlation id @@ -616,6 +616,18 @@ export type ForgeCallRequest = Message<"compass.v1.ForgeCallRequest"> & { */ value: SubmitReviewRequest; case: "submitReview"; + } | { + /** + * @generated from field: compass.v1.TransitionIssueStateRequest transition_issue_state = 14; + */ + value: TransitionIssueStateRequest; + case: "transitionIssueState"; + } | { + /** + * @generated from field: compass.v1.TransitionPullRequestStateRequest transition_pull_request_state = 15; + */ + value: TransitionPullRequestStateRequest; + case: "transitionPullRequestState"; } | { case: undefined; value?: undefined }; /** @@ -636,7 +648,7 @@ export type ForgeCallRequest = Message<"compass.v1.ForgeCallRequest"> & { * join + Provision dedup)"). A retried create with the same key returns the * ORIGINAL artifact, never a duplicate. Distinct from call_id, which is * correlation-only. Ignored on non-create arms. Field 13 is collision-free: - * call_id=1, oneof arms 2-11, forge=12. + * call_id=1, oneof arms 2-11 and 14-15, forge=12. * * @generated from field: string client_request_id = 13; */ @@ -671,7 +683,7 @@ export type ForgeCallResult = Message<"compass.v1.ForgeCallResult"> & { */ result: { /** - * create_issue / get_issue + * create_issue / get_issue / transition_issue_state * * @generated from field: compass.v1.Issue issue = 2; */ @@ -695,7 +707,7 @@ export type ForgeCallResult = Message<"compass.v1.ForgeCallResult"> & { case: "issues"; } | { /** - * create_pull_request / get_pull_request + * create_pull_request / get_pull_request / transition_pull_request_state * * @generated from field: compass.v1.PullRequest pull_request = 5; */ @@ -781,7 +793,7 @@ export const ForgeCallErrorSchema: GenMessage = /*@__PURE__*/ messageDesc(file_compass_v1_agent_gateway, 14); /** - * The seven forge operation requests. Every field is a scalar — no forge domain + * The forge operation requests. Every field is a scalar — no forge domain * type appears in any request shape, so the request wire is identical under * either forge read model. `repo` is "/" on GitHub and the team * key on Linear, REQUIRED on every call — an empty `repo` is an invalid_argument @@ -1113,6 +1125,98 @@ export type ReviewCommentInput = Message<"compass.v1.ReviewCommentInput"> & { export const ReviewCommentInputSchema: GenMessage = /*@__PURE__*/ messageDesc(file_compass_v1_agent_gateway, 24); +/** + * Move an existing artifact between forge states. Mutates a coordinate and + * mints none, so neither create-only mechanism applies: no client_request_id + * (a repeated transition to the same state is already idempotent at the forge) + * and no owner stamp (there is no body to stamp). `state` is the raw forge + * state string the whole read path already speaks (forge.Issue.State, + * ForgeNotification.state), not a new enum — DL-069's no-forge-shape rule + * concerns message types, which these add none of. Named on the *transition* + * stem because the board lane already owns SetIssueStateRequest, which + * operates on the Compass-local Issue.id + compass.v1.IssueState instead. + * A successful transition returns the UPDATED artifact on the existing + * ForgeCallResult.issue / .pull_request arms, so the caller sees + * post-transition truth exactly as a create's caller sees the created one. + * + * @generated from message compass.v1.TransitionIssueStateRequest + */ +export type TransitionIssueStateRequest = Message<"compass.v1.TransitionIssueStateRequest"> & { + /** + * REQUIRED; "/" on GitHub, team key on Linear + * + * @generated from field: string repo = 1; + */ + repo: string; + + /** + * @generated from field: uint64 issue_number = 2; + */ + issueNumber: bigint; + + /** + * REQUIRED: "open" | "closed" (the forge.Issue.State domain) + * + * @generated from field: string state = 3; + */ + state: string; + + /** + * GitHub only: "completed" | "not_planned"; "" = provider default + * + * @generated from field: string close_reason = 4; + */ + closeReason: string; + + /** + * Linear only: target workflow state NAME; "" = default mapping + * + * @generated from field: string workflow_state = 5; + */ + workflowState: string; +}; + +/** + * Describes the message compass.v1.TransitionIssueStateRequest. + * Use `create(TransitionIssueStateRequestSchema)` to create a new message. + */ +export const TransitionIssueStateRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_compass_v1_agent_gateway, 25); + +/** + * The PR twin. No refinement fields: `close_reason` is a GitHub *issue* + * concept, and merge is a separate concern never expressed as a transition. + * + * @generated from message compass.v1.TransitionPullRequestStateRequest + */ +export type TransitionPullRequestStateRequest = Message<"compass.v1.TransitionPullRequestStateRequest"> & { + /** + * REQUIRED + * + * @generated from field: string repo = 1; + */ + repo: string; + + /** + * @generated from field: uint64 pr_number = 2; + */ + prNumber: bigint; + + /** + * "open" | "closed" + * + * @generated from field: string state = 3; + */ + state: string; +}; + +/** + * Describes the message compass.v1.TransitionPullRequestStateRequest. + * Use `create(TransitionPullRequestStateRequestSchema)` to create a new message. + */ +export const TransitionPullRequestStateRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_compass_v1_agent_gateway, 26); + /** * @generated from message compass.v1.SubscribeForgeRequest */ @@ -1156,7 +1260,7 @@ export type SubscribeForgeRequest = Message<"compass.v1.SubscribeForgeRequest"> * Use `create(SubscribeForgeRequestSchema)` to create a new message. */ export const SubscribeForgeRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_compass_v1_agent_gateway, 25); + messageDesc(file_compass_v1_agent_gateway, 27); /** * @generated from message compass.v1.SubscribeForgeResponse @@ -1173,7 +1277,7 @@ export type SubscribeForgeResponse = Message<"compass.v1.SubscribeForgeResponse" * Use `create(SubscribeForgeResponseSchema)` to create a new message. */ export const SubscribeForgeResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_compass_v1_agent_gateway, 26); + messageDesc(file_compass_v1_agent_gateway, 28); /** * @generated from message compass.v1.UnsubscribeForgeRequest @@ -1190,7 +1294,7 @@ export type UnsubscribeForgeRequest = Message<"compass.v1.UnsubscribeForgeReques * Use `create(UnsubscribeForgeRequestSchema)` to create a new message. */ export const UnsubscribeForgeRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_compass_v1_agent_gateway, 27); + messageDesc(file_compass_v1_agent_gateway, 29); /** * @generated from message compass.v1.UnsubscribeForgeResponse @@ -1203,7 +1307,7 @@ export type UnsubscribeForgeResponse = Message<"compass.v1.UnsubscribeForgeRespo * Use `create(UnsubscribeForgeResponseSchema)` to create a new message. */ export const UnsubscribeForgeResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_compass_v1_agent_gateway, 28); + messageDesc(file_compass_v1_agent_gateway, 30); /** * One agent-initiated board call. `call_id` is the agent-minted correlation id @@ -1237,7 +1341,7 @@ export type BoardCallRequest = Message<"compass.v1.BoardCallRequest"> & { * Use `create(BoardCallRequestSchema)` to create a new message. */ export const BoardCallRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_compass_v1_agent_gateway, 29); + messageDesc(file_compass_v1_agent_gateway, 31); /** * Set an issue's canonical lifecycle state. Carries the full frozen @@ -1271,7 +1375,7 @@ export type SetIssueStateRequest = Message<"compass.v1.SetIssueStateRequest"> & * Use `create(SetIssueStateRequestSchema)` to create a new message. */ export const SetIssueStateRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_compass_v1_agent_gateway, 30); + messageDesc(file_compass_v1_agent_gateway, 32); /** * The post-transition truth, correlated in BoardCallResult. Unchanged on a no-op. @@ -1290,7 +1394,7 @@ export type SetIssueStateResponse = Message<"compass.v1.SetIssueStateResponse"> * Use `create(SetIssueStateResponseSchema)` to create a new message. */ export const SetIssueStateResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_compass_v1_agent_gateway, 31); + messageDesc(file_compass_v1_agent_gateway, 33); /** * The result of one board call, correlated by `call_id`. A successful call sets @@ -1329,7 +1433,7 @@ export type BoardCallResult = Message<"compass.v1.BoardCallResult"> & { * Use `create(BoardCallResultSchema)` to create a new message. */ export const BoardCallResultSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_compass_v1_agent_gateway, 32); + messageDesc(file_compass_v1_agent_gateway, 34); /** * An in-band board-call failure: a tool error the agent renders to the model, @@ -1355,7 +1459,7 @@ export type BoardCallError = Message<"compass.v1.BoardCallError"> & { * Use `create(BoardCallErrorSchema)` to create a new message. */ export const BoardCallErrorSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_compass_v1_agent_gateway, 33); + messageDesc(file_compass_v1_agent_gateway, 35); /** * Publish stream element: one trace/session AgentFrame, in emission order. No @@ -1379,7 +1483,7 @@ export type PublishFrameRequest = Message<"compass.v1.PublishFrameRequest"> & { * Use `create(PublishFrameRequestSchema)` to create a new message. */ export const PublishFrameRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_compass_v1_agent_gateway, 34); + messageDesc(file_compass_v1_agent_gateway, 36); /** * Acked at stream close, mirroring RunnerService.PublishEvents' PublishEventsResponse. @@ -1394,7 +1498,7 @@ export type PublishFrameResponse = Message<"compass.v1.PublishFrameResponse"> & * Use `create(PublishFrameResponseSchema)` to create a new message. */ export const PublishFrameResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_compass_v1_agent_gateway, 35); + messageDesc(file_compass_v1_agent_gateway, 37); /** * The durable-frame unary carries the SAME AgentFrame message, constrained by @@ -1427,7 +1531,7 @@ export type PostConversationFrameRequest = Message<"compass.v1.PostConversationF * Use `create(PostConversationFrameRequestSchema)` to create a new message. */ export const PostConversationFrameRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_compass_v1_agent_gateway, 36); + messageDesc(file_compass_v1_agent_gateway, 38); /** * Returned only after the upstream PublishEvents forward is accepted. @@ -1442,7 +1546,7 @@ export type PostConversationFrameResponse = Message<"compass.v1.PostConversation * Use `create(PostConversationFrameResponseSchema)` to create a new message. */ export const PostConversationFrameResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_compass_v1_agent_gateway, 37); + messageDesc(file_compass_v1_agent_gateway, 39); /** * The Control subscribe request carries no session id: the per-container socket @@ -1458,7 +1562,7 @@ export type ControlSubscribeRequest = Message<"compass.v1.ControlSubscribeReques * Use `create(ControlSubscribeRequestSchema)` to create a new message. */ export const ControlSubscribeRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_compass_v1_agent_gateway, 38); + messageDesc(file_compass_v1_agent_gateway, 40); /** * Subscribe/unsubscribe a forge artifact for change notifications (DL-053). The diff --git a/proto/compass/v1/agent_gateway.proto b/proto/compass/v1/agent_gateway.proto index 0a1aae546..211a358e0 100644 --- a/proto/compass/v1/agent_gateway.proto +++ b/proto/compass/v1/agent_gateway.proto @@ -258,6 +258,8 @@ message ForgeCallRequest { SubscribeForgeRequest subscribe = 9; // DL-053 UnsubscribeForgeRequest unsubscribe = 10; // DL-053 SubmitReviewRequest submit_review = 11; + TransitionIssueStateRequest transition_issue_state = 14; + TransitionPullRequestStateRequest transition_pull_request_state = 15; } // Which forge the call addresses. UNSET selects the default (configured // GitHub) forge — additive, existing callers unchanged. An unknown/unconfigured @@ -271,7 +273,7 @@ message ForgeCallRequest { // join + Provision dedup)"). A retried create with the same key returns the // ORIGINAL artifact, never a duplicate. Distinct from call_id, which is // correlation-only. Ignored on non-create arms. Field 13 is collision-free: - // call_id=1, oneof arms 2-11, forge=12. + // call_id=1, oneof arms 2-11 and 14-15, forge=12. string client_request_id = 13; } @@ -284,10 +286,10 @@ message ForgeCallRequest { message ForgeCallResult { string call_id = 1; oneof result { - compass.v1.Issue issue = 2; // create_issue / get_issue + compass.v1.Issue issue = 2; // create_issue / get_issue / transition_issue_state CommentRef issue_comment = 3; // comment_on_issue (write ack: url + comment_id) ListIssuesResponse issues = 4; // list_issues - compass.v1.PullRequest pull_request = 5; // create_pull_request / get_pull_request + compass.v1.PullRequest pull_request = 5; // create_pull_request / get_pull_request / transition_pull_request_state CommentRef pr_comment = 6; // comment_on_pull_request (write ack) SubscribeForgeResponse subscribed = 7; UnsubscribeForgeResponse unsubscribed = 8; @@ -305,7 +307,7 @@ message ForgeCallError { uint32 retry_after_ms = 3; // time until a retry stops fail-fasting; 0 = no hint } -// The seven forge operation requests. Every field is a scalar — no forge domain +// The forge operation requests. Every field is a scalar — no forge domain // type appears in any request shape, so the request wire is identical under // either forge read model. `repo` is "/" on GitHub and the team // key on Linear, REQUIRED on every call — an empty `repo` is an invalid_argument @@ -368,6 +370,34 @@ message ReviewCommentInput { string body = 4; // NOT stamped (rides inside the stamped review) } +// Move an existing artifact between forge states. Mutates a coordinate and +// mints none, so neither create-only mechanism applies: no client_request_id +// (a repeated transition to the same state is already idempotent at the forge) +// and no owner stamp (there is no body to stamp). `state` is the raw forge +// state string the whole read path already speaks (forge.Issue.State, +// ForgeNotification.state), not a new enum — DL-069's no-forge-shape rule +// concerns message types, which these add none of. Named on the *transition* +// stem because the board lane already owns SetIssueStateRequest, which +// operates on the Compass-local Issue.id + compass.v1.IssueState instead. +// A successful transition returns the UPDATED artifact on the existing +// ForgeCallResult.issue / .pull_request arms, so the caller sees +// post-transition truth exactly as a create's caller sees the created one. +message TransitionIssueStateRequest { + string repo = 1; // REQUIRED; "/" on GitHub, team key on Linear + uint64 issue_number = 2; + string state = 3; // REQUIRED: "open" | "closed" (the forge.Issue.State domain) + string close_reason = 4; // GitHub only: "completed" | "not_planned"; "" = provider default + string workflow_state = 5; // Linear only: target workflow state NAME; "" = default mapping +} + +// The PR twin. No refinement fields: `close_reason` is a GitHub *issue* +// concept, and merge is a separate concern never expressed as a transition. +message TransitionPullRequestStateRequest { + string repo = 1; // REQUIRED + uint64 pr_number = 2; + string state = 3; // "open" | "closed" +} + // Subscribe/unsubscribe a forge artifact for change notifications (DL-053). The // notification payload is ForgeNotification (forge.proto), delivered on the // Sessions -> AgentGateway.Control push path.