From 77eddb617db9bdbc7ada19837ce4b81b52603eff Mon Sep 17 00:00:00 2001 From: Gerald Fruhmann Date: Mon, 6 Jul 2026 11:14:21 +0200 Subject: [PATCH] chore: add validate command, Atlassian quickstart, CHANGELOG MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - qms-kit validate --target --config: checks config schema, required env vars for the target, and renders all templates — exits 0/1 for CI use - 6 new tests for validate command (91 total, all passing) - README: Atlassian quickstart section, updated target table to Phase 2 complete, Atlassian badge, Stack line updated - CHANGELOG.md: Keep a Changelog format, v0.1.0–0.3.0 documented - .markdownlint.json: disable MD041 (false positive on
header) Co-Authored-By: Claude Sonnet 4.6 --- .markdownlint.json | 3 +- CHANGELOG.md | 73 +++++++++++++++++++++++++++++++ cli/main.py | 106 +++++++++++++++++++++++++++++++++++++++++++++ readme.md | 44 ++++++++++++++++--- tests/test_cli.py | 64 ++++++++++++++++++++++++++- 5 files changed, 281 insertions(+), 9 deletions(-) create mode 100644 CHANGELOG.md diff --git a/.markdownlint.json b/.markdownlint.json index fb16fb9..92140bd 100644 --- a/.markdownlint.json +++ b/.markdownlint.json @@ -2,5 +2,6 @@ "default": true, "MD033": false, "MD060": false, - "MD013": false + "MD013": false, + "MD041": false } diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..cd740c2 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,73 @@ +# Changelog + +All notable changes to qms-kit are documented here. +Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +--- + +## [Unreleased] + +## [0.3.0] — 2025-07-06 + +### Added +- `qms-kit validate` CLI command — validates config and env vars for a target, + renders all templates, exits 0/1 (useful in CI pipelines) +- Atlassian Quickstart section in README +- CHANGELOG (this file) + +## [0.2.0] — 2025-06-29 + +### Added +- **Phase 2: Atlassian Cloud adapter** (`--target atlassian`) + - `ConfluenceAdapter`: deploys QMS document tree via Confluence REST API v2, + Markdown → Storage Format (XHTML) converter (`md_to_confluence_storage`) + - `JiraAdapter`: creates Jira project (`projectTypeKey=business`), seeds QMS + issue types via Jira REST API v3 — idempotent (skips existing resources) + - Single `ATLASSIAN_API_TOKEN` covers both services +- `AtlassianConfig`, `ConfluenceConfig`, `JiraConfig` Pydantic models +- `config/atlassian.yaml` overlay template +- 30 unit tests for Confluence converter and Jira adapter + +### Changed +- `--target atlassian` now fully wired in CLI (was "not yet implemented") +- `_deploy_atlassian()` in `cli/main.py` calls both adapters in sequence +- Added record-template placeholder vars to CLI render context + (`audit_date`, `capa_id`, `opened_by`, etc.) + +## [0.1.1] — 2025-06-22 + +### Fixed +- Removed `[dependency-groups]` section from `pyproject.toml`; dev deps + consolidated into `[project.optional-dependencies] dev` so + `uv sync --frozen --extra dev` installs them correctly in CI +- XWiki deploy now deploys pages flat in QMS space — eliminates duplicate + "QMS → QMS" nav entry caused by intermediate parent page + +### Changed +- GitHub Actions PR size check now excludes `*.lock`, `*.md`, `tests/`, `test_*.py` + +## [0.1.0] — 2025-06-15 + +### Added +- **Phase 1: Self-hosted adapter** (`--target selfhosted`) + - `XWikiAdapter`: deploys QMS space + document pages via XWiki REST API, + Markdown → XWiki 2.1 syntax converter (`md_to_xwiki`) + - `RedmineAdapter`: creates project, seeds scaffold issues via Redmine REST API +- **16 Jinja2 document templates** (Markdown as single source of truth): + quality policy, procedures (context, leadership, planning, support, operation, + performance, improvement), management review agenda, internal audit checklist, + CAPA form, NC form, KPI dashboard, competence matrix, risk register, + supplier evaluation +- Four-layer architecture: Config → Templates → Adapters → CLI +- `CoreConfig` / Pydantic v2 schema with `_deep_merge()` overlay loader +- `qms-kit deploy` Click CLI with `--target`, `--config`, `--dry-run` +- Docker Compose stack for XWiki + Redmine (self-hosted target) +- GitHub Actions CI: ruff, bandit, mypy, pytest, PR size check (400-line limit) +- `uv` package manager, installable as CLI via `pyproject.toml` + +[Unreleased]: https://github.com/gerfru/qms-kit/compare/v0.3.0...HEAD +[0.3.0]: https://github.com/gerfru/qms-kit/compare/v0.2.0...v0.3.0 +[0.2.0]: https://github.com/gerfru/qms-kit/compare/v0.1.1...v0.2.0 +[0.1.1]: https://github.com/gerfru/qms-kit/compare/v0.1.0...v0.1.1 +[0.1.0]: https://github.com/gerfru/qms-kit/releases/tag/v0.1.0 diff --git a/cli/main.py b/cli/main.py index 916b5b2..979dec4 100644 --- a/cli/main.py +++ b/cli/main.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os from pathlib import Path import click @@ -104,6 +105,111 @@ def deploy(target: str, config_path: Path, dry_run: bool) -> None: raise click.ClickException("M365 adapter not yet implemented (Phase 3).") +_ENV_VARS: dict[str, list[str]] = { + "selfhosted": ["XWIKI_PASSWORD", "REDMINE_API_KEY"], + "atlassian": ["ATLASSIAN_API_TOKEN"], + "m365": ["M365_CLIENT_ID", "M365_CLIENT_SECRET", "M365_TENANT_ID"], +} + + +@cli.command() +@click.option( + "--target", + required=True, + type=click.Choice(["selfhosted", "atlassian", "m365"]), + help="Deployment target platform.", +) +@click.option( + "--config", + "config_path", + required=True, + type=click.Path(exists=True, path_type=Path), + help="Path to the client overlay config.", +) +def validate(target: str, config_path: Path) -> None: + """Validate config and environment for a target — no changes written. + + Exits 0 if everything is ready to deploy, 1 if any check fails. + Suitable for use in CI pipelines before a deploy step. + """ + errors: list[str] = [] + + # Config load + schema validation + core_path = CONFIG_DIR / "core.yaml" + click.echo(f"Loading config: core={core_path}, overlay={config_path}") + try: + config = load_config(core_path, config_path) + except Exception as exc: + raise click.ClickException(f"Config invalid: {exc}") from exc + click.echo( + f"Config loaded: {config.organisation.name} " + f"({config.meta.standard}, {len(config.documents)} documents)" + ) + + # Target-specific config block + if target == "selfhosted" and config.selfhosted is None: + errors.append("selfhosted config block missing in overlay") + if target == "atlassian" and config.atlassian is None: + errors.append("atlassian config block missing in overlay") + + # Required env vars + missing_vars = [v for v in _ENV_VARS.get(target, []) if not os.environ.get(v)] + for var in missing_vars: + errors.append(f"env var not set: {var}") + + # Template rendering + click.echo("Rendering templates...") + render_errors = 0 + try: + render_template("qms_index.md.j2", config=config, templates_dir=TEMPLATES_DIR) + except Exception as exc: + errors.append(f"qms_index.md.j2: {exc}") + render_errors += 1 + + for doc in config.documents: + if doc.template is None: + continue + try: + render_template( + f"{doc.template}.md.j2", + config=config, + templates_dir=TEMPLATES_DIR, + extra={ + "procedure_title": doc.title, + "doc_id": f"QMS-{doc.clause}-{doc.id.upper()[:8]}", + "clause": doc.clause, + "owner_role": "Quality Management Officer", + "approver": config.organisation.management, + "author": config.organisation.quality_officer, + "date": "TBD", + "audit_date": "TBD", + "auditor": "TBD", + "audit_scope": "TBD", + "contact": "TBD", + "capa_id": "CAPA-XXXX-NNN", + "opened_date": "TBD", + "opened_by": "TBD", + "source_ref": "TBD", + "participants": "TBD", + }, + ) + except Exception as exc: + errors.append(f"{doc.template}.md.j2: {exc}") + render_errors += 1 + + templated = sum(1 for d in config.documents if d.template) + 1 # +1 for index + rendered_ok = templated - render_errors + click.echo(f" {rendered_ok}/{templated} templates rendered successfully") + + if errors: + click.echo("\nValidation FAILED:") + for err in errors: + click.echo(f" ✗ {err}") + raise click.ClickException(f"{len(errors)} check(s) failed.") + + click.echo(f"\nValidation OK — ready to deploy --target {target}") + + def _deploy_atlassian(config: object, rendered: dict[str, tuple[str, str]]) -> None: from adapters.atlassian.confluence import ConfluenceAdapter from adapters.atlassian.jira import JiraAdapter diff --git a/readme.md b/readme.md index ccf31f0..7da2563 100644 --- a/readme.md +++ b/readme.md @@ -11,6 +11,7 @@ Fill in your organisation's content over the following two weeks. ![YAML](https://img.shields.io/badge/Config-YAML-CB171E?style=flat-square&logo=yaml&logoColor=white) ![Docker](https://img.shields.io/badge/Docker-Compose-2496ED?style=flat-square&logo=docker&logoColor=white) ![XWiki](https://img.shields.io/badge/Self--hosted-XWiki%20%2B%20Redmine-10B981?style=flat-square) +![Atlassian](https://img.shields.io/badge/Atlassian-Confluence%20%2B%20Jira-0052CC?style=flat-square&logo=atlassian&logoColor=white) ![ISO 9001](https://img.shields.io/badge/ISO%209001-Clauses%204–10-0057B8?style=flat-square) [Quickstart](#-quickstart) · [What gets deployed](#-what-gets-deployed) · [Targets](#-deployment-targets) · [Docs](#-documentation) @@ -57,7 +58,7 @@ or SharePoint + Power Automate (M365). Reuse the same kit for every client; swap ## 🚀 Quickstart -Self-hosted target (XWiki + Redmine via Docker Compose): +### Self-hosted (XWiki + Redmine via Docker Compose) ```bash # 1. Clone and install @@ -73,13 +74,13 @@ docker compose -f docker/docker-compose.yml up -d # See docs/redmine-setup.md # 4. Create your client overlay -cp config/clients/example.yaml config/clients/acme.yaml +cp config/selfhosted.yaml config/clients/acme.yaml # edit acme.yaml: org name, URLs, credentials -# 5. Dry-run first (no changes written) +# 5. Validate (checks config + env vars, no writes) export XWIKI_PASSWORD=Admin export REDMINE_API_KEY= -qms-kit deploy --target selfhosted --config config/clients/acme.yaml --dry-run +qms-kit validate --target selfhosted --config config/clients/acme.yaml # 6. Deploy qms-kit deploy --target selfhosted --config config/clients/acme.yaml @@ -91,6 +92,35 @@ qms-kit deploy --target selfhosted --config config/clients/acme.yaml → Full walkthrough: **[docs/setup.md](docs/setup.md)** +### Atlassian Cloud (Confluence + Jira) + +Requirements: an Atlassian Cloud account with admin access to a Confluence space and a Jira project. + +```bash +# 1. Clone and install (same as above) +git clone https://github.com/gerfru/qms-kit.git +cd qms-kit +uv sync + +# 2. Create your client overlay from the Atlassian template +cp config/atlassian.yaml config/clients/acme.yaml +# edit acme.yaml: org name, base_url (https://.atlassian.net), email, +# space_key, project_key + +# 3. Generate an API token at id.atlassian.com → Security → API tokens +export ATLASSIAN_API_TOKEN= + +# 4. Validate (checks config + token presence, no writes) +qms-kit validate --target atlassian --config config/clients/acme.yaml + +# 5. Deploy +qms-kit deploy --target atlassian --config config/clients/acme.yaml +``` + +> **One token, two services:** `ATLASSIAN_API_TOKEN` is used for both +> Confluence and Jira. The email in your overlay config must match the +> token owner's Atlassian account. + --- ## 🎯 Deployment targets @@ -98,8 +128,8 @@ qms-kit deploy --target selfhosted --config config/clients/acme.yaml | Target | Status | Stack | |--------|--------|-------| | **Self-hosted** | ✅ Phase 1 complete | XWiki (docs) + Redmine (records) via Docker | -| **Atlassian** | 🔜 Phase 2 | Confluence + Jira via REST API | -| **Microsoft 365** | 🔜 Phase 3 | SharePoint + Power Automate via PnP PowerShell | +| **Atlassian** | ✅ Phase 2 complete | Confluence + Jira via REST API | +| **Microsoft 365** | 🔜 Phase 3 | SharePoint + Planner via Microsoft Graph API | > **Atlassian note:** Approval workflows and versioning are not native to Confluence Cloud > — a Marketplace app (e.g. Comala) is required; that part is only partially automatable. @@ -121,7 +151,7 @@ qms-kit deploy --target selfhosted --config config/clients/acme.yaml ## 🧱 Stack -Python · Jinja2 · Pydantic · YAML · Click · Docker Compose · XWiki REST API · Redmine REST API +Python · Jinja2 · Pydantic · YAML · Click · Docker Compose · XWiki REST API · Redmine REST API · Confluence REST API v2 · Jira REST API v3 Three layers above the adapters: a config loader with Pydantic schema validation, a Jinja2 template renderer, and a single CLI entry point (`qms-kit deploy --target`). diff --git a/tests/test_cli.py b/tests/test_cli.py index 3c6013d..e019f7e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,4 +1,4 @@ -"""Unit tests for the CLI deploy command.""" +"""Unit tests for the CLI deploy and validate commands.""" from pathlib import Path from unittest.mock import patch @@ -9,6 +9,7 @@ CORE_YAML = Path(__file__).parent.parent / "config" / "core.yaml" SELFHOSTED_YAML = Path(__file__).parent.parent / "config" / "selfhosted.yaml" +ATLASSIAN_YAML = Path(__file__).parent.parent / "config" / "atlassian.yaml" class TestDeployDryRun: @@ -87,3 +88,64 @@ def test_selfhosted_deploy_calls_adapters(self) -> None: assert result.exit_code == 0, result.output mock_xwiki.assert_called_once() mock_redmine.assert_called_once() + + +class TestValidate: + def test_validate_selfhosted_ok_with_env(self) -> None: + runner = CliRunner() + result = runner.invoke( + cli, + ["validate", "--target", "selfhosted", "--config", str(SELFHOSTED_YAML)], + env={"XWIKI_PASSWORD": "Admin", "REDMINE_API_KEY": "abc123"}, + ) + assert result.exit_code == 0, result.output + assert "Validation OK" in result.output + + def test_validate_selfhosted_fails_missing_env(self) -> None: + runner = CliRunner() + result = runner.invoke( + cli, + ["validate", "--target", "selfhosted", "--config", str(SELFHOSTED_YAML)], + env={}, + ) + assert result.exit_code != 0 + assert "XWIKI_PASSWORD" in result.output + assert "REDMINE_API_KEY" in result.output + + def test_validate_atlassian_fails_wrong_config_block(self) -> None: + # selfhosted.yaml has no atlassian block + runner = CliRunner() + result = runner.invoke( + cli, + ["validate", "--target", "atlassian", "--config", str(SELFHOSTED_YAML)], + env={"ATLASSIAN_API_TOKEN": "tok"}, + ) + assert result.exit_code != 0 + assert "atlassian config block missing" in result.output + + def test_validate_atlassian_ok_with_atlassian_config(self) -> None: + runner = CliRunner() + result = runner.invoke( + cli, + ["validate", "--target", "atlassian", "--config", str(ATLASSIAN_YAML)], + env={"ATLASSIAN_API_TOKEN": "tok"}, + ) + assert result.exit_code == 0, result.output + assert "Validation OK" in result.output + + def test_validate_missing_config_file_exits_nonzero(self) -> None: + runner = CliRunner() + result = runner.invoke( + cli, + ["validate", "--target", "selfhosted", "--config", "nonexistent.yaml"], + ) + assert result.exit_code != 0 + + def test_validate_prints_rendered_count(self) -> None: + runner = CliRunner() + result = runner.invoke( + cli, + ["validate", "--target", "selfhosted", "--config", str(SELFHOSTED_YAML)], + env={"XWIKI_PASSWORD": "x", "REDMINE_API_KEY": "x"}, + ) + assert "templates rendered successfully" in result.output