From 968f68280f988fa813c6c8e652e74499d263e83a Mon Sep 17 00:00:00 2001 From: readwrightexecute Date: Wed, 24 Jun 2026 15:22:51 +0000 Subject: [PATCH 01/13] fix(moonshot): say consensus changes were auto-applied (not "review them below") The shared run_party() consensus message told users to "Review them below.", which is wrong for the moonshot flow where changes are auto-applied. Add an auto_apply flag (passed from run_moonshot) that switches the wording to "applied automatically." while leaving the normal review flow unchanged. Co-authored-by: Cursor --- app/wizard/flow.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/app/wizard/flow.py b/app/wizard/flow.py index 9918497..3f657a5 100644 --- a/app/wizard/flow.py +++ b/app/wizard/flow.py @@ -469,9 +469,13 @@ async def _round_table(s: Session, subject: str, turns += 1 -async def run_party(s: Session) -> None: +async def run_party(s: Session, *, auto_apply: bool = False) -> None: """Orchestrate the round table: opening round → facilitator-driven - mic-passing until consensus → synthesize approvable changes.""" + mic-passing until consensus → synthesize approvable changes. + + `auto_apply` only changes the consensus message wording: the moonshot flow + applies the agreed changes itself (no manual review), so we say "applied + automatically" instead of telling the user to review them below.""" try: s.party_status = "running" s.party_messages = [] @@ -496,10 +500,15 @@ async def run_party(s: Session) -> None: s.party_status = "ready" await _emit(s, "party_turn", "") n = len(s.party_changes) - await _say(s, PartyMessage("system", "", "", "", - f"✅ Consensus reached — {n} proposed change{'' if n == 1 else 's'}. " - "Review them below." if n else - "✅ The group reviewed the spec and proposed no changes.", "system")) + if not n: + consensus_msg = "✅ The group reviewed the spec and proposed no changes." + else: + changes = f"{n} proposed change{'' if n == 1 else 's'}" + # Moonshot auto-applies the consensus; the normal flow waits for the + # user to review/approve each change below. + tail = ", applied automatically." if auto_apply else ". Review them below." + consensus_msg = f"✅ Consensus reached — {changes}{tail}" + await _say(s, PartyMessage("system", "", "", "", consensus_msg, "system")) await _emit(s, "party_ready") except Exception as e: log.exception("party mode failed") @@ -715,7 +724,7 @@ async def run_moonshot(s: Session) -> None: await run_sections(s) await _emit(s, "moon", "🎉 Convening the BMAD round table…") - await run_party(s) + await run_party(s, auto_apply=True) pending = [c.id for c in s.party_changes if c.status == "pending"] if pending: From 31cb14bb98f1493e3743a8e1d190bbd9ca0109d8 Mon Sep 17 00:00:00 2001 From: readwrightexecute Date: Wed, 24 Jun 2026 15:26:54 +0000 Subject: [PATCH 02/13] fix(cold-start): show a loading-model graphic during model switch / cold start The OpenAI backend now reports status="loading" on the first call / model switch so the UI can surface a cold-start state. The first model call of a run (_infer_calibration) is wrapped in the heartbeat (_with_status) so the status bar shows the "loading model" graphic instead of nothing, and the status bars render an immediate cold-start graphic on first paint. Co-authored-by: Cursor --- app/llm/openai_compat.py | 14 +++++++++++++- app/templates/step3_clarify.html | 5 ++++- app/templates/step4_sections.html | 5 ++++- app/templates/step_moonshot.html | 4 +++- app/wizard/flow.py | 21 ++++++++++++++++----- 5 files changed, 40 insertions(+), 9 deletions(-) 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/templates/step3_clarify.html b/app/templates/step3_clarify.html index ed2a7f2..02b24db 100644 --- a/app/templates/step3_clarify.html +++ b/app/templates/step3_clarify.html @@ -1,7 +1,10 @@

Step 2 of 3 — Clarify

-
{% if not s.qas %} Thinking up the right questions…{% endif %}
+ {# Static fallback so the very first paint (before the SSE heartbeat connects) + still shows a cold-start graphic — the first model call has to load/switch + the model, which can take a minute. Replaced by the live heartbeat. #} +
{% if not s.qas %} Loading model (cold start, can take a minute)…{% endif %}
{% include "partials/question_list.html" %}
{% if s.party_status != 'idle' %}{% include "partials/party_qa_panel.html" %}{% endif %} diff --git a/app/templates/step4_sections.html b/app/templates/step4_sections.html index 80a2ea3..def597f 100644 --- a/app/templates/step4_sections.html +++ b/app/templates/step4_sections.html @@ -1,7 +1,10 @@

Step 3 of 3 — Draft & refine

-
+ {# While any section is still queued/drafting, show a cold-start graphic on + first paint (the SSE heartbeat replaces it). Omitted on a fully-drafted + resume so a stale "loading" line can't linger with nothing to clear it. #} +
{% if s.sections | selectattr("status", "in", ["pending", "generating"]) | list %} Loading model (cold start, can take a minute)…{% endif %}
{# Section cards. Refetched as a group when a background pass (Implement fixes / party) rewrites sections — sse:sections_updated fires only then. #} diff --git a/app/templates/step_moonshot.html b/app/templates/step_moonshot.html index edbafee..765cf9e 100644 --- a/app/templates/step_moonshot.html +++ b/app/templates/step_moonshot.html @@ -5,7 +5,9 @@

🌙 Shoot the Moon

smart-default answers, a full spec draft, then a BMAD round table whose consensus is applied automatically. This takes a few minutes; watch it unfold below.

-
+ {# Cold-start graphic on first paint; the live SSE feed (moon steps + model + heartbeat) replaces it as soon as the stream connects. #} +
Loading model (cold start, can take a minute)…
diff --git a/app/wizard/flow.py b/app/wizard/flow.py index 9918497..2d98851 100644 --- a/app/wizard/flow.py +++ b/app/wizard/flow.py @@ -81,12 +81,12 @@ async def _emit(s: Session, event: str, data: str = "") -> None: s.publish(event, data) -async def _generate(s: Session, prompt: str, max_tokens: int = 2048, - label: str = "Working") -> str: - """One LLM call with a contextual progress/model-loading heartbeat.""" +async def _with_status(s: Session, label: str, coro): + """Run an LLM coroutine while a contextual progress/model-loading heartbeat + feeds the status bar, then clear the bar when it finishes.""" notify_task = asyncio.create_task(_heartbeat(s, label)) try: - return await backend.generate(prompt, system=SYSTEM, max_tokens=max_tokens) + return await coro finally: notify_task.cancel() # _heartbeat swallows CancelledError and returns, so awaiting it here @@ -102,6 +102,13 @@ async def _generate(s: Session, prompt: str, max_tokens: int = 2048, await _emit(s, "progress", "") +async def _generate(s: Session, prompt: str, max_tokens: int = 2048, + label: str = "Working") -> str: + """One LLM call with a contextual progress/model-loading heartbeat.""" + return await _with_status( + s, label, backend.generate(prompt, system=SYSTEM, max_tokens=max_tokens)) + + def _anim_for(label: str) -> str: """Pick a spinner.js theme from the activity label, for a bit of personality.""" l = label.lower() @@ -661,7 +668,11 @@ async def _infer_calibration(s: Session) -> tuple[str, str, str]: ) stakes, form_factor, project_type = "internal", "web app", "new" try: - raw = await _party_gen(prompt, max_tokens=48) + # Usually the first model call of a run, so this is where a cold start / + # model switch lands — wrap it in the heartbeat so the status bar shows + # the "loading model" graphic instead of nothing. + raw = await _with_status( + s, "Reading your idea", _party_gen(prompt, max_tokens=48)) except Exception: return stakes, form_factor, project_type for ln in raw.splitlines(): From 2af54ee13762f59e95f0dab5718db918b87dc793 Mon Sep 17 00:00:00 2001 From: readwrightexecute Date: Thu, 25 Jun 2026 21:27:45 +0000 Subject: [PATCH 03/13] chore: rename INCIPIT_ env-var prefix (was PROMPTGEN_) Pure identifier rename of all PROMPTGEN_* environment variables to INCIPIT_*; defaults and behavior unchanged. Lowercase logger names and the .promptgen.json default filename are intentionally left as-is. Co-authored-by: Cursor --- .env.example | 32 +++++++++---------- .opencodereview/rule.json | 2 +- CLAUDE.md | 12 +++---- README.md | 10 +++--- app/config.py | 48 ++++++++++++++-------------- app/llm/base.py | 4 +-- app/llm/diffusion_oneshot.py | 2 +- app/repo.py | 4 +-- app/settings.py | 10 +++--- app/templates/partials/settings.html | 2 +- tests/test_settings.py | 8 ++--- 11 files changed, 67 insertions(+), 67 deletions(-) diff --git a/.env.example b/.env.example index 5896ecc..d8cad95 100644 --- a/.env.example +++ b/.env.example @@ -2,34 +2,34 @@ # 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= # 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 # --- 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/.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..61174c0 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,7 +90,7 @@ 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. @@ -108,7 +108,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 +120,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/config.py b/app/config.py index bd30b7b..904786a 100644 --- a/app/config.py +++ b/app/config.py @@ -1,4 +1,4 @@ -"""Environment-driven settings. Every knob has a PROMPTGEN_* env override.""" +"""Environment-driven settings. Every knob has a INCIPIT_* env override.""" import logging import os @@ -31,22 +31,22 @@ def _int(name: str, default: int) -> int: # 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 +57,16 @@ 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", "") +OPENAI_BASE_URL = os.environ.get("INCIPIT_OPENAI_BASE_URL", "http://localhost:11434/v1") +OPENAI_MODEL = os.environ.get("INCIPIT_OPENAI_MODEL", "") +OPENAI_API_KEY = os.environ.get("INCIPIT_OPENAI_API_KEY", "") # Reasoning effort sent to the OpenAI-compatible endpoint. One of: # default - omit the field entirely (the model decides) @@ -74,17 +74,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 +92,14 @@ 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) 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/repo.py b/app/repo.py index 7fd4c5f..a699991 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. """ diff --git a/app/settings.py b/app/settings.py index 182c0e5..ea86529 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,7 @@ 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", "") + 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 ) @@ -75,7 +75,7 @@ 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." ) return normalized @@ -143,7 +143,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/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).

+ OpenAI-compatible backend (set INCIPIT_BACKEND=openai).

{% endif %} {% if error %}

{{ error }}

