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
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ Dispatch ships with Atlassian Confluence storage and document plugins. This page

### `API URL` \[Required\]

> URL of the confluence cloud instance.
> URL of the Confluence instance. For Cloud this is the site root, for example `https://your-site.atlassian.net/`, without the `/wiki` path.

### `Hosting Type` \[Default: cloud\]

> Either `cloud` or `server`. Dispatch reaches Confluence Cloud over the REST v2 API and Confluence Server/Data Center over REST v1; these are different APIs, so this must match the deployment.

### `Username` \[Required\]

Expand All @@ -26,9 +30,9 @@ Dispatch ships with Atlassian Confluence storage and document plugins. This page

> This is the page id of the template that contains the post incident review document details. The plugin uses this template to create a new page and then replace the supported variables with the incident details.

### `Default Space ID` \[Required\]
### `Default Space Key` \[Required\]

> This is the default space [key](https://confluence.atlassian.com/doc/space-keys-829076188.html) where all the pages will be created in confluence.
> This is the default space [key](https://confluence.atlassian.com/doc/space-keys-829076188.html) where all the pages will be created in confluence. The space key, not its numeric id: on Cloud, Dispatch resolves the key to the id the v2 API requires.

### `Parent ID of the pages` \[Required\]

Expand All @@ -46,7 +50,11 @@ Dispatch ships with Atlassian Confluence storage and document plugins. This page

### `API URL` \[Required\]

> URL of the confluence cloud instance.
> URL of the Confluence instance. For Cloud this is the site root, for example `https://your-site.atlassian.net/`, without the `/wiki` path.

### `Hosting Type` \[Default: cloud\]

> Either `cloud` or `server`. Dispatch reaches Confluence Cloud over the REST v2 API and Confluence Server/Data Center over REST v1; these are different APIs, so this must match the deployment.

### `Username` \[Required\]

Expand Down
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,10 @@ dependencies = [
"aiohttp",
"alembic",
"anthropic==0.122.0",
"atlassian-python-api",
# 5.0.0 split Confluence into separate Cloud (REST v2) and Server (REST v1)
# classes with different method signatures. The major ceiling keeps the next
# such split from reaching production through a routine bump.
"atlassian-python-api>=5.0.3,<6",
"attrs>=26.1.0",
"bcrypt",
"blockkit==2.1.3",
Expand Down
6 changes: 3 additions & 3 deletions requirements-lock.txt
Original file line number Diff line number Diff line change
Expand Up @@ -164,9 +164,9 @@ anyio==4.14.2 \
# httpx2
# openai
# starlette
atlassian-python-api==4.0.7 \
--hash=sha256:46a70cb29eaab87c0a1697fccd3e25df1aa477e6aa4fb9ba936a9d46b425933c \
--hash=sha256:8d9cc6068b1d2a48eb434e22e57f6bbd918a47fac9e46b95b7a3cefb00fceacb
atlassian-python-api==5.0.3 \
--hash=sha256:7d0443a01568cdbf9af45efd30980472979923f6b605f2640e0e80dd02c1dc8a \
--hash=sha256:c5265c39a8bceb7c3b254d768b4a006a63c7fd80153d293a686fa4e31d52e3c2
# via dispatch (pyproject.toml)
attrs==26.1.0 \
--hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 \
Expand Down
133 changes: 133 additions & 0 deletions src/dispatch/plugins/dispatch_atlassian_confluence/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
"""Confluence client construction, for atlassian-python-api 5.x.

5.x split Confluence into a Cloud client speaking REST v2 and a Server client
speaking REST v1, with different method signatures. The 4.x
``Confluence(cloud=...)`` factory routes any truthy ``cloud`` value to a class
carrying none of the methods used here, so ``hosting_type`` selects a class
rather than being passed through as a flag. Both call shapes live here so the
plugins never branch on the deployment type.
"""

from atlassian import ConfluenceServer, ConfluenceV2

from dispatch.plugins.dispatch_atlassian_confluence.config import (
ConfluenceConfigurationBase,
HostingType,
)


class ConfluenceApi:
"""The Confluence operations Dispatch performs, over one 5.x client."""

client_class = None

def __init__(self, configuration: ConfluenceConfigurationBase):
self.configuration = configuration
self.client = self.client_class(
url=str(configuration.api_url),
username=configuration.username,
password=configuration.password.get_secret_value(),
)

@property
def site_url(self) -> str:
"""The configured site, without a trailing slash.

AnyHttpUrl only appends one when the path is empty, so a Server
instance under a context path would otherwise be concatenated straight
onto the next path segment.
"""
return str(self.configuration.api_url).rstrip("/")


class CloudApi(ConfluenceApi):
"""Confluence Cloud, over the REST v2 API.

Atlassian removed the v1 Cloud content endpoints on 2025-04-30, so this is
the only API a Cloud deployment can be migrated onto.
"""

client_class = ConfluenceV2

def create_page(self, *, space: str, title: str, body: str, parent_id: str) -> dict:
# v2 addresses spaces by numeric id. `space` is the configured space
# key, which the v2 page endpoint rejects, so resolve it first.
space_id = self.client.get_space_by_key(space)["id"]
return self.client.create_page(
space_id=str(space_id),
title=title,
body=body,
parent_id=parent_id,
body_format="storage",
)

def get_page(self, page_id: str) -> dict:
return self.client.get_page_by_id(page_id, body_format="storage")

def update_page(self, *, page_id: str, title: str, body: str) -> dict:
# v2's PageUpdateRequest requires id, status, title, body and version.
# The client sends `status` only when asked to, so omitting it here
# produces a payload the API rejects.
return self.client.update_page(
page_id=page_id, title=title, body=body, body_format="storage", status="current"
)

@property
def site_url(self) -> str:
# The client appends /wiki itself, so an operator who already included
# it would otherwise get it twice in every weblink.
return super().site_url.removesuffix("/wiki")

def page_weblink(self, *, space: str, page_id: str, title: str) -> str:
return f"{self.site_url}/wiki/spaces/{space}/pages/{page_id}/{title}"


class ServerApi(ConfluenceApi):
"""Confluence Server/Data Center, over the REST v1 API.

The v1 signatures are unchanged from 4.0.7, including ``editor`` and
``full_width``, which have no v2 equivalent: v2 pages are always created in
the new editor.
"""

client_class = ConfluenceServer

def create_page(self, *, space: str, title: str, body: str, parent_id: str) -> dict:
return self.client.create_page(
space=space,
title=title,
body=body,
parent_id=parent_id,
type="page",
representation="storage",
editor="v2",
full_width=False,
)

def get_page(self, page_id: str) -> dict:
return self.client.get_page_by_id(page_id, expand="body.storage", status=None, version=None)

def update_page(self, *, page_id: str, title: str, body: str) -> dict:
return self.client.update_page(
page_id=page_id,
title=title,
body=body,
representation="storage",
type="page",
parent_id=None,
minor_edit=False,
full_width=False,
)

def page_weblink(self, *, space: str, page_id: str, title: str) -> str:
# Server has no /wiki context path, and addressing by page id avoids
# having to encode the space key and title into the path.
return f"{self.site_url}/pages/viewpage.action?pageId={page_id}"


APIS = {HostingType.cloud: CloudApi, HostingType.server: ServerApi}


def confluence_api(configuration: ConfluenceConfigurationBase) -> ConfluenceApi:
"""Builds an authenticated API for the configured deployment."""
return APIS[configuration.hosting_type](configuration)
Original file line number Diff line number Diff line change
@@ -1,28 +1,21 @@
from dispatch.plugins.dispatch_atlassian_confluence import docs as confluence_doc_plugin
from dispatch.plugins.bases import DocumentPlugin
from dispatch.plugins.dispatch_atlassian_confluence.config import ConfluenceConfigurationBase
from atlassian import Confluence
from dispatch.plugins.dispatch_atlassian_confluence.client import ConfluenceApi, confluence_api


def replace_content(client: Confluence, document_id: str, replacements: list[str]) -> {}:
def replace_content(api: ConfluenceApi, document_id: str, replacements: dict[str, str]) -> dict:
# read content based on document_id
current_content = client.get_page_by_id(
document_id, expand="body.storage", status=None, version=None
)
current_content = api.get_page(document_id)
current_content_body = current_content["body"]["storage"]["value"]
for k, v in replacements.items():
if v:
current_content_body = current_content_body.replace(k, v)

updated_content = client.update_page(
updated_content = api.update_page(
page_id=document_id,
title=current_content["title"],
body=current_content_body,
representation="storage",
type="page",
parent_id=None,
minor_edit=False,
full_width=False,
)
return updated_content

Expand All @@ -42,10 +35,4 @@ def __init__(self):
def update(self, document_id: str, **kwargs):
"""Replaces text in document."""
kwargs = {"{{" + k + "}}": v for k, v in kwargs.items()}
confluence_client = Confluence(
url=str(self.configuration.api_url),
username=self.configuration.username,
password=self.configuration.password.get_secret_value(),
cloud=self.configuration.hosting_type,
)
return replace_content(confluence_client, document_id, kwargs)
return replace_content(confluence_api(self.configuration), document_id, kwargs)
73 changes: 37 additions & 36 deletions src/dispatch/plugins/dispatch_atlassian_confluence/plugin.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,31 @@
from dispatch.plugins import dispatch_atlassian_confluence as confluence_plugin
from dispatch.plugins.bases import StoragePlugin
from dispatch.plugins.dispatch_atlassian_confluence.client import confluence_api
from dispatch.plugins.dispatch_atlassian_confluence.config import ConfluenceConfigurationBase

from pydantic import Field

from atlassian import Confluence
import requests
from requests.auth import HTTPBasicAuth
import logging

logger = logging.getLogger(__name__)


def describe(exception: Exception) -> str:
"""Renders an exception that may carry nothing but a response.

The Cloud client's raise_for_status does not understand v2's error
envelope, so HTTPError arrives with an empty message and the status is the
only thing left to report. The body is deliberately not included: it is not
audited for what a Confluence error may quote back.
"""
response = getattr(exception, "response", None)
if str(exception) or response is None:
return str(exception)
return f"HTTP {response.status_code} {response.reason}"


# TODO : Use the common config from the root directory.
class ConfluenceConfiguration(ConfluenceConfigurationBase):
"""Confluence configuration description."""
Expand All @@ -20,7 +34,8 @@ class ConfluenceConfiguration(ConfluenceConfigurationBase):
title="Incident template ID", description="This is the page id of the template."
)
root_id: str = Field(
title="Default Space ID", description="Defines the default Confluence Space to use."
title="Default Space Key",
description="Key of the default Confluence Space to use, not its numeric id or display name.",
)
parent_id: str = Field(
title="Parent ID for the pages",
Expand Down Expand Up @@ -51,74 +66,60 @@ def __init__(self):
self.configuration_schema = ConfluenceConfiguration

def create_file(
self, drive_id: str, name: str, participants: list[str] = None, file_type: str = "folder"
self, parent_id: str, name: str, participants: list[str] = None, file_type: str = "folder"
):
"""Creates a new Home page for the incident documents.."""
try:
if file_type not in ["document", "folder"]:
return None
confluence_client = Confluence(
url=str(self.configuration.api_url),
username=self.configuration.username,
password=self.configuration.password.get_secret_value(),
cloud=self.configuration.hosting_type,
)
# The storage interface is Drive-shaped: callers pass the project's
# storage root, which for Confluence is a space key. The ancestor
# page comes from the plugin configuration instead.
space = parent_id
api = confluence_api(self.configuration)
child_display_body = """<h3>Incident Documents:</h3><ac:structured-macro ac:name="children"
ac:schema-version="2" data-layout="default" ac:local-id="ec0e8d6d-3215-4328-b1f8-e96b03ccefb9"
ac:macro-id="10235d28b48543519d4e2b06ca230142"><ac:parameter ac:name="sort">modified</ac:parameter>
<ac:parameter ac:name="reverse">true</ac:parameter></ac:structured-macro>"""
page_details = confluence_client.create_page(
drive_id,
name,
page_details = api.create_page(
space=space,
title=name,
body=child_display_body,
parent_id=self.configuration.parent_id,
type="page",
representation="storage",
editor="v2",
full_width=False,
)
return {
"weblink": f"{self.configuration.api_url}wiki/spaces/{drive_id}/pages/{page_details['id']}/{name}",
"weblink": api.page_weblink(space=space, page_id=page_details["id"], title=name),
"id": page_details["id"],
"name": name,
"description": "",
}
except Exception as e:
logger.error(f"Exception happened while creating page: {e}")
logger.error(f"Exception happened while creating page: {describe(e)}")

def copy_file(self, folder_id: str, file_id: str, name: str):
# TODO : This is the function that is responsible for making the incident documents.
try:
confluence_client = Confluence(
url=str(self.configuration.api_url),
username=self.configuration.username,
password=self.configuration.password.get_secret_value(),
cloud=self.configuration.hosting_type,
)
api = confluence_api(self.configuration)
logger.info(f"Copy_file function with args {folder_id}, {file_id}, {name}")
template_content = confluence_client.get_page_by_id(
self.configuration.template_id, expand="body.storage", status=None, version=None
)
page_details = confluence_client.create_page(
template_content = api.get_page(self.configuration.template_id)
page_details = api.create_page(
space=self.configuration.root_id,
parent_id=folder_id,
title=name,
type="page",
body=template_content["body"],
representation="storage",
editor="v2",
full_width=False,
body=template_content["body"]["storage"]["value"],
)
if self.configuration.parent_id:
"""TODO: Find and fix why the page is not created under the parent_id, folder_id"""
self.move_file_confluence(page_id_to_move=page_details["id"], parent_id=folder_id)
return {
"weblink": f"{self.configuration.api_url}wiki/spaces/{folder_id}/pages/{page_details['id']}/{name}",
"weblink": api.page_weblink(
space=self.configuration.root_id, page_id=page_details["id"], title=name
),
"id": page_details["id"],
"name": name,
}
except Exception as e:
logger.error(f"Exception happened while creating page: {e}")
logger.error(f"Exception happened while creating page: {describe(e)}")

def move_file(self, new_folder_id: str, file_id: str, **kwargs):
"""Moves a file from one place to another. Not used in the plugin,
Expand All @@ -135,4 +136,4 @@ def move_file_confluence(self, page_id_to_move: str, parent_id: str):
response = requests.request("PUT", url, headers=headers, auth=auth)
return response
except Exception as e:
logger.error(f"Exception happened while moving page: {e}")
logger.error(f"Exception happened while moving page: {describe(e)}")
8 changes: 8 additions & 0 deletions src/dispatch/storage/flows.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,14 @@ def create_storage(subject: Subject, storage_members: list[str], db_session: Ses
plugin.instance.create_file(parent_id=external_storage["id"], name=folder_two_name)

if subject.project.storage_use_folder_one_as_primary:
# A plugin that could not create the folder returns None, the same way
# the root above does. Subscripting it here would abort the whole
# incident flow, which has no except around this call.
if not folder_one:
log.error(
f"Storage not created. Plugin {plugin.plugin.slug} could not create {folder_one_name}." # noqa: E501
)
return
external_storage = folder_one

# we create the internal storage
Expand Down
Loading
Loading