diff --git a/.env.example b/.env.example
index 5896ecc..15caec6 100644
--- a/.env.example
+++ b/.env.example
@@ -2,34 +2,55 @@
# Everything here is optional; defaults target a local Ollama install.
# Backend: openai (default) | diffusion-cnv | diffusion-oneshot
-PROMPTGEN_BACKEND=openai
+INCIPIT_BACKEND=openai
# --- OpenAI-compatible endpoint (the default backend) ---
# Ollama: http://localhost:11434/v1
# LM Studio: http://localhost:1234/v1
# llama-server: http://localhost:8080/v1
# OpenAI: https://api.openai.com/v1
-PROMPTGEN_OPENAI_BASE_URL=http://localhost:11434/v1
-PROMPTGEN_OPENAI_MODEL=
-PROMPTGEN_OPENAI_API_KEY=
+INCIPIT_OPENAI_BASE_URL=http://localhost:11434/v1
+INCIPIT_OPENAI_MODEL=
+INCIPIT_OPENAI_API_KEY=
+# Shared Doppler/Open WebUI aliases are used only when the corresponding
+# INCIPIT_OPENAI_* value is absent. A WebUI origin is normalized to /api.
+# WEBUI_API_URL=https://webui.example.com
+# WEBUI_MODEL=
+# WEBUI_API_KEY=
# Send chat_template_kwargs.enable_thinking=false (Qwen/llama.cpp reasoning
# models only; OpenAI proper rejects it). Leave unset/false for portability.
-PROMPTGEN_DISABLE_THINKING=
+INCIPIT_DISABLE_THINKING=
# Where the in-UI settings are persisted (overrides the above once saved).
-# PROMPTGEN_SETTINGS_FILE=.promptgen.json
+# INCIPIT_SETTINGS_FILE=.promptgen.json
# Additional hosts allowed in the runtime settings base URL. Defaults allow
-# localhost, api.openai.com, and the host from PROMPTGEN_OPENAI_BASE_URL.
-# PROMPTGEN_ALLOWED_BASE_URL_HOSTS=llm.internal.example.com
+# localhost, api.openai.com, and the host from INCIPIT_OPENAI_BASE_URL.
+# INCIPIT_ALLOWED_BASE_URL_HOSTS=llm.internal.example.com
# Timeouts (seconds)
-# PROMPTGEN_GEN_TIMEOUT=300
-# PROMPTGEN_SESSION_TTL=86400
+# INCIPIT_GEN_TIMEOUT=300
+# INCIPIT_SESSION_TTL=86400
+
+# --- Optional: Login with GitHub (private-repo grounding) ---
+# Per-user OAuth grant so the wizard can read your private repos. The access
+# token is stored server-side only; the browser cookie carries just a signed,
+# opaque session id. Leave the client id/secret blank to hide the login button.
+# The CLIENT_ID below is the public, registered OAuth-app id (not a secret).
+# INCIPIT_GITHUB_OAUTH_CLIENT_ID=Ov23liQTAZncU8NnfMS4
+# INCIPIT_GITHUB_OAUTH_CLIENT_SECRET= # SECRET — set via env/Doppler, never commit
+# INCIPIT_GITHUB_OAUTH_REDIRECT_URL=https://incipit.nexus.inmotionhosting.com/auth/github/callback
+# INCIPIT_GITHUB_OAUTH_SCOPES=repo
+# Secret used to sign the session-id cookie. Set it so auth cookies survive a
+# restart; if unset an ephemeral per-process secret is generated.
+# INCIPIT_SESSION_COOKIE_SECRET=
+# Set the Secure flag on auth cookies (HTTPS only). Default true; set false for
+# local plain-HTTP development.
+# INCIPIT_COOKIE_SECURE=true
# --- Advanced: diffusion backend (homelab / GPU only — see README) ---
-# PROMPTGEN_CLI_BIN=/usr/local/bin/llama-diffusion-cli
-# PROMPTGEN_MODEL=/models/diffusiongemma-26B-A4B-it-GGUF/diffusiongemma-26B-A4B-it-Q4_K_M.gguf
-# PROMPTGEN_NGL=99
-# PROMPTGEN_N_CPU_MOE=18
-# PROMPTGEN_THREADS=8
-# PROMPTGEN_IDLE_TIMEOUT=600
+# INCIPIT_CLI_BIN=/usr/local/bin/llama-diffusion-cli
+# INCIPIT_MODEL=/models/diffusiongemma-26B-A4B-it-GGUF/diffusiongemma-26B-A4B-it-Q4_K_M.gguf
+# INCIPIT_NGL=99
+# INCIPIT_N_CPU_MOE=18
+# INCIPIT_THREADS=8
+# INCIPIT_IDLE_TIMEOUT=600
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..fa60232
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,32 @@
+name: CI
+
+on:
+ push:
+ branches: ["**"]
+ pull_request:
+
+jobs:
+ test:
+ name: pytest + coverage
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Python 3.11
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.11"
+ cache: pip
+ cache-dependency-path: |
+ requirements.txt
+ requirements-dev.txt
+
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install -r requirements-dev.txt
+
+ - name: Run tests with coverage
+ # Coverage scope + 90% gate are configured in pytest.ini addopts; the
+ # suite is fully offline (no network, no model/subprocess).
+ run: pytest -q
diff --git a/.gitignore b/.gitignore
index 6ab229b..9bfcf72 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,9 +1,13 @@
__pycache__/
*.pyc
.promptgen.json
+.incipit.json
.env
.venv/
.pytest_cache/
+.coverage
+.coverage.*
+htmlcov/
.opencodereview/last-review.txt
# Claude Code authoring tooling — general skill, not part of the app. Kept on
# disk (so it still works locally) but not tracked; it dominated every diff.
diff --git a/.opencodereview/rule.json b/.opencodereview/rule.json
index 2966910..805cb7a 100644
--- a/.opencodereview/rule.json
+++ b/.opencodereview/rule.json
@@ -36,7 +36,7 @@
},
{
"path": "app/llm/**/*.py",
- "rule": "Backends sit behind the LLMBackend Protocol (base.py); get_backend() selects by PROMPTGEN_BACKEND and imports lazily. The openai path must pull in NO diffusion/GPU code. enable_thinking=false is only sent when the disable_thinking setting is on. Flag cross-backend imports or eager diffusion imports on the openai path."
+ "rule": "Backends sit behind the LLMBackend Protocol (base.py); get_backend() selects by INCIPIT_BACKEND and imports lazily. The openai path must pull in NO diffusion/GPU code. enable_thinking=false is only sent when the disable_thinking setting is on. Flag cross-backend imports or eager diffusion imports on the openai path."
}
]
}
diff --git a/CLAUDE.md b/CLAUDE.md
index 1fd8dc5..ee78d30 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -26,13 +26,13 @@ There is no test suite, linter, or build step for the Python app. The fast dev
loop avoids spawning the GPU model by pointing at an OpenAI-compatible endpoint:
```bash
-PROMPTGEN_BACKEND=openai \
-PROMPTGEN_OPENAI_BASE_URL=http://:/v1 \
-PROMPTGEN_OPENAI_API_KEY= \
+INCIPIT_BACKEND=openai \
+INCIPIT_OPENAI_BASE_URL=http://:/v1 \
+INCIPIT_OPENAI_API_KEY= \
python3 -m uvicorn app.main:app --port 8911
```
-All configuration is environment variables (`PROMPTGEN_*`) read in
+All configuration is environment variables (`INCIPIT_*`) read in
`app/config.py` — there is no config file. Container CMD runs uvicorn on
`:8000`.
@@ -66,7 +66,7 @@ Request/orchestration flow is fully async and event-driven:
open connection** (`subscribers` list) — a shared queue would split events
between stale and live tabs.
- **`app/llm/`** — backend abstraction behind the `LLMBackend` Protocol
- (`base.py`). `get_backend()` selects by `PROMPTGEN_BACKEND`. Everything above
+ (`base.py`). `get_backend()` selects by `INCIPIT_BACKEND`. Everything above
this boundary is backend-agnostic.
### Wizard phases
@@ -89,7 +89,7 @@ To change what the spec contains, edit the YAML — not the code. Prompt wording
lives in `app/wizard/prompts/*.md.j2`; the system prompt is loaded once at
import (`flow.SYSTEM`).
-### Backends (`PROMPTGEN_BACKEND`)
+### Backends (`INCIPIT_BACKEND`)
- **`openai`** (default, `app/llm/openai_compat.py`) — any OpenAI-compatible
endpoint. Reads endpoint/model/key from `app/settings.py` (the runtime
diff --git a/README.md b/README.md
index 5d0a451..9e33aaf 100644
--- a/README.md
+++ b/README.md
@@ -37,8 +37,8 @@ Example endpoints (set the base URL in the settings panel):
| OpenAI | `https://api.openai.com/v1` | required |
Runtime endpoint changes are restricted to localhost, `api.openai.com`, and the
-host from `PROMPTGEN_OPENAI_BASE_URL` by default. For another trusted host, set
-`PROMPTGEN_ALLOWED_BASE_URL_HOSTS=host.example.com` before starting the app.
+host from `INCIPIT_OPENAI_BASE_URL` by default. For another trusted host, set
+`INCIPIT_ALLOWED_BASE_URL_HOSTS=host.example.com` before starting the app.
> **"Disable thinking" toggle:** local reasoning models (Qwen, etc.) can burn the
> whole token budget on a hidden think channel and return empty content. Turning
@@ -90,15 +90,126 @@ wording lives in `app/wizard/prompts/*.md.j2`.
## Configuration
-All config is environment variables (`PROMPTGEN_*`) — see [`.env.example`](.env.example).
+All config is environment variables (`INCIPIT_*`) — see [`.env.example`](.env.example).
A local `.env` is auto-loaded if present. Anything you save in the **⚙ Model
settings** panel is written to `.promptgen.json` (gitignored) and takes precedence
on the next run, so you configure your endpoint once.
-There is **no authentication** — run it on localhost or a trusted network only.
+The app has no login of its own — run it on localhost or a trusted network. The
+optional **"Login with GitHub"** flow (see below) is a per-user OAuth grant used
+only to read your private repos for grounding; it does not gate the app.
-There's no test suite or build step for the app itself. For a fast dev loop,
-point it at any running endpoint and run `uvicorn` as above.
+### Optional: Login with GitHub (private-repo grounding)
+
+For existing-codebase specs you can sign in with GitHub so the wizard can read
+your **private** repos. The user's access token is stored **server-side only**
+(in-memory, `app/auth.py`); the browser cookie carries just a signed, opaque
+session id (`HttpOnly` + `Secure` + `SameSite=Lax`). Configure the OAuth app:
+
+| Env var | What |
+|---|---|
+| `INCIPIT_GITHUB_OAUTH_CLIENT_ID` | OAuth app client id (public; a registered default is built in) |
+| `INCIPIT_GITHUB_OAUTH_CLIENT_SECRET` | OAuth app client secret — **secret**, set via env/Doppler, never commit |
+| `INCIPIT_GITHUB_OAUTH_REDIRECT_URL` | Callback URL registered on the OAuth app (`…/auth/github/callback`) |
+| `INCIPIT_GITHUB_OAUTH_SCOPES` | Requested scopes (default `repo`) |
+| `INCIPIT_SESSION_COOKIE_SECRET` | Secret used to sign the session cookie (set it so cookies survive restarts) |
+| `INCIPIT_COOKIE_SECURE` | Set the cookie `Secure` flag (default `true`; set `false` for local plain HTTP) |
+
+Token issuance/revocation is recorded on the `promptgen.audit` logger (no
+tokens are ever logged). Leave the client id/secret blank to disable the button.
+
+### Optional: Sign in with Atlassian (Jira export)
+
+On the final step you can **"Sign in with Atlassian"** (OAuth 2.0 / 3LO) and
+push the assembled mega-prompt straight into a Jira issue: pick a **Project** +
+**Issue type**, hit **Export to Jira**, and you get back the issue key and a
+clickable link. The brief is sent as a pretty **ADF** description and the raw
+`.md` is also attached. Each user authorizes their **own** Jira site — there is
+no shared/admin token. Access **and** refresh tokens plus the resolved
+`cloudId`/site live **server-side only**; the cookie still carries just the
+opaque signed session id, and the token is auto-refreshed before it lapses.
+
+| Env var | What |
+|---|---|
+| `INCIPIT_ATLASSIAN_OAUTH_CLIENT_ID` | Atlassian OAuth app client id (public; a registered default is built in) |
+| `INCIPIT_ATLASSIAN_OAUTH_CLIENT_SECRET` | Atlassian OAuth app client secret — **secret**, set via env/Doppler, never commit |
+| `INCIPIT_ATLASSIAN_OAUTH_REDIRECT_URL` | Callback URL registered on the app (`…/auth/atlassian/callback`) |
+| `INCIPIT_ATLASSIAN_OAUTH_SCOPES` | Console scopes (default `read:jira-work write:jira-work read:jira-user`); `offline_access` is appended at request time so a refresh token is issued |
+| `INCIPIT_JIRA_ISSUE_TYPES` | Comma-separated issue types for the dropdown (default `Task,Story,Bug`) |
+| `INCIPIT_JIRA_DEFAULT_PROJECT_KEY` | Optional project key to pre-select |
+| `INCIPIT_JIRA_EXPORT_TIMEOUT` | End-to-end export budget in ms (default `4000`) |
+
+Export events are recorded on the `promptgen.audit` logger. Leave the Atlassian
+client id/secret blank to hide the button. **`INCIPIT_ATLASSIAN_OAUTH_CLIENT_SECRET`
+must be supplied via env/Doppler** for the export flow to work.
+
+### All environment variables
+
+One table so a Doppler (or `.env`) config can be populated end-to-end. Secrets
+are flagged — never commit them.
+
+| Env var | Purpose | Default |
+|---|---|---|
+| `INCIPIT_BACKEND` | LLM backend: `openai` \| `diffusion-cnv` \| `diffusion-oneshot` | `openai` |
+| `INCIPIT_OPENAI_BASE_URL` | OpenAI-compatible endpoint base URL | `http://localhost:11434/v1` |
+| `INCIPIT_OPENAI_MODEL` | Default model id (overridable in the UI) | _(empty)_ |
+| `INCIPIT_OPENAI_API_KEY` | API key for the endpoint (**secret**) | _(empty)_ |
+| `WEBUI_API_URL` | Doppler/Open WebUI endpoint alias; an origin is normalized to `/api` | _(empty)_ |
+| `WEBUI_MODEL` | Model alias used when `INCIPIT_OPENAI_MODEL` is unset | _(empty)_ |
+| `WEBUI_API_KEY` | API-key alias used when `INCIPIT_OPENAI_API_KEY` is unset (**secret**) | _(empty)_ |
+| `INCIPIT_REASONING_EFFORT` | `default` \| `none` \| `low` \| `medium` \| `high` | `default` |
+| `INCIPIT_DISABLE_THINKING` | Back-compat: truthy → `reasoning_effort=none` | _(unset)_ |
+| `INCIPIT_ALLOWED_BASE_URL_HOSTS` | Extra hosts allowed for the model endpoint (SSRF allow-list) | _(empty)_ |
+| `INCIPIT_SETTINGS_FILE` | Path for persisted UI settings | `.promptgen.json` |
+| `INCIPIT_MAX_TOKENS` | Max generated tokens | `2048` |
+| `INCIPIT_GEN_TIMEOUT` | Generation timeout (s) | `300` |
+| `INCIPIT_LOAD_TIMEOUT` | Model load timeout (s) | `600` |
+| `INCIPIT_IDLE_TIMEOUT` | Idle-kill timeout for the diffusion subprocess (s) | `600` |
+| `INCIPIT_CLI_BIN` | Path to `llama-diffusion-cli` (diffusion backends) | `/usr/local/bin/llama-diffusion-cli` |
+| `INCIPIT_MODEL` | GGUF model path (diffusion backends) | _(see config)_ |
+| `INCIPIT_NGL` / `INCIPIT_N_CPU_MOE` / `INCIPIT_THREADS` | Diffusion CLI GPU/CPU/thread knobs | `99` / `18` / `8` |
+| `INCIPIT_PROMPT_MARKER` | Diffusion `-cnv` turn marker | `"\n> "` |
+| `INCIPIT_DIFFUSION_ARGS` | Extra diffusion CLI args | _(see config)_ |
+| `INCIPIT_SESSION_TTL` | Session + auth-record TTL (s) | `86400` |
+| `INCIPIT_GITHUB_TOKEN` | Anonymous-rate-limit token for public repo grounding | _(empty)_ |
+| `INCIPIT_FIRECRAWL_URL` | Firecrawl base URL for non-GitHub repo scraping | _(empty)_ |
+| `INCIPIT_REPO_TIMEOUT` | Repo-fetch HTTP timeout (s) | `25` |
+| `INCIPIT_REPO_CONTEXT_MAX` | Max chars of repo context injected into prompts | `6000` |
+| `INCIPIT_GITHUB_OAUTH_CLIENT_ID` | GitHub OAuth app client id (public) | _(built-in default)_ |
+| `INCIPIT_GITHUB_OAUTH_CLIENT_SECRET` | GitHub OAuth app client secret (**secret**) | _(empty)_ |
+| `INCIPIT_GITHUB_OAUTH_REDIRECT_URL` | GitHub OAuth callback URL | `https://incipit.nexus.inmotionhosting.com/auth/github/callback` |
+| `INCIPIT_GITHUB_OAUTH_SCOPES` | GitHub OAuth scopes | `repo` |
+| `INCIPIT_ATLASSIAN_OAUTH_CLIENT_ID` | Atlassian OAuth app client id (public) | _(built-in default)_ |
+| `INCIPIT_ATLASSIAN_OAUTH_CLIENT_SECRET` | Atlassian OAuth app client secret (**secret**) | _(empty)_ |
+| `INCIPIT_ATLASSIAN_OAUTH_REDIRECT_URL` | Atlassian OAuth callback URL | `https://incipit.nexus.inmotionhosting.com/auth/atlassian/callback` |
+| `INCIPIT_ATLASSIAN_OAUTH_SCOPES` | Atlassian console scopes (`offline_access` appended at request time) | `read:jira-work write:jira-work read:jira-user` |
+| `INCIPIT_JIRA_ISSUE_TYPES` | Issue-type dropdown options | `Task,Story,Bug` |
+| `INCIPIT_JIRA_DEFAULT_PROJECT_KEY` | Pre-selected project key | _(empty)_ |
+| `INCIPIT_JIRA_EXPORT_TIMEOUT` | Export time budget (ms) | `4000` |
+| `INCIPIT_SESSION_COOKIE_SECRET` | Secret for signing the session cookie (**secret**; set so cookies survive restarts) | _(ephemeral per-process)_ |
+| `INCIPIT_COOKIE_SECURE` | Set the cookie `Secure` flag | `true` |
+
+## Testing & coverage
+
+The repo ships an offline `pytest` suite (no network, no model/subprocess) —
+OAuth flows and the Jira REST client are exercised against a mocked httpx
+transport (`respx`):
+
+```bash
+pip install -r requirements-dev.txt
+pytest # runs with coverage (see pytest.ini)
+```
+
+CI ([`.github/workflows/ci.yml`](.github/workflows/ci.yml)) runs the same suite
+on Python 3.11. Coverage is gated at **90%** but **scoped** (in `pytest.ini`) to
+the security-critical, fully-offline-testable modules — `app/auth.py`,
+`app/audit.py`, `app/jira.py`, `app/markdown_adf.py` — rather than the whole
+`app` package: the LLM/diffusion backends and the wizard orchestration call out
+to a model/subprocess and aren't covered by the offline suite, so a 90% gate
+over all of `app` is impractical. The auth + export **routes** live in
+`app/main.py` alongside every wizard route (so they can't be isolated per-file
+by coverage), but they are covered by `tests/test_auth.py`,
+`tests/test_jira_auth.py`, and `tests/test_jira_export.py`.
---
@@ -108,7 +219,7 @@ Incipit was originally built around **DiffusionGemma 26B-A4B-it** run through
`llama-diffusion-cli` (llama.cpp PR #24423, which has no HTTP server yet — the
app drives a persistent `-cnv` subprocess over stdin/stdout). This path requires
building llama.cpp from a pinned PR and a GPU, and is selected with
-`PROMPTGEN_BACKEND=diffusion-cnv` (or `diffusion-oneshot`). It is **not** needed
+`INCIPIT_BACKEND=diffusion-cnv` (or `diffusion-oneshot`). It is **not** needed
for the OpenAI-compatible path above.
```bash
@@ -120,7 +231,7 @@ hf download unsloth/diffusiongemma-26B-A4B-it-GGUF diffusiongemma-26B-A4B-it-Q4_
podman build -t localhost/promptgen:v3 .
```
-Backends (`PROMPTGEN_BACKEND`):
+Backends (`INCIPIT_BACKEND`):
| Value | What |
|---|---|
diff --git a/app/audit.py b/app/audit.py
new file mode 100644
index 0000000..4b3c302
--- /dev/null
+++ b/app/audit.py
@@ -0,0 +1,52 @@
+"""Append-only audit log for OAuth token lifecycle events.
+
+There's no database (single-replica, in-memory app), so "audit table" is an
+append-only log line on the dedicated `promptgen.audit` logger (lowercase
+namespace, matching the rest of the app's loggers). Each record carries a
+timestamp, the action, the provider, and the provider account id so token
+issuance / revocation can be traced (AC8 / NFR3). No tokens or secrets are ever
+logged.
+"""
+
+import logging
+import time
+
+log = logging.getLogger("promptgen.audit")
+
+
+def _emit(action: str, provider: str, user_id: str = "", user_login: str = "",
+ **extra) -> None:
+ fields = {
+ "ts": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
+ "action": action,
+ "provider": provider,
+ "user_id": user_id or "-",
+ "user_login": user_login or "-",
+ **extra,
+ }
+ log.info("audit %s", " ".join(f"{k}={v}" for k, v in fields.items()))
+
+
+def token_issued(provider: str, user_id: str = "", user_login: str = "",
+ **extra) -> None:
+ """Record that a provider access token was issued/stored for a user."""
+ _emit("token_issued", provider, user_id, user_login, **extra)
+
+
+def token_refreshed(provider: str, user_id: str = "", user_login: str = "",
+ **extra) -> None:
+ """Record that a provider access token was refreshed (Atlassian)."""
+ _emit("token_refreshed", provider, user_id, user_login, **extra)
+
+
+def token_revoked(provider: str, user_id: str = "", user_login: str = "",
+ **extra) -> None:
+ """Record that a provider token was revoked / the user logged out."""
+ _emit("token_revoked", provider, user_id, user_login, **extra)
+
+
+def jira_export(project_key: str, issue_key: str, user_id: str = "",
+ user_login: str = "", **extra) -> None:
+ """Record that a mega-prompt was exported to a Jira issue."""
+ _emit("jira_export", "atlassian", user_id, user_login,
+ project=project_key or "-", issue=issue_key or "-", **extra)
diff --git a/app/auth.py b/app/auth.py
new file mode 100644
index 0000000..e8c3134
--- /dev/null
+++ b/app/auth.py
@@ -0,0 +1,122 @@
+"""In-memory, server-side auth store for per-user OAuth tokens.
+
+A signed, opaque session id lives in the browser cookie (see the cookie
+helpers in app/main.py); the *tokens themselves never leave the server*. Each
+session record holds per-provider entries (`github`, and later `atlassian`)
+plus the short-lived CSRF `state` values for in-flight OAuth handshakes.
+
+Single replica, single user — like app/wizard/state.py, records are kept in a
+module-level dict and TTL-swept; losing them on restart is acceptable (the user
+simply logs in again). Generalized now so the Atlassian/Jira login can reuse
+the same store and cookie.
+"""
+
+import asyncio
+import secrets
+import time
+import uuid
+from dataclasses import dataclass, field
+
+from app import config
+
+
+@dataclass
+class ProviderEntry:
+ """One provider's credentials + audit metadata, stored server-side only."""
+ access_token: str
+ scope: str = ""
+ token_type: str = "bearer"
+ # Atlassian (and any refreshable provider) populates these later.
+ refresh_token: str = ""
+ expires_at: float = 0.0 # epoch seconds; 0 = no expiry tracked
+ # Provider account identity, used for the audit log.
+ user_id: str = ""
+ user_login: str = ""
+ # Provider extras (e.g. Atlassian cloud_id / site_url).
+ meta: dict = field(default_factory=dict)
+
+
+@dataclass
+class AuthRecord:
+ id: str
+ created: float
+ providers: dict[str, ProviderEntry] = field(default_factory=dict)
+ # In-flight OAuth handshakes: CSRF state token -> {provider, return_to}.
+ pending: dict[str, dict] = field(default_factory=dict)
+ # Atlassian rotates refresh tokens, so refreshes for one auth record must
+ # be serialized to avoid submitting the same token concurrently.
+ refresh_lock: asyncio.Lock = field(default_factory=asyncio.Lock, repr=False)
+
+ def provider(self, name: str) -> ProviderEntry | None:
+ return self.providers.get(name)
+
+
+_auths: dict[str, AuthRecord] = {}
+
+
+def _sweep() -> None:
+ cutoff = time.time() - config.SESSION_TTL
+ for sid in [k for k, v in _auths.items() if v.created < cutoff]:
+ del _auths[sid]
+
+
+def create_auth() -> AuthRecord:
+ """Mint a fresh session record with an opaque id (the value signed into the
+ cookie)."""
+ _sweep()
+ rec = AuthRecord(id=uuid.uuid4().hex, created=time.time())
+ _auths[rec.id] = rec
+ return rec
+
+
+def get_auth(sid: str | None) -> AuthRecord | None:
+ """Return the (non-expired) record for a session id, or None."""
+ if not sid:
+ return None
+ rec = _auths.get(sid)
+ if rec is None:
+ return None
+ if rec.created < time.time() - config.SESSION_TTL:
+ del _auths[sid]
+ return None
+ return rec
+
+
+def new_state(rec: AuthRecord, provider: str, return_to: str = "/") -> str:
+ """Create + store an opaque CSRF `state` for an OAuth redirect."""
+ state = secrets.token_urlsafe(24)
+ rec.pending[state] = {"provider": provider, "return_to": return_to}
+ return state
+
+
+def pop_state(rec: AuthRecord, state: str, provider: str) -> dict | None:
+ """Consume a CSRF `state` (single use). Returns its stored payload only if
+ it exists and was issued for this provider; otherwise None."""
+ if not state:
+ return None
+ payload = rec.pending.pop(state, None)
+ if payload is None or payload.get("provider") != provider:
+ return None
+ return payload
+
+
+def set_provider(rec: AuthRecord, provider: str, **kwargs) -> ProviderEntry:
+ """Store/replace a provider's token + metadata on the record."""
+ entry = ProviderEntry(**kwargs)
+ rec.providers[provider] = entry
+ return entry
+
+
+def get_provider(sid: str | None, provider: str) -> ProviderEntry | None:
+ """Convenience: resolve a session id straight to a provider entry."""
+ rec = get_auth(sid)
+ return rec.provider(provider) if rec else None
+
+
+def revoke(rec: AuthRecord, provider: str | None = None) -> ProviderEntry | None:
+ """Revoke one provider (returns the removed entry, for audit) or, when
+ provider is None, drop the entire session record."""
+ if provider is None:
+ _auths.pop(rec.id, None)
+ return None
+ return rec.providers.pop(provider, None)
diff --git a/app/config.py b/app/config.py
index bd30b7b..e494142 100644
--- a/app/config.py
+++ b/app/config.py
@@ -1,8 +1,9 @@
-"""Environment-driven settings. Every knob has a PROMPTGEN_* env override."""
+"""Environment-driven settings. Every knob has a INCIPIT_* env override."""
import logging
import os
import shlex
+from urllib.parse import urlparse
try:
from dotenv import load_dotenv
@@ -27,26 +28,47 @@ def _int(name: str, default: int) -> int:
return default
+def _bool(name: str, default: bool) -> bool:
+ """Parse a boolean env override. Truthy: 1/true/yes/on (case-insensitive)."""
+ raw = os.environ.get(name)
+ if raw is None:
+ return default
+ return raw.strip().lower() in ("1", "true", "yes", "on")
+
+
+def _webui_api_base() -> str:
+ """Normalize a shared Open WebUI URL into its OpenAI-compatible API base."""
+ raw = os.environ.get("WEBUI_API_URL", "").strip().rstrip("/")
+ if not raw:
+ return ""
+ if raw.endswith("/chat/completions"):
+ return raw[: -len("/chat/completions")]
+ parsed = urlparse(raw)
+ if parsed.scheme and parsed.netloc and parsed.path in ("", "/"):
+ return raw + "/api"
+ return raw
+
+
# Backend selection: openai | diffusion-cnv | diffusion-oneshot
# Default is `openai` so a fresh clone runs against any OpenAI-compatible
# endpoint (Ollama by default) with no GPU / llama.cpp build. The diffusion
# backends are the opt-in "advanced" path (see README).
-BACKEND = os.environ.get("PROMPTGEN_BACKEND", "openai")
+BACKEND = os.environ.get("INCIPIT_BACKEND", "openai")
# llama-diffusion-cli settings
-CLI_BIN = os.environ.get("PROMPTGEN_CLI_BIN", "/usr/local/bin/llama-diffusion-cli")
+CLI_BIN = os.environ.get("INCIPIT_CLI_BIN", "/usr/local/bin/llama-diffusion-cli")
MODEL_PATH = os.environ.get(
- "PROMPTGEN_MODEL",
+ "INCIPIT_MODEL",
"/models/diffusiongemma-26B-A4B-it-GGUF/diffusiongemma-26B-A4B-it-Q4_K_M.gguf",
)
-N_GPU_LAYERS = os.environ.get("PROMPTGEN_NGL", "99")
-N_CPU_MOE = os.environ.get("PROMPTGEN_N_CPU_MOE", "18")
-THREADS = os.environ.get("PROMPTGEN_THREADS", "8")
-MAX_TOKENS = _int("PROMPTGEN_MAX_TOKENS", 2048)
-PROMPT_MARKER = os.environ.get("PROMPTGEN_PROMPT_MARKER", "\n> ")
+N_GPU_LAYERS = os.environ.get("INCIPIT_NGL", "99")
+N_CPU_MOE = os.environ.get("INCIPIT_N_CPU_MOE", "18")
+THREADS = os.environ.get("INCIPIT_THREADS", "8")
+MAX_TOKENS = _int("INCIPIT_MAX_TOKENS", 2048)
+PROMPT_MARKER = os.environ.get("INCIPIT_PROMPT_MARKER", "\n> ")
DIFFUSION_ARGS = os.environ.get(
- "PROMPTGEN_DIFFUSION_ARGS",
+ "INCIPIT_DIFFUSION_ARGS",
"--diffusion-eb auto --diffusion-eb-max-steps 48 "
"--diffusion-eb-t-max 0.8 --diffusion-eb-t-min 0.4 "
"--diffusion-eb-entropy-bound 0.1 --diffusion-eb-confidence 0.005 "
@@ -57,16 +79,21 @@ def _int(name: str, default: int) -> int:
DIFFUSION_ARGS = shlex.split(DIFFUSION_ARGS)
# Timeouts (seconds)
-GEN_TIMEOUT = _int("PROMPTGEN_GEN_TIMEOUT", 300)
-LOAD_TIMEOUT = _int("PROMPTGEN_LOAD_TIMEOUT", 600)
-IDLE_TIMEOUT = _int("PROMPTGEN_IDLE_TIMEOUT", 600)
+GEN_TIMEOUT = _int("INCIPIT_GEN_TIMEOUT", 300)
+LOAD_TIMEOUT = _int("INCIPIT_LOAD_TIMEOUT", 600)
+IDLE_TIMEOUT = _int("INCIPIT_IDLE_TIMEOUT", 600)
# OpenAI-compatible endpoint (the default backend). Defaults target a local
-# Ollama install; override for LM Studio, llama-server, vLLM, or OpenAI proper.
-# These seed the runtime settings (app/settings.py), which the UI can override.
-OPENAI_BASE_URL = os.environ.get("PROMPTGEN_OPENAI_BASE_URL", "http://localhost:11434/v1")
-OPENAI_MODEL = os.environ.get("PROMPTGEN_OPENAI_MODEL", "")
-OPENAI_API_KEY = os.environ.get("PROMPTGEN_OPENAI_API_KEY", "")
+# Ollama install; override for LM Studio, llama-server, vLLM, Open WebUI, or
+# OpenAI proper. WEBUI_* aliases support shared Doppler configurations.
+WEBUI_API_BASE = _webui_api_base()
+OPENAI_BASE_URL = (
+ os.environ.get("INCIPIT_OPENAI_BASE_URL")
+ or WEBUI_API_BASE
+ or "http://localhost:11434/v1"
+)
+OPENAI_MODEL = os.environ.get("INCIPIT_OPENAI_MODEL") or os.environ.get("WEBUI_MODEL", "")
+OPENAI_API_KEY = os.environ.get("INCIPIT_OPENAI_API_KEY") or os.environ.get("WEBUI_API_KEY", "")
# Reasoning effort sent to the OpenAI-compatible endpoint. One of:
# default - omit the field entirely (the model decides)
@@ -74,17 +101,17 @@ def _int(name: str, default: int) -> int:
# low | medium | high - reasoning_effort=
# Seeds the runtime setting (app/settings.py); the UI can override it live.
# Only the OpenAI-compatible backend reads this; the diffusion backends ignore it.
-# Back-compat: the older PROMPTGEN_DISABLE_THINKING boolean maps truthy -> "none".
+# Back-compat: the older INCIPIT_DISABLE_THINKING boolean maps truthy -> "none".
_REASONING_EFFORTS = ("default", "none", "low", "medium", "high")
def _reasoning_effort_default() -> str:
- val = os.environ.get("PROMPTGEN_REASONING_EFFORT", "").strip().lower()
+ val = os.environ.get("INCIPIT_REASONING_EFFORT", "").strip().lower()
if val in _REASONING_EFFORTS:
return val
if val:
return "default" # unrecognized explicit value -> safe default
- if os.environ.get("PROMPTGEN_DISABLE_THINKING", "").lower() in ("1", "true", "yes"):
+ if os.environ.get("INCIPIT_DISABLE_THINKING", "").lower() in ("1", "true", "yes"):
return "none"
return "default"
@@ -92,14 +119,79 @@ def _reasoning_effort_default() -> str:
REASONING_EFFORT = _reasoning_effort_default()
# Session housekeeping
-SESSION_TTL = _int("PROMPTGEN_SESSION_TTL", 24 * 3600)
+SESSION_TTL = _int("INCIPIT_SESSION_TTL", 24 * 3600)
# Existing-project repo grounding (Workstream F). For "existing" projects the
# wizard fetches a compact repo summary and injects it into the drafting prompts.
# GITHUB_TOKEN is optional (lifts the 60 req/h anonymous rate limit). FIRECRAWL_URL
# is the homelab Firecrawl base (e.g. http://firecrawl.default.svc:3002) used as a
# fallback for non-GitHub hosts or API failures; blank disables the fallback.
-GITHUB_TOKEN = os.environ.get("PROMPTGEN_GITHUB_TOKEN", "")
-FIRECRAWL_URL = os.environ.get("PROMPTGEN_FIRECRAWL_URL", "")
-REPO_TIMEOUT = _int("PROMPTGEN_REPO_TIMEOUT", 25)
-REPO_CONTEXT_MAX_CHARS = _int("PROMPTGEN_REPO_CONTEXT_MAX", 6000)
+GITHUB_TOKEN = os.environ.get("INCIPIT_GITHUB_TOKEN", "")
+FIRECRAWL_URL = os.environ.get("INCIPIT_FIRECRAWL_URL", "")
+REPO_TIMEOUT = _int("INCIPIT_REPO_TIMEOUT", 25)
+REPO_CONTEXT_MAX_CHARS = _int("INCIPIT_REPO_CONTEXT_MAX", 6000)
+
+# --- GitHub OAuth login (per-user "Login with GitHub") ---------------------
+# Lets a signed-in user ground the spec in their own private repos. The user's
+# access token is stored server-side only (app/auth.py); the browser cookie
+# carries just a signed, opaque session id. The CLIENT_ID below is the public,
+# registered OAuth-app id (not a secret); the CLIENT_SECRET must come from the
+# environment (Doppler/Vault) and must never be committed. Blank client
+# id/secret simply disables the login button.
+GITHUB_OAUTH_CLIENT_ID = os.environ.get(
+ "INCIPIT_GITHUB_OAUTH_CLIENT_ID", "Ov23liQTAZncU8NnfMS4")
+GITHUB_OAUTH_CLIENT_SECRET = os.environ.get("INCIPIT_GITHUB_OAUTH_CLIENT_SECRET", "")
+GITHUB_OAUTH_REDIRECT_URL = os.environ.get(
+ "INCIPIT_GITHUB_OAUTH_REDIRECT_URL",
+ "https://incipit.nexus.inmotionhosting.com/auth/github/callback")
+GITHUB_OAUTH_SCOPES = os.environ.get("INCIPIT_GITHUB_OAUTH_SCOPES", "repo")
+
+# --- Atlassian (Jira) OAuth 2.0 / 3LO ("Sign in with Atlassian") -----------
+# Per-user Jira export: each user authorizes their own Atlassian site. The
+# access + refresh tokens and the resolved cloudId / site live server-side only
+# (app/auth.py); the browser cookie carries just the signed, opaque session id.
+# CLIENT_ID is the public, registered OAuth-app id (not a secret); CLIENT_SECRET
+# must come from the environment (Doppler/Vault) and must never be committed.
+# `offline_access` is appended to the scope at request time (not configured
+# here) so Atlassian returns a refresh token. Blank client id/secret disables
+# the "Sign in with Atlassian" button.
+ATLASSIAN_OAUTH_CLIENT_ID = os.environ.get(
+ "INCIPIT_ATLASSIAN_OAUTH_CLIENT_ID", "gp295kGiSA32NqMPoeQCwMmbSTI8wjtp")
+ATLASSIAN_OAUTH_CLIENT_SECRET = os.environ.get(
+ "INCIPIT_ATLASSIAN_OAUTH_CLIENT_SECRET", "")
+ATLASSIAN_OAUTH_REDIRECT_URL = os.environ.get(
+ "INCIPIT_ATLASSIAN_OAUTH_REDIRECT_URL",
+ "https://incipit.nexus.inmotionhosting.com/auth/atlassian/callback")
+ATLASSIAN_OAUTH_SCOPES = os.environ.get(
+ "INCIPIT_ATLASSIAN_OAUTH_SCOPES",
+ "read:jira-work write:jira-work read:jira-user")
+
+# --- Jira export (issue creation over REST v3) -----------------------------
+# Issue types offered in the export dropdown. Defaults match a standard Jira
+# Cloud software/business project; override per-instance if your projects use a
+# different scheme.
+JIRA_ISSUE_TYPES = [
+ t.strip() for t in os.environ.get(
+ "INCIPIT_JIRA_ISSUE_TYPES", "Task,Story,Bug").split(",") if t.strip()
+]
+# Optional project key to pre-select in the export dropdown (blank = none).
+JIRA_DEFAULT_PROJECT_KEY = os.environ.get("INCIPIT_JIRA_DEFAULT_PROJECT_KEY", "")
+# End-to-end export time budget in milliseconds (create issue + attach .md).
+# The route enforces it; an overrun returns a clear per-export failure message.
+JIRA_EXPORT_TIMEOUT_MS = _int("INCIPIT_JIRA_EXPORT_TIMEOUT", 4000)
+
+# Secret used to sign the opaque session-id cookie (itsdangerous). If unset we
+# generate an ephemeral per-process secret: cookies then work within a single
+# run but don't survive a restart — acceptable for the single-replica design,
+# but set this in any real deploy so sessions persist across restarts.
+SESSION_COOKIE_SECRET = os.environ.get("INCIPIT_SESSION_COOKIE_SECRET", "")
+if not SESSION_COOKIE_SECRET:
+ import secrets as _secrets
+
+ SESSION_COOKIE_SECRET = _secrets.token_urlsafe(32)
+ log.warning("INCIPIT_SESSION_COOKIE_SECRET not set; using an ephemeral "
+ "per-process secret (auth cookies won't survive a restart)")
+
+# Set the Secure flag on auth cookies (HTTPS only). Default true; set false for
+# local plain-HTTP development.
+COOKIE_SECURE = _bool("INCIPIT_COOKIE_SECURE", True)
diff --git a/app/jira.py b/app/jira.py
new file mode 100644
index 0000000..2a4111b
--- /dev/null
+++ b/app/jira.py
@@ -0,0 +1,145 @@
+"""Jira Cloud REST v3 client for the per-user export-to-Jira flow.
+
+Every call targets ``https://api.atlassian.com/ex/jira/{cloud_id}/rest/api/3/…``
+with the signed-in user's OAuth Bearer token. This module is a *pure* REST
+client — the access token + cloudId come in, results go out. The auth store and
+token-refresh live in app/main.py (so jira.py never imports main.py and there's
+no circular dependency).
+"""
+
+import logging
+
+import httpx
+
+from app import config
+
+log = logging.getLogger("promptgen.jira")
+
+API_BASE = "https://api.atlassian.com/ex/jira"
+_PROJECT_PAGE = 50
+_MAX_PROJECT_PAGES = 20 # safety cap: up to 1000 projects
+
+
+class JiraError(Exception):
+ """A Jira REST call failed (non-2xx response or transport error)."""
+
+ def __init__(self, message: str, *, status_code: int | None = None):
+ super().__init__(message)
+ self.status_code = status_code
+
+
+def _base(cloud_id: str) -> str:
+ return f"{API_BASE}/{cloud_id}/rest/api/3"
+
+
+def _headers(token: str) -> dict:
+ return {"Authorization": f"Bearer {token}", "Accept": "application/json"}
+
+
+def _timeout(timeout_s: float | None) -> float:
+ return timeout_s if timeout_s is not None else float(config.REPO_TIMEOUT)
+
+
+def browse_url(site_url: str, key: str) -> str:
+ """The human-facing issue URL ({site}/browse/KEY)."""
+ return f"{(site_url or '').rstrip('/')}/browse/{key}"
+
+
+async def list_projects(cloud_id: str, token: str, *,
+ timeout: float | None = None) -> list[dict]:
+ """Return the user's projects (paginated via project/search) for the picker.
+ Each item is {key, name, id}. Raises JiraError on a non-200 response."""
+ url = f"{_base(cloud_id)}/project/search"
+ projects: list[dict] = []
+ async with httpx.AsyncClient(timeout=_timeout(timeout),
+ headers=_headers(token)) as c:
+ start = 0
+ for _ in range(_MAX_PROJECT_PAGES):
+ try:
+ r = await c.get(
+ url, params={"startAt": start, "maxResults": _PROJECT_PAGE})
+ except httpx.HTTPError as e:
+ raise JiraError(f"project/search transport failure: {e}") from e
+ if r.status_code != 200:
+ raise JiraError(
+ f"project/search returned {r.status_code}",
+ status_code=r.status_code)
+ try:
+ data = r.json()
+ except ValueError as e:
+ raise JiraError("project/search returned invalid JSON") from e
+ values = data.get("values", []) or []
+ for p in values:
+ projects.append({"key": p.get("key", ""),
+ "name": p.get("name", ""),
+ "id": str(p.get("id", ""))})
+ if data.get("isLast", True) or not values:
+ break
+ start += len(values)
+ return projects
+
+
+async def create_issue(cloud_id: str, token: str, *, project_key: str,
+ summary: str, issue_type: str, description_adf: dict,
+ timeout: float | None = None) -> dict:
+ """Create an issue with an ADF description. Returns {key, id}. Raises
+ JiraError on a non-2xx response."""
+ url = f"{_base(cloud_id)}/issue"
+ payload = {"fields": {
+ "project": {"key": project_key},
+ "summary": summary,
+ "issuetype": {"name": issue_type},
+ "description": description_adf,
+ }}
+ headers = {**_headers(token), "Content-Type": "application/json"}
+ try:
+ async with httpx.AsyncClient(timeout=_timeout(timeout), headers=headers) as c:
+ r = await c.post(url, json=payload)
+ except httpx.HTTPError as e:
+ raise JiraError(f"create issue transport failure: {e}") from e
+ if r.status_code not in (200, 201):
+ raise JiraError(
+ f"create issue failed ({r.status_code}): {_error_text(r)}",
+ status_code=r.status_code)
+ try:
+ data = r.json()
+ except ValueError as e:
+ raise JiraError("create issue returned invalid JSON") from e
+ return {"key": data.get("key", ""), "id": str(data.get("id", ""))}
+
+
+async def upload_attachment(cloud_id: str, token: str, issue_key: str,
+ filename: str, content: str | bytes,
+ timeout: float | None = None) -> list:
+ """Attach a file to an issue (multipart `file`). Jira requires the
+ X-Atlassian-Token: no-check header for attachment uploads. Raises JiraError
+ on a non-2xx response."""
+ url = f"{_base(cloud_id)}/issue/{issue_key}/attachments"
+ headers = {**_headers(token), "X-Atlassian-Token": "no-check"}
+ blob = content.encode("utf-8") if isinstance(content, str) else content
+ files = {"file": (filename, blob, "text/markdown")}
+ try:
+ async with httpx.AsyncClient(timeout=_timeout(timeout), headers=headers) as c:
+ r = await c.post(url, files=files)
+ except httpx.HTTPError as e:
+ raise JiraError(f"attachment upload transport failure: {e}") from e
+ if r.status_code not in (200, 201):
+ raise JiraError(
+ f"attachment upload failed ({r.status_code}): {_error_text(r)}",
+ status_code=r.status_code)
+ try:
+ return r.json()
+ except ValueError as e:
+ raise JiraError("attachment upload returned invalid JSON") from e
+
+
+def _error_text(r: httpx.Response) -> str:
+ """A short, log-safe snippet of a Jira error body (no secrets involved)."""
+ try:
+ data = r.json()
+ except ValueError:
+ return r.text[:200]
+ msgs = data.get("errorMessages") or []
+ errs = data.get("errors") or {}
+ parts = list(msgs) + [f"{k}: {v}" for k, v in errs.items()]
+ return "; ".join(parts)[:300] or r.text[:200]
diff --git a/app/llm/base.py b/app/llm/base.py
index 758870d..f0b3390 100644
--- a/app/llm/base.py
+++ b/app/llm/base.py
@@ -1,5 +1,5 @@
"""Backend interface. Everything above this boundary is backend-agnostic, so the
-diffusion CLI can be swapped for an OpenAI-compatible endpoint via PROMPTGEN_BACKEND."""
+diffusion CLI can be swapped for an OpenAI-compatible endpoint via INCIPIT_BACKEND."""
from typing import Protocol
@@ -31,4 +31,4 @@ def get_backend() -> "LLMBackend":
from app.llm.openai_compat import OpenAIBackend
return OpenAIBackend()
- raise ValueError(f"Unknown PROMPTGEN_BACKEND: {config.BACKEND}")
+ raise ValueError(f"Unknown INCIPIT_BACKEND: {config.BACKEND}")
diff --git a/app/llm/diffusion_oneshot.py b/app/llm/diffusion_oneshot.py
index 4275ae7..957dc36 100644
--- a/app/llm/diffusion_oneshot.py
+++ b/app/llm/diffusion_oneshot.py
@@ -2,7 +2,7 @@
Pays a full model load per call, but supports multi-line prompts cleanly via
-f and avoids all stdin-protocol fragility. Used if -cnv pipe-driving proves
-unreliable (PROMPTGEN_BACKEND=diffusion-oneshot).
+unreliable (INCIPIT_BACKEND=diffusion-oneshot).
"""
import asyncio
diff --git a/app/llm/openai_compat.py b/app/llm/openai_compat.py
index 6504876..72482f0 100644
--- a/app/llm/openai_compat.py
+++ b/app/llm/openai_compat.py
@@ -22,6 +22,11 @@ def __init__(self) -> None:
self._lock = asyncio.Lock()
self._client: httpx.AsyncClient | None = None
self.status = "ready"
+ # Last model we successfully generated with. A mismatch (or None on the
+ # first call) means the endpoint has to load/switch the model on this
+ # request — a cold start that can block for a long time — so we surface
+ # status="loading" and the UI shows a "loading model" graphic.
+ self._loaded_model: str | None = None
def _http(self) -> httpx.AsyncClient:
# Reuse one client so HTTP keep-alive/connection pooling survives across
@@ -67,7 +72,11 @@ async def generate(
body["reasoning_effort"] = effort
# "default": send neither key so the model uses its own default.
async with self._lock:
- self.status = "generating"
+ # A model switch (or the very first call) makes llama-swap / Ollama
+ # load the model on this request, which can block for a minute. Flag
+ # it as a cold start so the heartbeat renders the "loading model"
+ # graphic instead of a generic "working…" spinner.
+ self.status = "loading" if cfg.model != self._loaded_model else "generating"
try:
r = await self._http().post(
f"{cfg.base_url}/chat/completions",
@@ -91,6 +100,9 @@ async def generate(
)
if not content.strip():
raise GenerationError("upstream returned empty content")
+ # Remember the now-loaded model so the next call with the same
+ # model isn't mistaken for another cold start.
+ self._loaded_model = cfg.model
return content.strip()
except httpx.RequestError as e:
raise GenerationError(f"could not reach {cfg.base_url}: {e}")
diff --git a/app/main.py b/app/main.py
index 7c5051b..fe1aba2 100644
--- a/app/main.py
+++ b/app/main.py
@@ -2,14 +2,24 @@
import html
import json
import logging
+import time
from pathlib import Path
+from urllib.parse import urlencode, urlsplit
+import httpx
from fastapi import FastAPI, Form, Request
-from fastapi.responses import HTMLResponse, PlainTextResponse, Response, StreamingResponse
+from fastapi.responses import (
+ HTMLResponse,
+ PlainTextResponse,
+ RedirectResponse,
+ Response,
+ StreamingResponse,
+)
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
+from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
-from app import config, settings
+from app import audit, auth, config, jira, markdown_adf, repo, settings
from app.llm.base import GenerationError
from app.wizard import flow, state
@@ -103,6 +113,103 @@ def _push(s) -> dict:
return {"HX-Push-Url": f"/sessions/{s.id}"}
+# ---- Auth-session cookie (opaque, signed; tokens stay server-side) ----------
+# The cookie carries ONLY a signed session id (itsdangerous). The GitHub /
+# Atlassian access tokens live in app/auth.py and never reach the browser.
+AUTH_COOKIE = "incipit_auth"
+_COOKIE_SALT = "incipit-auth"
+
+
+def _serializer() -> URLSafeTimedSerializer:
+ # Built per call so a rotated SESSION_COOKIE_SECRET (or a test monkeypatch)
+ # takes effect without re-importing the module.
+ return URLSafeTimedSerializer(config.SESSION_COOKIE_SECRET, salt=_COOKIE_SALT)
+
+
+def _read_sid(request: Request) -> str | None:
+ """Return the verified session id from the request cookie, or None if the
+ cookie is missing, tampered with, or older than the session TTL."""
+ raw = request.cookies.get(AUTH_COOKIE)
+ if not raw:
+ return None
+ try:
+ return _serializer().loads(raw, max_age=config.SESSION_TTL)
+ except (BadSignature, SignatureExpired):
+ return None
+
+
+def _set_auth_cookie(response: Response, sid: str) -> None:
+ """Attach the signed session-id cookie with the hardened flags
+ (HttpOnly + Secure unless explicitly disabled for dev).
+
+ SameSite is Lax, not Strict: the OAuth callbacks (/auth/*/callback) are
+ reached via a top-level cross-site redirect from github.com /
+ auth.atlassian.com, and a Strict cookie is NOT sent on that navigation, so
+ the server could not recover the session id to validate the OAuth `state`.
+ Lax is sent on top-level cross-site GETs while still being withheld from
+ cross-site subrequests, so it preserves the CSRF protection that matters
+ here (the token stays HttpOnly + server-side regardless)."""
+ response.set_cookie(
+ AUTH_COOKIE, _serializer().dumps(sid),
+ max_age=config.SESSION_TTL, httponly=True,
+ secure=config.COOKIE_SECURE, samesite="lax", path="/",
+ )
+
+
+def current_auth(request: Request) -> auth.AuthRecord | None:
+ """Resolve the request's auth record (verified cookie → server-side store)."""
+ return auth.get_auth(_read_sid(request))
+
+
+def _github_oauth_configured() -> bool:
+ return bool(config.GITHUB_OAUTH_CLIENT_ID and config.GITHUB_OAUTH_CLIENT_SECRET)
+
+
+def _atlassian_oauth_configured() -> bool:
+ return bool(config.ATLASSIAN_OAUTH_CLIENT_ID and config.ATLASSIAN_OAUTH_CLIENT_SECRET)
+
+
+def _github_ctx(request: Request) -> dict:
+ """GitHub-login state for the step-1 existing-codebase controls."""
+ rec = current_auth(request)
+ entry = rec.provider("github") if rec else None
+ return {
+ "github_configured": _github_oauth_configured(),
+ "github_connected": entry is not None,
+ "github_login": entry.user_login if entry else "",
+ }
+
+
+async def _apply_repo_selection(request: Request, s) -> None:
+ """Record picked repos plus an auth-store reference for background fetches.
+
+ OAuth credentials remain exclusively in app.auth, so logout immediately
+ prevents in-flight wizard work from using the provider token.
+ """
+ if s.project_type != "existing":
+ return
+ form = await request.form()
+ s.selected_repos = [
+ r.strip() for r in form.getlist("selected_repos") if r and r.strip()
+ ][:10]
+ rec = current_auth(request)
+ entry = rec.provider("github") if rec else None
+ if entry is not None:
+ s.github_auth_id = rec.id
+
+
+def _atlassian_ctx(request: Request) -> dict:
+ """Atlassian-login state for the final/export step controls."""
+ rec = current_auth(request)
+ entry = rec.provider("atlassian") if rec else None
+ meta = entry.meta if entry else {}
+ return {
+ "atlassian_configured": _atlassian_oauth_configured(),
+ "atlassian_connected": entry is not None,
+ "atlassian_site": meta.get("site_name") or meta.get("site_url", ""),
+ }
+
+
# Background wizard jobs are fire-and-forget. Keep a strong reference (a bare
# create_task() may be garbage-collected before it finishes) and log any
# unhandled exception (otherwise it's swallowed and the session is left stuck
@@ -169,7 +276,8 @@ async def settings_models(request: Request, base_url: str = Form(""),
@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
- return _render("step1_idea.html", request, **_calibration_ctx())
+ return _render("step1_idea.html", request, **_calibration_ctx(),
+ **_github_ctx(request))
@app.get("/sessions/{sid}", response_class=HTMLResponse)
@@ -187,8 +295,11 @@ async def resume(request: Request, sid: str):
return _render("resume.html", request, body="step_moonshot.html", s=s)
if s.phase == "final":
return _render("step6_final.html", request, s=s,
- mega_prompt=flow.assemble_final(s))
- return _render("step1_idea.html", request, **_calibration_ctx())
+ mega_prompt=flow.assemble_final(s),
+ atlassian_error=request.query_params.get("atlassian_error", ""),
+ **_atlassian_ctx(request))
+ return _render("step1_idea.html", request, **_calibration_ctx(),
+ **_github_ctx(request))
@app.get("/sessions/{sid}/back/{to}", response_class=HTMLResponse)
@@ -201,7 +312,8 @@ async def go_back(request: Request, sid: str, to: str):
# Re-edit the brain dump with the prior inputs prefilled. (Submitting
# again starts a fresh draft — accepted.)
return _render("step1_idea.html", request, idea=s.idea, repo_url=s.repo_url,
- sel_project_type=s.project_type, **_calibration_ctx())
+ sel_project_type=s.project_type, **_calibration_ctx(),
+ repo_selection_sid=s.id, **_github_ctx(request))
if to == "clarify":
s.phase = "clarify"
return _render("resume.html", request, body="step3_clarify.html", s=s)
@@ -221,6 +333,7 @@ async def create_session(request: Request, idea: str = Form(...),
# form_factor is inferred from the idea in run_clarify; stakes is fixed.
s.project_type, s.form_factor, s.stakes = project_type, "", DEFAULT_STAKES
s.repo_url = repo_url.strip() if project_type == "existing" else ""
+ await _apply_repo_selection(request, s)
s.phase = "clarify"
_spawn(flow.run_clarify(s))
return _render("step3_clarify.html", request, headers=_push(s), s=s)
@@ -236,6 +349,7 @@ async def moonshot(request: Request, idea: str = Form(...),
# rest (form factor always inferred now). Stakes is fixed to the default.
s.project_type, s.form_factor, s.stakes = project_type, "", DEFAULT_STAKES
s.repo_url = repo_url.strip() if project_type == "existing" else ""
+ await _apply_repo_selection(request, s)
s.phase = "moonshot"
_spawn(flow.run_moonshot(s))
return _render("step_moonshot.html", request, headers=_push(s), s=s)
@@ -592,7 +706,7 @@ async def final(request: Request, sid: str):
return _render("expired.html", request)
s.phase = "final"
return _render("step6_final.html", request, s=s,
- mega_prompt=flow.assemble_final(s))
+ mega_prompt=flow.assemble_final(s), **_atlassian_ctx(request))
@app.get("/sessions/{sid}/download.md")
@@ -607,6 +721,465 @@ async def download(sid: str):
)
+# ---- GitHub OAuth login -----------------------------------------------------
+# Flow: /auth/github/login mints a session + CSRF state, sets the signed cookie,
+# and 302s to GitHub. GitHub redirects back to /auth/github/callback?code&state;
+# we exchange the code for a token, fetch the user, store the token server-side
+# (app/auth.py), audit it, and redirect back to where the user started. The
+# browser only ever holds the opaque signed session id.
+
+GITHUB_AUTHORIZE_URL = "https://github.com/login/oauth/authorize"
+GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token"
+GITHUB_USER_URL = "https://api.github.com/user"
+
+
+def _safe_return_to(value: str | None) -> str:
+ """Only allow a clean same-app path, never a browser-normalized authority."""
+ if not value or "\\" in value or any(ord(ch) < 32 for ch in value):
+ return "/"
+ try:
+ parsed = urlsplit(value)
+ except ValueError:
+ return "/"
+ if (value.startswith("/") and not value.startswith("//")
+ and not parsed.scheme and not parsed.netloc):
+ return value
+ return "/"
+
+
+def _atlassian_error_redirect(return_to: str, message: str) -> RedirectResponse:
+ """Return OAuth failures to the originating page instead of stranding users
+ on the callback endpoint. `message` is application-defined, never upstream
+ response content."""
+ separator = "&" if "?" in return_to else "?"
+ return RedirectResponse(
+ f"{return_to}{separator}{urlencode({'atlassian_error': message})}",
+ status_code=302,
+ )
+
+
+@app.get("/auth/github/login")
+async def github_login(request: Request, return_to: str = "/"):
+ if not _github_oauth_configured():
+ return PlainTextResponse("GitHub login is not configured.", status_code=503)
+ # Reuse an existing session if the cookie is valid, else start a new one.
+ rec = current_auth(request) or auth.create_auth()
+ state = auth.new_state(rec, "github", _safe_return_to(return_to))
+ params = {
+ "client_id": config.GITHUB_OAUTH_CLIENT_ID,
+ "redirect_uri": config.GITHUB_OAUTH_REDIRECT_URL,
+ "scope": config.GITHUB_OAUTH_SCOPES,
+ "state": state,
+ "allow_signup": "false",
+ }
+ resp = RedirectResponse(f"{GITHUB_AUTHORIZE_URL}?{urlencode(params)}", status_code=302)
+ _set_auth_cookie(resp, rec.id)
+ return resp
+
+
+@app.get("/auth/github/callback")
+async def github_callback(request: Request, code: str = "", state: str = "",
+ error: str = ""):
+ rec = current_auth(request)
+ if rec is None:
+ return PlainTextResponse("Auth session expired; please log in again.",
+ status_code=400)
+ payload = auth.pop_state(rec, state, "github")
+ if payload is None:
+ return PlainTextResponse("Invalid or expired OAuth state.", status_code=400)
+ return_to = _safe_return_to(payload.get("return_to"))
+ if error or not code:
+ # User denied, or GitHub returned an error — back to where they started.
+ return RedirectResponse(return_to, status_code=302)
+
+ try:
+ async with httpx.AsyncClient(timeout=config.REPO_TIMEOUT) as c:
+ tok = await c.post(GITHUB_TOKEN_URL, headers={"Accept": "application/json"},
+ data={
+ "client_id": config.GITHUB_OAUTH_CLIENT_ID,
+ "client_secret": config.GITHUB_OAUTH_CLIENT_SECRET,
+ "code": code,
+ "redirect_uri": config.GITHUB_OAUTH_REDIRECT_URL,
+ })
+ token_data = tok.json() if tok.status_code == 200 else {}
+ access_token = token_data.get("access_token", "")
+ if not access_token:
+ return PlainTextResponse("GitHub did not return an access token.",
+ status_code=400)
+ ur = await c.get(GITHUB_USER_URL, headers={
+ "Accept": "application/vnd.github+json",
+ "Authorization": f"Bearer {access_token}",
+ "User-Agent": "incipit",
+ })
+ user = ur.json() if ur.status_code == 200 else {}
+ except (httpx.HTTPError, ValueError) as e:
+ log.warning("GitHub OAuth callback failed: %s", e)
+ return PlainTextResponse(
+ "GitHub authentication could not be completed. Please try again.",
+ status_code=502)
+
+ auth.set_provider(rec, "github", access_token=access_token,
+ scope=token_data.get("scope", ""),
+ token_type=token_data.get("token_type", "bearer"),
+ user_id=str(user.get("id", "")),
+ user_login=user.get("login", ""))
+ audit.token_issued("github", user_id=str(user.get("id", "")),
+ user_login=user.get("login", ""), scope=token_data.get("scope", ""))
+ rec.created = time.time()
+ resp = RedirectResponse(return_to, status_code=302)
+ _set_auth_cookie(resp, rec.id) # refresh the cookie's max-age
+ return resp
+
+
+@app.post("/auth/github/logout")
+async def github_logout(request: Request):
+ rec = current_auth(request)
+ if rec is not None:
+ entry = auth.revoke(rec, "github")
+ if entry is not None:
+ audit.token_revoked("github", user_id=entry.user_id,
+ user_login=entry.user_login)
+ # Keep the shared cookie: it may still reference an Atlassian provider.
+ return Response(status_code=204, headers={"HX-Refresh": "true"})
+
+
+@app.get("/api/github/repos", response_class=HTMLResponse)
+async def github_repos(request: Request, sid: str = ""):
+ """The signed-in user's private repos, as a searchable multi-select partial.
+ 401 (not signed in / token rejected) renders the re-authorize modal instead,
+ which the client swaps in via the htmx:beforeSwap 401 handler."""
+ rec = current_auth(request)
+ entry = rec.provider("github") if rec else None
+ if entry is None:
+ return HTMLResponse(
+ _html("partials/github_error.html",
+ reason="You're not signed in to GitHub. Log in to pick your private repos."),
+ status_code=401)
+ try:
+ repos = await repo.list_private_repos(entry.access_token)
+ except repo.GitHubAuthError:
+ return HTMLResponse(
+ _html("partials/github_error.html",
+ reason="GitHub rejected your session (the token expired or was revoked)."),
+ status_code=401)
+ except (httpx.HTTPError, ValueError) as e:
+ log.warning("github repository listing failed: %s", e)
+ return _render(
+ "partials/github_error.html", request,
+ reason="Couldn't load your GitHub repositories. Please try again.")
+ prior = state.get(sid) if sid else None
+ selected = set(prior.selected_repos) if prior else set()
+ return _render(
+ "partials/github_repos.html", request, repos=repos, selected=selected)
+
+
+# ---- Atlassian (Jira) OAuth 2.0 / 3LO login ---------------------------------
+# Mirrors the GitHub flow: /auth/atlassian/login mints a CSRF state, sets the
+# signed cookie, and 302s to Atlassian with `offline_access` appended so we get
+# a refresh token. The callback exchanges the code, caches the user's cloudId +
+# site via accessible-resources, and stores access+refresh+expiry server-side
+# (app/auth.py). The browser only ever holds the opaque signed session id.
+
+ATLASSIAN_AUTHORIZE_URL = "https://auth.atlassian.com/authorize"
+ATLASSIAN_TOKEN_URL = "https://auth.atlassian.com/oauth/token"
+ATLASSIAN_RESOURCES_URL = "https://api.atlassian.com/oauth/token/accessible-resources"
+
+# Refresh the access token when it's within this many seconds of expiry (or
+# already expired), so an export never starts with a token about to lapse.
+ATLASSIAN_REFRESH_SKEW = 60
+
+
+class AtlassianAuthError(Exception):
+ """Raised when the current session has no usable Atlassian token (not
+ signed in, or a refresh failed) so callers can surface the re-authorize
+ modal instead of a generic 500 — mirrors repo.GitHubAuthError."""
+
+
+def _atlassian_scope() -> str:
+ """Configured scopes plus `offline_access` (appended at request time, not a
+ console scope) so Atlassian returns a refresh token."""
+ scopes = config.ATLASSIAN_OAUTH_SCOPES.split()
+ if "offline_access" not in scopes:
+ scopes.append("offline_access")
+ return " ".join(scopes)
+
+
+@app.get("/auth/atlassian/login")
+async def atlassian_login(request: Request, return_to: str = "/"):
+ if not _atlassian_oauth_configured():
+ return PlainTextResponse("Atlassian login is not configured.", status_code=503)
+ rec = current_auth(request) or auth.create_auth()
+ state = auth.new_state(rec, "atlassian", _safe_return_to(return_to))
+ params = {
+ "audience": "api.atlassian.com",
+ "client_id": config.ATLASSIAN_OAUTH_CLIENT_ID,
+ "scope": _atlassian_scope(),
+ "redirect_uri": config.ATLASSIAN_OAUTH_REDIRECT_URL,
+ "state": state,
+ "response_type": "code",
+ "prompt": "consent",
+ }
+ resp = RedirectResponse(f"{ATLASSIAN_AUTHORIZE_URL}?{urlencode(params)}", status_code=302)
+ _set_auth_cookie(resp, rec.id)
+ return resp
+
+
+@app.get("/auth/atlassian/callback")
+async def atlassian_callback(request: Request, code: str = "", state: str = "",
+ error: str = ""):
+ rec = current_auth(request)
+ if rec is None:
+ return PlainTextResponse("Auth session expired; please log in again.",
+ status_code=400)
+ payload = auth.pop_state(rec, state, "atlassian")
+ if payload is None:
+ return PlainTextResponse("Invalid or expired OAuth state.", status_code=400)
+ return_to = _safe_return_to(payload.get("return_to"))
+ if error or not code:
+ return _atlassian_error_redirect(return_to, "Atlassian sign-in was cancelled or denied.")
+
+ try:
+ async with httpx.AsyncClient(timeout=config.REPO_TIMEOUT) as c:
+ tok = await c.post(ATLASSIAN_TOKEN_URL, json={
+ "grant_type": "authorization_code",
+ "client_id": config.ATLASSIAN_OAUTH_CLIENT_ID,
+ "client_secret": config.ATLASSIAN_OAUTH_CLIENT_SECRET,
+ "code": code,
+ "redirect_uri": config.ATLASSIAN_OAUTH_REDIRECT_URL,
+ })
+ token_data = tok.json() if tok.status_code == 200 else {}
+ access_token = token_data.get("access_token", "")
+ if not access_token:
+ return _atlassian_error_redirect(
+ return_to, "Atlassian could not complete the token exchange."
+ )
+ # Resolve the user's accessible Jira site(s) → cloudId + site URL.
+ rr = await c.get(ATLASSIAN_RESOURCES_URL, headers={
+ "Accept": "application/json",
+ "Authorization": f"Bearer {access_token}",
+ })
+ resources = rr.json() if rr.status_code == 200 else []
+ except (httpx.HTTPError, ValueError) as e:
+ log.warning("Atlassian OAuth callback failed: %s", e)
+ return _atlassian_error_redirect(
+ return_to, "Atlassian could not complete the sign-in request."
+ )
+
+ valid_resources = [
+ item for item in resources
+ if isinstance(item, dict) and item.get("id") and item.get("url")
+ ] if isinstance(resources, list) else []
+ if not valid_resources:
+ return _atlassian_error_redirect(
+ return_to, "No accessible Jira site was returned by Atlassian."
+ )
+ first = valid_resources[0]
+ meta = {
+ "cloud_id": first.get("id", ""),
+ "site_url": first.get("url", ""),
+ "site_name": first.get("name", "") or first.get("url", ""),
+ }
+ auth.set_provider(rec, "atlassian", access_token=access_token,
+ refresh_token=token_data.get("refresh_token", ""),
+ scope=token_data.get("scope", ""),
+ token_type=token_data.get("token_type", "bearer"),
+ expires_at=_expires_at(token_data.get("expires_in")),
+ user_id=meta["cloud_id"], user_login=meta["site_name"],
+ meta=meta)
+ audit.token_issued("atlassian", user_id=meta["cloud_id"],
+ user_login=meta["site_name"], scope=token_data.get("scope", ""))
+ rec.created = time.time()
+ resp = RedirectResponse(return_to, status_code=302)
+ _set_auth_cookie(resp, rec.id) # refresh the cookie's max-age
+ return resp
+
+
+@app.post("/auth/atlassian/logout")
+async def atlassian_logout(request: Request):
+ rec = current_auth(request)
+ if rec is not None:
+ entry = auth.revoke(rec, "atlassian")
+ if entry is not None:
+ audit.token_revoked("atlassian", user_id=entry.user_id,
+ user_login=entry.user_login)
+ # HX-Refresh re-renders the final page so the connected chip disappears.
+ return Response(status_code=204, headers={"HX-Refresh": "true"})
+
+
+# ---- Jira export (issue creation over REST) ---------------------------------
+# /api/jira/projects loads the user's projects into the picker (refreshing the
+# token first); /api/jira/export builds the mega-prompt, converts it to ADF,
+# creates the issue, attaches the raw .md, and audits the export — all inside a
+# time budget. A lapsed Atlassian session answers 401 with the re-authorize
+# modal (same beforeSwap handler as the GitHub path).
+
+def _jira_unauthorized() -> HTMLResponse:
+ return HTMLResponse(
+ _html("partials/jira_error.html",
+ reason="Your Atlassian session has expired. Re-authorize to export to Jira."),
+ status_code=401)
+
+
+def _jira_summary(s) -> str:
+ """A concise issue summary derived from the idea."""
+ idea = " ".join((s.idea or "").split())
+ if not idea:
+ return "Incipit mega-prompt"
+ return f"Incipit brief: {idea[:120]}"
+
+
+@app.get("/api/jira/projects", response_class=HTMLResponse)
+async def jira_projects(request: Request, sid: str = ""):
+ """The connected user's projects + issue-type options as the export form.
+ 401 (not signed in / refresh failed) renders the re-authorize modal."""
+ rec = current_auth(request)
+ try:
+ entry = await refresh_atlassian_token(rec)
+ except AtlassianAuthError:
+ return _jira_unauthorized()
+ cloud_id = entry.meta.get("cloud_id", "")
+ try:
+ projects = await jira.list_projects(cloud_id, entry.access_token)
+ except jira.JiraError as e:
+ log.warning("jira project/search failed: %s", e)
+ if e.status_code == 401:
+ return _jira_unauthorized()
+ return HTMLResponse(
+ _html("partials/jira_error.html",
+ reason="Couldn't load your Jira projects. Please try again."),
+ status_code=200)
+ return _render("partials/jira_projects.html", request, sid=sid, projects=projects,
+ issue_types=config.JIRA_ISSUE_TYPES,
+ default_project=config.JIRA_DEFAULT_PROJECT_KEY)
+
+
+@app.post("/api/jira/export", response_class=HTMLResponse)
+async def jira_export(request: Request, sid: str = Form(...),
+ project_key: str = Form(...), issue_type: str = Form("Task")):
+ s = state.get(sid)
+ if s is None:
+ return _render("partials/jira_result.html", request, ok=False,
+ message="That session has expired; start a new one.")
+ rec = current_auth(request)
+ try:
+ entry = await refresh_atlassian_token(rec)
+ except AtlassianAuthError:
+ return _jira_unauthorized()
+ if not project_key:
+ return _render("partials/jira_result.html", request, ok=False,
+ message="Pick a project before exporting.")
+
+ cloud_id = entry.meta.get("cloud_id", "")
+ site_url = entry.meta.get("site_url", "")
+ md = flow.assemble_final(s)
+ adf = markdown_adf.to_adf(md)
+ summary = _jira_summary(s)
+ budget = config.JIRA_EXPORT_TIMEOUT_MS / 1000
+
+ try:
+ result = await _export_to_jira(
+ cloud_id, entry.access_token, site_url,
+ project_key, summary, issue_type, adf, md, timeout=budget)
+ except jira.JiraError as e:
+ if e.status_code == 401:
+ return _jira_unauthorized()
+ log.warning("jira issue creation failed: %s", e)
+ return _render("partials/jira_result.html", request, ok=False,
+ message=("Jira did not confirm issue creation. Check Jira "
+ "before retrying to avoid a duplicate."))
+
+ audit.jira_export(project_key, result["key"], user_id=entry.user_id,
+ user_login=entry.user_login, attached=result["attached"])
+ return _render("partials/jira_result.html", request, ok=True, key=result["key"],
+ url=result["url"], attached=result["attached"])
+
+
+async def _export_to_jira(cloud_id, token, site_url, project_key, summary,
+ issue_type, adf, md, *, timeout: float) -> dict:
+ """Create the issue, then best-effort attach the raw .md. Attachment failure
+ doesn't fail the export — the issue exists either way; we just flag it."""
+ loop = asyncio.get_running_loop()
+ deadline = loop.time() + timeout
+ issue = await jira.create_issue(
+ cloud_id, token, project_key=project_key, summary=summary,
+ issue_type=issue_type, description_adf=adf, timeout=timeout)
+ key = issue["key"]
+ attached = True
+ try:
+ remaining = max(0.001, deadline - loop.time())
+ await jira.upload_attachment(
+ cloud_id, token, key, "mega-prompt.md", md, timeout=remaining)
+ except jira.JiraError as e:
+ log.warning("jira attachment failed for %s: %s", key, e)
+ attached = False
+ return {"key": key, "url": jira.browse_url(site_url, key), "attached": attached}
+
+
+def _expires_at(expires_in) -> float:
+ """Convert Atlassian's `expires_in` (seconds) into an absolute epoch; 0 when
+ absent so we treat the token as already due for refresh."""
+ try:
+ return time.time() + int(expires_in)
+ except (TypeError, ValueError):
+ return 0.0
+
+
+def _atlassian_token_expiring(entry: auth.ProviderEntry) -> bool:
+ """True when the access token is unset, untracked, or within the refresh
+ skew of expiry."""
+ if not entry.expires_at:
+ return True
+ return entry.expires_at - time.time() <= ATLASSIAN_REFRESH_SKEW
+
+
+async def refresh_atlassian_token(rec: auth.AuthRecord | None) -> auth.ProviderEntry:
+ """Return a usable Atlassian provider entry, refreshing the access token via
+ the stored refresh_token when it's near/after expiry. Raises
+ AtlassianAuthError when there's no entry or the refresh fails, so the caller
+ can surface the re-authorize modal (same pattern as the GitHub 401 path)."""
+ entry = rec.provider("atlassian") if rec else None
+ if entry is None:
+ raise AtlassianAuthError("not signed in to Atlassian")
+ async with rec.refresh_lock:
+ # A concurrent request may have refreshed while this one waited.
+ entry = rec.provider("atlassian")
+ if entry is None:
+ raise AtlassianAuthError("not signed in to Atlassian")
+ if not _atlassian_token_expiring(entry):
+ return entry
+ if not entry.refresh_token:
+ raise AtlassianAuthError("Atlassian access token expired and no refresh token")
+ try:
+ async with httpx.AsyncClient(timeout=config.REPO_TIMEOUT) as c:
+ tok = await c.post(ATLASSIAN_TOKEN_URL, json={
+ "grant_type": "refresh_token",
+ "client_id": config.ATLASSIAN_OAUTH_CLIENT_ID,
+ "client_secret": config.ATLASSIAN_OAUTH_CLIENT_SECRET,
+ "refresh_token": entry.refresh_token,
+ })
+ except httpx.HTTPError as e:
+ raise AtlassianAuthError(f"Atlassian token refresh failed: {e}") from e
+ if tok.status_code != 200:
+ raise AtlassianAuthError("Atlassian rejected the refresh token")
+ try:
+ data = tok.json()
+ except ValueError as e:
+ raise AtlassianAuthError("Atlassian refresh returned invalid JSON") from e
+ new_token = data.get("access_token", "")
+ if not new_token:
+ raise AtlassianAuthError("Atlassian refresh returned no access token")
+ entry.access_token = new_token
+ entry.expires_at = _expires_at(data.get("expires_in"))
+ # Atlassian rotates refresh tokens; keep the new one when provided.
+ if data.get("refresh_token"):
+ entry.refresh_token = data["refresh_token"]
+ if data.get("scope"):
+ entry.scope = data["scope"]
+ audit.token_refreshed("atlassian", user_id=entry.user_id,
+ user_login=entry.user_login, scope=entry.scope)
+ return entry
+
+
@app.on_event("shutdown")
async def shutdown():
await flow.backend.shutdown()
diff --git a/app/markdown_adf.py b/app/markdown_adf.py
new file mode 100644
index 0000000..211d41d
--- /dev/null
+++ b/app/markdown_adf.py
@@ -0,0 +1,139 @@
+"""Minimal Markdown → Atlassian Document Format (ADF) converter.
+
+Produces the "pretty" body for a Jira REST v3 issue `description` from the
+mega-prompt markdown (flow.assemble_final). Supports the subset the wizard
+emits: headings, paragraphs, bold/italic, inline + fenced code, bullet/ordered
+lists, and links. Anything it doesn't recognize degrades to plain paragraph
+text — never raises, so an export can't be blocked by an exotic snippet.
+
+ADF reference: https://developer.atlassian.com/cloud/jira/platform/apis/document/structure/
+"""
+
+import re
+
+# One pass scans for the next inline span. Code is matched first so `**`/`_`
+# inside a code span aren't treated as emphasis. Marks are flat (no nesting),
+# which is all the assembled brief needs.
+_INLINE_RE = re.compile(
+ r"(?P`[^`]+`)"
+ r"|(?P\*\*[^*]+?\*\*|__[^_]+?__)"
+ r"|(?P\*[^*]+?\*|_[^_]+?_)"
+ r"|(?P\[[^\]]+\]\([^)\s]+\))"
+)
+_LINK_RE = re.compile(r"\[([^\]]+)\]\(([^)\s]+)\)")
+_HEADING_RE = re.compile(r"^(#{1,6})\s+(.*)$")
+_FENCE_RE = re.compile(r"^```(.*)$")
+_BULLET_RE = re.compile(r"^\s*[-*+]\s+(.*)$")
+_ORDERED_RE = re.compile(r"^\s*\d+\.\s+(.*)$")
+_BLOCK_START_RE = re.compile(r"^(#{1,6}\s|```|\s*[-*+]\s+|\s*\d+\.\s+)")
+
+
+def _push_text(nodes: list, text: str) -> None:
+ if text:
+ nodes.append({"type": "text", "text": text})
+
+
+def _inline(text: str) -> list:
+ """Parse inline markdown into a list of ADF text nodes (with marks)."""
+ nodes: list = []
+ pos = 0
+ for m in _INLINE_RE.finditer(text):
+ if m.start() > pos:
+ _push_text(nodes, text[pos:m.start()])
+ if m.group("code"):
+ _push_text_mark(nodes, m.group("code")[1:-1], {"type": "code"})
+ elif m.group("bold"):
+ _push_text_mark(nodes, m.group("bold")[2:-2], {"type": "strong"})
+ elif m.group("italic"):
+ _push_text_mark(nodes, m.group("italic")[1:-1], {"type": "em"})
+ elif m.group("link"):
+ lm = _LINK_RE.match(m.group("link"))
+ label, href = lm.group(1), lm.group(2)
+ _push_text_mark(nodes, label, {"type": "link", "attrs": {"href": href}})
+ pos = m.end()
+ if pos < len(text):
+ _push_text(nodes, text[pos:])
+ return nodes
+
+
+def _push_text_mark(nodes: list, text: str, mark: dict) -> None:
+ if text:
+ nodes.append({"type": "text", "text": text, "marks": [mark]})
+
+
+def _paragraph(text: str) -> dict | None:
+ content = _inline(text)
+ return {"type": "paragraph", "content": content} if content else None
+
+
+def _collect_list(lines: list, i: int, rex: re.Pattern) -> tuple[list, int]:
+ items: list = []
+ n = len(lines)
+ while i < n:
+ m = rex.match(lines[i])
+ if not m:
+ break
+ para = _paragraph(m.group(1).strip()) or {"type": "paragraph", "content": []}
+ items.append({"type": "listItem", "content": [para]})
+ i += 1
+ return items, i
+
+
+def to_adf(md: str) -> dict:
+ """Convert markdown to an ADF document node (always a valid `doc`)."""
+ lines = (md or "").replace("\r\n", "\n").replace("\r", "\n").split("\n")
+ content: list = []
+ i, n = 0, len(lines)
+ while i < n:
+ line = lines[i]
+ fence = _FENCE_RE.match(line)
+ if fence:
+ lang = fence.group(1).strip()
+ i += 1
+ code_lines: list = []
+ while i < n and not line_is_fence(lines[i]):
+ code_lines.append(lines[i])
+ i += 1
+ i += 1 # consume the closing fence (or run off the end)
+ node = {"type": "codeBlock"}
+ if lang:
+ node["attrs"] = {"language": lang}
+ code = "\n".join(code_lines)
+ if code:
+ node["content"] = [{"type": "text", "text": code}]
+ content.append(node)
+ continue
+ if not line.strip():
+ i += 1
+ continue
+ h = _HEADING_RE.match(line)
+ if h:
+ inner = _inline(h.group(2).strip()) or [{"type": "text", "text": h.group(2).strip()}]
+ content.append({"type": "heading", "attrs": {"level": len(h.group(1))},
+ "content": inner})
+ i += 1
+ continue
+ if _BULLET_RE.match(line):
+ items, i = _collect_list(lines, i, _BULLET_RE)
+ content.append({"type": "bulletList", "content": items})
+ continue
+ if _ORDERED_RE.match(line):
+ items, i = _collect_list(lines, i, _ORDERED_RE)
+ content.append({"type": "orderedList", "content": items})
+ continue
+ # paragraph: gather contiguous lines until a blank / block start
+ para_lines = [line]
+ i += 1
+ while i < n and lines[i].strip() and not _BLOCK_START_RE.match(lines[i]):
+ para_lines.append(lines[i])
+ i += 1
+ para = _paragraph(" ".join(s.strip() for s in para_lines))
+ if para:
+ content.append(para)
+ if not content:
+ content = [{"type": "paragraph", "content": []}]
+ return {"version": 1, "type": "doc", "content": content}
+
+
+def line_is_fence(line: str) -> bool:
+ return line.lstrip().startswith("```")
diff --git a/app/repo.py b/app/repo.py
index 7fd4c5f..8b53ba5 100644
--- a/app/repo.py
+++ b/app/repo.py
@@ -2,8 +2,8 @@
spec drafting can be grounded in the real codebase.
Strategy: GitHub REST first (public repos, no auth needed; optional
-`PROMPTGEN_GITHUB_TOKEN` lifts the anonymous rate limit). For non-GitHub hosts
-or API failures, fall back to homelab Firecrawl (`PROMPTGEN_FIRECRAWL_URL`) to
+`INCIPIT_GITHUB_TOKEN` lifts the anonymous rate limit). For non-GitHub hosts
+or API failures, fall back to homelab Firecrawl (`INCIPIT_FIRECRAWL_URL`) to
scrape the repo page. Best-effort throughout — a fetch failure never blocks
drafting; it returns a short note instead.
"""
@@ -25,6 +25,21 @@
# owner/repo from https://github.com/owner/repo(.git)(/...) or git@github.com:owner/repo
_GITHUB_RE = re.compile(r"github\.com[/:]+([^/\s]+)/([^/\s#?]+)", re.I)
+GITHUB_API = "https://api.github.com"
+
+# Listing private repos for a signed-in user is interactive (NFR2): keep it
+# snappy with a tight timeout, and retry once after a short backoff to ride out
+# a transient blip rather than failing the whole picker.
+_REPOS_TIMEOUT = 3.0 # 3000ms
+_REPOS_BACKOFF = 1.5 # 1500ms
+_REPOS_MAX_PAGES = 10 # safety cap: up to 1000 private repos
+
+
+class GitHubAuthError(Exception):
+ """Raised when GitHub returns 401 for an authenticated request (token
+ missing, expired, or lacking scope) so callers can surface a re-auth modal
+ instead of a generic 500."""
+
async def fetch_repo_context(url: str) -> str:
"""Return a compact repo summary for prompt injection, or a short note on
@@ -84,10 +99,80 @@ async def _host_is_public(url: str) -> bool:
return True
-async def _github(owner: str, repo: str) -> str:
+async def fetch_selected_repo_context(full_name: str, token: str = "") -> str:
+ """Compact summary for one private repo the user picked (owner/name), using
+ their OAuth token. Best-effort: never raises, so one bad repo can't block
+ drafting."""
+ try:
+ owner, _, name = (full_name or "").strip().partition("/")
+ if not owner or not name:
+ return ""
+ return await _github(owner, name, token=token)
+ except Exception as e: # noqa: BLE001 — best-effort; surface as a context note
+ log.warning("selected repo fetch failed for %s: %s", full_name, e)
+ return f"(Could not fetch repo context for {full_name}: {e})"
+
+
+async def list_private_repos(token: str) -> list[dict]:
+ """Return the signed-in user's private repos (paginated). Raises
+ GitHubAuthError on 401 so the route can answer 401 and the UI can prompt a
+ re-authorize. Retries once after a short backoff on a transient error."""
+ if not token:
+ raise GitHubAuthError("no GitHub token for the current session")
+ headers = {
+ "Accept": "application/vnd.github+json",
+ "User-Agent": "incipit",
+ "Authorization": f"Bearer {token}",
+ }
+ repos: list[dict] = []
+ async with httpx.AsyncClient(timeout=_REPOS_TIMEOUT, headers=headers,
+ follow_redirects=True) as c:
+ for page in range(1, _REPOS_MAX_PAGES + 1):
+ params = {"visibility": "private", "per_page": 100, "page": page,
+ "sort": "updated"}
+ batch = await _get_repos_page(c, params)
+ for r in batch:
+ repos.append({
+ "full_name": r.get("full_name", ""),
+ "name": r.get("name", ""),
+ "private": bool(r.get("private")),
+ "description": r.get("description") or "",
+ "html_url": r.get("html_url", ""),
+ "default_branch": r.get("default_branch", "main"),
+ })
+ if len(batch) < 100: # last page
+ break
+ return repos
+
+
+async def _get_repos_page(c: httpx.AsyncClient, params: dict) -> list:
+ """One GET /user/repos page with retry-once + backoff. 401 → GitHubAuthError."""
+ url = f"{GITHUB_API}/user/repos"
+ try:
+ return await _repos_request(c, url, params)
+ except GitHubAuthError:
+ raise # an auth failure won't fix itself on retry
+ except httpx.HTTPError:
+ await asyncio.sleep(_REPOS_BACKOFF)
+ return await _repos_request(c, url, params) # retry once; may raise
+
+
+async def _repos_request(c: httpx.AsyncClient, url: str, params: dict) -> list:
+ r = await c.get(url, params=params)
+ if r.status_code == 401:
+ raise GitHubAuthError("GitHub rejected the token (401)")
+ r.raise_for_status()
+ data = r.json()
+ return data if isinstance(data, list) else []
+
+
+async def _github(owner: str, repo: str, token: str = "") -> str:
headers = {"Accept": "application/vnd.github+json", "User-Agent": "promptgen"}
- if config.GITHUB_TOKEN:
- headers["Authorization"] = f"Bearer {config.GITHUB_TOKEN}"
+ # A per-user OAuth token (private-repo grounding) takes precedence over the
+ # optional anonymous-rate-limit token from config.
+ auth_token = token or config.GITHUB_TOKEN
+ if auth_token:
+ headers["Authorization"] = f"Bearer {auth_token}"
base = f"https://api.github.com/repos/{owner}/{repo}"
async with httpx.AsyncClient(timeout=config.REPO_TIMEOUT, headers=headers,
follow_redirects=True) as c:
diff --git a/app/settings.py b/app/settings.py
index 182c0e5..aa019d7 100644
--- a/app/settings.py
+++ b/app/settings.py
@@ -1,7 +1,7 @@
"""Runtime-mutable settings for the OpenAI-compatible backend.
Single-user, single-replica app (see app/wizard/state.py), so settings are a
-process-global object rather than per-session. Seeded from PROMPTGEN_* env
+process-global object rather than per-session. Seeded from INCIPIT_* env
(app/config.py), overridable live from the UI, and persisted to a gitignored
JSON file so a work-PC user only configures their endpoint once.
"""
@@ -17,7 +17,7 @@
log = logging.getLogger("promptgen.settings")
# CWD-relative so it lives next to the repo checkout; override for containers.
-STORE_PATH = os.environ.get("PROMPTGEN_SETTINGS_FILE", ".promptgen.json")
+STORE_PATH = os.environ.get("INCIPIT_SETTINGS_FILE", ".promptgen.json")
_FIELDS = ("base_url", "model", "api_key", "reasoning_effort")
_DEFAULT_ALLOWED_BASE_URL_HOSTS = {"localhost", "127.0.0.1", "::1", "api.openai.com"}
@@ -53,7 +53,10 @@ def allowed_base_url_hosts() -> set[str]:
seeded_host = _hostname(config.OPENAI_BASE_URL)
if seeded_host:
hosts.add(seeded_host)
- extra = os.environ.get("PROMPTGEN_ALLOWED_BASE_URL_HOSTS", "")
+ webui_host = _hostname(os.environ.get("WEBUI_API_URL", ""))
+ if webui_host:
+ hosts.add(webui_host)
+ extra = os.environ.get("INCIPIT_ALLOWED_BASE_URL_HOSTS", "")
hosts.update(
host for host in (_hostname(part.strip()) for part in extra.split(",")) if host
)
@@ -65,6 +68,8 @@ def normalize_base_url(base_url: str) -> str:
normalized = base_url.strip().rstrip("/")
if not normalized:
return ""
+ if normalized.endswith("/chat/completions"):
+ normalized = normalized[: -len("/chat/completions")]
parsed = urlparse(normalized)
if parsed.scheme not in {"http", "https"} or not parsed.netloc or not parsed.hostname:
raise SettingsError("Endpoint must be an http(s) URL with a host.")
@@ -75,8 +80,11 @@ def normalize_base_url(base_url: str) -> str:
if host not in allowed_hosts:
raise SettingsError(
f"Endpoint host '{host}' is not allowed. "
- "Set PROMPTGEN_ALLOWED_BASE_URL_HOSTS to allow it."
+ "Set INCIPIT_ALLOWED_BASE_URL_HOSTS to allow it."
)
+ webui_host = _hostname(config.WEBUI_API_BASE)
+ if config.WEBUI_API_BASE and host == webui_host and parsed.path in ("", "/"):
+ return config.WEBUI_API_BASE
return normalized
@@ -143,7 +151,7 @@ def update(*, base_url: str, model: str, api_key: str, reasoning_effort: str) ->
# A blank api_key field means "keep the existing stored key" (the UI never
# echoes the secret back, so the field is empty on every load). Submit a
# non-blank value to replace it. This means an empty key can't be set via
- # the form once one exists; clear PROMPTGEN_SETTINGS_FILE / env to reset.
+ # the form once one exists; clear INCIPIT_SETTINGS_FILE / env to reset.
new_api_key = api_key.strip()
if new_api_key:
current.api_key = new_api_key
diff --git a/app/templates/base.html b/app/templates/base.html
index b06bdf2..3a01d12 100644
--- a/app/templates/base.html
+++ b/app/templates/base.html
@@ -410,6 +410,19 @@
.assumption { color: var(--muted-foreground); font-size: var(--fs-xs); line-height: 16px; }
.error { color: var(--blocked); }
+ /* ---- GitHub private-repo multi-select (step 1, existing codebase) ---- */
+ .gh-connected { margin-top: 12px; }
+ .gh-repo-filter { margin: 6px 0; }
+ .gh-repo-list { display: flex; flex-direction: column; gap: 2px; max-height: 16rem; overflow-y: auto;
+ margin: 6px 0; padding: 6px; border: 1px solid var(--border); border-radius: 8px; }
+ .gh-repo { display: flex; align-items: baseline; gap: 8px; margin: 0; padding: 5px 8px;
+ border-radius: 8px; cursor: pointer; }
+ .gh-repo:hover { background: color-mix(in srgb, var(--accent) 8%, transparent); }
+ .gh-repo input { width: auto; margin: 0; flex: 0 0 auto; }
+ .gh-repo-name { font-weight: 600; }
+ .gh-repo-desc { color: var(--muted-foreground); font-size: var(--fs-xs);
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+
/* ---- Refine example chips ---- */
.refine-examples { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.ex-chip { font-size: var(--fs-xs); padding: 2px 10px; border: 1px solid var(--border);
@@ -480,6 +493,23 @@
.final-actions { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 20px; }
.final-actions button, .final-actions a[role="button"] { margin-bottom: 0; }
+ /* ---- Jira export (final step) ---- */
+ .jira-export { margin-top: 20px; padding: 14px 16px; border: 1px solid var(--border);
+ border-radius: 12px; background: var(--card); }
+ .jira-heading { font-size: 16px; margin: 0 0 8px; }
+ .jira-hint { color: var(--muted-foreground); font-size: var(--fs-sm); margin: 0 0 10px; }
+ .jira-chip { display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
+ font-size: var(--fs-sm); margin: 0 0 10px; }
+ .jira-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--safe); flex: 0 0 auto; }
+ .jira-disconnect { margin: 0 0 0 auto; width: auto; padding: 2px 12px; font-size: var(--fs-xs); }
+ .jira-controls { display: flex; gap: 8px; flex-wrap: wrap; align-items: flex-end; }
+ .jira-controls label { font-size: var(--fs-sm); margin: 0; }
+ .jira-controls select { margin-bottom: 0; min-width: 12rem; }
+ .jira-controls .jira-go { width: auto; margin: 0; }
+ .jira-result { margin-top: 12px; font-size: var(--fs-sm); }
+ .jira-result.ok { color: var(--safe); }
+ .jira-result.error { color: var(--blocked); }
+
/* ---- Change cards: state = full border + faint tint, no side stripe ---- */
.change-card article { transition: border-color .15s var(--ease), background-color .15s var(--ease); }
.change-card.applied article { border-color: color-mix(in srgb, var(--safe) 50%, var(--border));
@@ -558,6 +588,24 @@
Incipit
document.body.addEventListener(ev, scrollPartyChat);
});
+ // Client-side filter for the GitHub private-repo multi-select.
+ function filterGhRepos(input) {
+ var q = (input.value || '').trim().toLowerCase();
+ var items = document.querySelectorAll('.gh-repo');
+ for (var i = 0; i < items.length; i++) {
+ var hay = items[i].getAttribute('data-name') || '';
+ items[i].style.display = (!q || hay.indexOf(q) !== -1) ? '' : 'none';
+ }
+ }
+
+ // Let OAuth-related 401 responses swap in their re-authorization partial.
+ document.body.addEventListener('htmx:beforeSwap', function (e) {
+ if (e.detail.xhr && e.detail.xhr.status === 401) {
+ e.detail.shouldSwap = true;
+ e.detail.isError = false;
+ }
+ });
+
// Theme toggle: cycles system → dark → light, persists to localStorage, and
// re-applies the pre-paint resolution logic on each change.
(function () {
diff --git a/app/templates/partials/github_error.html b/app/templates/partials/github_error.html
new file mode 100644
index 0000000..31edd21
--- /dev/null
+++ b/app/templates/partials/github_error.html
@@ -0,0 +1,11 @@
+
+
+
🔐 GitHub access needed
+
{{ reason|default("We couldn't read your GitHub repositories.") }}
+
+
+
diff --git a/app/templates/partials/github_repos.html b/app/templates/partials/github_repos.html
new file mode 100644
index 0000000..d8e4f8f
--- /dev/null
+++ b/app/templates/partials/github_repos.html
@@ -0,0 +1,19 @@
+
+ {% if repos %}
+
+
+ {% for r in repos %}
+
+ {% endfor %}
+
+ Tick the private repos to ground the spec in. The repo link above still works without selecting any.
+ {% else %}
+ No private repositories found on your GitHub account.
+ {% endif %}
+
diff --git a/app/templates/partials/jira_error.html b/app/templates/partials/jira_error.html
new file mode 100644
index 0000000..f354491
--- /dev/null
+++ b/app/templates/partials/jira_error.html
@@ -0,0 +1,12 @@
+
+
+
Re-authorize Atlassian
+
{{ reason }}
+
+
+
diff --git a/app/templates/partials/jira_projects.html b/app/templates/partials/jira_projects.html
new file mode 100644
index 0000000..0a51ad2
--- /dev/null
+++ b/app/templates/partials/jira_projects.html
@@ -0,0 +1,28 @@
+
+ {% if projects %}
+
+ {% else %}
+
No Jira projects are visible to your Atlassian account.
+ {% endif %}
+
+
diff --git a/app/templates/partials/jira_result.html b/app/templates/partials/jira_result.html
new file mode 100644
index 0000000..c6613b2
--- /dev/null
+++ b/app/templates/partials/jira_result.html
@@ -0,0 +1,8 @@
+{% if ok %}
+
✓ Created
+ {{ key }}
+ {% if attached %}with the mega-prompt attached as mega-prompt.md.{% else %}— attaching the .md failed, but the full brief is in the issue description.{% endif %}
+
+{% else %}
+
✗ {{ message }}
+{% endif %}
diff --git a/app/templates/partials/settings.html b/app/templates/partials/settings.html
index 971c3d6..1ecb8ac 100644
--- a/app/templates/partials/settings.html
+++ b/app/templates/partials/settings.html
@@ -7,7 +7,7 @@
{% if not openai %}
A diffusion backend is active; these settings apply only to the
- OpenAI-compatible backend (set PROMPTGEN_BACKEND=openai).