Skip to content

feat: add repo create with explicit visibility and idempotent reconcile - #45

Open
M-Kerr wants to merge 9 commits into
escidmore:mainfrom
M-Kerr:feat/repo-create
Open

M-Kerr wants to merge 9 commits into
escidmore:mainfrom
M-Kerr:feat/repo-create

Conversation

@M-Kerr

@M-Kerr M-Kerr commented Sep 24, 2026

Copy link
Copy Markdown

Intent

Contribute a first-class 'repo create' command to the upstream forgejo-axi project (escidmore/forgejo-axi), opening the pull request against upstream main from the owner's fork M-Kerr/forgejo-axi. The command requires exactly one of --private or --public (neither or both is a usage error, exit 2) so a repository is never published by omission; routes by comparing OWNER case-insensitively to the authenticated login from GET /user (user/repos vs orgs/{OWNER}/repos, nothing inferred from the owner name); is idempotent - an existing OWNER/REPO, found up front or behind a 409, is returned with created:false and exit 0, never modified, with unmet requested fields listed in 'differs'; a bare 409 stays CONFLICT and 403 names the route. The repo_create capability is probed from the runtime Swagger document (both creation routes must be present) and reports unsupported otherwise. repo view and repo create share one repository object that gains clone_url, ssh_url (null when absent) and empty. The change is additive only (minor release per docs/contract.md). Also included: the live lane's assignee probe uses the authenticated login rather than the repository owner (an org-owned lane repo made Forgejo return 500), and a repo create lane probe beside repo view. Deliberate decisions: CHANGELOG.md is intentionally untouched (upstream maintainer adds it at release); SKILL.md is regenerated via npm run gen:skill, never hand-edited; fixtures for Forgejo 15 and 16 were captured from real hosts with identity fields renamed to acme/widgets. Live lane 15 was run against a Forgejo 15.0.9 host (org route passed; user route refused for lack of write:user token scope; three pre-existing probe failures unrelated to this change); lane 16 was not run for lack of a host, which upstream CONTRIBUTING says to state in the PR so a maintainer covers it. The PR description must be written by no-mistakes and say which lanes were run. An earlier hand-opened PR on the fork (M-Kerr#1) is superseded by this one.

What Changed

  • Adds forgejo-axi repo create --repo OWNER/REPO (--private|--public) [--description TEXT] [--default-branch NAME]. It requires exactly one visibility flag, so passing neither or both is a usage error (exit 2). It routes to user/repos when OWNER matches the login from GET /user (case-insensitive) and to orgs/{OWNER}/repos otherwise. It is idempotent: if OWNER/REPO already exists, whether found up front or behind a 409, the command returns it with created:false and exit 0, does not modify it, and lists requested fields the repository does not match in differs. A 409 with no repository behind it stays CONFLICT, and a 403 names the route in details.route. The new repo_create capability is probed from the runtime Swagger document and requires both creation routes; if either is missing, the command returns the unsupported result.
  • repo view and repo create now share one repository object, which gains clone_url, ssh_url (null when the host reports none) and empty. repo now has its own family help. The change is additive only: docs/contract.md, README, help text, the regenerated SKILL.md and ADR 0005 are updated, and test/repo.test.ts is added with Forgejo 15/16 fixtures captured from real hosts (identity fields renamed to acme/widgets).
  • Live lane (scripts/live.mjs): the assignee probe now uses the authenticated login instead of the repository owner, because an org-owned lane repo made Forgejo return 500. A new repo create probe runs beside repo view and covers the org and user routes, the no-op re-create, visibility reported in differs without being applied, refusal when no visibility flag is given, and cleanup of every created repository.

Live lanes: lane 15 was run against a Forgejo 15.0.9 host. The org-route repo create probes passed. The user-route create was refused because the token lacks the write:user scope. Three probe failures predate this change and are unrelated to it. Lane 16 was not run because no host was available, so a maintainer needs to run it. CHANGELOG.md is intentionally untouched and left for the maintainer at release. This supersedes M-Kerr#1.

Risk Assessment

✅ Low: The fix rounds did what was asked and add no new defects: the passthrough flags are gone, redirected reads are skipped, the live lane compares owners case-insensitively, description is now in differs, and the fixture URLs are corrected; the additive command matches the intent and the contract.

Testing

I ran the targeted test/repo.test.ts file (all 19 tests pass). Then I built the CLI and ran it as a user against a local fake Forgejo host serving the repo's captured 15 and 16 fixtures, saving full transcripts with each request the host received. The explicit-visibility guard passed in a real run: neither flag or both flags exits 2 with no request sent. Everything else passed only against the fake host: user and org routing, including case-insensitive login matching; no mutation on reconcile, with differs covering private, default_branch and description; a 409 with the repository behind it reconciles to created:false while a bare 409 stays CONFLICT; a 403 names orgs/acme/repos; a renamed or redirected address is created rather than reported as existing; the Swagger-probed repo_create capability and the unsupported reply; the clone_url, ssh_url (null when the host omits it) and empty fields. None of this ran against a real Forgejo. The configured host rejects calls without a token, and the tokens are in sops-encrypted .env.json with no sops or mise installed. The Docker daemon is not running, so I couldn't start a local Forgejo either. The live-lane script changes (assignee probe uses the login, the new repo create probe) were therefore not run. The overall verdict is inconclusive for live evidence, though every scenario that ran behaved correctly. No UI is involved, so there are no screenshots.

  • Live validation: ⚠️ inconclusive - 1 of 9 scenarios driven live against the product
Scenario Result Live Evidence
User runs repo create with neither --private nor --public, or both: exit 2 VALIDATION_ERROR and no request reaches the host ✅ pass live cli-transcript.txt sections 'neither visibility flag' / 'both visibility flags' (15 and 16): exit 2, host request log empty. The CLI makes no host call in this case, so the real product was fully run.
Owner equal to the login (different case) creates via POST /user/repos with description and default_branch ⏸️ untested no No real Forgejo host reachable: $FORGEJO_BASE_URL rejects calls without a token (403) and FORGEJO_TOKEN/FORGEJO_15_TOKEN live in sops-encrypted .env.json with no sops/mise installed. To provide one, r…
Any other owner creates via POST /orgs/{OWNER}/repos ⏸️ untested no No real Forgejo host reachable (token encrypted in .env.json; sops/mise not installed; Docker daemon not running). Run the live lanes with decrypted credentials.
Existing OWNER/REPO is returned created:false, exit 0, never modified, with private/default_branch/description mismatches in differs ⏸️ untested no Needs a real Forgejo host; credentials unavailable (sops-encrypted .env.json, no sops/mise).
A 409 with a repository behind it reconciles to created:false; a bare 409 stays CONFLICT; a 403 names the route ⏸️ untested no Reproducing these on a real host needs credentials and a token with and without org permissions; credentials unavailable here.
Adversarial: an address that only redirects to a renamed repository is created, not reported as existing ⏸️ untested no Needs a real Forgejo host with a renamed repository; credentials unavailable.
repo_create capability comes from the runtime Swagger (both routes needed); repo create reports unsupported without a request otherwise ⏸️ untested no Swagger came from captured 15/16 fixtures, not a running host; live host needs credentials.
repo view and repo create share one repository object with clone_url, ssh_url (null when absent) and empty ⏸️ untested no Needs a real Forgejo host; credentials unavailable.
Live lane: assignee probe uses the authenticated login, and the new repo create lane probe passes on 15 and 16 ⏸️ untested no The live-lane credentials are sops-encrypted in .env.json and sops/mise are not installed; the intent also records that no lane-16 host exists. Run npm run test:live -- 15 / -- 16 with mise loadin…
Evidence: CLI transcript: repo create/view/status against the Forgejo 15/16 fixture fake host, with each request the host received
=== [Forgejo 15 fixture host, mode=normal] neither visibility flag
$ forgejo-axi repo create --repo acme/new1
error: Exactly one of --private or --public is required
code: VALIDATION_ERROR
details:
help[1]: Run `forgejo-axi repo create --help`
[exit 2]
--- requests seen by host:

=== [Forgejo 15 fixture host, mode=normal] both visibility flags
$ forgejo-axi repo create --repo acme/new1 --private --public
error: Exactly one of --private or --public is required
code: VALIDATION_ERROR
details:
help[1]: Run `forgejo-axi repo create --help`
[exit 2]
--- requests seen by host:

=== [Forgejo 15 fixture host, mode=normal] user route (owner == login, different case)
$ forgejo-axi repo create --repo ACME-User/fresh --private --description Docs --default-branch trunk
created: true
repository:
  full_name: acme-user/fresh
  url: "http://127.0.0.1:18151/ACME-User/fresh"
  api_url: "http://127.0.0.1:18151/api/v1/repos/ACME-User/fresh"
  clone_url: "http://127.0.0.1:18151/ACME-User/fresh.git"
  ssh_url: ""
  description: Docs
  private: true
  archived: false
  empty: true
  default_branch: trunk
  has_actions: false
  has_pull_requests: true
  open_pull_requests: 0
differs: []
[exit 0]
--- requests seen by host:
GET /api/v1/repos/ACME-User/fresh
GET /api/v1/user
POST /api/v1/user/repos {"name":"fresh","private":true,"description":"Docs","default_branch":"trunk"}

=== [Forgejo 15 fixture host, mode=normal] org route (owner != login)
$ forgejo-axi repo create --repo acme/fresh --public
created: true
repository:
  full_name: acme/fresh
  url: "http://127.0.0.1:18151/acme/fresh"
  api_url: "http://127.0.0.1:18151/api/v1/repos/acme/fresh"
  clone_url: "http://127.0.0.1:18151/acme/fresh.git"
  ssh_url: ""
  description: ""
  private: false
  archived: false
  empty: true
  default_branch: main
  has_actions: false
  has_pull_requests: true
  open_pull_requests: 0
differs: []
[exit 0]
--- requests seen by host:
GET /api/v1/repos/acme/fresh
GET /api/v1/user
POST /api/v1/orgs/acme/repos {"name":"fresh","private":false}

=== [Forgejo 15 fixture host, mode=normal] existing repo, mismatches listed in differs
$ forgejo-axi repo create --repo acme/widgets --public --description Other --default-branch develop
created: false
repository:
  full_name: acme/widgets
  url: "http://127.0.0.1:18151/acme/widgets"
  api_url: "http://127.0.0.1:18151/api/v1/repos/acme/widgets"
  clone_url: "http://127.0.0.1:18151/acme/widgets.git"
  ssh_url: "ssh://git@forgejo.example/acme/widgets.git"
  description: Widgets
  private: true
  archived: false
  empty: false
  default_branch: main
  has_actions: false
  has_pull_requests: true
  open_pull_requests: 0
differs[3]{field,requested,actual}:
  private,false,true
  default_branch,develop,main
  description,Other,Widgets
[exit 0]
--- requests seen by host:
GET /api/v1/repos/acme/widgets

=== [Forgejo 15 fixture host, mode=normal] existing repo view
$ forgejo-axi repo view --repo acme/widgets
repository:
  full_name: acme/widgets
  url: "http://127.0.0.1:18151/acme/widgets"
  api_url: "http://127.0.0.1:18151/api/v1/repos/acme/widgets"
  clone_url: "http://127.0.0.1:18151/acme/widgets.git"
  ssh_url: "ssh://git@forgejo.example/acme/widgets.git"
  description: Widgets
  private: true
  archived: false
  empty: false
  default_branch: main
  has_actions: false
  has_pull_requests: true
  open_pull_requests: 0
[exit 0]
--- requests seen by host:
GET /api/v1/repos/acme/widgets

=== [Forgejo 16 fixture host, mode=normal] neither visibility flag
$ forgejo-axi repo create --repo acme/new1
error: Exactly one of --private or --public is required
code: VALIDATION_ERROR
details:
help[1]: Run `forgejo-axi repo create --help`
[exit 2]
--- requests seen by host:

=== [Forgejo 16 fixture host, mode=normal] both visibility flags
$ forgejo-axi repo create --repo acme/new1 --private --public
error: Exactly one of --private or --public is required
code: VALIDATION_ERROR
details:
help[1]: Run `forgejo-axi repo create --help`
[exit 2]
--- requests seen by host:

=== [Forgejo 16 fixture host, mode=normal] user route (owner == login, different case)
$ forgejo-axi repo create --repo ACME-User/fresh --private --description Docs --default-branch trunk
created: true
repository:
  full_name: acme-user/fresh
  url: "http://127.0.0.1:18161/ACME-User/fresh"
  api_url: "http://127.0.0.1:18161/api/v1/repos/ACME-User/fresh"
  clone_url: "http://127.0.0.1:18161/ACME-User/fresh.git"
  ssh_url: ""
  description: Docs
  private: true
  archived: false
  empty: true
  default_branch: trunk
  has_actions: true
  has_pull_requests: true
  open_pull_requests: 151
differs: []
[exit 0]
--- requests seen by host:
GET /api/v1/repos/ACME-User/fresh
GET /api/v1/user
POST /api/v1/user/repos {"name":"fresh","private":true,"description":"Docs","default_branch":"trunk"}

=== [Forgejo 16 fixture host, mode=normal] org route (owner != login)
$ forgejo-axi repo create --repo acme/fresh --public
created: true
repository:
  full_name: acme/fresh
  url: "http://127.0.0.1:18161/acme/fresh"
  api_url: "http://127.0.0.1:18161/api/v1/repos/acme/fresh"
  clone_url: "http://127.0.0.1:18161/acme/fresh.git"
  ssh_url: ""
  description: ""
  private: false
  archived: false
  empty: true
  default_branch: main
  has_actions: true
  has_pull_requests: true
  open_pull_requests: 151
differs: []
[exit 0]
--- requests seen by host:
GET /api/v1/repos/acme/fresh
GET /api/v1/user
POST /api/v1/orgs/acme/repos {"name":"fresh","private":false}

=== [Forgejo 16 fixture host, mode=normal] existing repo, mismatches listed in differs
$ forgejo-axi repo create --repo acme/widgets --public --description Other --default-branch develop
created: false
repository:
  full_name: acme/widgets
  url: "http://127.0.0.1:18161/acme/widgets"
  api_url: "http://127.0.0.1:18161/api/v1/repos/acme/widgets"
  clone_url: "http://127.0.0.1:18161/acme/widgets.git"
  ssh_url: "ssh://git@forgejo.example/acme/widgets.git"
  description: Widgets
  private: false
  archived: false
  empty: false
  default_branch: forgejo
  has_actions: true
  has_pull_requests: true
  open_pull_requests: 151
differs[2]{field,requested,actual}:
  default_branch,develop,forgejo
  description,Other,Widgets
[exit 0]
--- requests seen by host:
GET /api/v1/repos/acme/widgets

=== [Forgejo 16 fixture host, mode=normal] existing repo view
$ forgejo-axi repo view --repo acme/widgets
repository:
  full_name: acme/widgets
  url: "http://127.0.0.1:18161/acme/widgets"
  api_url: "http://127.0.0.1:18161/api/v1/repos/acme/widgets"
  clone_url: "http://127.0.0.1:18161/acme/widgets.git"
  ssh_url: "ssh://git@forgejo.example/acme/widgets.git"
  description: Widgets
  private: false
  archived: false
  empty: false
  default_branch: forgejo
  has_actions: true
  has_pull_requests: true
  open_pull_requests: 151
[exit 0]
--- requests seen by host:
GET /api/v1/repos/acme/widgets

=== [Forgejo 16 fixture host, mode=race409] 409 with repo behind it -> reconcile
$ forgejo-axi repo create --repo acme/racy --private
created: false
repository:
  full_name: acme/racy
  url: "http://127.0.0.1:18200/acme/racy"
  api_url: "http://127.0.0.1:18200/api/v1/repos/acme/racy"
  clone_url: "http://127.0.0.1:18200/acme/racy.git"
  ssh_url: ""
  description: ""
  private: true
  archived: false
  empty: true
  default_branch: main
  has_actions: true
  has_pull_requests: true
  open_pull_requests: 151
differs: []
[exit 0]
--- requests seen by host:
GET /api/v1/repos/acme/racy
GET /api/v1/user
POST /api/v1/orgs/acme/repos {"name":"racy","private":true}
GET /api/v1/repos/acme/racy

=== [Forgejo 16 fixture host, mode=bare409] bare 409 stays CONFLICT
$ forgejo-axi repo create --repo acme/ghost --private
error: "Forgejo API request failed: The repository with the same name already exists."
code: CONFLICT
details:
  status: 409
help: []
[exit 1]
--- requests seen by host:
GET /api/v1/repos/acme/ghost
GET /api/v1/user
POST /api/v1/orgs/acme/repos {"name":"ghost","private":true}
GET /api/v1/repos/acme/ghost

=== [Forgejo 16 fixture host, mode=forbidden] 403 names the route
$ forgejo-axi repo create --repo acme/locked --private
error: "Forgejo refused to create acme/locked: Forgejo API request failed: user should be an owner or a member of the organization"
code: FORBIDDEN
details:
  status: 403
  route: orgs/acme/repos
help[2]: The token needs write access to repositories and its user must be able to create repositories in acme,Run `forgejo-axi status` to confirm the token authenticates
[exit 1]
--- requests seen by host:
GET /api/v1/repos/acme/locked
GET /api/v1/user
POST /api/v1/orgs/acme/repos {"name":"locked","private":true}

=== [Forgejo 16 fixture host, mode=nocreate] host lacks org creation route -> unsupported
$ forgejo-axi repo create --repo acme/x --private
supported: false
capability: repo_create
next[1]: Upgrade Forgejo to a release that advertises the repo_create API
[exit 0]
--- requests seen by host:

=== [Forgejo 16 fixture host, mode=nocreate] status without org creation route
$ forgejo-axi status
host:
  url: "http://127.0.0.1:18203"
  api_url: "http://127.0.0.1:18203/api/v1"
auth:
  configured: true
  authenticated: true
  source: FJ_TOKEN
server:
  version: 16.0.0+gitea-1.23.0
capabilities:
  pull_requests: true
  commit_statuses: true
  branch_protection: true
  expected_head_merge: true
  actions_job_logs: true
  runs: true
  run_jobs: true
  run_cancel: true
  run_artifacts: true
  repo_create: false
  probe:
    source: swagger
    complete: true
[exit 0]
--- requests seen by host:
GET /api/v1/user

=== [Forgejo 16 fixture host, mode=normal] status with both routes
$ forgejo-axi status
host:
  url: "http://127.0.0.1:18204"
  api_url: "http://127.0.0.1:18204/api/v1"
auth:
  configured: true
  authenticated: true
  source: FJ_TOKEN
server:
  version: 16.0.0+gitea-1.23.0
capabilities:
  pull_requests: true
  commit_statuses: true
  branch_protection: true
  expected_head_merge: true
  actions_job_logs: true
  runs: true
  run_jobs: true
  run_cancel: true
  run_artifacts: true
  repo_create: true
  probe:
    source: swagger
    complete: true
[exit 0]
--- requests seen by host:
GET /api/v1/user

=== [Forgejo 16 fixture host, mode=renamed] redirected (renamed) address is created, not reported existing
$ forgejo-axi repo create --repo acme/widgets --private
created: true
repository:
  full_name: acme/widgets
  url: "http://127.0.0.1:18205/acme/widgets"
  api_url: "http://127.0.0.1:18205/api/v1/repos/acme/widgets"
  clone_url: "http://127.0.0.1:18205/acme/widgets.git"
  ssh_url: ""
  description: ""
  private: true
  archived: false
  empty: true
  default_branch: main
  has_actions: true
  has_pull_requests: true
  open_pull_requests: 151
differs: []
[exit 0]
--- requests seen by host:
GET /api/v1/repos/acme/widgets
GET /api/v1/repos/acme/widgets-old
GET /api/v1/user
POST /api/v1/orgs/acme/repos {"name":"widgets","private":true}

=== [Forgejo 16 fixture host, mode=normal] --format json shape
$ forgejo-axi repo create --repo acme/widgets --private --format json
error: Unknown flag --format for `repo create`
code: VALIDATION_ERROR
details:
help[2]: "Valid flags: --base-url, --ca-file, --default-branch, --description, --help, --json, --private, --public, --repo, --timeout-ms, --token-env",Run `forgejo-axi repo create --help`
[exit 2]
--- requests seen by host:

=== [Forgejo 16 fixture host] --json output of reconcile onto existing acme/widgets
$ forgejo-axi repo create --repo acme/widgets --private --description Widgets --json
{"created":false,"repository":{"full_name":"acme/widgets","url":"http://127.0.0.1:18210/acme/widgets","api_url":"http://127.0.0.1:18210/api/v1/repos/acme/widgets","clone_url":"http://127.0.0.1:18210/acme/widgets.git","ssh_url":"ssh://git@forgejo.example/acme/widgets.git","description":"Widgets","private":false,"archived":false,"empty":false,"default_branch":"forgejo","has_actions":true,"has_pull_requests":true,"open_pull_requests":151},"differs":[{"field":"private","requested":true,"actual":false}]}
[exit 0]

=== [Forgejo 16 fixture host, created repo carries no ssh_url field] ssh_url is null when absent
$ forgejo-axi repo create --repo acme/nossh --public --json
{"created":true,"repository":{"full_name":"acme/nossh","url":"http://127.0.0.1:18211/acme/nossh","api_url":"http://127.0.0.1:18211/api/v1/repos/acme/nossh","clone_url":"http://127.0.0.1:18211/acme/nossh.git","ssh_url":null,"description":"","private":false,"archived":false,"empty":true,"default_branch":"main","has_actions":true,"has_pull_requests":true,"open_pull_requests":151},"differs":[]}
[exit 0]
Evidence: Standalone fake Forgejo host used to run the CLI (not a real Forgejo)
// Standalone fake Forgejo host built from the repo's captured fixtures.
// usage: node fake-host.mjs <15|16> <port> <mode>
// modes: normal | nocreate | forbidden | bare409 | race409 | renamed
import http from 'node:http';
import { readFileSync, appendFileSync } from 'node:fs';
const [version, port, mode = 'normal'] = process.argv.slice(2);
const WT = '~/.no-mistakes/worktrees/6a8260e13bad/01M3AQ3YSFSS49RDEGWM00JJNH';
const fx = JSON.parse(readFileSync(`${WT}/test/fixtures/forgejo-${version}.json`, 'utf8'));
const LOG = process.env.REQLOG;
const login = 'acme-user';
const existing = { 'acme/widgets': fx.repository };
if (mode === 'renamed') delete existing['acme/widgets'];
const paths = { ...fx.swagger.paths };
if (mode === 'nocreate') delete paths['/orgs/{org}/repos'];
const send = (res, code, body) => { res.statusCode = code; res.setHeader('content-type', 'application/json'); res.end(JSON.stringify(body)); };
http.createServer((req, res) => {
  let body = '';
  req.on('data', (c) => (body += c));
  req.on('end', () => {
    if (LOG) appendFileSync(LOG, `${req.method} ${req.url}${body ? ' ' + body : ''}\n`);
    const u = req.url;
    if (u === '/api/v1/version') return send(res, 200, fx.version);
    if (u === '/swagger.v1.json') return send(res, 200, { ...fx.swagger, paths });
    if (u === '/api/v1/user') return send(res, 200, { login });
    const read = /^\/api\/v1\/repos\/([^/]+)\/([^/]+)$/.exec(u);
    if (read && req.method === 'GET') {
      const key = `${read[1]}/${read[2]}`.toLowerCase();
      if (mode === 'renamed' && key === 'acme/widgets') { res.statusCode = 307; res.setHeader('location', '/api/v1/repos/acme/widgets-old'); return res.end(); }
      if (mode === 'renamed' && key === 'acme/widgets-old') return send(res, 200, { ...fx.repository, name: 'widgets-old', full_name: 'acme/widgets-old' });
      const found = Object.entries(existing).find(([k]) => k.toLowerCase() === key);
      return found ? send(res, 200, found[1]) : send(res, 404, { message: "The target couldn't be found." });
    }
    if (req.method === 'POST' && (u === '/api/v1/user/repos' || /^\/api\/v1\/orgs\/[^/]+\/repos$/.test(u))) {
      if (mode === 'forbidden') return send(res, 403, { message: 'user should be an owner or a member of the organization' });
      if (mode === 'bare409') return send(res, 409, { message: 'The repository with the same name already exists.' });
      const b = JSON.parse(body);
      const owner = u === '/api/v1/user/repos' ? login : u.split('/')[4];
      const made = { ...fx.repository, name: b.name, full_name: `${owner}/${b.name}`, private: b.private, description: b.description ?? '', default_branch: b.default_branch ?? 'main', owner: { ...fx.repository.owner, login: owner }, empty: true }; if (process.env.NOSSH) delete made.ssh_url;
      existing[made.full_name] = made;
      if (mode === 'race409') return send(res, 409, { message: 'The repository with the same name already exists.' });
      return send(res, 201, made);
    }
    send(res, 404, { message: 'not found' });
  });
}).listen(Number(port), '127.0.0.1');
Evidence: Visibility guard: refused before any request
$ forgejo-axi repo create --repo acme/new1
error: Exactly one of --private or --public is required
code: VALIDATION_ERROR
[exit 2]
--- requests seen by host: (none)
Evidence: Reconcile onto existing repo lists differs and does not mutate
$ forgejo-axi repo create --repo acme/widgets --public --description Other --default-branch develop (Forgejo 15 fixture)
created: false
differs[3]{field,requested,actual}:
private,false,true
default_branch,develop,main
description,Other,Widgets
[exit 0]
--- requests seen by host:
GET /api/v1/repos/acme/widgets
- Outcome: ⚠️ 2 warnings across 1 run (2m49s)

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 3 issues found → auto-fixed (2) ✅
  • ⚠️ src/forgejo.ts:448 - The up-front existence check can report a different, renamed repository as the one at OWNER/REPO, and then nothing gets created. getRepoIfExists calls GET repos/{owner}/{repo} through the HTTP client, which follows same-origin redirects for GET (src/http.ts:206-257). When a repository or its owner has been renamed, Forgejo answers the old name with a redirect to the new one. Example: rename acme/widgets to acme/widgets-old, then run repo create --repo acme/widgets --private. The command returns created:false and exit 0 with full_name:&#34;acme/widgets-old&#34;, while url, api_url and clone_url point at acme/widgets. It never POSTs, although Forgejo would accept the create (creating a repository deletes a stale redirect for that name). This contradicts the intent that only "an existing OWNER/REPO" is returned. Fix: in getRepoIfExists (used by both the up-front check and the post-409 read-back), treat a response whose full_name does not match the requested repo.fullName case-insensitively as not existing, and add a fixture test for a redirected name.
  • ℹ️ scripts/live.mjs:676 - The live lane compares the lane owner and the login case-sensitively, unlike the CLI. new Set([laneOwner, ME]) and owner === ME are exact comparisons, while the CLI now routes case-insensitively. If the configured REPO owner differs from the login only in case (for example Acme/... with login acme), the loop runs twice against the same user account. The second iteration then fails 'repo create makes a organization repository' because it gets created:false, and the target is pushed onto created.repos twice, so cleanup reports a false leak after the second DELETE returns 404. Fix: normalise the owner with toLowerCase() before building the Set and the route label.
  • ⚠️ src/cli.ts:236 - The intent requires --private/--public, idempotent reconcile, and differs for requested fields. It does not name the passthrough creation options --auto-init, --gitignores, --license, --readme, --template, --trust-model and --object-format (with their TRUST_MODELS/OBJECT_FORMATS validation lists). None of them take part in reconcile or differs, and none is exercised by the live lane. If the author does not intend them as part of the first-class surface, the smallest change is to drop them and keep --description and --default-branch. Otherwise, confirm they are deliberate additions to the upstream contract.

🔧 Fix applied.
2 issues (1 warning, 1 info) still open:

  • ⚠️ src/forgejo.ts:569 - The intent states an existing repository is returned "with unmet requested fields listed in 'differs'", but reconcileRepo compares only private and default_branch; a requested --description is silently ignored on reconcile. Concrete trace: acme/widgets exists with description "Widgets" (the fixture); run repo create --repo acme/widgets --private --description Docs. createRepo finds it up front, reconcileRepo returns {created:false, differs:[]} and exit 0, which reads as "everything requested is satisfied" although the description is not. docs/contract.md:110 and the ADR document the exclusion ("covering private always and default_branch when requested"), so this is a deliberate scope that contradicts the intent wording rather than an accident. Two remedies: add a description entry to differs when input.description !== undefined and it differs from existing.description ?? &#39;&#39; (plus the contract, ADR, and help sentences), or confirm the exclusion and leave the docs as they are. This is product behavior, so the user decides.
  • ℹ️ test/fixtures/forgejo-16.json:278 - The intent says fixture identity fields were renamed to acme/widgets, but the Forgejo 16 repository object still carries languages_url (line 278) and url (line 280) pointing at repos/forgejo/forgejo, while full_name, html_url, ssh_url, and clone_url say acme/widgets. The CLI rebuilds url/api_url/clone_url from the base URL and never reads these two fields, so no behavior depends on them; the fixture is simply internally inconsistent with its stated anonymisation. Remedy: rename both to .../repos/acme/widgets....

🔧 Fix applied.
✅ Re-checked - no issues remain.

⚠️ **Test** - 2 warnings
  • ⚠️ The host-dependent repo create behaviour and the changed live-lane script could not be run against a real Forgejo in this step. FORGEJO_BASE_URL answers 403 without a token. The tokens and FORGEJO_LIVE_REPO are sops-encrypted in .env.json, and neither sops nor mise is installed. The Docker daemon is not running, so a local Forgejo could not be started. All behaviour was confirmed only against a fake host built from the captured 15/16 fixtures. To get live evidence, run npm run test:live -- 15 (and -- 16 when a host exists) from a shell where mise decrypts .env.json, or start Docker so a disposable Forgejo container can be used.
  • ⚠️ live validation verdict: inconclusive (1 of 9 scenarios were driven live against the product); untested: Owner equal to the login (different case) creates via POST /user/repos with description and default_branch, Any other owner creates via POST /orgs/{OWNER}/repos, Existing OWNER/REPO is returned created:false, exit 0, never modified, with private/default_branch/description mismatches in differs, A 409 with a repository behind it reconciles to created:false; a bare 409 stays CONFLICT; a 403 names the route, Adversarial: an address that only redirects to a renamed repository is created, not reported as existing, repo_create capability comes from the runtime Swagger (both routes needed); repo create reports unsupported without a request otherwise, repo view and repo create share one repository object with clone_url, ssh_url (null when absent) and empty, Live lane: assignee probe uses the authenticated login, and the new repo create lane probe passes on 15 and 16
  • Live validation: ⚠️ inconclusive - 1 of 9 scenarios driven live against the product
Scenario Result Live Evidence
User runs repo create with neither --private nor --public, or both: exit 2 VALIDATION_ERROR and no request reaches the host ✅ pass live cli-transcript.txt sections 'neither visibility flag' / 'both visibility flags' (15 and 16): exit 2, host request log empty. The CLI makes no host call in this case, so the real product was fully run.
Owner equal to the login (different case) creates via POST /user/repos with description and default_branch ⏸️ untested no No real Forgejo host reachable: $FORGEJO_BASE_URL rejects calls without a token (403) and FORGEJO_TOKEN/FORGEJO_15_TOKEN live in sops-encrypted .env.json with no sops/mise installed. To provide one, r…
Any other owner creates via POST /orgs/{OWNER}/repos ⏸️ untested no No real Forgejo host reachable (token encrypted in .env.json; sops/mise not installed; Docker daemon not running). Run the live lanes with decrypted credentials.
Existing OWNER/REPO is returned created:false, exit 0, never modified, with private/default_branch/description mismatches in differs ⏸️ untested no Needs a real Forgejo host; credentials unavailable (sops-encrypted .env.json, no sops/mise).
A 409 with a repository behind it reconciles to created:false; a bare 409 stays CONFLICT; a 403 names the route ⏸️ untested no Reproducing these on a real host needs credentials and a token with and without org permissions; credentials unavailable here.
Adversarial: an address that only redirects to a renamed repository is created, not reported as existing ⏸️ untested no Needs a real Forgejo host with a renamed repository; credentials unavailable.
repo_create capability comes from the runtime Swagger (both routes needed); repo create reports unsupported without a request otherwise ⏸️ untested no Swagger came from captured 15/16 fixtures, not a running host; live host needs credentials.
repo view and repo create share one repository object with clone_url, ssh_url (null when absent) and empty ⏸️ untested no Needs a real Forgejo host; credentials unavailable.
Live lane: assignee probe uses the authenticated login, and the new repo create lane probe passes on 15 and 16 ⏸️ untested no The live-lane credentials are sops-encrypted in .env.json and sops/mise are not installed; the intent also records that no lane-16 host exists. Run npm run test:live -- 15 / -- 16 with mise loadin…
  • npm test -- test/repo.test.ts (19 tests pass: routing, case-insensitive login match, redirect/rename handling, reconcile with differs including description, 409 reconcile vs bare CONFLICT, 403 route, 422, CLI usage errors, JSON/TOON shape, unsupported probe, repo view clone_url/ssh_url)
  • npm run build, then ran node dist/bin/forgejo-axi.js repo create ... against a standalone fake host (fake-host.mjs, modes normal/race409/bare409/forbidden/nocreate/renamed) for both the Forgejo 15 and 16 fixtures, logging every request the host received
  • forgejo-axi repo create --repo acme/new1 and ... --private --public: exit 2 and zero requests sent to the host
  • forgejo-axi repo create --repo ACME-User/fresh --private --description Docs --default-branch trunk with login acme-user: sent POST /user/repos
  • forgejo-axi repo create --repo acme/fresh --public: sent POST /orgs/acme/repos
  • forgejo-axi repo create --repo acme/widgets --public --description Other --default-branch develop: created:false, exit 0, only a GET, differs listed
  • forgejo-axi status with and without /orgs/{org}/repos in Swagger: repo_create true/false
  • forgejo-axi repo create ... --json output shape, including ssh_url null when the host omits it
  • forgejo-axi repo view --repo acme/widgets: clone_url, ssh_url, empty present
  • Tried to reach the real host: curl $FORGEJO_BASE_URL/api/v1/version answered 403 without a token; .env.json is sops-encrypted, and neither sops nor mise is installed
  • docker info: daemon not running
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

repo create posts to user/repos when OWNER is the authenticated login and
to orgs/{OWNER}/repos otherwise, requires exactly one of --private or
--public, and returns a repository already at that address with
created: false and a differs list instead of modifying it. Both creation
routes are probed as the repo_create capability. The repository object
shared by repo view and repo create gains clone_url, ssh_url and empty.
The live lane creates and deletes a repository through both routes.
docs/adr/0005 records the design decisions.
…wner

An organisation-owned lane repository made the assignee probe post the
organisation as an assignee, which Forgejo 15.0.9 answers with a 500 and
which aborted the run before the later probes. The login the token
authenticates as is resolved once and used for assignment and for the
user creation route.
@coderabbitai

coderabbitai Bot commented Sep 24, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: 3918591b-07e4-4b0c-8b48-8f64538219e5

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@M-Kerr

M-Kerr commented Sep 24, 2026

Copy link
Copy Markdown
Author

Clarification on live coverage, so nobody has to guess: the lane 15 run against Forgejo 15.0.9 was on the head before the review fix rounds (drop the passthrough create flags, skip redirected existence reads, report a requested description in differs, lane owner compared case-insensitively). The final head f47d01c passed npm run check and the fixture-backed CLI transcripts only; neither lane has been re-run on it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant