shortcutClient.ts•5.08 kB
import type {
ShortcutConfig,
Story,
StoryComment,
CreateStoryComment,
CreateStoryParams,
UpdateStory,
Workflow,
Project,
Epic,
Member,
SearchResults,
UploadedFile,
} from './shortcut-types'
import { isTextFile } from './mimeTypes'
export class ShortcutClient {
private baseUrl: string
private headers: Record<string, string>
constructor(config: ShortcutConfig) {
this.baseUrl = config.baseUrl
this.headers = {
'Content-Type': 'application/json',
'Shortcut-Token': config.apiToken,
}
}
private async request<T>(path: string, options: RequestInit = {}): Promise<T> {
const url = `${this.baseUrl}${path}`
const response = await fetch(url, {
...options,
headers: {
...this.headers,
...options.headers,
},
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
const error = new Error(`Shortcut API error: ${response.statusText}`)
;(error as unknown as { response: { status: number; data: unknown } }).response = {
status: response.status,
data: errorData,
}
throw error
}
return response.json() as Promise<T>
}
async getStory(storyId: number): Promise<Story> {
return this.request<Story>(`/stories/${storyId}`)
}
async createStory(params: CreateStoryParams): Promise<Story> {
return this.request<Story>('/stories', {
method: 'POST',
body: JSON.stringify(params),
})
}
async searchStories(query: string): Promise<SearchResults> {
const params = new URLSearchParams({ query, page_size: '25' })
return this.request<SearchResults>(`/search?${params.toString()}`)
}
async getStoriesInProject(projectId: number): Promise<Story[]> {
const params = new URLSearchParams({
query: `project:${projectId}`,
page_size: '100',
})
const result = await this.request<{ data: Story[] }>(`/search/stories?${params.toString()}`)
return result.data
}
async getStoriesInEpic(epicId: number): Promise<Story[]> {
const params = new URLSearchParams({
query: `epic:${epicId}`,
page_size: '100',
})
const result = await this.request<{ data: Story[] }>(`/search/stories?${params.toString()}`)
return result.data
}
async updateStory(storyId: number, update: UpdateStory): Promise<Story> {
return this.request<Story>(`/stories/${storyId}`, {
method: 'PUT',
body: JSON.stringify(update),
})
}
async addComment(storyId: number, comment: CreateStoryComment): Promise<StoryComment> {
return this.request<StoryComment>(`/stories/${storyId}/comments`, {
method: 'POST',
body: JSON.stringify(comment),
})
}
async getComments(storyId: number): Promise<StoryComment[]> {
return this.request<StoryComment[]>(`/stories/${storyId}/comments`)
}
async getWorkflows(): Promise<Workflow[]> {
return this.request<Workflow[]>('/workflows')
}
async getProjects(): Promise<Project[]> {
return this.request<Project[]>('/projects')
}
async getEpics(): Promise<Epic[]> {
return this.request<Epic[]>('/epics')
}
async getMembers(): Promise<Member[]> {
return this.request<Member[]>('/members')
}
async getCurrentUser(): Promise<Member> {
return this.request<Member>('/member')
}
async getFile(fileId: number): Promise<UploadedFile> {
return this.request<UploadedFile>(`/files/${fileId}`)
}
async downloadFile(fileUrl: string): Promise<{ content: Buffer; contentType: string }> {
// File URLs from Shortcut require authentication
const response = await fetch(fileUrl, {
headers: {
'Shortcut-Token': this.headers['Shortcut-Token'],
},
})
if (!response.ok) {
throw new Error(`Failed to download file: ${response.statusText}`)
}
const buffer = Buffer.from(await response.arrayBuffer())
const contentType = response.headers.get('content-type') || 'application/octet-stream'
return { content: buffer, contentType }
}
async getStoryFiles(storyId: number): Promise<UploadedFile[]> {
// Get the full story with files included
const story = await this.getStory(storyId)
return story.files || []
}
async downloadFileAsText(fileUrl: string): Promise<string> {
const { content, contentType } = await this.downloadFile(fileUrl)
// Use comprehensive mime type detection
if (isTextFile(contentType, fileUrl)) {
return content.toString('utf-8')
}
// For binary files, return base64 encoded
return content.toString('base64')
}
}