{% endif %}
diff --git a/tests/test_settings.py b/tests/test_settings.py index 1cdb4e1..03cb4b3 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -22,7 +22,7 @@ def _clear_allowlist_env(monkeypatch): """Start every test from a known allow-list: only the built-in defaults (localhost, 127.0.0.1, ::1, api.openai.com) plus the env-seeded base URL host. Tests opt extra hosts in explicitly.""" - monkeypatch.delenv("PROMPTGEN_ALLOWED_BASE_URL_HOSTS", raising=False) + monkeypatch.delenv("INCIPIT_ALLOWED_BASE_URL_HOSTS", raising=False) # --- allowed_base_url_hosts ------------------------------------------------- @@ -40,7 +40,7 @@ def test_allowed_hosts_includes_seeded_base_url_host(): def test_allowed_hosts_picks_up_env_extra_hosts(monkeypatch): monkeypatch.setenv( - "PROMPTGEN_ALLOWED_BASE_URL_HOSTS", + "INCIPIT_ALLOWED_BASE_URL_HOSTS", "https://my.endpoint.com:8443/v1, bare.example.com ,", ) hosts = allowed_base_url_hosts() @@ -53,7 +53,7 @@ def test_allowed_hosts_picks_up_env_extra_hosts(monkeypatch): def test_allowed_hosts_lowercases_env_hosts(monkeypatch): - monkeypatch.setenv("PROMPTGEN_ALLOWED_BASE_URL_HOSTS", "API.Example.COM") + monkeypatch.setenv("INCIPIT_ALLOWED_BASE_URL_HOSTS", "API.Example.COM") assert "api.example.com" in allowed_base_url_hosts() @@ -68,7 +68,7 @@ def test_normalize_accepts_localhost(): def test_normalize_accepts_env_added_host(monkeypatch): - monkeypatch.setenv("PROMPTGEN_ALLOWED_BASE_URL_HOSTS", "my.endpoint.com") + monkeypatch.setenv("INCIPIT_ALLOWED_BASE_URL_HOSTS", "my.endpoint.com") assert normalize_base_url("https://my.endpoint.com/v1") == "https://my.endpoint.com/v1" From 1e9de675c167ea868c53f1bdb7e8fc437665cf3a Mon Sep 17 00:00:00 2001 From: readwrightexecute Date: Thu, 25 Jun 2026 21:32:28 +0000 Subject: [PATCH 04/13] test: add mocked-HTTP end-to-end tests for repo fetch Drive app/repo.py's _github() and _firecrawl() against a respx-mocked httpx transport (no DNS/sockets): the full GitHub meta->languages->readme->tree->_summarize path, graceful degradation when the follow-up calls fail, bearer-token header, and the private-repo->Firecrawl fallback (with and without FIRECRAWL_URL configured). Adds respx to requirements-dev.txt. Co-authored-by: Cursor --- requirements-dev.txt | 2 + tests/test_repo.py | 159 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 160 insertions(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index ff96beb..9789a09 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -2,3 +2,5 @@ # Test harness. Tests run fully offline (no network, no real LLM/subprocess). pytest==9.1.1 +# Mocked HTTP transport for httpx (repo-fetch / OAuth end-to-end tests). +respx==0.22.0 diff --git a/tests/test_repo.py b/tests/test_repo.py index 6dcc1f3..f016da3 100644 --- a/tests/test_repo.py +++ b/tests/test_repo.py @@ -7,17 +7,24 @@ """ import asyncio +import base64 import socket +import httpx import pytest +import respx -from app import repo +from app import config, repo def _run(coro): return asyncio.run(coro) +def _b64(text: str) -> str: + return base64.b64encode(text.encode("utf-8")).decode("ascii") + + def _addrinfo(*ips): """Build a getaddrinfo-shaped result for the given IP strings.""" return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (ip, 0)) for ip in ips] @@ -193,3 +200,153 @@ async def boom(owner, repo_name): out = _run(repo.fetch_repo_context("https://github.com/owner/repo")) assert "Could not fetch repo context" in out assert "kaboom" in out + + +# --- _github / _firecrawl end-to-end (mocked HTTP) -------------------------- +# +# These drive the real httpx code paths in app/repo.py against a respx-mocked +# transport — no DNS, no sockets. They cover the full GitHub +# meta→languages→readme→tree→_summarize path and the private-repo→Firecrawl +# fallback, complementing the routing tests above (which stub _github/_firecrawl +# entirely). + +_GH_BASE = "https://api.github.com/repos/owner/repo" + + +def _mock_github_full(): + """Register the four GitHub REST endpoints for a healthy public repo.""" + respx.get(_GH_BASE).mock(return_value=httpx.Response(200, json={ + "full_name": "owner/repo", + "default_branch": "main", + "description": "A test repository", + "topics": ["python", "cli"], + })) + respx.get(_GH_BASE + "/languages").mock(return_value=httpx.Response(200, json={ + "Python": 12000, "Shell": 800, + })) + respx.get(_GH_BASE + "/readme").mock(return_value=httpx.Response(200, json={ + "content": _b64("# Owner Repo\n\nA sample project README body."), + })) + respx.get(_GH_BASE + "/git/trees/main", params={"recursive": "1"}).mock( + return_value=httpx.Response(200, json={"tree": [ + {"path": "app", "type": "tree"}, + {"path": "app/main.py", "type": "blob"}, + {"path": "README.md", "type": "blob"}, + {"path": "tests/test_main.py", "type": "blob"}, + ]})) + + +@respx.mock +def test_github_full_path_summarizes(monkeypatch): + monkeypatch.setattr(config, "GITHUB_TOKEN", "") + _mock_github_full() + out = _run(repo._github("owner", "repo")) + assert "Repository: owner/repo" in out + assert "Description: A test repository" in out + # Languages preserved in order, capped at 6. + assert "Python" in out and "Shell" in out + assert "Topics: python, cli" in out + # Top-level entries derived from blob paths' first segment. + assert "app" in out and "README.md" in out and "tests" in out + # File sample lists blobs (not trees). + assert "app/main.py" in out + assert "tests/test_main.py" in out + # README excerpt is decoded from base64 and included. + assert "A sample project README body." in out + + +@respx.mock +def test_github_sends_bearer_token_when_configured(monkeypatch): + monkeypatch.setattr(config, "GITHUB_TOKEN", "ghp_secret") + _mock_github_full() + _run(repo._github("owner", "repo")) + # The meta request must carry the Authorization header derived from the token. + meta_call = respx.calls[0] + assert meta_call.request.headers["Authorization"] == "Bearer ghp_secret" + + +@respx.mock +def test_github_handles_missing_languages_readme_tree(monkeypatch): + # meta succeeds, but the follow-up calls fail — _summarize degrades cleanly. + monkeypatch.setattr(config, "GITHUB_TOKEN", "") + respx.get(_GH_BASE).mock(return_value=httpx.Response(200, json={ + "full_name": "owner/repo", "default_branch": "main", + })) + respx.get(_GH_BASE + "/languages").mock(return_value=httpx.Response(403)) + respx.get(_GH_BASE + "/readme").mock(return_value=httpx.Response(404)) + respx.get(_GH_BASE + "/git/trees/main", params={"recursive": "1"}).mock( + return_value=httpx.Response(404)) + out = _run(repo._github("owner", "repo")) + assert "Repository: owner/repo" in out + assert "Primary language(s): unknown" in out + assert "Top-level entries: (unknown)" in out + # No README section appended when the readme call failed. + assert "README (excerpt):" not in out + + +@respx.mock +def test_github_private_falls_back_to_firecrawl(monkeypatch): + # Private/not-found repo: meta lacks full_name → scrape the web page. + monkeypatch.setattr(config, "GITHUB_TOKEN", "") + monkeypatch.setattr(config, "FIRECRAWL_URL", "https://firecrawl.example") + respx.get(_GH_BASE).mock(return_value=httpx.Response(404, json={ + "message": "Not Found", + })) + scrape = respx.post("https://firecrawl.example/v1/scrape").mock( + return_value=httpx.Response(200, json={ + "data": {"markdown": "# owner/repo\nScraped page content."}})) + out = _run(repo._github("owner", "repo")) + assert scrape.called + # The fallback scrapes the canonical github.com page for the repo. + sent = scrape.calls[0].request + assert b"https://github.com/owner/repo" in sent.content + assert "Repository page: https://github.com/owner/repo" in out + assert "Scraped page content." in out + + +@respx.mock +def test_github_private_without_firecrawl_returns_note(monkeypatch): + monkeypatch.setattr(config, "GITHUB_TOKEN", "") + monkeypatch.setattr(config, "FIRECRAWL_URL", "") + respx.get(_GH_BASE).mock(return_value=httpx.Response(404, json={"message": "Not Found"})) + out = _run(repo._github("owner", "repo")) + assert "No structured fetch available" in out + assert "github.com/owner/repo" in out + + +@respx.mock +def test_fetch_repo_context_github_url_end_to_end(monkeypatch): + # Full public path through the entry point fetch_repo_context (URL → regex + # → _github → _summarize), exercising real httpx with no stubs. + monkeypatch.setattr(config, "GITHUB_TOKEN", "") + _mock_github_full() + out = _run(repo.fetch_repo_context("https://github.com/owner/repo")) + assert "Repository: owner/repo" in out + assert "A sample project README body." in out + + +@respx.mock +def test_firecrawl_reads_top_level_markdown_key(monkeypatch): + # Some Firecrawl responses put markdown at the top level rather than under data. + monkeypatch.setattr(config, "FIRECRAWL_URL", "https://firecrawl.example/") + respx.post("https://firecrawl.example/v1/scrape").mock( + return_value=httpx.Response(200, json={"markdown": "top-level md"})) + out = _run(repo._firecrawl("https://gitlab.com/owner/repo")) + assert "Repository page: https://gitlab.com/owner/repo" in out + assert "top-level md" in out + + +@respx.mock +def test_firecrawl_raises_on_5xx(monkeypatch): + # A 5xx (often an HTML error page) must raise before .json() is attempted. + monkeypatch.setattr(config, "FIRECRAWL_URL", "https://firecrawl.example") + respx.post("https://firecrawl.example/v1/scrape").mock( + return_value=httpx.Response(502, text="bad gateway")) + with pytest.raises(httpx.HTTPStatusError): + _run(repo._firecrawl("https://gitlab.com/owner/repo")) + + +def test_firecrawl_no_url_returns_note(monkeypatch): + monkeypatch.setattr(config, "FIRECRAWL_URL", "") + out = _run(repo._firecrawl("https://gitlab.com/owner/repo")) + assert "No structured fetch available" in out From 5f9a5643384de2b23dd46f1c5bd3b62ea051ba87 Mon Sep 17 00:00:00 2001 From: readwrightexecute Date: Thu, 25 Jun 2026 21:38:08 +0000 Subject: [PATCH 05/13] feat: add GitHub OAuth login with server-side token store Per-user "Login with GitHub" so the wizard can ground specs in private repos. The access token is stored server-side only (new app/auth.py in-memory store, TTL-swept like the session store); the browser cookie carries just a signed, opaque session id (itsdangerous) set HttpOnly + Secure + SameSite=Strict. - app/auth.py: per-provider token + CSRF-state store keyed by opaque session id (generalized so the Atlassian login can reuse it). - app/audit.py: append-only promptgen.audit log of token issuance/revocation with timestamp + provider account id (no tokens logged). - app/config.py: INCIPIT_GITHUB_OAUTH_CLIENT_ID/SECRET/REDIRECT_URL/SCOPES, INCIPIT_SESSION_COOKIE_SECRET, INCIPIT_COOKIE_SECURE (default true). - app/main.py: GET /auth/github/login, GET /auth/github/callback, POST /auth/github/logout + signed-cookie helpers. - itsdangerous dependency; .env.example + README documented. - tests/test_auth.py: login redirect, cookie flags, code exchange storing the token server-side, CSRF-state validation, logout clearing the cookie, audit. Co-authored-by: Cursor --- .env.example | 16 ++++ README.md | 26 ++++++- app/audit.py | 45 +++++++++++ app/auth.py | 118 ++++++++++++++++++++++++++++ app/config.py | 39 ++++++++++ app/main.py | 156 ++++++++++++++++++++++++++++++++++++- requirements.txt | 1 + tests/test_auth.py | 190 +++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 587 insertions(+), 4 deletions(-) create mode 100644 app/audit.py create mode 100644 app/auth.py create mode 100644 tests/test_auth.py diff --git a/.env.example b/.env.example index d8cad95..cdf6c0e 100644 --- a/.env.example +++ b/.env.example @@ -26,6 +26,22 @@ INCIPIT_DISABLE_THINKING= # 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) --- # 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 diff --git a/README.md b/README.md index 61174c0..bdadc3a 100644 --- a/README.md +++ b/README.md @@ -95,10 +95,32 @@ 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. + +### 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=Strict`). 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. 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. +point it at any running endpoint and run `uvicorn` as above. The repo does ship +an offline `pytest` suite (`pip install -r requirements-dev.txt && pytest`). --- diff --git a/app/audit.py b/app/audit.py new file mode 100644 index 0000000..415fad4 --- /dev/null +++ b/app/audit.py @@ -0,0 +1,45 @@ +"""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) diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..466bfce --- /dev/null +++ b/app/auth.py @@ -0,0 +1,118 @@ +"""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 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) + + 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 904786a..82ccb63 100644 --- a/app/config.py +++ b/app/config.py @@ -27,6 +27,14 @@ 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") + + # 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 @@ -103,3 +111,34 @@ def _reasoning_effort_default() -> str: 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") + +# 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/main.py b/app/main.py index 9d887d0..252aedd 100644 --- a/app/main.py +++ b/app/main.py @@ -3,13 +3,22 @@ import json import logging from pathlib import Path +from urllib.parse import urlencode +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, settings from app.llm.base import GenerationError from app.wizard import flow, state @@ -91,6 +100,50 @@ 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 + SameSite=Strict, Secure unless explicitly disabled for dev).""" + response.set_cookie( + AUTH_COOKIE, _serializer().dumps(sid), + max_age=config.SESSION_TTL, httponly=True, + secure=config.COOKIE_SECURE, samesite="strict", path="/", + ) + + +def _clear_auth_cookie(response: Response) -> None: + response.delete_cookie(AUTH_COOKIE, 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)) + + # 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 @@ -574,6 +627,105 @@ 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 same-app relative redirects (no open-redirect via //host).""" + if value and value.startswith("/") and not value.startswith("//"): + return value + return "/" + + +@app.get("/auth/github/login") +async def github_login(request: Request, return_to: str = "/"): + if not config.GITHUB_OAUTH_CLIENT_ID: + 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) + + 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 {} + + 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", "")) + 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) + # Clear the cookie and tell HTMX to refresh so the UI reflects logged-out. + resp = Response(status_code=204, headers={"HX-Refresh": "true"}) + _clear_auth_cookie(resp) + return resp + + @app.on_event("shutdown") async def shutdown(): await flow.backend.shutdown() diff --git a/requirements.txt b/requirements.txt index 67d070f..48e8a12 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,4 @@ pyyaml==6.0.2 httpx==0.28.1 python-multipart==0.0.20 python-dotenv==1.0.1 +itsdangerous==2.2.0 diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..263f9fc --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,190 @@ +"""Tests for the GitHub OAuth login flow (app/main.py routes + app/auth.py store). + +All GitHub HTTP is mocked with respx; the app's outbound httpx calls use the +real AsyncHTTPTransport (intercepted by respx), while the Starlette TestClient +talks to the app over its own ASGI transport (not intercepted). No network. +""" + +import logging +from urllib.parse import parse_qs, urlparse + +import httpx +import pytest +import respx +from fastapi.testclient import TestClient + +from app import auth, config, main + + +@pytest.fixture +def client(monkeypatch): + # Deterministic, configured OAuth app; plain-HTTP cookies so the TestClient + # jar round-trips them between login and callback. + monkeypatch.setattr(config, "GITHUB_OAUTH_CLIENT_ID", "test-client-id") + monkeypatch.setattr(config, "GITHUB_OAUTH_CLIENT_SECRET", "test-client-secret") + monkeypatch.setattr(config, "GITHUB_OAUTH_REDIRECT_URL", + "https://app.example/auth/github/callback") + monkeypatch.setattr(config, "COOKIE_SECURE", False) + return TestClient(main.app) + + +def _start_login(client, return_to="/"): + """Hit /auth/github/login, return (response, csrf_state). Leaves the signed + cookie in the client jar.""" + resp = client.get(f"/auth/github/login?return_to={return_to}", + follow_redirects=False) + state = parse_qs(urlparse(resp.headers["location"]).query)["state"][0] + return resp, state + + +def _sid_from_jar(client) -> str: + return main._serializer().loads(client.cookies[main.AUTH_COOKIE]) + + +# --- login ------------------------------------------------------------------- + +def test_login_redirects_to_github_authorize(client): + resp, _ = _start_login(client, return_to="/sessions/abc") + assert resp.status_code == 302 + loc = resp.headers["location"] + assert loc.startswith("https://github.com/login/oauth/authorize") + q = parse_qs(urlparse(loc).query) + assert q["client_id"] == ["test-client-id"] + assert q["scope"] == ["repo"] + assert q["redirect_uri"] == ["https://app.example/auth/github/callback"] + assert q["state"] # CSRF state present + + +def test_login_sets_hardened_cookie(monkeypatch): + # Secure flag is on by default; assert the full flag set on the raw header. + monkeypatch.setattr(config, "GITHUB_OAUTH_CLIENT_ID", "test-client-id") + monkeypatch.setattr(config, "COOKIE_SECURE", True) + c = TestClient(main.app) + resp = c.get("/auth/github/login", follow_redirects=False) + set_cookie = resp.headers["set-cookie"] + assert "incipit_auth=" in set_cookie + low = set_cookie.lower() + assert "httponly" in low + assert "secure" in low + assert "samesite=strict" in low + assert "path=/" in low + + +def test_login_not_configured_returns_503(monkeypatch): + monkeypatch.setattr(config, "GITHUB_OAUTH_CLIENT_ID", "") + c = TestClient(main.app) + resp = c.get("/auth/github/login", follow_redirects=False) + assert resp.status_code == 503 + + +# --- callback ---------------------------------------------------------------- + +@respx.mock +def test_callback_exchanges_code_and_stores_token_server_side(client, caplog): + token_route = respx.post(main.GITHUB_TOKEN_URL).mock( + return_value=httpx.Response(200, json={ + "access_token": "gho_secret_token", "scope": "repo", + "token_type": "bearer"})) + user_route = respx.get(main.GITHUB_USER_URL).mock( + return_value=httpx.Response(200, json={"login": "octocat", "id": 583231})) + + _, state = _start_login(client) + with caplog.at_level(logging.INFO, logger="promptgen.audit"): + resp = client.get(f"/auth/github/callback?code=abc123&state={state}", + follow_redirects=False) + + assert resp.status_code == 302 + assert resp.headers["location"] == "/" + assert token_route.called and user_route.called + # The secret never appears in the redirect / cookie — only the opaque sid. + assert "gho_secret_token" not in resp.headers.get("set-cookie", "") + + # Token is retrievable only server-side, via the signed session id. + rec = auth.get_auth(_sid_from_jar(client)) + entry = rec.provider("github") + assert entry.access_token == "gho_secret_token" + assert entry.user_login == "octocat" + assert entry.user_id == "583231" + + # Audit recorded issuance with the account id, but no token value. + assert "token_issued" in caplog.text + assert "octocat" in caplog.text + assert "gho_secret_token" not in caplog.text + + +@respx.mock +def test_callback_passes_client_secret_to_github(client): + token_route = respx.post(main.GITHUB_TOKEN_URL).mock( + return_value=httpx.Response(200, json={"access_token": "t", "scope": "repo"})) + respx.get(main.GITHUB_USER_URL).mock( + return_value=httpx.Response(200, json={"login": "u", "id": 1})) + _, state = _start_login(client) + client.get(f"/auth/github/callback?code=abc&state={state}", follow_redirects=False) + sent = parse_qs(token_route.calls[0].request.content.decode()) + assert sent["client_secret"] == ["test-client-secret"] + assert sent["code"] == ["abc"] + + +def test_callback_rejects_invalid_state(client): + _start_login(client) # establishes a session, but we send a bogus state + resp = client.get("/auth/github/callback?code=abc&state=not-the-real-state", + follow_redirects=False) + assert resp.status_code == 400 + + +def test_callback_without_session_returns_400(client): + resp = client.get("/auth/github/callback?code=abc&state=whatever", + follow_redirects=False) + assert resp.status_code == 400 + + +def test_callback_with_error_param_redirects_back(client): + _, state = _start_login(client, return_to="/sessions/xyz") + resp = client.get( + f"/auth/github/callback?error=access_denied&state={state}", + follow_redirects=False) + assert resp.status_code == 302 + assert resp.headers["location"] == "/sessions/xyz" + + +# --- logout ------------------------------------------------------------------ + +@respx.mock +def test_logout_revokes_token_and_clears_cookie(client, caplog): + respx.post(main.GITHUB_TOKEN_URL).mock( + return_value=httpx.Response(200, json={"access_token": "t", "scope": "repo"})) + respx.get(main.GITHUB_USER_URL).mock( + return_value=httpx.Response(200, json={"login": "octocat", "id": 7})) + _, state = _start_login(client) + client.get(f"/auth/github/callback?code=abc&state={state}", follow_redirects=False) + sid = _sid_from_jar(client) + assert auth.get_auth(sid).provider("github") is not None + + with caplog.at_level(logging.INFO, logger="promptgen.audit"): + resp = client.post("/auth/github/logout") + + assert resp.status_code == 204 + assert resp.headers.get("HX-Refresh") == "true" + # Cookie cleared (Max-Age=0 / past expiry). + set_cookie = resp.headers["set-cookie"].lower() + assert "incipit_auth=" in set_cookie + assert "max-age=0" in set_cookie or "expires=" in set_cookie + # Server-side token gone, revocation audited. + assert auth.get_auth(sid).provider("github") is None + assert "token_revoked" in caplog.text + + +# --- store unit -------------------------------------------------------------- + +def test_auth_store_state_is_single_use(): + rec = auth.create_auth() + state = auth.new_state(rec, "github", "/x") + assert auth.pop_state(rec, state, "github") == {"provider": "github", "return_to": "/x"} + # Second pop fails (single use) and a wrong-provider pop fails. + assert auth.pop_state(rec, state, "github") is None + + +def test_auth_store_state_rejects_wrong_provider(): + rec = auth.create_auth() + state = auth.new_state(rec, "github", "/") + assert auth.pop_state(rec, state, "atlassian") is None From d37736e54a58ec01fb31f2863b17c07529087d26 Mon Sep 17 00:00:00 2001 From: readwrightexecute Date: Thu, 25 Jun 2026 21:44:26 +0000 Subject: [PATCH 06/13] feat: GitHub private-repo multi-select and grounding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Let a signed-in user pick their private repos to ground the spec in. - app/repo.py: list_private_repos(token) — paginated GET /user/repos (visibility=private), Bearer auth, 3000ms timeout, retry-once + 1500ms backoff, typed GitHubAuthError on 401. fetch_selected_repo_context() reuses _github() with the per-user token (best-effort). - app/main.py: GET /api/github/repos — requires a valid auth cookie (else 401), renders a searchable multi-select; GitHubAuthError -> 401 re-authorize modal. create_session/moonshot copy the picked repos + token onto the session. - app/wizard/state.py: selected_repos + github_token (server-side only). - app/wizard/flow.py: _ensure_repo_context fetches + concatenates the selected private repos and the repo_url fallback, token-budgeted by REPO_CONTEXT_MAX. - UI: "Login with GitHub" button + repo checklist with client-side filter in step1 #repo-row, github_error re-authorize modal (.history-modal styling), and an htmx:beforeSwap handler so 401s render the modal. - tests/test_github_repos.py: repos JSON shape, pagination, 401->GitHubAuthError, retry/backoff, route authed vs 401 (no token leakage), grounding + budget. Co-authored-by: Cursor --- app/main.py | 60 +++++- app/repo.py | 91 ++++++++- app/templates/base.html | 31 ++++ app/templates/partials/github_error.html | 11 ++ app/templates/partials/github_repos.html | 18 ++ app/templates/step1_idea.html | 19 ++ app/wizard/flow.py | 26 ++- app/wizard/state.py | 2 + tests/test_github_repos.py | 224 +++++++++++++++++++++++ 9 files changed, 471 insertions(+), 11 deletions(-) create mode 100644 app/templates/partials/github_error.html create mode 100644 app/templates/partials/github_repos.html create mode 100644 tests/test_github_repos.py diff --git a/app/main.py b/app/main.py index 252aedd..0736f47 100644 --- a/app/main.py +++ b/app/main.py @@ -18,7 +18,7 @@ from fastapi.templating import Jinja2Templates from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer -from app import audit, auth, config, settings +from app import audit, auth, config, repo, settings from app.llm.base import GenerationError from app.wizard import flow, state @@ -144,6 +144,31 @@ def current_auth(request: Request) -> auth.AuthRecord | None: return auth.get_auth(_read_sid(request)) +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": bool(config.GITHUB_OAUTH_CLIENT_ID), + "github_connected": entry is not None, + "github_login": entry.user_login if entry else "", + } + + +async def _apply_repo_selection(request: Request, s) -> None: + """Copy the user's picked private repos + their GitHub token onto the + session (server-side) so the wizard can ground drafting in them. The token + never goes back to the browser; only the opaque session cookie does.""" + 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()] + rec = current_auth(request) + entry = rec.provider("github") if rec else None + if entry is not None: + s.github_token = entry.access_token + + # 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 @@ -208,7 +233,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) @@ -227,7 +253,8 @@ async def resume(request: Request, sid: str): 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()) + return _render("step1_idea.html", request, **_calibration_ctx(), + **_github_ctx(request)) @app.get("/sessions/{sid}/back/{to}", response_class=HTMLResponse) @@ -240,7 +267,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(), + **_github_ctx(request)) if to == "clarify": s.phase = "clarify" return _render("resume.html", request, body="step3_clarify.html", s=s) @@ -259,6 +287,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) @@ -273,6 +302,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) @@ -726,6 +756,28 @@ async def github_logout(request: Request): return resp +@app.get("/api/github/repos", response_class=HTMLResponse) +async def github_repos(request: Request): + """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) + return _render("partials/github_repos.html", request, repos=repos) + + @app.on_event("shutdown") async def shutdown(): await flow.backend.shutdown() diff --git a/app/repo.py b/app/repo.py index a699991..8b53ba5 100644 --- a/app/repo.py +++ b/app/repo.py @@ -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/templates/base.html b/app/templates/base.html index c588049..e6451e3 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -142,6 +142,17 @@ .assumption { color: var(--pico-muted-color); font-size: var(--fs-sm); } textarea { min-height: 10rem; } .error { color: var(--danger); } + /* ---- GitHub private-repo multi-select (step 1, existing codebase) ---- */ + .gh-connected { margin-top: .7rem; } + .gh-repo-filter { margin: .4rem 0 .3rem; } + .gh-repo-list { display: flex; flex-direction: column; gap: .15rem; max-height: 16rem; overflow-y: auto; + margin: .35rem 0; padding: .4rem; border: 1px solid var(--pico-card-border-color); border-radius: var(--pico-border-radius); } + .gh-repo { display: flex; align-items: baseline; gap: .5rem; margin: 0; padding: .3rem .45rem; + border-radius: var(--pico-border-radius); cursor: pointer; } + .gh-repo:hover { background: rgb(var(--accent-rgb) / .08); } + .gh-repo input { width: auto; margin: 0; flex: 0 0 auto; } + .gh-repo-name { font-weight: 600; } + .gh-repo-desc { color: var(--pico-muted-color); font-size: var(--fs-xs); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } article header h3 { margin-bottom: 0; } .refine-examples { display: flex; flex-wrap: wrap; gap: .35rem; margin-top: .4rem; } .ex-chip { font-size: var(--fs-xs); padding: .12rem .6rem; border: 1px solid var(--pico-muted-border-color); @@ -253,6 +264,26 @@

