docs: refocus AGENTS.md on code style and testing for AI agents
Replace CI/CD documentation with practical guidelines for AI coding agents including module structure, naming conventions, error handling patterns, Oban worker examples, and testing patterns with Mox.
This commit is contained in:
parent
4d15e0892d
commit
4473f81fa5
1 changed files with 198 additions and 161 deletions
359
AGENTS.md
359
AGENTS.md
|
|
@ -1,218 +1,255 @@
|
|||
# Agent Guidelines for Pinchflat
|
||||
|
||||
This document provides context and guidelines for AI agents working on this codebase.
|
||||
Guidelines for AI agents working on this Elixir/Phoenix codebase.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Pinchflat is an Elixir/Phoenix application for self-hosted media management. It uses:
|
||||
Pinchflat is a self-hosted media management app using:
|
||||
|
||||
- **Backend**: Elixir 1.17+, Phoenix 1.7, Ecto with SQLite
|
||||
- **Frontend**: Phoenix LiveView, Tailwind CSS, esbuild
|
||||
- **Background Jobs**: Oban
|
||||
- **Containerization**: Docker with multi-arch support (amd64/arm64)
|
||||
- **Testing**: ExUnit with Mox for mocking
|
||||
|
||||
## Development Environment
|
||||
|
||||
This project uses Nix flakes for reproducible development environments.
|
||||
## Build/Lint/Test Commands
|
||||
|
||||
```bash
|
||||
# Enter dev shell
|
||||
# Enter Nix dev shell (required for tooling)
|
||||
nix develop
|
||||
|
||||
# Or with specific shell
|
||||
nix develop . --command fish
|
||||
# Run all checks (compile, format, credo, tests, sobelow, prettier)
|
||||
mix check
|
||||
|
||||
# Run tests only
|
||||
mix test
|
||||
|
||||
# Run a single test file
|
||||
mix test test/pinchflat/media_test.exs
|
||||
|
||||
# Run a specific test by line number
|
||||
mix test test/pinchflat/media_test.exs:42
|
||||
|
||||
# Run tests matching a pattern
|
||||
mix test --only describe:"list_media_items"
|
||||
|
||||
# Format Elixir code
|
||||
mix format
|
||||
|
||||
# Run Credo linter
|
||||
mix credo
|
||||
|
||||
# Security analysis
|
||||
mix sobelow --config
|
||||
|
||||
# Format JS/CSS/YAML/JSON
|
||||
yarn run lint:fix
|
||||
```
|
||||
|
||||
### Available Tools (via flake.nix)
|
||||
## Code Style Guidelines
|
||||
|
||||
- `lefthook` - Git hooks manager
|
||||
- `actionlint` - GitHub Actions linter
|
||||
- `typos` - Spell checker
|
||||
- `prettier` - Code formatter (JS/CSS/YAML/JSON)
|
||||
- `cocogitto` - Conventional commits
|
||||
- `just` - Command runner
|
||||
- Docker tooling (docker, docker-buildx, docker-compose)
|
||||
### Module Structure
|
||||
|
||||
## Git Hooks (lefthook)
|
||||
Follow this order in modules:
|
||||
|
||||
Pre-commit hooks are configured in `lefthook.yml`:
|
||||
1. `@moduledoc` - required for all public modules
|
||||
2. `use`/`import`/`alias`/`require` statements
|
||||
3. Module attributes (`@allowed_fields`, `@impl`, etc.)
|
||||
4. Public functions with `@doc`
|
||||
5. Private functions
|
||||
|
||||
| Hook | Files | Purpose |
|
||||
| ------------ | ----------------------------------------- | ------------------- |
|
||||
| `prettier` | `*.{css,html,js,json,md,mjs,ts,yaml,yml}` | Format check |
|
||||
| `mix format` | `*.{ex,exs}` | Elixir formatting |
|
||||
| `typos` | All staged files | Spell checking |
|
||||
| `actionlint` | `.github/workflows/*.{yml,yaml}` | Validate GH Actions |
|
||||
```elixir
|
||||
defmodule Pinchflat.Media do
|
||||
@moduledoc """
|
||||
The Media context.
|
||||
"""
|
||||
|
||||
### Bypassing Hooks
|
||||
import Ecto.Query, warn: false
|
||||
use Pinchflat.Media.MediaQuery
|
||||
|
||||
If hooks fail due to environment issues (e.g., permission errors):
|
||||
alias Pinchflat.Repo
|
||||
alias Pinchflat.Media.MediaItem
|
||||
|
||||
```bash
|
||||
git commit --no-verify -m "message"
|
||||
@doc """
|
||||
Returns the list of media_items.
|
||||
"""
|
||||
def list_media_items do
|
||||
Repo.all(MediaItem)
|
||||
end
|
||||
|
||||
defp some_private_function do
|
||||
# ...
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## GitHub Actions Workflows
|
||||
### Imports and Aliases
|
||||
|
||||
### `lint_and_test.yml`
|
||||
- Use `alias` for frequently used modules
|
||||
- Prefer explicit imports: `import Ecto.Query, warn: false`
|
||||
- Group aliases alphabetically
|
||||
- Use `alias __MODULE__` for self-reference in schemas
|
||||
|
||||
Runs on PRs and pushes to master. Uses Docker Compose for testing.
|
||||
### Naming Conventions
|
||||
|
||||
Key features:
|
||||
- **Modules**: PascalCase matching file path (`Pinchflat.Media.MediaItem`)
|
||||
- **Functions**: snake*case with verb prefixes (`get*`, `list*`, `create*`, `update*`, `delete*`)
|
||||
- **Predicate functions**: end with `?` (`pending_download?/1`)
|
||||
- **Private functions**: prefix with `do_` for wrapper pattern (`do_delete_media_files`)
|
||||
- **Workers**: suffix with `Worker` (`MediaDownloadWorker`)
|
||||
- **Fixtures**: suffix with `_fixture` (`media_item_fixture`)
|
||||
|
||||
- Concurrency control (cancels in-progress runs on same PR)
|
||||
- 30-minute timeout
|
||||
- Pinned action versions (SHA-based for security)
|
||||
### Formatting
|
||||
|
||||
### `docker_release.yml`
|
||||
- Line length: 120 characters max
|
||||
- Use `mix format` - config in `.formatter.exs`
|
||||
- Prettier for JS/CSS/YAML/JSON
|
||||
|
||||
Builds and pushes Docker images to GHCR.
|
||||
### Return Types
|
||||
|
||||
**Architecture**:
|
||||
Document returns in `@doc`:
|
||||
|
||||
```
|
||||
prepare job (determines platform matrix)
|
||||
|
|
||||
v
|
||||
build job (parallel)
|
||||
├── linux/amd64 on ubuntu-latest (native)
|
||||
└── linux/arm64 on ubuntu-24.04-arm (native, NOT QEMU)
|
||||
|
|
||||
v
|
||||
merge job (creates multi-arch manifest)
|
||||
```elixir
|
||||
@doc """
|
||||
Creates a media_item.
|
||||
|
||||
Returns {:ok, %MediaItem{}} | {:error, %Ecto.Changeset{}}
|
||||
"""
|
||||
def create_media_item(attrs) do
|
||||
# ...
|
||||
end
|
||||
```
|
||||
|
||||
**Trigger behavior**:
|
||||
### Error Handling
|
||||
|
||||
- Push to master: Builds amd64 only (fast dev iteration)
|
||||
- Release published: Builds both amd64 and arm64
|
||||
- workflow_dispatch: User chooses platforms
|
||||
- Use `{:ok, result}` / `{:error, reason}` tuples
|
||||
- Use `!` suffix for functions that raise (`get_media_item!/1`)
|
||||
- Pattern match on error tuples explicitly
|
||||
- Rescue specific exceptions when needed:
|
||||
|
||||
**Important**: GHCR requires lowercase repository names. The workflow converts `$GITHUB_REPOSITORY` to lowercase using `${GITHUB_REPOSITORY,,}`.
|
||||
|
||||
## Releasing a New Version
|
||||
|
||||
1. Update version in `mix.exs`:
|
||||
|
||||
```elixir
|
||||
version: "YYYY.M.D",
|
||||
```
|
||||
|
||||
2. Commit and tag:
|
||||
|
||||
```bash
|
||||
git add mix.exs
|
||||
git commit -m "chore: bump version to vYYYY.M.D"
|
||||
git tag vYYYY.M.D
|
||||
```
|
||||
|
||||
3. Push to your fork:
|
||||
|
||||
```bash
|
||||
git push origin master && git push origin vYYYY.M.D
|
||||
```
|
||||
|
||||
4. Create GitHub release:
|
||||
```bash
|
||||
gh release create vYYYY.M.D --title "vYYYY.M.D" --generate-notes --repo <owner>/pinchflat
|
||||
```
|
||||
|
||||
### Cleaning Up Tags
|
||||
|
||||
If you need to delete and recreate tags:
|
||||
|
||||
```bash
|
||||
# Delete locally
|
||||
git tag -d vX.Y.Z
|
||||
|
||||
# Delete on remote
|
||||
git push origin --delete vX.Y.Z
|
||||
|
||||
# Recreate on specific commit
|
||||
git tag vX.Y.Z <commit-sha>
|
||||
|
||||
# Force push to overwrite remote
|
||||
git push origin vX.Y.Z --force
|
||||
```elixir
|
||||
def perform(%Oban.Job{args: %{"id" => id}}) do
|
||||
# ...
|
||||
rescue
|
||||
Ecto.NoResultsError -> Logger.info("Record not found")
|
||||
Ecto.StaleEntryError -> Logger.info("Record stale")
|
||||
end
|
||||
```
|
||||
|
||||
## Validating GitHub Actions Locally
|
||||
### Oban Workers
|
||||
|
||||
Always validate workflow changes before pushing:
|
||||
```elixir
|
||||
defmodule Pinchflat.Downloading.MediaDownloadWorker do
|
||||
@moduledoc false
|
||||
|
||||
```bash
|
||||
# Check all workflows
|
||||
actionlint
|
||||
use Oban.Worker,
|
||||
queue: :media_fetching,
|
||||
priority: 5,
|
||||
unique: [period: :infinity, states: [:available, :scheduled, :retryable, :executing]],
|
||||
tags: ["media_item", "show_in_dashboard"]
|
||||
|
||||
# Check specific file
|
||||
actionlint .github/workflows/docker_release.yml
|
||||
@impl Oban.Worker
|
||||
def perform(%Oban.Job{args: %{"id" => id} = args}) do
|
||||
# Return :ok, {:ok, result}, {:error, reason}, or {:snooze, seconds}
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Common Issues actionlint Catches
|
||||
### Testing Patterns
|
||||
|
||||
- Invalid YAML syntax
|
||||
- Unknown/misspelled action inputs
|
||||
- Type errors in expressions (`${{ }}`)
|
||||
- Invalid `runs-on` values
|
||||
- Shell script issues (via shellcheck)
|
||||
- Deprecated features
|
||||
Use `Pinchflat.DataCase` for database tests:
|
||||
|
||||
### Shellcheck Directives
|
||||
```elixir
|
||||
defmodule Pinchflat.MediaTest do
|
||||
use Pinchflat.DataCase
|
||||
|
||||
For intentional word splitting in workflows, add the directive inside the `run:` block:
|
||||
import Pinchflat.MediaFixtures
|
||||
import Pinchflat.SourcesFixtures
|
||||
|
||||
```yaml
|
||||
run: |
|
||||
# shellcheck disable=SC2046
|
||||
docker buildx imagetools create $(jq -cr '...' <<< "$JSON") \
|
||||
$(printf 'image@sha256:%s ' *)
|
||||
alias Pinchflat.Media
|
||||
|
||||
describe "list_media_items/0" do
|
||||
test "returns all media_items" do
|
||||
media_item = media_item_fixture()
|
||||
assert Media.list_media_items() == [media_item]
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## CI Best Practices
|
||||
### Mocking with Mox
|
||||
|
||||
1. **Pin action versions to SHAs** for security:
|
||||
Mocks are defined in `test/test_helper.exs`:
|
||||
|
||||
```yaml
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
```
|
||||
- `YtDlpRunnerMock` - yt-dlp commands
|
||||
- `HTTPClientMock` - HTTP requests
|
||||
- `UserScriptRunnerMock` - user scripts
|
||||
- `AppriseRunnerMock` - notifications
|
||||
|
||||
2. **Quote shell variables** to satisfy shellcheck:
|
||||
```elixir
|
||||
test "calls user script" do
|
||||
expect(UserScriptRunnerMock, :run, fn :media_deleted, data ->
|
||||
assert data.id == media_item.id
|
||||
{:ok, "", 0}
|
||||
end)
|
||||
|
||||
```yaml
|
||||
run: echo "value" >> "$GITHUB_OUTPUT"
|
||||
```
|
||||
|
||||
3. **Use native ARM runners** instead of QEMU for faster builds:
|
||||
|
||||
```yaml
|
||||
runs-on: ubuntu-24.04-arm # Native ARM64
|
||||
```
|
||||
|
||||
4. **Scope caches by platform** to avoid conflicts:
|
||||
|
||||
```yaml
|
||||
cache-from: type=gha,scope=build-linux-amd64
|
||||
```
|
||||
|
||||
5. **Add concurrency controls** to save CI minutes:
|
||||
```yaml
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
# Via Docker Compose (as CI does)
|
||||
docker compose -f docker-compose.ci.yml up -d
|
||||
docker compose exec phx mix deps.get
|
||||
docker compose exec phx mix ecto.create
|
||||
docker compose exec phx mix ecto.migrate
|
||||
docker compose exec phx mix check --no-fix --no-retry
|
||||
Media.delete_media_item(media_item, delete_files: true)
|
||||
end
|
||||
```
|
||||
|
||||
## Code Style
|
||||
### Test Fixtures
|
||||
|
||||
- Elixir: Follow `mix format` (configured in `.formatter.exs`)
|
||||
- JavaScript/CSS: Follow Prettier (configured in `.prettierrc.js`)
|
||||
- Commits: Conventional commits recommended (cocogitto installed)
|
||||
Create fixtures in `test/support/fixtures/`:
|
||||
|
||||
```elixir
|
||||
def media_item_fixture(attrs \\ %{}) do
|
||||
{:ok, media_item} =
|
||||
attrs
|
||||
|> Enum.into(%{
|
||||
media_id: Faker.String.base64(12),
|
||||
title: Faker.Commerce.product_name(),
|
||||
source_id: source_fixture().id
|
||||
})
|
||||
|> Pinchflat.Media.create_media_item()
|
||||
|
||||
media_item
|
||||
end
|
||||
```
|
||||
|
||||
### Test Helpers
|
||||
|
||||
From `Pinchflat.TestingHelperMethods`:
|
||||
|
||||
- `now/0` - current UTC datetime
|
||||
- `now_minus/2` - datetime in past (`now_minus(5, :days)`)
|
||||
- `now_plus/2` - datetime in future
|
||||
- `assert_changed/2` - verify state change
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
lib/
|
||||
pinchflat/ # Business logic contexts
|
||||
media/ # Media context (MediaItem, queries)
|
||||
sources/ # Source context
|
||||
downloading/ # Download workers and helpers
|
||||
yt_dlp/ # yt-dlp integration
|
||||
pinchflat_web/ # Phoenix web layer
|
||||
test/
|
||||
pinchflat/ # Context tests
|
||||
pinchflat_web/ # Controller/LiveView tests
|
||||
support/
|
||||
fixtures/ # Test data factories
|
||||
data_case.ex # Database test setup
|
||||
conn_case.ex # HTTP test setup
|
||||
```
|
||||
|
||||
## Pre-commit Hooks
|
||||
|
||||
Lefthook runs on commit:
|
||||
|
||||
- `prettier` - JS/CSS/YAML/JSON formatting
|
||||
- `mix format` - Elixir formatting
|
||||
- `typos` - spell checking
|
||||
- `actionlint` - GitHub Actions validation
|
||||
|
||||
Bypass if needed: `git commit --no-verify -m "message"`
|
||||
|
|
|
|||
Loading…
Reference in a new issue