Skip to content

Proposal: Pull request workflow for repositories #1352

Description

@ikhoon

Proposal: Pull request workflow for repositories

Motivation

Central Dogma applies every change immediately: a push lands on the repository HEAD and watching clients
see it right away. This is a poor fit for several operational workflows:

  • Operators often prepare a configuration change ahead of time and want to apply it at a chosen moment
    (e.g. during a maintenance window), not at the moment of writing it.
  • There is no built-in way to have a change reviewed before it takes effect, even though a mistake in a
    configuration repository propagates to clients instantly.
  • Users without write permission cannot propose a change; they have to hand the content to someone with
    write access out of band.

This proposal adds a GitHub-style pull request workflow: stage a set of changes against a repository as a
pull request, review and approve it, then merge it with one click (or one API call) at the time of your
choosing.

Goals

  • Stage a proposed change set (one or more files) against a repository without touching its HEAD.
  • Review workflow: approve / request changes. Merging requires at least one approval.
  • One-click merge that applies the staged changes as a single ordinary commit.
  • Detect when a pull request is outdated — a file it touches changed since the pull request was created —
    and block merging until the author updates it.
  • Full REST API and web UI. Identical behavior in standalone and replicated deployments.

Non-goals (v1)

  • Scheduled or automatic merge at a specified time. The pull request workflow is the prerequisite for it;
    see Future work.
  • Git branches. Central Dogma repositories are single-branch by design; a pull request here is a stored
    change set, not a source branch.
  • Comment threads or line comments. A v1 review carries a state and an optional comment.

System design

Architecture

A pull request is a JSON document describing a proposed change set. It lives outside the target repository
until merged; merging turns it into an ordinary commit on the target repository.

flowchart TD
    C["Web UI / clients"] -->|REST| A["Pull request API"]
    A --> E["Command executor"]
    E -.->|"replicated log"| R["Other replicas"]
    E -->|"create, review, close"| S[("Pull request store<br/>RocksDB, per replica")]
    E -->|"merge commit"| G[("Target repository<br/>Git")]
Loading

Every mutation — creating a pull request, reviewing it, merging it — is expressed as a command and executed
through the command executor, the same write path as ordinary pushes. In a replicated deployment the
command log is the only channel between replicas, so routing pull request state through it is what makes
the feature work identically on one replica or many.

Storage

Pull request documents are not stored in Git. They are staging data and inherently ephemeral: once a
pull request is merged, its content lives in the target repository, and the staged copy is dead weight.
Central Dogma's Git storage keeps every revision forever and cannot reclaim space, so storing pull requests
there would grow the data directory without bound over the lifetime of a deployment.

Instead, each replica keeps pull requests in a local RocksDB store under its data directory. RocksDB is
already embedded in the server (it backs the encryption-at-rest storage), and the replication pattern is
also established: like login sessions and encryption keys, the store itself is per-replica local state
that stays consistent because all mutations arrive as replicated commands and every replica applies
the same command log in the same order.

Layout:

{project}/{repo}/counter        # next pull request number, per target repository
{project}/{repo}/{number}       # one JSON document per pull request

Properties by construction:

  • Replication and durability — new command types (create / update / review / merge-finalize / close)
    carry the full payload through the command log; every replica converges on the same store content.
  • Space is reclaimable — deleting a pull request actually frees storage, unlike a Git-backed store.
    This makes a retention policy (e.g. expire merged and closed pull requests after a configurable period)
    possible; see Future work.
  • Sequential numbering — the number is allocated from the per-repository counter inside the execution
    of the create command. Commands are totally ordered, so every replica allocates the same number.
  • Atomicity — state transitions are conditional updates validated inside command execution (e.g. a
    review command is rejected unless the document is still OPEN); total ordering makes the validation
    race-free.
  • Auditability trade-off — unlike Git storage, the store keeps no independent history. Each document
    therefore records its lifecycle facts (creation, reviews, merge, close — each with user and timestamp),
    which serve as the audit record and are removed together with the document.

The store is exposed behind a small repository interface following the existing CrudRepository pattern,
extended with an atomic conditional-update operation for state transitions (the existing update() is a
non-atomic read-then-write), so the storage backend stays swappable.

Replication considerations

The new commands follow the contract the existing commands already obey:

  • A command carries all of its inputs — author, timestamps, content. Execution derives everything from the
    command plus the current store state and never reads the wall clock, so replay on another replica is
    deterministic.
  • Re-applying an already-applied command converges to a no-op with the same result: document writes are
    idempotent puts, and a transition that finds its target state already reached succeeds idempotently.
  • If a replica's replay result diverges from the recorded result, it fences itself into read-only mode —
    the same protection the replication layer applies to every existing command.
  • Replicas are provisioned from a data-directory snapshot, as today; the pull request store lives inside
    the data directory and rides along.

Pull request lifecycle

flowchart LR
    N(( )) -->|create| OPEN
    OPEN -->|"review / update"| OPEN
    OPEN -->|merge| MERGED
    OPEN -->|close| CLOSED
    CLOSED -->|reopen| OPEN
Loading
  • Creating a pull request requires only READ permission on the target repository: letting users without
    write permission propose changes is one of the main points of the feature. Merging and reviewing require
    WRITE; the full mapping is in the API table below.
  • Updating the changes of a pull request resets its reviews and re-bases it on the current HEAD;
    updating only the title or description keeps the reviews.
  • A review is APPROVED or CHANGES_REQUESTED, one entry per reviewer (a new review by the same reviewer
    replaces the previous one). Authors cannot review their own pull requests.
  • MERGED is terminal. CLOSED pull requests can be reopened.

Data model

{
  "number": 42,
  "title": "Increase connection timeout of backend-a",
  "description": "To be applied during the maintenance window.",
  "author": { "name": "alice", "email": "alice@example.com" },
  "baseRevision": 1234,
  "changes": [
    { "path": "/backend-a/config.json", "type": "UPSERT_JSON", "content": { "timeoutMillis": 10000 } }
  ],
  "status": "OPEN",
  "reviews": [
    { "reviewer": "bob@example.com", "state": "APPROVED", "comment": "LGTM",
      "timestamp": "2026-08-06T12:00:00Z" }
  ],
  "creation": { "user": "alice@example.com", "timestamp": "2026-08-05T09:00:00Z" }
}
  • changes reuses the existing Change JSON format — the same shape the push API accepts.
  • status is OPEN, MERGED or CLOSED. A merged document additionally records mergedRevision and
    the merger; a closed document records who closed it and when.
  • v1 restricts change types to UPSERT_JSON, UPSERT_TEXT and REMOVE. APPLY_JSON_PATCH,
    APPLY_TEXT_PATCH and RENAME are rejected with 400 for now: upserts render trivially in a diff UI and
    merge deterministically. The restriction can be lifted later.

REST API

Method Path Required permission
POST /api/v1/projects/{p}/repos/{r}/pulls READ
GET /api/v1/projects/{p}/repos/{r}/pulls?status= READ
GET /api/v1/projects/{p}/repos/{r}/pulls/{n} READ
PUT /api/v1/projects/{p}/repos/{r}/pulls/{n} author (or ADMIN)
POST /api/v1/projects/{p}/repos/{r}/pulls/{n}/reviews WRITE, not the author
POST /api/v1/projects/{p}/repos/{r}/pulls/{n}/merge WRITE
POST /api/v1/projects/{p}/repos/{r}/pulls/{n}/close author or WRITE
POST /api/v1/projects/{p}/repos/{r}/pulls/{n}/reopen author or WRITE
  • The path parameters are the target repository, so the existing permission model applies unchanged. The
    pull request store is internal and never exposed directly. Pull requests cannot target internal
    repositories.
  • GET .../pulls/{n} additionally reports outdated and the current headRevision of the target
    repository.
  • Errors: 404 for an unknown pull request; 409 for a wrong status, a missing approval, an active change
    request, an outdated pull request or conflicting content; 403 for permission failures.

Merge semantics

Merging pull request #N applies its stored changes to the target repository HEAD:

  1. Verify the status is OPEN, at least one APPROVED review exists and no CHANGES_REQUESTED review is
    active; otherwise 409.
  2. Preview the stored changes against HEAD. An empty diff means the content is already applied — for
    example, a previous merge crashed between pushing and finalizing — so skip to step 5.
  3. Outdated check: if any path touched by the pull request changed between baseRevision and HEAD,
    respond 409; the author must update the pull request first.
  4. Push the stored changes as a single commit authored by the merger, with the summary
    Merge pull request #N: <title>; the commit detail records the original author.
  5. Finalize: transition the document to MERGED, recording mergedRevision, the merger and the time.
    Merging an already-merged pull request is idempotent.

Conflict handling

Because step 3 compares content rather than timestamps, the workflow is honest about drift: a pull request
merges only while the files it touches are exactly as they were at its base revision — otherwise the author
re-bases explicitly. There is no implicit three-way merge. Concretely:

  • Pull request vs. direct push — a direct push that touches a pull request's path makes it outdated:
    the detail API and UI flag it, and merging returns 409 until the author updates the pull request
    (which re-bases it and resets reviews).
  • Pull request vs. pull request — two open pull requests touching the same path are both valid until
    one merges; the merge makes the other outdated. First merge wins, the second author re-bases.
  • Unrelated changes never block — the check covers only the paths the pull request touches, so
    ordinary traffic to other files does not invalidate it.
  • Identical content — if the target files already contain exactly the proposed content (a manual push
    of the same change, or a retry after a crash between push and finalize), the merge succeeds by
    finalizing only (step 2).
  • Concurrent merges of the same pull request — commands are totally ordered, so one merge wins and the
    other observes MERGED and returns idempotently.
  • Close racing a merge — if a close lands after the merge commit was pushed but before finalization,
    the pull request still ends MERGED: the applied commit is the source of truth.
  • Residual push conflicts — a conflict surfacing at push time despite the outdated gate (e.g. a path
    type clash) returns 409 with the cause, and the pull request stays OPEN.

Web UI

  • A Pull Requests button on the repository page, next to History, leading to a list page with
    Open / Merged / Closed filters.
  • A detail page showing the diff (rendered with the existing diff viewer), the description, review states
    and an outdated badge, with Approve / Request changes / Merge / Close buttons gated by
    repository role.
  • The file editor and the new-file screen gain a Commit directly / Propose as pull request choice.
    Users with only READ permission get the pull request option alone — today their commit attempt would
    simply fail with 403. Multi-file pull requests are API-only in v1.

Future work

  • Scheduled merge: merge automatically at a specified time — the original operational motivation — via a
    leader-only scheduler, following the mirror scheduler pattern.
  • Retention policy: expire merged and closed pull requests after a configurable period. Deletion
    actually reclaims space in this design.
  • Support for APPLY_JSON_PATCH / APPLY_TEXT_PATCH / RENAME changes.
  • Multi-file pull request creation in the web UI.
  • Notification integration (e.g. webhooks) for pull request events.
  • Per-repository merge policy, e.g. a configurable required-approval count.

Open questions

  • Should the approval requirement be configurable per repository from the start? v1 proposes a fixed rule:
    at least one approval and no active change request.
  • Should merged and closed pull requests be kept indefinitely by default, or expire after a default period?
  • Is pulls the right API path segment, or should it be spelled out as pull-requests?

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions