UNPKG

@stackmemoryai/stackmemory

Version:

Project-scoped memory for AI coding tools. Durable context across sessions with MCP integration, frames, smart retrieval, Claude Code skills, and automatic hooks.

635 lines (634 loc) 16.2 kB
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 (e) { } } 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" }; } } } export { LinearClient }; //# sourceMappingURL=client.js.map