> ## Documentation Index
> Fetch the complete documentation index at: https://docs.kameleoon.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Exporting experiment results to Confluence

> Retrieve an experiment and its results with the Automation API, determine the winning variation from the Bayesian success probability, and upsert the record into a Confluence page, using a Python script or an MCP-connected AI assistant.

## Goal

This tutorial describes how the [kameleoon\_to\_confluence.py](/assets/developer-docs/script/apis/automation-api-rest/tutorials/kameleoon_to_confluence.py.zip) script works, step by step. Given a Kameleoon **experiment ID**, a Confluence **site**, and a Confluence **space ID**, the script retrieves the experiment metadata and statistical results, identifies the winning variation, and upserts a row for that experiment into a table on a Confluence page. Re-running the script for the same experiment updates its existing row instead of adding a duplicate.

Steps 1–5 reuse the same request and poll flow as the [Airtable export tutorial](./exporting-experiment-results-to-airtable). Only the destination changes.

## Alternative: export with an AI assistant instead of the script

Running the script requires a Python environment, four stored credentials, and a manual re-run for every experiment you want to export. If you already use an MCP-compatible AI assistant such as Claude, you can perform the same export from a conversation instead, by connecting it to both Kameleoon's and Atlassian's own remote MCP (Model Context Protocol) servers, either through a coding tool or directly in the Claude app. Follow the [guide](/mcp/mcp-confluence-guide) to export results using Claude.

<Warning>
  The Atlassian MCP server exposes tools across Jira, Confluence, and Bitbucket, including write access to Confluence pages. The Kameleoon MCP server can also start, pause, stop, or delete experiments and feature flags. Review any action either connector proposes before approving it.
</Warning>

## Requirements

