diff --git a/README.md b/README.md index 0f987fd2..ef89277d 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,9 @@ A schema is the input contract only: it cannot know your team's workflow states, ## Coverage -Linear's GraphQL API exposes **537 root operations** (164 queries, 373 mutations). Linearis wires **83 of them** directly, plus a number of nested reads — chosen to cover planning and issue work end to end rather than the whole API. +Linear's GraphQL API exposes **520 root operations** (159 queries, 361 mutations). Linearis wires **114 of them** directly, plus a number of nested reads — chosen to cover planning and issue work end to end rather than the whole API. + +Both figures are checkable rather than asserted: `npm run count:root-fields -- --verify` parses `graphql/**/*.graphql` and cross-checks the result against a live introspection of the schema. The table below is the honest picture of the whole surface — what works today, and what you'll need the [Linear MCP](#linearis-vs-linear-mcp) or a raw API call for. @@ -154,26 +156,26 @@ The table below is the honest picture of the whole surface — what works today, | Area | Extent | What you can do | Not covered | |---|---|---|---| | `auth` | ✅ | Interactive login, token status, logout | — | -| Discussions | ✅ | Root threads and replies on issues, projects, and initiatives; edit, delete, resolve/unresolve; emoji reactions on any of them | Custom workspace emoji management | +| Discussions | ✅ | Root threads and replies on issues, projects, and initiatives; edit, delete, resolve/unresolve; emoji reactions on any of them | Custom workspace emoji management, and comment threads on a status update — `CommentCreateInput.projectUpdateId` makes an update its own discussion surface, which neither `projects updates` nor `initiatives updates` exposes | | `issues` | ✅ | List, filter, full-text search, read, create, update, batch create/update, archive/unarchive, delete/restore, snooze; assign labels/assignee/delegate/state/priority/project/cycle/team (including moves between teams); subscribe/unsubscribe, share/unshare, reminders; find the issue for a git branch (`from-branch`); relations (list/add/remove); activity history | Deliberately excluded: the AI-assist and integration-suggestion queries (Figma file lookup, filter/repository suggestions, title-from-customer-request) — see the Integrations row — and `issuePriorityValues`, a static list already in the help text | | `initiatives` | 🟡 | List, read, create, update, archive/unarchive, delete; attach/detach projects; initiative-to-initiative relations; initiative updates (list, read, create, update, archive/unarchive); discussions | Initiative labels, lead-team reassignment, relation reordering | -| `projects` | 🟡 | List, read, create, update, archive/unarchive, delete; assign project labels by name (`--labels`, `--label-mode`, `--clear-labels`); discussions | Project updates (status posts), project-label CRUD, project relations, project status administration, Slack channel creation | +| `projects` | ✅ | List, full-text search, read, create, update, delete (trash) and unarchive (restore), disable external sync; assign project labels by name (`--labels`, `--label-mode`, `--clear-labels`); status updates (list, read, create, edit, archive/unarchive, remind); dependency relations (list, read, add, update, remove); administer the workspace project status flow (`projects statuses`); discussions; activity timeline | Deliberately excluded: Slack-channel creation and the AI filter suggestion, which are `[Internal]` integration plumbing — see the Integrations row. Project labels live under `labels --type project`; milestone delete/move under `milestones` | | `documents` | 🟡 | List, read, create, update, delete | Content history, document full-text search, unarchive | | `milestones` | 🟡 | List, read, create, update (per project) | Delete, reordering/move between projects | | `attachments` | 🟡 | List on an issue, create from a URL, delete, disable external sync | Update, and the provider-specific link mutations (GitHub PR/issue, GitLab MR, Slack, Jira, Zendesk, Intercom, Front, Salesforce, Discord) | | `files` | 🟡 | Upload a file, download via signed URL | Delete uploads, image-from-URL, CSV export reports | | `teams` | 🟡 | List, read, create, update; list/add/remove members | Delete, workflow-state administration, triage responsibility, git automation, SLA configuration | -| `labels` | 🟠 | Issue labels: list, read, create, update, delete; project labels: list (`--type project`) | Project-label create/update/delete, initiative labels, retire/restore | +| `labels` | 🟡 | Issue and project labels alike (`--type issue\|project`): list, read, create, update, delete, retire/restore; label groups (`--group`, `--parent`) | Initiative labels | | `cycles` | 🟠 | List cycles, read a cycle with its issues | Create, update, archive, shift all, start upcoming cycle | | `users` | 🟠 | List workspace members | Read a single user, update, role changes, suspend/unsuspend, user settings, session management | -| Integrations | 🔴 | — | All 73 integration root fields (65 mutations, 8 queries): Slack, GitHub, GitLab, Jira, Figma, Sentry, PagerDuty, Intercom, Salesforce, and more | +| Integrations | 🔴 | — | All 70 integration root fields (62 mutations, 8 queries): Slack, GitHub, GitLab, Jira, Figma, Sentry, PagerDuty, Intercom, Salesforce, and more. Also the `[Internal]` per-entity integration plumbing excluded from the `issues` and `projects` rows: Slack channel creation and dismissal on a project, Slack/Teams/Jira project posts, and the AI-assist suggestion queries (issue and project filter suggestions, Figma file lookup, title-from-customer-request) | | Organization & admin | 🔴 | — | Org settings, invites, domains, webhooks, OAuth apps, audit log, SSO | | Releases | 🔴 | — | Releases, release pipelines, stages, release notes | | Customers (CRM) | 🔴 | — | Customers, needs, tiers, customer statuses | | Views & templates | 🔴 | — | Custom views, favorites, templates, view preferences | | Notifications | 🔴 | — | Inbox, subscriptions, snooze, mark read, push subscriptions | | Agent sessions | 🔴 | — | Agent sessions, activities, skills, semantic search | -| Roadmaps | 🔴 | — | Roadmaps and roadmap-to-project links | +| Roadmaps | 🔴 | — | Roadmaps and roadmap-to-project links. `roadmapToProject*` is deprecated in favour of `initiativeToProject*`, which `initiatives` already wires | | Imports & exports | 🔴 | — | Jira/Asana/Clubhouse/GitHub/CSV import jobs | | Schedules | 🔴 | — | Time schedules and on-call rotations | diff --git a/docs/architecture.md b/docs/architecture.md index d8c9477e..277a028b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -127,7 +127,7 @@ Shared utilities used across layers. - **src/commands/cycles.ts** - Cycle listing and reading - **src/commands/teams.ts** - Team listing - **src/commands/users.ts** - User listing -- **src/commands/projects.ts** - Project listing +- **src/commands/projects/** - Project commands (`index.ts` registers the domain, `entity.ts` holds CRUD and discussions) - **src/commands/labels.ts** - Label listing - **src/commands/comments.ts** - Comment creation - **src/commands/embeds.ts** - File operations diff --git a/docs/files.md b/docs/files.md index c5a46abe..f39b7c9e 100644 --- a/docs/files.md +++ b/docs/files.md @@ -22,7 +22,9 @@ Each resolver converts a human-friendly identifier (name, key, or slug) into a U - **cycle-resolver.ts** -- `resolveCycleId(client, nameOrId, teamFilter?)` - **status-resolver.ts** -- `resolveStatusId(client, nameOrId, teamId?)` - **issue-resolver.ts** -- `resolveIssueId(client, issueIdOrIdentifier)` -- **milestone-resolver.ts** -- `resolveMilestoneId(gqlClient, sdkClient, nameOrId, projectNameOrId?)` +- **milestone-resolver.ts** -- `resolveMilestoneId(gqlClient, nameOrId, projectNameOrId?)` — a project scope is authoritative, never widened to the workspace +- **project-status-resolver.ts** -- `resolveProjectStatusId(client, nameOrId, { includeArchived? })` +- **project-relation-resolver.ts** -- `resolveProjectRelation(client, relationOrProjectId, relatedProjectId?)` — returns `{ id, inverted }`; `inverted` tells callers writing per-end fields to swap the two ends ## Service Layer (`src/services/`) @@ -35,8 +37,12 @@ Business logic and CRUD operations. Services use `GraphQLClient` exclusively and - **cycle-service.ts** -- `listCycles`, `getCycle` - **team-service.ts** -- `listTeams` - **user-service.ts** -- `listUsers` -- **project-service.ts** -- `listProjects` -- **label-service.ts** -- `listLabels` +- **project-service.ts** -- `listProjects`, `searchProjects`, `getProject`, `createProject`, `updateProject`, `applyProjectLabels`, `disableProjectExternalSync`, `unarchiveProject`, `deleteProject` +- **project-update-service.ts** -- Project status posts: `listProjectUpdates`, `getProjectUpdate`, `createProjectUpdate`, `editProjectUpdate`, archive/unarchive, `remindProjectUpdate` +- **project-status-service.ts** -- The workspace project status flow: list/get/create/update, `reassignProjectStatus`, archive/unarchive +- **project-relation-service.ts** -- Project dependencies: list (per project and workspace-wide), get, create, update, delete +- **project-activity-service.ts** -- Merges project discussions, history and status updates into one chronological timeline +- **label-service.ts** -- `listLabels`, `listProjectLabels`, and get/create/update/delete/retire/restore dispatching on `LabelType` - **comment-service.ts** -- `createComment` - **file-service.ts** -- File upload and download operations for Linear uploads @@ -52,8 +58,8 @@ CLI orchestration. Each file registers a command group via a `setup*Commands(pro - **cycles.ts** -- Cycle listing and detail reading - **teams.ts** -- Team listing - **users.ts** -- User listing -- **projects.ts** -- Project listing -- **labels.ts** -- Label listing +- **projects/** -- Project commands. `index.ts` owns `PROJECTS_META` and registers the domain; `entity.ts` holds CRUD, search, sync and discussions; `updates.ts`, `statuses.ts` and `relations.ts` hold the subgroups +- **labels.ts** -- Label commands for both issue and project labels (`--type`) - **comments.ts** -- Comment creation - **embeds.ts** -- File download from Linear upload URLs diff --git a/graphql/mutations/labels.graphql b/graphql/mutations/labels.graphql index 460afeed..44f7ee9b 100644 --- a/graphql/mutations/labels.graphql +++ b/graphql/mutations/labels.graphql @@ -1,5 +1,8 @@ # ------------------------------------------------------------ -# GraphQL mutations for Linear issue labels +# GraphQL mutations for Linear issue and project labels +# +# The two label kinds are separate types with parallel mutations; +# `labels --type issue|project` picks between them. # ------------------------------------------------------------ mutation CreateIssueLabel($input: IssueLabelCreateInput!) { @@ -26,3 +29,68 @@ mutation DeleteIssueLabel($id: String!) { entityId } } + +# Retire an issue label +# +# Retired labels stay on the issues that already carry them but cannot be +# applied to new ones — a softer alternative to delete. +mutation RetireIssueLabel($id: String!) { + issueLabelRetire(id: $id) { + success + issueLabel { + ...LabelFields + } + } +} + +mutation RestoreIssueLabel($id: String!) { + issueLabelRestore(id: $id) { + success + issueLabel { + ...LabelFields + } + } +} + +mutation CreateProjectLabel($input: ProjectLabelCreateInput!) { + projectLabelCreate(input: $input) { + success + projectLabel { + ...ProjectLabelFields + } + } +} + +mutation UpdateProjectLabel($id: String!, $input: ProjectLabelUpdateInput!) { + projectLabelUpdate(id: $id, input: $input) { + success + projectLabel { + ...ProjectLabelFields + } + } +} + +mutation DeleteProjectLabel($id: String!) { + projectLabelDelete(id: $id) { + success + entityId + } +} + +mutation RetireProjectLabel($id: String!) { + projectLabelRetire(id: $id) { + success + projectLabel { + ...ProjectLabelFields + } + } +} + +mutation RestoreProjectLabel($id: String!) { + projectLabelRestore(id: $id) { + success + projectLabel { + ...ProjectLabelFields + } + } +} diff --git a/graphql/mutations/project-relations.graphql b/graphql/mutations/project-relations.graphql new file mode 100644 index 00000000..57aafca2 --- /dev/null +++ b/graphql/mutations/project-relations.graphql @@ -0,0 +1,31 @@ +# ------------------------------------------------------------ +# GraphQL mutations for Linear project dependency relations +# ------------------------------------------------------------ + +mutation CreateProjectRelation($input: ProjectRelationCreateInput!) { + projectRelationCreate(input: $input) { + success + projectRelation { + ...ProjectRelationCoreFields + } + } +} + +mutation UpdateProjectRelation( + $id: String! + $input: ProjectRelationUpdateInput! +) { + projectRelationUpdate(id: $id, input: $input) { + success + projectRelation { + ...ProjectRelationCoreFields + } + } +} + +mutation DeleteProjectRelation($id: String!) { + projectRelationDelete(id: $id) { + success + entityId + } +} diff --git a/graphql/mutations/project-statuses.graphql b/graphql/mutations/project-statuses.graphql new file mode 100644 index 00000000..70aa2a33 --- /dev/null +++ b/graphql/mutations/project-statuses.graphql @@ -0,0 +1,58 @@ +# ------------------------------------------------------------ +# GraphQL mutations for the workspace project status flow +# ------------------------------------------------------------ + +mutation CreateProjectStatus($input: ProjectStatusCreateInput!) { + projectStatusCreate(input: $input) { + success + status { + ...ProjectStatusCoreFields + } + } +} + +mutation UpdateProjectStatus($id: String!, $input: ProjectStatusUpdateInput!) { + projectStatusUpdate(id: $id, input: $input) { + success + status { + ...ProjectStatusCoreFields + } + } +} + +# Archive a status +# +# Linear refuses this while projects are still assigned to the status, or +# when it is the last status of its type. +mutation ArchiveProjectStatus($id: String!) { + projectStatusArchive(id: $id) { + success + entity { + ...ProjectStatusCoreFields + } + } +} + +mutation UnarchiveProjectStatus($id: String!) { + projectStatusUnarchive(id: $id) { + success + entity { + ...ProjectStatusCoreFields + } + } +} + +# Move every project off one status and onto another +# +# The payload carries no entity — only whether the reassignment ran. +mutation ReassignProjectStatus( + $originalProjectStatusId: String! + $newProjectStatusId: String! +) { + projectReassignStatus( + originalProjectStatusId: $originalProjectStatusId + newProjectStatusId: $newProjectStatusId + ) { + success + } +} diff --git a/graphql/mutations/project-updates.graphql b/graphql/mutations/project-updates.graphql new file mode 100644 index 00000000..c0be183f --- /dev/null +++ b/graphql/mutations/project-updates.graphql @@ -0,0 +1,52 @@ +# ------------------------------------------------------------ +# GraphQL mutations for Linear project status updates +# +# `projectUpdateDelete` is deliberately not wired: Linear deprecates it +# in favour of `projectUpdateArchive`, which is reversible. +# ------------------------------------------------------------ + +mutation CreateProjectUpdate($input: ProjectUpdateCreateInput!) { + projectUpdateCreate(input: $input) { + success + projectUpdate { + ...ProjectUpdateCoreFields + } + } +} + +mutation EditProjectUpdate($id: String!, $input: ProjectUpdateUpdateInput!) { + projectUpdateUpdate(id: $id, input: $input) { + success + projectUpdate { + ...ProjectUpdateCoreFields + } + } +} + +mutation ArchiveProjectUpdate($id: String!) { + projectUpdateArchive(id: $id) { + success + entity { + ...ProjectUpdateCoreFields + } + } +} + +mutation UnarchiveProjectUpdate($id: String!) { + projectUpdateUnarchive(id: $id) { + success + entity { + ...ProjectUpdateCoreFields + } + } +} + +# Nudge someone to post the next update +# +# The payload carries no entity — there is nothing to return but whether +# the notification was created. +mutation CreateProjectUpdateReminder($projectId: String!, $userId: String) { + createProjectUpdateReminder(projectId: $projectId, userId: $userId) { + success + } +} diff --git a/graphql/mutations/projects.graphql b/graphql/mutations/projects.graphql index b0ce91c0..e505b746 100644 --- a/graphql/mutations/projects.graphql +++ b/graphql/mutations/projects.graphql @@ -30,8 +30,12 @@ mutation UpdateProject($id: String!, $input: ProjectUpdateInput!) { } } -mutation ArchiveProject($id: String!) { - projectArchive(id: $id) { +# Restore a trashed project +# +# Linear collapses "archived" and "trashed" into a single state, so this +# restores whatever projectDelete put away. +mutation UnarchiveProject($id: String!) { + projectUnarchive(id: $id) { success entity { ...ProjectDetailWithDefaultConnectionsFields @@ -39,8 +43,11 @@ mutation ArchiveProject($id: String!) { } } -mutation UnarchiveProject($id: String!) { - projectUnarchive(id: $id) { +# Trash a project +# +# Reversible: UnarchiveProject restores it. +mutation DeleteProject($id: String!) { + projectDelete(id: $id) { success entity { ...ProjectDetailWithDefaultConnectionsFields @@ -48,10 +55,39 @@ mutation UnarchiveProject($id: String!) { } } -mutation DeleteProject($id: String!) { - projectDelete(id: $id) { +# Stop syncing a project with an external tracker +# +# Mirrors `attachments disable-sync`: the link stays, the updates stop. +mutation DisableProjectExternalSync( + $projectId: String! + $syncSource: ExternalSyncService! +) { + projectExternalSyncDisable(projectId: $projectId, syncSource: $syncSource) { success - entity { + project { + ...ProjectDetailWithDefaultConnectionsFields + } + } +} + +# Add one label to a project +# +# Incremental, unlike `ProjectUpdateInput.labelIds`, which replaces the +# whole set — so `--label-mode add` needs no read of the current labels +# and cannot drop the ones it did not see. +mutation AddProjectLabel($id: String!, $labelId: String!) { + projectAddLabel(id: $id, labelId: $labelId) { + success + project { + ...ProjectDetailWithDefaultConnectionsFields + } + } +} + +mutation RemoveProjectLabel($id: String!, $labelId: String!) { + projectRemoveLabel(id: $id, labelId: $labelId) { + success + project { ...ProjectDetailWithDefaultConnectionsFields } } diff --git a/graphql/queries/labels.graphql b/graphql/queries/labels.graphql index 6c66ceca..843b614e 100644 --- a/graphql/queries/labels.graphql +++ b/graphql/queries/labels.graphql @@ -20,6 +20,14 @@ fragment LabelFields on IssueLabel { name color description + isGroup + # Retired labels stay on the entities that already carry them but cannot + # be applied to new ones, so a null here is what "usable" means. + retiredAt + parent { + id + name + } } fragment ProjectLabelFields on ProjectLabel { @@ -27,6 +35,12 @@ fragment ProjectLabelFields on ProjectLabel { name color description + isGroup + retiredAt + parent { + id + name + } } query GetIssueLabel($id: String!) { @@ -67,6 +81,12 @@ query GetLabels( } } +query GetProjectLabel($id: String!) { + projectLabel(id: $id) { + ...ProjectLabelFields + } +} + query GetProjectLabels($first: Int = 50, $after: String) { projectLabels(first: $first, after: $after) { nodes { diff --git a/graphql/queries/project-activity.graphql b/graphql/queries/project-activity.graphql new file mode 100644 index 00000000..83751779 --- /dev/null +++ b/graphql/queries/project-activity.graphql @@ -0,0 +1,54 @@ +# ------------------------------------------------------------ +# GraphQL queries backing `projects activity` +# +# The project timeline merges three independent connections: project +# history, discussion threads, and status updates. Linear exposes no +# unified activity connection, so each is exhausted separately and the +# service interleaves them. +# ------------------------------------------------------------ + +query GetProjectActivityRef($id: String!) { + project(id: $id) { + id + name + } +} + +# A project's history events +# +# Unlike `IssueHistory`, which has ~40 typed from*/to* fields, every +# `ProjectHistory` event body lives in one opaque `entries` JSONObject. +# There is nothing to normalize into a typed change list, and no actor +# field either — the shape a caller gets here is whatever Linear put in +# `entries`. +query ListProjectActivityHistory($id: String!, $first: Int, $after: String) { + project(id: $id) { + history(first: $first, after: $after) { + nodes { + id + createdAt + entries + } + pageInfo { + hasNextPage + endCursor + } + } + } +} + +query ListProjectActivityUpdates($projectId: ID!, $first: Int, $after: String) { + projectUpdates( + first: $first + after: $after + filter: { project: { id: { eq: $projectId } } } + ) { + nodes { + ...ProjectUpdateCoreFields + } + pageInfo { + hasNextPage + endCursor + } + } +} diff --git a/graphql/queries/project-milestones.graphql b/graphql/queries/project-milestones.graphql index 24ed9a55..b144a0c5 100644 --- a/graphql/queries/project-milestones.graphql +++ b/graphql/queries/project-milestones.graphql @@ -74,7 +74,7 @@ query FindProjectMilestoneScoped($name: String!, $projectId: String!) { } } -# Find project milestone by name globally (fallback) +# Find project milestone by name globally # # Searches for a project milestone by name across all projects. query FindProjectMilestoneGlobal($name: String!) { diff --git a/graphql/queries/project-relations.graphql b/graphql/queries/project-relations.graphql new file mode 100644 index 00000000..9b66a924 --- /dev/null +++ b/graphql/queries/project-relations.graphql @@ -0,0 +1,89 @@ +# ------------------------------------------------------------ +# GraphQL queries for Linear project dependency relations +# +# A project relation links a point on one project to a point on +# another: "this must finish before that starts". Both `type` and the +# two anchor fields are plain `String!` in the schema with no enum, so +# the accepted literals were read off a live workspace rather than +# derived from the schema. See `src/services/project-relation-service.ts`. +# ------------------------------------------------------------ + +fragment ProjectRelationCoreFields on ProjectRelation { + id + type + anchorType + relatedAnchorType + createdAt + updatedAt + project { + id + name + } + projectMilestone { + id + name + } + relatedProject { + id + name + } + relatedProjectMilestone { + id + name + } +} + +query GetProjectRelation($id: String!) { + projectRelation(id: $id) { + ...ProjectRelationCoreFields + } +} + +# Every dependency in the workspace +# +# The root connection takes no filter argument, so it cannot be narrowed +# to one project — that is what GetProjectRelations is for. This is the +# whole-workspace view, and the only way to page dependencies without +# already knowing which project to ask about. +query ListAllProjectRelations($first: Int = 50, $after: String) { + projectRelations(first: $first, after: $after) { + nodes { + ...ProjectRelationCoreFields + } + pageInfo { + hasNextPage + endCursor + } + } +} + +# Both directions of a project's dependencies +# +# `relations` are the ones this project declares; `inverseRelations` are +# the ones pointing at it. A caller asking "what does this depend on" +# needs both, so they are fetched together and merged by the service. +# +# `$first` bounds each direction separately. Callers pass their own page size +# so the number is stated once, next to the code that reports the truncation. +query GetProjectRelations($projectId: String!, $first: Int = 100) { + project(id: $projectId) { + id + name + relations(first: $first) { + nodes { + ...ProjectRelationCoreFields + } + pageInfo { + hasNextPage + } + } + inverseRelations(first: $first) { + nodes { + ...ProjectRelationCoreFields + } + pageInfo { + hasNextPage + } + } + } +} diff --git a/graphql/queries/project-statuses.graphql b/graphql/queries/project-statuses.graphql new file mode 100644 index 00000000..a7981d8d --- /dev/null +++ b/graphql/queries/project-statuses.graphql @@ -0,0 +1,61 @@ +# ------------------------------------------------------------ +# GraphQL queries for the workspace project status flow +# +# Project statuses are workspace-scoped, not per-team: every project in +# the workspace draws its status from this one ordered list. +# ------------------------------------------------------------ + +fragment ProjectStatusCoreFields on ProjectStatus { + id + name + description + color + type + position + indefinite + createdAt + updatedAt + archivedAt +} + +# List the workspace's project statuses +# +# The connection takes no name filter, which is why +# `resolveProjectStatusId()` matches names client-side. +# +# `first` overrides the API's default page of 50: archived statuses keep +# accumulating, so `--include-archived` can pass 50 in a long-lived +# workspace. pageInfo is selected so a caller that hits even the raised +# bound learns the list is partial instead of reading it as complete. +query ListProjectStatuses( + $includeArchived: Boolean = false + $first: Int = 250 +) { + projectStatuses(includeArchived: $includeArchived, first: $first) { + nodes { + ...ProjectStatusCoreFields + } + pageInfo { + hasNextPage + } + } +} + +query GetProjectStatus($id: String!) { + projectStatus(id: $id) { + ...ProjectStatusCoreFields + } +} + +# How many projects currently sit in a status +# +# Folded into `projects statuses read` rather than exposed as its own +# verb: the count only means anything next to the status it describes, +# and it is what tells you whether an archive will be refused. +query GetProjectStatusProjectCount($id: String!) { + projectStatusProjectCount(id: $id) { + count + privateCount + archivedTeamCount + } +} diff --git a/graphql/queries/project-updates.graphql b/graphql/queries/project-updates.graphql new file mode 100644 index 00000000..178e4ff7 --- /dev/null +++ b/graphql/queries/project-updates.graphql @@ -0,0 +1,60 @@ +# ------------------------------------------------------------ +# GraphQL queries for Linear project status updates +# +# A project update is a dated status post on a project: a markdown +# body plus a health signal. It is a different entity from the +# `projectUpdate` mutation, which edits the project itself. +# ------------------------------------------------------------ + +fragment ProjectUpdateCoreFields on ProjectUpdate { + id + body + health + isDiffHidden + isStale + url + createdAt + updatedAt + editedAt + archivedAt + project { + id + name + } + user { + id + name + } +} + +# List the status updates posted on one project +# +# `projectUpdates` is workspace-wide, so the project is applied as a +# filter rather than traversed from the project itself. +query ListProjectUpdates( + $projectId: ID! + $first: Int = 50 + $after: String + $includeArchived: Boolean = false +) { + projectUpdates( + first: $first + after: $after + includeArchived: $includeArchived + filter: { project: { id: { eq: $projectId } } } + ) { + nodes { + ...ProjectUpdateCoreFields + } + pageInfo { + hasNextPage + endCursor + } + } +} + +query GetProjectUpdate($id: String!) { + projectUpdate(id: $id) { + ...ProjectUpdateCoreFields + } +} diff --git a/graphql/queries/projects.graphql b/graphql/queries/projects.graphql index b8d9c310..14d8237f 100644 --- a/graphql/queries/projects.graphql +++ b/graphql/queries/projects.graphql @@ -110,6 +110,15 @@ fragment ProjectDetailFields on Project { hasNextPage } } + # The project's `health` is derived from its latest status update, so the + # read that reports the health also reports where it came from. + healthUpdatedAt + lastUpdate { + id + health + body + createdAt + } } fragment ProjectDetailWithDefaultConnectionsFields on Project { @@ -214,41 +223,101 @@ query GetProjectWithReactions($id: String!, $first: Int, $after: String) { } } -# List all project statuses in the workspace +# The search fields, mirroring ProjectListFields # -# Fetches project statuses for name-to-UUID resolution. -# The API does not support filter args on this connection, -# so all statuses are fetched and filtered client-side. -query GetProjectStatuses { - projectStatuses { +# `searchProjects` returns `ProjectSearchResult`, a sibling type of +# `Project` rather than the thing itself, so the list fragment cannot be +# reused. Two differences are real: `state` (deprecated on `Project`) does +# not exist here, and `metadata` carries the search-ranking detail. +fragment ProjectSearchFields on ProjectSearchResult { + id + name + description + status { + id + name + type + } + slugId + priority + priorityLabel + health + progress + startDate + targetDate + url + metadata + lead { + id + name + } + teams(first: 25) { + nodes { + id + key + name + } + } + labels(first: 25) { nodes { id name + color } } } -# Get only a project's label IDs +# Full-text search across projects # -# Lean lookup used by `projects update --label-mode add|remove`, which -# previously fetched the full project detail (milestones + issues) just -# to read the current label set — an over-budget query on real -# workspaces (#283). +# Ranked by relevance rather than by `updatedAt`, so there is no orderBy +# knob here — asking for one would silently discard the ranking that +# makes the search worth running. # -# 250 is Linear's per-connection maximum, so this read cannot be raised -# further; hasNextPage lets the service refuse a truncated label set -# instead of writing it back as if it were complete (labelIds is a -# full-replacement input). -query GetProjectLabelIds($id: String!) { - project(id: $id) { - id - labels(first: 250) { - nodes { - id - } - pageInfo { - hasNextPage - } +# `searchProjects` is rate-limited more tightly than the plain +# `projects` connection; use `projects list` when a filter would do. +query SearchProjects( + $term: String! + $first: Int = 25 + $after: String + $includeArchived: Boolean = false + $teamId: String +) { + searchProjects( + term: $term + first: $first + after: $after + includeArchived: $includeArchived + teamId: $teamId + ) { + nodes { + ...ProjectSearchFields + } + pageInfo { + hasNextPage + endCursor + } + } +} + +# List all project statuses in the workspace +# +# Fetches project statuses for name-to-UUID resolution. +# The API does not support filter args on this connection, +# so all statuses are fetched and filtered client-side. +# archivedAt is selected so that an ambiguity between an archived status +# and a live one of the same name can be spelled out to the caller. +# `first` raises the API's default page of 50, which archived statuses can +# outgrow; pageInfo lets the resolver refuse rather than report a name it +# simply did not fetch as missing. +query GetProjectStatuses($includeArchived: Boolean = false, $first: Int = 250) { + projectStatuses(includeArchived: $includeArchived, first: $first) { + nodes { + id + name + archivedAt + } + pageInfo { + hasNextPage } } } diff --git a/package.json b/package.json index e7440839..e9871997 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "check:ci": "biome check .", "knip": "knip --no-config-hints", "knip:ci": "knip --no-config-hints --reporter markdown", + "count:root-fields": "node scripts/count-root-fields.mjs", "verify:packed-binaries": "node scripts/verify-packed-binaries.mjs", "release": "npm test && npm run build && npm run verify:packed-binaries && rm -rf .clean-pkg && npx clean-publish --without-publish --temp-dir .clean-pkg && npm publish ./.clean-pkg --access public && rm -rf .clean-pkg", "prestart": "npm run generate", diff --git a/scripts/count-root-fields.mjs b/scripts/count-root-fields.mjs new file mode 100644 index 00000000..60102b91 --- /dev/null +++ b/scripts/count-root-fields.mjs @@ -0,0 +1,230 @@ +#!/usr/bin/env node +/** + * Counts the Linear root fields this CLI wires. + * + * The README's coverage section quotes a number ("wires N of them"). Before + * this script that number was asserted by hand and drifted — it read 83 while + * the documents held 81. Run this and paste the result rather than guessing. + * + * A "wired root field" is a top-level selection inside an operation in + * `graphql/{queries,mutations}/*.graphql`. Fragments are skipped; a field + * selected in several operations counts once. Note that a handful of names — + * `projectUpdate`, for one — exist as both a query and a mutation, so the + * operation count `--verify` reports is slightly higher than the name count. + * + * node scripts/count-root-fields.mjs # the count and the list + * node scripts/count-root-fields.mjs --json # machine-readable + * node scripts/count-root-fields.mjs --verify # also check against the live + * # schema (needs network; the + * # endpoint allows anonymous + * # introspection) + * + * `--verify` reports the API-wide totals the README also quotes, and flags any + * name that is not actually a root field — which would mean this parser has + * mistaken a nested selection for one. + */ + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); +const graphqlRoot = path.join(repoRoot, "graphql"); + +/** Strip comments and string literals so braces and names parse cleanly. */ +function stripNoise(source) { + return source + .replace(/"""[\s\S]*?"""/g, " ") + .replace(/"(?:[^"\\]|\\.)*"/g, '""') + .replace(/#[^\n]*/g, ""); +} + +/** + * Extract the top-level selections of every operation in one document. + * + * Walks brace depth rather than using a GraphQL parser so the script stays + * dependency-free: names at depth 1 inside an operation body are root fields. + */ +function rootFieldsIn(source) { + const text = stripNoise(source); + const found = new Set(); + const operation = /\b(query|mutation)\b[^{]*\{/g; + + let match = operation.exec(text); + while (match !== null) { + let depth = 1; + let index = match.index + match[0].length; + let expectFieldName = true; + + while (index < text.length && depth > 0) { + const char = text[index]; + + if (char === "{") { + depth += 1; + expectFieldName = false; + index += 1; + continue; + } + + if (char === "}") { + depth -= 1; + expectFieldName = depth === 1; + index += 1; + continue; + } + + if (char === "(") { + // Skip the argument list wholesale; nothing in it is a root field. + let parens = 1; + index += 1; + while (index < text.length && parens > 0) { + if (text[index] === "(") parens += 1; + if (text[index] === ")") parens -= 1; + index += 1; + } + expectFieldName = false; + continue; + } + + const name = /^[A-Za-z_][A-Za-z0-9_]*/.exec(text.slice(index)); + if (name) { + if (depth === 1 && expectFieldName) { + // `alias: field` — the name before the colon is the caller's label, + // not a root field. Leave expectFieldName set so the real one lands. + const isAlias = /^\s*:/.test(text.slice(index + name[0].length)); + if (!isAlias) { + found.add(name[0]); + expectFieldName = false; + } + } + index += name[0].length; + continue; + } + + if (char === "\n" || char === ",") { + // A newline or comma at depth 1 ends one selection and starts the next. + if (depth === 1) expectFieldName = true; + } else if (char === ":") { + // An alias: the name after the colon is the real field. + if (depth === 1) expectFieldName = true; + } + + index += 1; + } + + operation.lastIndex = index; + match = operation.exec(text); + } + + return found; +} + +const documents = ["queries", "mutations"].flatMap((kind) => { + const dir = path.join(graphqlRoot, kind); + return fs + .readdirSync(dir) + .filter((file) => file.endsWith(".graphql")) + .map((file) => path.join(dir, file)); +}); + +const byField = new Map(); +for (const file of documents) { + const relative = path.relative(repoRoot, file); + for (const field of rootFieldsIn(fs.readFileSync(file, "utf8"))) { + const sources = byField.get(field) ?? []; + sources.push(relative); + byField.set(field, sources); + } +} + +const fields = [...byField.keys()].sort(); + +const ENDPOINT = "https://api.linear.app/graphql"; + +async function introspectRootFields() { + const response = await fetch(ENDPOINT, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + query: + 'query { q: __type(name: "Query") { fields { name } } m: __type(name: "Mutation") { fields { name } } }', + }), + }); + + if (!response.ok) { + throw new Error(`introspection failed: HTTP ${response.status}`); + } + + const { data, errors } = await response.json(); + if (errors) { + throw new Error(`introspection failed: ${JSON.stringify(errors)}`); + } + + return { + queries: data.q.fields.map((field) => field.name), + mutations: data.m.fields.map((field) => field.name), + }; +} + +const verify = process.argv.includes("--verify"); +const schema = verify ? await introspectRootFields() : null; + +let report = null; +if (schema) { + const wired = new Set(fields); + const queries = schema.queries.filter((name) => wired.has(name)); + const mutations = schema.mutations.filter((name) => wired.has(name)); + const known = new Set([...schema.queries, ...schema.mutations]); + + report = { + wired: queries.length + mutations.length, + queries: { wired: queries.length, total: schema.queries.length }, + mutations: { wired: mutations.length, total: schema.mutations.length }, + total: schema.queries.length + schema.mutations.length, + // A name like `projectUpdate` is both a query and a mutation, so the + // operation count exceeds the count of distinct names. + sharedNames: queries.filter((name) => mutations.includes(name)), + notRootFields: fields.filter((name) => !known.has(name)), + }; +} + +if (process.argv.includes("--json")) { + console.log( + JSON.stringify( + { count: fields.length, fields, ...(report && { report }) }, + null, + 2, + ), + ); +} else { + for (const field of fields) { + console.log(field); + } + console.log(`\n${fields.length} distinct root field names wired`); + + if (report) { + console.log( + `${report.wired} of ${report.total} root operations ` + + `(${report.queries.wired}/${report.queries.total} queries, ` + + `${report.mutations.wired}/${report.mutations.total} mutations)`, + ); + + if (report.wired !== fields.length) { + console.log( + `${report.wired - fields.length} more operations than names: ` + + "some names exist as both a query and a mutation " + + `(${report.sharedNames.join(", ")})`, + ); + } + + if (report.notRootFields.length > 0) { + console.log( + `\nnot root fields — this parser is wrong about: ${report.notRootFields.join(", ")}`, + ); + process.exitCode = 1; + } + } +} diff --git a/src/commands/initiatives/updates.ts b/src/commands/initiatives/updates.ts index 96d16504..76c81e18 100644 --- a/src/commands/initiatives/updates.ts +++ b/src/commands/initiatives/updates.ts @@ -1,5 +1,6 @@ import type { Command } from "commander"; import { createContext, getRootOpts } from "../../common/context.js"; +import { parseHealth } from "../../common/domain-values.js"; import { invalidParameterError } from "../../common/errors.js"; import { asUuid } from "../../common/identifier.js"; import { @@ -15,7 +16,6 @@ import { createInitiativeUpdate, getInitiativeUpdate, listInitiativeUpdates, - parseHealth, type UpdateInitiativeUpdateInput, unarchiveInitiativeUpdate, updateInitiativeUpdate, diff --git a/src/commands/labels.ts b/src/commands/labels.ts index a22e4bdf..9a4ce232 100644 --- a/src/commands/labels.ts +++ b/src/commands/labels.ts @@ -11,8 +11,10 @@ import { handleCommand, outputSuccess, parseLimit } from "../common/output.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; import { type LabelResolverScope, + type ResolveLabelOptions, resolveLabelId, } from "../resolvers/label-resolver.js"; +import { resolveProjectLabelId } from "../resolvers/project-resolver.js"; import { resolveTeamId } from "../resolvers/team-resolver.js"; import { type CreateLabelInput, @@ -23,6 +25,8 @@ import { type LabelType, listLabels, listProjectLabels, + restoreLabel, + retireLabel, type UpdateLabelInput, updateLabel, } from "../services/label-service.js"; @@ -36,20 +40,28 @@ interface ListLabelsOptions extends CommandOptions { } interface LabelLookupOptions extends CommandOptions { + type?: string; team?: string; scope?: string; } interface CreateLabelOptions extends CommandOptions { + type?: string; team?: string; color?: string; description?: string; + parent?: string; + group?: boolean; } interface UpdateLabelOptions extends LabelLookupOptions { name?: string; color?: string; description?: string; + parent?: string; + clearParent?: boolean; + group?: boolean; + notGroup?: boolean; } function parseLabelType(value?: string): LabelType { @@ -83,14 +95,85 @@ function parseLabelColor(value?: string): string | undefined { return value; } -async function resolveIssueLabelLookup( +/** + * Project labels have no team dimension at all, so silently ignoring + * `--team`/`--scope` would answer a question the caller did not ask. + */ +function rejectTeamScopingForProjectLabels( + team: string | undefined, + scope: LabelScope | undefined, +): void { + if (team) { + throw invalidParameterError( + "--team", + "cannot be used with --type project because project labels are workspace-scoped", + ); + } + + if (scope) { + throw invalidParameterError( + "--scope", + "cannot be used with --type project because project labels are always workspace-scoped", + ); + } +} + +/** + * Resolves `--parent` against the same label kind as the label being written. + * + * A group and its children are always the same kind, so routing the parent + * through the other resolver could only ever produce a not-found or a + * cross-kind parent the API would reject. + * + * Issue-label lookups take the same team scoping the written label was + * resolved with. Without it the workspace-wide name match wins, so in a + * workspace where two teams each own a group called "Area" the new label would + * land in whichever one the API returned first. + */ +async function resolveLabelParentId( + client: ReturnType["gql"], + parent: string, + type: LabelType, + scoping: ResolveLabelOptions, +): Promise { + return type === "project" + ? resolveProjectLabelId(client, parent) + : resolveLabelId(client, parent, scoping); +} + +/** + * Resolves `