Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions adapters/linear/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@
// - UpdateSession: set/replace externalUrls, add/remove URLs, and replace the
// whole plan array. Setting externalUrls can keep a new session from being
// marked unresponsive.
// - CreateSessionOnIssue / CreateSessionOnComment (and their ForTenant
// variants for multi-tenant mode): typed proactive agent session creation
// (agentSessionCreateOnIssue / agentSessionCreateOnComment). The returned
// CreatedAgentSession carries an opaque ThreadID that works everywhere a
// webhook-minted agent-session Thread ID does.
// - SuggestRepositories: typed issueRepositorySuggestions ranking of
// candidate repositories with confidence scores; a low-confidence result
// pairs naturally with a "select" elicitation.
// - GraphQL: a deliberate low-level escape hatch for preview Linear APIs. It
// reuses the client-credentials token-refresh path, surfaces GraphQL errors,
// and never exposes or returns the access token.
Expand Down Expand Up @@ -136,8 +144,7 @@
//
// Multi-tenant OAuth installs (ADR 0006) reuse the per-org credential resolution
// above; the OAuth installation *web flow*, token streaming (ADR 0011), Markdown
// conversion, reactions, edit/delete, files, repository-suggestion ranking, and
// issue-workflow automation remain deferred; the latter are reachable through the
// GraphQL escape hatch. This is an app-owned actor, not a personal-API-key or
// user-OAuth user bot.
// conversion, reactions, edit/delete, files, and issue-workflow automation
// remain deferred; the latter is reachable through the GraphQL escape hatch.
// This is an app-owned actor, not a personal-API-key or user-OAuth user bot.
package linear
85 changes: 85 additions & 0 deletions adapters/linear/linear_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,17 @@ type linearAPIServer struct {
// 429 with Retry-After before succeeding.
rateLimit int
rateLimitSeen int
// throttleNext, when > 0, makes the next N GraphQL calls (any operation)
// return a 429 with Retry-After before succeeding.
throttleNext int
// sessionCreates records AgentSessionCreateOnIssue/OnComment inputs;
// sessionCreateOverride, when non-nil, is returned verbatim instead of the
// canned success payload. suggestionVars / suggestionsOverride are the same
// pair for IssueRepositorySuggestions.
sessionCreates []map[string]any
sessionCreateOverride map[string]any
suggestionVars []map[string]any
suggestionsOverride map[string]any
// History read fixtures (see history_test.go): mocked GraphQL responses keyed
// by operation name, recorded history requests, and knobs to throttle or block
// history reads.
Expand Down Expand Up @@ -530,6 +541,48 @@ func newLinearAPIServer(t *testing.T, expires int64) *linearAPIServer {
writeJSON(t, w, map[string]any{"data": map[string]any{"viewer": map[string]any{"id": "APP1", "name": "Linear Bot", "displayName": "Linear Bot", "organization": map[string]any{"id": "ORG1"}}}})
return
}
api.mu.Lock()
if api.throttleNext > 0 {
api.throttleNext--
api.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusTooManyRequests)
writeJSON(t, w, map[string]any{"retryAfter": "0.01"})
return
}
api.mu.Unlock()
if strings.Contains(req.Query, "AgentSessionCreateOn") {
input, _ := req.Variables["input"].(map[string]any)
api.mu.Lock()
api.sessionCreates = append(api.sessionCreates, input)
override := api.sessionCreateOverride
api.mu.Unlock()
if override != nil {
writeJSON(t, w, override)
return
}
if strings.Contains(req.Query, "AgentSessionCreateOnComment") {
writeJSON(t, w, map[string]any{"data": map[string]any{"agentSessionCreateOnComment": map[string]any{"success": true, "agentSession": map[string]any{"id": "SNEW2", "issue": map[string]any{"id": "ISSUE7"}, "comment": map[string]any{"id": "CROOT1"}}}}})
return
}
writeJSON(t, w, map[string]any{"data": map[string]any{"agentSessionCreateOnIssue": map[string]any{"success": true, "agentSession": map[string]any{"id": "SNEW1", "issue": map[string]any{"id": "ISSUE7"}, "comment": nil}}}})
return
}
if strings.Contains(req.Query, "IssueRepositorySuggestions") {
api.mu.Lock()
api.suggestionVars = append(api.suggestionVars, req.Variables)
override := api.suggestionsOverride
api.mu.Unlock()
if override != nil {
writeJSON(t, w, override)
return
}
writeJSON(t, w, map[string]any{"data": map[string]any{"issueRepositorySuggestions": map[string]any{"suggestions": []map[string]any{
{"hostname": "github.com", "repositoryFullName": "acme/backend", "confidence": 0.92},
{"hostname": "github.com", "repositoryFullName": "acme/frontend", "confidence": 0.35},
}}}})
return
}
if strings.Contains(req.Query, "AgentActivityCreate") {
input, ok := req.Variables["input"].(map[string]any)
if !ok {
Expand Down Expand Up @@ -682,6 +735,38 @@ func (a *linearAPIServer) lastComment(t *testing.T) map[string]any {
return a.comments[len(a.comments)-1]
}

func (a *linearAPIServer) sessionCreateCount() int {
a.mu.Lock()
defer a.mu.Unlock()
return len(a.sessionCreates)
}

func (a *linearAPIServer) lastSessionCreate(t *testing.T) map[string]any {
t.Helper()
a.mu.Lock()
defer a.mu.Unlock()
if len(a.sessionCreates) == 0 {
t.Fatal("no session create recorded")
}
return a.sessionCreates[len(a.sessionCreates)-1]
}

func (a *linearAPIServer) suggestionCount() int {
a.mu.Lock()
defer a.mu.Unlock()
return len(a.suggestionVars)
}

func (a *linearAPIServer) lastSuggestionVars(t *testing.T) map[string]any {
t.Helper()
a.mu.Lock()
defer a.mu.Unlock()
if len(a.suggestionVars) == 0 {
t.Fatal("no repository suggestion request recorded")
}
return a.suggestionVars[len(a.suggestionVars)-1]
}

func (a *linearAPIServer) commentCount() int {
a.mu.Lock()
defer a.mu.Unlock()
Expand Down
19 changes: 19 additions & 0 deletions adapters/linear/multitenant_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,11 @@ type linearMTServer struct {
// historyAuth records the bearer token of each AgentSessionHistory read, so
// history tests can prove per-tenant token resolution (see history_test.go).
historyAuth []string
// sessionCreateAuth / suggestAuth record the bearer token of each
// AgentSessionCreateOn* mutation and IssueRepositorySuggestions query, so
// proactive-capability tests can prove per-tenant token resolution.
sessionCreateAuth []string
suggestAuth []string
}

func newLinearMTServer(t *testing.T) *linearMTServer {
Expand Down Expand Up @@ -135,6 +140,20 @@ func newLinearMTServer(t *testing.T) *linearMTServer {
writeJSON(t, w, map[string]any{"data": map[string]any{"agentActivityCreate": map[string]any{"success": true, "agentActivity": map[string]any{"id": id}}}})
return
}
if strings.Contains(req.Query, "AgentSessionCreateOn") {
api.mu.Lock()
api.sessionCreateAuth = append(api.sessionCreateAuth, auth)
api.mu.Unlock()
writeJSON(t, w, map[string]any{"data": map[string]any{"agentSessionCreateOnIssue": map[string]any{"success": true, "agentSession": map[string]any{"id": "SMT1", "issue": map[string]any{"id": "ISSUEMT"}, "comment": nil}}}})
return
}
if strings.Contains(req.Query, "IssueRepositorySuggestions") {
api.mu.Lock()
api.suggestAuth = append(api.suggestAuth, auth)
api.mu.Unlock()
writeJSON(t, w, map[string]any{"data": map[string]any{"issueRepositorySuggestions": map[string]any{"suggestions": []map[string]any{{"hostname": "github.com", "repositoryFullName": "acme/backend", "confidence": 0.5}}}}})
return
}
if strings.Contains(req.Query, "AgentSessionHistory") {
api.mu.Lock()
api.historyAuth = append(api.historyAuth, auth)
Expand Down
Loading