From 8473b1eca3c65570736ba9eba0d99fa66c8566aa Mon Sep 17 00:00:00 2001 From: KKranthi6881 Date: Mon, 18 May 2026 13:00:06 -0500 Subject: [PATCH] Add Jira bridge workflow --- CHANGELOG.md | 7 + README.md | 22 + docs/integrations/jira.md | 130 +++++ docs/tutorials/05-jira-to-spec-workflow.md | 100 ++++ docs/tutorials/README.md | 3 + pyproject.toml | 3 +- src/dbt_specify/_version.py | 2 +- src/dbt_specify/cli.py | 190 ++++++ src/dbt_specify/jira.py | 648 +++++++++++++++++++++ tests/test_docs.py | 22 + tests/test_init.py | 3 +- tests/test_jira.py | 203 +++++++ 12 files changed, 1330 insertions(+), 3 deletions(-) create mode 100644 docs/integrations/jira.md create mode 100644 docs/tutorials/05-jira-to-spec-workflow.md create mode 100644 src/dbt_specify/jira.py create mode 100644 tests/test_jira.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 38b26b7..9e8f523 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.3.0] — 2026-05-18 + +### Added +- Jira bridge commands for pulling Jira issues into local specs, attaching approved artifacts, and + creating Jira subtasks from `tasks.md`. +- Jira integration docs and tutorial for enterprise intake workflows. + ### Changed - Clarified README and tutorial command examples so one-off `uvx` usage is not mixed with direct `dbt-specify` commands unless the CLI is persistently installed. diff --git a/README.md b/README.md index 0e5a92f..7b65256 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ become clear. | Agent knowledge | Use dbt Labs skills for dbt mechanics. Use dbt-spec-kit skills and sub-agent roles for business meaning, planning, governance, and review evidence. | [Skills and sub-agents](docs/skills-and-sub-agents.md) | | Warehouse guidance | Pick the closest warehouse preset for cost, materialization, SQL dialect, and governance guardrails. The project still runs through your normal dbt adapter and database connection. | [Warehouse guides](docs/warehouse-guides) | | CI evidence | Start with local `validate` and `report`; promote `dbt-specify ci` when the team wants lifecycle checks to block PRs. | [Enterprise CI](docs/enterprise-ci.md) | +| Jira integration | Pull Jira issues into local specs, attach approved specs/plans back to Jira, and create Jira subtasks from `tasks.md`. | [Jira integration](docs/integrations/jira.md) | The key repo hygiene rule: keep approved decision records, not raw agent scratch work. @@ -172,6 +173,26 @@ If you did not install the CLI persistently, run the `dbt-specify` commands abov Use `dbt-specify ci` when the lifecycle and dbt artifact checks should block a PR. +## Jira bridge + +For teams that use Jira as the intake system: + +```bash +export JIRA_BASE_URL="https://your-company.atlassian.net" +export JIRA_EMAIL="you@company.com" +export JIRA_API_TOKEN="" + +uvx --from dbt-spec-kit dbt-specify jira pull NBA-123 +uvx --from dbt-spec-kit dbt-specify jira attach NBA-123 \ + --spec specs/001-nba-123-player-journey/spec.md \ + --plan specs/001-nba-123-player-journey/plan.md +uvx --from dbt-spec-kit dbt-specify jira create-tasks NBA-123 \ + --from specs/001-nba-123-player-journey/tasks.md +``` + +Jira remains intake and tracking. `spec.md` and `plan.md` remain the approved engineering contract. +See [Jira integration](docs/integrations/jira.md). + ## Who this is for - Analytics engineers who want AI help without losing dbt conventions. @@ -189,6 +210,7 @@ Use `dbt-specify ci` when the lifecycle and dbt artifact checks should block a P - [Spec retention and repo hygiene](docs/spec-retention-and-repo-hygiene.md) - [Skills and sub-agents](docs/skills-and-sub-agents.md) - [Enterprise CI](docs/enterprise-ci.md) +- [Jira integration](docs/integrations/jira.md) - [Brownfield onboarding](docs/brownfield-onboarding.md) - [EARS cheatsheet](docs/ears-cheatsheet.md) - [Releasing to PyPI](docs/releasing.md) diff --git a/docs/integrations/jira.md b/docs/integrations/jira.md new file mode 100644 index 0000000..7547cb1 --- /dev/null +++ b/docs/integrations/jira.md @@ -0,0 +1,130 @@ +# Jira integration + +The Jira bridge lets enterprise teams keep Jira as the intake and tracking system while keeping +`spec.md`, `plan.md`, and `tasks.md` as the local engineering contract. + +```text +Jira story -> spec.md -> plan.md -> tasks.md -> Jira attachments/subtasks -> PR evidence +``` + +Jira does not replace the dbt-spec-kit workflow. It provides source context and receives approved +artifacts after review. + +## Authentication + +Set these environment variables before running Jira commands: + +```bash +export JIRA_BASE_URL="https://your-company.atlassian.net" +export JIRA_EMAIL="you@company.com" +export JIRA_API_TOKEN="" +``` + +Do not commit these values. Use local shell secrets or CI secrets. + +## Pull a Jira story into a spec + +Run from the dbt project root: + +```bash +uvx --from dbt-spec-kit dbt-specify jira pull NBA-123 +``` + +This creates: + +```text +specs/001-nba-123-player-journey/ + jira.yml + spec.md +``` + +`jira.yml` records the Jira issue key, URL, project key, local spec directory, and last sync time. +`spec.md` is a draft. A human or AI agent should refine the business meaning, acceptance criteria, +grain, downstream consumers, and open questions before the plan phase. + +## Plan and task locally + +Use the normal agent workflow: + +```text +/dbt.plan +/dbt.tasks +``` + +Review and approve `spec.md` before planning. Review and approve `plan.md` before implementation. + +## Attach approved artifacts to Jira + +Attach the approved spec and plan: + +```bash +uvx --from dbt-spec-kit dbt-specify jira attach NBA-123 \ + --spec specs/001-nba-123-player-journey/spec.md \ + --plan specs/001-nba-123-player-journey/plan.md +``` + +The command uploads each file to the Jira issue and adds a short comment listing the attached +artifacts. Add `--no-comment` when an automation or PR comment already summarizes the evidence. + +## Create Jira subtasks from tasks.md + +After tasks are approved: + +```bash +uvx --from dbt-spec-kit dbt-specify jira create-tasks NBA-123 \ + --from specs/001-nba-123-player-journey/tasks.md +``` + +Each unchecked task becomes a Jira subtask with a summary like: + +```text +[dbt-specify T-01] Add player journey mart. AC: AC1, AC2. +``` + +The prefix keeps sync idempotent. If the command sees an existing child issue with the same +`[dbt-specify T-01]` prefix, it skips that task instead of creating a duplicate. + +Use `--dry-run` to preview: + +```bash +uvx --from dbt-spec-kit dbt-specify jira create-tasks NBA-123 \ + --from specs/001-nba-123-player-journey/tasks.md \ + --dry-run +``` + +If your Jira site uses a different subtask type name, pass it explicitly: + +```bash +uvx --from dbt-spec-kit dbt-specify jira create-tasks NBA-123 \ + --from specs/001-nba-123-player-journey/tasks.md \ + --issue-type "Subtask" +``` + +## Sync a spec directory + +For the common case, sync the directory: + +```bash +uvx --from dbt-spec-kit dbt-specify jira sync NBA-123 \ + --spec-dir specs/001-nba-123-player-journey +``` + +This attaches `spec.md` and `plan.md` when they exist, then creates missing Jira subtasks from +`tasks.md`. + +## Recommended enterprise policy + +- Pull Jira context into a spec branch, not directly into `main`. +- Attach only reviewed or approved `spec.md` and `plan.md`; avoid publishing raw agent drafts. +- Create Jira subtasks after `tasks.md` is approved. +- Keep the PR as the merge gate. Jira status alone does not approve dbt code. +- Store Jira credentials in environment variables or CI secrets only. +- Use `jira sync --dry-run` before the first production rollout. + +## Troubleshooting + +- `Missing Jira environment variable`: set `JIRA_BASE_URL`, `JIRA_EMAIL`, and `JIRA_API_TOKEN`. +- `Jira API request failed with HTTP 401`: check the email/token pair and site URL. +- `Jira API request failed with HTTP 403`: confirm the account can browse the issue, add + attachments, create issues, and create subtasks in the project. +- Duplicate subtasks: check whether existing child issues use the `[dbt-specify T-XX]` prefix. diff --git a/docs/tutorials/05-jira-to-spec-workflow.md b/docs/tutorials/05-jira-to-spec-workflow.md new file mode 100644 index 0000000..0199d5c --- /dev/null +++ b/docs/tutorials/05-jira-to-spec-workflow.md @@ -0,0 +1,100 @@ +# Tutorial 5: Jira to spec workflow + +This tutorial shows the enterprise intake path: start from a Jira story, generate a local +dbt-spec-kit spec, approve the plan, and publish approved artifacts back to Jira. + +## 1. Configure Jira credentials + +```bash +export JIRA_BASE_URL="https://your-company.atlassian.net" +export JIRA_EMAIL="you@company.com" +export JIRA_API_TOKEN="" +``` + +Use a local shell profile or secret manager. Do not write tokens into the repo. + +## 2. Pull the Jira issue + +Run from the dbt project root: + +```bash +uvx --from dbt-spec-kit dbt-specify jira pull NBA-123 +``` + +Expected output: + +```text +created specs/001-nba-123-player-journey/spec.md +created specs/001-nba-123-player-journey/jira.yml +``` + +Review `spec.md`. The generated file is a draft from Jira context, not an approved contract. + +## 3. Refine the spec with an agent + +Ask your agent: + +```text +Read specs/001-nba-123-player-journey/spec.md and the current dbt project. +Refine the spec into clear business meaning, grain, downstream consumers, and EARS acceptance +criteria. Do not edit SQL or YAML models. +``` + +Approve `spec.md` before moving on. + +## 4. Plan and tasks + +Use the normal workflow: + +```text +/dbt.plan +/dbt.tasks +``` + +Approve `plan.md` before implementation. Approve `tasks.md` before creating Jira subtasks. + +## 5. Attach spec and plan to Jira + +```bash +uvx --from dbt-spec-kit dbt-specify jira attach NBA-123 \ + --spec specs/001-nba-123-player-journey/spec.md \ + --plan specs/001-nba-123-player-journey/plan.md +``` + +## 6. Create Jira subtasks + +```bash +uvx --from dbt-spec-kit dbt-specify jira create-tasks NBA-123 \ + --from specs/001-nba-123-player-journey/tasks.md +``` + +For a first rollout, preview first: + +```bash +uvx --from dbt-spec-kit dbt-specify jira create-tasks NBA-123 \ + --from specs/001-nba-123-player-journey/tasks.md \ + --dry-run +``` + +## 7. Implement and review + +Implementation remains local and PR-driven: + +```text +/dbt.implement +/dbt.review +``` + +After validation, attach final evidence if your team requires it: + +```bash +uvx --from dbt-spec-kit dbt-specify report --format markdown > dbt-specify-report.md +uvx --from dbt-spec-kit dbt-specify jira attach NBA-123 --file dbt-specify-report.md +``` + +## Success criteria + +- Jira story context is captured in `jira.yml` and `spec.md`. +- The approved spec and plan are attached to the Jira issue. +- Jira subtasks match `tasks.md` and do not duplicate on reruns. +- The PR remains the merge gate for dbt code. diff --git a/docs/tutorials/README.md b/docs/tutorials/README.md index 1e3f229..f5a4cd0 100644 --- a/docs/tutorials/README.md +++ b/docs/tutorials/README.md @@ -10,6 +10,7 @@ Use them in order when onboarding a team: | [2. Ship a jaffle-shop change](02-jaffle-shop-change.md) | 25 min | Analytics engineers and reviewers | Walk a real dbt feature from business request to review evidence | | [3. Adopt in a brownfield enterprise repo](03-brownfield-enterprise-adoption.md) | 30 min | Data platform leads | Roll out the workflow without rewriting production models | | [4. Run skills and sub-agent handoffs](04-skills-and-sub-agent-handoffs.md) | 20 min | Teams using AI agents | Decide when to use skills, sub-agents, and human approval gates | +| [5. Jira to spec workflow](05-jira-to-spec-workflow.md) | 20 min | Enterprise teams using Jira | Pull Jira context into specs and publish approved artifacts back | ## Learning path @@ -20,6 +21,7 @@ Install -> approve a plan -> implement one task -> attach CI evidence + -> sync approved artifacts to Jira -> review and merge ``` @@ -34,3 +36,4 @@ GitHub Copilot, Gemini CLI, Cline, and similar tools can all use the generated p - dbt Labs skills answer dbt framework questions. - dbt-spec-kit skills and sub-agent roles enforce enterprise delivery rules. - CI evidence proves the final diff followed the approved plan. +- Jira remains the intake and task tracking system, while specs remain the engineering contract. diff --git a/pyproject.toml b/pyproject.toml index ed147a1..e5579b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "dbt-spec-kit" -version = "1.2.0" +version = "1.3.0" description = "Enterprise AI SDLC toolkit for dbt projects, with spec-driven workflows, CI validation, and warehouse-specific presets." readme = "README.md" license = { file = "LICENSE" } @@ -25,6 +25,7 @@ keywords = [ "postgres", "duckdb", "athena", + "jira", ] classifiers = [ "Development Status :: 3 - Alpha", diff --git a/src/dbt_specify/_version.py b/src/dbt_specify/_version.py index 76ec51c..af44c69 100644 --- a/src/dbt_specify/_version.py +++ b/src/dbt_specify/_version.py @@ -1,4 +1,4 @@ """Single source of truth for the package version.""" from __future__ import annotations -__version__ = "1.2.0" +__version__ = "1.3.0" diff --git a/src/dbt_specify/cli.py b/src/dbt_specify/cli.py index 2141ad9..0cf3055 100644 --- a/src/dbt_specify/cli.py +++ b/src/dbt_specify/cli.py @@ -9,6 +9,14 @@ from dbt_specify.dbt_artifacts import validate_dbt_project from dbt_specify.doctor import doctor_project from dbt_specify.init import SUPPORTED_WAREHOUSES, init_project +from dbt_specify.jira import ( + JiraError, + attach_artifacts, + create_subtasks_from_tasks, + make_jira_client, + pull_issue_to_spec, + sync_spec_dir, +) from dbt_specify.lifecycle import validate_lifecycle from dbt_specify.reporting import ValidationReport, combine_reports from dbt_specify.validate import validate_spec @@ -174,6 +182,177 @@ def ci(target_dir: Path, manifest_path: Path | None) -> None: raise SystemExit(combined.exit_code) +@main.group() +def jira() -> None: + """Read from and publish dbt-specify artifacts to Jira Cloud.""" + + +@jira.command("pull") +@click.argument("issue_key") +@click.option( + "--target", + "target_dir", + type=click.Path(file_okay=False, path_type=Path), + default=Path("."), + help="Target dbt project directory (default: current directory).", +) +@click.option( + "--slug", + default=None, + help="Optional slug for the generated specs/-/ directory.", +) +@click.option("--force", is_flag=True, help="Overwrite existing generated files when present.") +def jira_pull(issue_key: str, target_dir: Path, slug: str | None, force: bool) -> None: + """Create a local spec draft from a Jira issue.""" + try: + spec_dir = pull_issue_to_spec( + client=make_jira_client(), + issue_key=issue_key, + target_dir=target_dir.resolve(), + slug=slug, + force=force, + ) + except JiraError as error: + click.echo(f"error: {error}", err=True) + raise SystemExit(1) from error + + click.echo(f"created {spec_dir / 'spec.md'}") + click.echo(f"created {spec_dir / 'jira.yml'}") + click.echo("next: review the draft spec, then run /dbt.plan after approval") + + +@jira.command("attach") +@click.argument("issue_key") +@click.option("--spec", "spec_path", type=click.Path(dir_okay=False, path_type=Path)) +@click.option("--plan", "plan_path", type=click.Path(dir_okay=False, path_type=Path)) +@click.option("--tasks", "tasks_path", type=click.Path(dir_okay=False, path_type=Path)) +@click.option( + "--file", + "extra_files", + multiple=True, + type=click.Path(dir_okay=False, path_type=Path), + help="Additional artifact to attach. Can be repeated.", +) +@click.option("--no-comment", is_flag=True, help="Do not add a Jira comment after upload.") +def jira_attach( + issue_key: str, + spec_path: Path | None, + plan_path: Path | None, + tasks_path: Path | None, + extra_files: tuple[Path, ...], + no_comment: bool, +) -> None: + """Attach approved local artifacts to a Jira issue.""" + files = _artifact_paths(spec_path, plan_path, tasks_path, extra_files) + try: + uploaded = attach_artifacts( + client=make_jira_client(), + issue_key=issue_key, + files=files, + comment=not no_comment, + ) + except JiraError as error: + click.echo(f"error: {error}", err=True) + raise SystemExit(1) from error + + for filename in uploaded: + click.echo(f"attached {filename} to {issue_key}") + + +@jira.command("create-tasks") +@click.argument("issue_key") +@click.option( + "--from", + "tasks_path", + required=True, + type=click.Path(dir_okay=False, path_type=Path), + help="Path to specs/-/tasks.md.", +) +@click.option( + "--issue-type", + "issue_type_name", + default="Sub-task", + show_default=True, + help="Jira issue type to create under the parent issue.", +) +@click.option("--include-done", is_flag=True, help="Also create subtasks for checked tasks.") +@click.option("--dry-run", is_flag=True, help="Print the subtasks that would be created.") +def jira_create_tasks( + issue_key: str, + tasks_path: Path, + issue_type_name: str, + include_done: bool, + dry_run: bool, +) -> None: + """Create Jira subtasks from a dbt-specify tasks.md file.""" + try: + results = create_subtasks_from_tasks( + client=make_jira_client(), + issue_key=issue_key, + tasks_path=tasks_path.resolve(), + issue_type_name=issue_type_name, + include_done=include_done, + dry_run=dry_run, + ) + except JiraError as error: + click.echo(f"error: {error}", err=True) + raise SystemExit(1) from error + + if not results: + click.echo("no pending tasks found") + return + for result in results: + if result.created: + click.echo(f"created {result.key}: {result.summary}") + elif result.key: + click.echo(f"skipped existing {result.key}: {result.summary}") + else: + click.echo(f"would create: {result.summary}") + + +@jira.command("sync") +@click.argument("issue_key") +@click.option( + "--spec-dir", + required=True, + type=click.Path(file_okay=False, path_type=Path), + help="Path to specs/-/.", +) +@click.option( + "--issue-type", + "issue_type_name", + default="Sub-task", + show_default=True, + help="Jira issue type to create under the parent issue.", +) +@click.option("--dry-run", is_flag=True, help="Print task sync actions without writing to Jira.") +def jira_sync(issue_key: str, spec_dir: Path, issue_type_name: str, dry_run: bool) -> None: + """Attach spec/plan and create Jira subtasks for a spec directory.""" + try: + uploaded, subtasks = sync_spec_dir( + client=make_jira_client(), + issue_key=issue_key, + spec_dir=spec_dir.resolve(), + issue_type_name=issue_type_name, + dry_run=dry_run, + ) + except JiraError as error: + click.echo(f"error: {error}", err=True) + raise SystemExit(1) from error + + for filename in uploaded: + click.echo(f"attached {filename} to {issue_key}") + for result in subtasks: + if result.created: + click.echo(f"created {result.key}: {result.summary}") + elif result.key: + click.echo(f"skipped existing {result.key}: {result.summary}") + else: + click.echo(f"would create: {result.summary}") + if not uploaded and not subtasks: + click.echo("nothing to sync") + + @main.command() def version() -> None: """Print the installed version.""" @@ -202,6 +381,17 @@ def _echo_report(report: ValidationReport, output_format: str) -> None: click.echo(report.to_markdown()) +def _artifact_paths( + spec_path: Path | None, + plan_path: Path | None, + tasks_path: Path | None, + extra_files: tuple[Path, ...], +) -> list[Path]: + files = [path for path in (spec_path, plan_path, tasks_path) if path is not None] + files.extend(extra_files) + return [path.resolve() for path in files] + + def _path_option(args: tuple[str, ...], flag: str, default: Path) -> Path: value = _optional_path_option(args, flag) return value or default diff --git a/src/dbt_specify/jira.py b/src/dbt_specify/jira.py new file mode 100644 index 0000000..78d4d2b --- /dev/null +++ b/src/dbt_specify/jira.py @@ -0,0 +1,648 @@ +"""Jira Cloud bridge for dbt-specify artifacts.""" +from __future__ import annotations + +import base64 +import json +import mimetypes +import os +import re +import uuid +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.parse import quote +from urllib.request import Request, urlopen + +import yaml + + +class JiraError(RuntimeError): + """Raised when Jira integration cannot complete safely.""" + + +@dataclass(frozen=True) +class JiraConfig: + """Jira Cloud connection settings.""" + + base_url: str + email: str + api_token: str + + +@dataclass(frozen=True) +class JiraSubtask: + """Existing Jira subtask summary.""" + + key: str + summary: str + + +@dataclass(frozen=True) +class JiraIssue: + """Normalized Jira issue fields used by dbt-specify.""" + + key: str + summary: str + description: str + project_key: str + issue_url: str + labels: tuple[str, ...] + components: tuple[str, ...] + priority: str | None + issue_type: str | None + status: str | None + subtasks: tuple[JiraSubtask, ...] + + +@dataclass(frozen=True) +class TaskItem: + """Task parsed from tasks.md.""" + + task_id: str + description: str + checked: bool + validates: str | None + + +@dataclass(frozen=True) +class CreatedSubtask: + """Result for a task pushed to Jira.""" + + task_id: str + summary: str + key: str | None + created: bool + + +class JiraClient: + """Small Jira Cloud REST client backed by the Python standard library.""" + + def __init__(self, config: JiraConfig) -> None: + self._config = config + + def get_issue(self, issue_key: str) -> JiraIssue: + """Fetch an issue and normalize fields needed by the bridge.""" + fields = ",".join( + [ + "summary", + "description", + "project", + "labels", + "components", + "priority", + "issuetype", + "status", + "subtasks", + ] + ) + raw = self._request_json( + "GET", + f"/rest/api/3/issue/{quote(issue_key)}?fields={quote(fields)}", + ) + return _parse_issue(raw, self._config.base_url) + + def attach_file(self, issue_key: str, path: Path) -> str: + """Attach a file to a Jira issue and return the uploaded filename.""" + if not path.exists() or not path.is_file(): + raise JiraError(f"attachment file not found: {path}") + + boundary = f"dbt-specify-{uuid.uuid4().hex}" + filename = path.name + content_type = mimetypes.guess_type(filename)[0] or "application/octet-stream" + file_bytes = path.read_bytes() + body = b"".join( + [ + f"--{boundary}\r\n".encode(), + ( + f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n' + ).encode(), + f"Content-Type: {content_type}\r\n\r\n".encode(), + file_bytes, + b"\r\n", + f"--{boundary}--\r\n".encode(), + ] + ) + self._request_json( + "POST", + f"/rest/api/3/issue/{quote(issue_key)}/attachments", + body=body, + headers={ + "Accept": "application/json", + "Content-Type": f"multipart/form-data; boundary={boundary}", + "X-Atlassian-Token": "no-check", + }, + ) + return filename + + def add_comment(self, issue_key: str, text: str) -> None: + """Add a plain-text comment to a Jira issue.""" + self._request_json( + "POST", + f"/rest/api/3/issue/{quote(issue_key)}/comment", + json_body={"body": text_to_adf(text)}, + ) + + def create_subtask( + self, + *, + parent_issue_key: str, + project_key: str, + issue_type_name: str, + summary: str, + description: str, + ) -> str: + """Create a Jira subtask and return its issue key.""" + response = self._request_json( + "POST", + "/rest/api/3/issue", + json_body={ + "fields": { + "project": {"key": project_key}, + "parent": {"key": parent_issue_key}, + "issuetype": {"name": issue_type_name}, + "summary": summary, + "description": text_to_adf(description), + "labels": ["dbt-specify"], + } + }, + ) + if not isinstance(response, dict): + raise JiraError("Jira did not return an object for the created subtask.") + key = response.get("key") + if not isinstance(key, str): + raise JiraError("Jira did not return a key for the created subtask.") + return key + + def _request_json( + self, + method: str, + path: str, + *, + json_body: dict[str, object] | None = None, + body: bytes | None = None, + headers: dict[str, str] | None = None, + ) -> dict[str, Any] | list[Any]: + data = body + request_headers = { + "Authorization": _basic_auth(self._config.email, self._config.api_token), + "Accept": "application/json", + } + if json_body is not None: + data = json.dumps(json_body).encode() + request_headers["Content-Type"] = "application/json" + if headers: + request_headers.update(headers) + + request = Request( + f"{self._config.base_url.rstrip('/')}{path}", + data=data, + headers=request_headers, + method=method, + ) + try: + with urlopen(request, timeout=30) as response: + response_body = response.read() + except HTTPError as error: + detail = error.read().decode(errors="replace") + raise JiraError(f"Jira API request failed with HTTP {error.code}: {detail}") from error + except URLError as error: + raise JiraError(f"Jira API request failed: {error.reason}") from error + + if not response_body: + return {} + parsed = json.loads(response_body.decode()) + if not isinstance(parsed, (dict, list)): + raise JiraError("Jira API returned an unexpected JSON response.") + return parsed + + +def config_from_env() -> JiraConfig: + """Load Jira Cloud credentials from environment variables.""" + base_url = os.environ.get("JIRA_BASE_URL", "").strip() + email = os.environ.get("JIRA_EMAIL", "").strip() + api_token = os.environ.get("JIRA_API_TOKEN", "").strip() + missing = [ + name + for name, value in ( + ("JIRA_BASE_URL", base_url), + ("JIRA_EMAIL", email), + ("JIRA_API_TOKEN", api_token), + ) + if not value + ] + if missing: + joined = ", ".join(missing) + raise JiraError(f"Missing Jira environment variable(s): {joined}") + return JiraConfig(base_url=base_url.rstrip("/"), email=email, api_token=api_token) + + +def make_jira_client() -> JiraClient: + """Create a Jira client from environment variables.""" + return JiraClient(config_from_env()) + + +def pull_issue_to_spec( + *, + client: JiraClient, + issue_key: str, + target_dir: Path, + slug: str | None = None, + force: bool = False, +) -> Path: + """Create a local spec directory from a Jira issue.""" + issue = client.get_issue(issue_key) + specs_dir = target_dir / "specs" + specs_dir.mkdir(exist_ok=True) + spec_dir = specs_dir / f"{_next_spec_number(specs_dir)}-{_slug_for_issue(issue, slug)}" + if spec_dir.exists() and not force: + raise JiraError(f"spec directory already exists: {spec_dir}") + spec_dir.mkdir(parents=True, exist_ok=True) + + (spec_dir / "jira.yml").write_text(_jira_yml(issue, spec_dir, target_dir)) + spec_path = spec_dir / "spec.md" + if spec_path.exists() and not force: + raise JiraError(f"spec.md already exists: {spec_path}") + spec_path.write_text(_spec_from_issue(issue)) + return spec_dir + + +def attach_artifacts( + *, + client: JiraClient, + issue_key: str, + files: list[Path], + comment: bool = True, +) -> list[str]: + """Attach local artifacts to a Jira issue.""" + if not files: + raise JiraError("No files were provided to attach.") + + uploaded: list[str] = [] + for path in files: + uploaded.append(client.attach_file(issue_key, path)) + + if comment: + client.add_comment( + issue_key, + "dbt-specify attached approved artifact(s):\n" + + "\n".join(f"- {filename}" for filename in uploaded), + ) + return uploaded + + +def create_subtasks_from_tasks( + *, + client: JiraClient, + issue_key: str, + tasks_path: Path, + issue_type_name: str = "Sub-task", + include_done: bool = False, + dry_run: bool = False, +) -> list[CreatedSubtask]: + """Create Jira subtasks from a dbt-specify tasks.md file.""" + if not tasks_path.exists() or not tasks_path.is_file(): + raise JiraError(f"tasks file not found: {tasks_path}") + + issue = client.get_issue(issue_key) + tasks = parse_tasks(tasks_path.read_text()) + existing = { + _extract_task_id_from_summary(subtask.summary): subtask + for subtask in issue.subtasks + if _extract_task_id_from_summary(subtask.summary) is not None + } + + results: list[CreatedSubtask] = [] + for task in tasks: + if task.checked and not include_done: + continue + + summary = _subtask_summary(task) + existing_subtask = existing.get(task.task_id) + if existing_subtask is not None: + results.append( + CreatedSubtask( + task_id=task.task_id, + summary=summary, + key=existing_subtask.key, + created=False, + ) + ) + continue + + if dry_run: + results.append( + CreatedSubtask(task_id=task.task_id, summary=summary, key=None, created=False) + ) + continue + + key = client.create_subtask( + parent_issue_key=issue.key, + project_key=issue.project_key, + issue_type_name=issue_type_name, + summary=summary, + description=_subtask_description(task, tasks_path), + ) + results.append(CreatedSubtask(task_id=task.task_id, summary=summary, key=key, created=True)) + + return results + + +def sync_spec_dir( + *, + client: JiraClient, + issue_key: str, + spec_dir: Path, + issue_type_name: str = "Sub-task", + dry_run: bool = False, +) -> tuple[list[str], list[CreatedSubtask]]: + """Attach approved artifacts and create Jira subtasks for a spec directory.""" + files = [path for path in (spec_dir / "spec.md", spec_dir / "plan.md") if path.exists()] + uploaded: list[str] = [] + if files and not dry_run: + uploaded = attach_artifacts(client=client, issue_key=issue_key, files=files) + + subtasks: list[CreatedSubtask] = [] + tasks_path = spec_dir / "tasks.md" + if tasks_path.exists(): + subtasks = create_subtasks_from_tasks( + client=client, + issue_key=issue_key, + tasks_path=tasks_path, + issue_type_name=issue_type_name, + dry_run=dry_run, + ) + + return uploaded, subtasks + + +_TASK_RE = re.compile( + r"^\s*-\s+\[(?P[ xX])\]\s+(?:\*\*)?(?PT[-_]?\d+)(?:\*\*)?\s+[—-]\s+" + r"(?P.+?)\s*$" +) +_VALIDATES_RE = re.compile(r"^\s*-\s+\*\*Validates:\*\*\s+(?P.+?)\s*$") +_SUBTASK_ID_RE = re.compile(r"^\[dbt-specify (?PT-\d+)\]") + + +def parse_tasks(text: str) -> list[TaskItem]: + """Parse dbt-specify task list entries from tasks.md.""" + tasks: list[TaskItem] = [] + current_index: int | None = None + + for line in text.splitlines(): + task_match = _TASK_RE.match(line) + if task_match is not None: + task = TaskItem( + task_id=_normalize_task_id(task_match.group("id")), + description=task_match.group("description").strip(), + checked=task_match.group("checked").lower() == "x", + validates=None, + ) + tasks.append(task) + current_index = len(tasks) - 1 + continue + + validates_match = _VALIDATES_RE.match(line) + if validates_match is not None and current_index is not None: + previous = tasks[current_index] + tasks[current_index] = TaskItem( + task_id=previous.task_id, + description=previous.description, + checked=previous.checked, + validates=validates_match.group("validates").strip(), + ) + + return tasks + + +def text_to_adf(text: str) -> dict[str, object]: + """Convert plain text to a minimal Atlassian Document Format document.""" + content: list[dict[str, object]] = [] + for raw_line in text.splitlines(): + line = raw_line.strip() + if not line: + continue + content.append( + { + "type": "paragraph", + "content": [{"type": "text", "text": line}], + } + ) + if not content: + content.append({"type": "paragraph", "content": [{"type": "text", "text": " "}]}) + return {"type": "doc", "version": 1, "content": content} + + +def _parse_issue(raw: dict[str, Any] | list[Any], base_url: str) -> JiraIssue: + if not isinstance(raw, dict): + raise JiraError("Jira issue response was not an object.") + fields = raw.get("fields") + if not isinstance(fields, dict): + raise JiraError("Jira issue response did not include fields.") + + key = _string(raw.get("key"), "issue key") + project = _mapping(fields.get("project")) + priority = _optional_name(fields.get("priority")) + issue_type = _optional_name(fields.get("issuetype")) + status = _optional_name(fields.get("status")) + components = tuple( + name + for item in _list(fields.get("components")) + if (name := _optional_name(item)) is not None + ) + labels = tuple(value for value in _list(fields.get("labels")) if isinstance(value, str)) + subtasks = tuple(_parse_subtask(item) for item in _list(fields.get("subtasks"))) + + return JiraIssue( + key=key, + summary=_string(fields.get("summary"), "summary"), + description=adf_to_text(fields.get("description")), + project_key=_string(project.get("key"), "project key"), + issue_url=f"{base_url.rstrip('/')}/browse/{key}", + labels=labels, + components=components, + priority=priority, + issue_type=issue_type, + status=status, + subtasks=subtasks, + ) + + +def _parse_subtask(value: object) -> JiraSubtask: + mapping = _mapping(value) + fields = _mapping(mapping.get("fields")) + return JiraSubtask( + key=_string(mapping.get("key"), "subtask key"), + summary=_string(fields.get("summary"), "subtask summary"), + ) + + +def adf_to_text(value: object) -> str: + """Convert a Jira ADF document, string, or null value to readable text.""" + if value is None: + return "" + if isinstance(value, str): + return value + if isinstance(value, dict): + node_type = value.get("type") + if node_type == "text": + text = value.get("text") + return text if isinstance(text, str) else "" + parts = [adf_to_text(child) for child in _list(value.get("content"))] + joined = " ".join(part for part in parts if part).strip() + if node_type in {"paragraph", "heading", "blockquote"}: + return joined + if node_type == "listItem": + return f"- {joined}" if joined else "" + if node_type in {"bulletList", "orderedList", "doc"}: + return "\n".join(part for part in parts if part) + return joined + if isinstance(value, list): + return "\n".join(adf_to_text(item) for item in value) + return "" + + +def _jira_yml(issue: JiraIssue, spec_dir: Path, target_dir: Path) -> str: + payload = { + "issue_key": issue.key, + "issue_url": issue.issue_url, + "summary": issue.summary, + "project_key": issue.project_key, + "spec_dir": str(spec_dir.relative_to(target_dir)), + "last_synced_at": datetime.now(UTC).replace(microsecond=0).isoformat(), + } + return yaml.safe_dump(payload, sort_keys=False) + + +def _spec_from_issue(issue: JiraIssue) -> str: + today = datetime.now(UTC).date().isoformat() + labels = ", ".join(issue.labels) if issue.labels else "none" + components = ", ".join(issue.components) if issue.components else "none" + description = issue.description.strip() or "No Jira description was provided." + return f"""# {issue.summary} + +**Ticket:** {issue.issue_url} +**Author:** Jira +**Date:** {today} +**Status:** draft + +## Problem + +Jira issue `{issue.key}` requests: {issue.summary} + +Source Jira description: + +{description} + +## Users + +| User | Job to be done | +|---|---| +| | | + +## What this is + + + +## Acceptance criteria + +- AC1: The system shall implement `{issue.key}` without unrelated dbt changes. +- AC2: When affected dbt models are parsed, the system shall preserve downstream references. +- AC3: Where grain, metrics, or semantic behavior changes, the system shall document approval. + +## Out of scope + +- Raw agent scratch notes are not part of the approved Jira record. +- Unrelated model cleanup is not included unless added to the approved plan. + +## Constraints + +- Warehouse: +- Materialization: +- Grain: +- Refresh cadence: +- Downstream consumers: +- Jira priority: {issue.priority or "not set"} +- Jira issue type: {issue.issue_type or "not set"} +- Jira status: {issue.status or "not set"} +- Jira labels: {labels} +- Jira components: {components} + +## Open questions + +- [ ] Confirm the business owner who can approve this spec. +- [ ] Confirm affected dbt models, metrics, and downstream consumers. +- [ ] Confirm the validation evidence that must be attached back to Jira. +""" + + +def _next_spec_number(specs_dir: Path) -> str: + highest = 0 + for path in specs_dir.iterdir(): + if not path.is_dir(): + continue + prefix = path.name.split("-", 1)[0] + if prefix.isdigit(): + highest = max(highest, int(prefix)) + return f"{highest + 1:03d}" + + +def _slug_for_issue(issue: JiraIssue, requested_slug: str | None) -> str: + base = requested_slug or f"{issue.key}-{issue.summary}" + slug = re.sub(r"[^a-z0-9]+", "-", base.lower()).strip("-") + return slug[:80] or issue.key.lower() + + +def _subtask_summary(task: TaskItem) -> str: + return f"[dbt-specify {task.task_id}] {task.description}"[:255] + + +def _subtask_description(task: TaskItem, tasks_path: Path) -> str: + lines = [ + f"Created from {tasks_path}", + "", + f"Task: {task.task_id}", + f"Description: {task.description}", + ] + if task.validates: + lines.append(f"Validates: {task.validates}") + return "\n".join(lines) + + +def _extract_task_id_from_summary(summary: str) -> str | None: + match = _SUBTASK_ID_RE.match(summary) + if match is None: + return None + return match.group("id") + + +def _normalize_task_id(task_id: str) -> str: + digits = re.sub(r"\D", "", task_id) + return f"T-{int(digits):02d}" + + +def _basic_auth(email: str, api_token: str) -> str: + token = base64.b64encode(f"{email}:{api_token}".encode()).decode() + return f"Basic {token}" + + +def _string(value: object, field_name: str) -> str: + if not isinstance(value, str) or not value: + raise JiraError(f"Jira issue response is missing {field_name}.") + return value + + +def _mapping(value: object) -> dict[str, Any]: + return value if isinstance(value, dict) else {} + + +def _list(value: object) -> list[Any]: + return value if isinstance(value, list) else [] + + +def _optional_name(value: object) -> str | None: + mapping = _mapping(value) + name = mapping.get("name") + return name if isinstance(name, str) else None diff --git a/tests/test_docs.py b/tests/test_docs.py index 263128e..3215b09 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -135,6 +135,7 @@ def test_readme_highlights_enterprise_adoption_choices() -> None: assert "Enterprise adoption choices" in readme assert "Spec folder structure" in readme + assert "Jira bridge" in readme assert "Development workflow" in readme assert "Repo retention" in readme assert "Brownfield rollout" in readme @@ -144,6 +145,7 @@ def test_readme_highlights_enterprise_adoption_choices() -> None: assert "keep approved decision records, not raw agent scratch work" in readme assert "001-core-customer-segmentation" in readme assert "not as nested folders" in readme + assert "docs/integrations/jira.md" in readme def test_uvx_command_examples_are_clear() -> None: @@ -169,6 +171,7 @@ def test_tutorials_cover_enterprise_onboarding_path() -> None: tutorials_dir / "02-jaffle-shop-change.md", tutorials_dir / "03-brownfield-enterprise-adoption.md", tutorials_dir / "04-skills-and-sub-agent-handoffs.md", + tutorials_dir / "05-jira-to-spec-workflow.md", ] for path in required_paths: assert path.exists(), f"Missing tutorial: {path.relative_to(ROOT)}" @@ -179,6 +182,7 @@ def test_tutorials_cover_enterprise_onboarding_path() -> None: assert "Ship a jaffle-shop change" in index assert "Adopt in a brownfield enterprise repo" in index assert "Run skills and sub-agent handoffs" in index + assert "Jira to spec workflow" in index handoffs = (tutorials_dir / "04-skills-and-sub-agent-handoffs.md").read_text() assert "dbt Labs skills" in handoffs @@ -186,6 +190,24 @@ def test_tutorials_cover_enterprise_onboarding_path() -> None: assert "Human approval remains the merge gate" in handoffs +def test_jira_integration_docs_are_documented() -> None: + guide = ROOT / "docs" / "integrations" / "jira.md" + tutorial = ROOT / "docs" / "tutorials" / "05-jira-to-spec-workflow.md" + assert guide.exists() + _assert_local_links_exist(guide) + _assert_local_links_exist(tutorial) + + guide_text = guide.read_text() + tutorial_text = tutorial.read_text() + assert "JIRA_BASE_URL" in guide_text + assert "dbt-specify jira pull" in guide_text + assert "dbt-specify jira attach" in guide_text + assert "dbt-specify jira create-tasks" in guide_text + assert "dbt-specify jira sync" in guide_text + assert "Jira story -> spec.md -> plan.md -> tasks.md" in guide_text + assert "The PR remains the merge gate" in tutorial_text + + def _markdown_links(text: str) -> list[str]: return re.findall(r"(? None: assert "ci" in result.output assert "doctor" in result.output assert "init" in result.output + assert "jira" in result.output assert "report" in result.output assert "validate" in result.output assert "version" in result.output @@ -40,7 +41,7 @@ def test_cli_version_prints_package_version() -> None: runner = CliRunner() result = runner.invoke(main, ["version"]) assert result.exit_code == 0 - assert "1.2.0" in result.output + assert "1.3.0" in result.output def test_init_help_shows_flags() -> None: diff --git a/tests/test_jira.py b/tests/test_jira.py new file mode 100644 index 0000000..c0f4a48 --- /dev/null +++ b/tests/test_jira.py @@ -0,0 +1,203 @@ +"""Tests for Jira bridge helpers.""" +from __future__ import annotations + +from pathlib import Path + +import yaml +from click.testing import CliRunner + +from dbt_specify.cli import main +from dbt_specify.jira import ( + CreatedSubtask, + JiraIssue, + JiraSubtask, + attach_artifacts, + create_subtasks_from_tasks, + parse_tasks, + pull_issue_to_spec, +) + + +class FakeJiraClient: + def __init__(self, issue: JiraIssue) -> None: + self.issue = issue + self.attachments: list[Path] = [] + self.comments: list[str] = [] + self.created: list[dict[str, str]] = [] + + def get_issue(self, issue_key: str) -> JiraIssue: + assert issue_key == self.issue.key + return self.issue + + def attach_file(self, issue_key: str, path: Path) -> str: + assert issue_key == self.issue.key + self.attachments.append(path) + return path.name + + def add_comment(self, issue_key: str, text: str) -> None: + assert issue_key == self.issue.key + self.comments.append(text) + + def create_subtask( + self, + *, + parent_issue_key: str, + project_key: str, + issue_type_name: str, + summary: str, + description: str, + ) -> str: + assert parent_issue_key == self.issue.key + assert project_key == self.issue.project_key + assert issue_type_name == "Sub-task" + key = f"{self.issue.project_key}-{len(self.created) + 200}" + self.created.append({"summary": summary, "description": description, "key": key}) + return key + + +def test_cli_help_lists_jira_group() -> None: + runner = CliRunner() + result = runner.invoke(main, ["--help"]) + assert result.exit_code == 0, result.output + assert "jira" in result.output + + jira_result = runner.invoke(main, ["jira", "--help"]) + assert jira_result.exit_code == 0, jira_result.output + assert "pull" in jira_result.output + assert "attach" in jira_result.output + assert "create-tasks" in jira_result.output + assert "sync" in jira_result.output + + +def test_pull_issue_to_spec_creates_traceable_spec(tmp_path: Path) -> None: + (tmp_path / "dbt_project.yml").write_text("name: demo\nversion: '1.0.0'\nprofile: demo\n") + client = FakeJiraClient(_issue()) + + spec_dir = pull_issue_to_spec( + client=client, # type: ignore[arg-type] + issue_key="NBA-123", + target_dir=tmp_path, + ) + + assert spec_dir.name == "001-nba-123-player-journey-mart" + spec = (spec_dir / "spec.md").read_text() + assert "# Player journey mart" in spec + assert "https://example.atlassian.net/browse/NBA-123" in spec + assert "AC1: The system shall implement `NBA-123` without unrelated dbt changes." in spec + + jira_yml = yaml.safe_load((spec_dir / "jira.yml").read_text()) + assert jira_yml["issue_key"] == "NBA-123" + assert jira_yml["spec_dir"] == "specs/001-nba-123-player-journey-mart" + + +def test_parse_tasks_extracts_pending_items_and_validation() -> None: + tasks = parse_tasks( + """# Tasks + +## Task list + +- [ ] **T-01** — Add mart SQL. AC: AC1. + - **Done when:** model builds + - **Validates:** AC1, AC2 + +- [x] **T-02** — Add docs. AC: AC3. + - **Validates:** AC3 +""" + ) + + assert [task.task_id for task in tasks] == ["T-01", "T-02"] + assert tasks[0].description == "Add mart SQL. AC: AC1." + assert tasks[0].validates == "AC1, AC2" + assert tasks[1].checked is True + + +def test_attach_artifacts_uploads_files_and_comments(tmp_path: Path) -> None: + client = FakeJiraClient(_issue()) + spec = tmp_path / "spec.md" + plan = tmp_path / "plan.md" + spec.write_text("spec") + plan.write_text("plan") + + uploaded = attach_artifacts( + client=client, # type: ignore[arg-type] + issue_key="NBA-123", + files=[spec, plan], + ) + + assert uploaded == ["spec.md", "plan.md"] + assert client.attachments == [spec, plan] + assert "spec.md" in client.comments[0] + assert "plan.md" in client.comments[0] + + +def test_create_subtasks_is_idempotent(tmp_path: Path) -> None: + tasks_path = tmp_path / "tasks.md" + tasks_path.write_text( + """# Tasks + +## Task list + +- [ ] **T-01** — Add player journey mart. AC: AC1. + - **Validates:** AC1 +- [ ] **T-02** — Add tests and docs. AC: AC2. + - **Validates:** AC2 +""" + ) + issue = _issue( + subtasks=( + JiraSubtask( + key="NBA-199", + summary="[dbt-specify T-01] Add player journey mart. AC: AC1.", + ), + ) + ) + client = FakeJiraClient(issue) + + results = create_subtasks_from_tasks( + client=client, # type: ignore[arg-type] + issue_key="NBA-123", + tasks_path=tasks_path, + ) + + assert results == [ + CreatedSubtask( + task_id="T-01", + summary="[dbt-specify T-01] Add player journey mart. AC: AC1.", + key="NBA-199", + created=False, + ), + CreatedSubtask( + task_id="T-02", + summary="[dbt-specify T-02] Add tests and docs. AC: AC2.", + key="NBA-200", + created=True, + ), + ] + assert client.created == [ + { + "summary": "[dbt-specify T-02] Add tests and docs. AC: AC2.", + "description": ( + f"Created from {tasks_path}\n\n" + "Task: T-02\n" + "Description: Add tests and docs. AC: AC2.\n" + "Validates: AC2" + ), + "key": "NBA-200", + } + ] + + +def _issue(subtasks: tuple[JiraSubtask, ...] = ()) -> JiraIssue: + return JiraIssue( + key="NBA-123", + summary="Player journey mart", + description="Build a season-by-season player journey.", + project_key="NBA", + issue_url="https://example.atlassian.net/browse/NBA-123", + labels=("analytics",), + components=("dbt",), + priority="Medium", + issue_type="Story", + status="Ready", + subtasks=subtasks, + )