From 6e065f7d2f40fe9162e8647cdb280849cb983eb8 Mon Sep 17 00:00:00 2001 From: KKranthi6881 Date: Mon, 18 May 2026 13:18:56 -0500 Subject: [PATCH] Add Confluence bridge workflow --- CHANGELOG.md | 7 + README.md | 24 + docs/integrations/confluence.md | 137 ++++ .../06-confluence-context-workflow.md | 86 +++ docs/tutorials/README.md | 3 + pyproject.toml | 3 +- src/dbt_specify/_version.py | 2 +- src/dbt_specify/cli.py | 122 ++++ src/dbt_specify/confluence.py | 593 ++++++++++++++++++ tests/test_confluence.py | 199 ++++++ tests/test_docs.py | 21 + tests/test_init.py | 3 +- 12 files changed, 1197 insertions(+), 3 deletions(-) create mode 100644 docs/integrations/confluence.md create mode 100644 docs/tutorials/06-confluence-context-workflow.md create mode 100644 src/dbt_specify/confluence.py create mode 100644 tests/test_confluence.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e8f523..8b63e09 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.4.0] — 2026-05-18 + +### Added +- Confluence bridge commands for pulling wiki pages into local spec context, publishing spec + summaries, and syncing existing Confluence pages from approved artifacts. +- Confluence integration docs and tutorial for knowledge-base context workflows. + ## [1.3.0] — 2026-05-18 ### Added diff --git a/README.md b/README.md index 7b65256..f407760 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ become clear. | 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) | +| Confluence integration | Pull approved wiki context into `specs//context/` and publish spec summaries back to Confluence. | [Confluence integration](docs/integrations/confluence.md) | The key repo hygiene rule: keep approved decision records, not raw agent scratch work. @@ -193,6 +194,28 @@ uvx --from dbt-spec-kit dbt-specify jira create-tasks NBA-123 \ Jira remains intake and tracking. `spec.md` and `plan.md` remain the approved engineering contract. See [Jira integration](docs/integrations/jira.md). +## Confluence bridge + +For teams that use Confluence as the knowledge base: + +```bash +export CONFLUENCE_BASE_URL="https://your-company.atlassian.net" +export CONFLUENCE_EMAIL="you@company.com" +export CONFLUENCE_API_TOKEN="" + +uvx --from dbt-spec-kit dbt-specify confluence pull-page 123456789 \ + --to specs/001-player-journey/context/player-metrics.md +uvx --from dbt-spec-kit dbt-specify confluence publish \ + --spec-dir specs/001-player-journey \ + --space-key DATA \ + --parent-id 987654321 +uvx --from dbt-spec-kit dbt-specify confluence sync \ + --spec-dir specs/001-player-journey +``` + +Confluence remains the knowledge base. `spec.md`, `plan.md`, and `tasks.md` remain the approved +implementation contract. See [Confluence integration](docs/integrations/confluence.md). + ## Who this is for - Analytics engineers who want AI help without losing dbt conventions. @@ -211,6 +234,7 @@ See [Jira integration](docs/integrations/jira.md). - [Skills and sub-agents](docs/skills-and-sub-agents.md) - [Enterprise CI](docs/enterprise-ci.md) - [Jira integration](docs/integrations/jira.md) +- [Confluence integration](docs/integrations/confluence.md) - [Brownfield onboarding](docs/brownfield-onboarding.md) - [EARS cheatsheet](docs/ears-cheatsheet.md) - [Releasing to PyPI](docs/releasing.md) diff --git a/docs/integrations/confluence.md b/docs/integrations/confluence.md new file mode 100644 index 0000000..4068c46 --- /dev/null +++ b/docs/integrations/confluence.md @@ -0,0 +1,137 @@ +# Confluence integration + +The Confluence bridge lets teams use wiki pages as approved business and architecture context +without turning the wiki into the source of truth for dbt implementation. + +```text +Confluence context -> local context markdown -> spec.md -> plan.md -> Confluence summary page +``` + +Confluence is for shared knowledge. The local `spec.md`, `plan.md`, and `tasks.md` files remain the +implementation contract. + +## Authentication + +Set these environment variables before running Confluence commands: + +```bash +export CONFLUENCE_BASE_URL="https://your-company.atlassian.net" +export CONFLUENCE_EMAIL="you@company.com" +export CONFLUENCE_API_TOKEN="" +``` + +Create the token from [Atlassian account security settings](https://id.atlassian.com/manage-profile/security/api-tokens) +and make sure the account can read, create, and update pages in the target space. The bridge uses +the [Confluence Cloud REST API v2 page endpoints](https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-page/). +Do not commit these values. Use local shell secrets or CI secrets. + +## Pull a wiki page into spec context + +Run from the dbt project root: + +```bash +uvx --from dbt-spec-kit dbt-specify confluence pull-page 123456789 \ + --to specs/001-player-journey/context/player-metrics.md +``` + +This creates or updates: + +```text +specs/001-player-journey/ + confluence.yml + context/ + player-metrics.md +``` + +The context file contains the page title, URL, page id, sync timestamp, and a lightweight markdown +rendering of the page body. `confluence.yml` records the source page so reviewers know where the +context came from. + +Use this for focused wiki context only. Do not bulk-copy entire spaces into the repo. + +## Publish a spec summary page + +After `spec.md` and `plan.md` are approved: + +```bash +uvx --from dbt-spec-kit dbt-specify confluence publish \ + --spec-dir specs/001-player-journey \ + --space-key DATA \ + --parent-id 987654321 +``` + +This creates a Confluence page summarizing the local artifacts and writes page metadata to +`specs/001-player-journey/confluence.yml`. + +If your automation already knows the Confluence v2 space id, use `--space-id` instead of +`--space-key`: + +```bash +uvx --from dbt-spec-kit dbt-specify confluence publish \ + --spec-dir specs/001-player-journey \ + --space-id 12345 +``` + +To update an existing page: + +```bash +uvx --from dbt-spec-kit dbt-specify confluence publish \ + --spec-dir specs/001-player-journey \ + --page-id 123456789 +``` + +Preview without writing: + +```bash +uvx --from dbt-spec-kit dbt-specify confluence publish \ + --spec-dir specs/001-player-journey \ + --space-key DATA \ + --dry-run +``` + +Dry-run reads local files only and does not require Confluence credentials. + +## Sync a previously published page + +Once `confluence.yml` has a `page_id`, use: + +```bash +uvx --from dbt-spec-kit dbt-specify confluence sync \ + --spec-dir specs/001-player-journey +``` + +This updates the recorded page from current local files. + +## What gets published + +The summary page includes the available files from the spec directory: + +- `spec.md` +- `plan.md` +- `tasks.md` +- `review.md` +- `governance-review.md` +- `dbt-specify-report.md` +- markdown files under `context/` + +The page is intentionally a readable knowledge summary. It does not replace the PR, CI checks, or +the local approved artifacts. + +## Recommended enterprise policy + +- Pull only relevant Confluence pages into `specs//context/`. +- Keep page ids and URLs in `confluence.yml` for traceability. +- Publish only approved or review-ready spec directories. +- Use Confluence for durable business summaries, onboarding, architecture notes, and metric + definitions. +- Keep Jira as the ticket/task tracker and the PR as the merge gate. +- Use `confluence publish --dry-run` before the first production rollout. + +## Troubleshooting + +- `Missing Confluence environment variable`: set `CONFLUENCE_BASE_URL`, `CONFLUENCE_EMAIL`, and + `CONFLUENCE_API_TOKEN`. +- `Confluence space not found`: verify `--space-key` or pass `--space-id`. +- `confluence.yml is missing page_id`: run `confluence publish` before `confluence sync`. +- `HTTP 401`: check the email/token pair and site URL. +- `HTTP 403`: confirm the account can read pages and create or update pages in the target space. diff --git a/docs/tutorials/06-confluence-context-workflow.md b/docs/tutorials/06-confluence-context-workflow.md new file mode 100644 index 0000000..cc4047a --- /dev/null +++ b/docs/tutorials/06-confluence-context-workflow.md @@ -0,0 +1,86 @@ +# Tutorial 6: Confluence context workflow + +This tutorial shows how to use Confluence pages as approved business context for a dbt-spec-kit +feature, then publish the final spec summary back to Confluence. + +## 1. Configure Confluence credentials + +```bash +export CONFLUENCE_BASE_URL="https://your-company.atlassian.net" +export CONFLUENCE_EMAIL="you@company.com" +export CONFLUENCE_API_TOKEN="" +``` + +Use a local shell profile or secret manager. Do not write tokens into the repo. + +## 2. Pull relevant wiki context + +Run from the dbt project root: + +```bash +uvx --from dbt-spec-kit dbt-specify confluence pull-page 123456789 \ + --to specs/001-player-journey/context/player-metrics.md +``` + +Expected output: + +```text +pulled Player metric definitions (123456789) +wrote specs/001-player-journey/context/player-metrics.md +``` + +The spec directory also gets `confluence.yml` with source page traceability. + +## 3. Ask the agent to use context carefully + +Ask your agent: + +```text +Read specs/001-player-journey/context/player-metrics.md and the current dbt project. +Use it as source context for business meaning, but keep the approved spec.md as the implementation +contract. Do not edit SQL or YAML yet. +``` + +Then run the normal workflow: + +```text +/dbt.specify Build a player journey mart using the approved player metrics definitions. +/dbt.plan +/dbt.tasks +``` + +## 4. Publish the approved summary + +After `spec.md` and `plan.md` are approved: + +```bash +uvx --from dbt-spec-kit dbt-specify confluence publish \ + --spec-dir specs/001-player-journey \ + --space-key DATA \ + --parent-id 987654321 +``` + +This creates a Confluence page and records the page id in: + +```text +specs/001-player-journey/confluence.yml +``` + +## 5. Sync after review + +After implementation and review evidence are added: + +```bash +uvx --from dbt-spec-kit dbt-specify report --format markdown \ + > specs/001-player-journey/dbt-specify-report.md + +uvx --from dbt-spec-kit dbt-specify confluence sync \ + --spec-dir specs/001-player-journey +``` + +## Success criteria + +- Confluence source context is stored under `context/`, not copied blindly into the spec. +- `confluence.yml` records source page ids and the published summary page id. +- The Confluence summary reflects approved local artifacts. +- The PR remains the merge gate for dbt code. diff --git a/docs/tutorials/README.md b/docs/tutorials/README.md index f5a4cd0..4b93ac6 100644 --- a/docs/tutorials/README.md +++ b/docs/tutorials/README.md @@ -11,6 +11,7 @@ Use them in order when onboarding a team: | [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 | +| [6. Confluence context workflow](06-confluence-context-workflow.md) | 20 min | Enterprise teams using Confluence | Pull wiki context into specs and publish approved summaries | ## Learning path @@ -22,6 +23,7 @@ Install -> implement one task -> attach CI evidence -> sync approved artifacts to Jira + -> publish durable context to Confluence -> review and merge ``` @@ -37,3 +39,4 @@ GitHub Copilot, Gemini CLI, Cline, and similar tools can all use the generated p - 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. +- Confluence remains the knowledge base, while local specs remain the implementation contract. diff --git a/pyproject.toml b/pyproject.toml index e5579b8..d951c1f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "dbt-spec-kit" -version = "1.3.0" +version = "1.4.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" } @@ -26,6 +26,7 @@ keywords = [ "duckdb", "athena", "jira", + "confluence", ] classifiers = [ "Development Status :: 3 - Alpha", diff --git a/src/dbt_specify/_version.py b/src/dbt_specify/_version.py index af44c69..6f410a7 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.3.0" +__version__ = "1.4.0" diff --git a/src/dbt_specify/cli.py b/src/dbt_specify/cli.py index 0cf3055..5f9739d 100644 --- a/src/dbt_specify/cli.py +++ b/src/dbt_specify/cli.py @@ -6,6 +6,17 @@ import click from dbt_specify._version import __version__ +from dbt_specify.confluence import ( + ConfluenceError, + make_confluence_client, + pull_page_to_context, +) +from dbt_specify.confluence import ( + publish_spec_dir as publish_confluence_spec_dir, +) +from dbt_specify.confluence import ( + sync_spec_dir as sync_confluence_spec_dir, +) 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 @@ -353,6 +364,117 @@ def jira_sync(issue_key: str, spec_dir: Path, issue_type_name: str, dry_run: boo click.echo("nothing to sync") +@main.group() +def confluence() -> None: + """Read from and publish dbt-specify context to Confluence Cloud.""" + + +@confluence.command("pull-page") +@click.argument("page_id") +@click.option( + "--to", + "output_path", + required=True, + type=click.Path(dir_okay=False, path_type=Path), + help="Local markdown file to write, usually specs//context/.md.", +) +@click.option( + "--spec-dir", + type=click.Path(file_okay=False, path_type=Path), + default=None, + help="Optional spec directory whose confluence.yml should record this source page.", +) +def confluence_pull_page(page_id: str, output_path: Path, spec_dir: Path | None) -> None: + """Pull a Confluence page into local markdown context.""" + try: + page = pull_page_to_context( + client=make_confluence_client(), + page_id=page_id, + output_path=output_path.resolve(), + spec_dir=spec_dir.resolve() if spec_dir else None, + ) + except ConfluenceError as error: + click.echo(f"error: {error}", err=True) + raise SystemExit(1) from error + + click.echo(f"pulled {page.title} ({page.page_id})") + click.echo(f"wrote {output_path}") + + +@confluence.command("publish") +@click.option( + "--spec-dir", + required=True, + type=click.Path(file_okay=False, path_type=Path), + help="Path to specs/-/.", +) +@click.option("--space-key", default=None, help="Confluence space key for new pages.") +@click.option("--space-id", default=None, help="Confluence v2 space id for new pages.") +@click.option("--parent-id", default=None, help="Optional Confluence parent page id.") +@click.option("--page-id", default=None, help="Existing Confluence page id to update.") +@click.option("--title", default=None, help="Page title. Defaults to dbt-spec-kit: .") +@click.option("--dry-run", is_flag=True, help="Print the publish action without writing.") +def confluence_publish( + spec_dir: Path, + space_key: str | None, + space_id: str | None, + parent_id: str | None, + page_id: str | None, + title: str | None, + dry_run: bool, +) -> None: + """Create or update a Confluence summary page for a spec directory.""" + try: + page = publish_confluence_spec_dir( + client=None if dry_run else make_confluence_client(), + spec_dir=spec_dir.resolve(), + space_key=space_key, + space_id=space_id, + parent_id=parent_id, + page_id=page_id, + title=title, + dry_run=dry_run, + ) + except ConfluenceError as error: + click.echo(f"error: {error}", err=True) + raise SystemExit(1) from error + + action = "would create" if dry_run and page.created else "would update" if dry_run else ( + "created" if page.created else "updated" + ) + click.echo(f"{action} Confluence page {page.page_id}: {page.title}") + if page.page_url != "": + click.echo(page.page_url) + + +@confluence.command("sync") +@click.option( + "--spec-dir", + required=True, + type=click.Path(file_okay=False, path_type=Path), + help="Path to specs/-/ with confluence.yml.", +) +@click.option("--dry-run", is_flag=True, help="Print the sync action without writing.") +def confluence_sync(spec_dir: Path, dry_run: bool) -> None: + """Update the Confluence page recorded in specs//confluence.yml.""" + try: + page = sync_confluence_spec_dir( + client=None if dry_run else make_confluence_client(), + spec_dir=spec_dir.resolve(), + dry_run=dry_run, + ) + except ConfluenceError as error: + click.echo(f"error: {error}", err=True) + raise SystemExit(1) from error + + action = "would create" if dry_run and page.created else "would update" if dry_run else ( + "created" if page.created else "updated" + ) + click.echo(f"{action} Confluence page {page.page_id}: {page.title}") + if page.page_url != "": + click.echo(page.page_url) + + @main.command() def version() -> None: """Print the installed version.""" diff --git a/src/dbt_specify/confluence.py b/src/dbt_specify/confluence.py new file mode 100644 index 0000000..4af3598 --- /dev/null +++ b/src/dbt_specify/confluence.py @@ -0,0 +1,593 @@ +"""Confluence Cloud bridge for dbt-specify knowledge artifacts.""" +from __future__ import annotations + +import base64 +import html +import json +import os +from dataclasses import dataclass +from datetime import UTC, datetime +from html.parser import HTMLParser +from pathlib import Path +from typing import Any, Protocol +from urllib.error import HTTPError, URLError +from urllib.parse import quote, urlencode +from urllib.request import Request, urlopen + +import yaml + + +class ConfluenceError(RuntimeError): + """Raised when Confluence integration cannot complete safely.""" + + +@dataclass(frozen=True) +class ConfluenceConfig: + """Confluence Cloud connection settings.""" + + base_url: str + email: str + api_token: str + + +@dataclass(frozen=True) +class ConfluencePage: + """Normalized Confluence page fields used by dbt-specify.""" + + page_id: str + title: str + space_id: str | None + space_key: str | None + version: int + page_url: str + storage: str + + +@dataclass(frozen=True) +class PublishedPage: + """Result of creating or updating a Confluence page.""" + + page_id: str + title: str + page_url: str + created: bool + + +class ConfluenceApi(Protocol): + """Methods required by spec publishing helpers.""" + + def get_page(self, page_id: str) -> ConfluencePage: + """Fetch a Confluence page with storage-format body.""" + + def resolve_space_id(self, space_key: str) -> str: + """Resolve a Confluence space key to a v2 API space id.""" + + def create_page( + self, + *, + space_id: str, + title: str, + storage: str, + parent_id: str | None = None, + ) -> PublishedPage: + """Create a Confluence page with storage-format body.""" + + def update_page(self, *, page_id: str, title: str, storage: str) -> PublishedPage: + """Update an existing Confluence page with storage-format body.""" + + +class ConfluenceClient: + """Small Confluence Cloud REST client backed by the Python standard library.""" + + def __init__(self, config: ConfluenceConfig) -> None: + self._config = config + + def get_page(self, page_id: str) -> ConfluencePage: + """Fetch a Confluence page with storage-format body.""" + raw = self._request_json( + "GET", + f"/wiki/api/v2/pages/{quote(page_id)}?body-format=storage", + ) + return _parse_page(raw, self._config.base_url) + + def resolve_space_id(self, space_key: str) -> str: + """Resolve a Confluence space key to a v2 API space id.""" + raw = self._request_json("GET", f"/wiki/api/v2/spaces?{urlencode({'keys': space_key})}") + if not isinstance(raw, dict): + raise ConfluenceError("Confluence spaces response was not an object.") + results = raw.get("results") + if not isinstance(results, list) or not results: + raise ConfluenceError(f"Confluence space not found for key: {space_key}") + first = results[0] + if not isinstance(first, dict): + raise ConfluenceError(f"Confluence space response missing id for key: {space_key}") + space_id = first.get("id") + if not isinstance(space_id, str): + raise ConfluenceError(f"Confluence space response missing id for key: {space_key}") + return space_id + + def create_page( + self, + *, + space_id: str, + title: str, + storage: str, + parent_id: str | None = None, + ) -> PublishedPage: + """Create a Confluence page with storage-format body.""" + payload: dict[str, object] = { + "spaceId": space_id, + "status": "current", + "title": title, + "body": {"representation": "storage", "value": storage}, + } + if parent_id: + payload["parentId"] = parent_id + + raw = self._request_json("POST", "/wiki/api/v2/pages", json_body=payload) + page = _parse_page(raw, self._config.base_url) + return PublishedPage( + page_id=page.page_id, + title=page.title, + page_url=page.page_url, + created=True, + ) + + def update_page(self, *, page_id: str, title: str, storage: str) -> PublishedPage: + """Update an existing Confluence page with storage-format body.""" + current = self.get_page(page_id) + raw = self._request_json( + "PUT", + f"/wiki/api/v2/pages/{quote(page_id)}", + json_body={ + "id": page_id, + "status": "current", + "title": title, + "body": {"representation": "storage", "value": storage}, + "version": { + "number": current.version + 1, + "message": "Updated by dbt-specify", + }, + }, + ) + page = _parse_page(raw, self._config.base_url) + return PublishedPage( + page_id=page.page_id, + title=page.title, + page_url=page.page_url, + created=False, + ) + + def _request_json( + self, + method: str, + path: str, + *, + json_body: dict[str, object] | None = None, + ) -> dict[str, Any] | list[Any]: + data = None + 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() + headers["Content-Type"] = "application/json" + + request = Request( + f"{self._config.base_url.rstrip('/')}{path}", + data=data, + headers=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 ConfluenceError( + f"Confluence API request failed with HTTP {error.code}: {detail}" + ) from error + except URLError as error: + raise ConfluenceError(f"Confluence 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 ConfluenceError("Confluence API returned an unexpected JSON response.") + return parsed + + +def config_from_env() -> ConfluenceConfig: + """Load Confluence Cloud credentials from environment variables.""" + base_url = os.environ.get("CONFLUENCE_BASE_URL", "").strip() + email = os.environ.get("CONFLUENCE_EMAIL", "").strip() + api_token = os.environ.get("CONFLUENCE_API_TOKEN", "").strip() + missing = [ + name + for name, value in ( + ("CONFLUENCE_BASE_URL", base_url), + ("CONFLUENCE_EMAIL", email), + ("CONFLUENCE_API_TOKEN", api_token), + ) + if not value + ] + if missing: + joined = ", ".join(missing) + raise ConfluenceError(f"Missing Confluence environment variable(s): {joined}") + return ConfluenceConfig( + base_url=base_url.rstrip("/"), + email=email, + api_token=api_token, + ) + + +def make_confluence_client() -> ConfluenceClient: + """Create a Confluence client from environment variables.""" + return ConfluenceClient(config_from_env()) + + +def pull_page_to_context( + *, + client: ConfluenceApi, + page_id: str, + output_path: Path, + spec_dir: Path | None = None, +) -> ConfluencePage: + """Pull a Confluence page into a local markdown context file.""" + page = client.get_page(page_id) + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(_context_markdown(page)) + + resolved_spec_dir = spec_dir or infer_spec_dir(output_path) + if resolved_spec_dir is not None: + _record_source_page(resolved_spec_dir, page, output_path) + + return page + + +def publish_spec_dir( + *, + client: ConfluenceApi | None, + spec_dir: Path, + space_key: str | None = None, + space_id: str | None = None, + parent_id: str | None = None, + page_id: str | None = None, + title: str | None = None, + dry_run: bool = False, +) -> PublishedPage: + """Create or update a Confluence summary page for a spec directory.""" + if not spec_dir.exists() or not spec_dir.is_dir(): + raise ConfluenceError(f"spec directory not found: {spec_dir}") + + manifest = read_confluence_manifest(spec_dir) + resolved_page_id = page_id or _optional_str(manifest.get("page_id")) + resolved_space_id = space_id or _optional_str(manifest.get("space_id")) + resolved_space_key = space_key or _optional_str(manifest.get("space_key")) + resolved_parent_id = parent_id or _optional_str(manifest.get("parent_id")) + resolved_title = title or _optional_str(manifest.get("title")) or _default_title(spec_dir) + storage = spec_dir_to_storage(spec_dir, resolved_title) + + if dry_run: + return PublishedPage( + page_id=resolved_page_id or "", + title=resolved_title, + page_url="", + created=resolved_page_id is None, + ) + + if client is None: + raise ConfluenceError("Confluence client is required unless --dry-run is used.") + + if resolved_page_id is not None: + published = client.update_page( + page_id=resolved_page_id, + title=resolved_title, + storage=storage, + ) + else: + if resolved_space_id is None: + if resolved_space_key is None: + raise ConfluenceError("Provide --space-id or --space-key when creating a page.") + resolved_space_id = client.resolve_space_id(resolved_space_key) + published = client.create_page( + space_id=resolved_space_id, + title=resolved_title, + storage=storage, + parent_id=resolved_parent_id, + ) + + write_confluence_manifest( + spec_dir, + { + **manifest, + "space_id": resolved_space_id, + "space_key": resolved_space_key, + "parent_id": resolved_parent_id, + "page_id": published.page_id, + "page_url": published.page_url, + "title": published.title, + "spec_dir": _display_path(spec_dir), + "last_synced_at": _now_iso(), + }, + ) + return published + + +def sync_spec_dir( + *, + client: ConfluenceApi | None, + spec_dir: Path, + dry_run: bool = False, +) -> PublishedPage: + """Update a previously published Confluence page for a spec directory.""" + manifest = read_confluence_manifest(spec_dir) + page_id = _optional_str(manifest.get("page_id")) + if page_id is None: + raise ConfluenceError("confluence.yml is missing page_id. Run confluence publish first.") + return publish_spec_dir( + client=client, + spec_dir=spec_dir, + page_id=page_id, + dry_run=dry_run, + ) + + +def read_confluence_manifest(spec_dir: Path) -> dict[str, object]: + """Read specs//confluence.yml if it exists.""" + path = spec_dir / "confluence.yml" + if not path.exists(): + return {} + loaded = yaml.safe_load(path.read_text()) + return loaded if isinstance(loaded, dict) else {} + + +def write_confluence_manifest(spec_dir: Path, manifest: dict[str, object]) -> None: + """Write specs//confluence.yml.""" + spec_dir.mkdir(parents=True, exist_ok=True) + cleaned = {key: value for key, value in manifest.items() if value is not None} + (spec_dir / "confluence.yml").write_text(yaml.safe_dump(cleaned, sort_keys=False)) + + +def spec_dir_to_storage(spec_dir: Path, title: str) -> str: + """Render local spec artifacts into safe Confluence storage XHTML.""" + sections = [ + f"

