# Linear

> Keep a Linear team's issues in a memory of their own, so an assistant knows what was decided and why. Each issue is one source with its comments; after the first run only the issues updated since are read, and an issue in the trash is deleted from memory.

Keep a Linear team's issues in a memory of their own, so a coding assistant or a teammate's assistant knows
what was decided and why. This sync adds each issue as one source, with its description, its state and each
person's comment, under the issue's id and the label `channel: linear`, in a space for the team. The first
run reads every issue; after that, only the issues updated since the last run are read. An archived issue
stays, since it is still history; an issue moved to the trash is deleted from memory.

## Install

```bash
pip install httpx geniffy
```

```bash
uv add httpx geniffy
```

Set `GENIFFY_API_KEY` from **API keys** in the Geniffy app. In Linear, make a personal API key in your
settings, or use your OAuth app's access token with `Bearer ` in front.

## The sync

```python
import httpx
from geniffy import Geniffy, NotFoundError

geniffy = Geniffy()                               # reads GENIFFY_API_KEY
LABELS = {"channel": "linear"}
ISSUES = """
query Issues($filter: IssueFilter, $after: String) {
  issues(first: 50, after: $after, filter: $filter, orderBy: updatedAt, includeArchived: true) {
    nodes {
      id identifier title description updatedAt trashed
      state { name }
      comments(first: 100) { nodes { body createdAt user { name } } }
    }
    pageInfo { hasNextPage endCursor }
  }
}
"""


def linear(api_key: str) -> httpx.Client:
    return httpx.Client(base_url="https://api.linear.app", timeout=30, headers={"Authorization": api_key})


def space_for(team: str) -> str:
    return "linear_" + team.lower()


def thread(issue: dict) -> str:
    """An issue as one note: its state and description, then each person's comment with who wrote it and when."""
    parts = [f"{issue['identifier']}, {issue['state']['name']}.", issue.get("description") or issue["title"]]
    for comment in issue["comments"]["nodes"]:
        if comment["user"] and comment["body"].strip():    # people, not bots or integrations
            parts.append(f"From {comment['user']['name']}, {comment['createdAt'][:10]}:\n{comment['body']}")
    return "\n\n".join(parts)


def sync(team: str, api_key: str, since: str | None = None) -> str | None:
    """Bring a Linear team's issues into its own space; team is its key, such as ENG. Returns the time to pass
    next time: pass None the first time and every issue is read; after that, only issues updated since."""
    mem, newest, after, seen = geniffy.space(space_for(team)), since, None, set()
    where = {"team": {"key": {"eq": team}}}
    if since:
        where["updatedAt"] = {"gt": since}
    with linear(api_key) as api:
        while True:
            out = api.post("/graphql", json={"query": ISSUES, "variables": {"filter": where, "after": after}})
            body = out.raise_for_status().json()
            if body.get("errors"):
                raise RuntimeError(body["errors"][0]["message"])
            page = body["data"]["issues"]
            for issue in page["nodes"]:
                newest, ref = max(newest or "", issue["updatedAt"]), f"linear:{issue['id']}"
                if issue["trashed"]:
                    try:
                        mem.sources.delete(external_id=ref)
                    except NotFoundError:
                        pass
                    continue
                mem.memories.add(thread(issue), title=f"{issue['identifier']} {issue['title']}",
                                 said_at=issue["updatedAt"], external_id=ref, labels=LABELS)
                seen.add(ref)
            if not page["pageInfo"]["hasNextPage"]:
                break
            after = page["pageInfo"]["endCursor"]
    if since is None:                             # everything was read: what is no longer there goes
        mem.sources.delete_labelled(LABELS, keep=seen)
    return newest
```

Run it on a schedule, and keep the time each run returns for the next: `since = sync("ENG", api_key, since)`.
Now and then, pass `None` to read everything again: an issue moved to another team, or deleted for good,
goes from memory then.

## Recall from it

```python
known = geniffy.space(space_for("ENG")).context("Why did we move off Redis?")
```

Each issue is cited by its identifier and title, such as ENG-142, so an answer can point to it.

## How it behaves

- **Each issue is one source,** under Linear's id for it, so it stays one source if it is renamed or moved.
  A new comment sends the issue again, and only what it adds is learned. See
  [Your own ids](https://docs.geniffy.com/add-memories/your-own-ids).
- **Only what was updated is read.** After the first run, the filter on `updatedAt` names the issues that
  changed.
- **People, not bots.** Comments from integrations and bots stay out. Each issue brings its first hundred
  comments.
- **Archived stays, trashed goes.** Linear archives finished work, which is still history; an issue in the
  trash is deleted from memory with what it taught.
- **One space for each team,** so two teams never mix. To forget one, erase its space:
  `geniffy.forget_space(space_for("ENG"))`.

Source: https://docs.geniffy.com/integrations/linear
