Metadata-Version: 2.5
Name: btodos
Version: 0.1.8
Summary: Pull your board tickets by column into a Markdown checklist or a localhost page
Requires-Python: >=3.10
Requires-Dist: jinja2>=3.1
Description-Content-Type: text/markdown

# btodos

Pulls your assigned Jira tickets for the board columns you care about and lists them as a
checklist — either as a Markdown file in your project root (readable in Cursor) or on a
localhost webpage.

Minimal runtime dependency: Jinja2 for HTML templates (Python 3.10+).

Board UI stack (vendored in the package):
- **HTMX** — soft-refresh / swap ticket list HTML from `/board`
- **Alpine.js** — modals, filter dropdowns, review chips, PR tabs
- **Tailwind** — utility CSS layered on `board.css` (`preflight` off)

## Install

From [PyPI](https://pypi.org/project/btodos/) (recommended):

```bash
uv tool install btodos
# or: pip install btodos
```

Commands: `btodos` and `btd` (same CLI).

Upgrade later with:

```bash
btodos self-update          # install latest from PyPI + migrate this workspace .btodos
btodos self-update --check  # compare versions only
btodos migrate              # refresh .btodos only (no package install)
```

`self-update` upgrades the tool, then runs **`btodos migrate`** in the current workspace so
`.btodos/config.json`, `prompts.md` (purpose comments + any new prompt keys), `globals.json`, and
the ticket cache layout match the new package. Existing values are never overwritten — only
missing keys / annotations are added. Restart any running `btodos serve` after upgrading.

### From source (development)

```bash
git clone https://github.com/dev-ahmed/btodos.git
cd btodos
uv sync
uv tool install --force --reinstall .
```

Rebuild Tailwind after template class changes:

```bash
pnpm install
pnpm run build:css   # → src/btodos/static/app.css
```

## Configure

In your workspace root:

```bash
btodos init
```

That writes `.btodos/config.json`:

```json
{
  "baseUrl": "https://your-company.atlassian.net",
  "projectKey": "ABC",
  "columns": ["To Do", "In Progress", "In Review"],
  "email": "you@your-company.com",
  "assignee": ["currentUser()"],
  "outputFile": "JIRA-TODOS.md",
  "port": 4321
}
```

| Key          | Required | Meaning                                                      |
| ------------ | -------- | ------------------------------------------------------------ |
| `baseUrl`    | yes      | Your Jira Cloud site URL                                      |
| `projectKey` | yes      | Project key, e.g. `ABC` from `ABC-123`                        |
| `columns`    | yes      | Status names to include, in display order                     |
| `email`      | yes      | Atlassian account email (Basic auth user)                     |
| `assignee`   | no       | One Jira user (`"currentUser()"`) or a list of teammates. Emails are resolved to account ids via Jira user/assignable search (even when Jira hides emails in the response) and cached in `.btodos/assignees.json`. Account ids and `currentUser()` are used as-is. The web assignee filter is built from whoever appears on the loaded tickets. |
| `outputFile` | no       | Markdown output path, relative to the workspace root             |
| `port`       | no       | Port for `serve` (default `4321`)                             |
| `labels`     | no       | Only include tickets carrying at least one of these labels     |
| `models`     | no       | Ollama models: `{ "recap", "steps", "review" }` — each a name or fallback list (default `llama3.2:3b`; `[]` disables). `steps`/`review` default to `recap`. |
| `ollama`     | no       | Host Ollama limits: `{ "numParallel", "maxLoadedModels" }` (defaults `1`). Apply with `btodos ollama apply`. |
| `ollamaUrl`  | no       | Ollama host (default `http://localhost:11434`)                 |
| `redisUrl`   | no       | Optional Redis URL for the ticket cache (Compose sets `BTODOS_REDIS_URL`) |
| `git`        | no       | Pull-request lookup — see [Pull requests](#pull-requests)        |
| `promptsFile` | no | Prompt templates file under `.btodos/` (default `prompts.md`; also `.json` / `.yaml`) |
| `promptsDir` | no | Directory of per-prompt Markdown files (e.g. `prompts/`). When set and present, loads instead of `promptsFile`. |
| `globalsFile` | no | Shared `{{variables}}` for config/prompts (default `globals.json`) |
| `enrich` | no | Bulk `--enrich` gates: status/label/… filters and/or LLM prompt — see below |
| `notificationsByPriority` | no | When `true`, the 🔔 inbox lists updates by ticket priority (Highest→Lowest), then newest |
| `taskKinds` | no | Closed set of task kinds for board filters, e.g. `["backend","web","mobile"]`. Empty disables. |
| `estimateField` | no | Jira custom field id for story-point / estimate (e.g. `customfield_10016`) — enables useful **order by → estimate** |
| `complexityField` | no | Optional Jira field for complexity; when filled, wins over the LLM |
| `complexities` | no | Rank levels for LLM complexity (default `0.0`…`1.0` in tenths). Empty disables. Edit criteria in `prompts.md` → `# complexity`. |
| `orderBy` | no | Board sort modes to show (default `["priority","estimate","complexity","created"]`). Override or remove entries; `[]` hides the control. Alias: `"time created"` → `created`. |

After upgrading btodos, **`btodos self-update`** (or **`btodos migrate`**) brings `.btodos` up to
date: migrates legacy keys (e.g. top-level `recapPrompt` or inline `prompts`) into
`.btodos/prompts.md`, adds any new defaults, refreshes prompt purpose comments, and when needed
moves `tickets.json` into `.btodos/tickets.bundle`. Existing values are never overwritten.
**`btodos config sync`** does the same config/prompts/cache migration without touching the
installed package.

## The web view

`btodos serve` is interactive:

- **Tick several tickets** and a sticky toolbar appears — pick a target column and hit **Move**.
  Defaults to `In Progress`, for starting the day in one action.
- **⧉ next to each key** copies that ticket number to the clipboard; **Copy keys** in the toolbar
  copies every selected key at once, space-separated.

Moves go straight to Jira through the issue transition API. A ticket that has no valid workflow
transition to the chosen column is reported back by name and left untouched; the others still move.

- **⊘ next to each ticket** hides it; a `N hidden · show hidden` control in the header brings
  them back so you can unhide one at a time.
- **Column headings collapse** when clicked.
- **Filter by epic, label, pull-request state, task kind, assignee, or created by (reporter)** from
  the sidebar. Epics are listed as `ABC-100 — Checkout` so you can pick by number or by name. PR
  states are `has a PR`, `no PR`, `has active PR`, `changes requested`, `review outdated`,
  `approved`, `in review`, `not reviewed`, `draft`, `merged`, `declined` — only the ones actually
  present are offered. An outdated review (new commits after a verdict) is marked on the card;
  **↻ review** there or **Re-request review** in the ticket modal requests the previous reviewer.
  The same action is available after requested changes are resolved. Task
  kinds come from `taskKinds` in config (classified by matching Jira labels, or via
  `btodos sync --enrich` / on-demand AI). Assignees and reporters come from the loaded tickets.
  The filters combine (epic `ABC-100` *and* `merged`), counts follow the filter, and **clear**
  resets them. Filtering is a view over the tickets already loaded — no refetch, and it doesn't
  touch what's hidden. Use **save as** (sidebar) to store the current search + filters + sort as a
  named preset in `.btodos/view.json`; pick it later from **Saved filters…** in the top chrome
  (right of the review chips), or **delete** a preset.
  **Order by** (priority / estimate / complexity / time created) reorders the board.
  Estimate needs `estimateField`. Complexity uses the LLM prompt in `prompts.md` → `# complexity`
  (and optional `complexityField` when present). Modes shown are set by `orderBy`.
  (defaults on `btodos init` / `migrate` / `config sync`; edit or remove freely).
- **Needs your review / Waiting on team** chips open the matching ticket modal only — they do
  not change search or filters.
- Card face shows **kind**, **labels**, priority, complexity, and status.
- Ticket **Description** has **recap** / **↻ recap** to run the `recap` prompt
  (same job as card **✨ recap**).
Hidden tickets, collapsed columns, and filter presets are stored in `.btodos/view.json`, so the
layout comes back the same next time you `serve`.

Tickets are read from `.btodos/tickets.bundle` (one JSON file per ticket) rather than
refetched on every page load. Use
the header buttons:

- **fetch** pulls fresh Jira issues and linked PR state.
- **sync state** updates ticket + PR state only (keeps cached descriptions/recaps/steps; no Ollama).

Use the ✨ buttons on each card (and **↻ recap** on Description) for **on-demand**
description/comments/PR recaps. Optional bulk enrichment: `btodos sync --enrich`, gated by
`enrich` filters and/or `prompts.enrichCriteria` (see [Recaps and test steps](#recaps-and-test-steps)).

## Pull requests

Each ticket shows the pull requests linked to it, with review state and comment count:

```
IA-142  Fix login redirect   [#312 approved 2/2 💬5]
IA-149  Search is slow       [#318 not reviewed]
IA-151  Audit log            [#301 merged 💬3] [#305 declined 💬1]
```

| Badge | Meaning |
| ----- | ------- |
| `merged` | the PR is merged |
| `declined` | the PR was closed without merging |
| `approved n/m` | open, with `n` of `m` reviewers approving |
| `in review` | open, has comments but no approval yet |
| `not reviewed` | open, nobody has commented or approved |

Configure where they come from under `git`:

```json
{
  "git": {
    "enabled": true,
    "provider": "github",
    "repos": ["your-org/api", "your-org/web"],
    "tokenEnv": "GITHUB_TOKEN",
    "apiUrl": "https://api.github.com",
    "maxPages": 3
  }
}
```

| Key | Meaning |
| --- | ------- |
| `provider` | `github` (the GitHub API) or `jira` (Jira's dev-status panel) |
| `repos` | repos to scan, `owner/name`. Required for `github` |
| `tokenEnv` | env var holding the token (default `GITHUB_TOKEN`) |
| `apiUrl` | override for GitHub Enterprise |
| `maxPages` | pages of 100 recent PRs to scan per repo (default 3) |
| `enabled` | `false` skips the lookup entirely |

With `provider: "github"`, btodos lists each repo's most recently updated pull requests and matches
them to tickets by issue key in the **PR title, branch name or body** — so `feature/ABC-123-login`
or a title starting `ABC-123` both link up. Only matched PRs cost extra requests (two each, for
comments and reviews).

The token needs `repo` scope for private repos (`public_repo` otherwise):

```bash
export GITHUB_TOKEN="ghp_..."    # https://github.com/settings/tokens
```

`provider: "jira"` uses Jira's development-status endpoint instead — no GitHub token needed, but
that endpoint is undocumented and returns nothing on many Jira sites.

Anything missing is reported and skipped rather than failing the run: no token, no repos, a bad
token, or an unreachable repo all print a reason and leave tickets without pull requests.

Unlike recaps, PR state is refreshed by the web **fetch** / **sync state** actions, since review
counts change far more often than a ticket description.

## Labels

Labels are shown under each ticket. Restrict the checklist to certain labels with the `labels`
config key, and edit them from the CLI:

```bash
btodos label ABC-123 backend urgent      # add
btodos label ABC-123 --remove urgent     # remove
```

This is the only command that writes to Jira.

## Recaps and test steps

Each ticket gets a one-line recap, plus numbered steps to reproduce or verify it when the
description has enough detail (tickets shorter than ~140 characters get no steps, since the model
would have to invent them). By default both are generated locally by
[Ollama](https://ollama.com) — nothing leaves your machine. Ticket recaps/steps are **on demand** from
the board ✨ buttons (and optionally `btodos sync --enrich`).
`btodos serve` / plain `btodos sync` never bulk-run the LLM; they reuse `.btodos/tickets.bundle`.

Bulk `--enrich` only summarises tickets that pass your criteria (you control both):

1. **Filters** in `config.json` → `enrich` (empty list = no restriction on that field):

```json
{
  "enrich": {
    "statuses": ["In Progress", "In Review"],
    "priorities": ["Highest", "High"],
    "labels": [],
    "kinds": [],
    "assignees": [],
    "prompt": true
  }
}
```

2. **Prompt** in `prompts.json` → `enrichCriteria` (YES/NO). Set `"prompt": true` to enable the LLM gate (optionally with filters); leave `"prompt": false` (default) for filters only, or empty filters with no prompt to summarise every ticket on `--enrich`.

Shared text for config/prompts can live in `.btodos/globals.json` and be referenced as `{{name}}` (single `{title}` placeholders stay for runtime).

PR review recaps (for **changes requested**) are generated **on demand** from a button on the web page, using `prompts.md` → `# reviewFeedback` (legacy key `reviewFeedbackPrompt`).

PR **code-change** briefs use **codeDiff** (`# codeDiff`). Review drafts use
**reviewDraft** (`# reviewDraft`). Small diffs run immediately; large ones ask you to proceed or ignore.

After editing on-demand prompts, **restart `btodos serve`** so the board picks them up, then click
**↻ codeDiff** / **↻ reviewDraft** (or the first-run buttons) to regenerate.

Pull the model once:

```bash
ollama pull llama3.2:3b
```

Host resource limits (how many requests / loaded models Ollama keeps) live under `ollama` and are
applied to the **host** Ollama process — not the btodos container — by:

```bash
btodos ollama apply
```

That sets `OLLAMA_NUM_PARALLEL` / `OLLAMA_MAX_LOADED_MODELS` from config, persists them on macOS via
a LaunchAgent, and restarts the Ollama app (use `--no-restart` to skip the restart):

```json
{
  "ollama": {
    "numParallel": 1,
    "maxLoadedModels": 1
  }
}
```

`models.recap` takes a single name or an ordered list of fallbacks. Each model is tried in turn, and
one that isn't pulled, errors, or answers with nothing hands off to the next:

```json
{ "models": { "recap": ["qwen2.5:7b", "llama3.2:3b"] } }
```

`models.steps` is separate and defaults to `models.recap`, so you can pair a small fast model for the
one-line recap with a stronger one for the steps — same number of calls, better fit per task:

```json
{
  "models": {
    "recap": ["llama3.2:3b"],
    "steps": ["qwen2.5:7b", "llama3.2:3b"]
  }
}
```

If every model fails — Ollama isn't running, none are pulled — the recap falls back to a truncated
excerpt of the ticket description and steps are omitted, so `sync` never fails because of it. Set
`"models": { "recap": [] }` to always use the excerpt and skip the LLM entirely.

Prompts live in **`.btodos/prompts.md`** by default (`promptsFile`), or as **one Markdown
file per prompt** under **`.btodos/prompts/`** when you set `"promptsDir": "prompts"`.

Each `# section` (or `prompts/<key>.md` file) has an HTML comment **before** it naming the board
CTA it powers. Those comments are editor-only — stripped on load so the model never sees them.

```markdown
<!-- Used for: codeDiff tab review brief (codeDiff / ↻ codeDiff) -->
# 1. codeDiff

You are helping a human review…
```

| Prompt key | Used for |
| --- | --- |
| `recap` | Description — recap / ↻ recap |
| `steps` | Ticket steps (with recap / `--enrich`) |
| `codeDiff` | PR — codeDiff / ↻ codeDiff |
| `reviewDraft` | PR — reviewDraft / ↻ reviewDraft |
| `reviewFeedback` | PR — reviewFeedback / ↻ reviewFeedback |
| `cursorWork` | Description — ✨ copy work prompt for Cursor |
| `jiraPlan` | Ticket modal — jiraPlan |
| `githubPlan` | Ticket modal — githubPlan |
| `taskKind` | Kind classifier (`--task-kind` / `--enrich`) |
| `complexity` | Complexity scorer (`--complexity` / `--enrich`) |
| `enrichCriteria` | Bulk `--enrich` YES/NO gate |

Re-write purpose comments into your prompts file after an upgrade (also done by
`btodos self-update` / `btodos migrate`):

```bash
btd migrate                         # refresh .btodos (config + prompts annotations + cache)
btd config sync                     # same migration without upgrading the package
btd config prompts migrate          # merge → prompts.md + purpose comments (legacy cleanup)
btd config prompts split            # prompts.md → prompts/*.md + set promptsDir
btd config prompts export           # prompts/*.md → prompts.md
```

Each file is plain Markdown (the prompt body), optionally starting with a purpose comment:

```markdown
<!-- Used for: ticket Description summary on the board (recap / ↻ recap) -->

Summarise this Jira ticket…
Title: {title}
```

In the combined `prompts.md`, sections are numbered in the heading (alphabetical key order):

```markdown
<!-- Used for: … -->
# 1. codeDiff
…
<!-- Used for: … -->
# 7. recap
```

Config stays readable:

```json
{
  "promptsDir": "prompts",
  "promptsFile": "prompts.md"
}
```

`promptsDir` wins when present (``.md`` preferred over legacy ``.yaml``). `promptsFile` can still be JSON, Markdown bundle, or YAML.
Each JSON value is a string or an array of lines. Most must contain `{description}`; `{title}` is optional.
`cursorWork` is the text copied by the ✨ button on each ticket for pasting into Cursor. It must
contain `{key}` and `{branch}`; optional placeholders: `{title}`, `{url}`, `{description}`, `{brief}`,
`{status}`, `{priority}`, `{assignee}`, `{reporter}`.
`taskKind` classifies tickets into `taskKinds` and must include `{kinds}` + `{description}`.
`btodos init` / `btodos config sync` write the defaults so you can edit them in place.
Print the built-in Ollama prompts template (and copy it) with:

```bash
btodos config prompts
```

Then paste into `.btodos/prompts.json` to replace.

```json
{
  "recap": [
    "Summarise this Jira ticket in one short sentence of at most 25 words.",
    "",
    "Title: {title}",
    "",
    "Description: {description}",
    "",
    "Summary:"
  ],
  "steps": ["…"],
  "reviewFeedback": ["…"],
  "codeDiff": ["…"],
  "reviewDraft": ["…"],
  "cursorWork": ["…"],
  "taskKind": ["…"]
}
```

Legacy inline `prompts` / top-level `recapPrompt` keys still load; `btodos config sync` moves them
into the prompts file.

Recaps and steps are cached and tracked separately, so a re-sync never starts over: it keeps
everything it already has and generates only what is new. A ticket is re-summarised when its
description changes, when its model or prompt changes (recaps and steps independently), or when
the previous attempt fell back to the excerpt because Ollama was unreachable.

The API token is read from the environment, never the config file:

```bash
export JIRA_API_TOKEN="..."   # https://id.atlassian.com/manage-profile/security/api-tokens
```

The config file is looked up in the current directory and every parent, so the commands work
from any subdirectory of the workspace.

## Use

Run `btodos commands` (or `btd commands`) anytime for a full command table.
Official short name: **`btd`** (same CLI as `btodos`).

```bash
# Daily
btd init                            # create .btodos/config.json
btd sync                            # fetch tickets → JIRA-TODOS.md
btd sync --enrich                   # also bulk-generate recaps/steps/kinds (slow)
btd sync --task-kind                # reclassify kinds only
btd serve                           # http://127.0.0.1:4321
btodos label ABC-123 backend        # add labels
btodos doctor                       # check Jira / GitHub / Ollama
btodos self-update                  # upgrade from PyPI + migrate .btodos
btodos self-update --check          # compare versions only
btodos migrate                      # refresh .btodos to this package version
btodos commands                     # show all commands as a table
btodos completion zsh               # shell completion

# Config (noun + verb)
btodos config sync                  # migrate/add keys (same as migrate, no package upgrade)
btodos config prompts               # built-in prompts.md → clipboard
btodos config sort                  # list orderBy modes
btodos config assignees             # prompt for emails → account ids
btodos config assignees a@x.com     # same, non-interactive

# Ollama host / container
btodos ollama apply                 # apply ollama.* limits + restart app
btodos container write              # write .btodos/docker-compose.yml
btodos container up --build         # start
btodos container logs               # follow
btodos container down               # stop
```

Output:

```markdown
# ABC — My Tickets

_4 ticket(s) · generated 2026-09-01 10:12 UTC by btodos._

## To Do (2)

- [ ] [ABC-123](https://your-company.atlassian.net/browse/ABC-123) — Fix login redirect _(High · 2026-08-28)_
- [ ] [ABC-131](https://your-company.atlassian.net/browse/ABC-131) — Add audit log _(Medium · 2026-08-27)_
```

## Notes

- Tickets are selected with `project = <key> AND assignee IN (<assignee>) AND status IN (<columns>)`
  (or `assignee = …` when only one is configured), then listed by priority (Highest first), most
  recently updated first within a priority.
- Everything btodos writes lives in `.btodos/` — config, ticket cache, assignee id cache and view
  state. Add it to your `.gitignore` unless you want the cache shared.
- Column names must match your Jira **status** names exactly (matching is case-insensitive).
- Checkboxes are for your own local tracking — ticking one does not write back to Jira.

## Docker

Run the web view as a background container instead of holding a terminal:

```bash
btodos container write         # writes .btodos/docker-compose.yml
btodos container up --build    # start
btodos container restart       # down then up (add --build to rebuild)
btodos container logs          # progress output
btodos container down          # stop
```

Or with compose directly:

```bash
btodos container write
docker compose -f .btodos/docker-compose.yml up -d --build
open http://127.0.0.1:4321
```

```bash
docker compose -f .btodos/docker-compose.yml logs -f   # progress output
docker compose -f .btodos/docker-compose.yml down      # stop
```

`container up` / `restart` pull missing images (`redis:7-alpine`, and `python:3.13-alpine`
when building) before compose runs. If the registry is down but `btodos:local` already
exists, `--build` falls back to starting that image instead of failing hard.

The image is `python:3.13-alpine` with the package installed. Compose installs as a
non-root user (~84 MB). The generated compose file:

- starts a **Redis sidecar** (`redis:7-alpine`) for the ticket cache (`BTODOS_REDIS_URL`);
  the JSON file under `.btodos/` remains a fallback;
- prefers local images (`pull_policy: if_not_present`, build `pull: false`);
- mounts your workspace at `/work`, so config, cache, view state and the Markdown output
  are the same files the CLI uses;
- sets `BTODOS_HOST=0.0.0.0` so the port mapping can reach the server;
- points `BTODOS_OLLAMA_URL` at `host.docker.internal`, since Ollama runs on the host;
- reads `JIRA_API_TOKEN` and your git token from your shell at `up` time — never stored
  in the file;
- restarts unless stopped, so it comes back after a reboot.

If `btodos serve` is already using the port, give the container a different host port:

```bash
BTODOS_PORT=4400 docker compose -f .btodos/docker-compose.yml up -d
```

`host` in the config (or `BTODOS_HOST`) also controls what the plain `serve` command binds to;
it stays `127.0.0.1` by default.

## Tests

```bash
uv sync
PYTHONPATH=tests uv run python -m unittest discover -s tests -v
```

Stdlib `unittest`, no network — every remote call is stubbed. Runtime needs Jinja2.
They cover config parsing and its error messages, JQL and Jira field parsing, priority ordering,
the incremental sync/merge rules, the ticket cache and view state, GitHub PR matching and review
state, Markdown and HTML rendering, the server endpoints, and the CLI.

Tests prove the logic; they cannot prove *your* credentials work. For that:

```bash
btodos doctor
```

It checks, against the real services: Jira credentials and that your JQL returns tickets, that
every configured column is a real status name, the GitHub token and each repo, whether any PR
actually mentions your ticket keys, and that Ollama is up with your models pulled. It exits
non-zero if anything failed.

## Publishing

Pushes to `main` run GitHub Actions:

1. **CI** — `uv sync` + unit tests
2. **Publish to PyPI** — builds with `uv build` and uploads with `uv publish` via
   [Trusted Publishing](https://docs.pypi.org/trusted-publishers/) (no API token in secrets)

To cut a release: bump `version` in `pyproject.toml`, commit, push to `main`. If that version is
already on PyPI, publish is skipped.

Dev install of the just-published tool:

```bash
uv tool install --force btodos
```
