GhSyncTransport
def GhSyncTransport(
debug:NoneType=None, limit_cb:NoneType=None, **kwargs
):Sync twin of GhTransport: same debug, header, and rate-limit handling over a blocking SyncTransport
You can set an environment variable named GH_HOST to override the default of https://api.github.com incase you are running GitHub Enterprise(GHE). However, this library has not been tested on GHE, so proceed at your own risk.
Sync twin of GhTransport: same debug, header, and rate-limit handling over a blocking SyncTransport
Async transport converting JSON responses to AttrDicts and tracking rate-limit and response headers.
Debug callback for GhApi(debug=...): print each request with the token (if any) removed
Generated endpoints accept owner/repo per call to override the client’s bound defaults. gh_patch extends that contract to convenience methods: it patches a method into GhApi with owner/repo params added to its signature, and when passed they override the defaults for that call and everything it calls internally - chains like pr_status → check_status → endpoints inherit the override through a ContextVar, so concurrent tasks can’t interfere with each other. _LiveDefaults is the endpoint-side half: the defaults mapping consults the active override at call time, and explicitly passed route params still win (they’re filled before defaults apply).
patch fn into GhApi, adding owner/repo params that override the client defaults for this call and its internal calls
GitHub API client. Endpoint groups (issues, pulls, …) are generated per-instance from GitHub’s OpenAPI metadata, so the class shows only convenience methods – inspect a live instance, e.g. doc(GhApi()), to see the full API.
Call a fully specified path (or full URL) using HTTP verb directly (returns an awaitable on an async client)
You can call a GhApi object as a function, passing in the path to the endpoint, the HTTP verb, and any route, query parameter, or post data parameters as required.
{ 'node_id': 'MDM6UmVmMzE1NzEyNTg4OnJlZnMvaGVhZHMvbWFzdGVy',
'object': { 'sha': 'b72d6c87a9237ca3c26298a64a6acf06217ace4a',
'type': 'commit',
'url': 'https://api.github.com/repos/fastai/ghapi-test/git/commits/b72d6c87a9237ca3c26298a64a6acf06217ace4a'},
'ref': 'refs/heads/master',
'url': 'https://api.github.com/repos/fastai/ghapi-test/git/refs/heads/master'}Lookup an endpoint by path and verb (which defaults to ‘GET’)
You can access endpoints by indexing into the object. When using the API this way, you do not need to specify what type of parameter (route, query, or post data) is being used. This is, therefore, the same call as above:
{ 'node_id': 'MDM6UmVmMzE1NzEyNTg4OnJlZnMvaGVhZHMvbWFzdGVy',
'object': { 'sha': 'b72d6c87a9237ca3c26298a64a6acf06217ace4a',
'type': 'commit',
'url': 'https://api.github.com/repos/fastai/ghapi-test/git/commits/b72d6c87a9237ca3c26298a64a6acf06217ace4a'},
'ref': 'refs/heads/master',
'url': 'https://api.github.com/repos/fastai/ghapi-test/git/refs/heads/master'}For some endpoints GitHub lets you specify a media type the for response data, using the Accept header. If you choose a media type that is not JSON formatted (for instance application/vnd.github.v3.sha) then the call to the GhApi object will return a string instead of an object.
GitHub has various rate limits for their API. After each call, the response includes information about how many requests are remaining in the hourly quota. If you’d like to add alerts, or indications showing current quota usage, you can register a callback with GhApi by passing a callable to the limit_cb parameter. This callback will be called whenever the amount of quota used changes. It will be called with two arguments: the new quota remaining, and the total hourly quota.
Quota remaining: 4931 of 5000
'refs/heads/master'
You can always get the remaining quota from the limit_rem attribute:
Everything in ghapi is async by default. For code that can’t (or shouldn’t) be async, pass sync=True to get a client whose endpoint calls block and return results directly, with the same groups, names, and signatures:
Only the generated endpoints are sync on such a client: the convenience methods below (read_issue, create_gist, …) are written as async code, so they stay awaitable-only. To use those from sync code, call them on a regular async client via fastcore.net.run_sync (a stdlib-only bridge that works even inside Jupyter), e.g. run_sync(GhApi().read_issue(205)). Pagination has sync twins, sync_paged and sync_pages (see page). And for a quick one-off endpoint call, call_gh wraps constructing a sync client and calling one operation:
Call one GitHub operation op (e.g. 'repos.get') on a fresh sync client; handy for one-off calls from sync code
Instead of passing a path to GhApi, you will more often use the operation methods provided in the API’s operation groups, which include documentation, signatures, and auto-complete.
If you provide owner and/or repo to the constructor, they will be automatically inserted into any calls which use them (except when calling GhApi as a function). You can also pass any other arbitrary keyword arguments you like to have them used as defaults for any relevant calls.
You must include a GitHub API token if you need to access any authenticated endpoints. If don’t pass the token param, then your GITHUB_TOKEN environment variable will be used, if available.
The following groups of endpoints are provided, which you can list at any time along with a link to documentation for all endpoints in that group, by displaying the GhApi object:
The GitHub API’s endpoint names generally start with a verb like “get”, “list”, “delete”, “create”, etc, followed _, then by a noun such as “ref”, “webhook”, “issue”, etc.
Each endpoint has a different signature, which you can see by using Shift-Tab in Jupyter, or by just printing the endpoint object (which also shows a link to the GitHub docs):
repos.create_webhook(name: str = UNSET, config: dict = UNSET, owner: str = 'AnswerDotAI', repo: str = 'ghapi-test', events: list = ['push'], active: bool = True)
https://docs.github.com/rest/repos/webhooks#create-a-repository-webhook
Displaying an endpoint object in Jupyter also provides a formatted summary and link to the official GitHub documentation:
Create a repository webhook
Docs: https://docs.github.com/rest/repos/webhooks#create-a-repository-webhook
Parameters: - name (str, optional): Use web to create a webhook. Default: web. This parameter only accepts the value web. - config (dict, optional): Key/value pairs to provide settings for this webhook. - owner (str, default: ‘AnswerDotAI’): The account owner of the repository. The name is not case sensitive. - repo (str, default: ‘ghapi-test’): The name of the repository without the .git extension. The name is not case sensitive. - events (list, default: [‘push’]): Determines what events the hook is triggered for. - active (bool, default: True): Determines if notifications are sent when the webhook is triggered. Set to true to send notifications.
Endpoint objects are called using standard Python method syntax:
Information about the endpoint are available as attributes:
You can get a list of all endpoints available in a group, along with a link to documentation for each, by viewing the group:
For “list” endpoints, the noun will be a plural form, e.g.:
You can pass dicts, lists, etc. directly, where they are required for GitHub API endpoints:
Let’s confirm that our new webhook has been created:
Finally, we can delete our new webhook:
Convert dt (which is assumed to be in UTC time zone) to a format suitable for GitHub API operations
The GitHub API assumes that dates will be in a specific string format. date2gh converts Python standard datetime objects to that format. For instance, to find issues opened in the ‘fastcore’ repo in the last 4 weeks:
2
Convert date string dtstr received from a GitHub API operation to a UTC datetime
You can set the debug attribute to any callable to intercept all requests – it’s called with the method, URL, and request kwargs before each request. print_summary is provided for this purpose (it prints each request with the auth token removed), and setting the GHAPI_DEBUG environment variable enables it globally:
Several convenience methods below return rows whose bare display is the whole answer: one line per row, leading with the id or number you act on. They share one container.
Result rows whose bare display is one actionable line each
Some methods in the GitHub API are a bit clunky or unintuitive. In these situations we add convenience methods to GhApi to make things simpler. There are also some multi-step processes in the GitHub API that GhApi provide convenient wrappers for. The methods currently available are shown below; do not hesitate to create an issue or pull request if there are other processes that you’d like to see supported better.
Create a gist, optionally with images where each md img url will be placed with img upload urls.
https://gist.github.com/jph00/d68e0a59cd9fc8e984797d29898b8317
https://gist.github.com/jph00/929068e9fa15a998e3c58bbb54e1bd24
'some image\n\n'
Note that if you want to create a gist with multiple files, call the GitHub API directly, e.g.:
Update the first file in a gist with new content
Get the first file from a gist; coro if async client
Retrieve a gist by id, or by user/id (as it appears in gist URLs); coro if async client
load_gist accepts a bare gist id or a user/id string (as it appears in a gist’s URL). gist_file and update_gist work with the first file in a gist; for gists with multiple files, use api.gists.get/api.gists.update directly. load_gist and gist_file follow the client’s mode: on an async client they return awaitables as usual, while on a GhApi(sync=True) client they return results directly (gist_file does this via fastcore’s then).
gistid = 'jph00/e7cfd4ded593e8ef6217e78a0131960c'
loaded = await api.load_gist(gistid)
test_eq(loaded.id, 'e7cfd4ded593e8ef6217e78a0131960c')
gfile = await api.gist_file(gistid)
assert gfile.content
sapi = GhApi(sync=True)
test_eq(sapi.load_gist(gistid).id, 'e7cfd4ded593e8ef6217e78a0131960c')
assert sapi.gist_file(gistid).content
g = await api.create_gist('update_gist test', 'v1')
url = await api.update_gist(g.id, 'v2')
test_eq(url, g.html_url)
for _ in range(20):
content = first((await api.gists.get(g.id)).files.values()).content
if content == 'v2': break
sleep(1)
test_eq(content, 'v2')
await api.gists.delete(g.id)Show signature and docstring for sym
Show signature and docstring for sym
Creating a release and attaching files to it is normally a multi-stage process, so create_release wraps this up for you. It takes the same arguments as repos.create_release, along with files, which can contain a single file name, or a list of file names to upload to your release. Arguments left at their defaults (such as make_latest) are omitted from the API call, so GitHub’s own defaults apply; pass make_latest='false' when releasing an update to an older version, so it doesn’t take over the repo’s “Latest” marker:
We can check that our file has been uploaded; GitHub refers to them as “assets”:
Show signature and docstring for sym
With no prefix, all tags are listed.
Using the full tag name will return just that tag.
Show signature and docstring for sym
Branches can be listed in the exactly the same way as tags.
We can delete our release and confirm that it is removed:
Show signature and docstring for sym
Show signature and docstring for sym
Show signature and docstring for sym
Show signature and docstring for sym
Creating or updating a branch with several files normally requires separate tree, commit, and reference calls. commit_tree performs that sequence as one commit. It accepts GitHub tree entries, preserving the API’s normal rules for modes, deletions, and blobs.
Show signature and docstring for sym
branch,path = 'testme','commit-tree.txt'
tree = [dict(path=path, mode='100644', type='blob', content='first')]
commit1 = await api.commit_tree(branch, 'Create test branch', tree)
test_eq((await api.get_branch(branch)).object.sha, commit1.sha)
tree[0]['content'] = 'second'
commit2 = await api.commit_tree(branch, 'Update test branch', tree)
test_eq(commit2.parents[0].sha, commit1.sha)
test_eq((await api.get_branch(branch)).object.sha, commit2.sha)
content = await api.repos.get_content(path=path, ref=branch)
test_eq(base64.b64decode(content.content).decode(), 'second')
await api.delete_branch(branch)
commit1.sha,commit2.shaShow signature and docstring for sym
Show signature and docstring for sym
Show signature and docstring for sym
Show signature and docstring for sym
person = dict(name="Monalisa Octocat", email="[email protected]")
res = await api.create_file(path='foo', message="Create foo", content="foobar", committer=person, author=person)
test_eq('foobar', (await api.get_content('foo')).decode())Show signature and docstring for sym
Show signature and docstring for sym
78
Repo files can be filtered using fnmatch Unix shell-style wildcards.
['*.md', 'README.md']
The include/exclude logic follows the rsync/grep model: a file must match at least one include pattern (if specified), AND must not match any exclude pattern. Exclude always wins—there’s no ambiguity. This is simpler and more predictable than gitignore-style ordering rules. Additionally, LLMs are already familiar with this common pattern from tools like rg and rsync, making it natural to use when this function is provided as an AI tool.
With rsync/grep style, exclude always wins. To get “all .md except README.md”, you’d include README.md explicitly in your results separately.
Include all .py files except for tests
Here is an example where we filter to include the README, all python files except for the ones under tests directory, include all notebooks, and exclude all files starting with an underscore.
['README.md',
'module/file.py',
'module/app/file.py',
'nbs/00.ipynb',
'file2.py']
Let’s exclude files starting with test_ and setup.py too.
['tests/*.py', '_*', '*/_*', '*test_*.py', '*/*test*.py', 'setup.py']
A function to get repo files with optional filtering
Get all file items of a repo, optionally filtered.
The list of files that are kept based on the filtering logic:
['README.md', 'fastcore/all.py', 'fastcore/ansi.py', 'fastcore/basics.py', 'fastcore/dispatch.py', 'fastcore/docments.py', 'fastcore/docscrape.py', 'fastcore/foundation.py', 'fastcore/imghdr.py', 'fastcore/imports.py', 'fastcore/meta.py', 'fastcore/nb_imports.py', 'fastcore/nbio.py', 'fastcore/net.py', 'fastcore/parallel.py', 'fastcore/py2pyi.py', 'fastcore/script.py', 'fastcore/shutil.py', 'fastcore/style.py', 'fastcore/tools.py', 'fastcore/transform.py', 'fastcore/utils.py', 'fastcore/xdg.py', 'fastcore/xml.py', 'fastcore/xtras.py', 'nbs/000_tour.ipynb', 'nbs/00_test.ipynb', 'nbs/01_basics.ipynb', 'nbs/02_foundation.ipynb', 'nbs/03_xtras.ipynb', 'nbs/03a_parallel.ipynb', 'nbs/03b_net.ipynb', 'nbs/04_docments.ipynb', 'nbs/05_meta.ipynb', 'nbs/06_script.ipynb', 'nbs/07_xdg.ipynb', 'nbs/08_style.ipynb', 'nbs/09_xml.ipynb', 'nbs/10_py2pyi.ipynb', 'nbs/11_external.ipynb', 'nbs/12_tools.ipynb', 'nbs/13_nbio.ipynb', 'nbs/index.ipynb', 'tests/minimal.ipynb']
Call self as a function.
Get all file items of a repo, optionally filtered.
Show signature and docstring for sym
branch is set to the default branch if None. path must be /docs or /.
Fetching everything needed to review an issue or PR – title, body, comments, and (for PRs) the diff, inline review comments, and review summaries – normally takes several separate calls, and the REST API doesn’t distinguish PRs from issues for some of them (a PR is an issue, so its general comments come from issues.list_comments, not pulls.*). read_issue bundles all of this into one call.
Show signature and docstring for sym
api = GhApi(owner='fastai', repo='ghapi', token=token)
pr = await api.read_issue(205)
assert pr.is_pr
assert pr.diff.startswith('diff --git')
test_eq(len(pr.review_comments), 1)
test_eq(len(pr.reviews), 1)
iss = await api.read_issue(206)
assert not iss.is_pr
assert 'diff' in iss and iss.diff.startswith('diff --git')
assert '(issue)' in repr(iss)
r = repr(pr)
assert r.startswith(f'**{pr.title}** (PR)')
assert 'diff --git' not in r
assert '1 review comments' in r
prUse Content-Type to determine response parsing (PR)
Replaces the hardcoded _decode_response endpoint list with Content-Type based response handling.
JSON endpoints return AttrDict, text endpoints return str, binary endpoints return bytes — all determined by the response Content-Type header, not a maintained list of paths.
Supersedes #204.
diff: 4 files, 190 lines (see .diff)
0 comments; 1 review comments; 1 commented
The client’s bound repo is only a default: owner/repo on any convenience method override it for that call, including every call it makes internally - here list_files resolves fastcore’s default branch and tree through two internal hops, all following the override.
async def read_pr(
pr_number:int | str, # Issue/PR number, or GitHub issue/PR URL
owner:str=None, # Owner (not needed if URL passed)
repo:str=None, # Repo (not needed if URL passed)
folder:str='', # For diffs, limit to only files in `folder`
replies:bool=False, # Include comments, review comments, and reviews?
):Fetch a GitHub PR or issue as one markdown string: title, body, diff (if any), and optionally replies
While read_issue returns structured data, read_pr formats the whole thing as a single LLM-ready markdown string: title, body, the diff reduced to just headers and changed lines, and (with replies=True) comments, inline review comments, and review verdicts. You can pass a number plus owner/repo, or just paste a full GitHub URL.
pr_file_diff is the better choice when you want the complete, untruncated patch for one specific file with addition/deletion counts. read_pr(folder=...) is better for getting a reduced overview of all changes in a subdirectory along with the PR context.
Get the untruncated patch/diff for a single file in a PR
pulls.list returns each PR as a few dozen keys of nested JSON, so displaying even a handful of them fills the screen. list_prs reduces them to one actionable line each, most recently updated first, in the same GhRows shape as check_status: the number you act on, the age of the last activity, and the title.
Show signature and docstring for sym
Pull request rows, displayed with titles truncated to maxlen
prs = await api.list_prs(state='all', per_page=5)
test_eq(len(prs), 5)
lines = repr(prs).splitlines()
test_eq(len(lines), 5)
assert all(l.startswith('#') for l in lines)
assert 'html_url' not in repr(prs)
test_eq(repr(prs[0]), prs[0].line())
assert max(len(l) for l in repr(await api.list_prs(state='all', per_page=5, maxlen=20)).splitlines()) < 50
prsThe REST API bypasses issue templates entirely (they’re a web-UI feature), so a programmatically-created issue can easily ignore a form the repo requires, and maintainers will bounce it. issue_template fetches a repo’s templates and parses yml issue forms into their section labels, falling back to the owner-level .github community-health repo when the repo has none.
Show signature and docstring for sym
For example, quarto-cli uses yml issue forms; each parsed template lists the ### section labels a compliant issue body needs:
qapi = GhApi(owner='quarto-dev', repo='quarto-cli', token=token)
tmpls = await qapi.issue_template()
bug = first(t for t in tmpls if 'bug' in t.name)
assert bug.sections and all(s.label for s in bug.sections)
test_eq(await GhApi(owner='fastai', repo='ghapi', token=token).issue_template(), [])
[s.label for s in bug.sections]['I have:',
'Bug description',
'Steps to reproduce',
'Actual behavior',
'Expected behavior',
'Your environment',
'Quarto check output']
Build an issue body following form tmpl from issue_template: ### <label> headings in template order. sections maps label to content (for checkbox sections: list of checked options, or True for all)
issue_body then turns a {label: content} dict into the body GitHub’s own web form would produce, checking required sections and unknown labels so a non-compliant issue fails before it reaches the tracker:
tmpl = _parse_tmpl('bug_report.yml', """
name: Bug report
description: Report an error
body:
- type: markdown
attributes:
value: Welcome!
- type: checkboxes
attributes:
label: "I have:"
options:
- label: searched the issue tracker
- label: read the docs
- type: textarea
attributes:
label: Bug description
validations:
required: true
""")
test_eq([s.label for s in tmpl.sections], ['I have:', 'Bug description'])
body = issue_body(tmpl, {'I have:': True, 'Bug description': 'It breaks.'})
test_eq(body, '### I have:\n\n- [x] searched the issue tracker\n- [x] read the docs\n\n### Bug description\n\nIt breaks.')
body = issue_body(tmpl, {'I have:': ['read the docs'], 'Bug description': 'It breaks.'})
assert '- [ ] searched the issue tracker\n- [x] read the docs' in body
test_fail(lambda: issue_body(tmpl, {'I have:': True}), contains='required')
test_fail(lambda: issue_body(tmpl, {'Bug description': 'x', 'Wrong': 'y'}), contains='template')GitHub Actions results are reported through the Checks API (checks.list_for_ref); other CI systems generally use the older Commit Status API (repos.get_combined_status_for_ref). Most repos only populate one or the other, so check_status merges both into a single result. Runs and statuses are GhRows of one-line rows carrying the id you act on, so the bare st display is the whole picture: read it, then use the id you see.
Show signature and docstring for sym
Show signature and docstring for sym
dict subclass that also provides access to keys as attrs, and has a pretty markdown repr
dict subclass that also provides access to keys as attrs, and has a pretty markdown repr
sha = (await api.actions.list_workflow_runs_for_repo(per_page=1)).workflow_runs[0].head_sha
st = await api.check_status(sha)
assert 'state' in st and 'statuses' in st and 'check_runs' in st
assert len(st.check_runs) > 0
pr_st = await api.pr_status(205)
assert 'state' in pr_st and 'statuses' in pr_st and 'check_runs' in pr_st
test_eq(repr(pr_st), 'no check runs')
r = repr(st)
assert r.startswith('**')
assert all(l.startswith('- ') for l in r.splitlines()[2:])
assert 'html_url' not in r
stfailure
When a check fails, the why lives in the job log, which runs to tens of thousands of characters of mostly setup. For GitHub Actions, a check run and its workflow job share an id, so a failing entry in check_status leads straight to its job. failed_step_log downloads the run’s log archive, which holds one file per step, and returns only the failed steps’ files: each headed by its step name, timestamp prefixes and ANSI colours stripped. (The single job log has no step boundaries, and step times are reported to the second, so slicing it by time would take in the tails of fast neighbouring steps.)
Show signature and docstring for sym
Job logs expire after about 90 days, so rather than a live example, here is a real (abridged) result from an intermittent fastcore CI failure. The failing run’s id is read straight from the st display above, no filtering needed:
Truncated output:
# Run tests
##[group]Run nbdev-test
...
AssertionError in /Users/runner/work/fastcore/fastcore/nbs/03c_aio.ipynb:
===========================================================================
While Executing Cell #21:
Traceback (most recent call last):
...
File "<ipython-input-1-a2eaf525b891>", line 10, in <module>
assert elapsed < 0.18, elapsed
^^^^^^^^^^^^^^
AssertionError: 0.18208718299865723
nbdev Tests Failed On The Following Notebooks:
==================================================
03c_aio.ipynb
##[error]Process completed with exit code 1.
When CI goes red across several repos of one ecosystem at once, a downstream failure is often just an upstream break propagating, so the productive fix order is dependency-first: sources before dests. These helpers build a dependency graph among repos from their pyprojects, and order any repo list that way. The graph is a plain dict {package: (repo name, [dep packages])}, so every piece here works on a graph from any source. dep_key reduces a PEP 508 dependency spec to the package key used throughout:
local_dep_graph scans a directory of checkouts (anything with a pyproject.toml one level down). It is the cheap way to build a graph when clones are at hand, and the seed that saves dep_graph (below) from fetching what you already have:
Dependency graph {package: (repo dir, [dep packages])} for checkouts under root
d = Path(tempfile.mkdtemp())
tomls = dict(appy='name = "appy"\ndependencies = ["LibX[all] >=1", "httpx"]',
libx='name = "LibX"\ndependencies = ["exty"]', exty='name = "exty"', toolz='name = "toolz"')
for dirname, toml in tomls.items():
(d/dirname).mkdir()
(d/dirname/'pyproject.toml').write_text(f'[project]\n{toml}\n')
smallg = local_dep_graph(d)
smallgKeys are casefolded package names, and edges may point at packages outside the graph (here httpx). dep_closure collects the repos a project transitively depends on within the graph — external names simply drop out:
Repo names for name and its transitive dependencies within graph
dep_order sorts repo (or package, or owner/name) names so every dependency comes before its dependents — the order to fix a red-CI list in, resolving upstream breaks before chasing their downstream echoes. Ordering uses transitive closure, so a dependency through a repo not in the list still counts: below, appy follows exty even though the connecting libx isn’t listed. Where the dependency relation leaves the order free, the most-depended-on names come first (that’s why exty beats the isolated toolz below), so the front of the list is also where fixes pay off most. A dependency cycle raises an error.
Order names (repo, package, or owner/name specs; default all of graph) dependency-first, ties most-depended-on first
dep_dependents is the reverse view — for each listed name, which other listed names transitively depend on it, most-depended-on first. Where dep_order says what to fix first, this says why it’s worth fixing: a repo with many dependents is the high-leverage one, and a repo with none can wait.
For each of names (default all of graph), the listed names that transitively depend on it, most-depended-on first
When checkouts aren’t at hand, dep_graph builds the same graph from GitHub: it fetches each listed repo’s pyproject.toml and walks dependencies transitively, trying each dep name as a repo under the same owner (GitHub repo lookups are case-insensitive). A name that 404s is external, and the walk stops there; a listed repo without a fetchable pyproject still joins the graph with no deps, so it takes part in ordering. Pass graph= to seed with what you already know — seeded repos are never fetched.
Show signature and docstring for sym
One fetch walks the whole reachable subgraph — here fastws pulls in fastgit, ghapi, and their deps, while external names like httpx 404 out. Package and repo names needn’t match (the fastws repo ships the fastws-cli package), and the graph tracks both:
Putting it together, the workflow this section was built for: several repos’ CI has gone red at once, and we want to fix them dependency-first. Local checkouts seed the graph so only unknown repos are fetched:
Truncated output:
aidialog lands before shell_sage although shell_sage doesn’t depend on it directly — the edge runs through intermediate packages that aren’t in the red list — and app-level repos like solveit land last, where most of their failures turn out to be upstream breaks already fixed by that point.