Skip to content
Merged
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
45 changes: 45 additions & 0 deletions .github/workflows/linear-project.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Puts each new GitHub issue's Linear copy into the Linear project cloudpid (scripts/linear-project.sh). Linear's GitHub
# Issues sync copies the issue into the team SPI but into no project, and agents read issues by project
# (docs/agents/issue-tracker.md). It runs for `cloudpid-report` issues too: the GitHub App opens them, and an issue
# opened by an App starts workflows (one opened with GITHUB_TOKEN would not). A dispatch takes an issue number, to retry
# one whose run failed; an issue opened before the sync existed has no copy to move. Needs the repository secret
# LINEAR_API_KEY: a Linear API key that may edit SPI's issues.
name: linear project

on:
issues:
types: [opened]
workflow_dispatch:
inputs:
issue:
description: GitHub issue number
required: true
type: number

permissions: {}

concurrency:
group: linear-project-${{ github.event.issue.number || inputs.issue }}

jobs:
project:
name: project
# Forks have neither the secret nor the Linear project.
if: github.repository == 'ian-pascoe/cloudpid'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read # sparse checkout of the script
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
sparse-checkout: scripts/linear-project.sh
sparse-checkout-cone-mode: false
- name: Add to the project
env:
LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
# The Linear project cloudpid (team SPI).
LINEAR_PROJECT: c5b4ec54-51b3-4742-88a1-0a75c7c646a8
ISSUE_URL: ${{ github.event.issue.html_url || format('{0}/{1}/issues/{2}', github.server_url, github.repository, inputs.issue) }}
run: scripts/linear-project.sh
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ Every workspace package (`apps/*`, `infra`, `packages/*`) is named `@cloudpid/<d

### Issue tracker

Issues are tracked in GitHub Issues for `ian-pascoe/cloudpid` via the `gh` CLI. See `docs/agents/issue-tracker.md`.
Issues live in Linear (team SPI, project cloudpid); GitHub Issues are the public intake and sync one-way into it. Reach Linear through Executor's Linear app, falling back to the `linear` CLI. See `docs/agents/issue-tracker.md`.

### Triage labels

Expand Down
107 changes: 80 additions & 27 deletions docs/agents/issue-tracker.md
Original file line number Diff line number Diff line change
@@ -1,48 +1,101 @@
# Issue tracker: GitHub
# Issue tracker: Linear

Issues and specs for this repo live as GitHub issues. Use the `gh` CLI for all operations.
Issues and specs for this repo live in **Linear**: workspace `spiritledsoftware`, team **SPI** (Spirit-Led Software), project **cloudpid**. The team also holds other repos' projects (Commissary, lspctl, Neopane, …), so create every issue for this repo in project `cloudpid`, and scope every read to it.

## Conventions
**GitHub Issues are the public intake.** Outside users open issues on GitHub, and cloudpid agents file `cloudpid-report` issues there through the GitHub App (below). A one-way sync (GitHub → Linear) copies each new one into team SPI, carrying its GitHub URL as an attachment. Comments in its synced thread sync both ways (**Comment on an issue**), and closing it in Linear closes it on GitHub. The sync puts the copy in no project; the workflow `linear project` (`.github/workflows/linear-project.yml`, `scripts/linear-project.sh`) moves it into cloudpid once it appears. Dispatch that workflow with an issue number to retry one whose run failed (an issue opened before the sync existed has no copy to move). Work every issue in Linear and refer to it by its Linear identifier (`SPI-123`). Never create issues with `gh issue create`. A GitHub number (`#745`) resolves to the Linear issue that carries its URL (**Read an issue** below); issues and PRs share GitHub's numbers, so when none carries it, try `gh pr view <n>`.

Issues labelled `cloudpid-report` were filed by a cloudpid agent through the GitHub App about cloudpid itself (the `cloudpid_report` tool, ADR-0051); their footer, after the last `---`, names the stage, version, model and Session they came from. Triage their Linear copies like any `needs-triage` issue (`docs/agents/triage-labels.md`).

