Skip to content
← 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 · TypeScript
import { 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
}