* **Kameleoon API credentials.** The Automation API requires an access token. The script obtains one programmatically from a `client_id` and `client_secret` using the `client_credentials` grant. See [Obtain an access token](/developer-docs/apis/automation-api-rest/get-started/get-started#1-obtain-an-access-token).

* **A Confluence API token**, paired with the email address of the account it belongs to. Create a token at [id.atlassian.com/manage-profile/security/api-tokens](https://id.atlassian.com/manage-profile/security/api-tokens). The script sends both as HTTP Basic authentication on every Confluence request.

* **The numeric Confluence space ID** for the space that hosts the page. Unlike a Notion database ID or an Airtable base ID, a space's numeric ID doesn't appear in its URL (that URL shows the space key instead). Find it under **Space settings** in Confluence, or ask an MCP-connected AI assistant to look it up for you.

* **Python 3.9+** with the `requests` library (`pip install requests`).

Confluence has no equivalent of a Notion database or an Airtable table that you build in advance. The script creates the destination page itself, with its table, the first time it runs. On every later run, it finds that page by title and updates it.

<Warning>
  Store all credentials in environment variables. Never hard-code secrets in the script.
</Warning>

```bash theme={null}
export KAMELEOON_CLIENT_ID="..."
export KAMELEOON_CLIENT_SECRET="..."
export CONFLUENCE_EMAIL="..."
export CONFLUENCE_API_TOKEN="..."
```

The tutorial uses the example experiment **Product Page Redesign** (ID `188308`), with two variations in addition to the original: variation `828220` and variation `828221`.

## 1. Authenticate with the Automation API

**Endpoint:** obtain an access token by sending a POST request to the token endpoint.

```
POST https://api.kameleoon.com/oauth/token
```

| Name           | Type   | Description                        |
| -------------- | ------ | ---------------------------------- |
| grant\_type    | String | Set to `client_credentials`.       |
| client\_id     | String | Your Automation API client ID.     |
| client\_secret | String | Your Automation API client secret. |

**Example:**

```python theme={null}
def kameleoon_token(client_id, client_secret):
    resp = requests.post(
        "https://api.kameleoon.com/oauth/token",
        headers={"Content-Type": "application/x-www-form-urlencoded"},
        data={
            "grant_type": "client_credentials",
            "client_id": client_id,
            "client_secret": client_secret,
        },
    )
    resp.raise_for_status()
    return resp.json()["access_token"]
```

**Response:**

```json theme={null}
{ "access_token": "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9..." }
```

Send the returned `access_token` as a `Bearer` token on every subsequent Automation API request. Access tokens remain valid for 2 hours by default.

## 2. Retrieve the experiment

**Endpoint:** fetch the experiment metadata by sending a GET request to the [Get an experiment](/api-reference/experiment/get-an-experiment) endpoint.

```
GET https://api.kameleoon.com/experiments/{experimentId}
```

| Name         | Type    | Description                                                     |
| ------------ | ------- | --------------------------------------------------------------- |
| experimentId | Integer | Mandatory path parameter. The ID of the experiment to retrieve. |

**Example:**

```python theme={null}
def get_experiment(token, experiment_id):
    resp = requests.get(
        f"https://api.kameleoon.com/experiments/{experiment_id}",
        headers={"Authorization": f"Bearer {token}"},
    )
    resp.raise_for_status()
    return resp.json()
```

**Response (truncated):**

```json theme={null}
{
  "id": 188308,
  "name": "Product Page Redesign",
  "status": "STOPPED",
  "dateStarted": "2025-01-15T09:00:00Z",
  "dateEnded": "2025-02-12T18:00:00Z",
  "description": "Testing two redesigns of the product page.",
  "mainGoalId": 279599,
  "variations": [828220, 828221]
}
```

The script reads `name`, `status`, `dateStarted`, `dateEnded`, and `description` for the Confluence row, and `mainGoalId` to scope the results request in the next step.

<Note>
  The API returns `mainGoalId` by default, so you don't need an `optionalFields` parameter to read it. The Automation API doesn't publish a fixed enum for the `status` field, but other status-type fields across the API consistently use uppercase tokens (for example, `STOPPED`, `ACTIVE`, `DRAFT`). [Step 6](#6-map-the-data-to-confluence-columns) maps the `status` field on that assumption. Confirm the exact tokens your account returns with a live request before you rely on the mapping.
</Note>

## 3. Request the experiment's results

**Endpoint:** trigger the generation of the results report by sending a POST request to the [Request experiment's results](/api-reference/experiment/request-experiments-results) endpoint.

```
POST https://api.kameleoon.com/experiments/{experimentId}/results
```

| Name                 | Type    | Description                                                                                                                                             |
| -------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| experimentId         | Integer | Mandatory path parameter.                                                                                                                               |
| goalsIds             | Array   | Restricts the report to the given goal IDs. The script passes the experiment's `mainGoalId`.                                                            |
| referenceVariationId | String  | The variation used as the reference for comparison. `"0"` uses the original page.                                                                       |
| visitorData          | Boolean | `false` for visit-based data, `true` for visitor-based data.                                                                                            |
| sequentialTesting    | Boolean | Set to `true` to use sequential testing for confidence intervals instead of the Bayesian success probability. Enable one method or the other, not both. |
| bayesian             | Boolean | Set to `true` to include the Bayesian success probability in the report. The script reads this value to populate the *Probability* column.              |
| conversionType       | String  | `ALL_CONVERSION` or `CONVERTED_VISITS`.                                                                                                                 |

**Example:**

```python theme={null}
def request_results(token, experiment_id, goal_id):
    body = {
        "visitorData": False,
        "sequentialTesting": False,
        "bayesian": True,
        "referenceVariationId": "0",
        "conversionType": "ALL_CONVERSION",
        "goalsIds": [goal_id] if goal_id else None,
    }
    resp = requests.post(
        f"https://api.kameleoon.com/experiments/{experiment_id}/results",
        headers={
            "Authorization": f"Bearer {token}",
            "Content-Type": "application/json",
            "Accept": "*/*",
        },
        json=body,
    )
    resp.raise_for_status()
    return resp.json()["dataCode"]
```

**Response:**

```json theme={null}
{ "dataCode": "14443931880924098266207585267983330260134079899081739889989435434342588016765" }
```

Kameleoon generates the report asynchronously. The endpoint returns a `dataCode`, which [step 4](#4-poll-for-the-results) uses to poll for the result.

<Note>
  This script requires `bayesian: true` and sets `sequentialTesting: false`. `bayesian` and `sequentialTesting` are alternative methods for computing significance, and this tutorial reports the Bayesian success probability. With Bayesian enabled, the report's `reliability` value carries the **Bayesian success probability** (the probability that a variation beats the reference), which the script maps to the *Probability* column. Confirm the value against the same report in the Kameleoon app if your account uses a different default statistical method.
</Note>

## 4. Poll for the results

**Endpoint:** retrieve the report by sending GET requests to the [Poll results](/api-reference/data/poll-results) endpoint until it's ready.

```
GET https://api.kameleoon.com/results?dataCode={dataCode}
```

| Name     | Type   | Description                                                                                                 |
| -------- | ------ | ----------------------------------------------------------------------------------------------------------- |
| dataCode | String | Mandatory query parameter. Use the hash that `POST /experiments/{experimentId}/results` returned in step 3. |

The response `status` is `WAITING` while Kameleoon computes the report, `READY` when the data is available, or `ERROR` / `TIMEOUT` on failure. When the status is `ERROR` or `TIMEOUT`, the response includes a top-level `errorDescription`. The script polls on a fixed interval until the status is `READY`.

**Example:**

```python theme={null}
def poll_results(token, data_code, max_attempts=30, delay=2.0):
    for _ in range(max_attempts):
        resp = requests.get(
            "https://api.kameleoon.com/results",
            headers={"Authorization": f"Bearer {token}"},
            params={"dataCode": data_code},
        )
        resp.raise_for_status()
        payload = resp.json()
        status = payload.get("status")
        if status == "READY":
            return payload["data"]
        if status in ("ERROR", "TIMEOUT"):
            raise RuntimeError(payload.get("errorDescription") or status)
        time.sleep(delay)  # status == "WAITING"
    raise TimeoutError("Timed out waiting for results.")
```

**Response (truncated):**

```json theme={null}
{
  "status": "READY",
  "data": {
    "variationData": {
      "_reference": { "breakdownData": { "_reference": { "generalData": {
        "goalsData": { "279599": { "conversionRate": 0.0149 } } } } } },
      "828220": { "breakdownData": { "_reference": { "generalData": {
        "goalsData": { "279599": {
          "reliability": 100.0,
          "improvementRate": 211.48,
          "conversionRate": 0.0466
        } } } } } },
      "828221": { "breakdownData": { "_reference": { "generalData": {
        "goalsData": { "279599": {
          "reliability": 100.0,
          "improvementRate": -43.33,
          "conversionRate": 0.0085
        } } } } } }
    }
  }
}
```

## 5. Select the winning variation

The results contain one entry per variation under `variationData`, plus the `_reference` line for the original page. For each variation, the metrics for the requested goal sit under `breakdownData._reference.generalData.goalsData[goalId]`.

The script skips the `_reference` entry, reads each variation's `improvementRate` and `reliability` (the Bayesian success probability), and selects the variation with the highest improvement rate as the winner. The *Result* column mapped in the next step records whether that variation actually won: whether it reached a high enough success probability with a positive uplift.

**Example:**

```python theme={null}
def pick_best_variation(result_data, goal_id):
    best, best_improvement = {}, float("-inf")
    for variation_id, vdata in result_data["variationData"].items():
        if variation_id == "_reference":
            continue
        general = vdata["breakdownData"]["_reference"]["generalData"]
        goals_data = general.get("goalsData", {})
        if not goals_data:
            continue
        key = str(goal_id) if str(goal_id) in goals_data else next(iter(goals_data))
        metrics = goals_data[key]
        improvement = metrics.get("improvementRate")
        if improvement is not None and improvement > best_improvement:
            best_improvement = improvement
            best = {
                "variation_id": variation_id,
                "improvement_rate": improvement,
                "bayesian_probability": metrics.get("reliability"),
            }
    return best
```

In this example, both variations reach a 100% Bayesian success probability, but variation `828220` shows a +211.48% improvement against variation `828221`'s -43.33%. Variation `828220` therefore has the higher improvement rate and, with a probability above 95% and a positive uplift, is the genuine winner.

## 6. Map the data to Confluence columns

The script transforms the experiment metadata and the winning variation's metrics into a row for the Confluence table, using the same eight columns as the Notion and Airtable exports.

| Confluence column | Source                                           | Transformation                                                                                                                          |
| ----------------- | ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- |
| Experiment Name   | `experiment.name`                                | Direct. Used as the upsert key.                                                                                                         |
| Status            | `experiment.status`                              | Mapped, case-insensitively: `active → Running`, `draft`/`planned → Implementing`, `stopped`/`diverted → Completed`, `paused → Defunct`. |
| Start date        | `experiment.dateStarted`                         | Date-time truncated to an ISO date (`YYYY-MM-DD`).                                                                                      |
| End date          | `experiment.dateEnded`                           | Date-time truncated to an ISO date.                                                                                                     |
| Notes             | `experiment.description`                         | Direct.                                                                                                                                 |
| Actual            | winning variation `improvementRate`              | Direct (measured uplift, %).                                                                                                            |
| Probability       | winning variation Bayesian success probability   | Bucketed: `≥95 → 80% - High`, `≥80 → 50% - Medium`, else `20% - Low`.                                                                   |
| Result            | Bayesian success probability + `improvementRate` | probability `≥ 95` and uplift > 0 → `Success`; `≥ 95` and uplift \< 0 → `Failure`; otherwise `Inconclusive`.                            |

**Example:**

```python theme={null}
STATUS_MAP = {
    "ACTIVE": "Running",
    "DRAFT": "Implementing",
    "PLANNED": "Implementing",
    "PAUSED": "Defunct",
    "STOPPED": "Completed",
    "DIVERTED": "Completed",
}


def map_status(status):
    return STATUS_MAP.get((status or "").upper())


def to_iso_date(value):
    return value[:10] if value else None


def bayesian_to_probability(probability):
    if probability is None:
        return None
    if probability >= 95:
        return "80% - High"
    if probability >= 80:
        return "50% - Medium"
    return "20% - Low"


def derive_result(probability, improvement):
    if probability is None or improvement is None:
        return "Inconclusive"
    if probability >= 95 and improvement > 0:
        return "Success"
    if probability >= 95 and improvement < 0:
        return "Failure"
    return "Inconclusive"


def build_row(experiment, best):
    probability = best.get("bayesian_probability")
    improvement = best.get("improvement_rate")
    fields = {
        "Experiment Name": experiment.get("name"),
        "Status": map_status(experiment.get("status")),
        "Start date": to_iso_date(experiment.get("dateStarted")),
        "End date": to_iso_date(experiment.get("dateEnded")),
        "Notes": experiment.get("description"),
        "Actual": improvement,
        "Probability": bayesian_to_probability(probability),
        "Result": derive_result(probability, improvement),
    }
    return [fields.get(column) for column in HEADER]
```

<Note>
  The Automation API doesn't publish a fixed enum for the experiment `status` field, and the tokens can evolve. `map_status` matches case-insensitively and returns `None` for an unrecognized status, which drops the *Status* cell rather than writing a wrong value. Confirm the tokens your account returns with a single `GET /experiments/{experimentId}` and extend `STATUS_MAP` if needed.
</Note>

## 7. Parse the existing Experiments table

Confluence has no database or table object of its own. Instead, the script keeps one dedicated page with a single HTML table on it, one row per experiment, and treats the *Experiment Name* column the same way Notion treats a title property or Airtable treats a merge field: as the key it upserts on. The script builds that table with a `full-width` layout rather than Confluence's narrower default, since 8 columns of moderately long headers wrap mid-word at the default page width.

Confluence's storage format (the HTML-like markup a page's body is stored as) wraps every cell's text in a `<p>` tag, and a freshly created page's header row is plain `<tr><th>...</th></tr>` cells with no surrounding `<thead>`. The parser below, built on Python's standard `html.parser.HTMLParser`, handles both that bare-header case and a `<thead>`-wrapped one, and treats an empty cell the same whether Confluence renders it as an empty `<p></p>` or a self-closing `<p />`. It also stops at the first `</table>`, so a second table elsewhere on the page (one you added by hand, for example) never merges into the parsed result, and closes any row or cell that a malformed edit left open before starting the next one, so a stray unclosed tag can't silently drop a row.

**Example:**

```python theme={null}
from html.parser import HTMLParser


class _TableParser(HTMLParser):
    def __init__(self):
        super().__init__()
        self.header = None
        self.rows = []
        self._row = None
        self._cell = None
        self._row_is_header = False
        self._in_table = False
        self._table_done = False

    def _close_cell(self):
        if self._cell is not None:
            self._row.append("".join(self._cell).strip())
            self._cell = None

    def _close_row(self):
        if self._row is not None:
            self._close_cell()
            if self.header is None and self._row_is_header:
                self.header = self._row
            else:
                self.rows.append(self._row)
            self._row = None

    def handle_starttag(self, tag, attrs):
        if self._table_done:
            return
        if tag == "table" and not self._in_table:
            self._in_table = True
        elif tag == "tr" and self._in_table:
            self._close_row()  # close a still-open row left by an unclosed <tr>
            self._row = []
            self._row_is_header = False
        elif tag in ("td", "th") and self._row is not None:
            self._close_cell()  # close a still-open cell left by an unclosed <td>/<th>
            self._cell = []
            if tag == "th":
                self._row_is_header = True

    def handle_data(self, data):
        if self._cell is not None:
            self._cell.append(data)

    def handle_endtag(self, tag):
        if self._table_done:
            return
        if tag in ("td", "th"):
            self._close_cell()
        elif tag == "tr":
            self._close_row()
        elif tag == "table":
            self._in_table = False
            self._table_done = True


def parse_experiments_table(storage_value):
    parser = _TableParser()
    parser.feed(storage_value or "")
    return parser.header or HEADER, parser.rows


def reorder_row(source_header, row):
    if source_header == HEADER:
        return row
    values = dict(zip(source_header, row))
    return [values.get(column) for column in HEADER]


def upsert_row(source_header, rows, key_column, new_row):
    rows = [reorder_row(source_header, row) for row in rows]
    key_index = HEADER.index(key_column)
    key = new_row[key_index]
    for i, row in enumerate(rows):
        if row[key_index] == key:
            rows[i] = new_row
            return rows
    rows.append(new_row)
    return rows
```

<Note>
  `reorder_row` guards against a table whose columns were manually reordered in Confluence, for example by a person dragging a column in the editor. Without it, a lookup by position would compare the new row's *Experiment Name* against whatever column now sits in that position on the existing table, silently matching the wrong row or none at all.
</Note>

## 8. Find, create, or update the Confluence page

**Find:** search for the page by title within the space using the `title` and `space-id` query parameters on the [Get pages](https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-page/#api-pages-get) endpoint. Passing `body-format=storage` returns the current table content in the same call.

```
GET https://{site}/wiki/api/v2/pages
```

**Create:** if no page matches, create one with the [Create page](https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-page/#api-pages-post) endpoint, with the table already built from a single row.

```
POST https://{site}/wiki/api/v2/pages
```

**Update:** if a page matches, rebuild the entire table from its existing rows plus the upserted one, and overwrite the page with the [Update page](https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-page/#api-pages-id-put) endpoint.

```
PUT https://{site}/wiki/api/v2/pages/{id}
```

<Note>
  Confluence has no per-row or per-field update endpoint. Updating a page replaces its entire body, so the script always reads the current table, upserts one row in memory, and writes the whole table back. That whole-body replacement differs from the Airtable export, which patches a single record, and the Notion export, which patches a single page's properties.
</Note>

<Note>
  Unlike the Notion and Airtable APIs, Confluence requires the caller to increment `version.number` on every update, to the current version number plus one. Sending the current number again, or omitting `version`, causes the request to fail. An MCP-connected AI assistant using Atlassian's own tools handles this automatically; a direct REST call, as in this script, doesn't.
</Note>

**Example:**

```python theme={null}
def find_page(base_url, auth, space_id, title):
    resp = requests.get(
        f"{base_url}/pages",
        auth=auth,
        params={"space-id": space_id, "title": title, "body-format": "storage"},
    )
    resp.raise_for_status()
    results = resp.json().get("results", [])
    return results[0] if results else None


def create_page(base_url, auth, space_id, title, storage_value):
    resp = requests.post(
        f"{base_url}/pages",
        auth=auth,
        headers={"Content-Type": "application/json"},
        json={
            "spaceId": space_id,
            "status": "current",
            "title": title,
            "body": {"representation": "storage", "value": storage_value},
        },
    )
    resp.raise_for_status()
    return resp.json()


def update_page(base_url, auth, page, storage_value):
    resp = requests.put(
        f"{base_url}/pages/{page['id']}",
        auth=auth,
        headers={"Content-Type": "application/json"},
        json={
            "id": page["id"],
            "status": "current",
            "title": page["title"],
            "version": {"number": page["version"]["number"] + 1},
            "body": {"representation": "storage", "value": storage_value},
        },
    )
    resp.raise_for_status()
    return resp.json()


def upsert_page(base_url, auth, space_id, title, new_row):
    page = find_page(base_url, auth, space_id, title)
    if page is None:
        storage_value = build_table_storage([new_row])
        return create_page(base_url, auth, space_id, title, storage_value), "Created"

    existing_header, rows = parse_experiments_table(page["body"]["storage"]["value"])
    rows = upsert_row(existing_header, rows, "Experiment Name", new_row)
    storage_value = build_table_storage(rows)
    return update_page(base_url, auth, page, storage_value), "Updated"
```

**Response (truncated):**

```json theme={null}
{
  "id": "557057",
  "status": "current",
  "title": "Experiments",
  "spaceId": "327682",
  "version": { "number": 3 },
  "body": {
    "storage": {
      "value": "<table data-layout=\"full-width\"><tbody><tr><th><p>Experiment Name</p></th>...</tr><tr><td><p>Product Page Redesign</p></td><td><p>Completed</p></td>...</tr></tbody></table>"
    }
  }
}
```

## 9. Run the script

Pass the experiment ID, the Confluence site, and the space ID as arguments:

```bash theme={null}
python kameleoon_to_confluence.py \
  --experiment-id 188308 \
  --site your-domain.atlassian.net \
  --space-id 131073
```

The script prints each step: authentication, the experiment fetched, the winning variation, the mapped row, and whether it created or updated the Confluence page. Pass `--page-title` to target a page name other than the default `Experiments`.

<Frame>
  ![Experiments page in Confluence after running the script](https://storage.googleapis.com/kameleoon-storage-documentation/user-manual/developers/images/apis/automation-api-rest/tutorials/confluence/confluence.png)
</Frame>

## Full script

The complete script below matches [kameleoon\_to\_confluence.py](/assets/developer-docs/script/apis/automation-api-rest/tutorials/kameleoon_to_confluence.py.zip) function for function. Copy it directly, or download the file from that link.

```python theme={null}
#!/usr/bin/env python3
"""Export a Kameleoon experiment's results to a Confluence page.

Given a Kameleoon experiment ID and a Confluence site, space ID, and page
title, this script:

  1. Authenticates with the Automation API (client_credentials grant).
  2. Retrieves the experiment metadata.
  3. Requests the experiment's results (with the Bayesian success probability).
  4. Polls until the report is ready.
  5. Selects the best-performing variation.
  6. Maps the data onto Confluence's Experiments table columns.
  7. Finds the Confluence page by title, or creates it if it doesn't exist.
  8. Parses the page's existing table and upserts a row, keyed on
     "Experiment Name".
  9. Writes the updated table back to Confluence.

Credentials are read from environment variables (never hard-code secrets):

    export KAMELEOON_CLIENT_ID="..."
    export KAMELEOON_CLIENT_SECRET="..."
    export CONFLUENCE_EMAIL="..."
    export CONFLUENCE_API_TOKEN="..."

Usage:

    python kameleoon_to_confluence.py \
        --experiment-id 188308 \
        --site your-domain.atlassian.net \
        --space-id 131073 \
        --page-title Experiments
"""

import argparse
import html
import os
import sys
import time
from html.parser import HTMLParser

import requests
from requests.auth import HTTPBasicAuth

# Maps the uppercase status tokens returned by the Automation API to the
# Status column values written to Confluence. Adjust the target values if
# you want different labels, and confirm the tokens your account returns
# with a single GET /experiments/{experimentId}.
STATUS_MAP = {
    "ACTIVE": "Running",
    "DRAFT": "Implementing",
    "PLANNED": "Implementing",
    "PAUSED": "Defunct",
    "STOPPED": "Completed",
    "DIVERTED": "Completed",
}

# Column order for the Experiments table. The script keys columns
# positionally against this header, both when it builds a new page and when
# it parses an existing one.
HEADER = [
    "Experiment Name",
    "Status",
    "Start date",
    "End date",
    "Notes",
    "Actual",
    "Probability",
    "Result",
]

TABLE_INTRO = (
    "<p>Kameleoon experiment results, kept current by "
    "kameleoon_to_confluence.py. Each row is upserted by matching the "
    "<strong>Experiment Name</strong> column; re-running the script for the "
    "same experiment updates its row instead of adding a duplicate.</p>"
)


# --------------------------------------------------------------------------- #
# 1. Authenticate with the Automation API
# --------------------------------------------------------------------------- #
def kameleoon_token(client_id, client_secret):
    resp = requests.post(
        "https://api.kameleoon.com/oauth/token",
        headers={"Content-Type": "application/x-www-form-urlencoded"},
        data={
            "grant_type": "client_credentials",
            "client_id": client_id,
            "client_secret": client_secret,
        },
    )
    resp.raise_for_status()
    return resp.json()["access_token"]


# --------------------------------------------------------------------------- #
# 2. Retrieve the experiment
# --------------------------------------------------------------------------- #
def get_experiment(token, experiment_id):
    resp = requests.get(
        f"https://api.kameleoon.com/experiments/{experiment_id}",
        headers={"Authorization": f"Bearer {token}"},
    )
    resp.raise_for_status()
    return resp.json()


# --------------------------------------------------------------------------- #
# 3. Request the experiment's results
# --------------------------------------------------------------------------- #
def request_results(token, experiment_id, goal_id):
    body = {
        "visitorData": False,
        "sequentialTesting": False,
        "bayesian": True,
        "referenceVariationId": "0",
        "conversionType": "ALL_CONVERSION",
        "goalsIds": [goal_id] if goal_id else None,
    }
    resp = requests.post(
        f"https://api.kameleoon.com/experiments/{experiment_id}/results",
        headers={
            "Authorization": f"Bearer {token}",
            "Content-Type": "application/json",
            "Accept": "*/*",
        },
        json=body,
    )
    resp.raise_for_status()
    return resp.json()["dataCode"]


# --------------------------------------------------------------------------- #
# 4. Poll for the results
# --------------------------------------------------------------------------- #
def poll_results(token, data_code, max_attempts=30, delay=2.0):
    for _ in range(max_attempts):
        resp = requests.get(
            "https://api.kameleoon.com/results",
            headers={"Authorization": f"Bearer {token}"},
            params={"dataCode": data_code},
        )
        resp.raise_for_status()
        payload = resp.json()
        status = payload.get("status")
        if status == "READY":
            return payload["data"]
        if status in ("ERROR", "TIMEOUT"):
            raise RuntimeError(payload.get("errorDescription") or status)
        time.sleep(delay)  # status == "WAITING"
    raise TimeoutError("Timed out waiting for results.")


# --------------------------------------------------------------------------- #
# 5. Select the best-performing variation
# --------------------------------------------------------------------------- #
def pick_best_variation(result_data, goal_id):
    best, best_improvement = {}, float("-inf")
    for variation_id, vdata in result_data["variationData"].items():
        if variation_id == "_reference":
            continue
        general = vdata["breakdownData"]["_reference"]["generalData"]
        goals_data = general.get("goalsData", {})
        if not goals_data:
            continue
        key = str(goal_id) if str(goal_id) in goals_data else next(iter(goals_data))
        metrics = goals_data[key]
        improvement = metrics.get("improvementRate")
        if improvement is not None and improvement > best_improvement:
            best_improvement = improvement
            best = {
                "variation_id": variation_id,
                "improvement_rate": improvement,
                "bayesian_probability": metrics.get("reliability"),
            }
    return best


# --------------------------------------------------------------------------- #
# 6. Map the data to Confluence columns
# --------------------------------------------------------------------------- #
def map_status(status):
    return STATUS_MAP.get((status or "").upper())


def to_iso_date(value):
    return value[:10] if value else None


def bayesian_to_probability(probability):
    if probability is None:
        return None
    if probability >= 95:
        return "80% - High"
    if probability >= 80:
        return "50% - Medium"
    return "20% - Low"


def derive_result(probability, improvement):
    if probability is None or improvement is None:
        return "Inconclusive"
    if probability >= 95 and improvement > 0:
        return "Success"
    if probability >= 95 and improvement < 0:
        return "Failure"
    return "Inconclusive"


def build_row(experiment, best):
    probability = best.get("bayesian_probability")
    improvement = best.get("improvement_rate")
    fields = {
        "Experiment Name": experiment.get("name"),
        "Status": map_status(experiment.get("status")),
        "Start date": to_iso_date(experiment.get("dateStarted")),
        "End date": to_iso_date(experiment.get("dateEnded")),
        "Notes": experiment.get("description"),
        "Actual": improvement,
        "Probability": bayesian_to_probability(probability),
        "Result": derive_result(probability, improvement),
    }
    return [fields.get(column) for column in HEADER]


# --------------------------------------------------------------------------- #
# 7. Parse the existing Experiments table
# --------------------------------------------------------------------------- #
class _TableParser(HTMLParser):
    """Extracts the header row and data rows of the first <table> in a
    Confluence storage-format document. Confluence wraps cell text in <p>
    tags and doesn't always emit <thead>; a row counts as the header the
    first time it's made of <th> cells. Only the first table is read, so a
    page with more than one table (for example, one a person added by hand)
    doesn't merge a second table's rows or header into the result."""

    def __init__(self):
        super().__init__()
        self.header = None
        self.rows = []
        self._row = None
        self._cell = None
        self._row_is_header = False
        self._in_table = False
        self._table_done = False

    def _close_cell(self):
        if self._cell is not None:
            self._row.append("".join(self._cell).strip())
            self._cell = None

    def _close_row(self):
        if self._row is not None:
            self._close_cell()
            if self.header is None and self._row_is_header:
                self.header = self._row
            else:
                self.rows.append(self._row)
            self._row = None

    def handle_starttag(self, tag, attrs):
        if self._table_done:
            return
        if tag == "table" and not self._in_table:
            self._in_table = True
        elif tag == "tr" and self._in_table:
            self._close_row()  # close a still-open row left by an unclosed <tr>
            self._row = []
            self._row_is_header = False
        elif tag in ("td", "th") and self._row is not None:
            self._close_cell()  # close a still-open cell left by an unclosed <td>/<th>
            self._cell = []
            if tag == "th":
                self._row_is_header = True

    def handle_data(self, data):
        if self._cell is not None:
            self._cell.append(data)

    def handle_endtag(self, tag):
        if self._table_done:
            return
        if tag in ("td", "th"):
            self._close_cell()
        elif tag == "tr":
            self._close_row()
        elif tag == "table":
            self._in_table = False
            self._table_done = True


def parse_experiments_table(storage_value):
    parser = _TableParser()
    parser.feed(storage_value or "")
    return parser.header or HEADER, parser.rows


def reorder_row(source_header, row):
    """Realigns a row parsed against source_header onto the canonical HEADER
    order, so a table whose columns were manually reordered in Confluence
    doesn't misalign a column-position lookup like the Experiment Name key."""
    if source_header == HEADER:
        return row
    values = dict(zip(source_header, row))
    return [values.get(column) for column in HEADER]


def upsert_row(source_header, rows, key_column, new_row):
    rows = [reorder_row(source_header, row) for row in rows]
    key_index = HEADER.index(key_column)
    key = new_row[key_index]
    for i, row in enumerate(rows):
        if row[key_index] == key:
            rows[i] = new_row
            return rows
    rows.append(new_row)
    return rows


def render_cell(value):
    text = "" if value in (None, "") else html.escape(str(value))
    return f"<p>{text}</p>"


def build_table_storage(rows):
    head_cells = "".join(f"<th>{render_cell(c)}</th>" for c in HEADER)
    body_rows = "".join(
        "<tr>" + "".join(f"<td>{render_cell(v)}</td>" for v in row) + "</tr>"
        for row in rows
    )
    return f'{TABLE_INTRO}<table data-layout="full-width"><tbody><tr>{head_cells}</tr>{body_rows}</tbody></table>'


# --------------------------------------------------------------------------- #
# 8. Find, create, or update the Confluence page
# --------------------------------------------------------------------------- #
def find_page(base_url, auth, space_id, title):
    resp = requests.get(
        f"{base_url}/pages",
        auth=auth,
        params={"space-id": space_id, "title": title, "body-format": "storage"},
    )
    resp.raise_for_status()
    results = resp.json().get("results", [])
    return results[0] if results else None


def create_page(base_url, auth, space_id, title, storage_value):
    resp = requests.post(
        f"{base_url}/pages",
        auth=auth,
        headers={"Content-Type": "application/json"},
        json={
            "spaceId": space_id,
            "status": "current",
            "title": title,
            "body": {"representation": "storage", "value": storage_value},
        },
    )
    resp.raise_for_status()
    return resp.json()


def update_page(base_url, auth, page, storage_value):
    resp = requests.put(
        f"{base_url}/pages/{page['id']}",
        auth=auth,
        headers={"Content-Type": "application/json"},
        json={
            "id": page["id"],
            "status": "current",
            "title": page["title"],
            "version": {"number": page["version"]["number"] + 1},
            "body": {"representation": "storage", "value": storage_value},
        },
    )
    resp.raise_for_status()
    return resp.json()


def upsert_page(base_url, auth, space_id, title, new_row):
    page = find_page(base_url, auth, space_id, title)
    if page is None:
        storage_value = build_table_storage([new_row])
        return create_page(base_url, auth, space_id, title, storage_value), "Created"

    existing_header, rows = parse_experiments_table(page["body"]["storage"]["value"])
    rows = upsert_row(existing_header, rows, "Experiment Name", new_row)
    storage_value = build_table_storage(rows)
    return update_page(base_url, auth, page, storage_value), "Updated"


# --------------------------------------------------------------------------- #
# 9. Orchestration
# --------------------------------------------------------------------------- #
def run(experiment_id, site, space_id, page_title):
    client_id = os.environ.get("KAMELEOON_CLIENT_ID")
    client_secret = os.environ.get("KAMELEOON_CLIENT_SECRET")
    confluence_email = os.environ.get("CONFLUENCE_EMAIL")
    confluence_token = os.environ.get("CONFLUENCE_API_TOKEN")

    missing = [
        name
        for name, value in (
            ("KAMELEOON_CLIENT_ID", client_id),
            ("KAMELEOON_CLIENT_SECRET", client_secret),
            ("CONFLUENCE_EMAIL", confluence_email),
            ("CONFLUENCE_API_TOKEN", confluence_token),
        )
        if not value
    ]
    if missing:
        raise SystemExit(
            "Missing required environment variable(s): " + ", ".join(missing)
        )

    # 1. Authenticate.
    token = kameleoon_token(client_id, client_secret)
    print("Authenticated with the Automation API.")

    # 2. Retrieve the experiment.
    experiment = get_experiment(token, experiment_id)
    goal_id = experiment.get("mainGoalId")
    print(
        f"Fetched experiment {experiment.get('id')}: "
        f"{experiment.get('name')!r} (status: {experiment.get('status')})."
    )

    # 3-4. Request and poll for the results.
    data_code = request_results(token, experiment_id, goal_id)
    result_data = poll_results(token, data_code)

    # 5. Select the best-performing variation.
    best = pick_best_variation(result_data, goal_id)
    if best:
        print(
            f"Best-performing variation: {best['variation_id']} "
            f"(uplift {best['improvement_rate']}%, "
            f"Bayesian probability {best.get('bayesian_probability')}%)."
        )
    else:
        print("No variation data available for the requested goal.")

    # 6. Map the data to a table row.
    row = build_row(experiment, best)
    print("Mapped row:")
    for column, value in zip(HEADER, row):
        print(f"  {column}: {value}")

    # 7-8. Find, create, or update the Confluence page.
    base_url = f"https://{site}/wiki/api/v2"
    auth = HTTPBasicAuth(confluence_email, confluence_token)
    page, action = upsert_page(base_url, auth, space_id, page_title, row)
    print(f"{action} Confluence page {page['id']} ({page_title!r}).")

    return page


def main():
    parser = argparse.ArgumentParser(
        description="Export a Kameleoon experiment's results to Confluence."
    )
    parser.add_argument(
        "--experiment-id",
        required=True,
        help="ID of the Kameleoon experiment to export.",
    )
    parser.add_argument(
        "--site",
        required=True,
        help="Confluence site hostname (for example, your-domain.atlassian.net).",
    )
    parser.add_argument(
        "--space-id",
        required=True,
        help="Numeric Confluence space ID that hosts the Experiments page.",
    )
    parser.add_argument(
        "--page-title",
        default="Experiments",
        help="Title of the page to create or update (default: Experiments).",
    )
    args = parser.parse_args()

    try:
        run(args.experiment_id, args.site, args.space_id, args.page_title)
    except requests.HTTPError as exc:
        detail = ""
        if exc.response is not None:
            detail = f" — {exc.response.status_code}: {exc.response.text}"
        print(f"HTTP error: {exc}{detail}", file=sys.stderr)
        sys.exit(1)
    except (RuntimeError, TimeoutError) as exc:
        print(f"Error: {exc}", file=sys.stderr)
        sys.exit(1)


if __name__ == "__main__":
    main()
```

## Customization notes

* **Status mapping** lives in the `STATUS_MAP` constant, keyed on the uppercase status tokens the API returns. Adjust the target values if your *Status* labels differ from `Running` / `Implementing` / `Completed` / `Defunct`, and confirm the tokens your account returns with a live `GET /experiments/{experimentId}` request before you rely on the mapping.
* **Probability** comes from the measured Bayesian success probability, which requires `bayesian: true` on the results request. If you want a pre-experiment estimate instead of a measured one, remove the `Probability` line from `build_row`.
* **Goal selection** uses the experiment's `mainGoalId`. To report on a different goal, pass its ID to `request_results` and `pick_best_variation`.
* **Upsert key.** The match on *Experiment Name* is exact, so differences in case or whitespace create a new row instead of updating the existing one. Because the find-then-write flow isn't atomic, avoid running two exports for the same experiment concurrently, and avoid renaming an experiment between runs unless you also want a new row for it.
* **Whole-page updates.** Every update rewrites the entire table, since Confluence has no per-row write. If you maintain the page by hand between script runs, keep your edits inside the table the script builds. Content outside that table isn't currently preserved.
* **Version conflicts.** The script always reads the page's current `version.number` immediately before writing, so a manual edit made between the read and the write causes the next `PUT` to fail with a version conflict. Re-run the script if that happens.
* **Rate limits.** The Automation API allows up to 50 requests per 10 seconds and 1,000 per hour, but Kameleoon recommends staying under 12 calls per minute per account. Confluence Cloud enforces its own rate limits, which vary by plan; see [Atlassian's rate limiting documentation](https://developer.atlassian.com/cloud/confluence/rate-limiting/) for current values. If you batch many experiments, cache tokens, throttle requests, and consider the [Data API](/developer-docs/apis/data-api-rest/overview) for high-volume needs.