{html.escape(title)}

", + "

Generated by: dbt-spec-kit

", + f"

Spec directory: {html.escape(_display_path(spec_dir))}

", + ( + "

This page is a Confluence knowledge summary. The approved local " + "spec.md, plan.md, and tasks.md files remain " + "the source of truth for implementation.

" + ), + ] + for filename in ( + "spec.md", + "plan.md", + "tasks.md", + "review.md", + "governance-review.md", + "dbt-specify-report.md", + ): + path = spec_dir / filename + if path.exists() and path.is_file(): + sections.append(f"

{html.escape(filename)}

") + sections.append(f"
{html.escape(path.read_text())}
") + + context_dir = spec_dir / "context" + if context_dir.is_dir(): + context_files = sorted(path for path in context_dir.glob("*.md") if path.is_file()) + if context_files: + sections.append("

Confluence context pulled into this spec

") + for path in context_files: + sections.append(f"

{html.escape(path.name)}

") + sections.append(f"
{html.escape(path.read_text())}
") + + return "\n".join(sections) + + +def storage_to_markdown(storage: str) -> str: + """Convert Confluence storage XHTML into lightweight markdown context.""" + parser = _StorageToMarkdownParser() + parser.feed(storage) + parser.close() + return parser.markdown() + + +def infer_spec_dir(path: Path) -> Path | None: + """Infer specs/-/ from a file path when possible.""" + parts = path.resolve().parts + if "specs" not in parts: + return None + index = parts.index("specs") + if index + 1 >= len(parts): + return None + return Path(*parts[: index + 2]) + + +def _record_source_page(spec_dir: Path, page: ConfluencePage, output_path: Path) -> None: + manifest = read_confluence_manifest(spec_dir) + raw_sources = manifest.get("source_pages") + sources = raw_sources if isinstance(raw_sources, list) else [] + next_source = { + "page_id": page.page_id, + "title": page.title, + "page_url": page.page_url, + "context_file": _display_path(output_path), + "last_synced_at": _now_iso(), + } + updated_sources = [ + source + for source in sources + if not (isinstance(source, dict) and source.get("page_id") == page.page_id) + ] + updated_sources.append(next_source) + write_confluence_manifest( + spec_dir, + { + **manifest, + "spec_dir": _display_path(spec_dir), + "source_pages": updated_sources, + "last_synced_at": _now_iso(), + }, + ) + + +def _context_markdown(page: ConfluencePage) -> str: + body = storage_to_markdown(page.storage).strip() + return f"""# {page.title} + +**Confluence page:** {page.page_url} +**Page ID:** {page.page_id} +**Synced at:** {_now_iso()} + +## Context + +{body} +""" + + +def _parse_page(raw: dict[str, Any] | list[Any], base_url: str) -> ConfluencePage: + if not isinstance(raw, dict): + raise ConfluenceError("Confluence page response was not an object.") + page_id = _string(raw.get("id"), "page id") + body = _mapping(raw.get("body")) + storage = _mapping(body.get("storage")) + version = _mapping(raw.get("version")) + links = _mapping(raw.get("_links")) + webui = links.get("webui") + page_url = f"{base_url.rstrip('/')}{webui}" if isinstance(webui, str) else "" + if not page_url: + page_url = f"{base_url.rstrip('/')}/wiki/spaces/~pages/{page_id}" + + version_number = version.get("number") + if not isinstance(version_number, int): + version_number = 1 + + return ConfluencePage( + page_id=page_id, + title=_string(raw.get("title"), "title"), + space_id=_optional_str(raw.get("spaceId")), + space_key=_optional_str(raw.get("spaceKey")), + version=version_number, + page_url=page_url, + storage=_optional_str(storage.get("value")) or "", + ) + + +class _StorageToMarkdownParser(HTMLParser): + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self._parts: list[str] = [] + self._href: str | None = None + self._in_pre = False + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + attr_map = dict(attrs) + if tag in {"h1", "h2", "h3"}: + self._break() + self._parts.append("#" * int(tag[1])) + self._parts.append(" ") + elif tag == "p": + self._break() + elif tag == "br": + self._parts.append("\n") + elif tag == "li": + self._break() + self._parts.append("- ") + elif tag == "a": + self._href = attr_map.get("href") + elif tag == "pre": + self._in_pre = True + self._break() + self._parts.append("```text\n") + elif tag == "strong": + self._parts.append("**") + elif tag == "em": + self._parts.append("_") + elif tag == "code" and not self._in_pre: + self._parts.append("`") + + def handle_endtag(self, tag: str) -> None: + if tag in {"h1", "h2", "h3", "p", "li", "ul", "ol"}: + self._break() + elif tag == "a": + if self._href: + self._parts.append(f" ({self._href})") + self._href = None + elif tag == "pre": + self._parts.append("\n```") + self._in_pre = False + self._break() + elif tag == "strong": + self._trim_trailing_space() + self._parts.append("** ") + elif tag == "em": + self._trim_trailing_space() + self._parts.append("_ ") + elif tag == "code" and not self._in_pre: + self._trim_trailing_space() + self._parts.append("` ") + + def handle_data(self, data: str) -> None: + if self._in_pre: + self._parts.append(data) + return + collapsed = " ".join(data.split()) + if collapsed: + self._parts.append(collapsed) + self._parts.append(" ") + + def markdown(self) -> str: + text = "".join(self._parts) + lines = [line.rstrip() for line in text.splitlines()] + output: list[str] = [] + previous_blank = False + for line in lines: + blank = not line.strip() + if blank and previous_blank: + continue + output.append(line) + previous_blank = blank + return "\n".join(output).strip() + "\n" + + def _break(self) -> None: + if self._parts and not self._parts[-1].endswith("\n"): + self._parts.append("\n\n") + + def _trim_trailing_space(self) -> None: + if self._parts: + self._parts[-1] = self._parts[-1].rstrip(" ") + + +def _default_title(spec_dir: Path) -> str: + return f"dbt-spec-kit: {spec_dir.name}" + + +def _display_path(path: Path) -> str: + return str(path) + + +def _now_iso() -> str: + return datetime.now(UTC).replace(microsecond=0).isoformat() + + +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 ConfluenceError(f"Confluence response is missing {field_name}.") + return value + + +def _mapping(value: object) -> dict[str, Any]: + return value if isinstance(value, dict) else {} + + +def _optional_str(value: object) -> str | None: + return value if isinstance(value, str) and value else None diff --git a/tests/test_confluence.py b/tests/test_confluence.py new file mode 100644 index 0000000..59617d7 --- /dev/null +++ b/tests/test_confluence.py @@ -0,0 +1,199 @@ +"""Tests for Confluence bridge helpers.""" +from __future__ import annotations + +from pathlib import Path + +import yaml +from click.testing import CliRunner +from pytest import MonkeyPatch + +from dbt_specify.cli import main +from dbt_specify.confluence import ( + ConfluencePage, + PublishedPage, + publish_spec_dir, + pull_page_to_context, + storage_to_markdown, + sync_spec_dir, +) + + +class FakeConfluenceClient: + def __init__(self, page: ConfluencePage | None = None) -> None: + self.page = page or _page() + self.created: list[dict[str, str | None]] = [] + self.updated: list[dict[str, str]] = [] + + def get_page(self, page_id: str) -> ConfluencePage: + assert page_id == self.page.page_id + return self.page + + def resolve_space_id(self, space_key: str) -> str: + assert space_key == "DATA" + return "space-123" + + def create_page( + self, + *, + space_id: str, + title: str, + storage: str, + parent_id: str | None = None, + ) -> PublishedPage: + self.created.append( + { + "space_id": space_id, + "title": title, + "storage": storage, + "parent_id": parent_id, + } + ) + return PublishedPage( + page_id="999", + title=title, + page_url="https://example.atlassian.net/wiki/spaces/DATA/pages/999", + created=True, + ) + + def update_page(self, *, page_id: str, title: str, storage: str) -> PublishedPage: + self.updated.append({"page_id": page_id, "title": title, "storage": storage}) + return PublishedPage( + page_id=page_id, + title=title, + page_url=f"https://example.atlassian.net/wiki/spaces/DATA/pages/{page_id}", + created=False, + ) + + +def test_cli_help_lists_confluence_group() -> None: + runner = CliRunner() + result = runner.invoke(main, ["--help"]) + assert result.exit_code == 0, result.output + assert "confluence" in result.output + + confluence_result = runner.invoke(main, ["confluence", "--help"]) + assert confluence_result.exit_code == 0, confluence_result.output + assert "pull-page" in confluence_result.output + assert "publish" in confluence_result.output + assert "sync" in confluence_result.output + + +def test_storage_to_markdown_converts_basic_storage() -> None: + markdown = storage_to_markdown( + "

