GeniffyDocs
Changelog Log In Get a key

Linear

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

Terminal
pip install 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

linear_sync.py
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

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.
  • 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")).
Last updated October 6, 2026