# Custom Content Management

Operators can edit game content (JSON files) directly from the portal, validate it against what's deployed, package it into a downloadable archive, and open a pull request against the source repository — all without touching the filesystem of a running shard or invoking `./pragma content apply`.


There are three logical layers:

1. **`ContentDataNodeService`**: the engine's content layer. With `enableDynamicContent` turned on, it pulls overrides from the configured `DynamicContentPlugin` in addition to the on-disk `content/src` and `content/package` files.
2. **`DatabaseDynamicContentPlugin`**: backs the dynamic content with a MySQL table. Game and social shards point at the same database via `ContentDataDaoConfig`, so an edit made through one is visible to both.
3. **Portal Content Editor + `CustomContentGithubService`**: the operator-facing surface. The editor reads on-disk src, writes overrides to the database, builds archives, and (via the GitHub service) opens a PR.

## Portal workflow

The Content Editor portlet (in operator-game) exposes four user-facing actions:

| Button                | What it does |
|-----------------------|-------------|
| **Stage** (Update)    | Writes the edited JSON to the machine's src dir. The change is made on a single machine's src dir. |
| **Publish**           | Writes to the DB using the `DatabaseDynamicContentPlugin`. |
| **Download Archive**  | Calls `DownloadContentArchiveOperatorV1`, which assembles a zip containing both `content/src` and `content/package` with current overrides applied. Use this when you want to commit by hand after running publish. The button will be disabled approx 15 seconds after running publish to ensure all nodes applied the changes. |
| **Publish to GitHub** | Calls `CommitContentToGithubOperatorV1` on `CustomContentGithubService`. The service uses the GitHub Git Data API to construct an atomic commit containing all current content files (from both src and pkg), pushes it to a new branch, and opens a PR. |

Behind the scenes the editor also calls `CheckContentDisparityOperatorV1` to highlight files where the database override differs from what's on disk. The check is **structural JSON comparison** (Jackson `JsonNode` equality after parsing) meaning that whitespace, key ordering, and pretty-printing differences are ignored; array element order and value differences are detected.

## Engine configuration (per shard)

```yaml
game:
  core:
  serviceConfigs:
    ContentDataDaoConfig:
      databaseConfig:
        identifierSchema:
          identifier: "defaultIdentifier"
          schema: "custom_content" # <-- This schema needs to be the same on both game and social
    ContentDataNodeServiceConfig:
      enableDynamicContent: true
    CustomContentGithubServiceConfig:
      repositoryUrl: "https://github.com/pragmaplatform/axolotl-pragma-engine"
      githubTokenSecret: "UNCONFIGURED"
      localDevFallbackBranch: "deploy/simpletitle-clean"
      contentRepoPath: "platform/simplegame/content"
  pluginConfigs:
    DynamicContentService.dynamicContentPlugin:
      class: "pragma.content.DatabaseDynamicContentPlugin"

social:
  core:
  serviceConfigs:
    ContentDataDaoConfig:
      databaseConfig:
        identifierSchema:
          identifier: "defaultIdentifier"
          schema: "custom_content" # <-- This schema needs to be the same on both game and social
    ContentDataNodeServiceConfig:
      enableDynamicContent: true
  pluginConfigs:
    DynamicContentService.dynamicContentPlugin:
      class: "pragma.content.DatabaseDynamicContentPlugin"
```

## `CustomContentGithubService` configuration

```yaml
game:
  serviceConfigs:
    CustomContentGithubServiceConfig:
      repositoryUrl: "https://github.com/pragmaplatform/axolotl-pragma-engine"
      contentRepoPath: "pragma-engine/platform/<gameModule-or-5-ext>/content"
      branchName: ""                       # optional, see "Branch resolution"
      localDevFallbackBranch: "main"       # fallback when running off a non-deployed build (eg: locally)
      githubTokenSecret: "<encrypted PAT>" # see "GitHub token"
```

Field-by-field:

- **`repositoryUrl`** — `https://github.com/owner/repo` or `git@github.com:owner/repo` (with or without `.git`). We use Github's REST API.
- **`contentRepoPath`** — required. Path **inside the repository** where content lives. Files are committed under `<contentRepoPath>/src/...` and `<contentRepoPath>/package/...`.
- **`branchName`** — *optional* override for the PR's base branch. Leave blank (or `"UNCONFIGURED"`) to derive automatically.
- **`localDevFallbackBranch`** — used as the base branch when running the service locally.
- **`githubTokenSecret`** — encrypted GitHub Personal Access Token. Scopes detailed below.

## Branch and base-SHA resolution

When you click *Commit to GitHub*, the service has to answer two questions: **which branch should the PR target?** and **which commit 
should the new content commit be parented to?**

**Branch** (target of the PR), in priority order:

1. `config.branchName` if explicitly set (not blank, not `"UNCONFIGURED"`)
2. Branch parsed from the deployed Content artifact version string (format `<timestamp>_<pragmaVersion>_<branch>_<gitsha>`) — gives you 
   "PRs against whatever branch and gitsha the content artifact was deployed to this shard"
3. `config.localDevFallbackBranch`: you usually get here when running pragma locally

If none of the above resolves the service fails fast with `Cannot determine base branch`.

**Base SHA** (parent of the new commit):

- If the artifact version contains a parseable SHA, we'll use that exact SHA. This pins the commit to whatever was actually deployed, so an in-flight content edit can't silently revert commits that landed on the branch since the artifact was built.
- Otherwise (local-dev, unparseable version): we'll use the tip of the resolved branch.

Once the commit lands, the service creates an ephemeral branch named `pragma-content-<yyyyMMdd-HHmmss>` from that commit and opens a PR 
with `head = pragma-content-…`, `base = <resolved branch>`. The branch lives on the remote until you merge or delete it; the PR title 
and body come from the operator portal.

## GitHub token

The token is a **fine-grained personal access token** scoped to the target repository.

**Repository access:** Only select repositories: the repo in `repositoryUrl`.

**Repository permissions:**

| Permission | Level | Used for |
|---|---|---|
| Contents | Read and write | `GET /git/commits`, `GET /git/refs`, `POST /git/blobs`, `POST /git/trees`, `POST /git/commits`, `POST /git/refs` |
| Pull requests | Read and write | `POST /pulls` |
| Metadata | Read-only | Mandatory; GitHub auto-selects when any repo permission is granted |

Caveats:

- If the repo lives in an org, an org owner must enable fine-grained tokens for that org and may have to approve the token before it works.
- If the target base branch has *Require signed commits* branch protection, the PR will open but won't be mergeable — Git Data API commits are not signed. The push to the new `pragma-content-*` branch itself is fine because that branch isn't protected.

## Troubleshooting

- **422 `field: "base" code: "invalid"`** — the resolved base branch doesn't exist on the remote. Either `branchName` points at a non-existent branch, or the artifact-extracted branch / `localDevFallbackBranch` was never pushed.
- **404 on `GET /git/commits/<sha>`** — the artifact-reported SHA isn't reachable on the remote (orphan local commit, force-pushed away, wrong repo). Either push the commit, fix `repositoryUrl`, or rebuild the Content artifact.
- **404 on `GET /git/refs/heads/<branch>`** — same as above but for the resolved branch when no artifact SHA is available.
- **403** on any GitHub call — token lacks Contents or Pull requests write, or the org hasn't approved the fine-grained PAT.
- **"No content files found"** — the running jar's `--content-data-directory` isn't pointing where you think it is. The service walks `<contentDataDirectory>/{src,package}`; both subdirs missing is logged as `Content subdir 'X' not found at …`.
- **Orphan `pragma-content-<timestamp>` branches on the remote** — created when commit succeeds but PR creation fails. Delete via `git push origin --delete pragma-content-<timestamp>` or in the GitHub UI.
