Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
48 changes: 48 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# wads CI — calls the reusable workflow hosted in i2mint/wads.
#
# All configuration comes from this repo's pyproject.toml [tool.wads.ci.*].
# To customize the workflow itself (rare), replace this file with the
# full inline template `wads/data/github_ci_uv.yml` from i2mint/wads.
#
# Pinning: `@master` floats with wads. If you need version stability for
# a release-sensitive repo, change `@master` to a wads tag (e.g. `@v0.1.81`).
# CI failure does not block a published release — it blocks the publish
# step itself — so floating master is generally safe.
#
# Permissions: GitHub validates that the caller grants AT LEAST the
# permissions any job in the called workflow requests — at workflow-parse
# time, not at run-time, even if the job would be skipped via `if:`.
# The reusable workflow needs:
# contents: write for the publish job's version-bump push-back
# and for the github-pages job's gh-pages branch push
# pages: write for the github-pages job's REST API Pages config
# Both default to `write` on org-account GITHUB_TOKEN and need to be
# granted explicitly on personal-account callers (where the default is
# read-only). No `id-token: write` needed — the publish-github-pages
# action uses peaceiris/actions-gh-pages (branch-based) + REST API,
# not the OIDC `actions/deploy-pages` flow.
name: Continuous Integration
on: [push, pull_request]
jobs:
ci:
uses: i2mint/wads/.github/workflows/uv-ci.yml@master
permissions:
contents: write
pages: write
# Explicit pass-through (not `secrets: inherit`) because `inherit` does
# not reliably propagate caller-repo secrets to a reusable workflow
# owned by a different account (verified empirically: caller in a
# personal account, called in i2mint org → `${{ secrets.PYPI_PASSWORD }}`
# resolved to empty inside the reusable workflow despite the secret
# being set on the caller repo). Listing each secret here makes the
# propagation unambiguous regardless of caller-vs-called ownership.
# Missing secrets on the caller resolve to empty strings, harmless for
# the optional ones; PYPI_PASSWORD must be set for the publish job.
secrets:
PYPI_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
HUGGINGFACE_TOKEN: ${{ secrets.HUGGINGFACE_TOKEN }}
KAGGLE_USERNAME: ${{ secrets.KAGGLE_USERNAME }}
KAGGLE_KEY: ${{ secrets.KAGGLE_KEY }}
87 changes: 75 additions & 12 deletions heed/__init__.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,83 @@
"""heed — embeddable, no-install, framework-agnostic end-user feedback.

``heed`` lets any visitor of a deployed web app report a bug or request a feature,
with useful context gathered automatically, routed to a pluggable backend (GitHub
Issues first). It works standalone and integrates with ``enlace`` as an optional
add-on, without depending on it.
``heed`` lets any visitor of a deployed web app report a bug or request a feature, with
useful context gathered automatically, routed to a pluggable backend (GitHub Issues
first). It works standalone and integrates with ``enlace`` as an optional add-on,
without depending on it.

This package is in its **design phase**. The competitive landscape, the design
rationale, and the roadmap live in ``misc/docs/`` and in the project's GitHub
issues and discussions. The public API will grow from here; for now this module
exposes only the version.
Phase 1 ships the backend core (the widget is tracked in issue #6). The competitive
landscape and design rationale live in ``misc/docs/``.

See:
- ``misc/docs/research-report.md`` — the deep-research landscape.
- ``misc/docs/design.md`` — architecture, the data model, and the sink interface.
Public API (Phase 1):
Models Report, ReportSubmission, Identity, Environment, Category, Status,
Attachment, LogEntry, NetEntry
Sinks Sink, BaseSink, SinkResult, StoreSink, GitHubIssuesSink,
github_sink_from_token
Backend process_submission, make_router, make_app, HeedConfig
Storage make_report_store, make_attachment_store

Example:
>>> from heed import StoreSink, ReportSubmission, process_submission
>>> store = {}
>>> report, result = process_submission(
... ReportSubmission(title="Login button does nothing"), StoreSink(store)
... )
>>> result.ok and report.id in store
True
"""

from heed.base import (
Attachment,
Category,
Environment,
Identity,
LogEntry,
NetEntry,
Report,
ReportSubmission,
Status,
new_report_id,
)
from heed.config import HeedConfig
from heed.ingest import make_app, make_router, process_submission
from heed.sinks import (
BaseSink,
GitHubIssuesSink,
Sink,
SinkResult,
StoreSink,
github_sink_from_token,
)
from heed.storage import make_attachment_store, make_report_store

__version__ = "0.0.1"

__all__ = ["__version__"]
__all__ = [
"__version__",
# models
"Report",
"ReportSubmission",
"Identity",
"Environment",
"Category",
"Status",
"Attachment",
"LogEntry",
"NetEntry",
"new_report_id",
# sinks
"Sink",
"BaseSink",
"SinkResult",
"StoreSink",
"GitHubIssuesSink",
"github_sink_from_token",
# backend
"HeedConfig",
"process_submission",
"make_router",
"make_app",
# storage
"make_report_store",
"make_attachment_store",
]
133 changes: 133 additions & 0 deletions heed/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
"""Domain model for heed — the single source of truth shared by widget and backend.

Everything that crosses the widget↔backend boundary, or moves between the ingest
layer and a sink, is one of the Pydantic models defined here. The widget POSTs a
:class:`ReportSubmission` (what an untrusted client may assert); the backend enriches
it into a :class:`Report` (server-assigned id, timestamp, resolved identity, validated
origin, status) before handing it to a sink.
"""

from __future__ import annotations

from datetime import datetime, timezone
from enum import Enum
from uuid import uuid4

from pydantic import BaseModel, Field


def new_report_id() -> str:
"""Return a fresh opaque report id (uuid4 hex)."""
return uuid4().hex


def utcnow() -> datetime:
"""Return the current UTC time (an injectable clock seam for tests)."""
return datetime.now(timezone.utc)


class Category(str, Enum):
"""What kind of feedback a report is."""

bug = "bug"
feature = "feature"
question = "question"
other = "other"


class Status(str, Enum):
"""Lifecycle of a report (flattened; see misc/docs/design.md for the full map)."""

received = "received"
triaged = "triaged"
planned = "planned"
started = "started"
completed = "completed"
declined = "declined"
duplicate = "duplicate"


class Environment(BaseModel):
"""Client environment captured by default (no PII beyond the user agent)."""

user_agent: str | None = None
browser: str | None = None
os: str | None = None
viewport: str | None = Field(default=None, description='e.g. "1280x720"')
locale: str | None = None
device_pixel_ratio: float | None = None
extra: dict[str, str] = Field(default_factory=dict)


class LogEntry(BaseModel):
"""One captured console entry (opt-in capture only)."""

level: str
message: str
at: datetime | None = None
source: str | None = None


class NetEntry(BaseModel):
"""One captured network entry (opt-in; metadata only, never bodies)."""

method: str
url: str
status: int | None = None
duration_ms: float | None = None
ok: bool | None = None


class Attachment(BaseModel):
"""A stored binary artifact (e.g. a screenshot), referenced by store key."""

kind: str = "screenshot"
media_type: str = "image/png"
ref: str
size: int | None = None


class Identity(BaseModel):
"""Who submitted the report — anonymous by default.

``anon_id`` is an opaque random id (the default). ``user`` is set only when an
authenticated session is present (e.g. via enlace_auth). The client IP is NEVER
stored here — it is used transiently for rate limiting only.
"""

anon_id: str
user: str | None = None


class ReportSubmission(BaseModel):
"""The (untrusted) payload a widget POSTs; server fields are assigned later."""

category: Category = Category.bug
title: str = Field(min_length=1, max_length=300)
body: str = Field(default="", max_length=20_000)
page_url: str = Field(default="", max_length=2_000)
env: Environment = Field(default_factory=Environment)
console: list[LogEntry] | None = None
network: list[NetEntry] | None = None
# Opaque id the widget may carry across submissions; server still owns identity.
anon_id: str | None = None


class Report(BaseModel):
"""A server-enriched report — the unit a sink receives."""

id: str = Field(default_factory=new_report_id)
created_at: datetime = Field(default_factory=utcnow)
category: Category = Category.bug
title: str
body: str = ""
page_url: str = ""
env: Environment = Field(default_factory=Environment)
attachments: list[Attachment] = Field(default_factory=list)
console: list[LogEntry] | None = None
network: list[NetEntry] | None = None
identity: Identity
origin: str = ""
status: Status = Status.received
labels: list[str] = Field(default_factory=list)
extra: dict[str, str] = Field(default_factory=dict)
17 changes: 17 additions & 0 deletions heed/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"""Configuration for the heed backend (smart defaults; keyword-only)."""

from __future__ import annotations

from dataclasses import dataclass


@dataclass(kw_only=True)
class HeedConfig:
"""Knobs for the ingest endpoint. All optional with sensible defaults."""

allowed_origins: list[str] | None = None # None = allow any (dev)
max_body_bytes: int = 2_000_000 # 2 MB total
max_screenshot_bytes: int = 5_000_000 # 5 MB
rate_limit_per_minute: int = 30
accept_console: bool = False # opt-in heavy capture (privacy by default)
accept_network: bool = False
Loading
Loading