ferryGL
Ferry your GitHub work into your own Linear workspace, from repositories you can read but don't administer.
Status: early version (0.1).
ferrygl checkandferrygl syncwork, and are tested with local fixtures and against a real GitHub repository.ferryGL is an independent project. It is not affiliated with or endorsed by Linear or GitHub.
Who it's for
You write code in someone else's GitHub organization (a client, a partner, an upstream project), but your team plans and reports work in its own Linear workspace.
Linear's GitHub integration doesn't cover this case:
- It must be installed by an owner, or a repository admin, of the GitHub organization.
- A GitHub organization can connect to only one Linear workspace. If the organization uses Linear itself, your workspace can't connect to it.
So you copy issues and pull requests into Linear by hand and move their status as they progress. ferryGL does that for you with your own credentials. It never writes to GitHub.
What it does
On each run, ferryGL finds every issue and pull request your GitHub account is part of in the repositories you list, and makes sure each one has exactly one Linear issue, assigned to you, with the right status.
| Your role on GitHub | How ferryGL finds it | Default Linear title |
|---|---|---|
| You created an issue, or are assigned to it | is:issue author:<you>, is:issue assignee:<you> |
{title} #{number} |
| You opened a pull request | is:pr author:<you> |
{title} - #{number} |
| You're assigned to someone else's pull request | is:pr assignee:<you> |
[PR] {title} - #{number} |
| You reviewed it, or your review is requested | reviewed-by:<you>, user-review-requested:<you> |
[PR] {title} - #{number} |
| You wrote one of its commits | the commit authors of each pull request | [PR] {title} - #{number} |
An item you have several roles on still gets one Linear issue. In the config, someone else's
pull request you're part of is an involved item.
GitHub search can't find pull requests by commit author, so ferryGL reads every pull request
in the route updated since since and checks the authors, including co-authors, of its first
100 commits. That's the slowest part of a run. Set since, or turn it off with
prs_with_commits = false.
A pull request you opened that refers to one of your synced issues, through a closing keyword
such as Fixes #252 or a plain #252 in its title or description, is linked to that
issue's Linear issue instead of getting its own. Someone else's pull request always gets its
own Linear issue.
For example, you're assigned acme/api#252 and open acme/api#335, whose description says
"Part of #252":
- ferryGL creates one Linear issue, "Store shared configuration once #252", links both GitHub URLs to it and sets it to In Review.
- #335 merges while #252 stays open. The Linear issue stays In Review, because the work isn't done until the GitHub issue is closed.
- #252 is closed as completed. The Linear issue moves to Done.
ferryGL only changes Linear issues assigned to you. If a teammate's Linear issue links the same pull request, it's left alone.
Status rules
Linear state names are configurable. These are the defaults:
| GitHub state | Linear status |
|---|---|
| Issue open, no linked pull request open | Todo |
| Issue open, a linked pull request open | In Review (In Progress if every open one is a draft) |
| Issue open, linked pull requests merged | Unchanged |
| Issue closed as completed | Done |
| Issue closed as not planned or duplicate | Canceled |
| Your pull request is a draft | In Progress |
| Your pull request is open | In Review |
| Pull request merged | Done |
| Pull request closed without merging | Canceled |
| Someone else's: your review is requested, including again after you reviewed | Todo |
| Someone else's: you reviewed, no new request, still open | In Review |
| Someone else's, assigned to you or with your commits: draft | In Progress |
| Someone else's, assigned to you or with your commits: open | In Review |
Your edits win. ferryGL records the GitHub state it last acted on in the link's metadata on the Linear issue, and changes the Linear status only when that GitHub state changes. If you move an issue by hand, ferryGL leaves it alone until something happens on GitHub.
Nothing is deleted. If an item leaves your scope, for example you're unassigned or a review request is withdrawn, its Linear issue stays as it is.
Linear issues you already made by hand
Before creating a Linear issue, ferryGL looks for one of yours in the route's team that already mentions the item:
- its GitHub URL is in the description or links, or
- its title ends with
#<number>, when the route has a single repository and the description mentions no other GitHub item.
A Linear issue already linked to a different GitHub item is about that item, and never matches. Two more rules cover hand-made issues for pull requests:
- If nothing mentions a pull request you opened, a Linear issue that mentions an issue it refers to counts, unless that Linear issue is linked to a pull request.
- If an issue assigned to you has no Linear issue but its pull request already has one, the issue joins that Linear issue.
When exactly one issue matches, ferryGL links it instead of creating a new one (adopt in
the plan). It doesn't change that issue's status unless GitHub says the work is done or
canceled. When several match, ferryGL skips the item and lists them, so you can link the
right one by hand.
What data leaves GitHub
Private repositories often belong to someone else, and their contents may be covered by an agreement. By default ferryGL copies as little as possible:
- Copied: title, number, repository, URL, state and your role (assignee, author or reviewer).
- Not copied unless you turn it on: descriptions. Comments, labels and other people's names are never copied.
- Stricter: title templates such as
"{repo}#{number}"keep titles out of Linear too.
Check that copying titles into your Linear workspace is acceptable for each repository you configure.
Setup
ferryGL needs Python 3.11 or later. Install the ferrygl command with any of:
$ pipx install ferrygl
$ uv tool install ferrygl
$ pip install ferrygl
To try unreleased changes, install from the repository instead, for example
uv tool install git+https://github.com/ao-batouche/ferryGL.
1. A GitHub token that can read the repositories. ferryGL only reads issues and pull requests. For each route it uses, in order:
- the GitHub CLI account named by the route's
github_user(gh auth token --user <name>), - otherwise
GH_TOKENorGITHUB_TOKEN, - otherwise the GitHub CLI's active account.
github_user is for people with more than one GitHub account, such as a work account for a
client's organization and a personal one for open source. A fine-grained token needs read
access to Issues, Pull requests and Metadata. Some organizations require owner approval for
fine-grained tokens, or block classic ones.
2. A Linear API key. Create a personal API key in
Linear's API settings with read and write access. You can
limit it to the teams ferryGL writes to. Put it in LINEAR_API_KEY, or in a file named by
[linear] api_key_file.
3. A config file. ferryGL reads ~/.config/ferrygl/ferrygl.toml, or the file given by
--config or FERRYGL_CONFIG. Each [[route]] sends some repositories to a Linear team and,
optionally, a project:
[[route]]
repos = ["acme/api", "acme/web"]
team = "ENG" # Linear team key or name
project = "Acme engagement" # optional
github_user = "alice-acme" # optional: read GitHub as this gh account
[[route]]
repos = ["upstream-org/*"] # every repository of a user or organization
team = "ENG"
project = "Upstream contributions"
github_user = "alice"
[sync]
since = 2026-09-01 # ignore items not updated since this date
label = "ferryGL" # optional: an existing label added to every issue ferryGL creates
# Roles, all true by default: issues_assigned, issues_created, prs_created, prs_assigned,
# prs_reviewed, review_requests, prs_with_commits
[titles] # use "{repo}#{number}" to keep titles out of Linear
issue = "{title} #{number}"
pr = "{title} - #{number}"
involved = "[PR] {title} - #{number}"
[copy]
description = false
[status] # your team's Linear workflow state names
todo = "Todo"
in_progress = "In Progress"
in_review = "In Review"
done = "Done"
canceled = "Canceled"
[linear]
api_key_file = "~/.config/ferrygl/linear-api-key"
4. Check, preview, then apply:
$ ferrygl check
$ ferrygl sync
$ ferrygl sync --apply
check confirms that both credentials work, every repository is readable, and the Linear
teams, projects, states and label exist. sync on its own only prints the plan, for
example:
acme/api#252 issue update ENG-7 Todo -> In Review
acme/api#335 pr link -> ENG-7
acme/api#348 involved adopt -> ENG-9
3 changes (1 adopt, 1 link, 1 update). Run with --apply to make them.
sync --apply makes those changes and prints the identifier of each Linear issue it
creates. Run the preview first: the first sync is the one most likely to find Linear issues
you made by hand, and it records the current GitHub state on each of them (record in the
plan).
Running on a schedule
-
cron on a machine you use:
0 9,15 * * 1-5 ferrygl sync --apply >> ~/.cache/ferrygl.log 2>&1
With
[linear] api_key_fileandgithub_userset, cron needs no extra environment. -
GitHub Actions in a repository on your own account: a scheduled workflow with your GitHub token and Linear key stored as secrets. The workflow's built-in
GITHUB_TOKENcan't read other organizations' repositories, so it needs your own token. A template workflow is planned.
When something goes wrong
- If a configured Linear team, project, state or label doesn't exist,
syncstops before reading GitHub or writing anything, and exits with status 2.ferrygl checkshows what's missing. - If a route's repositories can't be read, ferryGL reports it, skips that route, continues with the others, and exits with status 1.
- If a write fails, ferryGL reports it and continues. A run that fails halfway is safe to repeat: ferryGL finds Linear issues by their GitHub links, so a rerun doesn't create duplicates.
- Creating a Linear issue is never retried after a network error, because Linear may have created it anyway. If it did, the next sync adopts it through the GitHub URL in its description.
How it works
- No database. Every Linear issue ferryGL manages has a link with the GitHub URL.
ferryGL finds it with Linear's
attachmentsForURLquery, and keeps the last GitHub state it acted on in the link'smetadata, as flatferrygl_*keys such asferrygl_state. Linear holds all the state. - Few requests. GitHub reads use the GraphQL search API: up to four searches per route, plus one request per 50 results.
- Testable core. Planning (
ferrygl.plan) is a pure function from GitHub items, existing Linear issues and config to a list of changes. It's tested without network access. - Small footprint. One runtime dependency,
httpx.
Development
$ uv sync
$ uv run pytest
$ uv run ruff check && uv run ruff format --check
Before changing ferryGL, read AGENTS.md: it lists the invariants, the data ferryGL leaves in users' Linear workspaces, and the testing and review rules for contributors and coding agents.
Not planned
- Writing anything to GitHub: comments, labels or status.
- Two-way sync, or keeping descriptions and comments in sync.
- Webhooks or a hosted service. ferryGL polls, so changes arrive on your schedule, not instantly.
- Syncing every issue in a repository for a whole team. Linear's GitHub integration does that when you can install it.
Open questions
- Should commenting on an item, or being mentioned in it, count as a role? Today they don't, because they'd pull in many items you only touched in passing.
License
Metadata
Release files for ferrygl 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| ferrygl-0.1.0.tar.gz | 47.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| ferrygl-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 77.9 kB
Release files / ferrygl-0.1.0.tar.gz
| Download URL | ferrygl-0.1.0.tar.gz |
|---|---|
| Size | 47.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
f48c8ed2fc6086f2b14eb53db6c616c044d003a5b50dc7b2e5b6c2f11d5a6203
|
|
BLAKE2b-256 checksum How to use checksums |
727369aea4498fa76912a3eb4e08c3b6c03a26188efebe9167e7dc7cbe012fc6
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 16, 2026.
Transparency logRelease files / ferrygl-0.1.0-py3-none-any.whl
| Download URL | ferrygl-0.1.0-py3-none-any.whl |
|---|---|
| Size | 30.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
fe03dc732633c97eb17cb94abf18d386b6e9092d22b655a84e98d6ac5b6b831f
|
|
BLAKE2b-256 checksum How to use checksums |
4e541a19d86ee341715164171b26aa3a5af46cd2752b65a129aa8fd532f04e90
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 16, 2026.
Transparency log