← shitpost

Draft · subject to change

Portable review metadata on git-meta

Status: draft v0. Breaking changes are expected. Namespace: shitposter, provisional; it will change before v1. Builds on: git-meta specification v0.1.

This document specifies how code review data (reviews, review decisions, discussion threads and comments) is stored as git-meta metadata, so that any tool can write and read it and the data travels with ordinary Git transport.

It is an application schema defined by this project. It is not part of the git-meta specification and uses no git-meta extension. Where this document says “git-meta”, it refers to behavior git-meta itself defines; everything else is a convention of this specification. The reference implementation is the review-meta-schema crate of the shitpost tool.

The key words “must”, “must not”, “should” and “may” are to be read as in RFC 2119.

1. Overview

All data lives on the git-meta project target. A review and everything belonging to it is stored under one key prefix:

shitposter:schema-version
shitposter:review:<review-id>:…
    …:decision:<decision-id>:…
    …:thread:<thread-id>:…
    …:comment:<comment-id>:…

Every field is a separate git-meta key, so independent fields merge independently. All values are git-meta strings, except each thread’s ordered comment list, which is a git-meta list.

Serialized by git-meta, this becomes ordinary tree paths, for example:

project/shitposter/schema-version/__value
project/shitposter/review/<review-id>/title/__value
project/shitposter/review/<review-id>/thread/<thread-id>/comments/__list/<timestamp>-<hash>

2. Versioning

Writers must set shitposter:schema-version to the version of this document they implement (0 for this draft). Readers must refuse data whose version they do not support, rather than guess, even if no reviews are present. Writers must not write into a store whose marker names a different version: doing so would relabel that data without migrating it. A store without a marker must not contain reviews.

3. Identifiers

Every review, decision, thread and comment has a portable ID: a UUID, written in canonical lowercase hyphenated form in keys and values. Readers must reject any other form (uppercase, braces, no hyphens, URN), because IDs are part of keys and an alias would address the same object under a second key.

3.1 Imported objects

An object imported from a provider (forge) for the first time gets a UUIDv5:

uuid5(NAMESPACE, "<provider-host>/<kind>/<provider-object-id>")

NAMESPACE = d4d12fe2-0c4d-54a9-851f-455f71cec4bf
          = uuid5(NAMESPACE_URL, "https://shit-poster.dev/ns/review-model")
Object <kind>
review review
decision review-decision
thread review-thread
comment review-comment

Example: the GitHub thread PRRT_kwDOAAAB on github.com has ID b784df81-3649-5fcc-b841-4dc06f6d1944.

3.2 IDs are not re-derived

An ID is derived once, on first import from the forge where the object was created, and then stored. When importing, a writer must first look for an existing object mapped to the provider object and reuse its ID; it may derive a new ID only if none exists. Otherwise an object created locally, or first seen on another forge, would be duplicated when it comes back with a new provider ID.

3.3 Locally created objects

Objects created outside a provider, for example a comment written with a local tool, should get random UUIDv4 IDs. They have no provider reference until they are published to a provider, and their authors may have no provider account (§5.1). v0 tools do not create such objects yet, but readers must accept them.

4. Value encoding

Kind Encoding
timestamp RFC 3339 in UTC, e.g. 2026-10-07T16:36:44Z
commit full object ID in lowercase hex: 40 digits (SHA-1) or 64 (SHA-256); never abbreviated
boolean true or false
integer decimal digits
line range <side>:<line>-<side>:<line>, sides old or new, lines 1-based and inclusive, e.g. old:5-new:8
text the text itself; may be empty

Enumerations are listed with their fields below and are written exactly as shown.

Each value has exactly one valid encoding. Readers must reject anything else, such as a timestamp with a non-UTC offset, an integer with a sign or leading zeros, an abbreviated commit, or an uppercase UUID.

5. Keys

In the tables, <P> is the key prefix of the object being described. A field marked optional is absent when the value does not exist (see §6.2).

