feat: wire Forgejo Actions CI/CD workflows (#22)
Some checks are pending
CI / API — lint + test (pull_request) Waiting to run
CI / Web — typecheck + test + build (pull_request) Waiting to run

- ci.yml: API lint (ruff F+I) + pytest, web vue-tsc + vitest + build
- mirror.yml: push to GitHub (CircuitForgeLLC) + Codeberg (CircuitForge) on main/tags
- release.yml: Docker build → Forgejo registry + release via API; GHCR deferred pending BSL policy (cf-agents#3)
- .cliff.toml: git-cliff changelog config for semver releases
- pyproject.toml: add [dev] extras (pytest, ruff), ruff config
- Fix 45 ruff violations across codebase (import sorting, unused vars, unused imports)
This commit is contained in:
pyr0ball 2026-04-06 00:00:28 -07:00
parent 303b4bfb6f
commit eb05be0612
35 changed files with 318 additions and 61 deletions

41
.cliff.toml Normal file
View file

@ -0,0 +1,41 @@
[changelog]
header = """
# Changelog\n
"""
body = """
{% if version %}\
## [{{ version | trim_start_matches(pat="v") }}] — {{ timestamp | date(format="%Y-%m-%d") }}
{% else %}\
## [Unreleased]
{% endif %}\
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | striptags | trim | upper_first }}
{% for commit in commits %}
- {% if commit.scope %}**{{ commit.scope }}:** {% endif %}{{ commit.message | upper_first }}\
{% endfor %}
{% endfor %}\n
"""
trim = true
footer = ""
[git]
conventional_commits = true
filter_unconventional = true
split_commits = false
commit_preprocessors = [
{ pattern = '\(#([0-9]+)\)', replace = "([#${1}](https://git.opensourcesolarpunk.com/Circuit-Forge/snipe/issues/${1}))" },
]
commit_parsers = [
{ message = "^feat", group = "Features" },
{ message = "^fix", group = "Bug Fixes" },
{ message = "^perf", group = "Performance" },
{ message = "^refactor", group = "Refactoring" },
{ message = "^docs", group = "Documentation" },
{ message = "^test", group = "Testing" },
{ message = "^revert", group = "Reverts" },
{ message = "^chore", skip = true },
]
filter_commits = true
tag_pattern = "v[0-9]*"
topo_order = false
sort_commits = "oldest"

62
.forgejo/workflows/ci.yml Normal file
View file

@ -0,0 +1,62 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
api:
name: API — lint + test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install circuitforge-core
env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
run: |
git clone https://x-token:${FORGEJO_TOKEN}@git.opensourcesolarpunk.com/Circuit-Forge/circuitforge-core.git /tmp/circuitforge-core
pip install -e /tmp/circuitforge-core
- name: Install snipe + dev deps
run: pip install -e ".[dev]"
- name: Lint (ruff)
run: ruff check app/ api/ tests/
- name: Run tests
run: pytest tests/ -v
web:
name: Web — typecheck + test + build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
cache-dependency-path: web/package-lock.json
- name: Install deps
working-directory: web
run: npm ci
- name: Type check
working-directory: web
run: npx vue-tsc --noEmit
- name: Unit tests
working-directory: web
run: npm test
- name: Build
working-directory: web
run: npm run build

View file

@ -0,0 +1,37 @@
name: Mirror
on:
push:
branches: [main]
tags: ["v*"]
jobs:
mirror-github:
name: Mirror to GitHub
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Push to GitHub
env:
GITHUB_MIRROR_TOKEN: ${{ secrets.GITHUB_MIRROR_TOKEN }}
run: |
git remote add github https://x-access-token:${GITHUB_MIRROR_TOKEN}@github.com/CircuitForgeLLC/snipe.git
git push github --mirror
mirror-codeberg:
name: Mirror to Codeberg
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Push to Codeberg
env:
CODEBERG_MIRROR_TOKEN: ${{ secrets.CODEBERG_MIRROR_TOKEN }}
run: |
git remote add codeberg https://CircuitForge:${CODEBERG_MIRROR_TOKEN}@codeberg.org/CircuitForge/snipe.git
git push codeberg --mirror

View file

@ -0,0 +1,67 @@
name: Release
on:
push:
tags: ["v*"]
jobs:
release:
name: Build images + create release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Generate changelog
uses: orhun/git-cliff-action@v3
id: cliff
with:
config: .cliff.toml
args: --current --strip header
env:
OUTPUT: CHANGES.md
- uses: docker/setup-buildx-action@v3
# BSL product: push to Forgejo registry only.
# GHCR push is deferred pending license-gate-at-startup implementation (Circuit-Forge/snipe#3).
- uses: docker/login-action@v3
with:
registry: git.opensourcesolarpunk.com
username: ${{ gitea.actor }}
password: ${{ secrets.FORGEJO_RELEASE_TOKEN }}
- name: Build and push API image
uses: docker/build-push-action@v5
with:
context: .
file: docker/api/Dockerfile
push: true
platforms: linux/amd64,linux/arm64
tags: |
git.opensourcesolarpunk.com/circuit-forge/snipe-api:${{ gitea.ref_name }}
git.opensourcesolarpunk.com/circuit-forge/snipe-api:latest
- name: Build and push web image
uses: docker/build-push-action@v5
with:
context: .
file: docker/web/Dockerfile
push: true
platforms: linux/amd64,linux/arm64
tags: |
git.opensourcesolarpunk.com/circuit-forge/snipe-web:${{ gitea.ref_name }}
git.opensourcesolarpunk.com/circuit-forge/snipe-web:latest
- name: Create Forgejo release
env:
FORGEJO_RELEASE_TOKEN: ${{ secrets.FORGEJO_RELEASE_TOKEN }}
TAG: ${{ gitea.ref_name }}
run: |
body=$(cat CHANGES.md | python3 -c "import sys, json; print(json.dumps(sys.stdin.read()))")
curl -sf -X POST \
"https://git.opensourcesolarpunk.com/api/v1/repos/Circuit-Forge/snipe/releases" \
-H "Authorization: token ${FORGEJO_RELEASE_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"${TAG}\", \"name\": \"${TAG}\", \"body\": ${body}}"

View file

@ -16,8 +16,6 @@ FastAPI usage:
"""
from __future__ import annotations
import hashlib
import hmac
import logging
import os
import re
@ -77,7 +75,6 @@ def compute_features(tier: str) -> SessionFeatures:
"""Compute feature flags from tier. Evaluated server-side; sent to frontend."""
local = tier == "local"
paid_plus = local or tier in ("paid", "premium", "ultra")
premium_plus = local or tier in ("premium", "ultra")
return SessionFeatures(
saved_searches=True, # all tiers get saved searches

View file

@ -26,11 +26,11 @@ from pathlib import Path
from typing import Optional
import requests
from fastapi import APIRouter, Header, HTTPException, Request
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ec import ECDSA
from cryptography.hazmat.primitives.hashes import SHA1
from cryptography.hazmat.primitives.serialization import load_pem_public_key
from fastapi import APIRouter, Header, HTTPException, Request
from app.db.store import Store

View file

@ -1,8 +1,11 @@
"""Snipe FastAPI — search endpoint wired to ScrapedEbayAdapter + TrustScorer."""
from __future__ import annotations
import asyncio
import csv
import dataclasses
import hashlib
import io
import json as _json
import logging
import os
@ -12,28 +15,25 @@ from concurrent.futures import ThreadPoolExecutor
from contextlib import asynccontextmanager
from pathlib import Path
import asyncio
import csv
import io
from fastapi import Depends, FastAPI, HTTPException, Request, UploadFile, File
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from fastapi.middleware.cors import CORSMiddleware
from circuitforge_core.config import load_env
from circuitforge_core.affiliates import wrap_url as _wrap_affiliate_url
from circuitforge_core.api import make_feedback_router as _make_feedback_router
from circuitforge_core.config import load_env
from fastapi import Depends, FastAPI, File, HTTPException, Request, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from api.cloud_session import CloudUser, compute_features, get_session
from api.ebay_webhook import router as ebay_webhook_router
from app.db.models import SavedSearch as SavedSearchModel
from app.db.models import ScammerEntry
from app.db.store import Store
from app.db.models import SavedSearch as SavedSearchModel, ScammerEntry
from app.platforms import SearchFilters
from app.platforms.ebay.scraper import ScrapedEbayAdapter
from app.platforms.ebay.adapter import EbayAdapter
from app.platforms.ebay.auth import EbayTokenManager
from app.platforms.ebay.query_builder import expand_queries, parse_groups
from app.platforms.ebay.scraper import ScrapedEbayAdapter
from app.trust import TrustScorer
from api.cloud_session import CloudUser, compute_features, get_session
from api.ebay_webhook import router as ebay_webhook_router
load_env(Path(".env"))
log = logging.getLogger(__name__)
@ -50,8 +50,8 @@ async def _lifespan(app: FastAPI):
# Start vision/LLM background task scheduler.
# background_tasks queue lives in shared_db (cloud) or local_db (local)
# so the scheduler has a single stable DB path across all cloud users.
from api.cloud_session import _LOCAL_SNIPE_DB, CLOUD_MODE, _shared_db_path
from app.tasks.scheduler import get_scheduler, reset_scheduler
from api.cloud_session import CLOUD_MODE, _LOCAL_SNIPE_DB, _shared_db_path
sched_db = _shared_db_path() if CLOUD_MODE else _LOCAL_SNIPE_DB
get_scheduler(sched_db)
log.info("Snipe task scheduler started (db=%s)", sched_db)
@ -245,9 +245,10 @@ def _enqueue_vision_tasks(
trust_scores table in cloud mode.
"""
import json as _json
from api.cloud_session import _LOCAL_SNIPE_DB, CLOUD_MODE, _shared_db_path
from app.tasks.runner import insert_task
from app.tasks.scheduler import get_scheduler
from api.cloud_session import CLOUD_MODE, _shared_db_path, _LOCAL_SNIPE_DB
sched_db = _shared_db_path() if CLOUD_MODE else _LOCAL_SNIPE_DB
sched = get_scheduler(sched_db)

View file

@ -1,5 +1,6 @@
"""Dataclasses for all Snipe domain objects."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Optional

View file

@ -1,5 +1,6 @@
"""Thin SQLite read/write layer for all Snipe models."""
from __future__ import annotations
import json
from datetime import datetime, timezone
from pathlib import Path
@ -7,7 +8,7 @@ from typing import Optional
from circuitforge_core.db import get_connection, run_migrations
from .models import Listing, Seller, TrustScore, MarketComp, SavedSearch, ScammerEntry
from .models import Listing, MarketComp, SavedSearch, ScammerEntry, Seller, TrustScore
MIGRATIONS_DIR = Path(__file__).parent / "migrations"

View file

@ -1,8 +1,10 @@
"""PlatformAdapter abstract base and shared types."""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Optional
from app.db.models import Listing, Seller

View file

@ -1,10 +1,12 @@
"""eBay Browse API adapter."""
from __future__ import annotations
import hashlib
import logging
from dataclasses import replace
from datetime import datetime, timedelta, timezone
from typing import Optional
import requests
log = logging.getLogger(__name__)
@ -18,7 +20,7 @@ _SHOPPING_API_MAX_PER_SEARCH = 5 # sellers enriched per search call
_SHOPPING_API_INTER_REQUEST_DELAY = 0.5 # seconds between successive calls
_SELLER_ENRICH_TTL_HOURS = 24 # skip re-enrichment within this window
from app.db.models import Listing, Seller, MarketComp
from app.db.models import Listing, MarketComp, Seller
from app.db.store import Store
from app.platforms import PlatformAdapter, SearchFilters
from app.platforms.ebay.auth import EbayTokenManager

View file

@ -1,8 +1,10 @@
"""eBay OAuth2 client credentials token manager."""
from __future__ import annotations
import base64
import time
from typing import Optional
import requests
EBAY_OAUTH_URLS = {

View file

@ -1,8 +1,10 @@
"""Convert raw eBay API responses into Snipe domain objects."""
from __future__ import annotations
import json
from datetime import datetime, timezone
from typing import Optional
from app.db.models import Listing, Seller

View file

@ -16,7 +16,7 @@ import json
import logging
import re
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timedelta, timezone
from typing import Optional
@ -302,7 +302,8 @@ class ScrapedEbayAdapter(PlatformAdapter):
time.sleep(self._delay)
import subprocess, os
import os
import subprocess
display_num = next(_display_counter)
display = f":{display_num}"
xvfb = subprocess.Popen(
@ -313,8 +314,10 @@ class ScrapedEbayAdapter(PlatformAdapter):
env["DISPLAY"] = display
try:
from playwright.sync_api import sync_playwright # noqa: PLC0415 — lazy: only needed in Docker
from playwright_stealth import Stealth # noqa: PLC0415
from playwright.sync_api import (
sync_playwright, # noqa: PLC0415 — lazy: only needed in Docker
)
from playwright_stealth import Stealth # noqa: PLC0415
with sync_playwright() as pw:
browser = pw.chromium.launch(

View file

@ -19,7 +19,6 @@ import logging
from pathlib import Path
import requests
from circuitforge_core.db import get_connection
from circuitforge_core.llm import LLMRouter

View file

@ -5,9 +5,10 @@ from __future__ import annotations
from pathlib import Path
from circuitforge_core.tasks.scheduler import (
TaskScheduler,
TaskScheduler, # re-export for tests
)
from circuitforge_core.tasks.scheduler import (
get_scheduler as _base_get_scheduler,
reset_scheduler, # re-export for tests
)
from app.tasks.runner import LLM_TASK_TYPES, VRAM_BUDGETS, run_task

View file

@ -14,7 +14,8 @@ Intentionally ungated (free for all):
- saved_searches retention feature; friction cost outweighs gate value
"""
from __future__ import annotations
from circuitforge_core.tiers import can_use as _core_can_use, TIERS # noqa: F401
from circuitforge_core.tiers import can_use as _core_can_use # noqa: F401
# Feature key → minimum tier required.
FEATURES: dict[str, str] = {

View file

@ -1,11 +1,14 @@
from .metadata import MetadataScorer
from .photo import PhotoScorer
from .aggregator import Aggregator
from app.db.models import Seller, Listing, TrustScore
from app.db.store import Store
import hashlib
import math
from app.db.models import Listing, TrustScore
from app.db.models import Seller as Seller
from app.db.store import Store
from .aggregator import Aggregator
from .metadata import MetadataScorer
from .photo import PhotoScorer
class TrustScorer:
"""Orchestrates metadata + photo scoring for a batch of listings."""

View file

@ -1,8 +1,10 @@
"""Composite score and red flag extraction."""
from __future__ import annotations
import json
from datetime import datetime, timezone
from datetime import datetime
from typing import Optional
from app.db.models import Seller, TrustScore
HARD_FILTER_AGE_DAYS = 7

View file

@ -1,7 +1,9 @@
"""Five metadata trust signals, each scored 020."""
from __future__ import annotations
import json
from typing import Optional
from app.db.models import Seller
ELECTRONICS_CATEGORIES = {"ELECTRONICS", "COMPUTERS_TABLETS", "VIDEO_GAMES", "CELL_PHONES"}

View file

@ -1,7 +1,9 @@
"""Perceptual hash deduplication within a result set (free tier, v0.1)."""
from __future__ import annotations
from typing import Optional
import io
from typing import Optional
import requests
try:

View file

@ -1,19 +1,24 @@
"""Main search + results page."""
from __future__ import annotations
import logging
import os
from pathlib import Path
import streamlit as st
from circuitforge_core.config import load_env
from app.db.store import Store
from app.platforms import PlatformAdapter, SearchFilters
from app.trust import TrustScorer
from app.ui.components.filters import build_filter_options, render_filter_sidebar, FilterState
from app.ui.components.listing_row import render_listing_row
from app.ui.components.easter_eggs import (
inject_steal_css, check_snipe_mode, render_snipe_mode_banner,
auction_hours_remaining,
check_snipe_mode,
inject_steal_css,
render_snipe_mode_banner,
)
from app.ui.components.filters import FilterState, build_filter_options, render_filter_sidebar
from app.ui.components.listing_row import render_listing_row
log = logging.getLogger(__name__)

View file

@ -22,7 +22,6 @@ import streamlit as st
from app.db.models import Listing, TrustScore
# ---------------------------------------------------------------------------
# 1. Konami → Snipe Mode
# ---------------------------------------------------------------------------

View file

@ -1,9 +1,12 @@
"""Build dynamic filter options from a result set and render the Streamlit sidebar."""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import Optional
import streamlit as st
from app.db.models import Listing, TrustScore

View file

@ -1,13 +1,17 @@
"""Render a single listing row with trust score, badges, and error states."""
from __future__ import annotations
import json
from typing import Optional
import streamlit as st
from app.db.models import Listing, TrustScore, Seller
from app.db.models import Listing, Seller, TrustScore
from app.ui.components.easter_eggs import (
is_steal, render_steal_banner, render_auction_notice, auction_hours_remaining,
auction_hours_remaining,
is_steal,
render_auction_notice,
render_steal_banner,
)

View file

@ -1,6 +1,8 @@
"""First-run wizard: collect eBay credentials and write .env."""
from __future__ import annotations
from pathlib import Path
import streamlit as st
from circuitforge_core.wizard import BaseWizard

View file

@ -25,9 +25,23 @@ dependencies = [
"PyJWT>=2.8",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"ruff>=0.4",
]
[tool.setuptools.packages.find]
where = ["."]
include = ["app*", "api*"]
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.ruff.lint]
# E (pycodestyle) deferred — tighten incrementally once existing violations are resolved
select = ["F", "I"]

View file

@ -1,8 +1,9 @@
import pytest
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
from app.db.models import Listing, MarketComp, Seller
from app.db.store import Store
from app.db.models import Listing, Seller, TrustScore, MarketComp
@pytest.fixture

View file

@ -1,7 +1,9 @@
import time
import requests
from unittest.mock import patch, MagicMock
from unittest.mock import MagicMock, patch
import pytest
import requests
from app.platforms.ebay.auth import EbayTokenManager

View file

@ -1,4 +1,5 @@
import pytest
from app.platforms.ebay.normaliser import normalise_listing, normalise_seller

View file

@ -3,16 +3,18 @@
Uses a minimal HTML fixture mirroring eBay's current s-card markup.
No HTTP requests are made all tests operate on the pure parsing functions.
"""
import pytest
from datetime import timedelta
import pytest
from bs4 import BeautifulSoup
from app.platforms.ebay.scraper import (
scrape_listings,
scrape_sellers,
_extract_seller_from_card,
_parse_price,
_parse_time_left,
_extract_seller_from_card,
scrape_listings,
scrape_sellers,
)
from bs4 import BeautifulSoup
# ---------------------------------------------------------------------------
# Minimal eBay search results HTML fixture (li.s-card schema)

View file

@ -4,12 +4,10 @@ from __future__ import annotations
from collections.abc import Callable
from unittest.mock import MagicMock, patch
from circuitforge_core.api.feedback import make_feedback_router
from fastapi import FastAPI
from fastapi.testclient import TestClient
from circuitforge_core.api.feedback import make_feedback_router
# ── Test app factory ──────────────────────────────────────────────────────────
def _make_client(demo_mode_fn: Callable[[], bool] | None = None) -> TestClient:

View file

@ -4,7 +4,7 @@ from __future__ import annotations
import json
import sqlite3
from pathlib import Path
from unittest.mock import MagicMock, patch
from unittest.mock import patch
import pytest

View file

@ -1,4 +1,4 @@
from app.tiers import can_use, FEATURES, LOCAL_VISION_UNLOCKABLE
from app.tiers import can_use
def test_metadata_scoring_is_free():

View file

@ -4,10 +4,8 @@ from __future__ import annotations
import json
from datetime import datetime, timedelta, timezone
import pytest
from app.db.models import Listing, TrustScore
from app.ui.components.easter_eggs import is_steal, auction_hours_remaining
from app.ui.components.easter_eggs import auction_hours_remaining, is_steal
def _listing(**kwargs) -> Listing: