todo-jira-sync 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. todo_jira_sync-1.0.0/.dockerignore +17 -0
  2. todo_jira_sync-1.0.0/.env.example +35 -0
  3. todo_jira_sync-1.0.0/.github/dependabot.yaml +16 -0
  4. todo_jira_sync-1.0.0/.github/workflows/ci.yaml +74 -0
  5. todo_jira_sync-1.0.0/.github/workflows/publish-docker.yaml +68 -0
  6. todo_jira_sync-1.0.0/.github/workflows/publish-pypi.yaml +40 -0
  7. todo_jira_sync-1.0.0/.gitignore +27 -0
  8. todo_jira_sync-1.0.0/.pre-commit-config.yaml +13 -0
  9. todo_jira_sync-1.0.0/AGENTS.md +68 -0
  10. todo_jira_sync-1.0.0/Dockerfile +44 -0
  11. todo_jira_sync-1.0.0/LICENSE +21 -0
  12. todo_jira_sync-1.0.0/Makefile +46 -0
  13. todo_jira_sync-1.0.0/PKG-INFO +202 -0
  14. todo_jira_sync-1.0.0/README.md +176 -0
  15. todo_jira_sync-1.0.0/docker-compose.yaml +21 -0
  16. todo_jira_sync-1.0.0/pyproject.toml +71 -0
  17. todo_jira_sync-1.0.0/setup.cfg +4 -0
  18. todo_jira_sync-1.0.0/tests/__init__.py +0 -0
  19. todo_jira_sync-1.0.0/tests/fake_jira.py +53 -0
  20. todo_jira_sync-1.0.0/tests/fixtures/sample.todo +15 -0
  21. todo_jira_sync-1.0.0/tests/test_sync.py +217 -0
  22. todo_jira_sync-1.0.0/tests/test_todo_format.py +128 -0
  23. todo_jira_sync-1.0.0/todo_jira_sync/__init__.py +1 -0
  24. todo_jira_sync-1.0.0/todo_jira_sync/cli.py +168 -0
  25. todo_jira_sync-1.0.0/todo_jira_sync/config.py +78 -0
  26. todo_jira_sync-1.0.0/todo_jira_sync/jira_client.py +207 -0
  27. todo_jira_sync-1.0.0/todo_jira_sync/models.py +104 -0
  28. todo_jira_sync-1.0.0/todo_jira_sync/settings.py +98 -0
  29. todo_jira_sync-1.0.0/todo_jira_sync/state.py +72 -0
  30. todo_jira_sync-1.0.0/todo_jira_sync/sync.py +351 -0
  31. todo_jira_sync-1.0.0/todo_jira_sync/todo_format.py +263 -0
  32. todo_jira_sync-1.0.0/todo_jira_sync.egg-info/PKG-INFO +202 -0
  33. todo_jira_sync-1.0.0/todo_jira_sync.egg-info/SOURCES.txt +35 -0
  34. todo_jira_sync-1.0.0/todo_jira_sync.egg-info/dependency_links.txt +1 -0
  35. todo_jira_sync-1.0.0/todo_jira_sync.egg-info/entry_points.txt +2 -0
  36. todo_jira_sync-1.0.0/todo_jira_sync.egg-info/requires.txt +9 -0
  37. todo_jira_sync-1.0.0/todo_jira_sync.egg-info/top_level.txt +1 -0
