Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ The `api` command provides direct programmatic access to the QA Sphere public AP
**Key files**:

- `types.ts` — Defines `ApiEndpointSpec` as a discriminated union on `bodyMode` (`'none'` | `'json'` | `'file'`), plus supporting types (`ApiPathParamSpec`, `ApiFieldSpec`, `ApiQueryOptionSpec`, `ExecuteFn`)
- `manifests/` — One file per resource (e.g., `runs.ts`, `test-cases.ts`), each exporting an array of `ApiEndpointSpec` objects. `manifests/index.ts` aggregates all specs into a single `allSpecs` array. `manifests/utils.ts` has shared param definitions (e.g., `projectCodeParam`) and `paginationOptions()` for list options
- `manifests/` — One file per resource (e.g., `runs.ts`, `test-cases.ts`), each exporting an array of `ApiEndpointSpec` objects. `manifests/index.ts` aggregates all specs into a single `allSpecs` array. `manifests/utils.ts` has shared param definitions (e.g., `projectCodeParam`) and `paginationOptions()` for list options. The array order is the order `-h` lists the commands in: list, get (and other reads), create, update, other updates, delete, then nested groups. Define the specs in the same order, and keep the README and SKILL.md command trees in it too
- `builder.ts` — `buildCommandsFromSpecs()` builds a yargs command tree from the flat specs array, handling nested command paths automatically. Creates yargs options from path params, query options, field options, and body mode
- `executor.ts` — `executeCommand()` orchestrates execution: collects and validates path params → processes body (based on `bodyMode` discriminant) → collects and validates query options → connects to API (lazy env loading) → executes with error mapping
- `main.ts` — Registers the `api` command, delegates to `buildCommandsFromSpecs()`
Expand Down
228 changes: 147 additions & 81 deletions README.md

Large diffs are not rendered by default.

210 changes: 131 additions & 79 deletions skills/qas-cli/SKILL.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { createConfigurationApi } from './configurations'
import { createCustomFieldApi } from './custom-fields'
import { createFileApi } from './file'
import { createFolderApi } from './folders'
import { createIntegrationApi } from './integrations'
import { createMilestoneApi } from './milestones'
import { createProjectApi } from './projects'
import { createRequirementApi } from './requirements'
Expand Down Expand Up @@ -35,6 +36,7 @@ const getApi = (fetcher: typeof fetch) => {
customFields: createCustomFieldApi(fetcher),
files: createFileApi(fetcher),
folders: createFolderApi(fetcher),
integrations: createIntegrationApi(fetcher),
milestones: createMilestoneApi(fetcher),
projects: createProjectApi(fetcher),
requirements: createRequirementApi(fetcher),
Expand Down
223 changes: 223 additions & 0 deletions src/api/integrations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
import { z } from 'zod'
import { ResourceId, sortFieldParam, sortOrderParam, validateRequest } from './schemas'
import { appendSearchParams, jsonResponse, withJson } from './utils'

export interface ProjectIntegration {
id: string
name: string
type: 'jira' | 'github' | 'linear' | 'custom'
config: Record<string, unknown> | null
relationConfig: Record<string, unknown> | null
}

export interface JiraIssue {
id: string
key: string
summary: string
description: string
url: string
}

export interface GithubIssue {
id: number
number: number
title: string
body: string
url: string
}

export interface LinearIssue {
id: string
identifier: string
title: string
url: string
}

// Jira and Linear page their searches by cursor, so there is no offset or total.
export interface CursorPage<T> {
data: T[]
nextPageToken: string | null
isLast: boolean
}

const searchText = z.string().max(255, 'search must be at most 255 characters')

// Jira counts UTF-16 code units, as z.string().max() does.
const jiraSummary = z
.string()
.min(1, 'summary must not be empty')
.max(255, 'summary must be at most 255 characters')

const jiraDescription = z.string().max(32767, 'description must be at most 32767 characters')

// GitHub and Linear count characters, so an emoji counts once.
const maxChars = (max: number, field: string) =>
z.string().refine((s) => [...s].length <= max, `${field} must be at most ${max} characters`)

export const SearchJiraIssuesRequestSchema = z.object({
search: searchText.optional(),
types: z.array(z.string()).optional(),
fields: z.array(z.string()).optional(),
limit: z
.number()
.int()
.min(1, 'limit must be at least 1')
.max(5000, 'limit must be at most 5000')
.optional(),
nextPageToken: z.string().optional(),
sortField: sortFieldParam,
sortOrder: sortOrderParam,
})

export type SearchJiraIssuesRequest = z.infer<typeof SearchJiraIssuesRequestSchema>

export const CreateJiraIssueRequestSchema = z.object({
summary: jiraSummary,
description: jiraDescription.optional(),
issueTypeId: z.string().min(1, 'issueTypeId must not be empty'),
parentId: z.string().min(1, 'parentId must not be empty').optional(),
otherFields: z.record(z.string(), z.unknown()).optional(),
})

export type CreateJiraIssueRequest = z.infer<typeof CreateJiraIssueRequestSchema>

export interface CreateJiraIssueResponse {
id: string
key: string
url: string
attachmentUploadErrors: string[] | null
createdAs: 'user' | 'integration'
}

// GitHub and Linear take the same create body.
export const CreateIssueRequestSchema = z.object({
summary: maxChars(255, 'summary').refine((s) => s.length > 0, 'summary must not be empty'),
description: maxChars(32767, 'description').optional(),
})

export type CreateIssueRequest = z.infer<typeof CreateIssueRequestSchema>

export const SearchLinearIssuesRequestSchema = z.object({
search: searchText.optional(),
limit: z
.number()
.int()
.min(1, 'limit must be at least 1')
.max(250, 'limit must be at most 250')
.optional(),
nextPageToken: z.string().optional(),
})

export type SearchLinearIssuesRequest = z.infer<typeof SearchLinearIssuesRequestSchema>

export const createIntegrationApi = (fetcher: typeof fetch) => {
fetcher = withJson(fetcher)
const base = (projectCode: ResourceId) => `/api/public/v0/project/${projectCode}/integration`
const tracker = (projectCode: ResourceId, type: string, integrationId: ResourceId) =>
`${base(projectCode)}/${type}/${integrationId}`

return {
list: (projectCode: ResourceId) =>
fetcher(base(projectCode)).then((r) =>
jsonResponse<{ integrations: ProjectIntegration[] }>(r)
),

get: (projectCode: ResourceId, integrationId: ResourceId) =>
fetcher(`${base(projectCode)}/${integrationId}`).then((r) =>
jsonResponse<ProjectIntegration>(r)
),

jira: {
getIssue: (projectCode: ResourceId, integrationId: ResourceId, issueId: ResourceId) =>
fetcher(`${tracker(projectCode, 'jira', integrationId)}/issue/${issueId}`).then((r) =>
jsonResponse<{ issue: JiraIssue }>(r)
),

searchIssues: async (
projectCode: ResourceId,
integrationId: ResourceId,
params?: SearchJiraIssuesRequest
) => {
const validated = params ? validateRequest(params, SearchJiraIssuesRequestSchema) : {}
return fetcher(
appendSearchParams(`${tracker(projectCode, 'jira', integrationId)}/issue`, validated)
).then((r) => jsonResponse<CursorPage<JiraIssue>>(r))
},

listIssueTypes: (projectCode: ResourceId, integrationId: ResourceId) =>
fetcher(`${tracker(projectCode, 'jira', integrationId)}/issue-type`).then((r) =>
jsonResponse<{ issueTypes: Record<string, unknown>[] }>(r)
),

getIssueFields: (
projectCode: ResourceId,
integrationId: ResourceId,
issueTypeId: ResourceId
) =>
fetcher(
`${tracker(projectCode, 'jira', integrationId)}/issue-type/${issueTypeId}/field-metadata`
).then((r) => jsonResponse<{ fields: Record<string, unknown>[] }>(r)),

createIssue: async (
projectCode: ResourceId,
integrationId: ResourceId,
req: CreateJiraIssueRequest
) => {
const validated = validateRequest(req, CreateJiraIssueRequestSchema)
return fetcher(`${tracker(projectCode, 'jira', integrationId)}/issue`, {
method: 'POST',
body: JSON.stringify(validated),
}).then((r) => jsonResponse<CreateJiraIssueResponse>(r))
},
},

github: {
getIssue: (projectCode: ResourceId, integrationId: ResourceId, issueNumber: ResourceId) =>
fetcher(`${tracker(projectCode, 'github', integrationId)}/issue/${issueNumber}`).then((r) =>
jsonResponse<{ issue: GithubIssue }>(r)
),

createIssue: async (
projectCode: ResourceId,
integrationId: ResourceId,
req: CreateIssueRequest
) => {
const validated = validateRequest(req, CreateIssueRequestSchema)
return fetcher(`${tracker(projectCode, 'github', integrationId)}/issue`, {
method: 'POST',
body: JSON.stringify(validated),
}).then((r) => jsonResponse<{ id: number; url: string }>(r))
},
},

linear: {
getIssue: (projectCode: ResourceId, integrationId: ResourceId, issueId: ResourceId) =>
fetcher(`${tracker(projectCode, 'linear', integrationId)}/issue/${issueId}`).then((r) =>
jsonResponse<{ issue: LinearIssue }>(r)
),

searchIssues: async (
projectCode: ResourceId,
integrationId: ResourceId,
params?: SearchLinearIssuesRequest
) => {
const validated = params ? validateRequest(params, SearchLinearIssuesRequestSchema) : {}
return fetcher(
appendSearchParams(`${tracker(projectCode, 'linear', integrationId)}/issue`, validated)
).then((r) => jsonResponse<CursorPage<LinearIssue>>(r))
},

createIssue: async (
projectCode: ResourceId,
integrationId: ResourceId,
req: CreateIssueRequest
) => {
const validated = validateRequest(req, CreateIssueRequestSchema)
return fetcher(`${tracker(projectCode, 'linear', integrationId)}/issue`, {
method: 'POST',
body: JSON.stringify(validated),
}).then((r) => jsonResponse<{ id: string; identifier: string; url: string }>(r))
},
},
}
}
52 changes: 51 additions & 1 deletion src/api/milestones.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { z } from 'zod'
import { PaginatedResponse, ResourceId, paginationParams, validateRequest } from './schemas'
import {
MessageResponse,
PaginatedResponse,
ResourceId,
paginationParams,
validateRequest,
} from './schemas'
import { appendSearchParams, jsonResponse, withJson } from './utils'

export interface Milestone {
Expand All @@ -8,6 +14,12 @@ export interface Milestone {
createdAt: string
updatedAt: string
archivedAt: string | null
archivedBy: number | null
}

export interface ArchivedMilestone extends Milestone {
closedPlans: number
closedRuns: number
}

export const ListMilestonesRequestSchema = z.object({
Expand All @@ -26,6 +38,12 @@ export const CreateMilestoneRequestSchema = z.object({

export type CreateMilestoneRequest = z.infer<typeof CreateMilestoneRequestSchema>

export const UpdateMilestoneRequestSchema = z.object({
title: CreateMilestoneRequestSchema.shape.title.optional(),
})

export type UpdateMilestoneRequest = z.infer<typeof UpdateMilestoneRequestSchema>

export const createMilestoneApi = (fetcher: typeof fetch) => {
fetcher = withJson(fetcher)
return {
Expand All @@ -43,5 +61,37 @@ export const createMilestoneApi = (fetcher: typeof fetch) => {
body: JSON.stringify(validated),
}).then((r) => jsonResponse<{ id: number }>(r))
},

get: (projectCode: ResourceId, milestoneId: ResourceId) =>
fetcher(`/api/public/v0/project/${projectCode}/milestone/${milestoneId}`).then((r) =>
jsonResponse<Milestone>(r)
),

update: async (
projectCode: ResourceId,
milestoneId: ResourceId,
req: UpdateMilestoneRequest
) => {
const validated = validateRequest(req, UpdateMilestoneRequestSchema)
return fetcher(`/api/public/v0/project/${projectCode}/milestone/${milestoneId}`, {
method: 'PATCH',
body: JSON.stringify(validated),
}).then((r) => jsonResponse<Milestone>(r))
},

archive: (projectCode: ResourceId, milestoneId: ResourceId) =>
fetcher(`/api/public/v0/project/${projectCode}/milestone/${milestoneId}/archive`, {
method: 'POST',
}).then((r) => jsonResponse<ArchivedMilestone>(r)),

unarchive: (projectCode: ResourceId, milestoneId: ResourceId) =>
fetcher(`/api/public/v0/project/${projectCode}/milestone/${milestoneId}/unarchive`, {
method: 'POST',
}).then((r) => jsonResponse<Milestone>(r)),

delete: (projectCode: ResourceId, milestoneId: ResourceId) =>
fetcher(`/api/public/v0/project/${projectCode}/milestone/${milestoneId}`, {
method: 'DELETE',
}).then((r) => jsonResponse<MessageResponse>(r)),
}
}
Loading
Loading