Documentation
¶
Overview ¶
Package linear is the bot's Linear identity: assigned issues polled into inbound events, and the five ways the conductor talks back. It implements conductor.Source and knows nothing about sessions, tasks or briefs.
It DIALS OUT and never listens. Linear's own documentation prefers webhooks, and a webhook would be better — AppUserNotification carries an `issueAssignedToYou` action, which is the exact predicate this file has to reconstruct. It needs an OAuth `actor=app` application, which this track deliberately does not build, and it would mean the host accepting an inbound connection it does not accept today. So: polling.
Index ¶
- Constants
- func ParseRef(ref string) (issueID, teamID, identifier string, err error)
- func Ref(issueID, teamID, identifier string) string
- func SourceKey(issueID string) string
- type ActorBot
- type Client
- func (c *Client) AddComment(ctx context.Context, issueID, body string) (string, error)
- func (c *Client) AssignedIssues(ctx context.Context, userID string, since time.Time, after string) (issues []Issue, next string, err error)
- func (c *Client) EditComment(ctx context.Context, commentID, body string) error
- func (c *Client) Issue(ctx context.Context, id string) (Issue, error)
- func (c *Client) MoveIssue(ctx context.Context, issueID, stateID string) error
- func (c *Client) PrepareUpload(ctx context.Context, filename, contentType string, size int64) (UploadTarget, error)
- func (c *Client) TeamStates(ctx context.Context, teamID string) ([]WorkflowState, error)
- func (c *Client) Viewer(ctx context.Context) (User, error)
- type ClientOptions
- type Comment
- type CursorStore
- type GraphQLError
- type Issue
- type Metrics
- type Options
- type SessionLookup
- type Source
- func (s *Source) Attach(ctx context.Context, ref string, file conductor.Attachment) error
- func (s *Source) Edit(ctx context.Context, ref, msgID string, out conductor.Outbound) error
- func (s *Source) Events() <-chan conductor.InboundEvent
- func (s *Source) FetchTranscript(ctx context.Context, ref string) ([]conductor.BriefEntry, error)
- func (s *Source) Kind() string
- func (s *Source) Post(ctx context.Context, ref string, out conductor.Outbound) (string, error)
- func (s *Source) React(ctx context.Context, ref string, kind conductor.Reaction) error
- func (s *Source) Run(ctx context.Context) error
- type Team
- type UploadHeader
- type UploadTarget
- type User
- type WorkflowState
Constants ¶
const ( // CodeRateLimited arrives with HTTP 400, not 429. Linear's leaky bucket rejects rather // than queues, and it does it with the status you would use for a malformed query. CodeRateLimited = "RATELIMITED" // CodeAuthentication is a wrong or missing API key. It arrives with HTTP 401. CodeAuthentication = "AUTHENTICATION_ERROR" )
Error codes Linear reports in extensions.code. There are more; these are the two the caller branches on.
const ( PollOK = "ok" PollError = "error" PollRateLimited = "ratelimited" )
Poll results, as the polls_total metric labels them.
const ( EventAssignment = "assignment" EventFollowup = "followup" )
Event kinds, as the events_total metric labels them.
const ( StateTypeTriage = "triage" StateTypeBacklog = "backlog" StateTypeUnstarted = "unstarted" StateTypeStarted = "started" StateTypeCompleted = "completed" StateTypeCanceled = "canceled" StateTypeDuplicate = "duplicate" )
Workflow state types. `WorkflowState.type` is a String, not an enum: filtering or switching on it means comparing to these literals.
const DefaultTimeout = 30 * time.Second
DefaultTimeout bounds one GraphQL call. A poll page with its comments is the slowest thing here and Linear answers it in well under a second.
const EditThrottle = 2 * time.Second
EditThrottle is the shortest gap between two edits of one turn's working comment. The newest text wins and an edit inside the window is superseded rather than queued: the outcome edit at the end of the turn overwrites the comment anyway.
const FirstRunLookback = 24 * time.Hour
FirstRunLookback is how far the first tick of a database with no cursor looks. An assignment made while the conductor was down for a day is still picked up; nothing older is, because a ticket assigned last month and never started is not news.
const InProgressStateName = "In Progress"
InProgressStateName is the state React(working) moves a ticket to, by name, because "which state means the bot has picked this up" is a team's convention and not something the schema records. The type: started fallback is what happens when a team renamed it.
const Kind = "linear"
Kind is the source kind Linear sessions are recorded under.
const MaxBackoff = 5 * time.Minute
MaxBackoff caps the rate-limit backoff. Beyond five minutes a human has noticed.
const MaxUploadBytes = 50 << 20
MaxUploadBytes is the largest attachment this source uploads into Linear. Above it the comment gets a link to the Podium task page instead.
Variables ¶
This section is empty.
Functions ¶
func ParseRef ¶
ParseRef splits a Ref. Only the issue id is required: an issue with no team or no identifier is not something Linear produces, but a ref written by an older version must not make a resumed turn unpostable.
func Ref ¶
Ref is what the conductor carries around for one turn. Three parts, like the Slack source's, and for a related reason: posting needs the issue, moving the state needs the team, and the identifier is what makes a branch name — and it has to survive a restart, because the ref is persisted as turns.trigger_ref and a resumed turn has nothing else.
Types ¶
type ActorBot ¶
type ActorBot struct {
ID string `json:"id"`
Name string `json:"name"`
UserDisplayName string `json:"userDisplayName"`
}
ActorBot is the non-human author of a comment. Its presence is how a comment written by an integration or an agent is told apart from one written by a person.
type Client ¶
type Client struct {
// contains filtered or unexported fields
}
Client is the GraphQL client. It is hand-written rather than @linear/sdk's generated Go equivalent for the same reason internal/agent/memory is: six operations, and the two places Linear's schema surprises you (a bare Authorization header, a nullable comment author) are worth a comment each rather than a generated type.
func NewClient ¶
func NewClient(opts ClientOptions) (*Client, error)
NewClient validates the options and returns a client. It makes no request: the source's Run is what proves the key works, and it does it where the failure can be fatal.
func (*Client) AddComment ¶
AddComment posts one comment and returns its id.
The id is supplied by this client rather than Linear: CommentCreateInput takes a client-generated UUIDv4 precisely so a create can be repeated without duplicating, and the conductor's relayed ledger is the other half of the same guarantee.
func (*Client) AssignedIssues ¶
func (c *Client) AssignedIssues( ctx context.Context, userID string, since time.Time, after string, ) (issues []Issue, next string, err error)
AssignedIssues is one page of issues assigned to userID and touched after since.
func (*Client) EditComment ¶
EditComment replaces a comment's body.
func (*Client) Issue ¶
Issue reads one issue with every comment on it, oldest first.
The comments connection is newest-first and has no ascending option either, so the pages are collected and then sorted here. maxTranscriptPages bounds the cost; past it the OLDEST comments are the ones missing, which is the same end the brief truncates from.
func (*Client) PrepareUpload ¶
func (c *Client) PrepareUpload(ctx context.Context, filename, contentType string, size int64) (UploadTarget, error)
PrepareUpload asks Linear for somewhere to put a file.
This mutation is the ONE operation in this file that could not be validated against Linear's published schema: the verified surface notes cover every query above and stop short of fileUpload. The shape below is what Linear documents. Every caller treats a failure here as "fall back to a task link" rather than as a failed turn, so a schema that has moved costs a nicer comment and nothing else.
func (*Client) TeamStates ¶
TeamStates is one team's workflow states.
type ClientOptions ¶
type ClientOptions struct {
// Endpoint is the GraphQL endpoint. Required.
Endpoint string
// APIKey is a PERSONAL API key belonging to the bot user. Required. SENSITIVE.
APIKey string
// HTTPClient lets a test point at an httptest server. Nil means one with DefaultTimeout.
HTTPClient *http.Client
}
ClientOptions is what NewClient needs.
type Comment ¶
type Comment struct {
ID string `json:"id"`
Body string `json:"body"`
CreatedAt time.Time `json:"createdAt"`
// ParentID is set on a reply. Threading is one level deep.
ParentID *string `json:"parentId"`
// QuotedText is set on an inline comment against a passage of the description.
QuotedText *string `json:"quotedText"`
User *User `json:"user"`
BotActor *ActorBot `json:"botActor"`
}
Comment is one comment on an issue.
User is NULLABLE and that is the important part: Linear's schema says it is "null for comments created by integrations or bots without a user association". A nil User is therefore never a human, and never starts a turn.
type CursorStore ¶
type CursorStore struct {
Get func(ctx context.Context) (time.Time, error)
Put func(ctx context.Context, at time.Time) error
}
CursorStore persists the poll watermark. Get returns the zero time when there is none.
type GraphQLError ¶
type GraphQLError struct {
// Op is the operation name, so a log line says which query failed.
Op string
// Message is the first error's message. Linear's messages are written for a human.
Message string
// Code is extensions.code, empty when Linear did not send one.
Code string
// Status is the HTTP status.
Status int
}
GraphQLError is a Linear refusal: the first message from the errors array, its extension code, and the HTTP status it came with. GraphQL puts application errors in a 200 as often as not, so the status alone says very little.
func (*GraphQLError) Error ¶
func (e *GraphQLError) Error() string
func (*GraphQLError) RateLimited ¶
func (e *GraphQLError) RateLimited() bool
RateLimited reports whether Linear throttled the request. The code is the reliable half: throttling arrives as HTTP 400, and 429 is checked too because the documentation of the two disagrees and both are cheap to accept.
func (*GraphQLError) Unauthorized ¶
func (e *GraphQLError) Unauthorized() bool
Unauthorized reports whether the API key is wrong or missing. It is separated because it is the one failure an operator fixes by editing .env rather than by reading a log.
type Issue ¶
type Issue struct {
ID string `json:"id"`
Identifier string `json:"identifier"`
Title string `json:"title"`
Description *string `json:"description"`
URL string `json:"url"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
State WorkflowState `json:"state"`
Team Team `json:"team"`
Assignee *User `json:"assignee"`
Comments struct {
PageInfo pageInfo `json:"pageInfo"`
Nodes []Comment `json:"nodes"`
} `json:"comments"`
}
Issue is an issue as the poll query and the transcript query ask for one.
type Metrics ¶
type Metrics struct {
Polls *prometheus.CounterVec
Events *prometheus.CounterVec
Backoff prometheus.Gauge
}
Metrics is what the Linear source reports on /metrics. A struct rather than package globals, for the same reason the conductor's is: two sources in one test process must not fight over a default registry.
func NewMetrics ¶
func NewMetrics(reg prometheus.Registerer) *Metrics
NewMetrics registers the collectors on reg. A nil registerer is allowed and registers nothing, which is what a unit test wants.
type Options ¶
type Options struct {
// APIKey is the bot user's PERSONAL API key. Required. SENSITIVE.
APIKey string
// Endpoint is the GraphQL endpoint. Required.
Endpoint string
// PollInterval is how often assigned issues are asked for. Required.
PollInterval time.Duration
// Playbook names the playbook Linear tickets run — the one with linear: true. Required: a
// ticket has no channel and no /playbook prefix, so the source names it and the profile's
// routing rules are bypassed. It is a function because that playbook can change while the
// process runs, and it must return non-empty at New.
Playbook func() string
// TaskURL renders a link to a Podium task page for a human. It is the fallback when an
// attachment cannot be uploaded into the conversation.
TaskURL func(taskID string) string
// Session is how the source tells an assignment from a follow-up.
Session SessionLookup
// Cursor persists the poll watermark.
Cursor CursorStore
// Metrics is optional; nil registers nothing.
Metrics *Metrics
Logger *slog.Logger
// HTTPClient lets a test point at an httptest server.
HTTPClient *http.Client
// Clock is time.Now unless a test replaces it. It is only read for the edit throttle
// and the first-run lookback.
Clock func() time.Time
}
Options configures the source.
type SessionLookup ¶
SessionLookup reports whether a source key already has a session and, if so, when its last turn started. It is injected rather than read from the store because a source never touches the store.
The zero time with ok == true means a session exists that has never run a turn.
type Source ¶
type Source struct {
// contains filtered or unexported fields
}
Source is the Linear integration.
func (*Source) Attach ¶
Attach puts one file into the conversation.
Linear has no message-with-files primitive: a file is uploaded to its asset store and then referenced from markdown. So the bytes go up first and the final comment is edited to carry the link — an image inline, anything else as a plain link. Two comments per turn is the budget and appending is what keeps to it.
Anything that goes wrong — a file over the limit, an upload Linear refuses, a PUT that fails — degrades to a link to the Podium task page, which is where the artifact actually lives. The reader has to be logged in to Podium to open it, and that is said in the comment.
func (*Source) Edit ¶
Edit replaces a comment this source posted, at most once every EditThrottle. An edit inside the window is held and superseded by the next one — the same coalescing the Slack source does, and for the same reason: the runtime already emits progress at most every five seconds, so the throttle exists to bound a misbehaving one rather than a normal turn.
A held edit at the end of a turn is dropped, because React rewrites the comment to the outcome anyway.
func (*Source) Events ¶
func (s *Source) Events() <-chan conductor.InboundEvent
Events implements conductor.Source. The channel closes when Run returns.
func (*Source) FetchTranscript ¶
FetchTranscript reads the ticket and its comments, oldest first: the description is the first thing said and every comment follows it. The bot's own comments are the assistant's turns; its scaffolding — the working comment and the outcome edit — is left out.
func (*Source) Post ¶
Post says something new. A turn produces at most two comments: one working comment, created by the first progress, and one final. Attachments are appended to the final rather than posted separately, so a turn with six screenshots is still two comments.
func (*Source) React ¶
React shows a turn's state on the ticket.
- working moves the issue to the team's "In Progress" state, which is what an engineer scanning the board is looking for.
- done and failed rewrite the working comment. They deliberately do NOT move the state: whether a ticket is finished is decided by a human reading the pull request, not by the bot having opened one.
The conductor posts its own plain-words failure message through Post before this is called, so React(failed) says nothing new — it only stops the comment claiming to still be working.
type UploadHeader ¶
UploadHeader is one header fileUpload requires on the PUT.
type UploadTarget ¶
type UploadTarget struct {
// UploadURL is a pre-signed PUT.
UploadURL string
// AssetURL is the permanent URL to put in a comment body.
AssetURL string
// Headers must be sent verbatim with the PUT.
Headers []UploadHeader
}
UploadTarget is where an attachment's bytes go and how to address it afterwards.