Incipit

['htmx:sseMessage', 'htmx:afterSwap', 'htmx:afterSettle'].forEach(function (ev) { 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 401 responses (e.g. /api/github/repos once the GitHub session lapses) + // swap their body in, so the re-authorize modal renders instead of htmx + // dropping the response as an error. + document.body.addEventListener('htmx:beforeSwap', function (e) { + if (e.detail.xhr && e.detail.xhr.status === 401) { + e.detail.shouldSwap = true; + e.detail.isError = false; + } + }); 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..7ada092 --- /dev/null +++ b/app/templates/partials/github_repos.html @@ -0,0 +1,18 @@ +
+ {% 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/step1_idea.html b/app/templates/step1_idea.html index 6206bcf..5eb973b 100644 --- a/app/templates/step1_idea.html +++ b/app/templates/step1_idea.html @@ -24,6 +24,25 @@

Step 1 of 3 — Brain dump & calibra We fetch the README + structure and feed a summary into drafting. Public GitHub works best; leave blank to skip. + + {% if github_configured %} +
+ {% if github_connected %} + + Connected to GitHub as {{ github_login }} · + Log out + +
+ Loading your repositories… +
+ {% else %} + Login with GitHub to pick private repos + Optional — grounds the spec in your private repos. Your token stays on the server. + {% endif %} +
+ {% endif %}

diff --git a/app/wizard/flow.py b/app/wizard/flow.py index 9918497..81758fe 100644 --- a/app/wizard/flow.py +++ b/app/wizard/flow.py @@ -67,13 +67,31 @@ def _ctx(s: Session) -> dict: async def _ensure_repo_context(s: Session) -> None: - """For existing projects with a repo link, fetch a compact codebase summary - once and cache it on the session so it can ground clarify + drafting.""" - if s.repo_context or s.project_type != "existing" or not s.repo_url: + """For existing projects, fetch a compact codebase summary once and cache it + on the session so it can ground clarify + drafting. Combines any private + repos the user picked after signing in with GitHub (fetched with their + server-side token) and the no-login repo_url fallback, token-budgeted by + config.REPO_CONTEXT_MAX_CHARS so the prompt can't blow up.""" + if s.repo_context or s.project_type != "existing": + return + if not s.selected_repos and not s.repo_url: return await _emit(s, "progress", ' Reading the repo…') - s.repo_context = await repo.fetch_repo_context(s.repo_url) + budget = config.REPO_CONTEXT_MAX_CHARS + parts: list[str] = [] + for full_name in s.selected_repos: + if budget <= 0: + break + ctx = (await repo.fetch_selected_repo_context(full_name, s.github_token))[:budget] + if ctx: + parts.append(ctx) + budget -= len(ctx) + if s.repo_url and budget > 0: + ctx = (await repo.fetch_repo_context(s.repo_url))[:budget] + if ctx: + parts.append(ctx) + s.repo_context = "\n\n---\n\n".join(parts) await _emit(s, "progress", "") diff --git a/app/wizard/state.py b/app/wizard/state.py index db466fb..7457068 100644 --- a/app/wizard/state.py +++ b/app/wizard/state.py @@ -74,6 +74,8 @@ class Session: form_factor: str = "" repo_url: str = "" # existing projects: link to the codebase repo_context: str = "" # fetched repo summary injected into drafting prompts + selected_repos: list[str] = field(default_factory=list) # private repos (owner/name) picked after GitHub login + github_token: str = "" # the picker's OAuth token, copied server-side at session create (never sent to the browser) qas: list[QA] = field(default_factory=list) sections: list[Section] = field(default_factory=list) phase: str = "idea" # idea | clarify | sections | moonshot | final diff --git a/tests/test_github_repos.py b/tests/test_github_repos.py new file mode 100644 index 0000000..ade000a --- /dev/null +++ b/tests/test_github_repos.py @@ -0,0 +1,224 @@ +"""Tests for private-repo listing + the /api/github/repos route + the selected- +repo grounding in flow._ensure_repo_context. + +GitHub HTTP is respx-mocked; the app's outbound httpx uses the real +AsyncHTTPTransport (intercepted), while the TestClient talks to the app over its +own ASGI transport (not intercepted). No network, no sleeps (backoff is patched). +""" + +import asyncio + +import httpx +import pytest +import respx +from fastapi.testclient import TestClient + +from app import auth, config, main, repo +from app.wizard import flow, state + +_REPOS_URL = "https://api.github.com/user/repos" + + +def _run(coro): + return asyncio.run(coro) + + +@pytest.fixture(autouse=True) +def _no_backoff(monkeypatch): + """Don't actually sleep on the retry-backoff path during tests.""" + async def _noslee_p(*_a, **_k): + return None + monkeypatch.setattr(repo.asyncio, "sleep", _noslee_p) + + +def _repo_json(full_name, **extra): + owner, _, name = full_name.partition("/") + return {"full_name": full_name, "name": name, "private": True, + "description": extra.get("description", ""), + "html_url": f"https://github.com/{full_name}", + "default_branch": extra.get("default_branch", "main")} + + +# --- repo.list_private_repos ------------------------------------------------ + +@respx.mock +def test_list_private_repos_shape_single_page(): + respx.get(_REPOS_URL).mock(return_value=httpx.Response(200, json=[ + _repo_json("octocat/secret", description="hush"), + _repo_json("octocat/other"), + ])) + repos = _run(repo.list_private_repos("gho_token")) + assert [r["full_name"] for r in repos] == ["octocat/secret", "octocat/other"] + first = repos[0] + assert set(first) == {"full_name", "name", "private", "description", + "html_url", "default_branch"} + assert first["private"] is True + assert first["name"] == "secret" + assert first["description"] == "hush" + + +@respx.mock +def test_list_private_repos_sends_bearer_and_visibility(): + route = respx.get(_REPOS_URL).mock(return_value=httpx.Response(200, json=[])) + _run(repo.list_private_repos("gho_token")) + req = route.calls[0].request + assert req.headers["Authorization"] == "Bearer gho_token" + assert req.url.params["visibility"] == "private" + assert req.url.params["per_page"] == "100" + + +@respx.mock +def test_list_private_repos_paginates(): + page1 = [_repo_json(f"octocat/r{i}") for i in range(100)] + page2 = [_repo_json("octocat/last")] + + def handler(request): + page = request.url.params.get("page") + return httpx.Response(200, json=page1 if page == "1" else page2) + + respx.get(_REPOS_URL).mock(side_effect=handler) + repos = _run(repo.list_private_repos("gho_token")) + assert len(repos) == 101 + assert repos[-1]["full_name"] == "octocat/last" + + +@respx.mock +def test_list_private_repos_401_raises_auth_error(): + respx.get(_REPOS_URL).mock(return_value=httpx.Response(401, json={"message": "Bad credentials"})) + with pytest.raises(repo.GitHubAuthError): + _run(repo.list_private_repos("expired")) + + +def test_list_private_repos_no_token_raises_auth_error(): + with pytest.raises(repo.GitHubAuthError): + _run(repo.list_private_repos("")) + + +@respx.mock +def test_list_private_repos_retries_once_then_succeeds(): + route = respx.get(_REPOS_URL).mock(side_effect=[ + httpx.ConnectError("transient blip"), + httpx.Response(200, json=[_repo_json("octocat/ok")]), + ]) + repos = _run(repo.list_private_repos("gho_token")) + assert [r["full_name"] for r in repos] == ["octocat/ok"] + assert route.call_count == 2 # initial failure + one retry + + +@respx.mock +def test_list_private_repos_retry_exhausted_propagates(): + respx.get(_REPOS_URL).mock(side_effect=[ + httpx.ConnectError("blip 1"), + httpx.ConnectError("blip 2"), + ]) + with pytest.raises(httpx.HTTPError): + _run(repo.list_private_repos("gho_token")) + + +# --- /api/github/repos route ------------------------------------------------ + +def _authed_client(monkeypatch, token="gho_secret"): + monkeypatch.setattr(config, "GITHUB_OAUTH_CLIENT_ID", "test-client-id") + monkeypatch.setattr(config, "COOKIE_SECURE", False) + rec = auth.create_auth() + auth.set_provider(rec, "github", access_token=token, user_login="octocat", + user_id="1", scope="repo") + c = TestClient(main.app) + c.cookies.set(main.AUTH_COOKIE, main._serializer().dumps(rec.id)) + return c + + +def test_repos_route_unauthenticated_returns_401(monkeypatch): + monkeypatch.setattr(config, "GITHUB_OAUTH_CLIENT_ID", "test-client-id") + c = TestClient(main.app) # no auth cookie + resp = c.get("/api/github/repos") + assert resp.status_code == 401 + # Surfaces the re-authorize modal; never any repo data. + assert "Re-authorize" in resp.text + assert "selected_repos" not in resp.text + + +@respx.mock +def test_repos_route_authed_lists_repos_without_leaking_token(monkeypatch): + respx.get(_REPOS_URL).mock(return_value=httpx.Response(200, json=[ + _repo_json("octocat/private-one", description="d1"), + _repo_json("octocat/private-two"), + ])) + c = _authed_client(monkeypatch, token="gho_super_secret") + resp = c.get("/api/github/repos") + assert resp.status_code == 200 + assert "octocat/private-one" in resp.text + assert "octocat/private-two" in resp.text + # The token must never appear in the rendered HTML. + assert "gho_super_secret" not in resp.text + # Checkboxes are wired to the form field the wizard reads. + assert 'name="selected_repos"' in resp.text + + +@respx.mock +def test_repos_route_auth_error_returns_401_modal(monkeypatch): + respx.get(_REPOS_URL).mock(return_value=httpx.Response(401, json={"message": "Bad credentials"})) + c = _authed_client(monkeypatch, token="revoked") + resp = c.get("/api/github/repos") + assert resp.status_code == 401 + assert "Re-authorize" in resp.text + + +# --- flow grounding for selected private repos ------------------------------ + +def test_ensure_repo_context_concatenates_selected_repos(monkeypatch): + calls = {"selected": [], "url": None} + + async def fake_selected(full_name, token): + calls["selected"].append((full_name, token)) + return f"CTX[{full_name}]" + + async def fake_url(url): + calls["url"] = url + return "CTX[url-fallback]" + + monkeypatch.setattr(flow.repo, "fetch_selected_repo_context", fake_selected) + monkeypatch.setattr(flow.repo, "fetch_repo_context", fake_url) + + s = state.Session(id="t", created=0.0, project_type="existing", + selected_repos=["o/a", "o/b"], github_token="tok", + repo_url="https://github.com/o/c") + _run(flow._ensure_repo_context(s)) + + # Both picked repos fetched with the server-side token, plus the URL fallback. + assert calls["selected"] == [("o/a", "tok"), ("o/b", "tok")] + assert calls["url"] == "https://github.com/o/c" + assert "CTX[o/a]" in s.repo_context + assert "CTX[o/b]" in s.repo_context + assert "CTX[url-fallback]" in s.repo_context + + +def test_ensure_repo_context_respects_token_budget(monkeypatch): + monkeypatch.setattr(config, "REPO_CONTEXT_MAX_CHARS", 10) + + async def fake_selected(full_name, token): + return "X" * 100 # exceeds the whole budget on its own + + async def fake_url(url): # must never be reached once the budget is spent + raise AssertionError("repo_url fallback should not run when budget is 0") + + monkeypatch.setattr(flow.repo, "fetch_selected_repo_context", fake_selected) + monkeypatch.setattr(flow.repo, "fetch_repo_context", fake_url) + + s = state.Session(id="t", created=0.0, project_type="existing", + selected_repos=["o/a"], github_token="tok", + repo_url="https://github.com/o/c") + _run(flow._ensure_repo_context(s)) + assert len(s.repo_context) <= 10 + + +def test_ensure_repo_context_skips_when_not_existing(monkeypatch): + async def boom(*a, **k): + raise AssertionError("should not fetch for non-existing projects") + + monkeypatch.setattr(flow.repo, "fetch_selected_repo_context", boom) + monkeypatch.setattr(flow.repo, "fetch_repo_context", boom) + s = state.Session(id="t", created=0.0, project_type="new", + selected_repos=["o/a"], repo_url="https://github.com/o/c") + _run(flow._ensure_repo_context(s)) + assert s.repo_context == "" From 43a6d3fb14c1f41ff7aaf2201bd00946995d3616 Mon Sep 17 00:00:00 2001 From: readwrightexecute Date: Thu, 25 Jun 2026 21:53:16 +0000 Subject: [PATCH 07/13] feat: add Atlassian (Jira) OAuth 2.0 / 3LO "Sign in with Atlassian" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the GitHub login on the shared server-side auth store (app/auth.py) and audit log (app/audit.py): the Atlassian access + refresh tokens, expiry, and resolved cloudId/site live server-side only; the browser keeps just the opaque signed session id. - config: INCIPIT_ATLASSIAN_OAUTH_CLIENT_ID/SECRET/REDIRECT_URL/SCOPES - routes: GET /auth/atlassian/login (audience=api.atlassian.com, response_type=code, prompt=consent, CSRF state, scope + offline_access), GET /auth/atlassian/callback (token exchange + accessible-resources → cache cloudId + site), POST /auth/atlassian/logout (revoke + audit) - refresh_atlassian_token(): refresh via refresh_token near/after expiry, raising AtlassianAuthError on failure for the re-authorize path - UI: "Sign in with Atlassian" button + connected-site chip on step6_final - tests: login redirect (offline_access + state), callback stores token/cloudId with hardened cookie, refresh + failure paths, logout Also add respx to requirements-dev.txt (the OAuth tests mock httpx). Co-authored-by: Cursor --- app/config.py | 20 +++ app/main.py | 192 +++++++++++++++++++++++- app/templates/base.html | 15 ++ app/templates/step6_final.html | 19 +++ requirements-dev.txt | 2 + tests/test_jira_auth.py | 263 +++++++++++++++++++++++++++++++++ 6 files changed, 509 insertions(+), 2 deletions(-) create mode 100644 tests/test_jira_auth.py diff --git a/app/config.py b/app/config.py index 82ccb63..25995ad 100644 --- a/app/config.py +++ b/app/config.py @@ -127,6 +127,26 @@ def _reasoning_effort_default() -> str: "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") + # 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, diff --git a/app/main.py b/app/main.py index 252aedd..3e0cac9 100644 --- a/app/main.py +++ b/app/main.py @@ -2,6 +2,7 @@ import html import json import logging +import time from pathlib import Path from urllib.parse import urlencode @@ -144,6 +145,18 @@ def current_auth(request: Request) -> auth.AuthRecord | None: return auth.get_auth(_read_sid(request)) +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": bool(config.ATLASSIAN_OAUTH_CLIENT_ID), + "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 @@ -226,7 +239,7 @@ 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)) + mega_prompt=flow.assemble_final(s), **_atlassian_ctx(request)) return _render("step1_idea.html", request, **_calibration_ctx()) @@ -612,7 +625,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") @@ -726,6 +739,181 @@ async def github_logout(request: Request): return resp +# ---- 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 config.ATLASSIAN_OAUTH_CLIENT_ID: + 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: + # User denied, or Atlassian returned an error — back to where they were. + return RedirectResponse(return_to, status_code=302) + + 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 PlainTextResponse("Atlassian did not return an access token.", + status_code=400) + # 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 [] + + first = resources[0] if isinstance(resources, list) and resources else {} + 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", "")) + 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"}) + + +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") + 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") + data = tok.json() + 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/templates/base.html b/app/templates/base.html index c588049..c66aeb1 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -198,6 +198,21 @@ main.container { padding-bottom: 5.5rem; } /* clear the fixed action bar */ .final-actions { display: flex; justify-content: space-between; align-items: center; gap: 1rem; flex-wrap: wrap; margin-bottom: 1rem; } .final-actions button, .final-actions a[role="button"] { margin-bottom: 0; } + /* ---- Jira export (final step) ---- */ + .jira-export { margin-top: 1.25rem; padding: .9rem 1rem; border: 1px solid var(--pico-card-border-color); + border-radius: var(--pico-border-radius); background: var(--pico-card-background-color); } + .jira-heading { font-size: 1rem; margin: 0 0 .5rem; } + .jira-hint { color: var(--pico-muted-color); font-size: var(--fs-sm); margin: 0 0 .6rem; } + .jira-chip { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; font-size: var(--fs-sm); margin: 0 0 .6rem; } + .jira-dot { width: .55rem; height: .55rem; border-radius: 50%; background: var(--success); flex: 0 0 auto; } + .jira-disconnect { margin: 0 0 0 auto; width: auto; padding: .15rem .7rem; font-size: var(--fs-xs); } + .jira-controls { display: flex; gap: .5rem; 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: .7rem; font-size: var(--fs-sm); } + .jira-result.ok { color: var(--success); } + .jira-result.error { color: var(--danger); } /* State reads as a full-border + faint tint on the inner card, not a side stripe. */ .change-card article { transition: border-color .15s, background .15s; } .change-card.applied article { border-color: color-mix(in srgb, var(--success) 50%, var(--pico-card-border-color)); diff --git a/app/templates/step6_final.html b/app/templates/step6_final.html index 9fbcd2d..45b3b1f 100644 --- a/app/templates/step6_final.html +++ b/app/templates/step6_final.html @@ -9,6 +9,25 @@

Your Super-Prompt

New session
{{ mega_prompt }}
+ + {% if atlassian_configured %} +
+

Export to Jira

+ {% if atlassian_connected %} +

+ + Connected to Atlassian{% if atlassian_site %} · {{ atlassian_site }}{% endif %} + +

+ {% block jira_export_controls %}{% endblock %} + {% else %} +

Sign in with your own Atlassian account to push this brief into a Jira issue.

+ Sign in with Atlassian + {% endif %} +
+ {% 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/step6_final.html b/app/templates/step6_final.html index 45b3b1f..0f5a922 100644 --- a/app/templates/step6_final.html +++ b/app/templates/step6_final.html @@ -20,7 +20,11 @@

Export to Jira

- {% block jira_export_controls %}{% endblock %} +
+

Loading your Jira projects +

+
{% else %}

Sign in with your own Atlassian account to push this brief into a Jira issue.

Date: Thu, 25 Jun 2026 22:05:24 +0000 Subject: [PATCH 09/13] chore: add CI workflow + scoped coverage gate + env-var reference - pytest.ini: enable pytest-cov with a 90% --cov-fail-under gate, scoped to the security-critical, fully-offline-testable modules (app/auth.py, app/audit.py, app/jira.py, app/markdown_adf.py). A 90% gate over all of `app` is impractical because the LLM/diffusion backends and wizard orchestration call out to a model/subprocess and aren't exercised offline; the auth/export routes live in app/main.py (alongside every wizard route, so they can't be isolated per-file) but are covered by the OAuth/export tests. - requirements-dev.txt: pin pytest-cov==7.1.0 (respx already added). - .github/workflows/ci.yml: Python 3.11, install deps, run pytest + coverage. - README: consolidated INCIPIT_* env-var reference table (covers the GitHub + Atlassian OAuth and Jira export vars so a Doppler config can be populated end-to-end), a "Sign in with Atlassian (Jira export)" usage section, and a "Testing & coverage" section documenting the scoping. - tests: auth-store edge-case tests (lifts app/auth.py to 100%). Scoped modules report 100% coverage; full suite is 177 passed, 1 xfailed. Co-authored-by: Cursor --- .github/workflows/ci.yml | 32 ++++++++++++++ .gitignore | 3 ++ README.md | 92 ++++++++++++++++++++++++++++++++++++++-- pytest.ini | 10 ++++- requirements-dev.txt | 2 + tests/test_auth.py | 41 ++++++++++++++++++ 6 files changed, 176 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/ci.yml 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..a200aa6 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ __pycache__/ .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/README.md b/README.md index bdadc3a..e280d9b 100644 --- a/README.md +++ b/README.md @@ -118,9 +118,95 @@ session id (`HttpOnly` + `Secure` + `SameSite=Strict`). Configure the OAuth app: 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. -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. The repo does ship -an offline `pytest` suite (`pip install -r requirements-dev.txt && pytest`). +### 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)_ | +| `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`. --- diff --git a/pytest.ini b/pytest.ini index c34e94f..4ccbada 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,4 +1,12 @@ [pytest] testpaths = tests pythonpath = . -addopts = -ra +# Coverage is scoped to the security-critical, fully-offline-testable modules +# (auth/token store, audit log, Jira REST client, markdown→ADF) rather than the +# whole `app` package: the LLM/diffusion backends and wizard orchestration call +# out to a model/subprocess and aren't exercised 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) and so can't be isolated per-file; +# they're covered by tests/test_jira_auth.py, tests/test_auth.py and +# tests/test_jira_export.py. See README "Testing & coverage". +addopts = -ra --cov=app.auth --cov=app.audit --cov=app.jira --cov=app.markdown_adf --cov-report=term-missing --cov-fail-under=90 diff --git a/requirements-dev.txt b/requirements-dev.txt index 0c6b5f1..0041899 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -4,3 +4,5 @@ pytest==9.1.1 # Mocked HTTP transport for httpx (OAuth login/callback + Jira REST tests). respx==0.22.0 +# Coverage gate (see pytest.ini addopts / .github/workflows/ci.yml). +pytest-cov==7.1.0 diff --git a/tests/test_auth.py b/tests/test_auth.py index 263f9fc..3f0d234 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -6,6 +6,7 @@ """ import logging +import time from urllib.parse import parse_qs, urlparse import httpx @@ -188,3 +189,43 @@ def test_auth_store_state_rejects_wrong_provider(): rec = auth.create_auth() state = auth.new_state(rec, "github", "/") assert auth.pop_state(rec, state, "atlassian") is None + + +def test_auth_store_pop_state_empty_is_none(): + rec = auth.create_auth() + assert auth.pop_state(rec, "", "github") is None + + +def test_get_auth_none_and_unknown_returns_none(): + assert auth.get_auth(None) is None + assert auth.get_auth("does-not-exist") is None + + +def test_get_auth_expired_is_evicted(monkeypatch): + rec = auth.create_auth() + # Backdate the record beyond the TTL: get_auth should evict and return None. + rec.created = time.time() - config.SESSION_TTL - 1 + assert auth.get_auth(rec.id) is None + assert auth.get_auth(rec.id) is None # already removed + + +def test_sweep_drops_expired_records(): + rec = auth.create_auth() + rec.created = time.time() - config.SESSION_TTL - 1 + auth.create_auth() # triggers a sweep on insert + assert auth.get_auth(rec.id) is None + + +def test_get_provider_resolves_via_session_id(): + rec = auth.create_auth() + auth.set_provider(rec, "github", access_token="t", user_login="octocat") + assert auth.get_provider(rec.id, "github").user_login == "octocat" + assert auth.get_provider(rec.id, "atlassian") is None + assert auth.get_provider(None, "github") is None + + +def test_revoke_whole_record_drops_session(): + rec = auth.create_auth() + auth.set_provider(rec, "github", access_token="t") + assert auth.revoke(rec, None) is None + assert auth.get_auth(rec.id) is None From 6053d3c6743a86f5f09872246b9d4a5e70258e5b Mon Sep 17 00:00:00 2001 From: readwrightexecute Date: Thu, 25 Jun 2026 22:34:27 +0000 Subject: [PATCH 10/13] fix(auth): use SameSite=Lax for session cookie so OAuth callbacks work SameSite=Strict prevented the signed session cookie from being sent on the top-level cross-site redirect back from github.com / auth.atlassian.com to /auth/*/callback, so the server could not recover the session id to validate the OAuth `state` (login failed with "invalid/expired state"). Lax is sent on top-level cross-site GET navigations while still being withheld from cross-site subrequests; the access token remains HttpOnly + server-side regardless. Co-authored-by: Cursor --- README.md | 2 +- app/main.py | 12 ++++++++++-- tests/test_auth.py | 3 ++- tests/test_jira_auth.py | 3 ++- 4 files changed, 15 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index e280d9b..0c28abc 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ only to read your private repos for grounding; it does not gate the app. 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=Strict`). Configure the OAuth app: +session id (`HttpOnly` + `Secure` + `SameSite=Lax`). Configure the OAuth app: | Env var | What | |---|---| diff --git a/app/main.py b/app/main.py index 8e49dd4..e84c037 100644 --- a/app/main.py +++ b/app/main.py @@ -128,11 +128,19 @@ def _read_sid(request: Request) -> str | None: def _set_auth_cookie(response: Response, sid: str) -> None: """Attach the signed session-id cookie with the hardened flags - (HttpOnly + SameSite=Strict, Secure unless explicitly disabled for dev).""" + (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="strict", path="/", + secure=config.COOKIE_SECURE, samesite="lax", path="/", ) diff --git a/tests/test_auth.py b/tests/test_auth.py index 3f0d234..1b51eb9 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -67,7 +67,8 @@ def test_login_sets_hardened_cookie(monkeypatch): low = set_cookie.lower() assert "httponly" in low assert "secure" in low - assert "samesite=strict" in low + # Lax (not Strict) so the cookie survives the cross-site OAuth callback. + assert "samesite=lax" in low assert "path=/" in low diff --git a/tests/test_jira_auth.py b/tests/test_jira_auth.py index 9f84f66..6e2c62b 100644 --- a/tests/test_jira_auth.py +++ b/tests/test_jira_auth.py @@ -77,7 +77,8 @@ def test_login_sets_hardened_cookie(monkeypatch): assert "incipit_auth=" in low assert "httponly" in low assert "secure" in low - assert "samesite=strict" in low + # Lax (not Strict) so the cookie survives the cross-site OAuth callback. + assert "samesite=lax" in low assert "path=/" in low From 80ba763afaa1e8e26324c05a8d542d69ad2aee87 Mon Sep 17 00:00:00 2001 From: readwrightexecute Date: Wed, 15 Jul 2026 14:00:25 +0000 Subject: [PATCH 11/13] fix: harden OAuth and Jira integrations Keep provider credentials lifecycle-safe, prevent ambiguous Jira retries, and preserve reusable WebUI configuration aliases. Co-authored-by: Cursor --- .env.example | 5 + .gitignore | 1 + README.md | 3 + app/auth.py | 4 + app/config.py | 29 ++- app/jira.py | 51 ++++- app/main.py | 267 ++++++++++++++--------- app/settings.py | 8 + app/templates/partials/github_repos.html | 3 +- app/templates/step1_idea.html | 4 +- app/wizard/flow.py | 8 +- app/wizard/state.py | 2 +- tests/test_auth.py | 29 ++- tests/test_github_repos.py | 8 +- tests/test_jira_auth.py | 19 +- tests/test_jira_export.py | 27 ++- tests/test_settings.py | 34 ++- 17 files changed, 355 insertions(+), 147 deletions(-) diff --git a/.env.example b/.env.example index cdf6c0e..15caec6 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,11 @@ INCIPIT_BACKEND=openai 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. INCIPIT_DISABLE_THINKING= diff --git a/.gitignore b/.gitignore index a200aa6..9bfcf72 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ __pycache__/ *.pyc .promptgen.json +.incipit.json .env .venv/ .pytest_cache/ diff --git a/README.md b/README.md index 0c28abc..9e33aaf 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,9 @@ are flagged — never commit them. | `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)_ | diff --git a/app/auth.py b/app/auth.py index 466bfce..e8c3134 100644 --- a/app/auth.py +++ b/app/auth.py @@ -11,6 +11,7 @@ the same store and cookie. """ +import asyncio import secrets import time import uuid @@ -42,6 +43,9 @@ class AuthRecord: 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) diff --git a/app/config.py b/app/config.py index 6d7f0e4..e494142 100644 --- a/app/config.py +++ b/app/config.py @@ -3,6 +3,7 @@ import logging import os import shlex +from urllib.parse import urlparse try: from dotenv import load_dotenv @@ -35,6 +36,19 @@ def _bool(name: str, default: bool) -> bool: 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 @@ -70,11 +84,16 @@ def _bool(name: str, default: bool) -> bool: 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("INCIPIT_OPENAI_BASE_URL", "http://localhost:11434/v1") -OPENAI_MODEL = os.environ.get("INCIPIT_OPENAI_MODEL", "") -OPENAI_API_KEY = os.environ.get("INCIPIT_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) diff --git a/app/jira.py b/app/jira.py index aa22481..2a4111b 100644 --- a/app/jira.py +++ b/app/jira.py @@ -23,6 +23,10 @@ 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" @@ -51,10 +55,19 @@ async def list_projects(cloud_id: str, token: str, *, headers=_headers(token)) as c: start = 0 for _ in range(_MAX_PROJECT_PAGES): - r = await c.get(url, params={"startAt": start, "maxResults": _PROJECT_PAGE}) + 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}") - data = r.json() + 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", ""), @@ -79,11 +92,19 @@ async def create_issue(cloud_id: str, token: str, *, project_key: str, "description": description_adf, }} headers = {**_headers(token), "Content-Type": "application/json"} - async with httpx.AsyncClient(timeout=_timeout(timeout), headers=headers) as c: - r = await c.post(url, json=payload) + 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)}") - data = r.json() + 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", ""))} @@ -97,11 +118,19 @@ async def upload_attachment(cloud_id: str, token: str, issue_key: str, 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")} - async with httpx.AsyncClient(timeout=_timeout(timeout), headers=headers) as c: - r = await c.post(url, files=files) + 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)}") - return r.json() + 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: diff --git a/app/main.py b/app/main.py index 4d5c058..38b69e1 100644 --- a/app/main.py +++ b/app/main.py @@ -4,7 +4,7 @@ import logging import time from pathlib import Path -from urllib.parse import urlencode +from urllib.parse import urlencode, urlsplit import httpx from fastapi import FastAPI, Form, Request @@ -156,38 +156,46 @@ def _set_auth_cookie(response: Response, sid: str) -> None: ) -def _clear_auth_cookie(response: Response) -> None: - response.delete_cookie(AUTH_COOKIE, 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": bool(config.GITHUB_OAUTH_CLIENT_ID), + "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: - """Copy the user's picked private repos + their GitHub token onto the - session (server-side) so the wizard can ground drafting in them. The token - never goes back to the browser; only the opaque session cookie does.""" + """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()] + 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_token = entry.access_token + s.github_auth_id = rec.id def _atlassian_ctx(request: Request) -> dict: @@ -196,7 +204,7 @@ def _atlassian_ctx(request: Request) -> dict: entry = rec.provider("atlassian") if rec else None meta = entry.meta if entry else {} return { - "atlassian_configured": bool(config.ATLASSIAN_OAUTH_CLIENT_ID), + "atlassian_configured": _atlassian_oauth_configured(), "atlassian_connected": entry is not None, "atlassian_site": meta.get("site_name") or meta.get("site_url", ""), } @@ -303,7 +311,7 @@ async def go_back(request: Request, sid: str, to: str): # 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(), - **_github_ctx(request)) + 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) @@ -724,15 +732,22 @@ async def download(sid: str): def _safe_return_to(value: str | None) -> str: - """Only allow same-app relative redirects (no open-redirect via //host).""" - if value and value.startswith("/") and not value.startswith("//"): + """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 "/" @app.get("/auth/github/login") async def github_login(request: Request, return_to: str = "/"): - if not config.GITHUB_OAUTH_CLIENT_ID: + 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() @@ -764,25 +779,31 @@ async def github_callback(request: Request, code: str = "", state: str = "", # User denied, or GitHub returned an error — back to where they started. return RedirectResponse(return_to, status_code=302) - 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 {} + 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", ""), @@ -791,6 +812,7 @@ async def github_callback(request: Request, code: str = "", state: str = "", 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 @@ -804,14 +826,12 @@ async def github_logout(request: Request): if entry is not None: audit.token_revoked("github", user_id=entry.user_id, user_login=entry.user_login) - # Clear the cookie and tell HTMX to refresh so the UI reflects logged-out. - resp = Response(status_code=204, headers={"HX-Refresh": "true"}) - _clear_auth_cookie(resp) - return resp + # 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): +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.""" @@ -829,7 +849,15 @@ async def github_repos(request: Request): _html("partials/github_error.html", reason="GitHub rejected your session (the token expired or was revoked)."), status_code=401) - return _render("partials/github_repos.html", request, repos=repos) + 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 --------------------------------- @@ -865,7 +893,7 @@ def _atlassian_scope() -> str: @app.get("/auth/atlassian/login") async def atlassian_login(request: Request, return_to: str = "/"): - if not config.ATLASSIAN_OAUTH_CLIENT_ID: + 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)) @@ -898,27 +926,40 @@ async def atlassian_callback(request: Request, code: str = "", state: str = "", # User denied, or Atlassian returned an error — back to where they were. return RedirectResponse(return_to, status_code=302) - 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 PlainTextResponse("Atlassian did not return an access token.", - status_code=400) - # 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 [] - - first = resources[0] if isinstance(resources, list) and resources else {} + 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 PlainTextResponse("Atlassian did not return an access token.", + status_code=400) + # 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 PlainTextResponse( + "Atlassian authentication could not be completed. Please try again.", + status_code=502) + + 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 PlainTextResponse( + "Atlassian returned no accessible Jira sites.", status_code=400) + first = valid_resources[0] meta = { "cloud_id": first.get("id", ""), "site_url": first.get("url", ""), @@ -933,6 +974,7 @@ async def atlassian_callback(request: Request, code: str = "", state: str = "", 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 @@ -986,10 +1028,12 @@ async def jira_projects(request: Request, sid: str = ""): 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. Re-authorize and try again."), - status_code=401) + 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) @@ -1019,17 +1063,16 @@ async def jira_export(request: Request, sid: str = Form(...), budget = config.JIRA_EXPORT_TIMEOUT_MS / 1000 try: - result = await asyncio.wait_for( - _export_to_jira(cloud_id, entry.access_token, site_url, - project_key, summary, issue_type, adf, md), - timeout=budget) - except asyncio.TimeoutError: - return _render("partials/jira_result.html", request, ok=False, - message=(f"Export exceeded the {config.JIRA_EXPORT_TIMEOUT_MS}ms " - "budget. Nothing partial was reported — please retry.")) + 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=f"Jira rejected the export: {e}") + 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"]) @@ -1038,16 +1081,20 @@ async def jira_export(request: Request, sid: str = Form(...), async def _export_to_jira(cloud_id, token, site_url, project_key, summary, - issue_type, adf, md) -> dict: + 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) + issue_type=issue_type, description_adf=adf, timeout=timeout) key = issue["key"] attached = True try: - await jira.upload_attachment(cloud_id, token, key, "mega-prompt.md", md) + 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 @@ -1079,36 +1126,44 @@ async def refresh_atlassian_token(rec: auth.AuthRecord | None) -> auth.ProviderE entry = rec.provider("atlassian") if rec else None if entry is None: raise AtlassianAuthError("not signed in to Atlassian") - if not _atlassian_token_expiring(entry): + 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 - 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") - data = tok.json() - 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") diff --git a/app/settings.py b/app/settings.py index ea86529..aa019d7 100644 --- a/app/settings.py +++ b/app/settings.py @@ -53,6 +53,9 @@ def allowed_base_url_hosts() -> set[str]: seeded_host = _hostname(config.OPENAI_BASE_URL) if seeded_host: hosts.add(seeded_host) + 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.") @@ -77,6 +82,9 @@ def normalize_base_url(base_url: str) -> str: f"Endpoint host '{host}' is not allowed. " "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 diff --git a/app/templates/partials/github_repos.html b/app/templates/partials/github_repos.html index 7ada092..d8e4f8f 100644 --- a/app/templates/partials/github_repos.html +++ b/app/templates/partials/github_repos.html @@ -5,7 +5,8 @@
{% for r in repos %} diff --git a/app/templates/step1_idea.html b/app/templates/step1_idea.html index 5eb973b..9c0a407 100644 --- a/app/templates/step1_idea.html +++ b/app/templates/step1_idea.html @@ -33,7 +33,9 @@

Step 1 of 3 — Brain dump & calibra Log out -
+
Loading your repositories…
{% else %} diff --git a/app/wizard/flow.py b/app/wizard/flow.py index 12b1338..eae8f34 100644 --- a/app/wizard/flow.py +++ b/app/wizard/flow.py @@ -10,7 +10,7 @@ import yaml from jinja2 import Environment, FileSystemLoader -from app import config, repo +from app import auth, config, repo from app.llm.base import GenerationError, get_backend from app.wizard import state from app.wizard.state import QA, PartyChange, PartyMessage, PartyQAChange, Section, Session @@ -80,10 +80,12 @@ async def _ensure_repo_context(s: Session) -> None: ' Reading the repo…') budget = config.REPO_CONTEXT_MAX_CHARS parts: list[str] = [] - for full_name in s.selected_repos: + github = auth.get_provider(s.github_auth_id, "github") + github_token = github.access_token if github else "" + for full_name in s.selected_repos if github_token else []: if budget <= 0: break - ctx = (await repo.fetch_selected_repo_context(full_name, s.github_token))[:budget] + ctx = (await repo.fetch_selected_repo_context(full_name, github_token))[:budget] if ctx: parts.append(ctx) budget -= len(ctx) diff --git a/app/wizard/state.py b/app/wizard/state.py index 7457068..a65903d 100644 --- a/app/wizard/state.py +++ b/app/wizard/state.py @@ -75,7 +75,7 @@ class Session: repo_url: str = "" # existing projects: link to the codebase repo_context: str = "" # fetched repo summary injected into drafting prompts selected_repos: list[str] = field(default_factory=list) # private repos (owner/name) picked after GitHub login - github_token: str = "" # the picker's OAuth token, copied server-side at session create (never sent to the browser) + github_auth_id: str = "" # auth-store reference; OAuth credentials stay in app.auth qas: list[QA] = field(default_factory=list) sections: list[Section] = field(default_factory=list) phase: str = "idea" # idea | clarify | sections | moonshot | final diff --git a/tests/test_auth.py b/tests/test_auth.py index 1b51eb9..6375e9d 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -59,6 +59,7 @@ def test_login_redirects_to_github_authorize(client): def test_login_sets_hardened_cookie(monkeypatch): # Secure flag is on by default; assert the full flag set on the raw header. monkeypatch.setattr(config, "GITHUB_OAUTH_CLIENT_ID", "test-client-id") + monkeypatch.setattr(config, "GITHUB_OAUTH_CLIENT_SECRET", "test-secret") monkeypatch.setattr(config, "COOKIE_SECURE", True) c = TestClient(main.app) resp = c.get("/auth/github/login", follow_redirects=False) @@ -73,7 +74,8 @@ def test_login_sets_hardened_cookie(monkeypatch): def test_login_not_configured_returns_503(monkeypatch): - monkeypatch.setattr(config, "GITHUB_OAUTH_CLIENT_ID", "") + monkeypatch.setattr(config, "GITHUB_OAUTH_CLIENT_ID", "test-client-id") + monkeypatch.setattr(config, "GITHUB_OAUTH_CLIENT_SECRET", "") c = TestClient(main.app) resp = c.get("/auth/github/login", follow_redirects=False) assert resp.status_code == 503 @@ -149,10 +151,21 @@ def test_callback_with_error_param_redirects_back(client): assert resp.headers["location"] == "/sessions/xyz" +@pytest.mark.parametrize("unsafe", [r"/\evil.example", "//evil.example", "https://evil.example"]) +def test_login_rejects_unsafe_return_to(client, unsafe): + _, state = _start_login(client, return_to=unsafe) + rec = auth.get_auth(_sid_from_jar(client)) + assert auth.pop_state(rec, state, "github")["return_to"] == "/" + + +def test_safe_return_to_rejects_malformed_authority(): + assert main._safe_return_to("//[") == "/" + + # --- logout ------------------------------------------------------------------ @respx.mock -def test_logout_revokes_token_and_clears_cookie(client, caplog): +def test_logout_revokes_only_github_and_keeps_shared_cookie(client, caplog): respx.post(main.GITHUB_TOKEN_URL).mock( return_value=httpx.Response(200, json={"access_token": "t", "scope": "repo"})) respx.get(main.GITHUB_USER_URL).mock( @@ -160,19 +173,19 @@ def test_logout_revokes_token_and_clears_cookie(client, caplog): _, state = _start_login(client) client.get(f"/auth/github/callback?code=abc&state={state}", follow_redirects=False) sid = _sid_from_jar(client) - assert auth.get_auth(sid).provider("github") is not None + rec = auth.get_auth(sid) + assert rec.provider("github") is not None + auth.set_provider(rec, "atlassian", access_token="atl", refresh_token="refresh") with caplog.at_level(logging.INFO, logger="promptgen.audit"): resp = client.post("/auth/github/logout") assert resp.status_code == 204 assert resp.headers.get("HX-Refresh") == "true" - # Cookie cleared (Max-Age=0 / past expiry). - set_cookie = resp.headers["set-cookie"].lower() - assert "incipit_auth=" in set_cookie - assert "max-age=0" in set_cookie or "expires=" in set_cookie - # Server-side token gone, revocation audited. + assert "set-cookie" not in resp.headers + # GitHub is gone, but the shared session and Atlassian provider remain. assert auth.get_auth(sid).provider("github") is None + assert auth.get_auth(sid).provider("atlassian").access_token == "atl" assert "token_revoked" in caplog.text diff --git a/tests/test_github_repos.py b/tests/test_github_repos.py index ade000a..fc65ce6 100644 --- a/tests/test_github_repos.py +++ b/tests/test_github_repos.py @@ -180,8 +180,10 @@ async def fake_url(url): monkeypatch.setattr(flow.repo, "fetch_selected_repo_context", fake_selected) monkeypatch.setattr(flow.repo, "fetch_repo_context", fake_url) + rec = auth.create_auth() + auth.set_provider(rec, "github", access_token="tok") s = state.Session(id="t", created=0.0, project_type="existing", - selected_repos=["o/a", "o/b"], github_token="tok", + selected_repos=["o/a", "o/b"], github_auth_id=rec.id, repo_url="https://github.com/o/c") _run(flow._ensure_repo_context(s)) @@ -205,8 +207,10 @@ async def fake_url(url): # must never be reached once the budget is spent monkeypatch.setattr(flow.repo, "fetch_selected_repo_context", fake_selected) monkeypatch.setattr(flow.repo, "fetch_repo_context", fake_url) + rec = auth.create_auth() + auth.set_provider(rec, "github", access_token="tok") s = state.Session(id="t", created=0.0, project_type="existing", - selected_repos=["o/a"], github_token="tok", + selected_repos=["o/a"], github_auth_id=rec.id, repo_url="https://github.com/o/c") _run(flow._ensure_repo_context(s)) assert len(s.repo_context) <= 10 diff --git a/tests/test_jira_auth.py b/tests/test_jira_auth.py index 6e2c62b..6206a77 100644 --- a/tests/test_jira_auth.py +++ b/tests/test_jira_auth.py @@ -70,6 +70,7 @@ def test_login_redirects_to_atlassian_authorize(client): def test_login_sets_hardened_cookie(monkeypatch): monkeypatch.setattr(config, "ATLASSIAN_OAUTH_CLIENT_ID", "test-atl-id") + monkeypatch.setattr(config, "ATLASSIAN_OAUTH_CLIENT_SECRET", "test-secret") monkeypatch.setattr(config, "COOKIE_SECURE", True) c = TestClient(main.app) resp = c.get("/auth/atlassian/login", follow_redirects=False) @@ -83,7 +84,8 @@ def test_login_sets_hardened_cookie(monkeypatch): def test_login_not_configured_returns_503(monkeypatch): - monkeypatch.setattr(config, "ATLASSIAN_OAUTH_CLIENT_ID", "") + monkeypatch.setattr(config, "ATLASSIAN_OAUTH_CLIENT_ID", "test-atl-id") + monkeypatch.setattr(config, "ATLASSIAN_OAUTH_CLIENT_SECRET", "") c = TestClient(main.app) resp = c.get("/auth/atlassian/login", follow_redirects=False) assert resp.status_code == 503 @@ -146,6 +148,21 @@ def test_callback_sends_authorization_code_grant_with_secret(client): assert sent["redirect_uri"] == "https://app.example/auth/atlassian/callback" +@respx.mock +def test_callback_rejects_empty_accessible_resources(client): + respx.post(main.ATLASSIAN_TOKEN_URL).mock( + return_value=httpx.Response(200, json={ + "access_token": "t", "refresh_token": "r", "expires_in": 3600})) + respx.get(main.ATLASSIAN_RESOURCES_URL).mock( + return_value=httpx.Response(200, json=[])) + _, state = _start_login(client) + resp = client.get( + f"/auth/atlassian/callback?code=abc&state={state}", + follow_redirects=False) + assert resp.status_code == 400 + assert auth.get_auth(_sid_from_jar(client)).provider("atlassian") is None + + def test_callback_rejects_invalid_state(client): _start_login(client) resp = client.get("/auth/atlassian/callback?code=abc&state=wrong", diff --git a/tests/test_jira_export.py b/tests/test_jira_export.py index 04d03de..d83a547 100644 --- a/tests/test_jira_export.py +++ b/tests/test_jira_export.py @@ -84,6 +84,15 @@ def test_create_issue_error_raises(): issue_type="Task", description_adf={"type": "doc"})) +@respx.mock +def test_create_issue_transport_error_is_wrapped(): + respx.post(f"{_REST}/issue").mock(side_effect=httpx.ConnectError("offline")) + with pytest.raises(jira.JiraError, match="transport failure"): + _run(jira.create_issue( + _CLOUD, "tok", project_key="ABC", summary="x", + issue_type="Task", description_adf={"type": "doc"})) + + @respx.mock def test_upload_attachment_multipart_and_header(): route = respx.post(f"{_REST}/issue/ABC-7/attachments").mock( @@ -204,21 +213,25 @@ def test_export_unauthenticated_returns_401(monkeypatch): assert "Re-authorize" in resp.text -@respx.mock -def test_export_time_budget_overrun_reports_clearly(monkeypatch): +def test_export_attachment_timeout_reports_created_issue(monkeypatch): monkeypatch.setattr(config, "JIRA_EXPORT_TIMEOUT_MS", 50) - async def _slow(*a, **k): - await asyncio.sleep(0.5) # exceeds the 50ms budget - return {"key": "ABC-1", "url": "x", "attached": True} + async def _created(*a, **k): + return {"key": "ABC-1", "id": "1"} + + async def _attachment_timeout(*a, **k): + assert 0 < k["timeout"] <= 0.05 + raise jira.JiraError("attachment upload transport failure: timed out") - monkeypatch.setattr(main, "_export_to_jira", _slow) + monkeypatch.setattr(jira, "create_issue", _created) + monkeypatch.setattr(jira, "upload_attachment", _attachment_timeout) c = _atlassian_client(monkeypatch) s = _final_session() resp = c.post("/api/jira/export", data={"sid": s.id, "project_key": "ABC", "issue_type": "Task"}) assert resp.status_code == 200 - assert "budget" in resp.text.lower() + assert "ABC-1" in resp.text + assert "failed" in resp.text.lower() def test_export_missing_project_reports(monkeypatch): diff --git a/tests/test_settings.py b/tests/test_settings.py index 03cb4b3..4853502 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -7,7 +7,7 @@ import pytest -from app import settings +from app import config, settings from app.settings import ( REASONING_EFFORTS, SettingsError, @@ -23,6 +23,7 @@ def _clear_allowlist_env(monkeypatch): (localhost, 127.0.0.1, ::1, api.openai.com) plus the env-seeded base URL host. Tests opt extra hosts in explicitly.""" monkeypatch.delenv("INCIPIT_ALLOWED_BASE_URL_HOSTS", raising=False) + monkeypatch.delenv("WEBUI_API_URL", raising=False) # --- allowed_base_url_hosts ------------------------------------------------- @@ -72,6 +73,37 @@ def test_normalize_accepts_env_added_host(monkeypatch): assert normalize_base_url("https://my.endpoint.com/v1") == "https://my.endpoint.com/v1" +def test_webui_alias_normalizes_origin_and_allows_host(monkeypatch): + monkeypatch.setenv("WEBUI_API_URL", "https://webui.example.com") + monkeypatch.setattr(config, "WEBUI_API_BASE", "https://webui.example.com/api") + assert normalize_base_url("https://webui.example.com") == \ + "https://webui.example.com/api" + + +def test_webui_completions_url_is_trimmed(monkeypatch): + monkeypatch.setenv("WEBUI_API_URL", "https://webui.example.com/api/chat/completions") + monkeypatch.setattr(config, "WEBUI_API_BASE", "https://webui.example.com/api") + assert normalize_base_url( + "https://webui.example.com/api/chat/completions" + ) == "https://webui.example.com/api" + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ("https://webui.example.com", "https://webui.example.com/api"), + ("https://webui.example.com/api", "https://webui.example.com/api"), + ( + "https://webui.example.com/api/chat/completions", + "https://webui.example.com/api", + ), + ], +) +def test_webui_env_alias_to_api_base(monkeypatch, raw, expected): + monkeypatch.setenv("WEBUI_API_URL", raw) + assert config._webui_api_base() == expected + + def test_normalize_host_match_is_case_insensitive(): # The host comparison lowercases, but the returned string preserves the # caller's original casing (only trailing slashes are stripped). From 2822dac1e5b5892a2bd175731a7341ddde132628 Mon Sep 17 00:00:00 2001 From: readwrightexecute Date: Wed, 29 Jul 2026 01:57:58 +0000 Subject: [PATCH 12/13] fix: preserve wizard when loading GitHub repos Scope the repository-picker request to its own element so HTMX does not replace the step-one form. Co-authored-by: Cursor --- app/templates/step1_idea.html | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/templates/step1_idea.html b/app/templates/step1_idea.html index 5eb973b..b82762c 100644 --- a/app/templates/step1_idea.html +++ b/app/templates/step1_idea.html @@ -33,7 +33,8 @@

Step 1 of 3 — Brain dump & calibra Log out -
+
Loading your repositories…
{% else %} From c6847aaf0ea91c9520a5981db807608319830f96 Mon Sep 17 00:00:00 2001 From: readwrightexecute Date: Wed, 29 Jul 2026 12:43:06 +0000 Subject: [PATCH 13/13] fix: recover Atlassian OAuth callback failures Return users to their originating brief with a retry message when Atlassian cannot complete authorization, and surface sign-in in the fixed action bar. Co-authored-by: Cursor --- app/main.py | 66 ++++++++++++++++++++++------------ app/templates/step6_final.html | 8 +++-- tests/test_jira_auth.py | 35 +++++++++++++++++- tests/test_jira_export.py | 12 +++++++ 4 files changed, 96 insertions(+), 25 deletions(-) diff --git a/app/main.py b/app/main.py index 4d5c058..59e9c4a 100644 --- a/app/main.py +++ b/app/main.py @@ -287,7 +287,9 @@ 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), **_atlassian_ctx(request)) + 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)) @@ -730,6 +732,17 @@ def _safe_return_to(value: str | None) -> str: 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 config.GITHUB_OAUTH_CLIENT_ID: @@ -895,30 +908,39 @@ async def atlassian_callback(request: Request, code: str = "", state: str = "", 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 Atlassian returned an error — back to where they were. - return RedirectResponse(return_to, status_code=302) + return _atlassian_error_redirect(return_to, "Atlassian sign-in was cancelled or denied.") - 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 PlainTextResponse("Atlassian did not return an access token.", - status_code=400) - # 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 [] + 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): + return _atlassian_error_redirect( + return_to, "Atlassian could not complete the sign-in request." + ) first = resources[0] if isinstance(resources, list) and resources else {} + if not first.get("id"): + return _atlassian_error_redirect( + return_to, "No accessible Jira site was returned by Atlassian." + ) meta = { "cloud_id": first.get("id", ""), "site_url": first.get("url", ""), diff --git a/app/templates/step6_final.html b/app/templates/step6_final.html index 0f5a922..a933e24 100644 --- a/app/templates/step6_final.html +++ b/app/templates/step6_final.html @@ -6,6 +6,9 @@

Your Super-Prompt

Download .md + {% if atlassian_configured and not atlassian_connected %} + Sign in with Atlassian + {% endif %} New session
{{ mega_prompt }}
@@ -27,8 +30,9 @@

Export to Jira

{% else %}

Sign in with your own Atlassian account to push this brief into a Jira issue.

- Sign in with Atlassian + {% if atlassian_error %} + + {% endif %} {% endif %}

{% endif %} diff --git a/tests/test_jira_auth.py b/tests/test_jira_auth.py index 6e2c62b..7d54a94 100644 --- a/tests/test_jira_auth.py +++ b/tests/test_jira_auth.py @@ -164,7 +164,40 @@ def test_callback_with_error_param_redirects_back(client): resp = client.get(f"/auth/atlassian/callback?error=access_denied&state={state}", follow_redirects=False) assert resp.status_code == 302 - assert resp.headers["location"] == "/sessions/zzz" + assert resp.headers["location"] == ( + "/sessions/zzz?atlassian_error=Atlassian+sign-in+was+cancelled+or+denied." + ) + + +@respx.mock +def test_callback_token_failure_returns_to_origin_with_retry_message(client): + respx.post(main.ATLASSIAN_TOKEN_URL).mock( + return_value=httpx.Response(400, json={"error": "invalid_grant"})) + _, state = _start_login(client, return_to="/sessions/xyz") + + resp = client.get(f"/auth/atlassian/callback?code=expired&state={state}", + follow_redirects=False) + + assert resp.status_code == 302 + assert resp.headers["location"] == ( + "/sessions/xyz?atlassian_error=Atlassian+could+not+complete+the+token+exchange." + ) + + +@respx.mock +def test_callback_without_accessible_site_returns_to_origin_with_retry_message(client): + respx.post(main.ATLASSIAN_TOKEN_URL).mock( + return_value=httpx.Response(200, json={"access_token": "token"})) + respx.get(main.ATLASSIAN_RESOURCES_URL).mock(return_value=httpx.Response(200, json=[])) + _, state = _start_login(client, return_to="/sessions/xyz") + + resp = client.get(f"/auth/atlassian/callback?code=abc&state={state}", + follow_redirects=False) + + assert resp.status_code == 302 + assert resp.headers["location"] == ( + "/sessions/xyz?atlassian_error=No+accessible+Jira+site+was+returned+by+Atlassian." + ) @respx.mock diff --git a/tests/test_jira_export.py b/tests/test_jira_export.py index 04d03de..9046797 100644 --- a/tests/test_jira_export.py +++ b/tests/test_jira_export.py @@ -130,6 +130,18 @@ def _final_session(): return s +def test_final_page_places_atlassian_login_in_fixed_action_bar(monkeypatch): + monkeypatch.setattr(config, "ATLASSIAN_OAUTH_CLIENT_ID", "test-atl-id") + s = _final_session() + resp = TestClient(main.app).get(f"/sessions/{s.id}") + + assert resp.status_code == 200 + action_bar = resp.text.split('
', 1)[1].split("
", 1)[0] + assert "Sign in with Atlassian" in action_bar + jira_export = resp.text.split('