← Public packages
@kentcdodds/devin
Start, monitor, and manage Devin sessions, knowledge, playbooks, and schedules via the Devin v3 API
src/lib/client.ts
226 lines · 6.5 KB · TypeScriptimport { packageStorage } from 'kody:runtime'
/** User-scoped Devin v3 service user API key (`cog_…`). */
export const DEVIN_SECRET_NAME = 'devinServiceUserKey'
/** Bearer auth; Devin v3 accepts service user keys and PATs identically. */
const DEVIN_AUTH = 'Bearer {{secret:devinServiceUserKey}}'
export const DEVIN_BASE_URL = 'https://api.devin.ai'
/** Package-storage key (and leftover value name) for the org id (`org-…`). */
export const DEVIN_ORG_VALUE_NAME = 'devinOrgId'
export class DevinApiError extends Error {
status: number
body: unknown
constructor(status: number, body: unknown, message?: string) {
const detail =
body && typeof body === 'object'
? String(
(body as { detail?: unknown; title?: unknown }).detail ??
(body as { title?: unknown }).title ??
'',
)
: ''
const suffix = detail ? `: ${detail}` : ''
const rateNote = status === 429 ? ' (rate limited — retry with backoff)' : ''
const authNote =
status === 401 || status === 403
? ' (check that the "devinServiceUserKey" user secret holds a cog_ service user key with host approval for api.devin.ai, and that the service user role grants this endpoint)'
: ''
super(message || `Devin API ${status}${suffix}${rateNote}${authNote}`)
this.name = 'DevinApiError'
this.status = status
this.body = body
}
}
export function trimString(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
export function clampInt(
value: unknown,
min: number,
max: number,
fallback: number,
): number {
const number = Number(value)
if (!Number.isFinite(number)) return fallback
return Math.min(max, Math.max(min, Math.floor(number)))
}
let cachedOrgId: string | null = null
export async function readDevinOrgId(): Promise<string> {
if (cachedOrgId) return cachedOrgId
const storage = packageStorage()
const stored = trimString(await storage.get(DEVIN_ORG_VALUE_NAME))
if (stored) {
cachedOrgId = stored
return stored
}
return ''
}
export async function writeDevinOrgId(orgId: string): Promise<string> {
const value = trimString(orgId)
if (!value.startsWith('org-')) {
throw new Error('orgId must be a Devin organization id (org-…).')
}
await packageStorage().set(DEVIN_ORG_VALUE_NAME, value)
cachedOrgId = value
return value
}
export async function migrateDevinFromValues() {
const orgId = await readDevinOrgId()
return {
copied: orgId ? [DEVIN_ORG_VALUE_NAME] : [],
orgIdPresent: Boolean(orgId),
}
}
/**
* Resolve the organization id every `/v3/organizations/...` path needs.
*
* Explicit ids win; otherwise package storage is read once per runtime.
*/
export async function resolveOrgId(orgId?: string): Promise<string> {
const explicit = trimString(orgId)
if (explicit) return explicit
const value = await readDevinOrgId()
if (!value) {
throw new Error(
`No Devin org id available. Save it with packages.invoke({ kodyId: 'devin', exportName: './settings', params: { orgId: 'org-…' } }) or pass orgId explicitly. Find it on Devin Settings → Devin API.`,
)
}
return value
}
export type DevinQuery = Record<
string,
string | number | boolean | undefined | null | Array<string | number>
>
export type DevinRequestInput = {
path: string
query?: DevinQuery
method?: string
body?: unknown
headers?: Record<string, string>
accept?: string
}
export type DevinRequestResult = {
status: number
body: unknown
headers: Record<string, string>
}
/** Compact authenticated request to api.devin.ai (raw Response parsed). */
export async function devinRequestRaw(
input: DevinRequestInput,
): Promise<DevinRequestResult> {
const url = new URL(trimString(input.path), DEVIN_BASE_URL)
if (input.query) {
for (const [key, value] of Object.entries(input.query)) {
if (value === undefined || value === null || value === '') continue
// Devin repeats list filters (`?tags=a&tags=b`) rather than joining them.
if (Array.isArray(value)) {
for (const entry of value) url.searchParams.append(key, String(entry))
} else {
url.searchParams.set(key, String(value))
}
}
}
const headers = new Headers({
Accept: input.accept || 'application/json',
Authorization: DEVIN_AUTH,
'User-Agent': 'kody-devin/1.0',
})
if (input.headers) {
for (const [key, value] of Object.entries(input.headers)) {
if (value) headers.set(key, value)
}
}
const init: RequestInit = {
method: trimString(input.method) || 'GET',
headers,
}
if (input.body !== undefined) {
if (!headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json')
}
init.body =
typeof input.body === 'string' ? input.body : JSON.stringify(input.body)
}
const response = await fetch(url.toString(), init)
const responseHeaders: Record<string, string> = {}
response.headers.forEach((value, key) => {
responseHeaders[key] = value
})
const text = await response.text()
let body: unknown = null
if (response.status !== 204 && text.trim()) {
const contentType = response.headers.get('content-type') || ''
if (contentType.includes('application/json')) {
try {
body = JSON.parse(text)
} catch {
body = text
}
} else {
body = text
}
}
return { status: response.status, body, headers: responseHeaders }
}
export async function devinApi<T = unknown>(
input: DevinRequestInput,
): Promise<T> {
const result = await devinRequestRaw(input)
if (result.status < 200 || result.status >= 300) {
throw new DevinApiError(result.status, result.body)
}
return result.body as T
}
/** Request against `/v3/organizations/{orgId}` with the org id resolved. */
export async function devinOrgApi<T = unknown>(
input: Omit<DevinRequestInput, 'path'> & { path: string; orgId?: string },
): Promise<T> {
const org = await resolveOrgId(input.orgId)
const suffix = input.path.startsWith('/') ? input.path : `/${input.path}`
return devinApi<T>({
path: `/v3/organizations/${org}${suffix}`,
method: input.method,
query: input.query,
body: input.body,
headers: input.headers,
accept: input.accept,
})
}
/** Drop undefined/null keys so request bodies only carry set fields. */
export function compact<T extends Record<string, unknown>>(input: T): Partial<T> {
const result: Record<string, unknown> = {}
for (const [key, value] of Object.entries(input)) {
if (value === undefined || value === null) continue
result[key] = value
}
return result as Partial<T>
}
/** Devin v3 pages with `{ items, end_cursor, has_next_page, total }`. */
export type Paginated<T> = {
items: T[]
end_cursor: string | null
has_next_page: boolean
total?: number | null
}