Metric definitions

Owner: Finance

" + "
  • Net revenue excludes refunds
" + ) + + assert "# Metric definitions" in markdown + assert "**Owner:** Finance" in markdown + assert "- Net revenue excludes refunds" in markdown + + +def test_pull_page_to_context_records_manifest(tmp_path: Path) -> None: + spec_dir = tmp_path / "specs" / "001-player-journey" + output_path = spec_dir / "context" / "metric-definitions.md" + client = FakeConfluenceClient() + + page = pull_page_to_context( + client=client, # type: ignore[arg-type] + page_id="123", + output_path=output_path, + ) + + assert page.title == "Player metric definitions" + context = output_path.read_text() + assert "# Player metric definitions" in context + assert "Net revenue excludes refunds" in context + + manifest = yaml.safe_load((spec_dir / "confluence.yml").read_text()) + assert manifest["spec_dir"] == str(spec_dir) + assert manifest["source_pages"][0]["page_id"] == "123" + assert manifest["source_pages"][0]["context_file"] == str(output_path) + + +def test_publish_spec_dir_creates_page_and_manifest(tmp_path: Path) -> None: + spec_dir = tmp_path / "specs" / "001-player-journey" + spec_dir.mkdir(parents=True) + (spec_dir / "spec.md").write_text("# Spec\n\nApproved player journey spec.") + (spec_dir / "plan.md").write_text("# Plan\n\nUpdate fct_player_performance.") + client = FakeConfluenceClient() + + page = publish_spec_dir( + client=client, # type: ignore[arg-type] + spec_dir=spec_dir, + space_key="DATA", + parent_id="456", + ) + + assert page.created is True + assert client.created[0]["space_id"] == "space-123" + assert client.created[0]["parent_id"] == "456" + assert "Approved player journey spec." in str(client.created[0]["storage"]) + + manifest = yaml.safe_load((spec_dir / "confluence.yml").read_text()) + assert manifest["page_id"] == "999" + assert manifest["space_key"] == "DATA" + assert manifest["parent_id"] == "456" + + +def test_confluence_publish_dry_run_does_not_require_credentials( + tmp_path: Path, + monkeypatch: MonkeyPatch, +) -> None: + for name in ("CONFLUENCE_BASE_URL", "CONFLUENCE_EMAIL", "CONFLUENCE_API_TOKEN"): + monkeypatch.delenv(name, raising=False) + spec_dir = tmp_path / "specs" / "001-player-journey" + spec_dir.mkdir(parents=True) + (spec_dir / "spec.md").write_text("# Spec\n\nApproved spec.") + + result = CliRunner().invoke( + main, + [ + "confluence", + "publish", + "--spec-dir", + str(spec_dir), + "--space-key", + "DATA", + "--dry-run", + ], + ) + + assert result.exit_code == 0, result.output + assert "would create Confluence page " in result.output + assert not (spec_dir / "confluence.yml").exists() + + +def test_sync_spec_dir_updates_existing_page(tmp_path: Path) -> None: + spec_dir = tmp_path / "specs" / "001-player-journey" + spec_dir.mkdir(parents=True) + (spec_dir / "spec.md").write_text("# Spec\n\nApproved spec.") + (spec_dir / "confluence.yml").write_text( + yaml.safe_dump( + { + "page_id": "123", + "title": "Player journey summary", + "page_url": "https://example.atlassian.net/wiki/spaces/DATA/pages/123", + } + ) + ) + client = FakeConfluenceClient() + + page = sync_spec_dir(client=client, spec_dir=spec_dir) # type: ignore[arg-type] + + assert page.created is False + assert client.updated[0]["page_id"] == "123" + assert client.updated[0]["title"] == "Player journey summary" + assert "Approved spec." in client.updated[0]["storage"] + + +def _page() -> ConfluencePage: + return ConfluencePage( + page_id="123", + title="Player metric definitions", + space_id="space-123", + space_key="DATA", + version=7, + page_url="https://example.atlassian.net/wiki/spaces/DATA/pages/123", + storage="

