diff --git a/.env.example b/.env.example index 60ba215..12e3d34 100644 --- a/.env.example +++ b/.env.example @@ -21,3 +21,8 @@ JIRA_API_TOKEN= JIRA_PROJECT_ID_OR_KEY= JIRA_ISSUE_TYPE_ID= JIRA_TIMEOUT_SECONDS=10 +CONFLUENCE_BASE_URL= +CONFLUENCE_EMAIL= +CONFLUENCE_API_TOKEN= +CONFLUENCE_TIMEOUT_SECONDS=10 +CONFLUENCE_MANAGER_TRAVEL_APPROVAL_GUIDE_PAGE_ID= diff --git a/README.md b/README.md index 0e9caec..aee78ed 100644 --- a/README.md +++ b/README.md @@ -4,15 +4,15 @@ ChangeOps analyzes operational and policy changes, identifies affected people an ## Current milestone -Milestone 7 PR A, Enterprise Knowledge Catalog Explorer. +Milestone 7 PR B, Read-Only Confluence Document Identity. -The local application now adds a read-only catalog explorer over the existing typed PostgreSQL -documents, systems, training courses, and policy dependencies. A reviewer can browse stable source -identities, inspect stored versus normalized metadata, follow policy-scoped rule references, and -see why persisted dependency rows are deterministic assessment inputs while row-level origin -provenance remains explicitly not recorded. Existing analysis, comparison, approval, Learning -System, and Jira behavior remains unchanged. PR A adds no migration or external catalog source; -Confluence remains the separately planned PR B. +The catalog explorer can now associate the selected Manager Travel Approval Guide with one real, +read-only Confluence Cloud page. An explicit refresh validates the configured page identity, +imports only bounded page and space metadata into PostgreSQL, and exposes the canonical external +link without changing the stable ChangeOps document ID. Failed refreshes preserve last-known-good +metadata. Existing relationships, assessment behavior, comparison, approval, Learning System, and +Jira behavior remain unchanged. Relationship-origin provenance remains the separately deferred +Milestone 7 PR C. Milestone 0 is complete and preserved by the `v0.0.1-milestone-0` tag. @@ -325,6 +325,55 @@ Management project's Task workflow so its initial status is **To Do**; ChangeOps transitions. Secrets stay in `.env` and are never stored in commands or results. Project and issue type identifiers are configuration rather than hard-coded source values. +### Optional read-only Confluence page + +The repository intentionally contains no live page ID or credential. To connect the flagship +Manager Travel Approval Guide, first create or select that page in a dedicated Acme Confluence +space, then configure: + +```dotenv +CONFLUENCE_BASE_URL=https://your-domain.atlassian.net +CONFLUENCE_EMAIL=changeops-reader@example.com +CONFLUENCE_API_TOKEN=your-api-token +CONFLUENCE_TIMEOUT_SECONDS=10 +CONFLUENCE_MANAGER_TRAVEL_APPROVAL_GUIDE_PAGE_ID=123456789 +``` + +Use a dedicated account that can only view the selected page and its space. ChangeOps uses only +Confluence Cloud REST API v2 `GET /wiki/api/v2/pages/{id}` and +`GET /wiki/api/v2/spaces/{spaceId}`. Atlassian documents `read:page:confluence` and +`read:space:confluence` as the corresponding granular OAuth scopes; the direct email/API-token +configuration used here remains constrained by the account's Confluence permissions. See the +[page API](https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-page/), +[space API](https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-space/), and +[basic-auth guidance](https://developer.atlassian.com/cloud/confluence/basic-auth-for-rest-apis/). +Do not grant content write, delete, search, or space-administration permissions. + +After the API is running, import or refresh the configured page explicitly: + +```bash +curl --fail --request POST \ + http://localhost:8000/api/v1/catalog-objects/enterprise_document/document-manager-travel-approval-guide/confluence-refresh +``` + +The first successful response is `success`; an identical later response is `already_current`. +Open +to inspect the external page ID, space, version, status, refresh state, and **Open in Confluence** +link. Authentication, permission, not-found, unavailable, validation, and ambiguous-response +failures have distinct stable codes. A failed refresh never overwrites imported metadata. + +To create the live fictional Acme content, use this bounded manual inventory: + +1. Required: **Manager Travel Approval Guide** in a space such as **Acme Travel Operations**. +2. Optional only if later demo needs justify them: **International Travel Booking Procedures** + and **International Travel FAQ**. +3. Copy the required page's numeric ID from its URL or page information into the environment + variable above. Do not add it to source control. +4. Run the explicit refresh and verify that the returned title, space, and page ID match the + intended page before the reviewer walkthrough. + +No code path creates, edits, publishes, searches, crawls, or deletes Confluence content. + Stop the stack while preserving database data: ```bash @@ -345,7 +394,9 @@ Configure `OPENAI_API_KEY` in `.env`, then use this repeatable golden path: as assessment-context counts. Open **Manager Travel Approval Guide** and inspect its stored Acme Knowledge source, published status, version 2, missing owner, and separate baseline and proposed `instructs_manager` dependency rows. Follow the baseline - `MANAGER_APPROVAL_REQUIRED` reference and return to the document. + `MANAGER_APPROVAL_REQUIRED` reference and return to the document. If Confluence is configured + and explicitly refreshed, also confirm the read-only external page identity and open the page + in Confluence. Otherwise confirm the honest not-imported state. 4. Return to policy analysis, select the baseline policy, and choose **Start new analysis**. 5. On the journey page, inspect the AI-proposed structured values, deterministic validation, and exact source quotes. The ordinary golden policy needs no clarification and is labeled @@ -497,7 +548,8 @@ make demo-reset ``` This command preserves the fictional organization, people, trips, systems, documents, training, -commitments, both policy sources, and the dependency catalog. It removes comparisons and their +commitments, both policy sources, the dependency catalog, and successfully imported Confluence +document metadata. It removes comparisons and their enterprise impact deltas, analysis runs, extraction attempts, clarifications, assessments, findings, evidence, impacts, proposed actions, interpretation attempts and plans, reviews, approval runs, commands, assignments, and @@ -511,6 +563,19 @@ database. ## API +Refresh the one configured read-only Confluence source by stable ChangeOps document ID: + +```http +POST /api/v1/catalog-objects/enterprise_document/{document_id}/confluence-refresh +``` + +The existing document-detail read includes `external_source_state` and the last-known-good +`external_source` metadata when available: + +```http +GET /api/v1/catalog-objects/enterprise_document/{document_id} +``` + Create an assessment for the seeded policy: ```http diff --git a/compose.yaml b/compose.yaml index b21a257..7572567 100644 --- a/compose.yaml +++ b/compose.yaml @@ -57,6 +57,11 @@ services: JIRA_PROJECT_ID_OR_KEY: ${JIRA_PROJECT_ID_OR_KEY:-} JIRA_ISSUE_TYPE_ID: ${JIRA_ISSUE_TYPE_ID:-} JIRA_TIMEOUT_SECONDS: ${JIRA_TIMEOUT_SECONDS:-10} + CONFLUENCE_BASE_URL: ${CONFLUENCE_BASE_URL:-} + CONFLUENCE_EMAIL: ${CONFLUENCE_EMAIL:-} + CONFLUENCE_API_TOKEN: ${CONFLUENCE_API_TOKEN:-} + CONFLUENCE_TIMEOUT_SECONDS: ${CONFLUENCE_TIMEOUT_SECONDS:-10} + CONFLUENCE_MANAGER_TRAVEL_APPROVAL_GUIDE_PAGE_ID: ${CONFLUENCE_MANAGER_TRAVEL_APPROVAL_GUIDE_PAGE_ID:-} ports: - "${API_PORT:-8000}:8000" healthcheck: diff --git a/docs/architecture.md b/docs/architecture.md index f58244f..7efd7f1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,12 +1,12 @@ # ChangeOps Architecture -This document describes the current implementation through Milestone 7 PR A: the completed +This document describes the current implementation through Milestone 7 PR B: the completed policy-analysis and approval lifecycles, the integrated Next.js journey and workbench, deterministic preparation and explicit execution of learning-assignment and Jira create-issue commands, and immutable deterministic comparison of two accepted international-travel policy rulesets and their immutable -enterprise impact assessments, plus the read-only Enterprise Knowledge Catalog Explorer over the -existing typed source and dependency tables. +enterprise impact assessments, plus the read-only Enterprise Knowledge Catalog Explorer and its +narrow persisted Confluence Cloud document-identity boundary. ## High-level architecture @@ -50,6 +50,8 @@ flowchart LR ImpactDelta["Enterprise impact delta service"] DeltaComparator["Pure stable-identity delta comparator"] CatalogProjection["Read-only catalog projection"] + ConfluenceRefresh["Explicit Confluence metadata refresh"] + ConfluenceAdapter["Read-only Confluence Cloud adapter"] ORM["SQLAlchemy models and sessions"] end @@ -88,6 +90,10 @@ flowchart LR CommandPreparation --> ORM Routes --> ExecutionService Routes --> CatalogProjection + Routes --> ConfluenceRefresh + ConfluenceRefresh --> ConfluenceAdapter + ConfluenceRefresh --> ORM + ConfluenceAdapter --> ConfluenceCloud["Confluence Cloud"] Routes --> Comparison Comparison --> Comparator Comparison --> ImpactDelta @@ -111,7 +117,8 @@ assessment, comparison, interpretation, review, approval-run, command, simulated learning-assignment, and execution-result data. The Next.js runtime provides the local analysis, comparison, approval, and enterprise-catalog experiences. A configured chat-model provider supports extraction and interpretation. Jira Cloud is the only real external -side-effect target; command preparation and simulated Learning execution remain local. +side-effect target. Confluence Cloud is a read-only external metadata source; command preparation +and simulated Learning execution remain local. ## Major runtime components @@ -150,6 +157,7 @@ The FastAPI application exposes: - `POST /api/v1/execution-commands/{command_id}/execute` - `GET /api/v1/catalog-objects?organization_id={organization_id}` - `GET /api/v1/catalog-objects/{object_type}/{object_id}` +- `POST /api/v1/catalog-objects/enterprise_document/{document_id}/confluence-refresh` - `GET /api/v1/policy-changes/{policy_change_id}/rule-references/{rule_code}` The create response adds an input fingerprint, enterprise-impact summary, and categorized @@ -829,7 +837,7 @@ into the reviewer application because a pre-completed run obscures the start of `make demo-reset` applies migrations, runs the idempotent catalog seed, then truncates the explicit set of delta-, comparison-, and workflow-owned tables in one transaction. It preserves both policy sources -and other catalog tables and requires an exact +and other catalog tables, including imported `confluence_document_sources`, and requires an exact confirmation value, recognized local PostgreSQL host and database name, and the seeded organization marker. No database or volume is dropped. @@ -852,8 +860,39 @@ assessment-context counts. Next.js server-renders `/catalog`, supported object details, and policy-rule-reference details with uncached API reads. Native `details` elements keep raw IDs and integrity basis collapsed by default. -There is no search, administration, relationship editing, graph view, Confluence access, or new -runtime dependency. PR A adds no migration and does not change assessment behavior. +There is no search, administration, relationship editing, or graph view. PR A added no migration +and PR B does not change assessment behavior. + +## Read-only Confluence document identity boundary + +`confluence_document_sources` is a Confluence-specific one-to-one child of +`enterprise_documents`; the existing string document primary key remains the catalog and +assessment identity. The child stores only validated external page metadata: provider, page ID +and title, canonical URL, space ID/key, version, status, source-updated time, import/refresh times, +source fingerprint, and the latest safe refresh result. It is not a catalog registry and contains +no policy relationships or arbitrary metadata JSON. + +Only `document-manager-travel-approval-guide` has an explicit environment mapping. The refresh +request accepts its stable ChangeOps ID, resolves the configured page ID server-side, calls +Confluence REST API v2 for that page and its owning space, validates both identities, and computes +a canonical SHA-256 fingerprint. The first valid response inserts the child row. A matching +fingerprint returns `already_current`; changed external metadata updates only the child row. + +Provider calls happen outside a database transaction. Persistence then locks the parent document +before insert/update so concurrent successful refreshes cannot create duplicate identities. +Authentication, permission, page-not-found, unavailable, validation, and ambiguous-response +failures are distinct. If a last-known-good row exists, failure updates only its latest outcome +fields; imported metadata and `refreshed_at` remain unchanged. A first-ever failure creates no +unvalidated external identity. + +The catalog detail response projects imported metadata next to—never over—the existing ChangeOps +document title, version, status, and stable ID. Relationships still come only from typed policy +dependency tables, and the assessment loader never queries `confluence_document_sources`. +Confluence cannot create relationships, findings, impacts, actions, approvals, or execution. + +The adapter uses only standard-library HTTPS GETs with a bounded timeout. Credentials and the one +page ID are environment-only. Automated tests use captured Confluence page/space fixtures and +mocked network behavior; CI requires no Atlassian account. ## Policy-analysis journey read model @@ -897,10 +936,15 @@ Jira Cloud is the only real side-effect target. Its synchronous REST v3 adapter credentials and configured project/Task identifiers. Automated tests inject or mock it; CI needs no Jira account or secret. +Confluence Cloud is a read-only source. Its synchronous REST v2 adapter reads one configured page +and that page's space using environment credentials. It never sends a write method, searches, +crawls, polls, or changes ChangeOps relationships. Automated tests inject or mock it; CI needs no +Confluence account or secret. + There are no: - agents, tool-calling loops, embeddings, RAG, or vector-search components; -- MCP or additional live enterprise integrations; +- MCP or additional live enterprise integrations beyond the bounded Jira and Confluence adapters; - graph databases; - background workers or message queues; - Jira operations beyond create Task, including transitions and reconciliation; diff --git a/docs/decisions.md b/docs/decisions.md index 4b30c34..769b607 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -790,3 +790,57 @@ search, transition, update, or reconciliation path is added. - The system prefers missed automatic recovery over violating the no-duplicate requirement. - Jira remains an execution adapter and does not enter analysis, comparison, approval, or planning domain models. + +# ADR-0020 — Preserve ChangeOps Document Identity Beside a Narrow Confluence Source + +## Status + +Accepted + +## Context + +Milestone 7 PR B must prove that one ChangeOps enterprise document corresponds to a real +Confluence Cloud page. The existing `enterprise_documents.id` is already referenced by typed +policy dependencies, assessment fingerprints, evidence, impact paths, comparisons, and proposed +actions. Replacing it with a provider identifier would make an external knowledge system +authoritative for ChangeOps analysis and destabilize historical lineage. + +The repository has no configured live page. Automated verification must therefore use captured +provider metadata and must not commit a placeholder page ID. The slice needs explicit refresh, +safe failure behavior, and a visible external link, but no crawling, content ingestion, editing, +relationship creation, or multi-provider platform. + +## Decision + +Add one Confluence-specific `confluence_document_sources` table with a one-to-one foreign key to +`enterprise_documents`. It stores only validated page identity and metadata, import/refresh times, +a canonical source fingerprint, and the latest safe refresh outcome. It stores neither policy +relationships nor arbitrary provider JSON. + +Only the Manager Travel Approval Guide has a named environment page-ID mapping. A manual POST by +stable ChangeOps document ID performs two read-only Confluence REST API v2 calls: get the page and +get its owning space. The service validates the configured and returned identities before an +idempotent insert/update. It updates no column on `enterprise_documents`. + +A first failed refresh persists no unvalidated identity. After a successful import, a later +failure updates only outcome fields and retains all last-known-good metadata. The catalog detail +projects both identities side by side. The assessment and relationship services never query the +Confluence table. + +The implementation uses direct email/API-token authentication to match the existing bounded Jira +configuration pattern. The configured account must have view-only access to the selected page and +space. OAuth scope guidance for an equivalent app is limited to `read:page:confluence` and +`read:space:confluence`. + +## Consequences + +- ChangeOps document, relationship, assessment, and audit identities remain stable if a page is + renamed, versioned, unavailable, or later moved between spaces. +- Confluence remains authoritative only for the imported external page metadata. +- Fixture-backed CI is deterministic and needs no live Atlassian credential. +- Adding another document requires an explicit named configuration decision; arbitrary page + selection, search, crawling, and bulk synchronization are unavailable. +- Retargeting a successfully imported document to a different page is rejected rather than + silently rewriting external identity. +- Relationship-origin provenance is still absent. PR C remains a separate schema and governance + decision. diff --git a/docs/demo-scenario.md b/docs/demo-scenario.md index 61418dd..08d8670 100644 --- a/docs/demo-scenario.md +++ b/docs/demo-scenario.md @@ -943,3 +943,28 @@ the immutable success result. Repeating execution returns `already_applied`, ref receipt, and performs no second Jira request. A mocked ambiguous availability failure demonstrates the at-most-once safety rule: ChangeOps records the unavailable outcome and refuses to resend when the first delivery may have succeeded. + +## Milestone 7 PR B Read-Only Confluence Identity Extension + +The Manager Travel Approval Guide retains the stable ChangeOps identity +`document-manager-travel-approval-guide`. When a reviewer has manually configured a real Acme +Confluence page and explicitly refreshed it, the document detail additionally shows: + +- source provider `Confluence Cloud`; +- the validated external page ID; +- owning space key and ID; +- Confluence page version and current/draft/archived status; +- source-updated and last-refreshed timestamps; +- an **Open in Confluence** link. + +The repository and automated demo remain honest when no live page exists: the detail shows that +Confluence metadata has not been imported, while fixture-backed tests prove the full boundary. +`make demo-reset` preserves a successful import. A failed later refresh leaves the last-known-good +metadata visible with the precise failure state. + +Confluence is authoritative only for the imported page metadata. ChangeOps remains authoritative +for its internal document identity, curated typed policy relationships, assessment behavior, +workflow state, and audit lineage. The refresh does not change the Manager guide's two +`instructs_manager` rows or any golden assessment fingerprint/count. No Confluence content, +relationship origin, crawling, RAG, editing, publishing, background synchronization, or PR C +provenance is included. diff --git a/docs/interview-guide.md b/docs/interview-guide.md index b4fdb20..e99700f 100644 --- a/docs/interview-guide.md +++ b/docs/interview-guide.md @@ -287,6 +287,8 @@ Evaluation follows architectural ownership: - Why is LangGraph needed if PostgreSQL stores the state? - How do comparisons behave when records have different UUIDs? - What changes when simulated integrations become real? +- Why does ChangeOps preserve a separate document ID when a Confluence page ID exists? +- What happens to imported metadata when Confluence is unavailable? ### What not to explain unless asked @@ -320,6 +322,8 @@ Keep the main story at the level of **problem → evidence → decision → cont - [ ] At least one action is available for review. - [ ] Actor identity and role are set correctly. - [ ] The simulated Learning target and configured Jira project are in a known state. +- [ ] The optional Confluence page is explicitly refreshed, or the honest not-imported state is + part of the walkthrough. - [ ] Browser zoom and window size make evidence readable. - [ ] A backup API response or screenshot is available. diff --git a/docs/milestone-7-enterprise-knowledge-catalog.md b/docs/milestone-7-enterprise-knowledge-catalog.md index 6a73ce8..5eed352 100644 --- a/docs/milestone-7-enterprise-knowledge-catalog.md +++ b/docs/milestone-7-enterprise-knowledge-catalog.md @@ -2,7 +2,9 @@ ## Status -Approved for implementation after this planning revision merges. +PR A is merged. PR B is implemented in code with fixture-backed verification; creating and +configuring the real Acme Confluence page remains the documented manual environment step. PR C +relationship-origin provenance remains deferred. This document is based on repository `main` at commit `14cfb5d` and on the implementation, migrations, seed/reset behavior, tests, and product-facing read models at that commit. The diff --git a/docs/remaining-work-inventory.md b/docs/remaining-work-inventory.md index 32f73a2..44d1823 100644 --- a/docs/remaining-work-inventory.md +++ b/docs/remaining-work-inventory.md @@ -78,7 +78,7 @@ Remaining work must add a distinct product, architecture, deployment, or present ## Recommended remaining work -- [ ] Narrow Confluence-backed document source +- [x] Narrow Confluence-backed document source integration (live Acme page setup remains manual) - [ ] Trusted relationship provenance and governance - [ ] Unified audit timeline - [ ] AWS deployment architecture decision @@ -276,13 +276,13 @@ A Jira remediation task may include a link to the relevant Confluence page. ### Completion criteria - [ ] A small, coherent Acme Confluence knowledge base exists. -- [ ] Catalog document records link to real Confluence pages. -- [ ] At least the Manager Travel Approval Guide exists as an actual page. -- [ ] ChangeOps can import or resolve bounded page metadata. -- [ ] Imported identity is stable and idempotent. -- [ ] Automated tests do not require live Confluence. -- [ ] CI uses mocked or fixture-based provider behavior. -- [ ] ChangeOps never edits Confluence. +- [ ] Catalog document records link to real Confluence pages after the documented live setup. +- [ ] At least the Manager Travel Approval Guide exists as an actual page after manual setup. +- [x] ChangeOps can import or resolve bounded page metadata. +- [x] Imported identity is stable and idempotent. +- [x] Automated tests do not require live Confluence. +- [x] CI uses mocked or fixture-based provider behavior. +- [x] ChangeOps never edits Confluence. --- @@ -725,8 +725,8 @@ Jira and Confluence REST integrations alone do not automatically justify MCP. 2. [x] Inspect current catalog models and seeded relationships. 3. [x] Define a narrow Enterprise Knowledge Catalog milestone. 4. [x] Implement the read-only catalog explorer. -5. [ ] Create the bounded Acme Confluence content set. -6. [ ] Add read-only Confluence metadata import and links. +5. [ ] Create the bounded Acme Confluence content set using the documented manual setup. +6. [x] Add read-only Confluence metadata import and links. 7. [ ] Expose trusted relationship provenance. 8. [ ] Decide whether AI-proposed relationship governance adds sufficient value. 9. [ ] Implement the unified audit timeline. diff --git a/docs/roadmap.md b/docs/roadmap.md index 2d89c7d..ebb46a4 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -686,7 +686,23 @@ Delivered behavior * no migration, runtime dependency, graph abstraction, search, or assessment behavior change is introduced. +Milestone 7 PR B — Read-Only Confluence Document Identity + +Status: complete in code; live Acme page creation and environment configuration remain manual. + +Delivered behavior + +* one Confluence-specific, one-to-one external source row preserves the existing document ID; +* only the Manager Travel Approval Guide has an explicit environment configuration boundary; +* one manual refresh reads page and space metadata through Confluence Cloud REST API v2; +* validated metadata and a source fingerprint are persisted idempotently; +* failure outcomes are distinct and never overwrite last-known-good metadata; +* the catalog detail shows unavailable, imported, and last-refresh-failed states plus the external + link; +* demo reset preserves imports and fixture-backed CI requires no Confluence account; +* typed relationships and deterministic assessment behavior remain unchanged. + Next vertical slice -Milestone 7 PR B is the separately planned narrow external document identity and read-only -Confluence metadata slice. Relationship-origin provenance remains a later PR C decision. +Relationship-origin provenance remains the separately reviewed Milestone 7 PR C decision. This PR +does not pre-approve or implement it. diff --git a/migrations/versions/0013_confluence_document_sources.py b/migrations/versions/0013_confluence_document_sources.py new file mode 100644 index 0000000..15b0089 --- /dev/null +++ b/migrations/versions/0013_confluence_document_sources.py @@ -0,0 +1,67 @@ +"""Add read-only Confluence document identity. + +Revision ID: 0013_confluence_document_sources +Revises: 0012_jira_execution +Create Date: 2026-08-04 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "0013_confluence_document_sources" +down_revision: str | None = "0012_jira_execution" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "confluence_document_sources", + sa.Column("document_id", sa.String(length=100), nullable=False), + sa.Column("provider", sa.String(length=30), nullable=False), + sa.Column("external_page_id", sa.String(length=100), nullable=False), + sa.Column("external_title", sa.String(length=300), nullable=False), + sa.Column("canonical_url", sa.Text(), nullable=False), + sa.Column("space_id", sa.String(length=100), nullable=False), + sa.Column("space_key", sa.String(length=100), nullable=False), + sa.Column("external_version", sa.Integer(), nullable=False), + sa.Column("external_status", sa.String(length=30), nullable=False), + sa.Column("source_updated_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("imported_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("refreshed_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("source_fingerprint", sa.String(length=64), nullable=False), + sa.Column("last_refresh_result", sa.String(length=40), nullable=False), + sa.Column("last_refresh_message", sa.Text(), nullable=False), + sa.Column("last_refresh_attempted_at", sa.DateTime(timezone=True), nullable=False), + sa.CheckConstraint( + "provider = 'confluence_cloud'", + name="ck_confluence_document_sources_provider", + ), + sa.CheckConstraint( + "external_version > 0", + name="ck_confluence_document_sources_external_version", + ), + sa.CheckConstraint( + "external_status IN ('current', 'draft', 'archived')", + name="ck_confluence_document_sources_external_status", + ), + sa.CheckConstraint( + "last_refresh_result IN " + "('success', 'already_current', 'authentication_failure', " + "'permission_denied', 'page_not_found', 'provider_unavailable', " + "'validation_failure', 'ambiguous_response')", + name="ck_confluence_document_sources_last_refresh_result", + ), + sa.ForeignKeyConstraint(["document_id"], ["enterprise_documents.id"]), + sa.PrimaryKeyConstraint("document_id"), + sa.UniqueConstraint( + "external_page_id", + name="uq_confluence_document_sources_external_page", + ), + ) + + +def downgrade() -> None: + op.drop_table("confluence_document_sources") diff --git a/output/playwright/m7-pr-b-catalog-1024.png b/output/playwright/m7-pr-b-catalog-1024.png new file mode 100644 index 0000000..84e1344 Binary files /dev/null and b/output/playwright/m7-pr-b-catalog-1024.png differ diff --git a/output/playwright/m7-pr-b-manager-guide-fixture-imported-1440.png b/output/playwright/m7-pr-b-manager-guide-fixture-imported-1440.png new file mode 100644 index 0000000..42ef23c Binary files /dev/null and b/output/playwright/m7-pr-b-manager-guide-fixture-imported-1440.png differ diff --git a/output/playwright/m7-pr-b-manager-guide-unconfigured-1440.png b/output/playwright/m7-pr-b-manager-guide-unconfigured-1440.png new file mode 100644 index 0000000..03ecdf5 Binary files /dev/null and b/output/playwright/m7-pr-b-manager-guide-unconfigured-1440.png differ diff --git a/output/playwright/m7-pr-b-manager-guide-unconfigured-390.png b/output/playwright/m7-pr-b-manager-guide-unconfigured-390.png new file mode 100644 index 0000000..1384802 Binary files /dev/null and b/output/playwright/m7-pr-b-manager-guide-unconfigured-390.png differ diff --git a/src/changeops/adapters/confluence.py b/src/changeops/adapters/confluence.py new file mode 100644 index 0000000..5e21158 --- /dev/null +++ b/src/changeops/adapters/confluence.py @@ -0,0 +1,198 @@ +import base64 +import json +from dataclasses import dataclass +from datetime import datetime +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.parse import quote, urlparse +from urllib.request import Request, urlopen + + +class ConfluenceAdapterError(Exception): + def __init__(self, result: str, message: str) -> None: + super().__init__(message) + self.result = result + self.message = message + + +@dataclass(frozen=True) +class ConfluencePageMetadata: + page_id: str + title: str + canonical_url: str + space_id: str + space_key: str + version: int + status: str + source_updated_at: datetime + + +class ConfluenceCloudAdapter: + def __init__( + self, *, base_url: str, email: str, api_token: str, timeout_seconds: float + ) -> None: + normalized_url = base_url.rstrip("/") + parsed_url = urlparse(normalized_url) + if ( + parsed_url.scheme != "https" + or not parsed_url.netloc + or parsed_url.username + or parsed_url.password + or parsed_url.query + or parsed_url.fragment + ): + raise ValueError("Confluence base URL must be an HTTPS site origin.") + self.base_url = normalized_url + self.email = email + self.api_token = api_token + self.timeout_seconds = timeout_seconds + + def get_page_metadata(self, page_id: str) -> ConfluencePageMetadata: + if not page_id.isdigit() or len(page_id) > 100: + raise ConfluenceAdapterError( + "validation_failure", + "The configured Confluence page ID must be numeric.", + ) + page = self._get_json( + f"/wiki/api/v2/pages/{quote(page_id, safe='')}?include-version=true", + resource="page", + ) + returned_page_id = _required_string(page, "id") + if returned_page_id != page_id: + raise ConfluenceAdapterError( + "validation_failure", + "Confluence returned a different page identity than the configured page.", + ) + space_id = _bounded_string(page, "spaceId", 100) + space = self._get_json( + f"/wiki/api/v2/spaces/{quote(space_id, safe='')}", + resource="space", + ) + if _required_string(space, "id") != space_id: + raise ConfluenceAdapterError( + "validation_failure", + "Confluence returned a space that does not own the configured page.", + ) + + version = page.get("version") + if not isinstance(version, dict): + raise _ambiguous_response() + version_number = version.get("number") + if ( + not isinstance(version_number, int) + or isinstance(version_number, bool) + or version_number < 1 + ): + raise _ambiguous_response() + status = _required_string(page, "status") + if status not in {"current", "draft", "archived"}: + raise ConfluenceAdapterError( + "validation_failure", + "Confluence returned an unsupported page status.", + ) + source_updated_at = _parse_timestamp(_required_string(version, "createdAt")) + space_key = _bounded_string(space, "key", 100) + canonical_url = ( + f"{self.base_url}/wiki/spaces/{quote(space_key, safe='')}/pages/" + f"{quote(returned_page_id, safe='')}" + ) + return ConfluencePageMetadata( + page_id=returned_page_id, + title=_bounded_string(page, "title", 300), + canonical_url=canonical_url, + space_id=space_id, + space_key=space_key, + version=version_number, + status=status, + source_updated_at=source_updated_at, + ) + + def _get_json(self, path: str, *, resource: str) -> dict[str, Any]: + token = base64.b64encode(f"{self.email}:{self.api_token}".encode()).decode() + request = Request( + f"{self.base_url}{path}", + headers={ + "Accept": "application/json", + "Authorization": f"Basic {token}", + }, + method="GET", + ) + try: + with urlopen(request, timeout=self.timeout_seconds) as response: # noqa: S310 + status = response.status + raw = response.read() + except HTTPError as error: + if error.code == 401: + raise ConfluenceAdapterError( + "authentication_failure", + "Confluence rejected the configured credentials.", + ) from error + if error.code == 403: + raise ConfluenceAdapterError( + "permission_denied", + "Confluence denied access to the configured page or space.", + ) from error + if error.code == 404: + result = "page_not_found" if resource == "page" else "validation_failure" + raise ConfluenceAdapterError( + result, + f"The configured Confluence {resource} was not found.", + ) from error + if 400 <= error.code < 500 and error.code != 429: + raise ConfluenceAdapterError( + "validation_failure", + "Confluence rejected the bounded metadata request.", + ) from error + raise ConfluenceAdapterError( + "provider_unavailable", + "Confluence is temporarily unavailable.", + ) from error + except (TimeoutError, URLError, OSError) as error: + raise ConfluenceAdapterError( + "provider_unavailable", + "Confluence is temporarily unavailable.", + ) from error + + if status != 200: + raise _ambiguous_response() + try: + payload = json.loads(raw) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise _ambiguous_response() from error + if not isinstance(payload, dict): + raise _ambiguous_response() + return payload + + +def _required_string(payload: dict[str, Any], key: str) -> str: + value = payload.get(key) + if not isinstance(value, str) or not value.strip(): + raise _ambiguous_response() + return value.strip() + + +def _bounded_string(payload: dict[str, Any], key: str, max_length: int) -> str: + value = _required_string(payload, key) + if len(value) > max_length: + raise ConfluenceAdapterError( + "validation_failure", + "Confluence returned metadata that exceeds the supported field length.", + ) + return value + + +def _parse_timestamp(value: str) -> datetime: + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as error: + raise _ambiguous_response() from error + if parsed.tzinfo is None: + raise _ambiguous_response() + return parsed + + +def _ambiguous_response() -> ConfluenceAdapterError: + return ConfluenceAdapterError( + "ambiguous_response", + "Confluence returned an incomplete or unreadable metadata response.", + ) diff --git a/src/changeops/api/catalog.py b/src/changeops/api/catalog.py index 95b7b02..2784498 100644 --- a/src/changeops/api/catalog.py +++ b/src/changeops/api/catalog.py @@ -5,6 +5,7 @@ from changeops.schemas.catalog import ( CatalogBrowseResponse, CatalogObjectDetailResponse, + ConfluenceRefreshResponse, PolicyRuleReferenceResponse, ) from changeops.services.catalog_projection_service import ( @@ -17,6 +18,13 @@ get_catalog_browse, get_catalog_object_detail, get_policy_rule_reference, + serialize_external_document_source, +) +from changeops.services.confluence_document_service import ( + ConfluenceDocumentNotFoundError, + ConfluenceNotConfiguredError, + ConfluenceRefreshError, + refresh_confluence_document, ) router = APIRouter(prefix="/api/v1", tags=["enterprise-knowledge-catalog"]) @@ -73,6 +81,44 @@ def retrieve_catalog_object( raise _http_error(409, "catalog_projection_inconsistent", str(error)) from error +@router.post( + "/catalog-objects/enterprise_document/{document_id}/confluence-refresh", + response_model=ConfluenceRefreshResponse, +) +def refresh_catalog_document_confluence_source( + document_id: str, + session: SessionDependency, +) -> ConfluenceRefreshResponse: + try: + outcome = refresh_confluence_document(session, document_id) + except ConfluenceDocumentNotFoundError as error: + raise _http_error( + 404, + "catalog_object_not_found", + "Catalog document was not found.", + ) from error + except ConfluenceNotConfiguredError as error: + raise _http_error( + 409, + "confluence_not_configured", + "Confluence is not configured for this catalog document.", + ) from error + except ConfluenceRefreshError as error: + status_code = ( + 404 + if error.code == "confluence_page_not_found" + else 409 + if error.code == "confluence_validation_failure" + else 502 + ) + raise _http_error(status_code, error.code, error.message) from error + return ConfluenceRefreshResponse( + document_id=document_id, + result=outcome.result, + external_source=serialize_external_document_source(outcome.source), + ) + + @router.get( "/policy-changes/{policy_change_id}/rule-references/{rule_code}", response_model=PolicyRuleReferenceResponse, diff --git a/src/changeops/config.py b/src/changeops/config.py index d4dbc2e..e6cad08 100644 --- a/src/changeops/config.py +++ b/src/changeops/config.py @@ -22,6 +22,11 @@ class Settings(BaseSettings): jira_project_id_or_key: str | None = None jira_issue_type_id: str | None = None jira_timeout_seconds: float = 10.0 + confluence_base_url: str | None = None + confluence_email: str | None = None + confluence_api_token: SecretStr | None = None + confluence_timeout_seconds: float = 10.0 + confluence_manager_travel_approval_guide_page_id: str | None = None model_config = SettingsConfigDict(env_file=".env", extra="ignore") diff --git a/src/changeops/db/models.py b/src/changeops/db/models.py index 4c3bed7..cebc206 100644 --- a/src/changeops/db/models.py +++ b/src/changeops/db/models.py @@ -147,6 +147,55 @@ class EnterpriseDocument(Base): status: Mapped[str] = mapped_column(String(30)) +class ConfluenceDocumentSource(Base): + __tablename__ = "confluence_document_sources" + __table_args__ = ( + CheckConstraint( + "provider = 'confluence_cloud'", + name="ck_confluence_document_sources_provider", + ), + CheckConstraint( + "external_version > 0", + name="ck_confluence_document_sources_external_version", + ), + CheckConstraint( + "external_status IN ('current', 'draft', 'archived')", + name="ck_confluence_document_sources_external_status", + ), + CheckConstraint( + "last_refresh_result IN " + "('success', 'already_current', 'authentication_failure', " + "'permission_denied', 'page_not_found', 'provider_unavailable', " + "'validation_failure', 'ambiguous_response')", + name="ck_confluence_document_sources_last_refresh_result", + ), + UniqueConstraint( + "external_page_id", + name="uq_confluence_document_sources_external_page", + ), + ) + + document_id: Mapped[str] = mapped_column( + ForeignKey("enterprise_documents.id"), + primary_key=True, + ) + provider: Mapped[str] = mapped_column(String(30)) + external_page_id: Mapped[str] = mapped_column(String(100)) + external_title: Mapped[str] = mapped_column(String(300)) + canonical_url: Mapped[str] = mapped_column(Text) + space_id: Mapped[str] = mapped_column(String(100)) + space_key: Mapped[str] = mapped_column(String(100)) + external_version: Mapped[int] = mapped_column(Integer) + external_status: Mapped[str] = mapped_column(String(30)) + source_updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + imported_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + refreshed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + source_fingerprint: Mapped[str] = mapped_column(String(64)) + last_refresh_result: Mapped[str] = mapped_column(String(40)) + last_refresh_message: Mapped[str] = mapped_column(Text) + last_refresh_attempted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + + class TrainingCourse(Base): __tablename__ = "training_courses" diff --git a/src/changeops/schemas/catalog.py b/src/changeops/schemas/catalog.py index 0214b15..6b79ff8 100644 --- a/src/changeops/schemas/catalog.py +++ b/src/changeops/schemas/catalog.py @@ -1,3 +1,4 @@ +from datetime import datetime from typing import Literal from pydantic import BaseModel, ConfigDict @@ -148,12 +149,48 @@ class AssessmentUsageResponse(StrictModel): changes_assessment_behavior: Literal[False] +class ExternalDocumentSourceResponse(StrictModel): + provider: Literal["confluence_cloud"] + provider_label: Literal["Confluence Cloud"] + external_page_id: str + external_title: str + canonical_url: str + space_id: str + space_key: str + external_version: int + external_status: Literal["current", "draft", "archived"] + source_updated_at: datetime + imported_at: datetime + refreshed_at: datetime + source_fingerprint: str + last_refresh_result: Literal[ + "success", + "already_current", + "authentication_failure", + "permission_denied", + "page_not_found", + "provider_unavailable", + "validation_failure", + "ambiguous_response", + ] + last_refresh_message: str + last_refresh_attempted_at: datetime + + class CatalogObjectDetailResponse(StrictModel): object: CatalogObjectResponse + external_source_state: Literal["imported", "not_imported", "not_applicable"] + external_source: ExternalDocumentSourceResponse | None relationships: list[CatalogRelationshipResponse] assessment_usage: AssessmentUsageResponse +class ConfluenceRefreshResponse(StrictModel): + document_id: str + result: Literal["success", "already_current"] + external_source: ExternalDocumentSourceResponse + + class StructuredRuleReferenceResponse(StrictModel): field_path: str | None label: str | None diff --git a/src/changeops/services/catalog_projection_service.py b/src/changeops/services/catalog_projection_service.py index 6870a88..6ea77e1 100644 --- a/src/changeops/services/catalog_projection_service.py +++ b/src/changeops/services/catalog_projection_service.py @@ -5,6 +5,7 @@ from sqlalchemy.orm import Session from changeops.db.models import ( + ConfluenceDocumentSource, CustomerCommitment, EnterpriseDocument, EnterpriseSystem, @@ -31,6 +32,7 @@ CatalogRelationshipResponse, CatalogStatusResponse, DocumentMetadataResponse, + ExternalDocumentSourceResponse, PolicyReferenceResponse, PolicyRuleReferenceResponse, RecordProvenanceResponse, @@ -226,6 +228,11 @@ def get_catalog_object_detail( raise CatalogObjectNotFoundError(object_id) relationships = _relationships_for_object(session, object_type, record) + external_source = ( + session.get(ConfluenceDocumentSource, record.id) + if object_type == "enterprise_document" + else None + ) completion_count = ( session.scalar( select(func.count()) @@ -251,6 +258,18 @@ def get_catalog_object_detail( catalog_context="seeded_demonstration", ), ), + external_source_state=( + "imported" + if external_source is not None + else "not_imported" + if object_type == "enterprise_document" + else "not_applicable" + ), + external_source=( + serialize_external_document_source(external_source) + if external_source is not None + else None + ), relationships=relationships, assessment_usage=AssessmentUsageResponse( statement=ASSESSMENT_USAGE[object_type], @@ -259,6 +278,29 @@ def get_catalog_object_detail( ) +def serialize_external_document_source( + source: ConfluenceDocumentSource, +) -> ExternalDocumentSourceResponse: + return ExternalDocumentSourceResponse( + provider="confluence_cloud", + provider_label="Confluence Cloud", + external_page_id=source.external_page_id, + external_title=source.external_title, + canonical_url=source.canonical_url, + space_id=source.space_id, + space_key=source.space_key, + external_version=source.external_version, + external_status=source.external_status, + source_updated_at=source.source_updated_at, + imported_at=source.imported_at, + refreshed_at=source.refreshed_at, + source_fingerprint=source.source_fingerprint, + last_refresh_result=source.last_refresh_result, + last_refresh_message=source.last_refresh_message, + last_refresh_attempted_at=source.last_refresh_attempted_at, + ) + + def get_policy_rule_reference( session: Session, policy_change_id: str, diff --git a/src/changeops/services/confluence_document_service.py b/src/changeops/services/confluence_document_service.py new file mode 100644 index 0000000..6869bb2 --- /dev/null +++ b/src/changeops/services/confluence_document_service.py @@ -0,0 +1,208 @@ +import hashlib +import json +from dataclasses import dataclass +from datetime import UTC, datetime + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from changeops.adapters.confluence import ( + ConfluenceAdapterError, + ConfluenceCloudAdapter, + ConfluencePageMetadata, +) +from changeops.config import get_settings +from changeops.db.models import ConfluenceDocumentSource, EnterpriseDocument + +MANAGER_GUIDE_DOCUMENT_ID = "document-manager-travel-approval-guide" + + +class ConfluenceDocumentNotFoundError(Exception): + pass + + +class ConfluenceNotConfiguredError(Exception): + pass + + +class ConfluenceRefreshError(Exception): + def __init__(self, code: str, message: str) -> None: + super().__init__(message) + self.code = code + self.message = message + + +@dataclass(frozen=True) +class ConfluenceRefreshOutcome: + result: str + source: ConfluenceDocumentSource + + +def refresh_confluence_document( + session: Session, + document_id: str, + *, + adapter: ConfluenceCloudAdapter | None = None, + configured_page_id: str | None = None, + attempted_at: datetime | None = None, +) -> ConfluenceRefreshOutcome: + document = session.get(EnterpriseDocument, document_id) + if document is None: + raise ConfluenceDocumentNotFoundError(document_id) + + existing = session.get(ConfluenceDocumentSource, document_id) + if configured_page_id is None: + configured_page_id = _configured_page_id(document_id) + if not configured_page_id: + raise ConfluenceNotConfiguredError(document_id) + attempted_at = attempted_at or datetime.now(UTC) + if existing is not None and existing.external_page_id != configured_page_id: + error = ConfluenceAdapterError( + "validation_failure", + "The configured page ID does not match the document's imported external identity.", + ) + _record_failed_refresh(session, document_id, error, attempted_at) + raise ConfluenceRefreshError( + "confluence_validation_failure", + error.message, + ) + + if adapter is None: + adapter = _configured_adapter() + session.rollback() + try: + metadata = adapter.get_page_metadata(configured_page_id) + except ConfluenceAdapterError as error: + _record_failed_refresh(session, document_id, error, attempted_at) + raise ConfluenceRefreshError(f"confluence_{error.result}", error.message) from error + + if metadata.page_id != configured_page_id: + error = ConfluenceAdapterError( + "validation_failure", + "Confluence returned a different page identity than the configured page.", + ) + _record_failed_refresh(session, document_id, error, attempted_at) + raise ConfluenceRefreshError(f"confluence_{error.result}", error.message) + + fingerprint = _source_fingerprint(metadata) + with session.begin(): + locked_document = session.scalar( + select(EnterpriseDocument).where(EnterpriseDocument.id == document_id).with_for_update() + ) + if locked_document is None: + raise ConfluenceDocumentNotFoundError(document_id) + source = session.get(ConfluenceDocumentSource, document_id) + if source is not None and source.external_page_id != configured_page_id: + raise ConfluenceRefreshError( + "confluence_validation_failure", + "The configured page ID does not match the document's imported external identity.", + ) + result = ( + "already_current" + if source is not None and source.source_fingerprint == fingerprint + else "success" + ) + message = ( + "Confluence metadata is already current." + if result == "already_current" + else "Confluence metadata was imported." + ) + if source is None: + source = ConfluenceDocumentSource( + document_id=document_id, + provider="confluence_cloud", + external_page_id=metadata.page_id, + external_title=metadata.title, + canonical_url=metadata.canonical_url, + space_id=metadata.space_id, + space_key=metadata.space_key, + external_version=metadata.version, + external_status=metadata.status, + source_updated_at=metadata.source_updated_at, + imported_at=attempted_at, + refreshed_at=attempted_at, + source_fingerprint=fingerprint, + last_refresh_result=result, + last_refresh_message=message, + last_refresh_attempted_at=attempted_at, + ) + session.add(source) + else: + if result == "success": + source.external_title = metadata.title + source.canonical_url = metadata.canonical_url + source.space_id = metadata.space_id + source.space_key = metadata.space_key + source.external_version = metadata.version + source.external_status = metadata.status + source.source_updated_at = metadata.source_updated_at + source.source_fingerprint = fingerprint + source.refreshed_at = attempted_at + source.last_refresh_result = result + source.last_refresh_message = message + source.last_refresh_attempted_at = attempted_at + session.flush() + return ConfluenceRefreshOutcome(result=result, source=source) + + +def _configured_page_id(document_id: str) -> str | None: + settings = get_settings() + if document_id == MANAGER_GUIDE_DOCUMENT_ID: + return settings.confluence_manager_travel_approval_guide_page_id + return None + + +def _configured_adapter() -> ConfluenceCloudAdapter: + settings = get_settings() + if ( + not settings.confluence_base_url + or not settings.confluence_email + or settings.confluence_api_token is None + or not settings.confluence_api_token.get_secret_value() + ): + raise ConfluenceNotConfiguredError("confluence_credentials") + try: + return ConfluenceCloudAdapter( + base_url=settings.confluence_base_url, + email=settings.confluence_email, + api_token=settings.confluence_api_token.get_secret_value(), + timeout_seconds=settings.confluence_timeout_seconds, + ) + except ValueError as error: + raise ConfluenceNotConfiguredError("confluence_base_url") from error + + +def _record_failed_refresh( + session: Session, + document_id: str, + error: ConfluenceAdapterError, + attempted_at: datetime, +) -> None: + session.rollback() + with session.begin(): + source = session.scalar( + select(ConfluenceDocumentSource) + .where(ConfluenceDocumentSource.document_id == document_id) + .with_for_update() + ) + if source is None: + return + source.last_refresh_result = error.result + source.last_refresh_message = error.message + source.last_refresh_attempted_at = attempted_at + + +def _source_fingerprint(metadata: ConfluencePageMetadata) -> str: + payload = { + "provider": "confluence_cloud", + "external_page_id": metadata.page_id, + "external_title": metadata.title, + "canonical_url": metadata.canonical_url, + "space_id": metadata.space_id, + "space_key": metadata.space_key, + "external_version": metadata.version, + "external_status": metadata.status, + "source_updated_at": metadata.source_updated_at.isoformat(), + } + canonical = json.dumps(payload, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(canonical.encode()).hexdigest() diff --git a/tests/fixtures/confluence/page.json b/tests/fixtures/confluence/page.json new file mode 100644 index 0000000..60b5235 --- /dev/null +++ b/tests/fixtures/confluence/page.json @@ -0,0 +1,17 @@ +{ + "id": "123456789", + "status": "current", + "title": "Manager Travel Approval Guide", + "spaceId": "987654321", + "createdAt": "2026-07-01T14:00:00.000Z", + "version": { + "createdAt": "2026-08-01T16:30:00.000Z", + "message": "Clarify approval evidence", + "number": 7, + "minorEdit": false, + "authorId": "account-123" + }, + "_links": { + "base": "https://acme.atlassian.net" + } +} diff --git a/tests/fixtures/confluence/space.json b/tests/fixtures/confluence/space.json new file mode 100644 index 0000000..1bc1ea7 --- /dev/null +++ b/tests/fixtures/confluence/space.json @@ -0,0 +1,10 @@ +{ + "id": "987654321", + "key": "TRAVEL", + "name": "Acme Travel Operations", + "type": "global", + "status": "current", + "_links": { + "base": "https://acme.atlassian.net" + } +} diff --git a/tests/integration/test_catalog_api.py b/tests/integration/test_catalog_api.py index 2ea08a4..3c98253 100644 --- a/tests/integration/test_catalog_api.py +++ b/tests/integration/test_catalog_api.py @@ -318,6 +318,12 @@ def test_only_get_catalog_routes_are_registered(client) -> None: assert client.post("/api/v1/catalog-objects", json={}).status_code == 405 assert client.put(MANAGER_GUIDE_URL, json={}).status_code == 405 assert client.delete(MANAGER_RULE_URL).status_code == 405 + assert ( + client.post( + "/api/v1/catalog-objects/enterprise_document/missing/confluence-refresh" + ).status_code + == 404 + ) def _artifact_counts() -> dict[str, int]: diff --git a/tests/integration/test_confluence_document_api.py b/tests/integration/test_confluence_document_api.py new file mode 100644 index 0000000..9760c82 --- /dev/null +++ b/tests/integration/test_confluence_document_api.py @@ -0,0 +1,289 @@ +from datetime import UTC, datetime + +import pytest +from sqlalchemy import select + +from changeops.adapters.confluence import ConfluenceAdapterError, ConfluencePageMetadata +from changeops.db.models import ( + ConfluenceDocumentSource, + EnterpriseDocument, + PolicyDocumentDependency, +) +from changeops.db.session import SessionLocal +from changeops.services import confluence_document_service +from changeops.services.demo_reset_service import reset_demo_workflows +from changeops.services.seed_service import POLICY_CHANGE_ID + +DOCUMENT_ID = "document-manager-travel-approval-guide" +PAGE_ID = "123456789" +REFRESH_URL = f"/api/v1/catalog-objects/enterprise_document/{DOCUMENT_ID}/confluence-refresh" +DETAIL_URL = f"/api/v1/catalog-objects/enterprise_document/{DOCUMENT_ID}" + + +@pytest.fixture(autouse=True) +def clean_confluence_source(): + with SessionLocal.begin() as session: + source = session.get(ConfluenceDocumentSource, DOCUMENT_ID) + if source is not None: + session.delete(source) + yield + with SessionLocal.begin() as session: + source = session.get(ConfluenceDocumentSource, DOCUMENT_ID) + if source is not None: + session.delete(source) + + +class FakeAdapter: + def __init__( + self, + metadata: ConfluencePageMetadata | None = None, + error: ConfluenceAdapterError | None = None, + ) -> None: + self.metadata = metadata or _metadata() + self.error = error + self.calls: list[str] = [] + + def get_page_metadata(self, page_id: str) -> ConfluencePageMetadata: + self.calls.append(page_id) + if self.error is not None: + raise self.error + return self.metadata + + +def _metadata( + *, + title: str = "Manager Travel Approval Guide", + version: int = 7, +) -> ConfluencePageMetadata: + return ConfluencePageMetadata( + page_id=PAGE_ID, + title=title, + canonical_url=f"https://acme.atlassian.net/wiki/spaces/TRAVEL/pages/{PAGE_ID}", + space_id="987654321", + space_key="TRAVEL", + version=version, + status="current", + source_updated_at=datetime(2026, 8, 1, 16, 30, tzinfo=UTC), + ) + + +def _configure(monkeypatch, adapter: FakeAdapter) -> None: + monkeypatch.setattr( + confluence_document_service, + "_configured_page_id", + lambda document_id: PAGE_ID if document_id == DOCUMENT_ID else None, + ) + monkeypatch.setattr( + confluence_document_service, + "_configured_adapter", + lambda: adapter, + ) + + +def test_refresh_requires_explicit_selected_document_configuration(client) -> None: + response = client.post(REFRESH_URL) + + assert response.status_code == 409 + assert response.json()["detail"]["code"] == "confluence_not_configured" + with SessionLocal() as session: + assert session.get(ConfluenceDocumentSource, DOCUMENT_ID) is None + + +def test_refresh_imports_metadata_and_repeats_idempotently(client, monkeypatch) -> None: + adapter = FakeAdapter() + _configure(monkeypatch, adapter) + + first = client.post(REFRESH_URL) + second = client.post(REFRESH_URL) + + assert first.status_code == second.status_code == 200 + assert first.json()["result"] == "success" + assert second.json()["result"] == "already_current" + assert adapter.calls == [PAGE_ID, PAGE_ID] + source = second.json()["external_source"] + assert source["provider_label"] == "Confluence Cloud" + assert source["external_page_id"] == PAGE_ID + assert source["external_title"] == "Manager Travel Approval Guide" + assert source["space_key"] == "TRAVEL" + assert source["external_version"] == 7 + assert source["external_status"] == "current" + assert len(source["source_fingerprint"]) == 64 + + detail = client.get(DETAIL_URL).json() + assert detail["external_source_state"] == "imported" + assert detail["external_source"]["canonical_url"].endswith(f"/pages/{PAGE_ID}") + with SessionLocal() as session: + assert len(list(session.scalars(select(ConfluenceDocumentSource)))) == 1 + + +def test_refresh_updates_only_imported_metadata_fields(client, monkeypatch) -> None: + adapter = FakeAdapter() + _configure(monkeypatch, adapter) + assert client.post(REFRESH_URL).status_code == 200 + + adapter.metadata = _metadata(title="Manager Travel Approval Guide — Revised", version=8) + refreshed = client.post(REFRESH_URL) + + assert refreshed.status_code == 200 + assert refreshed.json()["result"] == "success" + assert refreshed.json()["external_source"]["external_title"].endswith("Revised") + assert refreshed.json()["external_source"]["external_version"] == 8 + with SessionLocal() as session: + document = session.get(EnterpriseDocument, DOCUMENT_ID) + assert document is not None + assert document.title == "Manager Travel Approval Guide" + assert document.version == "2" + assert document.status == "published" + relationships = list( + session.scalars( + select(PolicyDocumentDependency) + .where(PolicyDocumentDependency.document_id == DOCUMENT_ID) + .order_by(PolicyDocumentDependency.id) + ) + ) + assert [item.id for item in relationships] == [ + "dependency-policy-manager-guide", + "dependency-policy-manager-guide-proposed", + ] + + +@pytest.mark.parametrize( + ("result", "status_code"), + [ + ("authentication_failure", 502), + ("permission_denied", 502), + ("page_not_found", 404), + ("provider_unavailable", 502), + ("validation_failure", 409), + ("ambiguous_response", 502), + ], +) +def test_first_failed_refresh_is_visible_without_persisting_unvalidated_identity( + client, + monkeypatch, + result: str, + status_code: int, +) -> None: + adapter = FakeAdapter(error=ConfluenceAdapterError(result, f"Controlled {result}.")) + _configure(monkeypatch, adapter) + + response = client.post(REFRESH_URL) + + assert response.status_code == status_code + assert response.json()["detail"]["code"] == f"confluence_{result}" + with SessionLocal() as session: + assert session.get(ConfluenceDocumentSource, DOCUMENT_ID) is None + + +def test_failed_refresh_preserves_last_known_good_metadata(client, monkeypatch) -> None: + adapter = FakeAdapter() + _configure(monkeypatch, adapter) + assert client.post(REFRESH_URL).status_code == 200 + with SessionLocal() as session: + source = session.get(ConfluenceDocumentSource, DOCUMENT_ID) + known_good = ( + source.external_title, + source.external_version, + source.source_updated_at, + source.source_fingerprint, + source.refreshed_at, + ) + + adapter.error = ConfluenceAdapterError( + "permission_denied", + "Confluence denied access to the configured page or space.", + ) + failed = client.post(REFRESH_URL) + + assert failed.status_code == 502 + detail = client.get(DETAIL_URL).json() + assert detail["external_source"]["last_refresh_result"] == "permission_denied" + assert "denied access" in detail["external_source"]["last_refresh_message"] + with SessionLocal() as session: + source = session.get(ConfluenceDocumentSource, DOCUMENT_ID) + assert ( + source.external_title, + source.external_version, + source.source_updated_at, + source.source_fingerprint, + source.refreshed_at, + ) == known_good + + +def test_refresh_rejects_silent_retargeting_of_imported_identity(client, monkeypatch) -> None: + adapter = FakeAdapter() + _configure(monkeypatch, adapter) + assert client.post(REFRESH_URL).status_code == 200 + monkeypatch.setattr( + confluence_document_service, + "_configured_page_id", + lambda document_id: "222222222" if document_id == DOCUMENT_ID else None, + ) + + response = client.post(REFRESH_URL) + + assert response.status_code == 409 + assert response.json()["detail"]["code"] == "confluence_validation_failure" + assert adapter.calls == [PAGE_ID] + with SessionLocal() as session: + source = session.get(ConfluenceDocumentSource, DOCUMENT_ID) + assert source.external_page_id == PAGE_ID + assert source.last_refresh_result == "validation_failure" + + +def test_refresh_does_not_change_assessment_behavior(client, monkeypatch) -> None: + before = client.post(f"/api/v1/policy-changes/{POLICY_CHANGE_ID}/impact-assessments") + assert before.status_code == 201 + adapter = FakeAdapter() + _configure(monkeypatch, adapter) + + assert client.post(REFRESH_URL).status_code == 200 + after = client.post(f"/api/v1/policy-changes/{POLICY_CHANGE_ID}/impact-assessments") + + assert after.status_code == 201 + before_body = before.json() + after_body = after.json() + assert after_body["input_fingerprint"] == before_body["input_fingerprint"] + assert after_body["summary"] == before_body["summary"] + assert [ + (item["worker"]["id"], item["classification"], item["reason_codes"]) + for item in after_body["worker_results"] + ] == [ + (item["worker"]["id"], item["classification"], item["reason_codes"]) + for item in before_body["worker_results"] + ] + assert [ + ( + impact["domain"], + impact["object_type"], + impact["source_key"], + impact["classification"], + impact["reason_code"], + ) + for impacts in after_body["enterprise_impacts"].values() + for impact in impacts + ] == [ + ( + impact["domain"], + impact["object_type"], + impact["source_key"], + impact["classification"], + impact["reason_code"], + ) + for impacts in before_body["enterprise_impacts"].values() + for impact in impacts + ] + assert after_body["summary"]["enterprise_impacts"] == 18 + assert len(after_body["proposed_actions"]) == 13 + + +def test_demo_reset_preserves_imported_confluence_identity(client, monkeypatch) -> None: + adapter = FakeAdapter() + _configure(monkeypatch, adapter) + assert client.post(REFRESH_URL).status_code == 200 + before = client.get(DETAIL_URL).json()["external_source"] + + with SessionLocal.begin() as session: + reset_demo_workflows(session) + + assert client.get(DETAIL_URL).json()["external_source"] == before diff --git a/tests/unit/test_catalog_schemas.py b/tests/unit/test_catalog_schemas.py index f8abd3d..abb6bea 100644 --- a/tests/unit/test_catalog_schemas.py +++ b/tests/unit/test_catalog_schemas.py @@ -24,6 +24,8 @@ def catalog_detail_payload() -> dict: "catalog_context": "seeded_demonstration", }, }, + "external_source_state": "not_imported", + "external_source": None, "relationships": [ { "relationship_id": "dependency-guide", diff --git a/tests/unit/test_confluence_adapter.py b/tests/unit/test_confluence_adapter.py new file mode 100644 index 0000000..2cb5d69 --- /dev/null +++ b/tests/unit/test_confluence_adapter.py @@ -0,0 +1,138 @@ +import io +import json +from pathlib import Path +from urllib.error import HTTPError, URLError + +import pytest + +from changeops.adapters import confluence +from changeops.adapters.confluence import ConfluenceAdapterError, ConfluenceCloudAdapter + +FIXTURE_DIR = Path(__file__).parents[1] / "fixtures" / "confluence" + + +def _fixture(name: str) -> bytes: + return (FIXTURE_DIR / name).read_bytes() + + +class Response: + status = 200 + + def __init__(self, body: bytes) -> None: + self.body = body + + def __enter__(self): + return self + + def __exit__(self, *args): + return None + + def read(self) -> bytes: + return self.body + + +def _adapter() -> ConfluenceCloudAdapter: + return ConfluenceCloudAdapter( + base_url="https://acme.atlassian.net", + email="reader@example.com", + api_token="secret", + timeout_seconds=4, + ) + + +def test_adapter_reads_bounded_page_and_space_metadata(monkeypatch) -> None: + requests = [] + + def fake_urlopen(request, timeout): + requests.append((request, timeout)) + body = _fixture("space.json") if "/spaces/" in request.full_url else _fixture("page.json") + return Response(body) + + monkeypatch.setattr(confluence, "urlopen", fake_urlopen) + metadata = _adapter().get_page_metadata("123456789") + + assert [request.full_url for request, _ in requests] == [ + "https://acme.atlassian.net/wiki/api/v2/pages/123456789?include-version=true", + "https://acme.atlassian.net/wiki/api/v2/spaces/987654321", + ] + assert all(timeout == 4 for _, timeout in requests) + assert all(request.headers["Authorization"].startswith("Basic ") for request, _ in requests) + assert metadata.page_id == "123456789" + assert metadata.title == "Manager Travel Approval Guide" + assert metadata.space_id == "987654321" + assert metadata.space_key == "TRAVEL" + assert metadata.version == 7 + assert metadata.status == "current" + assert metadata.source_updated_at.isoformat() == "2026-08-01T16:30:00+00:00" + assert metadata.canonical_url == ( + "https://acme.atlassian.net/wiki/spaces/TRAVEL/pages/123456789" + ) + + +@pytest.mark.parametrize( + ("status", "result"), + [ + (401, "authentication_failure"), + (403, "permission_denied"), + (404, "page_not_found"), + (400, "validation_failure"), + (429, "provider_unavailable"), + (503, "provider_unavailable"), + ], +) +def test_adapter_classifies_provider_failures(monkeypatch, status: int, result: str) -> None: + monkeypatch.setattr( + confluence, + "urlopen", + lambda *args, **kwargs: (_ for _ in ()).throw( + HTTPError("url", status, "error", {}, io.BytesIO(b"{}")) + ), + ) + + with pytest.raises(ConfluenceAdapterError) as captured: + _adapter().get_page_metadata("123456789") + + assert captured.value.result == result + + +def test_adapter_classifies_network_and_ambiguous_responses(monkeypatch) -> None: + monkeypatch.setattr( + confluence, + "urlopen", + lambda *args, **kwargs: (_ for _ in ()).throw(URLError("lost")), + ) + with pytest.raises(ConfluenceAdapterError) as unavailable: + _adapter().get_page_metadata("123456789") + assert unavailable.value.result == "provider_unavailable" + + monkeypatch.setattr(confluence, "urlopen", lambda *args, **kwargs: Response(b"not-json")) + with pytest.raises(ConfluenceAdapterError) as ambiguous: + _adapter().get_page_metadata("123456789") + assert ambiguous.value.result == "ambiguous_response" + + +def test_adapter_rejects_mismatched_identity_and_invalid_status(monkeypatch) -> None: + page = json.loads(_fixture("page.json")) + page["id"] = "222" + monkeypatch.setattr( + confluence, + "urlopen", + lambda *args, **kwargs: Response(json.dumps(page).encode()), + ) + with pytest.raises(ConfluenceAdapterError) as mismatch: + _adapter().get_page_metadata("123456789") + assert mismatch.value.result == "validation_failure" + + with pytest.raises(ConfluenceAdapterError) as invalid_id: + _adapter().get_page_metadata("placeholder") + assert invalid_id.value.result == "validation_failure" + + +def test_adapter_rejects_non_https_site_origin() -> None: + with pytest.raises(ValueError): + ConfluenceCloudAdapter( + base_url="http://acme.atlassian.net", + email="reader@example.com", + api_token="secret", + timeout_seconds=4, + ) diff --git a/web/app/components/catalog-views.test.tsx b/web/app/components/catalog-views.test.tsx index afa40dd..9c22f97 100644 --- a/web/app/components/catalog-views.test.tsx +++ b/web/app/components/catalog-views.test.tsx @@ -107,6 +107,8 @@ const detail: CatalogObjectDetail = { catalog_context: "seeded_demonstration", }, }, + external_source_state: "not_imported", + external_source: null, relationships: [ relationship, { @@ -161,6 +163,7 @@ describe("enterprise knowledge catalog views", () => { expect(screen.getByText("International Business Travel Approval and Security Training · 1")).toBeInTheDocument(); expect(screen.getByText("Proposed International Business Travel Revision · proposed-draft")).toBeInTheDocument(); expect(screen.getAllByText("Instructs Manager")).toHaveLength(2); + expect(screen.getByText("Confluence metadata has not been imported.")).toBeInTheDocument(); expect(screen.getAllByRole("link", { name: "Explain this policy rule" })[0]).toHaveAttribute( "href", "/catalog/policy-rules/policy-international-travel-2026-09/MANAGER_APPROVAL_REQUIRED", @@ -171,6 +174,76 @@ describe("enterprise knowledge catalog views", () => { expect(screen.queryByRole("button", { name: /edit|import|approve|refresh|search/i })).not.toBeInTheDocument(); }); + it("shows validated read-only Confluence identity and an external link", () => { + const imported: CatalogObjectDetail = { + ...detail, + external_source_state: "imported", + external_source: { + provider: "confluence_cloud", + provider_label: "Confluence Cloud", + external_page_id: "123456789", + external_title: "Manager Travel Approval Guide", + canonical_url: "https://acme.atlassian.net/wiki/spaces/TRAVEL/pages/123456789", + space_id: "987654321", + space_key: "TRAVEL", + external_version: 7, + external_status: "current", + source_updated_at: "2026-08-01T16:30:00Z", + imported_at: "2026-08-04T15:00:00Z", + refreshed_at: "2026-08-04T15:10:00Z", + source_fingerprint: "a".repeat(64), + last_refresh_result: "already_current", + last_refresh_message: "Confluence metadata is already current.", + last_refresh_attempted_at: "2026-08-04T15:10:00Z", + }, + }; + + render(); + + expect(screen.getByText("Confluence Cloud")).toBeInTheDocument(); + expect(screen.getByText("123456789")).toBeInTheDocument(); + expect(screen.getByText("TRAVEL · 987654321")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Open in Confluence" })).toHaveAttribute( + "href", + imported.external_source?.canonical_url, + ); + expect(screen.getByText("Technical source details").closest("details")).not.toHaveAttribute( + "open", + ); + }); + + it("shows last-known-good metadata as stale after a failed refresh", () => { + const stale: CatalogObjectDetail = { + ...detail, + external_source_state: "imported", + external_source: { + provider: "confluence_cloud", + provider_label: "Confluence Cloud", + external_page_id: "123456789", + external_title: "Manager Travel Approval Guide", + canonical_url: "https://acme.atlassian.net/wiki/spaces/TRAVEL/pages/123456789", + space_id: "987654321", + space_key: "TRAVEL", + external_version: 7, + external_status: "current", + source_updated_at: "2026-08-01T16:30:00Z", + imported_at: "2026-08-04T15:00:00Z", + refreshed_at: "2026-08-04T15:10:00Z", + source_fingerprint: "a".repeat(64), + last_refresh_result: "permission_denied", + last_refresh_message: "Confluence denied access to the configured page or space.", + last_refresh_attempted_at: "2026-08-04T16:10:00Z", + }, + }; + + render(); + + expect( + screen.getByText("Last refresh failed; showing last-known-good metadata."), + ).toBeInTheDocument(); + expect(screen.getByText(/denied access/i)).toBeInTheDocument(); + }); + it("explains a policy-scoped rule and links back to each typed target", () => { const reference: PolicyRuleReference = { stable_key: "policy-international-travel-2026-09:MANAGER_APPROVAL_REQUIRED", diff --git a/web/app/components/catalog-views.tsx b/web/app/components/catalog-views.tsx index ad316a4..56b2f7c 100644 --- a/web/app/components/catalog-views.tsx +++ b/web/app/components/catalog-views.tsx @@ -168,6 +168,10 @@ export function CatalogObjectDetailView({ detail }: { detail: CatalogObjectDetai + {object.object_type === "enterprise_document" && ( + + )} +
@@ -239,6 +243,62 @@ export function CatalogObjectDetailView({ detail }: { detail: CatalogObjectDetai ); } +function ExternalDocumentIdentity({ detail }: { detail: CatalogObjectDetail }) { + const source = detail.external_source; + return ( +
+

Read-only external source

+

Confluence document identity

+ {source ? ( + <> +
+

+ This ChangeOps document corresponds to a validated page in an external enterprise + knowledge system. ChangeOps preserves its own stable catalog identity. +

+ {humanize(source.external_status)} +
+
+ + + + + + +
+ {!["success", "already_current"].includes(source.last_refresh_result) && ( +
+ Last refresh failed; showing last-known-good metadata. +

{source.last_refresh_message}

+
+ )} + + Open in Confluence + +
+ Technical source details +
+ + + + + +
+
+ + ) : ( +
+ Confluence metadata has not been imported. +

+ The ChangeOps catalog record remains available. Configure the selected page and run + the explicit refresh API to validate and import its read-only external identity. +

+
+ )} +
+ ); +} + export function PolicyRuleReferenceView({ reference }: { reference: PolicyRuleReference }) { return (
@@ -394,3 +454,11 @@ function humanize(value: string): string { .replaceAll("_", " ") .replace(/\b\w/g, (character) => character.toUpperCase()); } + +function formatDateTime(value: string): string { + return new Intl.DateTimeFormat("en-US", { + dateStyle: "medium", + timeStyle: "short", + timeZone: "UTC", + }).format(new Date(value)); +} diff --git a/web/app/globals.css b/web/app/globals.css index 23c06a6..a488534 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -2318,10 +2318,31 @@ dd { .catalog-usage, .assessment-context, +.catalog-external-source, .catalog-rule-technical { margin-top: 2.5rem; } +.catalog-external-source > a { + display: inline-flex; + margin-top: 1rem; + font-weight: 750; +} + +.catalog-stale-notice, +.catalog-unavailable-source { + margin-top: 1rem; + border-left: 4px solid #a45b22; + border-radius: 4px; + background: #fff4e8; + padding: 1rem; +} + +.catalog-stale-notice p, +.catalog-unavailable-source p { + margin-bottom: 0; +} + .catalog-rule-technical { border: 1px solid var(--color-border); border-radius: 8px; diff --git a/web/lib/types.ts b/web/lib/types.ts index 3083bd0..96a7c4c 100644 --- a/web/lib/types.ts +++ b/web/lib/types.ts @@ -741,6 +741,33 @@ export interface CatalogObjectDetail { catalog_context: "seeded_demonstration"; }; }; + external_source_state: "imported" | "not_imported" | "not_applicable"; + external_source: { + provider: "confluence_cloud"; + provider_label: "Confluence Cloud"; + external_page_id: string; + external_title: string; + canonical_url: string; + space_id: string; + space_key: string; + external_version: number; + external_status: "current" | "draft" | "archived"; + source_updated_at: string; + imported_at: string; + refreshed_at: string; + source_fingerprint: string; + last_refresh_result: + | "success" + | "already_current" + | "authentication_failure" + | "permission_denied" + | "page_not_found" + | "provider_unavailable" + | "validation_failure" + | "ambiguous_response"; + last_refresh_message: string; + last_refresh_attempted_at: string; + } | null; relationships: CatalogRelationship[]; assessment_usage: { statement: string;