@@ -0,0 +1,17 @@
1
+ .git
2
+ .github
3
+ .venv
4
+ venv
5
+ __pycache__
6
+ *.pyc
7
+ .pytest_cache
8
+ .ruff_cache
9
+ .mypy_cache
10
+ build
11
+ dist
12
+ *.egg-info
13
+ .env
14
+ *.todojira.json
15
+ tests
16
+ Makefile
17
+ .pre-commit-config.yaml
@@ -0,0 +1,35 @@
1
+ # --- Connection -------------------------------------------------------------
2
+ # Jira Cloud base URL (no trailing path), e.g. https://acme.atlassian.net
3
+ JIRA_URL=https://your-domain.atlassian.net
4
+
5
+ # Auth mode: "basic" for Jira Cloud (email + API token),
6
+ # "bearer" for Server/Data Center personal access tokens.
7
+ JIRA_AUTH=basic
8
+
9
+ # For basic auth (Cloud): your account email + an API token from
10
+ # https://id.atlassian.com/manage-profile/security/api-tokens
11
+ JIRA_EMAIL=you@example.com
12
+ JIRA_API_TOKEN=your-api-token
13
+
14
+ # --- What to sync -----------------------------------------------------------
15
+ JIRA_PROJECT=WEB
16
+ TODO_FILE=todo.todo
17
+
18
+ # --- Behaviour --------------------------------------------------------------
19
+ # Conflict policy when BOTH sides changed the same field: jira | todo | skip
20
+ CONFLICT=jira
21
+ # Re-add issues to the todo file if they were deleted locally but still in Jira
22
+ PULL_LOCALLY_DELETED=false
23
+
24
+ # --- Optional: Jira issue-type names (match your project scheme) ------------
25
+ # TYPE_EPIC=Epic
26
+ # TYPE_STORY=Story
27
+ # TYPE_TASK=Task
28
+ # TYPE_SUBTASK=Sub-task
29
+
30
+ # --- Optional: Todo+ syntax overrides (comma-separated) ---------------------
31
+ # BOX_SYMBOLS=☐,[ ]
32
+ # DONE_SYMBOLS=✔,[x],[X],✓
33
+ # CANCELLED_SYMBOLS=✘,[-]
34
+ # INDENT_UNIT=
35
+ # CANCELLED_STATUS_NAMES=Cancelled,Canceled,Won't Do,Rejected
@@ -0,0 +1,16 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: pip
4
+ directory: "/"
5
+ schedule:
6
+ interval: weekly
7
+
8
+ - package-ecosystem: github-actions
9
+ directory: "/"
10
+ schedule:
11
+ interval: weekly
12
+
13
+ - package-ecosystem: docker
14
+ directory: "/"
15
+ schedule:
16
+ interval: weekly
@@ -0,0 +1,74 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ lint:
10
+ runs-on: ubuntu-latest
11
+ permissions:
12
+ contents: write
13
+ steps:
14
+ - uses: actions/checkout@v6
15
+ with:
16
+ ref: ${{ github.head_ref || github.ref_name }}
17
+ fetch-depth: 0
18
+
19
+ - name: Install uv
20
+ uses: astral-sh/setup-uv@v7
21
+
22
+ - name: Create venv
23
+ run: uv venv --python 3.12
24
+
25
+ - name: Install
26
+ run: uv pip install -e ".[dev]"
27
+
28
+ - name: Auto-fix with ruff
29
+ run: |
30
+ uv run ruff check --fix .
31
+ uv run ruff format .
32
+
33
+ - name: Commit auto-fixes
34
+ run: |
35
+ if ! git diff --quiet; then
36
+ git config user.name "github-actions[bot]"
37
+ git config user.email "github-actions[bot]@users.noreply.github.com"
38
+ git commit -am "style: auto-fix ruff lint issues"
39
+ git push
40
+ fi
41
+
42
+ test:
43
+ needs: lint
44
+ runs-on: ubuntu-latest
45
+ strategy:
46
+ fail-fast: false
47
+ matrix:
48
+ python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
49
+ steps:
50
+ - uses: actions/checkout@v6
51
+ with:
52
+ ref: ${{ github.head_ref || github.ref_name }}
53
+ fetch-depth: 0 # setuptools-scm needs tags/history
54
+
55
+ - name: Install uv
56
+ uses: astral-sh/setup-uv@v7
57
+
58
+ # Create a venv that uses *exactly* the matrix interpreter. uv downloads
59
+ # the version if the runner doesn't have it. Every step below runs via
60
+ # `uv run`, so it executes inside this venv on the matrix Python.
61
+ - name: Create venv on Python ${{ matrix.python-version }}
62
+ run: uv venv --python ${{ matrix.python-version }}
63
+
64
+ - name: Install
65
+ run: uv pip install -e ".[dev]"
66
+
67
+ - name: Show interpreter under test
68
+ run: uv run python --version
69
+
70
+ - name: Type check
71
+ run: uv run mypy todo_jira_sync
72
+
73
+ - name: Test
74
+ run: uv run pytest
@@ -0,0 +1,68 @@
1
+ name: Publish container
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+ workflow_dispatch:
8
+
9
+ env:
10
+ REGISTRY: ghcr.io
11
+ IMAGE_NAME: ${{ github.repository }}
12
+
13
+ jobs:
14
+ docker:
15
+ runs-on: ubuntu-latest
16
+ permissions:
17
+ contents: read
18
+ packages: write # push to GitHub Container Registry
19
+ steps:
20
+ - uses: actions/checkout@v6
21
+ with:
22
+ fetch-depth: 0 # so setuptools-scm can resolve the version
23
+
24
+ - name: Resolve version
25
+ id: version
26
+ run: |
27
+ if command -v git >/dev/null && git describe --tags >/dev/null 2>&1; then
28
+ echo "value=$(git describe --tags --always | sed 's/^v//')" >> "$GITHUB_OUTPUT"
29
+ else
30
+ echo "value=0.0.0" >> "$GITHUB_OUTPUT"
31
+ fi
32
+
33
+ - name: Set up QEMU
34
+ uses: docker/setup-qemu-action@v4
35
+
36
+ - name: Set up Buildx
37
+ uses: docker/setup-buildx-action@v4
38
+
39
+ - name: Log in to GHCR
40
+ uses: docker/login-action@v4
41
+ with:
42
+ registry: ${{ env.REGISTRY }}
43
+ username: ${{ github.actor }}
44
+ password: ${{ secrets.GITHUB_TOKEN }}
45
+
46
+ - name: Image metadata
47
+ id: meta
48
+ uses: docker/metadata-action@v5
49
+ with:
50
+ images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
51
+ tags: |
52
+ type=ref,event=branch
53
+ type=semver,pattern={{version}}
54
+ type=semver,pattern={{major}}.{{minor}}
55
+ type=sha
56
+
57
+ - name: Build and push (amd64 + arm64)
58
+ uses: docker/build-push-action@v6
59
+ with:
60
+ context: .
61
+ platforms: linux/amd64,linux/arm64
62
+ push: true
63
+ build-args: |
64
+ VERSION=${{ steps.version.outputs.value }}
65
+ tags: ${{ steps.meta.outputs.tags }}
66
+ labels: ${{ steps.meta.outputs.labels }}
67
+ cache-from: type=gha
68
+ cache-to: type=gha,mode=max
@@ -0,0 +1,40 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ jobs:
9
+ build:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v6
13
+ with:
14
+ fetch-depth: 0 # setuptools-scm needs the tag history
15
+
16
+ - name: Install uv
17
+ uses: astral-sh/setup-uv@v7
18
+
19
+ - name: Build sdist and wheel
20
+ run: uv build
21
+
22
+ - uses: actions/upload-artifact@v4
23
+ with:
24
+ name: dist
25
+ path: dist/
26
+
27
+ publish:
28
+ needs: build
29
+ runs-on: ubuntu-latest
30
+ environment: pypi
31
+ permissions:
32
+ id-token: write # OIDC for PyPI Trusted Publishing (no API token needed)
33
+ steps:
34
+ - uses: actions/download-artifact@v4
35
+ with:
36
+ name: dist
37
+ path: dist/
38
+
39
+ - name: Publish
40
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,27 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ build/
7
+ dist/
8
+
9
+ # Virtual envs
10
+ .venv/
11
+ venv/
12
+
13
+ # Tooling caches
14
+ .pytest_cache/
15
+ .ruff_cache/
16
+ .mypy_cache/
17
+
18
+ # Local config & secrets
19
+ .env
20
+
21
+ # Sync state sidecars (generated next to the todo file)
22
+ *.todojira.json
23
+
24
+ # Editors / OS
25
+ .vscode/
26
+ .idea/
27
+ .DS_Store
@@ -0,0 +1,13 @@
1
+ repos:
2
+ - repo: https://github.com/astral-sh/ruff-pre-commit
3
+ rev: v0.5.7
4
+ hooks:
5
+ - id: ruff
6
+ args: [--fix]
7
+ - id: ruff-format
8
+ - repo: https://github.com/pre-commit/mirrors-mypy
9
+ rev: v1.10.1
10
+ hooks:
11
+ - id: mypy
12
+ additional_dependencies: [types-requests, pydantic-settings, typer]
13
+ files: ^todo_jira_sync/
@@ -0,0 +1,68 @@
1
+ # AGENTS.md
2
+
3
+ Guidance for AI coding agents (and humans) working in this repo.
4
+
5
+ ## What this is
6
+
7
+ A bidirectional sync between a **Jira** project and a **Todo+** text file
8
+ (the format used by the `fabiospampinato/vscode-todo-plus` VS Code extension).
9
+
10
+ ## Architecture (important)
11
+
12
+ The package is split so the **sync core has zero third-party dependencies** and
13
+ can be unit-tested with the standard library alone:
14
+
15
+ | Module | Depends on | Purpose |
16
+ |-------------------|-------------------|------------------------------------------------------|
17
+ | `config.py` | stdlib only | `FormatConfig` dataclass + conflict constants |
18
+ | `models.py` | stdlib only | `Node`, `JiraIssue`, `NodeKind`, `Status` |
19
+ | `todo_format.py` | stdlib only | `parse()`, `serialize()`, `resolve_jira_parents()` |
20
+ | `state.py` | stdlib only | JSON sidecar baseline for 3-way merge |
21
+ | `sync.py` | stdlib only | the 3-way merge engine: `sync(...) -> SyncResult` |
22
+ | `jira_client.py` | `requests` (lazy) | `RestJiraClient` against Jira Cloud REST v3 |
23
+ | `settings.py` | `pydantic-settings` | env/`.env` -> `FormatConfig` + credentials |
24
+ | `cli.py` | `typer` | `sync` / `push` / `pull` / `status` commands |
25
+
26
+ **Rule:** never import `requests`, `pydantic`, or `typer` from the core
27
+ modules (`config`, `models`, `todo_format`, `state`, `sync`). Keep them at the
28
+ edges (`jira_client`, `settings`, `cli`).
29
+
30
+ ## Mapping rules
31
+
32
+ - Line ending with `:` at column 0 -> **Epic**
33
+ - Indented line ending with `:` -> **User Story**
34
+ - A Todo+ task (leading box/done/cancelled symbol) -> **Task**
35
+ - A task nested under another task -> **Sub-task**
36
+ - Identity anchor is the `@jira(KEY)` tag, written back into the file.
37
+
38
+ A node's Jira **parent** is its nearest enclosing container: Task -> enclosing
39
+ Story (or Epic if none); Story -> Epic; Sub-task -> the Task it sits under
40
+ (deeper nesting collapses onto that Task, since Jira forbids sub-task-of-sub-task).
41
+
42
+ ## Jira API note
43
+
44
+ Uses `POST /rest/api/3/search/jql` with `nextPageToken` pagination. The legacy
45
+ `/rest/api/3/search` endpoint was removed by Atlassian and must **not** be
46
+ reintroduced. `fields` must be requested explicitly (the default is now `id`).
47
+
48
+ ## Tests
49
+
50
+ ```bash
51
+ pytest # via uv/pip-installed pytest
52
+ python tests/test_sync.py # also runs standalone, no pytest needed
53
+ python tests/test_todo_format.py
54
+ ```
55
+
56
+ The engine is tested against `tests/fake_jira.py`, an in-memory double — no
57
+ live Jira is required.
58
+
59
+ ## Build, container & CI
60
+
61
+ uv is used everywhere (Makefile, Dockerfile, workflows). The image is a
62
+ multi-stage build: a `uv pip install` build stage and a slim runtime stage
63
+ whose entrypoint is the `todo-jira-sync` CLI. Versioning is `setuptools-scm`
64
+ (git tags); pass `--build-arg VERSION=...` to the Docker build since the build
65
+ context has no `.git`. GitHub Actions: `ci.yaml` (ruff + mypy + pytest, py3.10
66
+ –3.14), `publish-pypi.yaml` (sdist/wheel to PyPI via OIDC on `v*` tags) and
67
+ `publish-docker.yaml` (multi-arch image to GHCR on `v*` tags / manual dispatch).
68
+ Dependabot tracks pip, github-actions and docker.
@@ -0,0 +1,44 @@
1
+ # syntax=docker/dockerfile:1
2
+
3
+ # ---------------------------------------------------------------------------
4
+ # Build stage: install the package (and its deps) into an isolated venv with uv.
5
+ # ---------------------------------------------------------------------------
6
+ FROM python:3.14-slim-bookworm AS build
7
+
8
+ # uv, copied from its official distroless image (pinned by the publish workflow).
9
+ COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
10
+
11
+ ENV UV_COMPILE_BYTECODE=1 \
12
+ UV_LINK_MODE=copy \
13
+ UV_PYTHON_DOWNLOADS=never
14
+
15
+ # setuptools-scm derives the version from git. In CI we pass the resolved
16
+ # version as a build arg so the build does not need the .git directory.
17
+ ARG VERSION=0.0.0
18
+ ENV SETUPTOOLS_SCM_PRETEND_VERSION=${VERSION}
19
+
20
+ WORKDIR /app
21
+ COPY pyproject.toml README.md ./
22
+ COPY todo_jira_sync ./todo_jira_sync
23
+
24
+ # Create the venv and install the project into it.
25
+ RUN uv venv /opt/venv \
26
+ && VIRTUAL_ENV=/opt/venv uv pip install --no-cache .
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Runtime stage: copy only the venv onto a slim base. No compilers, no uv.
30
+ # ---------------------------------------------------------------------------
31
+ FROM python:3.14-slim-bookworm AS runtime
32
+
33
+ # Run as a non-root user.
34
+ RUN useradd --create-home --uid 1000 app
35
+
36
+ COPY --from=build /opt/venv /opt/venv
37
+ ENV PATH="/opt/venv/bin:$PATH"
38
+
39
+ # Work against a mounted directory holding the todo file and its .env.
40
+ WORKDIR /work
41
+ USER app
42
+
43
+ ENTRYPOINT ["todo-jira-sync"]
44
+ CMD ["--help"]
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Regis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,46 @@
1
+ .PHONY: help install lint format typecheck test check run docker docker-run clean
2
+
3
+ help:
4
+ @echo "Targets:"
5
+ @echo " install Create the venv and install in editable mode (uv)"
6
+ @echo " lint Run ruff"
7
+ @echo " format Auto-fix with ruff"
8
+ @echo " typecheck Run mypy"
9
+ @echo " test Run the test suite (pytest)"
10
+ @echo " check lint + typecheck + test"
11
+ @echo " run Run the CLI (pass ARGS=...)"
12
+ @echo " docker Build the container image"
13
+ @echo " clean Remove caches and build artifacts"
14
+
15
+ install:
16
+ uv venv
17
+ uv pip install -e ".[dev]"
18
+
19
+ lint:
20
+ uv run ruff check .
21
+
22
+ format:
23
+ uv run ruff check --fix .
24
+ uv run ruff format .
25
+
26
+ typecheck:
27
+ uv run mypy todo_jira_sync
28
+
29
+ test:
30
+ uv run pytest
31
+
32
+ check: lint typecheck test
33
+
34
+ run:
35
+ uv run todo-jira-sync $(ARGS)
36
+
37
+ VERSION ?= 0.0.0
38
+ docker:
39
+ docker build --build-arg VERSION=$(VERSION) -t todo-jira-sync:local .
40
+
41
+ docker-run:
42
+ docker run --rm --env-file .env -v "$(PWD):/work" todo-jira-sync:local $(ARGS)
43
+
44
+ clean:
45
+ rm -rf .pytest_cache .ruff_cache .mypy_cache build dist *.egg-info
46
+ find . -type d -name __pycache__ -prune -exec rm -rf {} +
@@ -0,0 +1,202 @@
1
+ Metadata-Version: 2.4
2
+ Name: todo-jira-sync
3
+ Version: 1.0.0
4
+ Summary: Bidirectionally sync a Jira project with a Todo+ (vscode-todo-plus) text file.
5
+ Author: Regis
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/kalw/todo-jira-sync
8
+ Project-URL: Issues, https://github.com/kalw/todo-jira-sync/issues
9
+ Keywords: jira,todo,todo-plus,sync,taskpaper,vscode
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Topic :: Utilities
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: typer>=0.12
18
+ Requires-Dist: requests>=2.31
19
+ Requires-Dist: pydantic-settings>=2.2
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest>=8; extra == "dev"
22
+ Requires-Dist: ruff>=0.5; extra == "dev"
23
+ Requires-Dist: mypy>=1.10; extra == "dev"
24
+ Requires-Dist: types-requests; extra == "dev"
25
+ Dynamic: license-file
26
+
27
+ # todo-jira-sync
28
+
29
+ Bidirectionally sync a **Jira** project with a **Todo+** plain-text file — the
30
+ format used by the [`vscode-todo-plus`](https://github.com/fabiospampinato/vscode-todo-plus)
31
+ extension. Edit your backlog as a flat, version-controllable text file in your
32
+ editor; run one command; Jira and the file converge.
33
+
34
+ Scaffolded from [`tedivm/robs_awesome_python_template`](https://github.com/tedivm/robs_awesome_python_template)
35
+ conventions (uv, `pyproject.toml`, Typer, Pydantic Settings, Ruff, mypy,
36
+ pytest, GitHub Actions).
37
+
38
+ ## Mapping rules
39
+
40
+ Each non-blank, non-comment line maps to one Jira issue, by indentation and the
41
+ trailing colon:
42
+
43
+ | Todo+ line | Jira issue type |
44
+ |----------------------------------------------|-----------------|
45
+ | ends with `:`, **no** leading indent | **Epic** |
46
+ | ends with `:`, **indented** | **User Story** |
47
+ | a task (`☐` / `✔` / `✘` …) | **Task** |
48
+ | a task **nested under another task** | **Sub-task** |
49
+
50
+ ```text
51
+ Authentication: ← Epic
52
+ Login flow: ← Story (parent: Authentication)
53
+ ☐ Build the login form ← Task (parent: Login flow)
54
+ ☐ Add OAuth providers ← Task (parent: Login flow)
55
+ ☐ Google provider ← Sub-task (parent: Add OAuth providers)
56
+ ☐ Password reset email ← Task (parent: Authentication)
57
+ ```
58
+
59
+ A node's Jira parent is its **nearest enclosing container**: a Task takes the
60
+ Story it sits under, or the Epic if there is no enclosing Story; a Story takes
61
+ its Epic; a Sub-task takes the Task it sits under.
62
+
63
+ ### Status mapping
64
+
65
+ | Todo+ symbol | Status | Jira category |
66
+ |-------------------------|---------------|----------------------|
67
+ | `☐` `[ ]` | To Do | *new* |
68
+ | `@started(...)` | In Progress | *indeterminate* |
69
+ | `✔` `✓` `[x]` | Done | *done* |
70
+ | `✘` `[-]` `@cancelled` | Cancelled | *done* + cancelled status name |
71
+
72
+ Jira has no "cancelled" status *category*; cancelled is detected by status
73
+ **name** (configurable via `CANCELLED_STATUS_NAMES`).
74
+
75
+ ### How identity works
76
+
77
+ Each synced line gets a `@jira(KEY)` tag written back into the file (inserted
78
+ *before* the trailing colon for projects, so it still reads as a project):
79
+
80
+ ```text
81
+ Authentication @jira(WEB-1):
82
+ ☐ Build the login form @jira(WEB-3)
83
+ ```
84
+
85
+ That tag is the stable anchor — rename a line freely and the link survives.
86
+
87
+ ## Install
88
+
89
+ Requires Python ≥ 3.10. Using [uv](https://docs.astral.sh/uv/):
90
+
91
+ ```bash
92
+ uv venv
93
+ uv pip install -e ".[dev]"
94
+ ```
95
+
96
+ or with pip:
97
+
98
+ ```bash
99
+ python -m venv .venv && . .venv/bin/activate
100
+ pip install -e ".[dev]"
101
+ ```
102
+
103
+ ## Configure
104
+
105
+ Copy `.env.example` to `.env` and fill in your details:
106
+
107
+ ```bash
108
+ cp .env.example .env
109
+ ```
110
+
111
+ For Jira Cloud, create an API token at
112
+ <https://id.atlassian.com/manage-profile/security/api-tokens> and set
113
+ `JIRA_EMAIL` + `JIRA_API_TOKEN` with `JIRA_AUTH=basic`. For Server/Data Center,
114
+ use a personal access token with `JIRA_AUTH=bearer`.
115
+
116
+ ## Use
117
+
118
+ ```bash
119
+ # See what would happen — touches nothing:
120
+ todo-jira-sync status --todo todo.todo --project WEB
121
+
122
+ # Full bidirectional sync:
123
+ todo-jira-sync sync --todo todo.todo --project WEB
124
+
125
+ # One-way only:
126
+ todo-jira-sync push # local file -> Jira (never edits the file)
127
+ todo-jira-sync pull # Jira -> local file (never creates/edits Jira)
128
+
129
+ # Conflict policy when both sides changed the same field:
130
+ todo-jira-sync sync --conflict jira # jira | todo | skip
131
+ ```
132
+
133
+ A JSON sidecar `todo.todo.todojira.json` is written next to your file. It is
134
+ the 3-way-merge baseline (the common ancestor) — keep it, but it need not be
135
+ committed (it is git-ignored by default).
136
+
137
+ ## How sync decides (3-way merge)
138
+
139
+ For each issue the engine compares the **live file** and **live Jira** against
140
+ the **baseline** from the last run:
141
+
142
+ - only the file changed → **push** to Jira
143
+ - only Jira changed → **pull** into the file
144
+ - both changed the same field → **conflict**, resolved by `--conflict`
145
+ - new in file → **created** in Jira (parents first)
146
+ - new in Jira → **pulled** into the file (under the right parent)
147
+ - gone from Jira but known → kept locally and reported (never silently lost)
148
+
149
+ The engine **never deletes Jira issues** and **never deletes local lines**.
150
+
151
+ ## Docker
152
+
153
+ A multi-stage, uv-based image is included. It builds the package into a slim
154
+ runtime image whose entrypoint is the CLI, running as a non-root user.
155
+
156
+ ```bash
157
+ # Build (VERSION is only needed because versioning comes from git tags):
158
+ docker build --build-arg VERSION=0.1.0 -t todo-jira-sync .
159
+
160
+ # Run against a working directory that holds your todo file and .env:
161
+ docker run --rm --env-file .env -v "$PWD:/work" todo-jira-sync \
162
+ sync --todo todo.todo --project WEB
163
+ ```
164
+
165
+ Or via Compose (put your `todo` file and `.env` in `./work`):
166
+
167
+ ```bash
168
+ docker compose run --rm sync status
169
+ docker compose run --rm sync sync --todo todo.todo --project WEB
170
+ ```
171
+
172
+ Prebuilt multi-arch images (amd64 + arm64) are published to GitHub Container
173
+ Registry by CI on version tags (and on demand via the workflow's manual
174
+ trigger).
175
+
176
+ ## Releasing
177
+
178
+ Versioning is derived from git tags via `setuptools-scm`. Pushing a tag like
179
+ `v0.1.0` triggers two workflows: one builds the sdist/wheel and publishes to
180
+ PyPI (via OIDC Trusted Publishing — no API token stored), the other builds and
181
+ pushes the multi-arch container image. CI (`ci.yaml`) runs ruff, mypy and
182
+ pytest on a Python 3.10–3.14 matrix for every push and PR.
183
+
184
+ ## Develop
185
+
186
+ ```bash
187
+ make check # ruff + mypy + pytest
188
+ make test
189
+ ```
190
+
191
+ The sync core (`config`, `models`, `todo_format`, `state`, `sync`) is pure
192
+ standard library, so the tests run against an in-memory fake Jira with no
193
+ network and no live credentials. The two test files also run standalone:
194
+
195
+ ```bash
196
+ python tests/test_todo_format.py
197
+ python tests/test_sync.py
198
+ ```
199
+
200
+ ## License
201
+
202
+ MIT — see [LICENSE](LICENSE).