Player metrics

Net revenue excludes refunds.

", + ) diff --git a/tests/test_docs.py b/tests/test_docs.py index 3215b09..ebd0685 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -136,6 +136,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 "Confluence bridge" in readme assert "Development workflow" in readme assert "Repo retention" in readme assert "Brownfield rollout" in readme @@ -146,6 +147,7 @@ def test_readme_highlights_enterprise_adoption_choices() -> None: assert "001-core-customer-segmentation" in readme assert "not as nested folders" in readme assert "docs/integrations/jira.md" in readme + assert "docs/integrations/confluence.md" in readme def test_uvx_command_examples_are_clear() -> None: @@ -172,6 +174,7 @@ def test_tutorials_cover_enterprise_onboarding_path() -> None: tutorials_dir / "03-brownfield-enterprise-adoption.md", tutorials_dir / "04-skills-and-sub-agent-handoffs.md", tutorials_dir / "05-jira-to-spec-workflow.md", + tutorials_dir / "06-confluence-context-workflow.md", ] for path in required_paths: assert path.exists(), f"Missing tutorial: {path.relative_to(ROOT)}" @@ -183,6 +186,7 @@ def test_tutorials_cover_enterprise_onboarding_path() -> None: 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 + assert "Confluence context workflow" in index handoffs = (tutorials_dir / "04-skills-and-sub-agent-handoffs.md").read_text() assert "dbt Labs skills" in handoffs @@ -208,6 +212,23 @@ def test_jira_integration_docs_are_documented() -> None: assert "The PR remains the merge gate" in tutorial_text +def test_confluence_integration_docs_are_documented() -> None: + guide = ROOT / "docs" / "integrations" / "confluence.md" + tutorial = ROOT / "docs" / "tutorials" / "06-confluence-context-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 "CONFLUENCE_BASE_URL" in guide_text + assert "dbt-specify confluence pull-page" in guide_text + assert "dbt-specify confluence publish" in guide_text + assert "dbt-specify confluence sync" in guide_text + assert "Confluence context -> local context markdown -> spec.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: result = runner.invoke(main, ["--help"]) assert result.exit_code == 0, result.output assert "ci" in result.output + assert "confluence" in result.output assert "doctor" in result.output assert "init" in result.output assert "jira" in result.output @@ -41,7 +42,7 @@ def test_cli_version_prints_package_version() -> None: runner = CliRunner() result = runner.invoke(main, ["version"]) assert result.exit_code == 0 - assert "1.3.0" in result.output + assert "1.4.0" in result.output def test_init_help_shows_flags() -> None: