Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .markdownlint.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@
"default": true,
"MD033": false,
"MD060": false,
"MD013": false
"MD013": false,
"MD041": false
}
73 changes: 73 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
106 changes: 106 additions & 0 deletions cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import os
from pathlib import Path

import click
Expand Down Expand Up @@ -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
Expand Down
44 changes: 37 additions & 7 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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=<your-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
Expand All @@ -91,15 +92,44 @@ 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://<org>.atlassian.net), email,
# space_key, project_key

# 3. Generate an API token at id.atlassian.com → Security → API tokens
export ATLASSIAN_API_TOKEN=<your-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

| 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.
Expand All @@ -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`).
Expand Down
64 changes: 63 additions & 1 deletion tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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
Loading