Răsfoiți Sursa

refactor(ci): move the bot's repository briefing into versioned files a test pins

Sanaei 13 ore în urmă
părinte
comite
19e71d9acc
4 a modificat fișierele cu 2205 adăugiri și 641 ștergeri
  1. 184 0
      .github/claude/repo-context.md
  2. 93 0
      .github/claude/review-rubric.md
  3. 1778 641
      .github/workflows/claude-bot.yml
  4. 150 0
      bot_context_test.go

+ 184 - 0
.github/claude/repo-context.md

@@ -0,0 +1,184 @@
+# Repository context for the Claude bot
+
+Shared briefing for every job in `.github/workflows/claude-bot.yml`. It exists so
+these facts live in ONE place next to the code instead of being restated in five
+prompts, where they went stale silently.
+
+**Read this from the workspace checkout, which is the base revision and is
+trusted. NEVER read it from `/tmp/head`** — a pull request controls that tree,
+and a fork that could supply this file could rewrite the rules it carries.
+
+`CLAUDE.md`, `frontend/CLAUDE.md` and `docs/architecture.md` outrank this file.
+Where they disagree with it, they win and this file is the thing to fix.
+`docs/architecture.md` carries a "Symptom -> File" index and the cron-job table,
+which answer "which file owns X" in one hop; grepping blind wastes turns on a
+question it already answers.
+
+## Stack
+
+3x-ui is an open-source web control panel for managing Xray-core servers.
+
+- Backend: Go 1.26, module `github.com/mhsanaei/3x-ui/v3`, Gin and GORM.
+- It runs Xray-core as a managed child process (`internal/xray/process.go`) and
+  imports `github.com/xtls/xray-core` for config types and the gRPC
+  stats/handler/router API. The release the panel BUNDLES is pinned in
+  `DockerInit.sh`; the version it COMPILES against is pinned in `go.mod`, and
+  the two are not always the same.
+- MTProto inbounds run a SECOND managed child, the `mtg-multi` binary (a
+  multi-secret mtg fork, panel-side code in `internal/mtproto/`), one process
+  per inbound. Client, ad-tag and quota/expiry edits are hot-applied through the
+  fork's management API (`PUT /secrets`) so connections survive, with a process
+  restart as the fallback on older binaries.
+- Storage: SQLite by default (`/etc/x-ui/x-ui.db` on Linux, the executable
+  directory on Windows) or PostgreSQL (`XUI_DB_TYPE` / `XUI_DB_DSN`). The SQLite
+  driver is CGo, so `CGO_ENABLED=0` builds fail.
+- Frontend: React 19 + Ant Design 6 + Vite 8 + TypeScript in `frontend/`, built
+  into `internal/web/dist/` (gitignored) and embedded with `embed.FS`.
+
+## Where things live
+
+| area | path |
+| --- | --- |
+| entry point + `x-ui` CLI | `main.go` |
+| env parsing | `internal/config/` |
+| schema, migrations | `internal/database/`, `internal/database/model/` |
+| Xray child process + config | `internal/xray/` |
+| MTProto inbounds | `internal/mtproto/` |
+| subscription server | `internal/sub/` |
+| HTTP handlers | `internal/web/controller/` |
+| business logic | `internal/web/service/` |
+| cron jobs (schedules in `web.go startTask()`) | `internal/web/job/` |
+| master/sub-node over mTLS | `internal/web/runtime/` |
+| i18n | `internal/web/locale/`, `internal/web/translation/` |
+| UI source | `frontend/src/` |
+| install / upgrade | `install.sh`, `x-ui.sh`, `DockerInit.sh` |
+
+## Hard rules a change must respect
+
+- **Dispatch through `runtime.Runtime`.** Every state-changing inbound or client
+  operation goes through the interface in `internal/web/runtime/`, never
+  straight to `internal/xray/api.go`. A direct call passes every local test and
+  silently breaks every multi-node deployment; it is invisible in a single-box
+  reading of a diff.
+- **Layering.** Controllers are thin — bind, validate, respond — with no GORM
+  queries, no Xray calls and no business rules. `internal/util/*` is leaf-only
+  and must not import service, controller or database. `internal/web/dist/` and
+  `frontend/src/generated/` are generated; a hand-edit is a violation.
+- **Comments in committed Go/TS/TSX: 2 lines MAX per block**, spent on the *why*
+  a name cannot hold — an invariant, an issue number, a non-obvious constraint.
+  Exempt, never flag: `//go:build`, `//go:generate`, `//nolint:`,
+  `// Code generated ... DO NOT EDIT.`. HTML `<!-- -->` is fine.
+- **The route contract chain**, which breaks in four distinct places:
+  1. a new `g.POST`/`g.GET` in `internal/web/controller/` needs a matching entry
+     in `frontend/src/pages/api-docs/endpoints.ts` — pinned BOTH ways by
+     `TestRouteRegistryContract` in `internal/web/routes_contract_test.go`, so a
+     renamed or removed route that leaves a stale entry fails too;
+  2. generated artefacts must be regenerated with `make gen`, or CI's `codegen`
+     job fails on a dirty `frontend/src/generated` or
+     `frontend/public/openapi.json`;
+  3. a NEW struct crossing the API boundary must be added to the `StructAllow`
+     allowlist in `tools/openapigen/main.go`, or it is SILENTLY dropped from the
+     schemas and `frontend/scripts/build-openapi.mjs` then fails — a guaranteed
+     CI break, not a style nit;
+  4. the step NOTHING checks — `frontend/public/openapi.json` must be copied to
+     `docs/public/openapi.json` and the MDX regenerated with
+     `cd docs && pnpm gen:api`, because `docs-ci.yml` fires only on `docs/**`.
+     Step 4 is the one that reaches production wrong.
+- **i18n.** A new English key goes in EVERY locale JSON in
+  `internal/web/translation/` (13 files) AND must be referenced from
+  `frontend/src` or Go in the SAME change.
+  `frontend/src/test/i18n-dead-keys.test.ts` fails on a missing locale file and
+  on an orphan key alike.
+- **Migrations.** Schema changes are GORM `AutoMigrate` PLUS hand-written
+  migrations in `internal/database/db.go`. There are no migration files and no
+  down-migrations, and everything has to work on SQLite AND PostgreSQL.
+- **Tests.** Stdlib `testing` only (no testify), table-driven with `t.Run`
+  subtests and `t.Helper()` on helpers. An assertion must pin the exact value,
+  typed error or emitted string — `err != nil` and `len(x) > 0` are findings,
+  not nits. Prefer real dependencies: a throwaway DB via
+  `database.InitDB(filepath.Join(t.TempDir(), "x-ui.db"))` with `t.Cleanup`, and
+  `httptest` for HTTP. `internal/sub`'s `initSubDB(t)` is the template.
+  A test must FAIL without its fix; one that passes either way certifies
+  nothing and then gets cited as proof the fix works.
+
+## The three link implementations
+
+Link and subscription generation is implemented three times, independently:
+
+| language | path | what it feeds |
+| --- | --- | --- |
+| Go | `internal/util/link/`, `internal/sub/` | what the panel serves |
+| TS | `frontend/src/lib/xray/` | what the panel UI shows |
+| TS | `docs/lib/xray/` | what the docs site shows |
+
+A change to share-link or subscription output that touches one and not the
+others is how they drift apart.
+
+## Downstream programs that must accept what the panel emits
+
+- **XTLS/Xray-core** — the Xray config the panel generates, and the VLESS/VMess
+  transport and security fields.
+- **MetaCubeX/mihomo** — consumes the Clash YAML from `internal/sub/`.
+- **SagerNet/sing-box** — parses the share links the panel emits.
+- **mhsanaei/mtg-multi** — the MTProto sidecar whose TOML (`[secrets]`,
+  `[secret-ad-tags]`, `[secret-limits]`) and management API
+  (`PUT /secrets`, `POST /secrets/{name}/reset-quota`) `internal/mtproto/`
+  writes and calls.
+
+## What CI runs
+
+`.github/workflows/ci.yml`, on every pull request touching Go or frontend code.
+It is paths-filtered, so a docs-only or workflow-only change produces no run.
+
+| job | what it proves |
+| --- | --- |
+| `go-test` | `go test -shuffle=on -count=1` over every package except `frontend/node_modules` |
+| `race` | the same set under `-race -shuffle=on` |
+| `postgres-durable-first` | live PostgreSQL 16: the `PostgresCommitFailure` tests plus `TestHostAutoMigrateCreatesColumns_Postgres` and `TestMigrate_Postgres`. Both steps COUNT passes rather than assert on SKIP, so a renamed or deleted test fails the job |
+| `govulncheck` | known vulnerabilities |
+| `golangci` | `golangci-lint` |
+| `fuzz-smoke` | 30s each on `FuzzParseLink` and `FuzzDecodeCertPin` |
+| `codegen` | `npm run gen` then `git diff --exit-code` on the generated files |
+| `frontend` | MSW worker drift, lint, format:check, typecheck, `npm test` (Vitest + headless-Chromium Storybook), build, build-storybook, `npm audit` |
+
+**What CI does NOT prove.** These test families `t.Skip` unless an environment
+variable is set, and CI sets only the PostgreSQL ones above:
+
+| gate | covers |
+| --- | --- |
+| `XUI_TEST_PG_DSN` | PostgreSQL-specific paths |
+| `XUI_DB_TYPE` + `XUI_DB_DSN` | dialect-dependent behaviour |
+| `XRAY_E2E_BINARY` | the Xray gRPC end-to-end tests in `internal/xray/` |
+| `XUI_SCALE_TEST` | scale tests in `internal/sub/`, `internal/web/job/`, `internal/web/service/` |
+
+Mutation testing (`mutation.yml`) runs nightly and never on a pull request, so a
+test that cannot fail is invisible to CI. `make verify` is the local gate.
+
+## Support facts reporters get wrong
+
+- Linux install: `bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)`
+- Install generates a RANDOM username, password and web base path — never
+  admin/admin. The `x-ui` menu on the server shows or resets them.
+- The installer service environment file is DISTRO-DEPENDENT:
+  `/etc/default/x-ui` (Debian/Ubuntu), `/etc/conf.d/x-ui` (Arch),
+  `/etc/sysconfig/x-ui` (RHEL/Fedora). Naming the wrong one means the reporter's
+  edit is silently never read by systemd — a common cause of "I set the variable
+  and nothing happened".
+- Windows is supported. There the database sits next to the executable, not in
+  `/etc` — never quote the Linux path to a Windows user.
+- SQLite to PostgreSQL: `x-ui migrate-db --dsn "postgres://..."`, then set
+  `XUI_DB_TYPE`/`XUI_DB_DSN` in that file and `systemctl restart x-ui`. The
+  source SQLite file is left in place.
+- Docker image `ghcr.io/mhsanaei/3x-ui`; PostgreSQL profile
+  `docker compose --profile postgres up -d`. Fail2ban IP-limit enforcement needs
+  `NET_ADMIN` + `NET_RAW` (compose grants them; a bare `docker run` must add
+  `--cap-add=NET_ADMIN --cap-add=NET_RAW`).
+- Never state that a `XUI_*` variable does not exist without grepping
+  `internal/config/` and `internal/tunnelmonitor/` first. The
+  `XUI_TUNNEL_HEALTH_*` family is the usual answer to "the panel restarts Xray
+  every few minutes".
+- Security per inbound is none / tls / reality. XTLS is a VLESS *flow*
+  (`xtls-rprx-vision`), not a security setting — never tell anyone to pick XTLS
+  in the security dropdown.
+- Never hardcode a version. For "is this already fixed" use
+  `gh release list -L 10`, `gh search commits`, and `git log -S`.

+ 93 - 0
.github/claude/review-rubric.md

@@ -0,0 +1,93 @@
+# Review rubric and lane map
+
+Shared by the four pull-request review lanes in
+`.github/workflows/claude-bot.yml`. The lane map below is here so it exists
+ONCE: when each lane carried its own copy of "mine / not mine", the four copies
+could quietly contradict each other and the same defect got reported twice or
+not at all.
+
+**Read this from the workspace checkout, which is the base revision and is
+trusted. NEVER read it from `/tmp/head`** — a pull request controls that tree,
+and a fork that could supply this file could rewrite the rubric it is judged by.
+
+## Lane map — who owns what
+
+Ownership is decided by WHAT YOU WOULD HAVE TO BE RIGHT ABOUT for the finding to
+be true, not by how bad the consequence would be.
+
+| lane | owns |
+| --- | --- |
+| **Senior Developer** | Correctness, edge cases, nil and empty handling, regressions. Layering and the `runtime.Runtime` dispatch rule. Security in code: authn/authz, input validation, injection, XSS, CSRF, SSRF, path traversal, secrets, unsafe defaults — weighted at `internal/web/controller/`, session and middleware, the PUBLIC `internal/sub/` surface, and Xray config generation. Concurrency: races, deadlocks, goroutine and task leaks around the Xray and mtg-multi children, the cron jobs, the eventbus, the websockets. Performance. Maintainability and the 2-line comment cap. Frontend code quality. **Every client-facing field name, encoding and hash choice** the change emits. |
+| **Senior QA** | `internal/database/**`, `internal/database/model/**`, `internal/config/`, `internal/web/translation/**`, `tools/openapigen/`, `frontend/src/pages/api-docs/endpoints.ts`, `.github/workflows/**`, `Dockerfile*`, `docker-compose.yml`, `install.sh`, `x-ui.sh`, `DockerInit.sh`, `Makefile`, `CLAUDE.md`, `frontend/CLAUDE.md`, `docs/**`, `README*`, `SECURITY.md`. Plus intent, upgrade safety, blast radius, backward compatibility of those contracts, operational impact, and labels. |
+| **Senior Tester** | Test quality and coverage, what CI proved and what it did not, weak assertions, vacuous tests, snapshot and golden-fixture abuse. |
+| **Arbiter** | Reconciliation, upstream wire-format resolution, and divergence BETWEEN the three link implementations. |
+
+### Boundaries that are easy to get wrong
+
+- **Field names are the Developer's, never QA's** — a config key, JSON tag, URI
+  parameter, YAML key, TOML key, value encoding, hash choice, or which of two
+  variables a field is populated from. However large the blast radius. If your
+  finding is only true when one of those is wrong, it is the Developer's.
+- **QA outside its own files** may report exactly ONE thing: *a configuration
+  that works on the base branch today behaves differently after this ships, with
+  no operator action* — and only when it can state (a) the concrete existing
+  configuration, (b) what it does today, (c) what it does after. Otherwise drop
+  it; the Developer has it.
+- **Destroying data IS QA's**, even outside its files: regenerating a live key or
+  UUID, overwriting a stored secret, resetting a traffic counter or expiry. That
+  is blast radius, not correctness.
+- **`docs/lib/xray/`**: QA reports the process omission ("it was not updated").
+  The Arbiter reports semantic divergence between the three implementations. The
+  Developer reports whether the one in front of it emits the right thing.
+- **The Tester never** opines on architecture, naming or what the code emits,
+  and never restates a green CI job as a finding.
+
+## Severity — exactly one per finding, plain text, no emoji
+
+| level | means |
+| --- | --- |
+| Critical | security hole, data corruption or loss, crash, privilege escalation, authentication bypass, unrecoverable migration, or a fleet-wide outage path |
+| High | likely production bug, incorrect behaviour on a common path, a breaking API or subscription-format change, a missing migration, a guaranteed CI break, or a significant performance problem |
+| Medium | missing validation, an unhandled edge case, an undeclared behaviour change, documentation or OpenAPI drift, a maintainability problem, or an untested new code path |
+| Low | minor readability, consistency, operational or documentation improvement |
+| Suggestion | optional improvement with no correctness or release impact |
+
+## Confidence — exactly one per finding
+
+High, Medium, or Low. Reserve **High** for something CONFIRMED in the source and
+citable as `file:line`, or observed in real command output. Anything inferred,
+or resting on a detail you could not check, is Medium or Low.
+
+## Verdict — exactly one
+
+`Approve`, `Comment`, or `Request changes`.
+
+## Finding block
+
+Fields on their own lines:
+
+```
+Severity / Confidence / Category
+Location: file:line as plain text, not a Markdown link
+Problem: what is wrong
+Why it matters: the practical runtime, security, operational or upgrade impact
+Recommendation: the preferred fix
+```
+
+The Tester replaces `Why it matters` with `Evidence`: the command or CI job and
+the real output it read. A code example is optional and, if included, must be a
+plain fenced code block — never a ```suggestion``` block, since the Arbiter
+republishes the text.
+
+## Reporting discipline
+
+- Report every problem, including Low and Suggestion. Never drop a finding
+  because you are unsure: report it at `Confidence: Low` and say what would
+  confirm it. Severity and confidence ARE the filter.
+- Dropping a finding because it is not YOURS is different, and is exactly what
+  the lane map asks for. A duplicate only costs the Arbiter a merge.
+- Do not report the same issue twice, do not bikeshed style, and ignore
+  pure-formatting changes unless they reduce readability. Ignore lock files and
+  true vendor code; do NOT ignore test fixtures or generated files.
+- If the diff is too large to cover completely, say so and name the files you
+  did NOT review. A truncated review that does not admit it is worse than none.

Fișier diff suprimat deoarece este prea mare
+ 1778 - 641
.github/workflows/claude-bot.yml


+ 150 - 0
bot_context_test.go

@@ -0,0 +1,150 @@
+package main
+
+// The Claude bot prompts in .github/workflows/claude-bot.yml no longer restate
+// repository facts; they read .github/claude/repo-context.md instead. A stale
+// claim in that file is invisible until it produces a wrong review, so every
+// claim a machine can check is pinned here.
+
+import (
+	"os"
+	"path/filepath"
+	"regexp"
+	"strings"
+	"testing"
+)
+
+const (
+	botContextPath = ".github/claude/repo-context.md"
+	botRubricPath  = ".github/claude/review-rubric.md"
+	ciWorkflowPath = ".github/workflows/ci.yml"
+)
+
+func readRepoFile(t *testing.T, path string) string {
+	t.Helper()
+	b, err := os.ReadFile(path)
+	if err != nil {
+		t.Fatalf("read %s: %v", path, err)
+	}
+	return string(b)
+}
+
+// section returns the text between two markers, so a table is matched only
+// inside the heading that owns it.
+func section(t *testing.T, doc, from, to string) string {
+	t.Helper()
+	i := strings.Index(doc, from)
+	if i < 0 {
+		t.Fatalf("%s no longer contains the heading %q", botContextPath, from)
+	}
+	rest := doc[i+len(from):]
+	if j := strings.Index(rest, to); j >= 0 {
+		return rest[:j]
+	}
+	return rest
+}
+
+func TestBotContextLocaleFileCount(t *testing.T) {
+	doc := readRepoFile(t, botContextPath)
+	m := regexp.MustCompile("`internal/web/translation/` \\((\\d+) files\\)").FindStringSubmatch(doc)
+	if m == nil {
+		t.Fatalf("%s no longer states the locale file count as \"`internal/web/translation/` (N files)\"", botContextPath)
+	}
+	files, err := filepath.Glob("internal/web/translation/*.json")
+	if err != nil {
+		t.Fatalf("glob locales: %v", err)
+	}
+	if got := len(files); m[1] != itoa(got) {
+		t.Errorf("%s claims %s locale files, internal/web/translation/ holds %d; update the claim and every prompt that relies on it", botContextPath, m[1], got)
+	}
+}
+
+func itoa(n int) string {
+	if n == 0 {
+		return "0"
+	}
+	var b []byte
+	for n > 0 {
+		b = append([]byte{byte('0' + n%10)}, b...)
+		n /= 10
+	}
+	return string(b)
+}
+
+func TestBotContextNamesRealCIJobs(t *testing.T) {
+	doc := readRepoFile(t, botContextPath)
+	ci := readRepoFile(t, ciWorkflowPath)
+	table := section(t, doc, "## What CI runs", "**What CI does NOT prove.**")
+	rows := regexp.MustCompile("(?m)^\\| `([a-z0-9-]+)` \\|").FindAllStringSubmatch(table, -1)
+	if len(rows) < 5 {
+		t.Fatalf("expected the CI table in %s to list at least 5 jobs, found %d", botContextPath, len(rows))
+	}
+	for _, r := range rows {
+		t.Run(r[1], func(t *testing.T) {
+			if !strings.Contains(ci, "\n  "+r[1]+":\n") {
+				t.Errorf("%s describes a CI job %q that %s does not define", botContextPath, r[1], ciWorkflowPath)
+			}
+		})
+	}
+}
+
+func TestBotContextNamesRealPaths(t *testing.T) {
+	doc := readRepoFile(t, botContextPath) + readRepoFile(t, botRubricPath)
+	// internal/web/dist and frontend/node_modules are build output: absent from a
+	// fresh clone, created by `make dist-stub` and `npm ci`.
+	generated := map[string]bool{
+		"internal/web/dist/":      true,
+		"frontend/node_modules":   true,
+		"frontend/src/generated/": true,
+	}
+	seen := map[string]bool{}
+	for _, m := range regexp.MustCompile("`([^`]+)`").FindAllStringSubmatch(doc, -1) {
+		p := m[1]
+		if !regexp.MustCompile(`^(internal|frontend|docs|tools|\.github)/`).MatchString(p) ||
+			strings.ContainsAny(p, "*{ ") || generated[p] || seen[p] {
+			continue
+		}
+		seen[p] = true
+		t.Run(p, func(t *testing.T) {
+			if _, err := os.Stat(strings.TrimSuffix(p, "/")); err != nil {
+				t.Errorf("%s names %q, which does not exist; the bot prompts trust this file", botContextPath, p)
+			}
+		})
+	}
+	if len(seen) < 20 {
+		t.Errorf("expected the bot context to name at least 20 repository paths, found %d - has the file been gutted?", len(seen))
+	}
+}
+
+func TestBotContextSkipGatesExist(t *testing.T) {
+	doc := readRepoFile(t, botContextPath)
+	table := section(t, doc, "**What CI does NOT prove.**", "Mutation testing")
+	// [A-Z0-9_] and not [A-Z_]: XRAY_E2E_BINARY carries a digit, and excluding it
+	// silently dropped that gate from the check instead of failing.
+	gates := regexp.MustCompile("`((?:XUI|XRAY)_[A-Z0-9_]+)`").FindAllStringSubmatch(table, -1)
+	if len(gates) < 5 {
+		t.Fatalf("expected at least 5 skip-gate variables in %s, found %d", botContextPath, len(gates))
+	}
+	var sources []string
+	err := filepath.WalkDir("internal", func(path string, d os.DirEntry, err error) error {
+		if err != nil {
+			return err
+		}
+		if !d.IsDir() && strings.HasSuffix(path, ".go") {
+			sources = append(sources, path)
+		}
+		return nil
+	})
+	if err != nil {
+		t.Fatalf("walk internal: %v", err)
+	}
+	for _, g := range gates {
+		t.Run(g[1], func(t *testing.T) {
+			for _, f := range sources {
+				if strings.Contains(readRepoFile(t, f), g[1]) {
+					return
+				}
+			}
+			t.Errorf("%s lists %s as a test skip gate, but no .go file under internal/ reads it", botContextPath, g[1])
+		})
+	}
+}

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff