A lightweight GitHub-flavoured issue tracker for Val Town's scoped SQLite
(std/sqlite/main.ts) — issues, labels, assignees, blocking edges, comments,
and a bearer-authenticated JSON API. A drop-in Hono app: imports execute against
the host val's database, and all tables use the issue_tracker_* prefix.
It is an integrations
contract integration, so issueTracker() returns a callable Integration:
the same value works both as a standalone HTTP val and mounted under a router
alongside other integrations.
main.ts mounts the tracker for this val's scoped database — the tracker
is the val's HTTP export and runs its own OAuth. Visit the val's endpoint and
log in.
Scoped SQLite is bound to whichever val's runtime is executing the code, not to where the source lives. So importing the tracker into another val makes it read and write that val's database automatically — no tokens, no registry:
import { issueTracker } from "https://esm.town/v/nbbaier/val-issue-tracker/mod.ts";
export default issueTracker({
maintainers: ["afriend"],
apiKey: Deno.env.get("ISSUE_TRACKER_API_KEY"),
});
The running val's owner is always a maintainer. Public reads, Val Town OAuth,
and the visible /source redirect remain built in.
Register it with a createRouter and it mounts under /issues/, sharing one
OAuth gate with every other integration:
import { createRouter } from "https://esm.town/v/nbbaier/integrations/mod.ts";
import { issueTracker } from "https://esm.town/v/nbbaier/val-issue-tracker/mod.ts";
export default createRouter({
integrations: [issueTracker({})],
});
The router owns authentication; the tracker's role model remains its own authorization layer, resolved from the router-provided user. A null user — the ungated router's anonymous visitor — becomes a read-only viewer rather than being redirected to a login that doesn't exist. The bearer-token JSON API keeps its own gate, which stacks behind the router's when the router has one.
| Option | Meaning |
|---|---|
maintainers | Additional Val Town handles with maintainer access |
owner | Deprecated alias for maintainers |
defaultRole | contributor (default) or viewer for other logged-in users |
auth | Val Town OAuth when true (default); fully open tracker when false |
title | Header and browser title |
apiKey | Bearer token for JSON writes; defaults to ISSUE_TRACKER_API_KEY |
With auth enabled, viewers can read; contributors can create/comment and manage issues they created; maintainers can manage all issues, labels, and deletions.
The OAuth gate — this val's own when standalone, the router's when routed —
needs an OAUTH_STATE_ENCRYPTION_KEY env var (any random 32-byte hex string) to
encrypt session cookies. Generate one with openssl rand -hex 32. The
integration declares it as requiredEnv, so a router's directory page badges it
when unset.
- Issues have
title, markdown-textbody,open/closedstatus, and an auto-managedstate_reason. - Labels are curated name/colour entities and are applied through a join table.
- Assignees are a set of Val Town handles.
- Blocking edges form a strict DAG;
blockedis derived from open blockers. - Comments are editable entities.
- An append-only events table is the sole provenance/history source. Live entities retain current state and timestamps only; relationship rows are bare tuples.
- Fresh installs seed
bug,enhancement,documentation,question, andduplicateexactly once.
Schema initialization is idempotent and runs on requests. It also migrates the
old tracker schema: description → body, priority removal,
in_progress → open, JSON labels and single assignee lifted into joins, and
synthetic creation events.
Reads under /api are public. Writes require Authorization: Bearer <apiKey>.
The main operations are:
| Method and path | Purpose |
|---|---|
GET/POST /api/issues | List/filter or create issues |
GET/PATCH/DELETE /api/issues/:id | Read, edit, or hard-delete an issue |
POST /api/issues/:id/close | Close with completed, not_planned, or duplicate |
POST /api/issues/:id/reopen | Reopen |
POST /api/issues/:id/labels | Apply/remove label IDs |
POST /api/issues/:id/assignees | Assign/unassign handles |
POST /api/issues/:id/blocks | Add/remove issues this issue blocks |
POST /api/issues/:id/blocked-by | Add/remove blockers |
POST /api/issues/:id/comments | Comment |
PATCH/DELETE /api/comments/:id | Edit/delete a comment |
GET/POST /api/labels | List/create labels |
PATCH/DELETE /api/labels/:id | Edit/delete a label |
Reads are public; writes are 401 without a valid bearer token and 403 if no
token is configured at all. Requests are validated strictly: malformed IDs,
filters, or collection fields (label_ids, add/remove, assignees) return
400; a missing issue, comment, or label returns 404; a blocking cycle or
duplicate label name returns 409. Errors are always {"error": "..."}. See
docs/issue-tracker-valtown.md for the exact
request and response shapes, the event-data grammar, and the full status-code
table.
Deno's built-in formatter is canonical, configured by deno.json. Generated
local tool state (.vt/, .claude/, .impeccable/) is excluded from
formatting; lint is scoped to the repository's top-level TypeScript files.
deno task fmt # deno fmt (write canonical formatting) deno task lint # deno lint deno task typecheck # deno check across source and test files deno task test # deno test deno task check # non-mutating format check, then lint/typecheck/tests
The focused test suite uses Deno's SQLite-compatible in-memory driver; it never writes a database file.
db.ts— schema, migration, domain operations, and default Val Town store.api.ts— bearer-authenticated Hono JSON API.app.tsx— OAuth/permissions and HTML form routes.components.tsx,ui.tsx,layout.tsx— server-rendered Hono TSX.mod.ts— reusable public entry point.main.ts— this val's own consumer.