- **Create an issue**: `gh issue create --title "..." --body "..."`. Use a heredoc for multi-line bodies.
- **Read an issue**: `gh issue view <number> --json number,title,body,labels,comments`.
- **List issues**: `gh issue list --state open --json number,title,body,labels,comments --jq '[.[] | {number, title, body, labels: [.labels[].name], comments: [.comments[].body]}]'` with appropriate `--label` and `--state` filters.
- **Make an issue a sub-issue of a parent**: `gh issue create --parent <parent> ...`, or `gh issue edit <parent> --add-sub-issue <child>` afterwards (`gh` 2.94+). Older `gh`: `gh api --method POST repos/<owner>/<repo>/issues/<parent>/sub_issues -F sub_issue_id=<child-db-id>` (database id, as in **Blocking** below). Without sub-issues, put `Part of #<parent>` at the top of the child body.
- **Comment on an issue**: `gh issue comment <number> --body "..."`
- **Apply / remove labels**: `gh issue edit <number> --add-label "..."` / `--remove-label "..."`
- **Close**: `gh issue close <number> --comment "..."`
## How to reach Linear

Issues labelled `cloudpid-report` were filed by a cloudpid agent through the GitHub App about cloudpid itself (the `cloudpid_report` tool, ADR-0051); their footer, after the last `---`, names the stage, version, model and Session they came from. Triage them like any `needs-triage` issue (`docs/agents/triage-labels.md`).
1. **Executor (default).** Executor's Linear app, through its MCP tools `call` / `execute`, under `tools.linear.*` with account `acc_0c3d8286-c8a7-491e-ae0c-e433e1367c79`.
- Every tool takes `{ accountId, input: { arguments: { … }, select: "<GraphQL selection>" } }`.
- Issue ids accept identifiers (`SPI-123`).
- A call may pause for the user's approval. Show the request and resume it; never repeat the call.
2. **The `linear` CLI (fallback)**, `@schpet/linear-cli`, installed globally through mise and signed in with `linear auth login`. Use it when:
- Executor's tools aren't available in the session;
- its Linear app fails;
- the user declines an approval.

Infer the repo from `git remote -v`; `gh` does this automatically when run inside a clone.
With the CLI:
- Pass `--team SPI` to every query (no repo default is configured).
- Pass `--no-input` to writes.
- Pass markdown through `--description-file` / `--body-file`.
- Use `linear api` (GraphQL on stdin) for anything without a dedicated command.

## Pull requests as a triage surface
Both reach the same API, so the GraphQL selections below work in either.

**PRs as a request surface: no.** _(Set to `yes` if this repo treats external PRs as feature requests; `/triage` reads this flag.)_
| Thing | Value |
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Team `SPI` | `86ad747a-262a-4ab8-a716-3a7b3cd5a6af` |
| Project `cloudpid` | `c5b4ec54-51b3-4742-88a1-0a75c7c646a8` |
| States (name: type) | Backlog: backlog `0b2345bf-8a16-4fd5-8150-ab08e0d74a26` · Todo: unstarted `ad8f9e8b-f89d-4623-8571-68607c315057` · Blocked: unstarted `e417759b-5003-44fd-bfdd-fc6159afb145` · In Progress: started `a52ed858-c23e-4098-8fd1-a313fb0f2204` · In Review: started `62d08185-be69-4fe5-873a-1710a52fb9aa` · Done: completed `d8e326e4-cff3-4cca-a760-5b19efccf264` · Canceled: canceled `9bff72eb-c2dc-4f1a-a6a5-c7522d98fc15` |

When set to `yes`, PRs run through the same labels and states as issues, using the `gh pr` equivalents:
Label ids are in `docs/agents/triage-labels.md`. For any other label: `tools.linear.query_issueLabels` with `filter: { name: { eq: "<name>" }, team: { key: { eq: "SPI" } } }`, or `linear label list --team SPI --json`.

- **Read a PR**: `gh pr view <number> --comments` and `gh pr diff <number>` for the diff.
- **List external PRs for triage**: `gh api --paginate 'repos/{owner}/{repo}/pulls?state=open' --jq '.[] | select(.author_association | IN("OWNER","MEMBER","COLLABORATOR") | not) | {number, title, author: .user.login, author_association, labels: [.labels[].name]}'`.
- **Comment / label / close**: `gh pr comment`, `gh pr edit --add-label`/`--remove-label`, `gh pr close`.
## Conventions

GitHub shares one number space across issues and PRs, so a bare `#42` may be either: resolve with `gh pr view 42` and fall back to `gh issue view 42`.
- **Create an issue**:
- Executor: `tools.linear.mutation_issueCreate`, `arguments: { input: { teamId, projectId, title, description, labelIds?, parentId? } }`, `select: "identifier url"`.
- CLI: `linear issue create --team SPI --project cloudpid --title "…" --description-file body.md [--label <name>] [--parent SPI-1] --no-input`.
- **Read an issue**:
- Executor: `tools.linear.query_issue`, `arguments: { id: "SPI-123" }`, `select: "identifier title description url state { name type } labels { nodes { name } } assignee { name } parent { identifier } children { nodes { identifier title state { type } } } comments { nodes { body createdAt user { name } } } attachments { nodes { url } }"`.
- CLI: `linear issue view SPI-123 --json`.
- From a GitHub number: Executor `tools.linear.query_attachmentsForURL`, `arguments: { url: "https://github.com/ian-pascoe/cloudpid/issues/<n>" }`, `select: "nodes { sourceType issue { identifier } }"`; take the node whose `sourceType` is `github` (the sync's; another issue may link the same URL by hand). The CLI uses the same query through `linear api`.
- **List issues**:
- Executor: `tools.linear.query_issues`, `arguments: { first: 100, filter: { project: { id: { eq: "<cloudpid>" } }, state: { type: { nin: ["completed", "canceled"] } }, labels: { name: { eq: "<label>" } } } }`, `select: "nodes { identifier title state { name } labels { nodes { name } } assignee { name } }"`.
- CLI: `linear issue query --team SPI --project cloudpid [--label <name>] [--state backlog --state unstarted --state started] --json`.
- **Make an issue a sub-issue of a parent**:
- Executor: `parentId: "SPI-1"` at creation, or `tools.linear.mutation_issueUpdate` with `arguments: { id, input: { parentId } }`.
- CLI: `--parent SPI-1` on `issue create` / `issue update`.
- **Comment on an issue**:
- Executor: `tools.linear.mutation_commentCreate`, `arguments: { input: { issueId: "SPI-123", body } }`.
- CLI: `linear issue comment add SPI-123 --body-file comment.md`.
- A plain comment stays in Linear. To reach the reporter of a synced GitHub issue (a `needs-info` question, a won't-fix reason), reply in its **synced thread**: the root comment whose body starts "This comment thread is synced to a corresponding GitHub issue" (read the issue's `comments { nodes { id body parent { id } } }`, or `linear issue comment list SPI-123 --json`). Executor: add `parentId: "<root comment id>"`; CLI: `--reply-to <root comment id>`. A reply there shows on GitHub.
- **Apply / remove labels**:
- Executor: `tools.linear.mutation_issueAddLabel` / `mutation_issueRemoveLabel`, `arguments: { id: "SPI-123", labelId }`.
- CLI: `linear issue update SPI-123 --add-label <name>` / `--remove-label <name>`.
- **Close**:
- Comment first, then set the state to Done, or Canceled for won't-do: Executor `tools.linear.mutation_issueUpdate`, `arguments: { id, input: { stateId } }`; CLI `linear issue update SPI-123 --state Done`.
- A duplicate is a relation: `mutation_issueRelationCreate` with `type: "duplicate"`, or `linear issue relation add SPI-2 duplicate SPI-1`.
- Closing a synced issue closes its GitHub copy.

## Pull requests

Code and PRs stay on GitHub (`gh`, the `code-host` skill). To close an issue from a PR, put `Fixes SPI-123` in the PR body (Linear's magic words). Linear's GitHub integration links the PR and moves the issue to Done when it merges. Use the Linear identifier for synced GitHub issues too: closing in Linear closes the GitHub copy.

**PRs as a request surface: no.** _(Set to `yes` if this repo treats external PRs as feature requests; `/triage` reads this flag.)_ When `yes`, read and act on external PRs with `gh` (`gh pr view <n> --comments`, `gh pr diff <n>`, `gh pr comment`, `gh pr edit --add-label`, `gh pr close`), and list them with `gh api --paginate 'repos/{owner}/{repo}/pulls?state=open' --jq '.[] | select(.author_association | IN("OWNER","MEMBER","COLLABORATOR") | not) | {number, title, author: .user.login, labels: [.labels[].name]}'`.

## When a skill says "publish to the issue tracker"

Create a GitHub issue.
Create a Linear issue in team SPI, project cloudpid.

## When a skill says "fetch the relevant ticket"

Read it as in **Read an issue** above.
Read it as in **Read an issue** above; a GitHub number resolves through its URL.

## Wayfinding operations

Used by `/wayfinder`. The **map** is a single issue with **child** issues as tickets.

- **Map**: a single issue labelled `wayfinder:map`, holding the Notes / Decisions-so-far / Fog body. `gh issue create --label wayfinder:map`.
- **Child ticket**: an issue linked to the map as a GitHub sub-issue (see **Make an issue a sub-issue of a parent**). Where sub-issues aren't enabled, add the child to a task list in the map body and put `Part of #<map>` at the top of the child body. Labels: `wayfinder:<type>` (`research`/`prototype`/`grilling`/`task`). Once claimed, the ticket is assigned to the driving dev.
- **Blocking**: GitHub's **native issue dependencies**, the canonical, UI-visible representation. Add an edge with `gh api --method POST repos/<owner>/<repo>/issues/<child>/dependencies/blocked_by -F issue_id=<blocker-db-id>`, where `<blocker-db-id>` is the blocker's numeric **database id** (`gh api repos/<owner>/<repo>/issues/<n> --jq .id`, _not_ the `#number` or `node_id`). GitHub reports `issue_dependencies_summary.blocked_by` (open blockers only, the live gate). Where dependencies aren't available, fall back to a `Blocked by: #<n>, #<n>` line at the top of the child body. A ticket is unblocked when every blocker is closed.
- **Frontier query**: list the map's open children (`gh issue list --state open`, scoped to the map's sub-issues / task list), drop any with an open blocker (`issue_dependencies_summary.blocked_by > 0`, or an open issue in the `Blocked by` line) or an assignee; first in map order wins.
- **Claim**: `gh issue edit <n> --add-assignee @me`, the session's first write.
- **Resolve**: `gh issue comment <n> --body "<answer>"`, then `gh issue close <n>`, then append a context pointer (gist + link) to the map's Decisions-so-far.
- **Map**: an issue labelled `wayfinder:map` in project cloudpid, holding the Notes / Decisions-so-far / Fog body.
- **Child ticket**: a sub-issue of the map (**Make an issue a sub-issue of a parent**), labelled `wayfinder:<type>` (`research` / `prototype` / `grilling` / `task`). Once claimed, it is assigned to the driving dev.
- **Blocking**: Linear's `blocks` relation, from the blocker to the blocked ticket.
- Executor: `tools.linear.mutation_issueRelationCreate`, `arguments: { input: { type: "blocks", issueId: "<blocker>", relatedIssueId: "<child>" } }`.
- CLI: `linear issue relation add <child> blocked-by <blocker>`.
- A ticket is unblocked when every blocker is completed or canceled.
- **Frontier query**: one query lists every child with its state, labels, assignee and blockers.
- Executor: `tools.linear.query_issue` with `arguments: { id: "<map>" }` and the block below as its `select`.
- CLI: `linear api --variable id=<map>` with `query($id: String!) { issue(id: $id) { … } }` on stdin, the block below in place of `…`.

```graphql
children(first: 250) { nodes { identifier title state { type } labels { nodes { name } } assignee { name }
inverseRelations { nodes { type issue { identifier state { type } } } } } }
```

A child's `blocks` entries in `inverseRelations` are its blockers. Keep the children whose state type is not `completed` / `canceled`, that have no assignee, and whose blockers are all `completed` / `canceled`; the first one wins.

- **Claim**, the session's first write:
- Executor: `tools.linear.mutation_issueUpdate`, `arguments: { id, input: { assigneeId: "<viewer id>" } }` (`tools.linear.query_viewer`, `select: "id"`).
- CLI: `linear issue update <n> --assignee @me`.
- **Resolve**: comment the answer, set the state to Done (**Close**), then append a context pointer (gist + link) to the map's Decisions-so-far. Read the map's description first: `issueUpdate`'s `description` replaces it whole, and the CLI's `--description-file` does the same.
Loading
Loading