Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,17 @@ names every path and the reason for each.
change request — a pull request on GitHub, a merge request on GitLab — it
asks this module for; `scripts/lib/queue.py` runs no forge CLI itself and
builds no forge URL. TWO implementations ship — GitHub through `gh`, GitLab
through `glab` — and each is a CONFIGURATION and not an assumption: which
hosts one owns comes from that CLI's own variable (`GH_HOST`, `GITLAB_HOST`),
so a self-hosted instance is the ordinary case and not a special one. The
file's own header owns the interface and how to add a third. Two things
follow: a repository is identified by HOST plus path
through `glab` — and each is a CONFIGURATION and not an assumption, so a
self-hosted instance is the ordinary case and not a special one. **Which
hosts the GitLab adapter owns is READ OFF THE MACHINE**: `forge.py`'s
`configured_hosts` takes every instance `glab auth status` reports, because
that is where the operator's answer already lives and `GITLAB_HOST` is a
variable nothing exports. `GITLAB_HOST` still decides when it IS set, the
GitHub adapter still takes `GH_HOST` alone, and discovery never becomes a
requirement — `configured_hosts`' own docstring owns those three and why.
`python3 scripts/lib/forge.py hosts <cli>` prints the list for a shell
caller. The file's own header owns the interface and how to add a third. Two
things follow: a repository is identified by HOST plus path
(`github.com/Thurbeen/fleet`), because a bare `owner/repo` names two
different repositories once two forges exist; and `queue-selftest.sh` drives
`collect`, the landing check and `shepherd` through a second forge with no
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,9 @@ What it needs, and what it will tell you itself:
`git`, `gh` (authenticated), `jq`, `python3` with PyYAML and `thurbox-cli`
**2.19.0 or newer** are required; `quota-axi` and `glab` are recommended, and
each names what degrades without it. `gh` is not optional even on a GitLab-only
fleet — it is what builds the repo map.
fleet — it is what builds the repo map. A `glab` that is logged in is also the
configuration: fleet asks it which GitLab instances this machine holds, so a
self-hosted one needs no variable exported for it.

That done, open the Mission Control session in thurbox and give it a goal.

Expand Down
41 changes: 35 additions & 6 deletions scripts/fixtures/glab/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,20 @@ the adapter's parsing is tested against **what `glab` actually prints** rather
than against what this repo assumed it prints. Nothing here reaches the
network when the selftest runs; a fake `glab` on `PATH` replays these files.

Every file below was recorded on **2026-09-10** with **`glab` 1.117.0
Every `mr-*` file below was recorded on **2026-09-10** with **`glab` 1.117.0
(44790937b)** against **gitlab.com**, unauthenticated, from the public project
`gitlab-org/cli`. The commands are exact and repeatable.
`gitlab-org/cli`; those commands are exact and repeatable. `auth-status.stderr`
is the one exception on every count — it prints whatever the running machine is
configured for, and it was recorded elsewhere and edited afterwards. Its own
bullet below owns its provenance.

| File | Command |
| --- | --- |
| `mr-view.json` | `glab mr view 3877 -R https://gitlab.com/gitlab-org/cli -F json` |
| `mr-commits.json` | `glab api "projects/gitlab-org%2Fcli/merge_requests/3875/commits?per_page=100" --hostname gitlab.com` |
| `mr-view-missing.json` | `glab mr view 999999 -R https://gitlab.com/gitlab-org/cli -F json` — stdout |
| `mr-view-missing.stderr` | the same call's stderr |
| `auth-status.stderr` | `glab auth status --all` — stderr, **with the hostnames and account renamed**, see below |

`glab mr list -F json` answers with the same objects minus `head_pipeline`, so
it is not recorded separately — the adapter reads only `iid` out of a listing
Expand All @@ -37,6 +41,26 @@ its bytes in a control-plane repo:
reason is the JSON on *stdout*, while stderr carries a blank-line-padded
`ERROR` box whose first line is decoration. An adapter that read stderr
first would report the box.
- **`auth-status.stderr` is where the GitLab host list comes from**, and it
is the one file here that was **edited after recording**. It was recorded on
**2026-09-11** with the same `glab`, from a machine logged in to one
self-hosted instance and not to gitlab.com; that instance's hostname, the
account name and the home directory were then replaced with
`gitlab.example.com`, `some-account` and `/home/user`, because this
repository is public. Nothing else was touched, so the shape is real —
which is the whole point, since `forge.configured_hosts` reads it by shape:

- the report goes to **stderr**, not stdout, which is the opposite of `gh`;
- each instance is a **bare hostname, alone on an unindented line**, with
everything said about it indented underneath;
- the trailing `ERROR` box — its blank-padded lines, and the line of spaces
inside it — is decoration that must not read as a host, and it is here
verbatim, trailing whitespace and all, so that it is tested rather than
imagined;
- `glab` **exits non-zero** because one of the two instances has no token,
which is the ordinary state of a machine logged in to one and not the
other. An adapter that read the exit code would discover nothing on
exactly the machines this exists for.

## What is NOT here

Expand All @@ -52,7 +76,12 @@ presented as recorded:

## Refreshing

Re-run the commands above. `3877` and `3875` are ordinary merge requests on a
public project and will eventually be closed; when they are, pick any open
merge request with a fork source and any with several commits, and update the
numbers in this table.
Re-run the `mr-*` commands above. `3877` and `3875` are ordinary merge
requests on a public project and will eventually be closed; when they are,
pick any open merge request with a fork source and any with several commits,
and update the numbers in this table.

`auth-status.stderr` is not refreshed that way: re-running `glab auth status
--all` prints the hostnames, account and home directory of whoever ran it, and
this repository is public. Replace them again, as the bullet above says, or
leave the file alone — it is a shape, not a live answer.
21 changes: 21 additions & 0 deletions scripts/fixtures/glab/auth-status.stderr
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
gitlab.com
x gitlab.com: API call failed: GET https://gitlab.com/api/v4/user: 401 {message: 401 Unauthorized}
✓ Git operations for gitlab.com configured to use ssh protocol.
✓ API calls for gitlab.com are made over https protocol.
✓ REST API Endpoint: https://gitlab.com/api/v4/
✓ GraphQL Endpoint: https://gitlab.com/api/graphql/
! No token found (checked config file, keyring, and environment variables).
gitlab.example.com
✓ Logged in to gitlab.example.com as some-account (/home/user/.config/glab-cli/config.yml)
✓ Git operations for gitlab.example.com configured to use ssh protocol.
✓ API calls for gitlab.example.com are made over https protocol.
✓ REST API Endpoint: https://gitlab.example.com/api/v4/
✓ GraphQL Endpoint: https://gitlab.example.com/api/graphql/
✓ Token found in configuration file (plaintext): **************************
! To store this token more securely, run glab auth login --hostname gitlab.example.com to move it into the operating system keyring.

ERROR


X could not authenticate to one or more of the configured GitLab instances.

143 changes: 134 additions & 9 deletions scripts/lib/forge.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,13 @@
manufacture a merge.

WHAT SHIPS. Two adapters: GitHub through `gh`, GitLab through `glab`. Both are
CONFIGURATION — which hosts each one owns comes from that CLI's own variable
(`GH_HOST`, `GITLAB_HOST`), because a self-hosted instance is the normal case
for everything that is not github.com or gitlab.com.
CONFIGURATION — a self-hosted instance is the normal case for everything that
is not github.com or gitlab.com, so which hosts an adapter owns is read off the
machine rather than assumed. For GitLab that is `configured_hosts` below: the
instances `glab auth status` reports, which is where the operator's answer
already lives. `GITLAB_HOST` still decides when it is set, and the GitHub
adapter still takes `GH_HOST` alone — see `configured_hosts` for why the same
discovery is not done for `gh`.

ADDING A FORGE. Write a class with the methods below and register it: either
in `BUILTIN` here, or — for a test, or a forge that is not fleet's business to
Expand Down Expand Up @@ -234,6 +238,99 @@ def change_url(url) -> str:
return m.group(1) if m else ""


# --- which hosts a CLI is configured for -------------------------------------


# How both `gh auth status` and `glab auth status` head each instance they are
# configured for: the bare hostname, alone on an unindented line, with
# everything they have to say about it indented underneath. A `:port` is
# allowed because a self-hosted instance on one is ordinary and `RepoId`
# carries the port as part of the host. A dot is REQUIRED, for the same reason
# `RepoId.parse` requires one: it is what tells a hostname from a decoration
# line, and it is the shape every host fleet can be handed as part of a URL.
AUTH_HOST_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9.-]*\.[A-Za-z0-9-]+(?::\d+)?$")


def configured_hosts(cli: str, timeout: int = 10) -> list:
"""Every instance `cli` is authenticated or configured for, asked of `cli`.

WHY THIS EXISTS. `GH_HOST` and `GITLAB_HOST` are the documented way to tell
fleet about a self-hosted instance, and in practice nothing exports them:
an operator logs their CLI in once and never thinks about it again. The
result was that a whole forge was invisible — `shepherd` never saw a merge
request on a self-hosted instance, `collect` could not verify a publish
there, and `reap` could never land the task, so its session and worktree
leaked with no upper bound. The operator's answer was already on the
machine; nothing was reading it.

IT NEEDS NO NETWORK. `auth status` prints one heading per configured
instance out of the CLI's own config and then decorates each with an API
call, so the headings are there whether or not the call succeeds — measured
on 2026-09-11 against `glab` 1.117.0 with every request refused, which
printed both instances in 0.2s. The exit code is ignored for the same
reason: `glab` exits non-zero when ANY one instance fails to authenticate,
which is the ordinary state of a machine logged in to one instance and not
the other.

BOTH STREAMS ARE READ, because they disagree: `glab` writes the whole
report to stderr and `gh` writes it to stdout.

IT NEVER FAILS. No CLI, no config, or a report it cannot parse each answer
with an empty list, which leaves every caller exactly where it was before
discovery existed. Discovery is an improvement on a default, never a
dependency: `collect` has to keep working with the network down and on a
machine that has neither CLI.

WHY THE GITHUB ADAPTER DOES NOT USE THIS, though `gh auth status` prints
the same shape and GitHub Enterprise is the same problem. §13 of
`queue-selftest.sh` drives the whole queue through a forge that is not
GitHub with `gh` on PATH as a TRIPWIRE — it fails on any invocation at all,
so code reaching around this seam shows up there by name. Building the
GitHub adapter would run `gh auth status` and trip it, and the honest
choice between "discover GitHub Enterprise" and "keep the regression test
that keeps this seam honest" is the second one: `GH_HOST` was never the
half that was broken. This function takes the CLI by name so that
everything else — `scripts/preflight.sh` reporting auth per host, say —
can ask it about `gh` too, and so that the day that tripwire can tell a
configuration read from a change-request call, the adapter needs one line.
"""
cli = str(cli or "").strip()
hosts: list = []
if cli and shutil.which(cli):
# `--all` is the documented way to ask about every instance rather than
# the one the current directory implies. A CLI too old to know the flag
# refuses the whole command, so the bare form is tried after it — which
# on a machine with no git context answers the same thing. A call that
# never answered at all stops the sequence rather than being retried:
# the second ask would hang exactly as long as the first, and paying
# the timeout twice is how discovery would start costing `collect`
# real time on the flaky network it is supposed to survive.
for argv in ([cli, "auth", "status", "--all"], [cli, "auth", "status"]):
said = _auth_status_hosts(argv, timeout)
if said is None:
break
if said:
hosts = said
break
return hosts


def _auth_status_hosts(argv: list, timeout: int):
"""The hostnames `argv` printed, or None if it never answered at all."""
try:
out = subprocess.run(argv, capture_output=True, text=True, timeout=timeout)
except (OSError, subprocess.SubprocessError):
return None
seen: dict = {}
for line in ((out.stdout or "") + "\n" + (out.stderr or "")).splitlines():
if not line[:1].strip(): # every heading is unindented; the rest is not
continue
host = line.strip().lower()
if AUTH_HOST_RE.match(host):
seen[host] = True
return list(seen)


# --- the interface -----------------------------------------------------------


Expand Down Expand Up @@ -664,11 +761,13 @@ class GitLabForge(Forge):
whatever credential the operator gave this machine, and a fleet that needed
its own token would need one per machine a worker runs on.

WHICH HOSTS ARE GITLAB. `gitlab.com`, plus `GITLAB_HOST` — glab's own
variable for a self-hosted instance, the way `GH_HOST` is gh's. A
self-hosted instance is the normal case here, so every call names its
repository by FULL URL (`-R https://host/group/project`) rather than by
slug: that is what makes `gitlab.example.com/group/proj` reach
WHICH HOSTS ARE GITLAB. `gitlab.com`, plus whichever instances this
machine's `glab` is configured for — `configured_hosts` above reads them
out of `glab auth status`. `GITLAB_HOST`, glab's own variable for one
chosen instance, overrides that entirely when it is set, the way `GH_HOST`
does for gh. A self-hosted instance is the normal case here, so every call
names its repository by FULL URL (`-R https://host/group/project`) rather
than by slug: that is what makes `gitlab.example.com/group/proj` reach
gitlab.example.com and not gitlab.com.

WHAT IT COSTS. GitLab does not put a merge request's pipeline in the list
Expand All @@ -687,7 +786,15 @@ class GitLabForge(Forge):

def __init__(self, hosts=None):
extra = [self._host(h) for h in (hosts or [])]
extra.append(self._host(os.environ.get("GITLAB_HOST", "")))
override = self._host(os.environ.get("GITLAB_HOST", ""))
# `GITLAB_HOST` DECIDES when it is set, and discovery does not run at
# all then: it is glab's own variable, an operator who exported it
# pointed fleet at that instance deliberately, and glab itself obeys it
# over its config. Unset, this used to mean "gitlab.com and nothing
# else", which made a self-hosted instance invisible on the very
# machines whose `glab` was logged in to one — so ask glab which
# instances it holds instead of waiting for a variable nothing sets.
extra.extend([override] if override else configured_hosts("glab"))
self.hosts = tuple(dict.fromkeys(
["gitlab.com", "www.gitlab.com"] + [h for h in extra if h]
))
Expand Down Expand Up @@ -1260,3 +1367,21 @@ def _git_remote(path: str) -> str:
except (OSError, subprocess.SubprocessError):
return ""
return out.stdout.strip() if out.returncode == 0 else ""


# --- asking from a shell -----------------------------------------------------


if __name__ == "__main__":
# `python3 scripts/lib/forge.py hosts glab` — the one thing in this module
# a shell script needs, since `configured_hosts` answers a question
# (`scripts/preflight.sh`'s "which instances should I report auth for")
# that is not itself about a change request. One host per line, nothing on
# a machine that has no such CLI, and always exit 0: a CLI that is not
# installed is an answer, not an error.
if len(sys.argv) == 3 and sys.argv[1] == "hosts":
for _host in configured_hosts(sys.argv[2]):
print(_host)
else:
print("usage: forge.py hosts <gh|glab>", file=sys.stderr)
sys.exit(2)
Loading