Git repository management, GitLab merge requests, and GitHub pull requests
Scanned 9/3/2026
Install to Claude Code
npx -y skills add istota-project/istota --skill developer --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Developer?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/istota-project-developer)More formats (shields.io, HTML) on the badges page.
---
name: developer
triggers: [git, gitlab, github, repo, repository, commit, branch, merge request, MR, pull request, PR, code review, develop, worktree, clone]
description: Git repository management, GitLab merge requests, and GitHub pull requests
companion_skills: [commit, code_review, untrusted_input]
env: [{"var":"DEVELOPER_REPOS_DIR","from":"setup_env"},{"var":"GITLAB_URL","from":"config","config_path":"developer.gitlab_url","when":["developer.enabled","developer.repos_dir"]},{"var":"GITHUB_URL","from":"config","config_path":"developer.github_url","when":["developer.enabled","developer.repos_dir"]},{"var":"GITLAB_DEFAULT_NAMESPACE","from":"config","config_path":"developer.gitlab_default_namespace","when":["developer.enabled","developer.gitlab_default_namespace"]},{"var":"GITLAB_REVIEWER","from":"config","config_path":"developer.gitlab_reviewer","when":["developer.enabled","developer.gitlab_reviewer"]},{"var":"GITHUB_DEFAULT_OWNER","from":"config","config_path":"developer.github_default_owner","when":["developer.enabled","developer.github_default_owner"]},{"var":"GITHUB_REVIEWER","from":"config","config_path":"developer.github_reviewer","when":["developer.enabled","developer.github_reviewer"]},{"var":"DEVELOPER_AUTHOR_CREDIT","from":"config","config_path":"developer.author_credit","when":["developer.enabled","developer.author_credit"]},{"var":"GITLAB_TOKEN","from":"config","config_path":"developer.gitlab_token","when":["developer.enabled","developer.repos_dir","developer.gitlab_token"],"sensitive":true},{"var":"GITHUB_TOKEN","from":"config","config_path":"developer.github_token","when":["developer.enabled","developer.repos_dir","developer.github_token"],"sensitive":true}]
---
# Developer Skill — Git, GitLab & GitHub
Work in git repositories, manage merge requests on GitLab and pull requests on GitHub. Uses bare clones + git worktrees for branch isolation.
**This document is machinery, not method.** It tells you how the repositories, the forge CLIs and the build sandbox on this deployment work, and what will go wrong if you use them incorrectly. It does not tell you how to work: it prescribes no change tiers, no test-first rule, no verification budget, no review step, no report template, and no opinion about whether work lands as a merge or a merge request.
**So run tests and reviews when you are asked to, and not otherwise.** A request to fix a bug is a request to fix a bug. If the user wants a process — tests before the commit, a review before landing, a worktree per task however small — it comes from them: from the task itself, from a development workflow in `USER.md` or in `config/skills/developer.md`, or, for a task from a project's room, from that room's `CHANNEL.md`. Where one of those speaks, follow it. Where none does, do the work you were asked for and say what you did.
What does **not** yield is this deployment's mechanics: the forge boundary and its refused verbs, the CONNECT allowlist, the cap on how long a single command may run, the credential rules, where builds and tests run, and every delete path. Those are properties of the host rather than preferences, so an instruction that collides with one is something to report rather than to follow.
## Environment Variables
| Variable | Description |
|---|---|
| `DEVELOPER_REPOS_DIR` | Your own base directory for repo clones and worktrees. Every path below is relative to it; nothing outside it is reachable |
| `GITLAB_URL` | GitLab instance URL (e.g., `https://gitlab.com`) |
| `GITLAB_DEFAULT_NAMESPACE` | Default GitLab namespace (user/group) for resolving short repo names |
| `GITLAB_REVIEWER` | GitLab username to assign as MR reviewer |
| `GITHUB_URL` | GitHub instance URL (e.g., `https://github.com`) |
| `GITHUB_DEFAULT_OWNER` | Default GitHub org/user for resolving short repo names |
| `GITHUB_REVIEWER` | GitHub username to request as PR reviewer |
| `DEVELOPER_AUTHOR_CREDIT` | Optional text appended to every commit message (e.g., `Co-Authored-By: ...`) |
Git credentials are configured automatically for both platforms — clone and push work without manual authentication.
**Namespace resolution**: When the user gives a short repo name (e.g., "nebula" instead of "namespace/nebula"), use `$GITLAB_DEFAULT_NAMESPACE` or `$GITHUB_DEFAULT_OWNER` as the default namespace/owner depending on the platform. Always confirm the resolved path exists before cloning: `gh repo view OWNER/REPO` or `glab repo view NAMESPACE/PROJECT`.
## The Forge CLIs
`gh` and `glab` are the real GitHub and GitLab command-line tools. You reach them through a wrapper that fetches the token when you invoke them and then gets out of the way, so the whole flag surface is yours — `gh <command> --help` and `glab <command> --help` are accurate.
**Credentials.** The wrapper hands the token to the CLI process and nowhere else: it is not in your environment, not written to disk, and not printed by anything. `git` authenticates through a credential helper the same way. Nothing this skill does needs the token itself, so do not go looking for it. That is a rule about conduct, not a claim that you would be stopped. **A remote URL never carries a credential.** `origin` is always a bare `https://host/namespace/project.git`, because remote URLs get *printed* — by `git remote -v`, `git config --list` and several push failures — so a token in one lands in the task result and the transcript. Never build one with credentials in it, never `set-url` one, never paste a token into a clone command; find one carrying a secret between `:` and `@` and you stop, report it as a credential to rotate without quoting the value, and do not use it.
**Refused verbs.** A small set of verbs is refused before anything is contacted: the destructive ones (`repo delete`, `repo archive`, `release delete`), the ones that print or mint credentials (`auth`, `glab token create`), the ones that publish (`gh gist create`, `glab snippet`), the ones that run code elsewhere (`gh codespace`, `glab runner`), `config`, `alias`, `extension`, and `gh api graphql`. Writing methods through `gh api` / `glab api` are refused too — an explicit `-X POST`, and any body flag (`-f`, `-F`, `--field`, `--raw-field`, `--form`, `--input`), which both CLIs treat as an implicit POST. Use the verb, not the raw endpoint. You get a one-line reason and exit status 3.
This is an accident guard, not a security boundary. Hitting it means you are about to do something outside this skill's job, so stop and ask the user — do not look for another route to the same effect.
**No terminal, and a 120-second budget.** These commands run non-interactively under a Bash tool that times out. Avoid every watch and follow mode: `gh pr checks --watch`, `gh run watch`, `glab ci status --live`, `glab ci status --wait`, `glab ci trace`, and `glab ci view` (a full-screen TUI). Run the plain command again instead of waiting inside one.
**Pre-submission checks** (mandatory before every MR/PR):
1. **Namespace verification**: Before creating any MR or PR, confirm the remote you are about to push to is the intended one — and read it from the worktree rather than from a path you retyped, because the worktree's `origin` is what will actually receive the push. From inside `$WORK_DIR`: `gh repo view --json nameWithOwner -q .nameWithOwner`, or on GitLab `glab repo view -F json` piped to a parser — glab has no `--jq`, and the full recipe below fails closed on a glab error rather than comparing an empty string. If the user said "submit to `acme/widget`", verify it resolves to `acme`. Abort and ask on any mismatch.
2. **Response verification**: `gh pr create` and `glab mr create` exit non-zero on failure and print the URL on success, so check the exit status rather than scraping the output for error text. Then confirm the thing exists before reporting success: `gh pr view --json number,url,state` or `glab mr view -F json`.
3. **No live source editing**: Never edit files under production installation paths (e.g., `/srv/app/*/src/`). All source changes must go through worktrees in `$DEVELOPER_REPOS_DIR` and be submitted as MRs/PRs.
## Where Builds and Tests Run
Some deployments run the package managers and language runtimes — `npm`, `npx`, `pnpm`, `yarn`, `node`, `uv`, `uvx`, `pip`, `pip3`, `cargo`, `rustc`, `rustup`, `go`, `bundle`, `gem` — inside a per-user Linux container instead of on the host. That is the default list and an operator may change it. The worktree is the same directory on both sides at the same path, so the files you write with your ordinary tools are the files the build reads. **Run these commands from inside the worktree**: the container refuses a working directory outside `$DEVELOPER_REPOS_DIR`, and that refusal is the commonest failure here.
Nothing about the invocation changes. `uv sync` is `uv sync`, output comes back as it is produced, and the exit status is the command's own — each stage of a pipeline you type is routed separately, so a shim always reports its own command's status. That is not the *pipeline's* status: the rule below about reading a test runner's result through a pipe still applies. Where the deployment does not route these commands, they run on the host as before and the rest of this section never comes up.
Four things do change, and none is guessable from the error you get.
- **A command that runs in the container sees the container's filesystem, not the host's.** Nothing you wrote outside `$DEVELOPER_REPOS_DIR` is there. `uv run python /tmp/analyze.py` fails "no such file or directory" from a command that worked one directory over, and a `#!/usr/bin/env node` script written outside the repository does not run. Put the file in the worktree.
- **`make` is normally not routed, and neither is anything it invokes.** That is deliberate: routing `make` would route every `git`, `gh` and `python3` in a recipe with it. A recipe calling `npm` or `cargo` is fine, because each of those routes on its own. A recipe calling `./node_modules/.bin/<tool>` by path is not, and neither is an absolute `.venv/bin/<tool>` you invoke yourself — that venv's interpreter exists only in the container. Use `uv run <tool>` instead.
- **Do not rely on a background process outliving the command that started it.** The container kills the whole process group when the command ends, so `npm run dev &` is gone when that command returns and `nohup` does not save it. Start and use anything long-running inside one command.
- **Some exit statuses are the transport reporting rather than your command.** Each prints one stderr line starting `istota-devbox-exec:`, which is what tells them apart — including `141` and `130`, which the shim can produce standing in for a command that never ran. `120` — nothing ran: either the container could not be reached, or it refused the request. Read the stderr line: a refusal naming the working directory means you are outside `$DEVELOPER_REPOS_DIR`, so `cd` into the worktree and re-run; a refusal saying the command is routed into the container and not installed there means exactly that — it ran nowhere, and the fix is to install it in the container rather than to look for a host copy (the image ships `uv`, Node, Go and Python; a Rust toolchain is an on-demand `rustup` install and does not survive a `devbox reset`); an unreachable container is worth reporting rather than retrying. `121` — the two halves of the transport are different versions, which is an operator problem. `122` — a malformed reply. `123` — the connection dropped after the command had started, so **what it did is unknown**: say exactly that rather than reporting success or failure, and read the tree's state before repeating anything that writes.
### Installing a project's dependencies
Nothing here says *when* to install — only what this deployment does that a plain `npm install` will not tell you.
- **Never share a `node_modules` or a `.venv` between worktrees.** Vite, Vitest and esbuild resolve plugins and their native binaries through the real path of `node_modules`, so a borrowed tree fails at transform time and surfaces as dozens of unrelated red suites. Each worktree gets its own.
- **Copy the gitignored files the stack needs to run** — `.env`, local config, test fixtures kept out of git. Never print their contents.
- **A refused connection during an install is a boundary, not a flake.** npm and crates.io are reachable, and PyPI unless the operator turned it off; a package that fetches a binary from somewhere else — `node-gyp` headers, Playwright browsers, a GitHub release asset — is not, and no number of retries will change that. Say which host was refused and stop, rather than reinstalling. The operator adds hosts through `extra_hosts`; you cannot. Installs that run in the container are not bounded this way, so a refusal there is a real network failure.
- **A zero exit from an install is not evidence it completed.** npm hides lifecycle-script output, so a postinstall binary fetch refused at that same boundary still prints `added N packages` and exits 0, leaving an empty cache directory the task meets much later as a missing binary. For anything that downloads a binary at install time — Puppeteer, Playwright, `node-gyp`, `esbuild`, `sharp`, any package with an `install` or `postinstall` script — re-run with `--foreground-scripts`, or check the artifact is there, before treating the install as done. Where the package has a skip variable (`PUPPETEER_SKIP_DOWNLOAD=1`, `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1`), set it so the install is honest and fast rather than silently empty — then report the binary as unavailable behind the boundary and stop, because skipping the download does not put it there.
- **From plain Node that boundary reads as a DNS failure.** npm, `uv`, `git` and `curl` honour `HTTPS_PROXY`, so a blocked host is refused at the proxy with a 403 against a `CONNECT` the tool itself names. Node ignores proxy environment by default, so a postinstall script written in JS never reaches the proxy at all: it resolves DNS inside the network namespace and fails with `EAI_AGAIN` or `ENOTFOUND`. Same boundary and same answer — name the host and stop — but it does not look like one, and it is not a flake to retry.
### Running something that takes a while
None of this says *whether* to run tests — that is the request's to make, or the user's. It is how to run one on this host without throwing the result away.
- **The exit status is the result, and a pipe throws it away unless `pipefail` is on.** It is already on in your Bash calls, so `pytest … | tail` no longer exits 0 on a suite that failed. It arrives through the environment, so it reaches a nested `bash script.sh` too — but bash only, and `/bin/sh` on Debian is dash, which has no `pipefail` at all. Write it yourself in a script, or capture and check instead:
```bash
set -o pipefail # same Bash call as the pipe
uv run pytest -q --no-header | tail -n 20
```
Or capture, check, then read — no shell option involved, and the whole log survives:
```bash
uv run pytest -q --no-header > "$WORK_DIR/.check.log" 2>&1; STATUS=$?
tail -n 20 "$WORK_DIR/.check.log"
exit "$STATUS" # last statement of the call
```
- **Cap the worker count to 2 before you run anything.** `pytest -n auto` and vitest's default size their pool from `cpu_count()`, so each suite claims the whole box — and you share it with the daemon and with other tasks. Two at once ask for more than exists, and the result is timeouts that have nothing to do with the code. Set `PYTEST_XDIST_AUTO_NUM_WORKERS=2` in the same call as the run, since shell state does not carry between calls; vitest takes `--maxWorkers=2`, `make` takes `-j2`. Use the environment rather than the repository's config: `-n auto` is right on a laptop and in CI, and this host is the special case.
- **A run that might outlast one tool call goes detached, not up against the cap.** Do not reach for `timeout 590 …`: it guarantees a kill at the moment a long run might have finished and throws away what it had produced. **Do not plan against a specific number of seconds** — the cap depends on which brain is driving and on what the caller passed, so treat it as short and detach anything that might not comfortably fit.
```bash
cd "$WORK_DIR" || exit 1
rm -f .check.status
setsid sh -c 'uv run pytest -q --no-header > .check.log 2>&1; echo $? > .check.status' &
```
```bash
cd "$WORK_DIR" && cat .check.status 2>/dev/null || echo still running # poll
cd "$WORK_DIR" && tail -n 30 .check.log # separate call, once it appeared
```
Three details, each load-bearing. `setsid`, not a bare `&`: your bash call kills its whole process group when it returns, so a merely backgrounded run dies the moment the call that started it finishes — a new session is what escapes that. `cd` on its own line, because `&` binds looser than `&&`, so `cd "$WORK_DIR" && cmd &` backgrounds the `cd` too and scatters the files across two directories. And the **status file is the result**: it appearing is what "finished" means, and what it holds is the exit code. Do not read pass or fail out of the log text, and do not poll for a pid — a missing pid file reads exactly like a finished run.
- **A killed run is not re-run as it was.** Not with a longer timeout, not with the same command again: one job spent forty minutes on four identical attempts, produced no coverage at all and held the host down throughout. Run something smaller instead. If every attempt dies at the same point and unrelated tests fail on time, the machine is the finding — stop and report that.
- **Say what you ran**: the command, the paths and stacks it covered, and the exit status it was read from. Partial coverage labelled honestly is usable; an impression of a complete run is not.
## Directory Layout
```
$DEVELOPER_REPOS_DIR/
├── namespace/project.git/ # bare clone
├── namespace/project--istota-42-add-auth/ # worktree for task 42
├── namespace/project--istota-55-fix-bug/ # worktree for task 55
└── .package-caches/ # uv + npm caches; leave it alone
```
- Bare clones go in `<namespace>/<project>.git/`; worktrees are siblings, `<namespace>/<project>--<branch-slug>/`
## Cloning a Repository
First time — create a bare clone:
```bash
BARE_DIR="$DEVELOPER_REPOS_DIR/namespace/project.git"
FRESH=""
if [ ! -d "$BARE_DIR" ]; then
mkdir -p "$(dirname "$BARE_DIR")"
# Use $GITLAB_URL or $GITHUB_URL depending on where the repo lives
git clone --bare "$GITLAB_URL/namespace/project.git" "$BARE_DIR"
git -C "$BARE_DIR" config remote.origin.fetch "+refs/heads/*:refs/remotes/origin/*"
FRESH=1
fi
# Always fetch latest
git -C "$BARE_DIR" fetch origin
# A fresh clone runs no hooks: core.hooksPath is per-clone config, so a repo
# whose committed hooks scan for credentials gets none of it (ISSUE-291).
# Worktrees inherit this; it is a no-op where the path does not exist.
git -C "$BARE_DIR" config core.hooksPath .githooks
# Everything below restores the invariant stated after this block. It runs on
# every pass, not just at clone time: the shape lives on disk, so a clone made
# before ISSUE-269 is still broken and the `if` above never runs for it again.
# `rev-parse`, not `symbolic-ref`: a dangling origin/HEAD survives the upstream
# default branch being renamed and reads as present to a check that does not
# resolve it (code_review's `_default_base` guards the same state).
git -C "$BARE_DIR" rev-parse -q --verify origin/HEAD >/dev/null 2>&1 ||
git -C "$BARE_DIR" remote set-head origin -a
DEFAULT_REF=$(git -C "$BARE_DIR" symbolic-ref -q refs/remotes/origin/HEAD)
DEFAULT_BRANCH="${DEFAULT_REF#refs/remotes/origin/}"
[ -n "$DEFAULT_BRANCH" ] || { echo "origin has no default branch"; exit 1; }
# HEAD below refs/heads/ (ISSUE-269): pointed into refs/remotes/ it reads as
# stale just the same, but `worktree add -b` resolves HEAD while writing its new
# local head and aborts with `fatal: HEAD not found below refs/heads!`. `-q` so
# a detached HEAD is a state to repair, not a `fatal:` in the log.
case "$(git -C "$BARE_DIR" symbolic-ref -q HEAD)" in
"refs/heads/$DEFAULT_BRANCH") ;;
*) git -C "$BARE_DIR" symbolic-ref HEAD "refs/heads/$DEFAULT_BRANCH" ;;
esac
# ...and nothing under it (ISSUE-125): `clone --bare` fills refs/heads/* once,
# at clone time, and the remote-tracking refspec never updates them again, so a
# local `main` stays frozen at clone day while origin/main moves on and
# `git show main:db.py` silently returns clone-day source. Deleting the fossils
# turns that silent-wrong into a loud `invalid object name`. Only on clone day
# is *every* local head a fossil: later, refs/heads/ also holds the
# {BOT_DIR}/<task> branch of every worktree ever made here, and one whose
# worktree was pruned may be the only copy of that work.
if [ -n "$FRESH" ]; then
FOSSILS=$(git -C "$BARE_DIR" for-each-ref --format='%(refname:short)' refs/heads/)
else
FOSSILS="$DEFAULT_BRANCH"
fi
CHECKED_OUT=$(git -C "$BARE_DIR" worktree list --porcelain | sed -n 's/^branch refs\/heads\///p')
for ref in $FOSSILS; do
# `update-ref -d`, not `branch -D`: it takes a full refname and consults
# neither HEAD nor the worktree list, so CHECKED_OUT is the only thing
# deciding what survives.
echo "$CHECKED_OUT" | grep -qx "$ref" || git -C "$BARE_DIR" update-ref -d "refs/heads/$ref"
done
```
### Reading current source from a bare clone
**Invariant: `refs/remotes/origin/HEAD` resolves, `HEAD` is a `refs/heads/` ref that does not, and you never name a local branch — always `origin/<branch>` or `origin/HEAD`.** The first lets every later step discover the base branch instead of assuming `main`; the rest keeps a clone-day fossil unreadable rather than silently stale. To read the live tree in one step that can't point at a stale ref:
```bash
# dev-show <BARE_DIR> <path> — current source from origin/HEAD, always fetched.
git -C "$BARE_DIR" fetch -q origin && git -C "$BARE_DIR" show origin/HEAD:"$path"
```
Use this (or `git -C "$BARE_DIR" log origin/HEAD`) for any hand-rolled verification read. Never `git show main:<path>` / `git log master` on a bare clone.
## Creating a Worktree for Development
Two reads before you cut one, whatever the task is.
**The base branch is whatever the repository says it is.** Never assume `main`; plenty are still on `master`, and a worktree branched from a base that does not exist is the commonest way this dies.
```bash
git -C "$BARE_DIR" symbolic-ref --quiet --short refs/remotes/origin/HEAD # -> origin/main | origin/master
git -C "$BARE_DIR" fetch origin --prune
```
**A credential in the repository's own config is a stop.** Repo-local config is not covered by the environment's git hardening, and everything under `$DEVELOPER_REPOS_DIR` is writable by tasks, so this is a tripwire rather than a guarantee — the daemon sweeps these at setup, and one appearing here appeared afterwards.
```bash
git -C "$BARE_DIR" config --list --includes | awk -F= '{ k = $1 }
k ~ /:\/\/[^\/@]*:[^\/@]+@/ { print "credential embedded in a config key"; next }
/^http\..*extraheader=/ || /:\/\/[^\/@]*:[^\/@]+@/ { print k }' # end of the credential check
```
It prints a setting's *name*, never its value. If it prints anything: stop, report that line as a credential to rotate, and do not work in that repository — every worktree you cut inherits the setting, and the next `git remote -v` puts it in your context.
```bash
TASK_ID="$ISTOTA_TASK_ID"
SLUG="add-auth" # short description, lowercase, hyphens
BRANCH="{BOT_DIR}/${TASK_ID}-${SLUG}"
BARE_DIR="$DEVELOPER_REPOS_DIR/namespace/project.git"
WORK_DIR="$DEVELOPER_REPOS_DIR/namespace/project--{BOT_DIR}-${TASK_ID}-${SLUG}"
# Create branch from latest main (or master — check which exists)
git -C "$BARE_DIR" fetch origin
# Assign, then default. `cmd | sed || echo main` takes the exit status of the
# *pipeline*, which is sed's, and sed succeeds on empty input — so a missing
# origin/HEAD gave an empty DEFAULT_BRANCH and `worktree add origin/` rather
# than the intended fallback.
DEFAULT_BRANCH=$(git -C "$BARE_DIR" symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null)
DEFAULT_BRANCH="${DEFAULT_BRANCH#origin/}"
DEFAULT_BRANCH="${DEFAULT_BRANCH:-main}"
git -C "$BARE_DIR" worktree add -b "$BRANCH" "$WORK_DIR" "origin/$DEFAULT_BRANCH"
```
All work happens inside `$WORK_DIR`.
## GitLab: Pushing and Creating a Merge Request
Run these from inside `$WORK_DIR` — both CLIs read the repository from the worktree's `origin` remote.
```bash
cd "$WORK_DIR"
# glab has no `--jq`: a field read is `-F json` piped to a parser. pipefail so a
# glab that fails after printing is not masked by the python3 that parsed it.
set -o pipefail
# REQUIRED: confirm the project before creating anything (pre-submission check 1).
# `||` fails closed: a glab error aborts rather than becoming an empty string.
RESOLVED=$(glab repo view -F json | python3 -c 'import json,sys; print(json.load(sys.stdin)["path_with_namespace"])') || {
echo "ERROR: could not read origin's project path from glab. Aborting."
exit 1
}
if [ "$RESOLVED" != "namespace/project" ]; then
echo "ERROR: origin resolves to '$RESOLVED', not 'namespace/project'. Aborting."
exit 1
fi
git push -u origin "$BRANCH"
# The reviewer variable is absent entirely unless the operator configured one,
# and `--reviewer ""` is an error rather than a no-op — so build the flag
# rather than interpolating it.
REVIEWER_ARGS=""
[ -n "${GITLAB_REVIEWER:-}" ] && REVIEWER_ARGS="--reviewer $GITLAB_REVIEWER"
glab mr create \
--source-branch "$BRANCH" \
--target-branch "$DEFAULT_BRANCH" \
--title "Add user authentication" \
--description "Implements JWT auth. Created by {BOT_NAME} task $TASK_ID." \
--remove-source-branch \
$REVIEWER_ARGS \
--yes
```
`--yes` skips the confirmation prompt; without it the command waits for a terminal that is not there. If `glab` rejects the reviewer — `failed to find user by name` — retry once without `--reviewer` so the merge request still opens, and tell the user that `developer.gitlab_reviewer` needs the reviewer's GitLab username. Do not guess a different value for it.
Then verify it exists, and capture the id for later steps (pre-submission check 2):
```bash
set -o pipefail
# Abort rather than carry an empty id: `glab mr merge ""` acts on whatever MR the
# current branch has.
MR_IID=$(glab mr view -F json | python3 -c 'import json,sys; print(json.load(sys.stdin)["iid"])') || {
echo "ERROR: could not read the merge request id. Aborting."
exit 1
}
glab mr view -F json | python3 -c 'import json,sys; d=json.load(sys.stdin); print("!%s %s" % (d["iid"], d["web_url"]))'
```
## GitHub: Pushing and Creating a Pull Request
```bash
cd "$WORK_DIR"
# REQUIRED: confirm the repository before creating anything.
# Substitute the owner/repo the user actually asked for.
RESOLVED=$(gh repo view --json nameWithOwner -q .nameWithOwner)
if [ "$RESOLVED" != "owner/repo" ]; then
echo "ERROR: origin resolves to '$RESOLVED', not 'owner/repo'. Aborting."
exit 1
fi
git push -u origin "$BRANCH"
REVIEWER_ARGS=""
[ -n "${GITHUB_REVIEWER:-}" ] && REVIEWER_ARGS="--reviewer $GITHUB_REVIEWER"
gh pr create \
--head "$BRANCH" \
--base "$DEFAULT_BRANCH" \
--title "Add user authentication" \
--body "Implements JWT auth. Created by {BOT_NAME} task $TASK_ID." \
$REVIEWER_ARGS
```
`--reviewer` requests the review as part of creating the pull request — this is the whole reviewer step, not a follow-up call.
Then verify, and capture the number for later steps:
```bash
PR_NUMBER=$(gh pr view --json number -q .number)
gh pr view --json number,url,state -q '"#\(.number) \(.state) \(.url)"'
```
A one-line description keeps the shell quoting simple. For a real multi-paragraph body, write it to a file first and pass `gh pr create --body-file BODY.md`; on the GitLab side pass the file's contents with `glab mr create --description "$(cat BODY.md)"`. Do not build a multi-paragraph string inline — the escaping is where these recipes break.
## Follow-Up Work on Existing MRs/PRs
To push additional commits to an open MR/PR, reuse the existing worktree:
```bash
WORK_DIR="$DEVELOPER_REPOS_DIR/namespace/project--istota-42-add-auth"
cd "$WORK_DIR"
# Make changes, then stage the specific files you touched — never `git add -A`.
# See the `commit` companion for the message format and the scrub rules.
git status --short
git add src/validation.py tests/test_validation.py
git commit -m "Address review feedback: add input validation"
git push origin HEAD
```
## GitLab: Listing and Merging MRs
```bash
set -o pipefail
# $MR_IID came from an earlier block, and a block is its own shell. Re-check it:
# `glab mr merge ""` merges the current branch's MR rather than refusing.
[ -n "${MR_IID:-}" ] || { echo "ERROR: MR_IID is empty. Re-read it before merging."; exit 1; }
glab mr list # open MRs, human-readable
glab mr list -F json | python3 -c 'import json,sys; print("\n".join("!%s %s" % (m["iid"], m["title"]) for m in json.load(sys.stdin)) or "(none open)")'
glab mr view "$MR_IID" # description, discussions, pipeline state
glab mr diff "$MR_IID"
glab mr merge "$MR_IID" --yes
```
Merge options: `--squash`, `--rebase`, `--remove-source-branch`. `--yes` is required in a non-interactive context.
**This may queue rather than merge.** When a pipeline is running, glab enables auto-merge by default and still exits 0 — so the MR is scheduled behind CI, not merged. Pass `--auto-merge=false` if you mean now, and read the command's output before reporting a merge as done.
## GitHub: Listing and Merging PRs
```bash
gh pr list # open PRs, human-readable
gh pr list --json number,title -q '.[] | "#\(.number) \(.title)"'
gh pr view "$PR_NUMBER"
gh pr diff "$PR_NUMBER"
gh pr merge "$PR_NUMBER" --squash --delete-branch
```
Merge methods: `--merge`, `--squash`, `--rebase`. Whether the bot may merge at all is a forge-side branch-protection question — if the merge is refused, report it rather than looking for another way to land the change.
## Watching CI
This is the loop that makes a bot useful on a real repository: push, see what broke, fix it, push again.
```bash
# GitHub
gh pr checks # one line per check
gh run list --branch "$BRANCH" --limit 5
RUN_ID=$(gh run list --branch "$BRANCH" --limit 1 --json databaseId -q '.[0].databaseId')
gh run view "$RUN_ID" --log-failed # only the failing steps
# GitLab
glab ci status # current branch's pipeline
glab ci list --ref "$BRANCH"
set -o pipefail
PIPELINE_ID=$(glab ci list --ref "$BRANCH" --per-page 1 -F json | python3 -c 'import json,sys; print(json.load(sys.stdin)[0]["id"])') || {
echo "No pipeline for $BRANCH yet, or glab could not read one. Re-run shortly."
exit 1
}
glab ci get -p "$PIPELINE_ID"
```
`gh pr checks` exits 0 when everything passed, 8 when checks are still pending, and non-zero otherwise — so treat 8 as "come back later", not as a failure.
`gh run view --log-failed` is the one to reach for — it prints only the failing steps, where `--log` prints the whole run and will bury the transcript.
**`gh run download` is not available.** Artifact downloads redirect to a per-request Azure Blob Storage shard, and the only network-allowlist entry that would cover it opens all of Azure Blob Storage to this sandbox. Logs carry what a fix needs; artifacts are not worth that trade. Do not try to route around it.
## Worktree Retention and Cleanup
**Worktrees are reaped automatically once their work has landed. Do not clean up after yourself, and never clean up after another task** — you cannot see whether the task that made it is still running, and the sweep's retention window can.
`worktree_reaper.py` runs on the scheduler's own interval, not at task start. It removes only a worktree that is clean (untracked and gitignored files included), unlocked, idle for `developer.worktree_retention_hours` (24 by default), and carrying no commit that is not already upstream — squash- and rebase-merged branches included, since it asks `git cherry` rather than testing ancestry. Everything else is kept and counted. So leaving a worktree in place after opening an MR is correct: the branch is not upstream, the sweep keeps it, and a later sweep takes it away once the MR merges. Uncommitted work there is safe only until the branch lands. `git worktree lock "$WORK_DIR" --reason "..."` pins one indefinitely; unlock it when done, or it is a leak with your name on it.
**To read the default branch as a tree**, use your own task worktree — the recipe above branches from `origin/$DEFAULT_BRANCH`, so it *is* the default branch until you commit. Never `worktree add` a checkout named after a branch: `project--main` is detached, outside the naming convention, and reads like a canonical checkout other work might trust (ISSUE-288).
To take a worktree out by hand rather than waiting for the sweep:
```bash
git -C "$BARE_DIR" worktree remove "$WORK_DIR" # no --force: a refusal is the answer
git -C "$BARE_DIR" update-ref -d "refs/heads/$BRANCH"
```
`update-ref -d`, not `branch -d`: this clone's HEAD points at a deleted ref by design (the fossil cleanup above) and `branch -d` consults HEAD, so it fails on every branch here, merged ones included.
## Quick Reference
| Task | GitHub | GitLab |
|---|---|---|
| Confirm the repo | `gh repo view --json nameWithOwner` | `glab repo view -F json` |
| Create the change | `gh pr create` | `glab mr create --yes` |
| List open | `gh pr list` | `glab mr list` |
| Read one | `gh pr view N` | `glab mr view N` |
| Its diff | `gh pr diff N` | `glab mr diff N` |
| Comment on it | `gh pr comment N --body "..."` | `glab mr note create N -m "..."` |
| Request a review | `gh pr edit N --add-reviewer USER` | `glab mr update N --reviewer USER` |
| CI state | `gh pr checks` | `glab ci status` |
| Failing CI logs | `gh run view ID --log-failed` | `glab ci get -p PIPELINE_ID` |
| Merge it | `gh pr merge N --squash` | `glab mr merge N --yes` |
| File an issue | `gh issue create --title ... --body ...` | `glab issue create --title ... --description ...` |
| Look up a user | `gh api /users/USERNAME` | `glab api /users?username=USERNAME` |
**The two CLIs do not have the same structured-output surface.** `gh` takes `--json` plus `--jq`/`-q` and filters in-process. `glab` takes `-F json` and nothing else — there is no `--jq` — so a glab field read is `-F json` piped to `python3`, and the pipeline needs `set -o pipefail` for its exit status to mean anything. Newer glab does have `--jq`, which is the trap: the deployment installs glab from the Debian archive on the Ansible path and pins a much newer build in the Docker image, so a recipe here has to run on the older of the two. Check `glab <command> --help` on the deployed binary before using a flag you know from `gh`. Anything not covered here: `gh <command> --help`, `glab <command> --help`. `gh api` and `glab api` reach any read endpoint the token allows; a write goes through the verb.
Check the help before trusting a spelling from memory — the deployed CLIs may be older than the ones these examples were written against, and `glab mr note` in particular was restructured. Newer glab wants `glab mr note create N -m "..."`; older glab wants `glab mr note N -m "..."` with no subcommand. Run `glab mr note --help` and use whichever it shows.
## Error Handling
- **Whenever something says stop**: stop, change nothing further, and leave the worktree and branch exactly as they are — they hold the work. Report what failed with the command output, the worktree path and branch name, what state the base branch is in, and what you would do next. **Never delete a worktree whose work did not land.**
- **Tests fail**: Fix the code and re-run. Do not push failing tests.
- **Push rejected (non-fast-forward)**: Fetch and rebase onto the target branch:
```bash
cd "$WORK_DIR"
git fetch origin "$DEFAULT_BRANCH"
git rebase "origin/$DEFAULT_BRANCH"
# Resolve conflicts if any, then force-push — YOUR OWN topic branch only.
git push origin "$BRANCH" --force-with-lease
```
**Never force-push a shared branch.** `--force-with-lease` is permitted on `$BRANCH`, the topic branch you created for this task, and nowhere else. A rejected push to `$DEFAULT_BRANCH` or any branch you did not create means someone else moved it: report it via the abort path and let the user decide. Do not resolve it.
- **MR/PR has merge conflicts**: Rebase the worktree branch onto the latest target and force-push `$BRANCH`, subject to the same restriction.
- **Exit 3, "not permitted by this deployment"**: a refused verb. Stop and tell the user what you were about to do and why you wanted to. Do not reach for `gh api`, a raw `curl`, or the web UI to get the same effect.
- **Exit 4 or 5 from `gh` / `glab`**: the credential path, not your command. Exit 4 means no credential proxy is reachable; exit 5 means the proxy refused or has no token for that forge. Both are deployment problems — report them, and note that only the affected forge is down (a missing GitLab token does not stop `gh`).
- **Exit 2**: a usage error, or one of the retired `github-api` / `gitlab-api` names. Use `gh` / `glab`.
- **Exit 7**: the wrapper is misconfigured (no CLI config directory). A deployment problem — report it.
- **Exit 6**: the real CLI is missing or not executable on this host. Report the path in the message; the operator has to install it.
- **Project not found**: Verify the namespace/project or owner/repo path matches exactly (case-sensitive), and that the token's scope covers it. A fine-grained token restricted to a repository list returns 404, not 403, for anything outside it — so "not found" can mean "not granted".
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!