5.1 Shared groups

Provider reference, where the object came from (provenance, never identity). The group is optional as a whole: it is absent for an object created locally and not yet published to a provider. When present, provider:host and provider:id are both required.

Key Value
<P>:provider:host provider deployment, e.g. github.com
<P>:provider:id the provider’s own object ID
<P>:provider:url web URL; optional

Author. author:name and author:email are always required. The three author:provider-* keys describe the author’s provider account; they are optional as a group: all three are present, or none is, for example for someone writing a review locally without a provider account.

Key Value
<P>:author:name display name
<P>:author:email email address only, e.g. alice@example.com; with author:name it forms the git-style identity (§7)
<P>:author:provider-host provider deployment of the account
<P>:author:provider-user-id stable provider account ID
<P>:author:provider-login provider login at import time

5.2 Review

<P> = shitposter:review:<review-id>. A review is a proposed change under review, such as a GitHub pull request or GitLab merge request.

Key Value
provider group
author group who opened the review
<P>:number integer shown to users (#123)
<P>:title text
<P>:description text
<P>:state open, closed or merged
<P>:created-at timestamp
<P>:edited-at timestamp; optional, present only if title or description was edited
<P>:head:sha, <P>:head:ref head commit SHA and branch name at import
<P>:base:sha, <P>:base:ref base commit SHA and branch name at import

5.3 Decision

<P> = shitposter:review:<review-id>:decision:<decision-id>. A reviewer’s verdict on one exact commit. It must never be applied to a different (e.g. rewritten) commit.

Key Value
provider group, author group
<P>:verdict approved, changes-requested or commented
<P>:body text submitted with the verdict
<P>:commit full SHA the verdict applies to
<P>:submitted-at timestamp
<P>:withdrawn-at timestamp; optional, present once the verdict no longer counts (e.g. dismissed, revoked, reset on push)
<P>:withdrawn-message text; optional, reason for the withdrawal

A withdrawn decision keeps its original verdict. Withdrawal records an explicit event; a verdict that is merely superseded by the same reviewer’s later verdict is not withdrawn (see §8).

5.4 Thread

<P> = shitposter:review:<review-id>:thread:<thread-id>. A discussion attached to a location in the change.

Key Value
provider group
<P>:resolved boolean
<P>:path file path in the repository
<P>:original:commit full SHA the thread was written against
<P>:original:lines line range; optional, absent for a comment on the whole file
<P>:original:hunk the unified-diff hunk of the original placement (§5.4.1); optional, absent for a comment on the whole file
<P>:current:commit full SHA the thread is currently placed on; optional
<P>:current:lines line range on that commit; optional
<P>:comments git-meta list of comment IDs in conversation order (§6.3)

The original placement is preserved exactly as reported. The current placement is where the provider (or, later, anchor reconciliation) places the thread on the review head. If current:commit is absent, the code the thread refers to no longer exists on the head; providers call this “outdated”. It is a property of the placement, not separate state.

5.4.1 Original hunk

original:hunk holds the diff the comment was written on, as unified-diff text: an @@ -<old>[,<count>] +<new>[,<count>] @@ header followed by the hunk’s lines, each prefixed with (unchanged), - (removed) or + (added), up to and including the last line of original:lines.

The old and new line numbers of original:lines refer to the two sides of this hunk. This keeps the placement interpretable without any commit: in particular, an old line number cannot be resolved from original:commit alone, because the commit on the old side of the diff is not recorded (GitHub does not expose it).

Writers store the hunk as the provider reported it. Readers must reject a hunk that does not parse, that is present without original:lines, or whose last line is not the end of original:lines (on its side).

5.5 Comment

<P> = shitposter:review:<review-id>:comment:<comment-id>. Comments are stored per review, not per thread; a thread refers to its comments through its comments list.

Key Value
provider group, author group
<P>:body text, current version
<P>:created-at timestamp
<P>:edited-at timestamp; optional, present only if the body was edited

Edit history is not preserved; only the current body and the time of the last edit are.

6. Writing

6.1 Whole-object writes

A writer importing an object must write all of its fields, not only the ones it believes changed. Writing the same data again must not change any value, so repeated imports produce no new git-meta commits.

git-meta resolves concurrent string writes in favor of the last pusher, so a writer that imported an older provider state can overwrite a newer import when it pushes. Writers that import from a provider and push to a shared metadata remote should therefore pull before importing, and, when a push is rejected because the remote moved on, import the provider’s current state again after merging and before pushing again.

This protects only the reviews being imported. A push also publishes every other review the writer changed locally and has not pushed, and those local values win over newer ones on the remote in the same way. Writers should therefore not push while another review that the remote already has differs locally, unless they import that review from the provider again as well.

6.2 Absent fields

A writer must remove (using git-meta removal, which records a tombstone) any optional field whose value does not exist, for example current:commit and current:lines once a thread becomes outdated. git-meta does not treat missing paths as deletion, so leaving the old value would keep it visible.

6.3 Comment lists

A thread’s comments list must be written as a whole, replacing the previous list, never by appending. git-meta list entries are ordered by an entry ID derived from a millisecond timestamp and a hash of the value. Each entry’s timestamp must be the comment’s creation time in milliseconds since the Unix epoch, raised where necessary to be strictly greater than the previous entry’s, so that a reply created in the same millisecond as its parent keeps its place. Appending would stamp entries with the writer’s clock and duplicate them on every re-import.

A list must not contain the same comment ID twice, and comments created before 1970-01-01T00:00:00Z cannot be written: git-meta reads an entry ID up to its first -, so negative timestamps are not representable. Readers must reject lists with duplicate IDs.

6.4 Unpublished content

Writers must import only content that other participants can see on the provider. In particular, unsubmitted draft comments or reviews, which a provider may show to their own author, must never be written.

6.5 Removed objects

Removing a review, decision, thread or comment is not specified in v0. Writers must not remove objects that no longer appear in provider data.

7. Identities

author:name and author:email form a git-style identity (Name <email>) intended for display. If the provider exposes no email, writers should use the provider’s no-reply form when one exists; on GitHub that is <user-id>+<login>@users.noreply.github.com (<user-id>+<login>[bot]@… for bots), which is also what GitHub writes into commits for such users.

An identity is not proof of who someone is. Readers must not conclude that two accounts on different providers belong to the same person because names or emails match; such links require an explicit mapping, which v0 does not define.

An author without a provider account, for example someone writing a review locally, is described by author:name and author:email alone (§5.1). That identity is self-declared and unverified (§9).

The git-meta authorship recorded for each key (who wrote the metadata) is a different thing from author:* (who wrote the comment). For imports, the former is the importer.

8. Reading

Readers validate each review on its own. A review whose data violates this specification is rejected and should be reported, but it must not prevent other reviews from being read; only problems concerning the whole store, such as an unsupported schema version (§2), apply to every review.

Readers should present:

A reviewer’s effective verdict is their latest decision, by submitted-at, with verdict approved or changes-requested; commented decisions do not change it. If that decision is withdrawn, the reviewer has no effective verdict: a withdrawal does not revive an earlier verdict the reviewer had already superseded. For example, a change request followed by an approval from the same reviewer leaves the approval effective; if the approval is later dismissed, neither counts. Both decisions remain part of the history. Reviewers are identified by provider account (author:provider-host and author:provider-user-id). A decision without a provider account identifies its reviewer by the exact git-style identity (author:name and author:email) instead. Such an identity is self-declared, so readers should mark these verdicts as unverified, and must never treat a reviewer with an account and one without as the same person, even if names or emails match. An effective verdict still applies only to its commit; when that is not the review’s current head, readers should say so rather than present the verdict as covering the head.

9. Open questions