Skip to content

Commit 069046e

Browse files
williamdesclaudehappy-otter
committed
docs: add CLAUDE.md project guide
Documents the layout, the two-client trait pattern, the three Gitea divergences (merge method/body, empty merge response, label-by-id), the three test layers (unit / integration / wire), and the conventions that aren't obvious from the code (env-test mutex, encode_path_segment, trait-first plumbing, errors-are-propagated except label-removal, output-through-Logger). Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
1 parent dbcfbf1 commit 069046e

1 file changed

Lines changed: 156 additions & 0 deletions

File tree

CLAUDE.md

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
# action-pull-request-merge
2+
3+
GitHub / Gitea Action that merges a pull request when an event triggers the
4+
workflow. Written in Rust, distributed as a Docker container action — no
5+
Node.js runtime required on the runner.
6+
7+
Inputs: `github-token`, `number`, `merge-method`,
8+
`allowed-usernames-regex`, `filter-label`, `merge-title`, `merge-message`.
9+
See `action.yml` and `README.md`.
10+
11+
## Layout
12+
13+
```
14+
src/
15+
main.rs Tiny entrypoint. Picks the client backend and hands off.
16+
lib.rs Re-exports + pick_backend(&ctx) -> Backend.
17+
action.rs Decision logic: actor gate, PR fetch, label gate,
18+
merge / fast-forward, label cleanup. Uses traits only,
19+
so it can be exercised end-to-end with fakes.
20+
context.rs Reads GITHUB_* env vars. Detects Gitea via
21+
GITEA_ACTIONS=true OR a `/api/v1` URL suffix.
22+
inputs.rs Reads INPUT_<NAME> env vars (matches @actions/core
23+
name normalisation).
24+
github_client.rs GithubClient trait + OctocrabClient (real GitHub
25+
impl) + path-segment percent-encoder.
26+
gitea_client.rs GiteaClient (real Gitea impl). Same trait, different
27+
wire shape.
28+
logger.rs Logger trait, WriteLogger<W: Write>, StdoutLogger
29+
(= WriteLogger<io::Stdout>), CaptureLogger (test).
30+
tests/
31+
integration.rs action::run driven by a fake client.
32+
wire.rs wiremock servers — pin the actual HTTP method, URL,
33+
headers and body each client sends.
34+
docker/
35+
Dockerfile Multi-stage: rust:1-alpine build, alpine:3.23 runtime.
36+
.github/workflows/ build / lock / merge / release.
37+
```
38+
39+
## How it's wired
40+
41+
`action::run` knows nothing about HTTP. It calls four trait methods on a
42+
`&dyn GithubClient`:
43+
44+
```
45+
get_pull GET /repos/{o}/{r}/pulls/{n}
46+
update_ref PATCH /repos/{o}/{r}/git/refs/{ref}
47+
merge_pull PUT|POST /repos/{o}/{r}/pulls/{n}/merge
48+
remove_label DELETE /repos/{o}/{r}/issues/{n}/labels/{name|id}
49+
```
50+
51+
Two implementations: `OctocrabClient` (GitHub) and `GiteaClient`. Both use
52+
`octocrab::Octocrab` purely as an authenticated HTTP client; the typed
53+
GitHub helpers from octocrab are *not* used. Selection happens once at
54+
startup via `pick_backend(&ctx)`.
55+
56+
### Three places Gitea diverges from GitHub
57+
58+
These are the only behavioural differences between the two clients —
59+
everything else is shared trait + identical wire calls:
60+
61+
1. **Merge endpoint method+body.** GitHub: `PUT` with
62+
`{merge_method, sha, commit_title, commit_message}`. Gitea: `POST` with
63+
CamelCase `{Do, MergeTitleField, MergeMessageField, head_commit_id}`.
64+
2. **Empty merge response.** Gitea returns `200` with an empty body, so
65+
`GiteaClient::merge_pull` uses the low-level `_post` helper and checks
66+
the status manually instead of the typed `.post()` deserialiser.
67+
3. **Label removal by id.** GitHub takes the label *name* in the URL;
68+
Gitea takes the numeric *id*. `GiteaClient::remove_label` does
69+
`GET .../issues/{n}/labels` first and resolves name → id via
70+
`resolve_label_id` (a pure helper kept testable on its own).
71+
72+
`update_ref` and `get_pull` are wire-compatible across both forges.
73+
74+
## Build / test / lint
75+
76+
```sh
77+
cargo build --release # what the Dockerfile runs
78+
cargo test # 74 tests across unit + integration + wire
79+
cargo fmt --check # style
80+
cargo clippy --all-targets -- -D warnings # lints
81+
make docker-build # local image build (linux/amd64 by default)
82+
```
83+
84+
Pre-push gate: `cargo fmt --check && cargo clippy --all-targets -- -D
85+
warnings && cargo test`. fmt is sub-second; clippy and test catch real
86+
bugs.
87+
88+
## Testing model
89+
90+
Three layers, in increasing order of fidelity:
91+
92+
1. **Unit tests in each module.** Pure-function level: serde body shapes,
93+
`encode_path_segment`, `resolve_label_id`, `escape_data`, env parsing,
94+
`pick_backend`. Fastest, most numerous.
95+
2. **`tests/integration.rs`.** Drives `action::run` against a fake
96+
`GithubClient` to verify decision logic end-to-end (skip cases, merge,
97+
fast-forward, label cleanup). No HTTP.
98+
3. **`tests/wire.rs`.** Stands up an in-process `wiremock` server and
99+
asserts the exact HTTP method, path, body and `Authorization` header
100+
each client sends. This is the only layer that catches "we shipped
101+
`PUT` instead of `POST`" or "we put the label name in the URL when
102+
Gitea wants the id" — the body-shape unit tests can't.
103+
104+
Always add or extend a wire test when changing how a request is built.
105+
106+
## Conventions / gotchas
107+
108+
- **Env-touching tests must use `with_env` in `context.rs`.** It holds a
109+
process-wide `Mutex` so parallel tests can't observe a half-mutated
110+
environment. New tests that read or write env vars belong in that
111+
module or copy the same lock pattern.
112+
- **Trait-first plumbing.** Don't add HTTP work directly into
113+
`action.rs`. Add a method to the `GithubClient` trait, implement it on
114+
both `OctocrabClient` and `GiteaClient`, and add a wire test on each
115+
side. The fake clients in `action.rs` and `tests/integration.rs` need
116+
matching impls for the test suite to compile.
117+
- **URL building goes through `encode_path_segment`.** Hand-rolling
118+
`replace(' ', "%20")` is what the previous code did and it broke on
119+
`?`, `#`, `&`, `+`, `=`, `:`, and non-ASCII. The encoder handles every
120+
byte outside the RFC 3986 unreserved set.
121+
- **Errors are propagated, not logged-and-swallowed.** Any failure in
122+
the merge / fast-forward step fails the action. Label removal is the
123+
one exception: failures there log a warning but still return success
124+
(parity with the original behaviour).
125+
- **Outputs go through the `Logger` trait.** Don't `println!` from
126+
library code — write to the logger so tests can capture it. Workflow
127+
command bytes (`::warning::`, `::error::`, `%0A`/`%0D`/`%25` escapes)
128+
are pinned by tests in `logger.rs`.
129+
- **Release profile is size-optimised** (`opt-level = "z"`, LTO, single
130+
codegen unit, strip). The runtime image is `alpine:3.23` with
131+
`ca-certificates` installed; binary is built on `rust:1-alpine` for
132+
matching musl ABI.
133+
134+
## Distribution
135+
136+
- Docker image: `ghcr.io/sudo-bot/action-pull-request-merge:latest`,
137+
also tagged with each release.
138+
- Marketplace tag: `@v2` (moving — points at the latest 2.x). `Cargo.toml`
139+
is on `2.0.0` but no `v2.0.0` git tag exists; the marketplace
140+
convention is to keep `@v2` moving. Compare links in `CHANGELOG.md`
141+
use the moving `v2` tag for that reason.
142+
- `make update-tags` re-points `v2` at `main` and force-pushes.
143+
144+
## When working on this
145+
146+
- For a behaviour change: edit `action.rs` (decision logic), update the
147+
fake client in tests, and check that integration + wire tests still
148+
pin the contract you intend.
149+
- For a wire-shape change: edit one or both clients, **add a wire test**
150+
in `tests/wire.rs`, run `cargo test --test wire` first to iterate
151+
fast.
152+
- For a new endpoint: extend the `GithubClient` trait, implement on
153+
both clients, write wire tests for both, then use it from `action.rs`.
154+
- The Cargo edition is `2021`. Async runtime is tokio
155+
(`#[tokio::main]` in main, `#[tokio::test]` everywhere async is
156+
needed).

0 commit comments

Comments
 (0)