diff --git a/.forgejo/issue_template/api-gap.md b/.forgejo/issue_template/api-gap.md new file mode 100644 index 0000000..1456173 --- /dev/null +++ b/.forgejo/issue_template/api-gap.md @@ -0,0 +1,24 @@ +--- +name: Forgejo API endpoint not wrapped +about: An /api/v1 endpoint fj doesn't expose yet. +labels: [enhancement, api-gap] +--- + +## Endpoint + + + +## What it does + + + +## Why you need it + + + +## Suggested CLI surface + +```sh +# Draft the fj command you'd want. +fj ... +``` diff --git a/.forgejo/issue_template/bug.md b/.forgejo/issue_template/bug.md new file mode 100644 index 0000000..b80994d --- /dev/null +++ b/.forgejo/issue_template/bug.md @@ -0,0 +1,43 @@ +--- +name: Bug report +about: Something that should work doesn't. +labels: [bug] +--- + +## What happened + + + +## What I expected + + + +## Reproduction + +```sh +# The exact command(s). If a subcommand has dynamic input, paste it too. +fj ... +``` + +## Output + +
+Stderr / error message + +``` + +``` + +
+ +## Environment + +- `fj --version`: +- `fj api /version` (or your Forgejo version): +- OS + arch: +- Shell: +- Installed via: + +## Anything else + + diff --git a/.forgejo/issue_template/feature.md b/.forgejo/issue_template/feature.md new file mode 100644 index 0000000..d0613b1 --- /dev/null +++ b/.forgejo/issue_template/feature.md @@ -0,0 +1,27 @@ +--- +name: Feature request +about: A capability fj doesn't have today. +labels: [enhancement] +--- + +## What + + + +## Why + + + +## How (optional) + + + +## Forgejo API + + + +## Alternatives considered + + diff --git a/.forgejo/pull_request_template.md b/.forgejo/pull_request_template.md new file mode 100644 index 0000000..95941f0 --- /dev/null +++ b/.forgejo/pull_request_template.md @@ -0,0 +1,39 @@ + + +## What + + + +## Why + + + +## How + + + +## Test plan + +- [ ] `cargo fmt --check` +- [ ] `cargo clippy --all-targets --all-features -- -D warnings` +- [ ] `cargo test --all` +- [ ] New code has a test (or here's why not): + +## Surface changes + + + +- [ ] README.md updated (if the command table changed) +- [ ] docs/gh-to-fj.md updated (if the gh-equivalence shifted) +- [ ] CHANGELOG.md updated under `[Unreleased]` diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml new file mode 100644 index 0000000..fdbb99e --- /dev/null +++ b/.forgejo/workflows/release.yml @@ -0,0 +1,193 @@ +name: release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - name: darwin-aarch64 + runs-on: macos + target: aarch64-apple-darwin + - name: darwin-x86_64 + runs-on: macos + target: x86_64-apple-darwin + - name: linux-x86_64 + runs-on: docker + container: rust:1.95-bookworm + target: x86_64-unknown-linux-gnu + runs-on: ${{ matrix.runs-on }} + container: ${{ matrix.container }} + steps: + - name: checkout + uses: actions/checkout@v4 + + - name: install rust target + run: | + rustup target add ${{ matrix.target }} || true + + - name: cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: release-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }} + + - name: build + run: | + cargo build --release --locked --target ${{ matrix.target }} + + - name: package + run: | + mkdir -p dist + BIN=target/${{ matrix.target }}/release/fj + test -x "$BIN" || (echo "binary missing"; exit 1) + STAGE=fj-${{ github.ref_name }}-${{ matrix.name }} + mkdir -p "$STAGE" + cp "$BIN" "$STAGE/" + cp README.md LICENSE CHANGELOG.md "$STAGE/" + tar czf "dist/$STAGE.tar.gz" "$STAGE" + (cd dist && shasum -a 256 "$STAGE.tar.gz" > "$STAGE.tar.gz.sha256") + + - name: upload artifacts + uses: actions/upload-artifact@v4 + with: + name: fj-${{ matrix.name }} + path: dist/* + retention-days: 7 + + publish: + needs: build + runs-on: docker + container: alpine:3.20 + steps: + - name: install tools + run: apk add --no-cache curl jq coreutils bash + + - name: fetch artifacts + uses: actions/download-artifact@v4 + with: + path: dist + merge-multiple: true + + - name: combined SHA256SUMS + run: | + cd dist + ls -la + # Concatenate per-artifact sha256 files into a single SHA256SUMS. + cat *.sha256 | sort > SHA256SUMS + cat SHA256SUMS + + - name: create or update release + env: + FORGEJO_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + TAG: ${{ github.ref_name }} + API: ${{ github.server_url }}/api/v1 + run: | + set -euo pipefail + + # Look up the release by tag (Forgejo auto-creates one on tag push, + # but the API call above may run first); create if missing. + RELEASE_JSON=$(curl -sf \ + -H "Authorization: token $FORGEJO_TOKEN" \ + "$API/repos/$REPO/releases/tags/$TAG" || echo "") + + if [ -z "$RELEASE_JSON" ]; then + RELEASE_JSON=$(curl -sf \ + -X POST \ + -H "Authorization: token $FORGEJO_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"draft\":false,\"prerelease\":false}" \ + "$API/repos/$REPO/releases") + fi + + RELEASE_ID=$(echo "$RELEASE_JSON" | jq -r .id) + echo "Release id: $RELEASE_ID" + + cd dist + for f in fj-*.tar.gz SHA256SUMS; do + [ -f "$f" ] || continue + echo "Uploading $f" + curl -sf \ + -X POST \ + -H "Authorization: token $FORGEJO_TOKEN" \ + -F "attachment=@$f" \ + "$API/repos/$REPO/releases/$RELEASE_ID/assets?name=$f" + done + + - name: render homebrew formula + env: + REPO: ${{ github.repository }} + TAG: ${{ github.ref_name }} + HOST: ${{ github.server_url }} + run: | + set -euo pipefail + VERSION=${TAG#v} + ARM_SHA=$(awk '/darwin-aarch64\.tar\.gz$/{print $1}' dist/SHA256SUMS) + X64_SHA=$(awk '/darwin-x86_64\.tar\.gz$/{print $1}' dist/SHA256SUMS) + LIN_SHA=$(awk '/linux-x86_64\.tar\.gz$/{print $1}' dist/SHA256SUMS) + BASE="$HOST/$REPO/releases/download/$TAG" + mkdir -p homebrew-out + cat > homebrew-out/fj.rb < + + ## Install ```sh +# Homebrew (macOS + Linux): +brew tap rasterandstate/tap +brew install fj + +# From source: cargo install --path . # or cargo build --release && cp target/release/fj ~/.local/bin/fj diff --git a/dist/homebrew/fj.rb.tmpl b/dist/homebrew/fj.rb.tmpl new file mode 100644 index 0000000..f46ec27 --- /dev/null +++ b/dist/homebrew/fj.rb.tmpl @@ -0,0 +1,42 @@ +class Fj < Formula + desc "Command-line tool for Forgejo, in the spirit of gh" + homepage "https://rasterhub.com/rasterstate/fj" + version "@@VERSION@@" + license "MIT" + + on_macos do + on_arm do + url "https://rasterhub.com/rasterstate/fj/releases/download/v@@VERSION@@/fj-v@@VERSION@@-darwin-aarch64.tar.gz" + sha256 "@@SHA_DARWIN_AARCH64@@" + end + on_intel do + url "https://rasterhub.com/rasterstate/fj/releases/download/v@@VERSION@@/fj-v@@VERSION@@-darwin-x86_64.tar.gz" + sha256 "@@SHA_DARWIN_X86_64@@" + end + end + + on_linux do + url "https://rasterhub.com/rasterstate/fj/releases/download/v@@VERSION@@/fj-v@@VERSION@@-linux-x86_64.tar.gz" + sha256 "@@SHA_LINUX_X86_64@@" + end + + def install + target = if OS.mac? + Hardware::CPU.arm? ? "darwin-aarch64" : "darwin-x86_64" + else + "linux-x86_64" + end + cd "fj-v@@VERSION@@-#{target}" + bin.install "fj" + # Optional: completions and man pages if present. + bash_completion.install "completions/fj.bash" if File.exist?("completions/fj.bash") + zsh_completion.install "completions/_fj" if File.exist?("completions/_fj") + fish_completion.install "completions/fj.fish" if File.exist?("completions/fj.fish") + man1.install Dir["man/*.1"] if Dir.exist?("man") + end + + test do + assert_match "fj @@VERSION@@", shell_output("#{bin}/fj --version") + assert_match "Command-line tool for Forgejo", shell_output("#{bin}/fj --help") + end +end diff --git a/docs/README.md b/docs/README.md index b86998a..49f402d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,6 +5,8 @@ - [`jq.md`](jq.md) — the `fj api --jq` syntax. Dot paths, brackets, negative indices, pipes. - [`gh-to-fj.md`](gh-to-fj.md) — command-by-command mapping from gh. +- [`compatibility.md`](compatibility.md) — Forgejo version matrix and + known caveats on older Gitea. - [`faq.md`](faq.md) — common questions about tokens, hosts, debug, scripting, plugins. - [`troubleshooting.md`](troubleshooting.md) — keychain prompts, hangs, diff --git a/docs/compatibility.md b/docs/compatibility.md new file mode 100644 index 0000000..c028a8b --- /dev/null +++ b/docs/compatibility.md @@ -0,0 +1,67 @@ +# Forgejo compatibility + +fj is built against the **Forgejo 7.x API surface**. We test against +`7.0.16+gitea-1.21.11` on rasterhub.com. Most endpoints are inherited +unchanged from Gitea, so old Gitea instances will largely work too, +but there are sharp edges: + +## Tested + +| Server | Version | Status | +| -------------------- | -------------------------------- | -------------- | +| Forgejo | 7.0.x (we test 7.0.16) | full support | +| Forgejo | 8.x | expected to work | +| Forgejo | 9.x / 10.x | expected to work | +| Gitea | 1.21.x (the base of Forgejo 7.0) | mostly works | +| Gitea | 1.20.x and earlier | YMMV, see below | + +If you run fj against an untested version and find something broken, +please open an [issue](../.forgejo/issue_template/bug.md) with +`fj api /version` output. + +## Known caveats on older Gitea (≤ 1.19) + +These features rely on endpoints introduced in or after Gitea 1.20. +`fj` will return an HTTP 404 with the endpoint path. + +- `fj pr ready` (`PATCH /repos/.../pulls/{n}` with `draft: false`). +- `fj repo mirror-sync` (`POST /repos/.../mirror-sync`). +- `fj milestone` group on instances that didn't expose + `/repos/.../milestones/{id}` for editing. +- `fj search code` (`/repos/search/code` is Forgejo 7+ only). + +For older instances, the typed API still works via `fj api ` if +you know the right path. + +## Forgejo-only endpoints + +These are fully implemented and exposed through fj. They are +Forgejo extensions of the Gitea base. + +- Branch protection rules (`fj protect`) +- Mirrors (`fj repo mirror`) +- Topics (`fj repo topics`) +- Webhooks (`fj hook`) — present in Gitea too, identical surface +- Actions runs / secrets / variables (`fj run`, `fj secret`, + `fj variable`) + +## How fj detects versions + +On `fj auth login`, fj calls `/api/v1/version` once and stores the +version string. If the version looks pre-7.x, fj prints a warning to +stderr so you know what to expect. After login, the stored version +is consulted only by `fj auth status`. + +If you want to know the version of a configured host: + +```sh +fj api /version +# or +fj auth status | grep Version +``` + +## Bumping the supported floor + +If we drop support for a Forgejo version, it'll be called out under +"BREAKING CHANGES" in CHANGELOG.md ahead of the release, and we'll +emit a stronger error (not just a warning) from the version probe. diff --git a/scripts/_demo-session.sh b/scripts/_demo-session.sh new file mode 100755 index 0000000..9815fe0 --- /dev/null +++ b/scripts/_demo-session.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Drives an asciinema recording. Don't invoke directly; run record-demo.sh. + +set -u +PS1='\$ ' +export PS1 + +PAUSE_BETWEEN=0.6 +PAUSE_AFTER=1.2 + +say() { printf '\033[1;34m# %s\033[0m\n' "$*"; sleep "$PAUSE_BETWEEN"; } +do_() { printf '\033[1m$ %s\033[0m\n' "$*"; sleep "$PAUSE_BETWEEN"; eval "$@"; sleep "$PAUSE_AFTER"; } + +clear +say "fj: a CLI for Forgejo. Multi-host, tokens in the keychain." +do_ "fj --version" + +say "Inside a clone, no flags needed: fj infers the repo from your git remote." +do_ "fj repo view | head -8" + +say "Issues, PRs, releases all work the same way." +do_ "fj issue list --state all -L 5" +do_ "fj pr list --state all -L 5" + +say "The api escape hatch with a jq-ish projector." +do_ "fj api /version" +do_ "fj api /user -q .login" + +say "Selective JSON for scripting." +do_ "fj repo list -L 3 --json --json-fields full_name,private" + +say "Or just browse on the web." +say " fj browse src/main.rs" +sleep 1.5 + +clear +say "60+ subcommands. Try fj --help." +sleep 2 diff --git a/scripts/record-demo.sh b/scripts/record-demo.sh new file mode 100755 index 0000000..2531703 --- /dev/null +++ b/scripts/record-demo.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Record an asciinema demo of fj. The result is a .cast file you can: +# - Upload to asciinema.org via `asciinema upload dist/demo.cast` +# - Embed as a static SVG/GIF via `agg` (https://github.com/asciinema/agg) +# - Link from README.md +# +# Requires: asciinema, fj (installed and authenticated), `jq` for nicer output. +# +# Usage: +# ./scripts/record-demo.sh # records dist/demo.cast +# ./scripts/record-demo.sh foo.cast # records to foo.cast +# +# The session below is deliberately short (~30 seconds) and covers what +# someone reading the README in their first 10 seconds cares about. + +set -euo pipefail + +OUT="${1:-dist/demo.cast}" +mkdir -p "$(dirname "$OUT")" + +if ! command -v asciinema >/dev/null 2>&1; then + cat >&2 </dev/null 2>&1; then + echo "fj not found on PATH; build it first (cargo build --release)" >&2 + exit 1 +fi + +# Drive a representative session through asciinema. +# Idle pauses keep readability up; trim them in post if you want a snappier +# embedded GIF. +asciinema rec \ + --overwrite \ + --idle-time-limit 2 \ + --title "fj: a CLI for Forgejo" \ + --command "bash $(dirname "$0")/_demo-session.sh" \ + "$OUT" + +echo "" +echo "✓ Recorded $OUT" +echo "" +echo "Next:" +echo " asciinema play $OUT # preview" +echo " asciinema upload $OUT # publish (returns a URL)" +echo " agg $OUT $OUT.gif # render to GIF" diff --git a/scripts/render-homebrew-formula.sh b/scripts/render-homebrew-formula.sh new file mode 100755 index 0000000..2528453 --- /dev/null +++ b/scripts/render-homebrew-formula.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Render dist/homebrew/fj.rb from the template + a release's SHA256SUMS. +# +# Usage: +# ./scripts/render-homebrew-formula.sh +# +# Reads SHA256SUMS from the Forgejo release for the given version and writes +# a ready-to-commit fj.rb. Then paste that into rasterandstate/homebrew-tap. +# +# Requires: curl, awk, jq. + +set -euo pipefail + +VERSION="${1:?version required, e.g. 0.1.0}" +TAG="v${VERSION#v}" +VERSION="${VERSION#v}" +REPO="rasterstate/fj" +HOST="https://rasterhub.com" + +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT + +SUMS_URL="$HOST/$REPO/releases/download/$TAG/SHA256SUMS" +echo "fetching $SUMS_URL" +curl -fsSL "$SUMS_URL" -o "$TMP/SHA256SUMS" + +extract() { + awk -v pat="$1" '$2 ~ pat {print $1; exit}' "$TMP/SHA256SUMS" +} + +SHA_ARM=$(extract "darwin-aarch64\\.tar\\.gz$") +SHA_X64=$(extract "darwin-x86_64\\.tar\\.gz$") +SHA_LIN=$(extract "linux-x86_64\\.tar\\.gz$") + +for s in "$SHA_ARM" "$SHA_X64" "$SHA_LIN"; do + [ -n "$s" ] || { echo "missing SHA in SHA256SUMS"; cat "$TMP/SHA256SUMS"; exit 1; } +done + +OUT="dist/homebrew/fj.rb" +sed \ + -e "s/@@VERSION@@/$VERSION/g" \ + -e "s/@@SHA_DARWIN_AARCH64@@/$SHA_ARM/g" \ + -e "s/@@SHA_DARWIN_X86_64@@/$SHA_X64/g" \ + -e "s/@@SHA_LINUX_X86_64@@/$SHA_LIN/g" \ + dist/homebrew/fj.rb.tmpl > "$OUT" + +echo "wrote $OUT" +echo "" +echo "next:" +echo " cp $OUT ~/path/to/homebrew-tap/Formula/fj.rb" +echo " cd ~/path/to/homebrew-tap" +echo " git commit -am 'fj $VERSION'" +echo " git push" diff --git a/src/cli/auth.rs b/src/cli/auth.rs index fcb0563..ffb824b 100644 --- a/src/cli/auth.rs +++ b/src/cli/auth.rs @@ -285,6 +285,13 @@ async fn login(args: LoginArgs) -> Result<()> { .await .context("verifying token against /api/v1/user")?; + // One-shot version probe. Surfaces obvious version mismatches before + // they manifest as cryptic 404s on later commands. Failure here is + // non-fatal: some Forgejo-compatible servers may not expose /version + // identically. + let version_string = probe_version(&probe_client).await.unwrap_or_default(); + warn_on_old_forgejo(&version_string); + // Persist host + token. let mut hosts = Hosts::load()?; let host = Host { @@ -297,14 +304,63 @@ async fn login(args: LoginArgs) -> Result<()> { token_store::store_token(&hostname, &token)?; println!( - "{} Logged in to {} as {}", + "{} Logged in to {} as {}{}", output::bold("✓"), output::bold(&hostname), - output::bold(&me.login) + output::bold(&me.login), + if version_string.is_empty() { + String::new() + } else { + format!(" ({})", output::dim(&version_string)) + } ); Ok(()) } +async fn probe_version(client: &Client) -> Result { + #[derive(serde::Deserialize)] + struct V { + version: String, + } + let v: V = client + .json(reqwest::Method::GET, "/api/v1/version", &[], None::<&()>) + .await?; + Ok(v.version) +} + +/// `version_string` looks like "7.0.16+gitea-1.21.11" or "1.21.11". We warn +/// when the leading numeric component looks pre-7.x. Anything we can't parse +/// gets a silent pass since exotic Forgejo-likes still mostly work. +fn warn_on_old_forgejo(version_string: &str) { + if let Some(msg) = version_warning(version_string) { + eprintln!("{} {msg}", output::bold("warning:")); + } +} + +/// Pure helper. Returns the human-readable warning (without the "warning:" +/// prefix) when the server's version is older than the fj baseline. Returns +/// `None` when the version is unparseable or fresh enough. +fn version_warning(version_string: &str) -> Option { + if version_string.is_empty() { + return None; + } + let head = version_string + .split(|c: char| !(c.is_ascii_digit() || c == '.')) + .next() + .unwrap_or(""); + let major: u32 = head + .split('.') + .next() + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + if major == 0 || major >= 7 { + return None; + } + Some(format!( + "server reports version `{version_string}`, which is older than the Forgejo 7.x baseline fj is tested against. Some commands (pr ready, repo mirror-sync, search code, milestone) may 404. See docs/compatibility.md." + )) +} + fn build_probe_client(host: &str, cfg: &Host, token: &str) -> Result { let resolved = crate::client::ResolvedHost { name: host.to_string(), @@ -473,4 +529,27 @@ mod setup_git_tests { assert!(validate_username("alice").is_ok()); assert!(validate_username("alice.bob_123").is_ok()); } + + #[test] + fn version_warning_fires_on_old() { + assert!(version_warning("1.21.11").is_some()); + assert!(version_warning("5.0.0").is_some()); + assert!(version_warning("6.0.0+gitea-1.20.0").is_some()); + } + + #[test] + fn version_warning_silent_on_modern() { + assert!(version_warning("7.0.16+gitea-1.21.11").is_none()); + assert!(version_warning("8.0.0").is_none()); + assert!(version_warning("10.5.2-beta").is_none()); + } + + #[test] + fn version_warning_silent_on_unparseable() { + assert!(version_warning("").is_none()); + assert!(version_warning("totally-unknown").is_none()); + // Hardcoded 0.x major never triggers the warning either (likely a + // dev / preview build we don't want to be noisy about). + assert!(version_warning("0.99.0").is_none()); + } }