From 93dd9aa7726145eb93321a5ad8abe4fef7f25b90 Mon Sep 17 00:00:00 2001 From: Zachery Aaron Shores-Chmielewski Date: Mon, 16 Feb 2026 22:43:13 +0700 Subject: [PATCH] fix: reorganize docs on CI development --- HOW_TO_TEST_DRIVE.md | 357 ------------------ crates/local-runner/src/main.rs | 105 +++--- docs/development_history/ci/CI_DEPLOYMENT.md | 140 +++++++ .../ci/CI_OUTPUT_IN_FORGEJO.md | 139 +++++++ docs/development_history/{ => ci}/CI_RELAY.md | 0 5 files changed, 338 insertions(+), 403 deletions(-) delete mode 100644 HOW_TO_TEST_DRIVE.md create mode 100644 docs/development_history/ci/CI_DEPLOYMENT.md create mode 100644 docs/development_history/ci/CI_OUTPUT_IN_FORGEJO.md rename docs/development_history/{ => ci}/CI_RELAY.md (100%) diff --git a/HOW_TO_TEST_DRIVE.md b/HOW_TO_TEST_DRIVE.md deleted file mode 100644 index 84752b1..0000000 --- a/HOW_TO_TEST_DRIVE.md +++ /dev/null @@ -1,357 +0,0 @@ -# How to Test Drive the Local CI Runner over LAN - -This guide walks through running the local CI runner on the Thinkpad and triggering it from Forgejo on your main laptop. By the end, pushes to your Forgejo repos will automatically run CI pipelines on the Thinkpad. - -## Network Layout - -``` -┌──────────────────────┐ LAN ┌──────────────────────┐ -│ Main Laptop │◄───────────────────►│ Thinkpad │ -│ │ │ │ -│ Forgejo instance │ webhook POST ───► │ local-runner │ -│ (e.g. :3000) │ ◄── status API ──── │ (e.g. :8787) │ -│ │ │ dashboard (:9090) │ -└──────────────────────┘ └──────────────────────┘ -``` - -## Prerequisites - -- **Main laptop**: Forgejo running and accessible on LAN (e.g. `http://192.168.1.100:3000`) -- **Thinkpad**: Rust toolchain installed, this repo cloned, network-reachable from the main laptop -- Both machines on the same LAN (or routable to each other) - -Find each machine's LAN IP: - -```bash -# On each machine -ip addr show | grep 'inet ' | grep -v 127.0.0.1 -# or -hostname -I -``` - -We'll use these example IPs throughout the guide: -- Main laptop (Forgejo): `192.168.1.100` -- Thinkpad (CI runner): `192.168.1.200` - -Replace them with your actual IPs. - ---- - -## Step 1: Create a Forgejo API Token - -On your main laptop, open Forgejo in a browser: - -1. Go to **Settings > Applications** (top-right user menu > Settings > Applications) -2. Create a new token with at least these permissions: - - `repo`: read/write (needed to post commit statuses) -3. Copy the token (e.g. `abc123def456`) - ---- - -## Step 2: Write a `.ci.yml` for Your Repository - -Create a `.ci.yml` file on the Thinkpad. This defines what pipelines and jobs to run. - -Example for a Rust project: - -```yaml -pipelines: - check: - triggers: - - event: push - branches: ["*"] - exclude: ["master"] - jobs: - fmt: - run: cargo fmt -- --check - clippy: - run: cargo clippy -- -D warnings - test: - needs: [fmt, clippy] - run: cargo test - timeout: 300 - - release: - triggers: - - event: push - branches: ["master"] - jobs: - test: - run: cargo test --all-features - timeout: 600 - bench: - needs: [test] - run: cargo bench -``` - -Key points: -- `triggers[].event`: one of `push`, `tag`, `merge` -- `triggers[].branches`: glob patterns (`"*"` matches all, `"feature-*"` matches prefixed) -- `triggers[].exclude`: branches to skip -- `jobs[].run`: a single command string, or a list of commands (run sequentially) -- `jobs[].needs`: list of jobs that must pass first (DAG dependencies) -- `jobs[].timeout`: max seconds before the job is killed (default: 300) -- `jobs[].env`: extra environment variables as key-value pairs - -Save this file somewhere accessible on the Thinkpad, e.g. `/home/user/ci/my-project.ci.yml`. - ---- - -## Step 3: Build the Local Runner on the Thinkpad - -SSH into the Thinkpad (or work directly on it): - -```bash -# Clone the repo if not already present -git clone ~/swactor -cd ~/swactor - -# Build the local-runner binary -cargo build --release -p local-runner -``` - -The binary will be at `target/release/local-runner`. - ---- - -## Step 4: Create a Working Directory - -The runner clones your repo into a working directory for each pipeline. Create it: - -```bash -mkdir -p ~/ci-work -``` - ---- - -## Step 5: Start the Local Runner - -```bash -./target/release/local-runner \ - --port 8787 \ - --forgejo-url http://192.168.1.100:3000 \ - --forgejo-token abc123def456 \ - --secret my-webhook-secret \ - --yaml /home/user/ci/my-project.ci.yml \ - --work-dir /home/user/ci-work \ - --repo-url http://192.168.1.100:3000/user/repo.git \ - --dashboard-port 9090 -``` - -| Flag | Description | -|------|-------------| -| `--port` | Port the webhook listener binds to (default: `8787`) | -| `--forgejo-url` | URL of your Forgejo instance on the main laptop | -| `--forgejo-token` | API token created in Step 1 | -| `--secret` | Webhook secret (must match what you set in Forgejo, see Step 6) | -| `--yaml` | Path to the `.ci.yml` file on disk | -| `--work-dir` | Base directory for git clones (one subdirectory per pipeline) | -| `--repo-url` | Git clone URL for the repository (HTTP or SSH) | -| `--dashboard-port` | Optional: enables the web dashboard on this port | - -You should see: - -``` -Webhook listener on http://0.0.0.0:8787 -Local CI runner started - Webhook: http://0.0.0.0:8787 - YAML: /home/user/ci/my-project.ci.yml - Workdir: /home/user/ci-work - Dashboard: http://0.0.0.0:9090 -``` - -The runner is now listening for webhooks. - ---- - -## Step 6: Configure the Forgejo Webhook - -On your main laptop, open Forgejo and go to the repo's settings: - -1. Navigate to **Settings > Webhooks > Add Webhook > Forgejo** -2. Fill in: - - **Target URL**: `http://192.168.1.200:8787` (Thinkpad's LAN IP and runner port) - - **Secret**: `my-webhook-secret` (must match `--secret` from Step 5) - - **Trigger On**: Choose which events to send: - - **Push Events** (for `push` triggers) - - **Create Events** (for `tag` triggers) - - **Pull Request Events** (for `merge` triggers) - - **Branch filter**: leave blank to send all branches, or set a pattern - - **Active**: checked -3. Click **Add Webhook** - -### Test the webhook connection - -After adding the webhook, Forgejo shows a **Test Delivery** button. Click it to send a test ping. Check the Thinkpad terminal for output. Forgejo also shows the response status — you should see `200 OK`. - ---- - -## Step 7: Push and Watch - -From your main laptop (or anywhere with push access): - -```bash -cd ~/my-project -git checkout -b test-ci -echo "// test" >> src/main.rs -git add src/main.rs -git commit -m "test CI" -git push origin test-ci -``` - -On the Thinkpad terminal, you'll see the runner: - -1. Receive the webhook -2. Match triggers in `.ci.yml` -3. Queue the pipeline -4. Clone the repo and checkout the commit SHA -5. Execute jobs one at a time, respecting the DAG order -6. Stream stdout/stderr in real time -7. Report commit statuses back to Forgejo - -Back in Forgejo, the commit will show status checks (pending, then success/failure) next to the SHA. - ---- - -## Step 8: View the Dashboard (Optional) - -If you started with `--dashboard-port 9090`, open a browser on any LAN machine: - -``` -http://192.168.1.200:9090 -``` - -This shows the swactor runtime dashboard with CI-specific panels: active pipelines, recent pipelines, job statuses, and actor system metrics. - ---- - -## Behavior Reference - -### One-at-a-Time Execution - -Jobs run serially — only one job executes at any moment across all pipelines. This guarantees benchmark isolation with no resource contention. - -### Queue Supersede - -If you push twice to the same branch quickly: - -- **First push** is already running: it finishes normally -- **Second push** is queued: it runs after the first finishes -- **Third push** arrives while second is still queued: the second is **superseded** (marked as error, skipped), and the third takes its place in the queue - -Only queued pipelines get superseded — a running pipeline always runs to completion. - -### DAG Dependencies - -Within a pipeline, jobs respect their `needs` dependencies. If `test` needs `[fmt, clippy]`, then `fmt` runs first, then `clippy`, then `test`. If `fmt` fails, `test` is skipped. - -### CI Environment Variables - -Every job command has these injected: - -| Variable | Value | -|----------|-------| -| `CI` | `true` | -| `CI_COMMIT_SHA` | The commit being tested | -| `CI_BRANCH` | The branch name | -| `CI_PIPELINE_ID` | Numeric pipeline identifier | -| `CI_JOB_NAME` | Name of the current job | - -Plus any `env` keys from the job definition in `.ci.yml`. - -### Commit Status Reporting - -The runner posts status updates to the Forgejo API for each pipeline and each job: - -- `pending` when a pipeline/job is queued -- `success` when all jobs pass -- `failure` when a job fails -- `error` when a pipeline is superseded - -These appear as commit status checks in Forgejo's UI. - ---- - -## Troubleshooting - -### Webhook not reaching the Thinkpad - -- Verify the Thinkpad's firewall allows inbound on the webhook port: - ```bash - # On Thinkpad - sudo ufw allow 8787/tcp # if using ufw - # or - sudo iptables -A INPUT -p tcp --dport 8787 -j ACCEPT - ``` -- Confirm connectivity from main laptop: - ```bash - curl -v http://192.168.1.200:8787 - # Should get "method not allowed" (405) — that means the server is reachable - ``` - -### Signature mismatch (401) - -- The `--secret` flag on the runner must exactly match the **Secret** field in Forgejo's webhook config -- If you don't want signature verification, set both to empty strings (omit `--secret` and leave Secret blank in Forgejo) - -### Git clone fails - -- Make sure `--repo-url` is reachable from the Thinkpad: - ```bash - # On Thinkpad - git ls-remote http://192.168.1.100:3000/user/repo.git - ``` -- If the repo is private, use an authenticated URL: - ``` - http://user:password@192.168.1.100:3000/user/repo.git - ``` - Or use SSH: - ``` - git@192.168.1.100:user/repo.git - ``` - -### Status updates not appearing in Forgejo - -- Verify the API token has `repo` write permissions -- Check the Thinkpad terminal for `StatusReporter: failed to post status` errors -- Test the token manually: - ```bash - curl -H "Authorization: token abc123def456" \ - http://192.168.1.100:3000/api/v1/user - ``` - -### Jobs failing unexpectedly - -- Check that the Thinkpad has the necessary toolchain (cargo, rustup, etc.) -- The working directory for each pipeline is `{work-dir}/pipeline-{id}/` — you can inspect it -- Job stdout/stderr is streamed to the runner's terminal output - ---- - -## Quick-Start Cheat Sheet - -```bash -# === Thinkpad === -cd ~/swactor -cargo build --release -p local-runner -mkdir -p ~/ci-work - -./target/release/local-runner \ - --port 8787 \ - --forgejo-url http://LAPTOP_IP:3000 \ - --forgejo-token YOUR_TOKEN \ - --secret YOUR_SECRET \ - --yaml /path/to/.ci.yml \ - --work-dir ~/ci-work \ - --repo-url http://LAPTOP_IP:3000/user/repo.git \ - --dashboard-port 9090 - -# === Main Laptop (Forgejo) === -# Repo > Settings > Webhooks > Add Webhook: -# URL: http://THINKPAD_IP:8787 -# Secret: YOUR_SECRET -# Events: Push, Create, Pull Request - -# === Test it === -git push origin my-branch # triggers CI on the Thinkpad -``` diff --git a/crates/local-runner/src/main.rs b/crates/local-runner/src/main.rs index 1d891e8..4519663 100644 --- a/crates/local-runner/src/main.rs +++ b/crates/local-runner/src/main.rs @@ -274,59 +274,72 @@ fn start_iroh_receiver( .expect("failed to bind iroh endpoint"); eprintln!(" Iroh local ID: {}", endpoint.id()); - eprintln!(" Connecting to relay {relay_key}..."); - let conn = endpoint - .connect(relay_key, CI_ALPN) - .await - .expect("failed to connect to ci-relay"); - - eprintln!(" Connected to relay!"); - - // Receive loop: the relay opens uni streams to send us events. + // Outer reconnection loop: reconnect when the connection drops. while !stop.load(Ordering::Relaxed) { - match tokio::time::timeout(Duration::from_secs(1), conn.accept_uni()).await { - Ok(Ok(mut recv)) => { - match read_tagged_message(&mut recv).await { - Ok((tag, payload)) => { - if tag == "ci::WebhookEvent" { - match serde_json::from_slice::( - &payload, - ) { - Ok(event) => { - eprintln!( - "iroh: received webhook {} on {}", - event - .commit_sha - .get(..8) - .unwrap_or(&event.commit_sha), - event.branch, - ); - let _ = swactor_rt.send_to( - coordinator_addr, - LocalCoordinatorMsg::Webhook(event), - ); - } - Err(e) => { - eprintln!("iroh: failed to deserialize event: {e}") + eprintln!(" Connecting to relay {relay_key}..."); + + let conn = match endpoint.connect(relay_key, CI_ALPN).await { + Ok(c) => c, + Err(e) => { + eprintln!("iroh: connect failed: {e}, retrying in 5s..."); + tokio::time::sleep(Duration::from_secs(5)).await; + continue; + } + }; + + eprintln!(" Connected to relay!"); + + // Receive loop: the relay opens uni streams to send us events. + while !stop.load(Ordering::Relaxed) { + match tokio::time::timeout(Duration::from_secs(1), conn.accept_uni()).await + { + Ok(Ok(mut recv)) => { + match read_tagged_message(&mut recv).await { + Ok((tag, payload)) => { + if tag == "ci::WebhookEvent" { + match serde_json::from_slice::< + swactor_ci::WebhookEvent, + >( + &payload + ) { + Ok(event) => { + eprintln!( + "iroh: received webhook {} on {}", + event + .commit_sha + .get(..8) + .unwrap_or(&event.commit_sha), + event.branch, + ); + let _ = swactor_rt.send_to( + coordinator_addr, + LocalCoordinatorMsg::Webhook(event), + ); + } + Err(e) => { + eprintln!( + "iroh: failed to deserialize event: {e}" + ) + } } + } else { + eprintln!("iroh: unknown tag '{tag}', ignoring"); } - } else { - eprintln!("iroh: unknown tag '{tag}', ignoring"); + } + Err(e) => { + eprintln!("iroh: read error: {e}"); + break; } } - Err(e) => { - eprintln!("iroh: read error: {e}"); - break; - } } - } - Ok(Err(e)) => { - eprintln!("iroh: connection error: {e}"); - break; - } - Err(_) => { - // Timeout — just loop and check stop flag. + Ok(Err(e)) => { + eprintln!("iroh: connection lost: {e}, reconnecting..."); + break; + } + Err(_) => { + // 1s poll timeout — just loop and check stop flag. + } } } } diff --git a/docs/development_history/ci/CI_DEPLOYMENT.md b/docs/development_history/ci/CI_DEPLOYMENT.md new file mode 100644 index 0000000..82be2ff --- /dev/null +++ b/docs/development_history/ci/CI_DEPLOYMENT.md @@ -0,0 +1,140 @@ +# CI Pipeline Deployment — Development History + +> Covers the first real deployment of the CI pipeline: Forgejo (VPS) → ci-relay +> (iroh) → local-runner (Thinkpad). Verified end-to-end with a smoke-test +> pipeline that reports status back to Forgejo. +> +> *Branch: `spot-instance`* + +--- + +## What Was Done + +### Deployed Components + +| Component | Machine | How | +|-----------|---------|-----| +| `.ci.yml` | Repo root | Smoke pipeline: `echo "CI is alive"` on push to `*` | +| `ci-relay` | VPS | Release binary, systemd service | +| `local-runner` | Runner host | Release binary, started via nohup | +| Forgejo webhook | VPS (Docker) | Hook #1, fires on push to relay's HTTP listener | + +### Deployment Steps + +1. **Created `.ci.yml`** — minimal smoke pipeline (`echo "CI is alive"`) +2. **Generated webhook secret** — `openssl rand -hex 32` → `~/.ssh/forgejo.ci-webhook-secret` +3. **Built release binaries** — `cargo build --release -p ci-relay -p local-runner` +4. **Distributed binaries** — `scp` to VPS (`docean:`) and Thinkpad (`thinkpad:`) +5. **Deployed ci-relay as systemd service** on VPS: + - Service file: `/etc/systemd/system/ci-relay.service` + - Iroh Node ID: `` +6. **Started local-runner on Thinkpad** — connects to relay via iroh, confirmed "Connected to relay!" +7. **Configured Forgejo**: + - Added `[webhook] ALLOWED_HOST_LIST = loopback,` to `app.ini` (Forgejo blocks private IPs by default) + - Restarted Forgejo container + - Created webhook via API targeting `http://:8787` + - **Fixed UFW firewall** — Docker bridge traffic to port 8787 was blocked by default DROP policy; added a UFW rule allowing the Docker subnet +8. **Verified end-to-end** — pushed commit, Forgejo shows green check: + - `ci/hello`: success — "Job 'hello' completed" + - `ci/smoke`: success — "Pipeline 'smoke' success" + +### Issue Encountered: UFW Blocking Docker Bridge + +The plan assumed Docker bridge traffic (`172.17.0.1`) would reach the host's port 8787 unimpeded. UFW's default INPUT policy is DROP, which blocks this. The fix was a single firewall rule allowing the Docker subnet. + +### Credentials & Secrets + +| File | Purpose | Location | +|------|---------|----------| +| Forgejo API token | CI status reporting | Spot instance + Thinkpad | +| HMAC webhook secret | Webhook signature verification | Spot instance + Thinkpad | + +Secrets are stored outside the repo. The webhook secret is embedded in the systemd service `ExecStart` line on the VPS. To rotate it: update the service file, restart ci-relay, update Forgejo webhook config. + +### Connection Details + +- **ci-relay** listens on HTTP (webhooks) + iroh (runner connection) +- **local-runner** connects outbound to relay's iroh Node ID (NAT-friendly) +- **Status reports** go directly from runner → Forgejo API over HTTPS (no relay) + +--- + +## Next Step: Real CI Jobs + +The smoke-test pipeline proves the plumbing works. The next step is replacing `echo "CI is alive"` with actual CI jobs in `.ci.yml`. + +Candidates for the first real pipeline: + +1. **`cargo check`** — fast compilation check, catches most errors +2. **`cargo test`** — full test suite (simulation tests can be slow) +3. **`cargo clippy`** — lint pass +4. **Benchmark runs** — the whole reason for running CI on the Thinkpad (consistent hardware) + +Things to consider: + +- **Rust toolchain on Thinkpad**: `local-runner` shells out to run jobs, so the Thinkpad needs `rustup`/`cargo` installed and on PATH +- **Build cache**: consecutive runs in separate `pipeline-N` dirs won't share a target directory. Consider a shared `CARGO_TARGET_DIR` or `sccache` for faster builds +- **Job timeouts**: no timeout mechanism exists yet; a hung `cargo build` would block the single-threaded job queue forever +- **Multiple jobs**: `.ci.yml` supports multiple jobs per pipeline, but they run sequentially. Could add `cargo check` as a fast gate before `cargo test` +- **Branch filtering**: currently triggers on `*` — may want to restrict benchmarks to `master` only + +### Suggested `.ci.yml` Evolution + +```yaml +pipelines: + check: + triggers: + - event: push + branches: ["*"] + jobs: + check: + run: cargo check --workspace + test: + run: cargo test --workspace + clippy: + run: cargo clippy --workspace -- -D warnings + + bench: + triggers: + - event: push + branches: ["master"] + jobs: + bench: + run: cargo bench --workspace +``` + +--- + +## Operational Notes + +### Restarting ci-relay (VPS) + +```bash +ssh +systemctl restart ci-relay +journalctl -u ci-relay -f +``` + +### Restarting local-runner (runner host) + +```bash +ssh +pkill local-runner +nohup ~/local-runner \ + --relay-node-id \ + --forgejo-url https://zachery.lol/code \ + --forgejo-token "$(cat )" \ + --yaml ~/.ci.yml \ + --work-dir ~/ci-work \ + --repo-url https://zachery.lol/code/zacheryasc/swactor.git \ + > ~/local-runner.log 2>&1 & +``` + +### Checking webhook deliveries + +```bash +# Forgejo webhook UI: Settings → Webhooks → Hook #1 → Recent Deliveries +# Or test delivery via API: +curl -X POST "https://zachery.lol/code/api/v1/repos/zacheryasc/swactor/hooks/1/tests" \ + -H "Authorization: token " +``` diff --git a/docs/development_history/ci/CI_OUTPUT_IN_FORGEJO.md b/docs/development_history/ci/CI_OUTPUT_IN_FORGEJO.md new file mode 100644 index 0000000..c1c24b3 --- /dev/null +++ b/docs/development_history/ci/CI_OUTPUT_IN_FORGEJO.md @@ -0,0 +1,139 @@ +# CI Output Visible in Forgejo — Development History + +> Added two mechanisms so CI results are visible directly in the Forgejo web +> UI without SSH-ing into the runner: **enhanced commit status descriptions** +> and **PR comments** with full job output. +> +> *Branch: `spot-instance`* + +--- + +## Problem + +The CI pipeline worked end-to-end but job output was only visible in the +runner's stderr log on the Thinkpad. To see why clippy failed, you had to +`ssh thinkpad 'tail ~/local-runner.log'`. Forgejo's commit status descriptions +just said "Job 'clippy' completed" with no output. + +Forgejo lacks GitHub's Checks API (no annotations, no log viewer), so we use +two complementary approaches. + +## What Was Done + +### 1. Enhanced Commit Status Descriptions + +On job completion, the status description now includes: + +- **On success**: `"Job 'check' passed"` +- **On failure**: `"Job 'clippy' failed: command exited with code 101\n[stderr] error: you should consider..."` — last ~10 lines of output, capped at 250 characters. + +This is visible directly on the PR page and commit page in Forgejo without +clicking anything. + +### 2. PR Comments with Full Output + +When a pipeline reaches terminal state, the StatusReporter: + +1. Queries `GET /repos/{owner}/{repo}/pulls?state=open` to find the PR for the branch +2. Builds a markdown comment with `
` sections per job (up to 100 lines each) +3. Posts it via `POST /repos/{owner}/{repo}/issues/{pr_number}/comments` +4. Re-posts the pipeline commit status with `target_url` pointing to the comment + +Example comment format: + +```markdown +## Pipeline `ci` — failure + +Commit: `5b7ae7b` + +
+clippy — failed: command exited with code 101 + +_Showing last 100 of 523 lines_ + +\``` +error[E0599]: ... +\``` + +
+ +
+check — passed + +\``` +$ cargo check --workspace + Compiling ... +\``` + +
+``` + +### 3. `target_url` on Commit Statuses + +Added `target_url: Option` to `StatusUpdate`. When a PR comment is +successfully posted, the pipeline's commit status badge links directly to that +comment. Clicking the status badge on the PR page jumps to the output. + +## Files Changed + +| File | Change | +|------|--------| +| `crates/ci/src/lib.rs` | Added `target_url: Option` to `StatusUpdate` | +| `crates/ci/src/status_reporter.rs` | Added `JobOutput`, `PostPipelineComment`, `find_pr_for_branch()`, `post_pr_comment()`, `build_pipeline_comment()`, `handle_pipeline_comment()` | +| `crates/ci/src/local_coordinator.rs` | Enhanced `handle_job_complete()` descriptions; added `emit_pipeline_comment()`, called from `try_schedule_next()` | +| `crates/ci/src/coordinator.rs` | Mechanical `target_url: None` at 4 sites | +| `crates/simulation/src/ci/local_sim.rs` | Mechanical `target_url: None` at 3 sites | +| `crates/simulation/src/ci/sim.rs` | Mechanical `target_url: None` at 2 sites | +| `crates/ci/Cargo.toml` | Added `features = ["json"]` to `ureq` for `into_json()` | + +## Deployment & Verification + +Built and deployed updated `local-runner` to the Thinkpad, pushed to the +`spot-instance` branch (which has PR #42 open), and observed: + +**Working:** + +- Commit statuses show descriptive output. The clippy failure status reads: + `Job 'clippy' failed: command exited with code 101` followed by the tail of + the clippy output, truncated at 250 chars. +- Passed jobs show `"Job 'check' passed"` / `"Job 'test' passed"`. +- Pipeline-level status correctly reports `ci/ci → failure`. + +**Blocked on token scope:** + +- PR comment posting returned HTTP 403. The Forgejo API token has + `write:repository` scope (sufficient for commit statuses) but needs + `write:issue` scope to post comments on PRs/issues. +- The code degrades gracefully: logs the error, skips the comment, posts the + pipeline status without `target_url`. + +## TODO + +- [ ] Regenerate Forgejo API token with `write:issue` scope to enable PR comments +- [ ] After token update, re-deploy and verify the comment + `target_url` flow end-to-end + +## Edge Cases Handled + +| Case | Behavior | +|------|----------| +| No open PR for branch | Comment silently skipped, status posted without `target_url` | +| API failures (403, network) | Logged via `eprintln!`, degrades gracefully | +| Long output | Capped at last 100 lines per job in PR comment, with `_Showing last N of M lines_` note | +| Long description | Capped at 250 chars for commit status description field | +| All HTTP code | Gated behind `#[cfg(feature = "local")]` — simulation builds unaffected | + +## Architecture Note + +All new HTTP calls (PR listing, comment posting) happen in the StatusReporter +actor, which is fire-and-forget. The LocalCoordinator never blocks on HTTP. +The flow is: + +``` +LocalCoordinator StatusReporter + | | + |-- emit_status(StatusUpdate) ----->|-- POST /statuses/{sha} + | | + |-- PostPipelineComment ----------->|-- GET /pulls?state=open + | |-- POST /issues/{n}/comments + | |-- POST /statuses/{sha} (with target_url) +``` diff --git a/docs/development_history/CI_RELAY.md b/docs/development_history/ci/CI_RELAY.md similarity index 100% rename from docs/development_history/CI_RELAY.md rename to docs/development_history/ci/CI_RELAY.md