ferrygl 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,12 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .pytest_cache/
8
+ .ruff_cache/
9
+
10
+ # Personal configuration and credentials
11
+ /ferrygl.toml
12
+ *.key
@@ -0,0 +1,164 @@
1
+ # Working on ferryGL
2
+
3
+ These rules are for anyone changing ferryGL, including coding agents. Read the
4
+ [README](README.md) first: it is the user-facing contract for behavior, configuration and
5
+ limits. This file adds the invariants, layout and workflow that keep changes safe.
6
+
7
+ ferryGL copies every issue and pull request a person's GitHub account is part of (created,
8
+ assigned, reviewed, review requested, or with their commits) one way into their own Linear
9
+ workspace, using only their own credentials. Its users usually work in repositories owned by someone else, often private,
10
+ so the safety rules below matter more than features.
11
+
12
+ ## Invariants
13
+
14
+ Don't break these without an explicit decision from the project owner.
15
+
16
+ 1. **GitHub is read-only.** `src/ferrygl/github.py` only runs GraphQL queries. Never add a
17
+ mutation, comment, label or status write to GitHub.
18
+ 2. **Only the user's Linear issues.** ferryGL changes a Linear issue only if it is assigned
19
+ to the API key's owner (`viewer_id`). It never deletes issues or links, and never edits
20
+ titles or descriptions after creating an issue.
21
+ 3. **The user's edits win.** A Linear status moves only when the GitHub state recorded on the
22
+ link changes. A link with no recorded state is a first sync, which may close an issue
23
+ (done or canceled) but never reopens or otherwise moves it.
24
+ 4. **One home per item, one status source per Linear issue.** Each GitHub URL maps to at most
25
+ one of the user's Linear issues. A pull request that rides with an issue doesn't set that
26
+ Linear issue's status on its own.
27
+ 5. **Reruns are idempotent.** Existing Linear issues are found by their GitHub links
28
+ (`attachmentsForURL`), so a rerun never duplicates. Only idempotent requests are retried
29
+ after network errors or 5xx responses; `issueCreate` is not.
30
+ 6. **The planner is pure.** `src/ferrygl/plan.py` does no I/O. All reads happen before
31
+ planning, and all writes happen after it, in `src/ferrygl/sync.py`.
32
+ 7. **Copy as little as possible.** By default only titles, numbers, repositories, URLs, states
33
+ and the user's role reach Linear. Descriptions are copied only when `[copy] description`
34
+ is on. Never copy comments or other people's names.
35
+ 8. **No surprise writes.** `ferrygl sync` without `--apply` must write nothing. A setup
36
+ problem (missing team, project, state or label) stops the run before GitHub is read or
37
+ Linear is written.
38
+
39
+ ## Retained data and public contracts
40
+
41
+ Users' Linear workspaces keep what ferryGL writes, so treat these as compatibility contracts:
42
+
43
+ - **Link metadata** uses flat keys with string or number values, because Linear documents
44
+ attachment metadata that way:
45
+ - `ferrygl_v`: format version, currently `1`
46
+ - `ferrygl_kind`: `issue`, `pr` (one you opened) or `involved` (someone else's)
47
+ - `ferrygl_state`: the GitHub state key ferryGL last acted on
48
+ - `ferrygl_synced_at`: ISO timestamp of that state
49
+ - `ferrygl_linked_to`: on links of pull requests that ride with an issue, such as
50
+ `owner/repo#12`
51
+
52
+ ferryGL replaces only its own `ferrygl_*` keys and keeps any others.
53
+ - **State keys** (`github_state` in `plan.py`): `issue:open`, `issue:open:pr_draft`,
54
+ `issue:open:pr_open`, `issue:open:pr_merged`, `issue:closed:completed`,
55
+ `issue:closed:not_planned`, `pr:draft`, `pr:open`, `pr:merged`, `pr:closed`,
56
+ `involved:requested`, `involved:reviewed`, `involved:draft`, `involved:open`,
57
+ `involved:merged`, `involved:closed`. Renaming one makes every recorded link look changed.
58
+ Add a key, or bump `ferrygl_v` and handle the old value.
59
+ - **Config keys** (`config.py`): unknown keys are rejected, so adding a key is safe but
60
+ renaming or removing one breaks existing files. Keep the old name working, with a clear
61
+ error or warning, for at least one release.
62
+ - **CLI:** commands `check` and `sync [--apply]`, `--config`, `FERRYGL_CONFIG`,
63
+ `LINEAR_API_KEY`, and exit statuses 0 (ok), 1 (a route or write failed) and 2 (setup or
64
+ config error).
65
+
66
+ ## Layout
67
+
68
+ | Path | Responsibility |
69
+ |---|---|
70
+ | `src/ferrygl/config.py` | Parse and validate `ferrygl.toml` into frozen dataclasses |
71
+ | `src/ferrygl/github.py` | Resolve tokens, run one GitHub search per role, scan commit authors, parse items |
72
+ | `src/ferrygl/linear.py` | Linear GraphQL reads and writes with a personal API key |
73
+ | `src/ferrygl/plan.py` | Pure planning: GitHub items + existing Linear state -> changes |
74
+ | `src/ferrygl/sync.py` | `check` and `sync`: resolve routes, plan, print, apply |
75
+ | `src/ferrygl/model.py` | Shared types: `Kind`, `Role`, `GitHubItem`, `LinearIssue`, `Attachment`, `Change` |
76
+ | `src/ferrygl/_http.py` | JSON POST with retries for both clients |
77
+ | `tests/builders.py` | Builders for synthetic items, Linear issues and links |
78
+
79
+ ## Development
80
+
81
+ ```console
82
+ $ uv sync
83
+ $ uv run pytest
84
+ $ uv run ruff check && uv run ruff format --check
85
+ ```
86
+
87
+ - Python 3.11 is the oldest supported version, and CI also runs 3.14. Don't use syntax newer
88
+ than 3.11.
89
+ - The only runtime dependency is `httpx`. Discuss any new dependency with the project owner
90
+ before adding it.
91
+ - Match the existing style: small modules, frozen dataclasses, type hints, comments that
92
+ explain why, and error messages that tell the user what to do next.
93
+
94
+ ## Testing
95
+
96
+ - Every change to planning behavior needs a test in `tests/test_plan.py` that states the
97
+ scenario in plain terms: what's on GitHub, what's in Linear, and the expected changes.
98
+ - Client changes are tested with `httpx.MockTransport`; end-to-end behavior uses the fakes in
99
+ `tests/test_sync.py`.
100
+ - **Use synthetic data only** (`acme/api`, `ENG-1`). Never put real repository names, issue
101
+ titles, Linear identifiers, tokens or API responses from a user's workspace in tests,
102
+ fixtures, docs, commits or PR descriptions. Users' repositories are often private and
103
+ belong to their clients.
104
+ - Check new or changed Linear queries against Linear's published schema
105
+ (`packages/sdk/src/schema.graphql` in `linear/linear` on GitHub) for field names, argument
106
+ names and types such as `String!` vs `ID!`.
107
+ - Live checks: run read-only commands (`ferrygl check`, `ferrygl sync` without `--apply`)
108
+ against a real workspace only with its owner's credentials and permission. Never run
109
+ `--apply` against a workspace unless its owner asks for that specific run.
110
+
111
+ ## Documentation
112
+
113
+ The README is part of every behavior change. Update it in the same change when you alter the
114
+ status rules, adoption rules, config keys, defaults, exit statuses, data copied or supported
115
+ limits. Describe current behavior only: history belongs in Git, and progress notes belong in
116
+ issues and pull requests. Mark planned features as planned.
117
+
118
+ ## Git and GitHub
119
+
120
+ - Work on a branch and open a pull request against `main`. Keep each pull request to one
121
+ concern, and say in its description what changed for users and how it was tested.
122
+ - Never push to `main` directly. GitHub can't enforce this while the repository is private on
123
+ a free plan, so a local `pre-push` hook may block it; don't bypass it with `--no-verify`.
124
+ - This checkout may have a repo-local git identity and credential helper. Don't change them.
125
+ When the machine has several GitHub accounts, run `gh` as the repository owner's account,
126
+ for example `GH_TOKEN=$(gh auth token --user <owner>) gh ...`.
127
+ - Don't add AI or tool attribution to commits, pull requests or comments: no `Co-Authored-By`
128
+ trailers or "generated with" footers. Work is published under the owner's name.
129
+ - These need the project owner's explicit approval each time: merging to `main`, making the
130
+ repository public, publishing a release or a PyPI package, and changing the license.
131
+
132
+ ## Releasing
133
+
134
+ Releases need the project owner's approval each time.
135
+
136
+ 1. In a pull request, set `__version__` in `src/ferrygl/__init__.py` (the only place the
137
+ version lives) and update the README for anything users will notice. Merge it.
138
+ 2. Tag the merge commit on `main` with `v<version>`, such as `v0.2.0`, and push the tag.
139
+ 3. `.github/workflows/release.yml` tests and builds the package, checks that the tag matches
140
+ the version, publishes to PyPI through trusted publishing (no token is stored), and
141
+ creates a GitHub release with the built files.
142
+
143
+ PyPI never accepts the same version twice. If a release is broken, publish a new patch
144
+ version rather than trying to replace it.
145
+
146
+ ## Working agreement
147
+
148
+ - Settle routine implementation choices yourself, within these rules.
149
+ - Bring consequential decisions (a contract change, a new dependency, anything that changes
150
+ what reaches a user's Linear workspace) to the owner with a recommendation, the tradeoff and
151
+ the evidence.
152
+ - When handing work back, say what changed, how it was tested, what wasn't tested, and the
153
+ next decision needed.
154
+
155
+ ## Current state
156
+
157
+ - `check` and `sync` are implemented and tested with fixtures. GitHub reads have been
158
+ exercised against a real repository. Reads and writes against a real Linear workspace
159
+ haven't been exercised yet, so do a read-only preview before the first release.
160
+ - Planned: a scheduled GitHub Actions template.
161
+ - Finding pull requests with the user's commits scans every pull request in scope, because
162
+ GitHub search has no commit-author qualifier for pull requests. Keep that scan bounded by
163
+ `since`, and watch its cost when changing it.
164
+ - Open question: whether comments or mentions should count as roles.
ferrygl-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Oussama Batouche
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
ferrygl-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,318 @@
1
+ Metadata-Version: 2.5
2
+ Name: ferrygl
3
+ Version: 0.1.0
4
+ Summary: Copy your GitHub work into your own Linear workspace, from repositories you can read but don't administer.
5
+ Project-URL: Homepage, https://github.com/ao-batouche/ferryGL
6
+ Project-URL: Repository, https://github.com/ao-batouche/ferryGL
7
+ Project-URL: Issues, https://github.com/ao-batouche/ferryGL/issues
8
+ Author: Oussama Batouche
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: github,issues,linear,pull-requests,sync
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Topic :: Software Development :: Bug Tracking
19
+ Requires-Python: >=3.11
20
+ Requires-Dist: httpx>=0.27
21
+ Description-Content-Type: text/markdown
22
+
23
+ <p align="center">
24
+ <img src="assets/ferrygl-logo-horizontal.png" alt="ferryGL" width="560">
25
+ </p>
26
+
27
+ # ferryGL
28
+
29
+ Ferry your GitHub work into your own Linear workspace, from repositories you can read but
30
+ don't administer.
31
+
32
+ > **Status: early version (0.1).** `ferrygl check` and `ferrygl sync` work, and are tested
33
+ > with local fixtures and against a real GitHub repository.
34
+ >
35
+ > ferryGL is an independent project. It is not affiliated with or endorsed by Linear or GitHub.
36
+
37
+ ## Who it's for
38
+
39
+ You write code in someone else's GitHub organization (a client, a partner, an upstream
40
+ project), but your team plans and reports work in its own Linear workspace.
41
+
42
+ Linear's GitHub integration doesn't cover this case:
43
+
44
+ - It must be installed by an owner, or a repository admin, of the GitHub organization.
45
+ - A GitHub organization can connect to only one Linear workspace. If the organization uses
46
+ Linear itself, your workspace can't connect to it.
47
+
48
+ So you copy issues and pull requests into Linear by hand and move their status as they
49
+ progress. ferryGL does that for you with your own credentials. It never writes to GitHub.
50
+
51
+ ## What it does
52
+
53
+ On each run, ferryGL finds every issue and pull request your GitHub account is part of in
54
+ the repositories you list, and makes sure each one has exactly one Linear issue, assigned to
55
+ you, with the right status.
56
+
57
+ | Your role on GitHub | How ferryGL finds it | Default Linear title |
58
+ |---|---|---|
59
+ | You created an issue, or are assigned to it | `is:issue author:<you>`, `is:issue assignee:<you>` | `{title} #{number}` |
60
+ | You opened a pull request | `is:pr author:<you>` | `{title} - #{number}` |
61
+ | You're assigned to someone else's pull request | `is:pr assignee:<you>` | `[PR] {title} - #{number}` |
62
+ | You reviewed it, or your review is requested | `reviewed-by:<you>`, `user-review-requested:<you>` | `[PR] {title} - #{number}` |
63
+ | You wrote one of its commits | the commit authors of each pull request | `[PR] {title} - #{number}` |
64
+
65
+ An item you have several roles on still gets one Linear issue. In the config, someone else's
66
+ pull request you're part of is an `involved` item.
67
+
68
+ GitHub search can't find pull requests by commit author, so ferryGL reads every pull request
69
+ in the route updated since `since` and checks the authors, including co-authors, of its first
70
+ 100 commits. That's the slowest part of a run. Set `since`, or turn it off with
71
+ `prs_with_commits = false`.
72
+
73
+ A pull request you opened that refers to one of your synced issues, through a closing keyword
74
+ such as `Fixes #252` or a plain `#252` in its title or description, is linked to that
75
+ issue's Linear issue instead of getting its own. Someone else's pull request always gets its
76
+ own Linear issue.
77
+
78
+ For example, you're assigned `acme/api#252` and open `acme/api#335`, whose description says
79
+ "Part of #252":
80
+
81
+ 1. ferryGL creates one Linear issue, "Store shared configuration once #252", links both
82
+ GitHub URLs to it and sets it to In Review.
83
+ 2. #335 merges while #252 stays open. The Linear issue stays In Review, because the work
84
+ isn't done until the GitHub issue is closed.
85
+ 3. #252 is closed as completed. The Linear issue moves to Done.
86
+
87
+ ferryGL only changes Linear issues assigned to you. If a teammate's Linear issue links the
88
+ same pull request, it's left alone.
89
+
90
+ ## Status rules
91
+
92
+ Linear state names are configurable. These are the defaults:
93
+
94
+ | GitHub state | Linear status |
95
+ |---|---|
96
+ | Issue open, no linked pull request open | Todo |
97
+ | Issue open, a linked pull request open | In Review (In Progress if every open one is a draft) |
98
+ | Issue open, linked pull requests merged | Unchanged |
99
+ | Issue closed as completed | Done |
100
+ | Issue closed as not planned or duplicate | Canceled |
101
+ | Your pull request is a draft | In Progress |
102
+ | Your pull request is open | In Review |
103
+ | Pull request merged | Done |
104
+ | Pull request closed without merging | Canceled |
105
+ | Someone else's: your review is requested, including again after you reviewed | Todo |
106
+ | Someone else's: you reviewed, no new request, still open | In Review |
107
+ | Someone else's, assigned to you or with your commits: draft | In Progress |
108
+ | Someone else's, assigned to you or with your commits: open | In Review |
109
+
110
+ **Your edits win.** ferryGL records the GitHub state it last acted on in the link's metadata
111
+ on the Linear issue, and changes the Linear status only when that GitHub state changes. If
112
+ you move an issue by hand, ferryGL leaves it alone until something happens on GitHub.
113
+
114
+ **Nothing is deleted.** If an item leaves your scope, for example you're unassigned or a
115
+ review request is withdrawn, its Linear issue stays as it is.
116
+
117
+ ## Linear issues you already made by hand
118
+
119
+ Before creating a Linear issue, ferryGL looks for one of yours in the route's team that
120
+ already mentions the item:
121
+
122
+ - its GitHub URL is in the description or links, or
123
+ - its title ends with `#<number>`, when the route has a single repository and the
124
+ description mentions no other GitHub item.
125
+
126
+ A Linear issue already linked to a different GitHub item is about that item, and never
127
+ matches. Two more rules cover hand-made issues for pull requests:
128
+
129
+ - If nothing mentions a pull request you opened, a Linear issue that mentions an issue it
130
+ refers to counts, unless that Linear issue is linked to a pull request.
131
+ - If an issue assigned to you has no Linear issue but its pull request already has one, the
132
+ issue joins that Linear issue.
133
+
134
+ When exactly one issue matches, ferryGL links it instead of creating a new one (`adopt` in
135
+ the plan). It doesn't change that issue's status unless GitHub says the work is done or
136
+ canceled. When several match, ferryGL skips the item and lists them, so you can link the
137
+ right one by hand.
138
+
139
+ ## What data leaves GitHub
140
+
141
+ Private repositories often belong to someone else, and their contents may be covered by an
142
+ agreement. By default ferryGL copies as little as possible:
143
+
144
+ - **Copied:** title, number, repository, URL, state and your role (assignee, author or
145
+ reviewer).
146
+ - **Not copied unless you turn it on:** descriptions. Comments, labels and other people's
147
+ names are never copied.
148
+ - **Stricter:** title templates such as `"{repo}#{number}"` keep titles out of Linear too.
149
+
150
+ Check that copying titles into your Linear workspace is acceptable for each repository you
151
+ configure.
152
+
153
+ ## Setup
154
+
155
+ ferryGL needs Python 3.11 or later. Install the `ferrygl` command with any of:
156
+
157
+ ```console
158
+ $ pipx install ferrygl
159
+ $ uv tool install ferrygl
160
+ $ pip install ferrygl
161
+ ```
162
+
163
+ To try unreleased changes, install from the repository instead, for example
164
+ `uv tool install git+https://github.com/ao-batouche/ferryGL`.
165
+
166
+ **1. A GitHub token that can read the repositories.** ferryGL only reads issues and pull
167
+ requests. For each route it uses, in order:
168
+
169
+ - the GitHub CLI account named by the route's `github_user` (`gh auth token --user <name>`),
170
+ - otherwise `GH_TOKEN` or `GITHUB_TOKEN`,
171
+ - otherwise the GitHub CLI's active account.
172
+
173
+ `github_user` is for people with more than one GitHub account, such as a work account for a
174
+ client's organization and a personal one for open source. A fine-grained token needs read
175
+ access to Issues, Pull requests and Metadata. Some organizations require owner approval for
176
+ fine-grained tokens, or block classic ones.
177
+
178
+ **2. A Linear API key.** Create a personal API key in
179
+ [Linear's API settings](https://linear.app/settings/api) with read and write access. You can
180
+ limit it to the teams ferryGL writes to. Put it in `LINEAR_API_KEY`, or in a file named by
181
+ `[linear] api_key_file`.
182
+
183
+ **3. A config file.** ferryGL reads `~/.config/ferrygl/ferrygl.toml`, or the file given by
184
+ `--config` or `FERRYGL_CONFIG`. Each `[[route]]` sends some repositories to a Linear team and,
185
+ optionally, a project:
186
+
187
+ ```toml
188
+ [[route]]
189
+ repos = ["acme/api", "acme/web"]
190
+ team = "ENG" # Linear team key or name
191
+ project = "Acme engagement" # optional
192
+ github_user = "alice-acme" # optional: read GitHub as this gh account
193
+
194
+ [[route]]
195
+ repos = ["upstream-org/*"] # every repository of a user or organization
196
+ team = "ENG"
197
+ project = "Upstream contributions"
198
+ github_user = "alice"
199
+
200
+ [sync]
201
+ since = 2026-09-01 # ignore items not updated since this date
202
+ label = "ferryGL" # optional: an existing label added to every issue ferryGL creates
203
+ # Roles, all true by default: issues_assigned, issues_created, prs_created, prs_assigned,
204
+ # prs_reviewed, review_requests, prs_with_commits
205
+
206
+ [titles] # use "{repo}#{number}" to keep titles out of Linear
207
+ issue = "{title} #{number}"
208
+ pr = "{title} - #{number}"
209
+ involved = "[PR] {title} - #{number}"
210
+
211
+ [copy]
212
+ description = false
213
+
214
+ [status] # your team's Linear workflow state names
215
+ todo = "Todo"
216
+ in_progress = "In Progress"
217
+ in_review = "In Review"
218
+ done = "Done"
219
+ canceled = "Canceled"
220
+
221
+ [linear]
222
+ api_key_file = "~/.config/ferrygl/linear-api-key"
223
+ ```
224
+
225
+ **4. Check, preview, then apply:**
226
+
227
+ ```console
228
+ $ ferrygl check
229
+ $ ferrygl sync
230
+ $ ferrygl sync --apply
231
+ ```
232
+
233
+ `check` confirms that both credentials work, every repository is readable, and the Linear
234
+ teams, projects, states and label exist. `sync` on its own only prints the plan, for
235
+ example:
236
+
237
+ ```text
238
+ acme/api#252 issue update ENG-7 Todo -> In Review
239
+ acme/api#335 pr link -> ENG-7
240
+ acme/api#348 involved adopt -> ENG-9
241
+ 3 changes (1 adopt, 1 link, 1 update). Run with --apply to make them.
242
+ ```
243
+
244
+ `sync --apply` makes those changes and prints the identifier of each Linear issue it
245
+ creates. Run the preview first: the first sync is the one most likely to find Linear issues
246
+ you made by hand, and it records the current GitHub state on each of them (`record` in the
247
+ plan).
248
+
249
+ ## Running on a schedule
250
+
251
+ - **cron on a machine you use:**
252
+
253
+ ```text
254
+ 0 9,15 * * 1-5 ferrygl sync --apply >> ~/.cache/ferrygl.log 2>&1
255
+ ```
256
+
257
+ With `[linear] api_key_file` and `github_user` set, cron needs no extra environment.
258
+
259
+ - **GitHub Actions in a repository on your own account:** a scheduled workflow with your
260
+ GitHub token and Linear key stored as secrets. The workflow's built-in `GITHUB_TOKEN`
261
+ can't read other organizations' repositories, so it needs your own token. A template
262
+ workflow is planned.
263
+
264
+ ## When something goes wrong
265
+
266
+ - If a configured Linear team, project, state or label doesn't exist, `sync` stops before
267
+ reading GitHub or writing anything, and exits with status 2. `ferrygl check` shows what's
268
+ missing.
269
+ - If a route's repositories can't be read, ferryGL reports it, skips that route, continues
270
+ with the others, and exits with status 1.
271
+ - If a write fails, ferryGL reports it and continues. A run that fails halfway is safe to
272
+ repeat: ferryGL finds Linear issues by their GitHub links, so a rerun doesn't create
273
+ duplicates.
274
+ - Creating a Linear issue is never retried after a network error, because Linear may have
275
+ created it anyway. If it did, the next sync adopts it through the GitHub URL in its
276
+ description.
277
+
278
+ ## How it works
279
+
280
+ - **No database.** Every Linear issue ferryGL manages has a link with the GitHub URL.
281
+ ferryGL finds it with Linear's `attachmentsForURL` query, and keeps the last GitHub state
282
+ it acted on in the link's `metadata`, as flat `ferrygl_*` keys such as `ferrygl_state`.
283
+ Linear holds all the state.
284
+ - **Few requests.** GitHub reads use the GraphQL search API: up to four searches per route,
285
+ plus one request per 50 results.
286
+ - **Testable core.** Planning (`ferrygl.plan`) is a pure function from GitHub items, existing
287
+ Linear issues and config to a list of changes. It's tested without network access.
288
+ - **Small footprint.** One runtime dependency, `httpx`.
289
+
290
+ ## Development
291
+
292
+ ```console
293
+ $ uv sync
294
+ $ uv run pytest
295
+ $ uv run ruff check && uv run ruff format --check
296
+ ```
297
+
298
+ Before changing ferryGL, read [AGENTS.md](AGENTS.md): it lists the invariants, the data
299
+ ferryGL leaves in users' Linear workspaces, and the testing and review rules for
300
+ contributors and coding agents.
301
+
302
+ ## Not planned
303
+
304
+ - Writing anything to GitHub: comments, labels or status.
305
+ - Two-way sync, or keeping descriptions and comments in sync.
306
+ - Webhooks or a hosted service. ferryGL polls, so changes arrive on your schedule, not
307
+ instantly.
308
+ - Syncing every issue in a repository for a whole team. Linear's GitHub integration does that
309
+ when you can install it.
310
+
311
+ ## Open questions
312
+
313
+ - Should commenting on an item, or being mentioned in it, count as a role? Today they don't,
314
+ because they'd pull in many items you only touched in passing.
315
+
316
+ ## License
317
+
318
+ [MIT](LICENSE)