@stackmemoryai/stackmemory
Version:
Lossless, project-scoped memory for AI coding tools. Durable context across sessions with 56 MCP tools, FTS5 search, conductor orchestrator, loop/watch monitoring, snapshot capture, pre-flight overlap checks, Claude/Codex/OpenCode wrappers, Linear sync, a
700 lines (699 loc) • 17.8 kB
JavaScript
import { fileURLToPath as __fileURLToPath } from 'url';
import { dirname as __pathDirname } from 'path';
const __filename = __fileURLToPath(import.meta.url);
const __dirname = __pathDirname(__filename);
import { logger } from "../../core/monitoring/logger.js";
import { IntegrationError, ErrorCode } from "../../core/errors/index.js";
class LinearClient {
config;
baseUrl;
rateLimitState = {
remaining: 1500,
// Linear's default limit
resetAt: Date.now() + 36e5,
retryAfter: 0
};
requestQueue = [];
isProcessingQueue = false;
minRequestInterval = 100;
// Minimum ms between requests
lastRequestTime = 0;
constructor(config) {
this.config = config;
this.baseUrl = config.baseUrl || "https://api.linear.app";
if (!config.apiKey) {
throw new IntegrationError(
"Linear API key is required",
ErrorCode.LINEAR_AUTH_FAILED
);
}
}
/**
* Wait for rate limit to reset if needed
*/
async waitForRateLimit() {
const now = Date.now();
if (this.rateLimitState.retryAfter > now) {
const waitTime = this.rateLimitState.retryAfter - now;
logger.warn(`Rate limited, waiting ${Math.ceil(waitTime / 1e3)}s`);
await this.sleep(waitTime);
}
if (this.rateLimitState.remaining <= 5) {
if (this.rateLimitState.resetAt > now) {
const waitTime = this.rateLimitState.resetAt - now;
logger.warn(
`Rate limit nearly exhausted, waiting ${Math.ceil(waitTime / 1e3)}s for reset`
);
await this.sleep(Math.min(waitTime, 6e4));
}
}
const timeSinceLastRequest = now - this.lastRequestTime;
if (timeSinceLastRequest < this.minRequestInterval) {
await this.sleep(this.minRequestInterval - timeSinceLastRequest);
}
}
sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Update rate limit state from response headers
*/
updateRateLimitState(response) {
const remaining = response.headers.get("x-ratelimit-remaining");
const reset = response.headers.get("x-ratelimit-reset");
const retryAfter = response.headers.get("retry-after");
if (remaining !== null) {
this.rateLimitState.remaining = parseInt(remaining, 10);
}
if (reset !== null) {
this.rateLimitState.resetAt = parseInt(reset, 10) * 1e3;
}
if (retryAfter !== null) {
this.rateLimitState.retryAfter = Date.now() + parseInt(retryAfter, 10) * 1e3;
}
}
/**
* Execute GraphQL query against Linear API with rate limiting
*/
async graphql(query, variables, retries = 3, allowAuthRefresh = true) {
await this.waitForRateLimit();
this.lastRequestTime = Date.now();
const authHeader = this.config.useBearer ? `Bearer ${this.config.apiKey}` : this.config.apiKey;
let response = await fetch(`${this.baseUrl}/graphql`, {
method: "POST",
headers: {
Authorization: authHeader,
"Content-Type": "application/json"
},
body: JSON.stringify({
query,
variables
})
});
this.updateRateLimitState(response);
if (response.status === 401 && this.config.onUnauthorized && allowAuthRefresh) {
try {
const newToken = await this.config.onUnauthorized();
this.config.apiKey = newToken;
const retryHeader = this.config.useBearer ? `Bearer ${newToken}` : newToken;
response = await fetch(`${this.baseUrl}/graphql`, {
method: "POST",
headers: {
Authorization: retryHeader,
"Content-Type": "application/json"
},
body: JSON.stringify({ query, variables })
});
this.updateRateLimitState(response);
} catch {
}
}
if (response.status === 429) {
if (retries > 0) {
const retryAfter = response.headers.get("retry-after");
const waitTime = retryAfter ? parseInt(retryAfter, 10) * 1e3 : 6e4;
logger.warn(
`Rate limited (429), retrying in ${waitTime / 1e3}s (${retries} retries left)`
);
this.rateLimitState.retryAfter = Date.now() + waitTime;
await this.sleep(waitTime);
return this.graphql(query, variables, retries - 1, allowAuthRefresh);
}
throw new IntegrationError(
"Linear API rate limit exceeded after retries",
ErrorCode.LINEAR_API_ERROR,
{ retries: 0 }
);
}
if (!response.ok) {
const errorText = await response.text();
if (response.status !== 401 && response.status !== 403) {
logger.error(
"Linear API error response:",
new Error(`${response.status}: ${errorText}`)
);
}
throw new IntegrationError(
`Linear API error: ${response.status} ${response.statusText}`,
ErrorCode.LINEAR_API_ERROR,
{
status: response.status,
statusText: response.statusText,
body: errorText
}
);
}
const result = await response.json();
if (result.errors) {
const rateLimitError = result.errors.find(
(e) => e.message.toLowerCase().includes("rate limit") || e.message.toLowerCase().includes("usage limit")
);
if (rateLimitError && retries > 0) {
const waitTime = 6e4;
logger.warn(
`GraphQL rate limit error, retrying in ${waitTime / 1e3}s (${retries} retries left)`
);
this.rateLimitState.retryAfter = Date.now() + waitTime;
await this.sleep(waitTime);
return this.graphql(query, variables, retries - 1);
}
logger.error("Linear GraphQL errors:", { errors: result.errors });
throw new IntegrationError(
`Linear GraphQL error: ${result.errors[0].message}`,
ErrorCode.LINEAR_API_ERROR,
{ errors: result.errors }
);
}
return result.data;
}
/**
* Create a new issue in Linear
*/
async createIssue(input) {
const mutation = `
mutation CreateIssue($input: IssueCreateInput!) {
issueCreate(input: $input) {
success
issue {
id
identifier
title
description
state {
id
name
type
}
priority
assignee {
id
name
email
}
estimate
labels {
nodes {
id
name
}
}
createdAt
updatedAt
url
}
}
}
`;
const result = await this.graphql(mutation, { input });
if (!result.issueCreate.success) {
throw new IntegrationError(
"Failed to create Linear issue",
ErrorCode.LINEAR_API_ERROR,
{ input }
);
}
return result.issueCreate.issue;
}
/**
* Update an existing Linear issue
*/
async updateIssue(issueId, updates) {
const mutation = `
mutation UpdateIssue($id: String!, $input: IssueUpdateInput!) {
issueUpdate(id: $id, input: $input) {
success
issue {
id
identifier
title
description
state {
id
name
type
}
priority
assignee {
id
name
email
}
estimate
labels {
nodes {
id
name
}
}
createdAt
updatedAt
url
}
}
}
`;
const result = await this.graphql(mutation, { id: issueId, input: updates });
if (!result.issueUpdate.success) {
throw new IntegrationError(
`Failed to update Linear issue ${issueId}`,
ErrorCode.LINEAR_API_ERROR,
{ issueId, updates }
);
}
return result.issueUpdate.issue;
}
/**
* Get issue by ID
*/
async getIssue(issueId) {
const query = `
query GetIssue($id: String!) {
issue(id: $id) {
id
identifier
title
description
state {
id
name
type
}
priority
assignee {
id
name
email
}
estimate
labels {
nodes {
id
name
}
}
createdAt
updatedAt
url
}
}
`;
const result = await this.graphql(query, { id: issueId });
return result.issue;
}
/**
* Search for issues by identifier (e.g., "SM-123")
*/
async findIssueByIdentifier(identifier) {
const query = `
query FindIssue($filter: IssueFilter!) {
issues(filter: $filter, first: 1) {
nodes {
id
identifier
title
description
state {
id
name
type
}
priority
assignee {
id
name
email
}
estimate
labels {
nodes {
id
name
}
}
createdAt
updatedAt
url
}
}
}
`;
const result = await this.graphql(query, {
filter: {
number: {
eq: parseInt(identifier.split("-")[1] || "0") || 0
}
}
});
return result.issues.nodes[0] || null;
}
/**
* Get team information
*/
async getTeam(teamId) {
const query = teamId ? `
query GetTeam($id: String!) {
team(id: $id) {
id
name
key
}
}
` : `
query GetTeams {
teams(first: 1) {
nodes {
id
name
key
}
}
}
`;
if (teamId) {
const result = await this.graphql(query, { id: teamId });
if (!result.team) {
throw new IntegrationError(
`Team ${teamId} not found`,
ErrorCode.LINEAR_API_ERROR,
{ teamId }
);
}
return result.team;
} else {
const result = await this.graphql(query);
if (result.teams.nodes.length === 0) {
throw new IntegrationError(
"No teams found",
ErrorCode.LINEAR_API_ERROR
);
}
return result.teams.nodes[0];
}
}
/**
* Get workflow states for a team
*/
async getWorkflowStates(teamId) {
const query = `
query GetWorkflowStates($teamId: String!) {
team(id: $teamId) {
states {
nodes {
id
name
type
color
}
}
}
}
`;
const result = await this.graphql(query, { teamId });
return result.team.states.nodes;
}
/**
* Get current viewer/user information
*/
async getViewer() {
const query = `
query GetViewer {
viewer {
id
name
email
}
}
`;
const result = await this.graphql(query);
return result.viewer;
}
/**
* Get all teams for the organization
*/
async getTeams() {
const query = `
query GetTeams {
teams(first: 50) {
nodes {
id
name
key
}
}
}
`;
const result = await this.graphql(query);
return result.teams.nodes;
}
/**
* Get issues with filtering options
*/
async getIssues(options) {
const query = `
query GetIssues($filter: IssueFilter, $first: Int!) {
issues(filter: $filter, first: $first) {
nodes {
id
identifier
title
description
state {
id
name
type
}
priority
assignee {
id
name
email
}
estimate
labels {
nodes {
id
name
}
}
createdAt
updatedAt
url
}
}
}
`;
const filter = {};
if (options?.teamId) {
filter.team = { id: { eq: options.teamId } };
}
if (options?.assigneeId) {
filter.assignee = { id: { eq: options.assigneeId } };
}
if (options?.stateType) {
filter.state = { type: { eq: options.stateType } };
}
const result = await this.graphql(query, {
filter: Object.keys(filter).length > 0 ? filter : void 0,
first: options?.limit || 50
});
return result.issues.nodes;
}
/**
* Assign an issue to a user
*/
async assignIssue(issueId, assigneeId) {
const mutation = `
mutation AssignIssue($issueId: String!, $assigneeId: String!) {
issueUpdate(id: $issueId, input: { assigneeId: $assigneeId }) {
success
issue {
id
identifier
title
assignee {
id
name
}
}
}
}
`;
const result = await this.graphql(mutation, { issueId, assigneeId });
return result.issueUpdate;
}
/**
* Update issue state (e.g., move to "In Progress")
*/
async updateIssueState(issueId, stateId) {
const mutation = `
mutation UpdateIssueState($issueId: String!, $stateId: String!) {
issueUpdate(id: $issueId, input: { stateId: $stateId }) {
success
issue {
id
identifier
title
state {
id
name
type
}
}
}
}
`;
const result = await this.graphql(mutation, { issueId, stateId });
return result.issueUpdate;
}
/**
* Get an issue by ID with team info
*/
async getIssueById(issueId) {
const query = `
query GetIssue($issueId: String!) {
issue(id: $issueId) {
id
identifier
title
description
state {
id
name
type
}
priority
assignee {
id
name
email
}
estimate
labels {
nodes {
id
name
}
}
team {
id
name
}
createdAt
updatedAt
url
}
}
`;
try {
const result = await this.graphql(query, { issueId });
return result.issue;
} catch (error) {
logger.debug("Failed to fetch issue by ID", {
issueId,
error: error instanceof Error ? error.message : String(error)
});
return null;
}
}
/**
* Start working on an issue (assign to self and move to In Progress)
*/
async startIssue(issueId) {
try {
const user = await this.getViewer();
const issue = await this.getIssueById(issueId);
if (!issue) {
return { success: false, error: "Issue not found" };
}
const assignResult = await this.assignIssue(issueId, user.id);
if (!assignResult.success) {
return { success: false, error: "Failed to assign issue" };
}
const teamId = issue.team?.id;
if (teamId) {
const states = await this.getWorkflowStates(teamId);
const inProgressState = states.find(
(s) => s.type === "started" || s.name.toLowerCase().includes("progress")
);
if (inProgressState) {
await this.updateIssueState(issueId, inProgressState.id);
}
}
return { success: true, issue: assignResult.issue };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error"
};
}
}
// --- Comment CRUD ---
async createComment(issueId, body) {
const mutation = `
mutation CreateComment($input: CommentCreateInput!) {
commentCreate(input: $input) {
success
comment {
id
body
createdAt
user { id name }
}
}
}
`;
const result = await this.graphql(mutation, { input: { issueId, body } });
if (!result.commentCreate.success) {
throw new IntegrationError(
"Failed to create comment",
ErrorCode.LINEAR_API_ERROR,
{ issueId }
);
}
return result.commentCreate.comment;
}
async updateComment(commentId, body) {
const mutation = `
mutation UpdateComment($id: String!, $input: CommentUpdateInput!) {
commentUpdate(id: $id, input: $input) {
success
comment {
id
body
updatedAt
}
}
}
`;
const result = await this.graphql(mutation, { id: commentId, input: { body } });
if (!result.commentUpdate.success) {
throw new IntegrationError(
"Failed to update comment",
ErrorCode.LINEAR_API_ERROR,
{ commentId }
);
}
return result.commentUpdate.comment;
}
async getComments(issueId) {
const query = `
query GetComments($issueId: String!) {
issue(id: $issueId) {
comments(first: 100) {
nodes {
id
body
createdAt
user { name }
}
}
}
}
`;
const result = await this.graphql(query, { issueId });
return result.issue.comments.nodes;
}
}
export {
LinearClient
};