linear

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 13, 2026 License: MIT Imports: 18 Imported by: 0

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

View Source
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.

View Source
const (
	PollOK          = "ok"
	PollError       = "error"
	PollRateLimited = "ratelimited"
)

Poll results, as the polls_total metric labels them.

View Source
const (
	EventAssignment = "assignment"
	EventFollowup   = "followup"
)

Event kinds, as the events_total metric labels them.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
const Kind = "linear"

Kind is the source kind Linear sessions are recorded under.

View Source
const MaxBackoff = 5 * time.Minute

MaxBackoff caps the rate-limit backoff. Beyond five minutes a human has noticed.

View Source
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

func ParseRef(ref string) (issueID, teamID, identifier string, err error)

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

func Ref(issueID, teamID, identifier string) string

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.

func SourceKey

func SourceKey(issueID string) string

SourceKey is a session's identity: the issue, for the life of the ticket.

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

func (c *Client) AddComment(ctx context.Context, issueID, body string) (string, error)

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

func (c *Client) EditComment(ctx context.Context, commentID, body string) error

EditComment replaces a comment's body.

func (*Client) Issue

func (c *Client) Issue(ctx context.Context, id string) (Issue, error)

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

func (c *Client) MoveIssue(ctx context.Context, issueID, stateID string) error

MoveIssue puts an issue in a workflow state.

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

func (c *Client) TeamStates(ctx context.Context, teamID string) ([]WorkflowState, error)

TeamStates is one team's workflow states.

func (*Client) Viewer

func (c *Client) Viewer(ctx context.Context) (User, error)

Viewer is who the API key belongs to. It is the bot user: every filter below is "assigned to this id".

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

type SessionLookup func(ctx context.Context, sourceKey string) (lastTurnAt time.Time, ok bool)

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 New

func New(opts Options) (*Source, error)

New builds the source. Nothing is dialled until Run.

func (*Source) Attach

func (s *Source) Attach(ctx context.Context, ref string, file conductor.Attachment) error

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

func (s *Source) Edit(ctx context.Context, ref, msgID string, out conductor.Outbound) error

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

func (s *Source) FetchTranscript(ctx context.Context, ref string) ([]conductor.BriefEntry, error)

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

func (s *Source) Kind() string

Kind implements conductor.Source.

func (*Source) Post

func (s *Source) Post(ctx context.Context, ref string, out conductor.Outbound) (string, error)

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

func (s *Source) React(ctx context.Context, ref string, kind conductor.Reaction) error

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.

func (*Source) Run

func (s *Source) Run(ctx context.Context) error

Run learns who the API key belongs to and then polls until ctx is cancelled.

A key that is set but does not work is FATAL: podium-agent exits non-zero at boot rather than discovering an hour later that no ticket was ever picked up.

type Team

type Team struct {
	ID  string `json:"id"`
	Key string `json:"key"`
}

Team is the team an issue belongs to.

type UploadHeader

type UploadHeader struct {
	Key   string `json:"key"`
	Value string `json:"value"`
}

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.

type User

type User struct {
	ID          string `json:"id"`
	Name        string `json:"name"`
	DisplayName string `json:"displayName"`
	IsMe        bool   `json:"isMe"`
}

User is a Linear user as the two queries here ask for one.

type WorkflowState

type WorkflowState struct {
	ID   string `json:"id"`
	Name string `json:"name"`
	// Type is one of the StateType constants. It is a String in the schema, not an enum.
	Type     string  `json:"type"`
	Position float64 `json:"position"`
}

WorkflowState is a column on a team's board.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL