Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.dev
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,6 @@ MCP_WRITE_TOKEN=agentique-dev-write
MCP_DB_USER=postgres
MCP_DB_PASSWORD=agentique-dev

# Jev (pipeline/jev.py), the newsletter link classifier. Empty locally: set a
# Jev (backend/app/core/jev.py), the newsletter link classifier. Empty locally: set a
# real key only in .env, never here.
OPENROUTER_API_KEY=
2 changes: 1 addition & 1 deletion .github/workflows/claude-code-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ jobs:

- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@f27be6b65a4652f08a2ba3cff79114829b588967 # v1
uses: anthropics/claude-code-action@f27be6b65a4652f08a2ba3cff79114829b588967 # v1.0.252
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/claude.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ jobs:

- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@f27be6b65a4652f08a2ba3cff79114829b588967 # v1
uses: anthropics/claude-code-action@f27be6b65a4652f08a2ba3cff79114829b588967 # v1.0.252
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}

Expand Down
26 changes: 11 additions & 15 deletions .github/workflows/deploy-production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,25 +49,21 @@ jobs:
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
version: "0.11.18"
# Straight into frontend/dist, so the one sync below ships it.
- name: Download dist
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: frontend-dist
path: frontend-dist
path: frontend/dist
# The workspace is a staging area only — actions/checkout resets it every
# run, so the services live in /opt/agentique. .env and .venv belong to
# the box; frontend/dist is replaced separately below.
- name: Sync source to /opt/agentique
run: >
rsync -a --delete
--exclude '.git'
--exclude '.env'
--exclude '.venv'
--exclude 'frontend/dist'
--exclude 'frontend-dist'
./ /opt/agentique/
- name: Sync frontend dist
run: rsync -a --delete frontend-dist/ /opt/agentique/frontend/dist/
# run, so the services live in /opt/agentique. deploy/rsync-filter lists
# what the box runs; --delete-excluded removes everything else there,
# except what the filter protects (.env, .venv, .git). A missing dist
# would delete the live site, so check for it first.
- name: Sync to /opt/agentique
run: |
test -f frontend/dist/index.html
rsync -a --delete --delete-excluded --filter='merge deploy/rsync-filter' ./ /opt/agentique/
# --package app: the workspace root is virtual, a bare sync would not
# install the backend (same flag the old Dockerfile used).
- name: Sync venv
Expand All @@ -76,7 +72,7 @@ jobs:
# --env-file is load-bearing: prestart.sh's seed guard checks the shell
# ENVIRONMENT, and a bare deploy shell doesn't have it.
- name: Run migrations + prestart
run: uv run --env-file ../.env bash scripts/prestart.sh
run: uv run --env-file ../.env bash app/entry/cli/prestart.sh
working-directory: /opt/agentique/backend
- name: Restart backend
run: sudo systemctl restart agentique-backend
7 changes: 5 additions & 2 deletions .github/workflows/test-backend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,15 @@ jobs:
POSTGRES_USER=postgres
POSTGRES_PASSWORD=ci-test-password
EOF
- name: Check import layers
run: uv run lint-imports
working-directory: backend
- run: docker compose up -d
- name: Migrate DB
run: uv run bash scripts/prestart.sh
run: uv run bash app/entry/cli/prestart.sh
working-directory: backend
- name: Run tests
run: uv run bash scripts/tests-start.sh "Coverage for ${{ github.sha }}"
run: uv run bash ../scripts/tests-start.sh "Coverage for ${{ github.sha }}"
working-directory: backend
- run: docker compose down -v
- name: Store coverage files
Expand Down
11 changes: 9 additions & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -53,14 +53,21 @@ repos:

- id: local-mypy
name: mypy check
entry: uv run mypy backend/app backend/scripts
entry: uv run mypy --config-file backend/pyproject.toml backend/app
require_serial: true
language: unsupported
pass_filenames: false

- id: local-ty
name: ty check
entry: uv run ty check backend/app backend/scripts
entry: uv run ty check --project backend backend/app
require_serial: true
language: unsupported
pass_filenames: false

- id: local-lint-imports
name: import-linter
entry: uv run lint-imports --config backend/pyproject.toml
require_serial: true
language: unsupported
pass_filenames: false
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
- Read and mutate freely. It's a local copy, not prod.

## MCP tools
- `.mcp.json` connects to the `agentique` MCP server (`backend/app/mcp/`), exposing `sql_query` (read-only prod Postgres), `web_fetch`, `web_search`.
- `.mcp.json` connects to the `agentique` MCP server (`backend/app/entry/mcp/`), exposing `sql_query` (read-only prod Postgres), `web_fetch`, `web_search`.
- With the write token it also exposes `list_candidates`, `get_content`, `similar`, `stories`, `check_link`, `vocabulary`, `approve`, `reject`, `reject_many` - the curation agent's tools. They publish to prod. Don't call them outside a curation session.
- The write token also reaches `week`, `related`, `draft_issue` - the newsletter agent's tools (`.claude/skills/newsletter/SKILL.md`). `draft_issue` creates a Resend draft addressed to every subscriber; sending is done by hand in Resend.
- And `story_candidates`, `save_story`, `close_story` - the stories agent's tools (`.claude/skills/stories/SKILL.md`). They write the right rail's stories in prod.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,61 @@
"""Factory for creating random Article rows in tests."""
"""Test factories: random users, publishers, tags and articles.

They write rows straight to the tables, so a test anywhere can use them without
importing a feature.
"""

from __future__ import annotations

import random
import string
from datetime import timedelta

from sqlmodel import Session
from sqlmodel import Session, select

from app.core.db.article import Article, ArticleKind
from app.core.db.publisher import Publisher, PublisherKind, slugify
from app.core.db.tag import ArticleTag, Tag
from tests.random_data import random_lower_string
from app.core.db.user import User
from app.core.security import create_access_token
from app.core.settings import settings


def random_lower_string() -> str:
"""Random enough that two tests never collide on a unique column."""
return "".join(random.choices(string.ascii_lowercase, k=32))


def random_email() -> str:
return f"{random_lower_string()}@{random_lower_string()}.com"


def create_user(db: Session, email: str) -> User:
user = User(email=email)
db.add(user)
db.commit()
db.refresh(user)
return user


def create_random_user(db: Session) -> User:
return create_user(db, random_email())


def user_authentication_headers(user: User) -> dict[str, str]:
token = create_access_token(user.id, expires_delta=timedelta(minutes=5))
return {"Authorization": f"Bearer {token}"}


def authentication_token_from_email(*, email: str, db: Session) -> dict[str, str]:
"""Headers for the user with this email, created first if it isn't there."""
user = db.exec(select(User).where(User.email == email)).first()
if not user:
user = create_user(db, email)
return user_authentication_headers(user)


def get_superuser_token_headers(db: Session) -> dict[str, str]:
return authentication_token_from_email(email=settings.FIRST_SUPERUSER, db=db)


def create_random_publisher(db: Session, **overrides: object) -> Publisher:
Expand Down
2 changes: 1 addition & 1 deletion backend/app/core/db/story.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ class Story(SQLModel, table=True):

class StoryArticle(SQLModel, table=True):
"""An article in a story. At most one open story per article, enforced in
`catalog.stories.save_story`: a closed story keeps its articles."""
`stories.service.save_story`: a closed story keeps its articles."""

__tablename__ = "story_article"
# The primary key leads with story_id; "which story holds this article" is
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,28 +7,11 @@
from sqlalchemy import text
from sqlmodel import SQLModel

from app.core import db
from app.core.db.engine import engine, get_session
from app.core.settings import settings
from app.entry.api.deps import SessionDep
from app.features.collect import service as collect
from app.main import app # noqa: F401 # every module that could declare a table


def test_every_table_lives_in_core_db():
"""The app is fully imported here, so a table declared anywhere else shows
up in the metadata and not in `app.core.db`."""
declared = set()
for name in db.__all__:
for cls in vars(getattr(db, name)).values():
if isinstance(cls, type) and issubclass(cls, SQLModel):
table = getattr(cls, "__table__", None)
if table is not None:
declared.add(table.name)
assert declared == set(SQLModel.metadata.tables)


def test_tables_match_the_migrations():
def test_tables_match_the_migrations() -> None:
"""`alembic check` in the test run: a table edit without a migration, or a
migration the tables do not declare, fails here."""
with engine.connect() as conn:
Expand All @@ -37,23 +20,18 @@ def test_tables_match_the_migrations():
assert diff == []


def test_engine_connects_with_the_settings_credentials():
def test_engine_connects_with_the_settings_credentials() -> None:
assert engine.url.database == settings.POSTGRES_DB
with engine.connect() as conn:
user = conn.execute(text("SELECT current_user")).scalar_one()
assert user == settings.POSTGRES_USER


def test_the_request_session_is_closed_after_the_request():
def test_the_request_session_is_closed_after_the_request() -> None:
sessions = get_session()
session = next(sessions)
assert session.bind is engine
session.execute(text("SELECT 1"))
session.connection().execute(text("SELECT 1"))
assert session.in_transaction()
sessions.close()
assert not session.in_transaction()


def test_routes_and_the_collect_run_share_the_engine():
assert collect.engine is engine
assert SessionDep.__metadata__[0].dependency is get_session
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
# Every table module, so a new one is guarded without a line here.
MODEL_MODULES = [getattr(db, name) for name in db.__all__]

VERSIONS = Path(__file__).parent.parent / "migrations" / "versions"
VERSIONS = Path(__file__).resolve().parents[3] / "migrations" / "versions"

# SQLModel names a Postgres enum type after the lowercased class name.
ENUM_MODELS = {
Expand All @@ -34,7 +34,7 @@
)


def test_migration_enum_labels_exist_in_python():
def test_migration_enum_labels_exist_in_python() -> None:
added = [
(m.group(1).lower(), m.group(2))
for path in VERSIONS.glob("*.py")
Expand Down
Loading
Loading