michal/tit

Browse tree · Show commit · Download archive

Diff

0de923f9b00766c575b0a117

README.md

Mode 100644100644; object 1e9f5f1ca5c9db597178f0bf

@@ -376,3 +376,17 @@
 account access, invalid input, raw Markdown storage, and atomic issue events.
 Read the [SSH issue command architectural decision record](docs/adr/0021-ssh-issue-commands.md)
 for the input, output, authorization, and error contracts.
+
+## Milestone 5.1 gate
+
+Install stock Git. Then, run the pull-request ref gate:
+
+```text
+./scripts/check-m5-1
+```
+
+This command tests increasing pull-request numbers, SHA-1 and SHA-256 refs,
+immutable revision object IDs, concurrent opens, intent recovery before and
+after a ref change, the Web forms, and historical schema migration. Read the
+[pull-request ref architectural decision record](docs/adr/0022-pull-request-refs.md)
+for the record, ref, permission, and recovery contracts.

docs/adr/0022-pull-request-refs.md

Mode 100644; object 1924f911aa3e

@@ -1,0 +1,84 @@
+# Architectural decision record 0022: Pull-request refs
+
+Status: Accepted
+
+Date: 2026-07-23
+
+## Context
+
+A pull request needs a stable repository number and a Git ref that a standard
+Git client can fetch. Each revision must keep its original base and head object
+IDs. Git refs and SQLite records cannot use one transaction. A process stop
+must not leave the current pull-request ref and its metadata with different
+heads.
+
+## Decision
+
+Store each pull request in SQLite with a random permanent ID and an increasing
+repository number. Store its title, Markdown body, author, state, base branch,
+head branch, current base object ID, and current head object ID. Store each
+revision in an append-only record. A revision contains its own random ID,
+revision number, author, base object ID, head object ID, and creation time.
+
+Accept only full branch names that start with `refs/heads/`. Resolve each base
+and head to a commit before the metadata mutation starts. Support the object-ID
+length of the repository, so SHA-1 and SHA-256 repositories use the same code.
+
+Publish the current head as this ordinary Git ref:
+
+```text
+refs/pull/<number>/head
+```
+
+Do not let Git push create or change this ref. The pull-request service changes
+it with an expected old object ID. Thus, a concurrent change cannot overwrite
+an unknown value.
+
+Use a durable `pull_request_ref_intent` record for each open or revision
+operation. The operation has these steps:
+
+1. Store the pending intent and reserve the pull-request number.
+2. Change the Git ref with the expected old value.
+3. In one SQLite transaction, store the pull request or revision, append its
+   repository event, and complete the intent.
+
+At process startup, inspect each pending intent. If the ref has its old value,
+apply the proposed value and complete the metadata. If the ref has the proposed
+value, complete the metadata. Stop startup if the ref has another value. The
+server uses one in-process lock for these operations. The instance lock stops a
+second server process from changing the same stores.
+
+Opening and revision require an active owner, maintainer, or writer. Anonymous
+users can read a public pull request. A private pull request uses the current
+repository read policy. A reader cannot open or revise a pull request.
+
+## Failure and threat cases
+
+Allocate a number before the Git ref changes. Do not reuse a number if the
+operation fails. Stable URLs must not identify a different object later.
+
+Reject a missing branch, a non-commit branch target, invalid content, an
+archived repository, and a role that cannot write. Recheck the account and role
+in the transaction that creates the intent. A change between initial Git
+resolution and this transaction cannot bypass authorization.
+
+The intent stores the expected and proposed object IDs. Recovery does not infer
+state from branch names or from current source content. A third ref value is a
+consistency error and requires operator inspection.
+
+## Evidence
+
+The pull-request integration test uses SHA-1 and SHA-256 repositories. It opens
+and revises a pull request, checks the fetch ref, and proves that the first
+revision keeps its original object IDs. It recovers once before the ref change
+and once after the ref change. It also opens two pull requests concurrently and
+proves that their numbers and refs are different.
+
+The Web integration test opens and reads a pull request without JavaScript. The
+migration test upgrades each historical schema and runs integrity checks.
+
+## Consequences
+
+Review code can use the immutable revision object IDs in the next milestone.
+The current ref remains useful to stock Git clients. The intent table adds
+recovery work, but it prevents a ref-only or metadata-only pull-request update.

scripts/check-m5-1

Mode 100755; object 305f497f83a6

@@ -1,0 +1,7 @@
+#!/bin/sh
+set -eu
+
+./scripts/check
+cargo test --locked --release --test pull_requests
+cargo test --locked --release --test public_routes runs_the_complete_issue_workflow_without_javascript
+cargo test --locked --release --test sqlite migrates_each_committed_historical_fixture

src/feed.rs

Mode 100644100644; object 4a813d991157fd8f4f6926b4

@@ -255,9 +255,13 @@
         "{}/{}/{}",
         base_url, record.repository.owner, record.repository.slug
     );
-    let number = issue_payload(&record.event).and_then(|payload| payload.get("number")?.as_i64());
+    let number = event_payload(&record.event).and_then(|payload| payload.get("number")?.as_i64());
     number.map_or(repository_url.clone(), |number| {
-        format!("{repository_url}/issues/{number}")
+        if record.event.kind.starts_with("pull-request-") {
+            format!("{repository_url}/pulls/{number}")
+        } else {
+            format!("{repository_url}/issues/{number}")
+        }
     })
 }
 
@@ -290,6 +294,8 @@
         "issue-unlabeled" => issue_value_title(event, "removed label", "label"),
         "issue-assigned" => issue_value_title(event, "assigned", "assignee"),
         "issue-unassigned" => issue_value_title(event, "unassigned", "assignee"),
+        "pull-request-created" => pull_request_title(event, "opened"),
+        "pull-request-revised" => pull_request_title(event, "revised"),
         _ => "Repository event".to_owned(),
     }
 }
@@ -319,6 +325,18 @@
 }
 
 fn issue_payload(event: &RepositoryEventRecord) -> Option<serde_json::Value> {
+    event_payload(event)
+}
+
+fn pull_request_title(event: &RepositoryEventRecord, action: &str) -> String {
+    let number = event_payload(event)
+        .and_then(|payload| payload.get("number")?.as_i64())
+        .map(|number| format!("#{number}"))
+        .unwrap_or_else(|| "pull request".to_owned());
+    format!("{} {action} {number}", event.actor)
+}
+
+fn event_payload(event: &RepositoryEventRecord) -> Option<serde_json::Value> {
     (event.payload_version == 1)
         .then(|| serde_json::from_str(&event.payload).ok())
         .flatten()

src/git/repository.rs

Mode 100644100644; object 3adddd388cc64295f119092f

@@ -4,6 +4,8 @@
 
 use gix::hash::{Kind, ObjectId};
 use gix::objs::{Data, Kind as ObjectKind, tree::EntryKind};
+use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog};
+use gix::refs::{FullName, Target};
 use gix_pack::data::Version;
 use gix_pack::data::output::{Count, Entry, bytes::FromEntriesIter};
 use thiserror::Error;
@@ -128,6 +130,69 @@
             );
         }
         Ok(references)
+    }
+
+    pub(crate) fn resolve_branch(&self, name: &str) -> Result<ObjectId, GitRepositoryError> {
+        if !name.starts_with("refs/heads/") || name.len() > 1024 {
+            return Err(GitRepositoryError::InvalidBranch);
+        }
+        let reference = self
+            .repository
+            .try_find_reference(name)
+            .map_err(|error| GitRepositoryError::References(error.to_string()))?
+            .ok_or_else(|| GitRepositoryError::MissingReference(name.to_owned()))?;
+        let target = reference
+            .try_id()
+            .map(gix::Id::detach)
+            .ok_or_else(|| GitRepositoryError::MissingReference(name.to_owned()))?;
+        if self.find_object(target)?.kind != ObjectKind::Commit {
+            return Err(GitRepositoryError::BranchNotCommit);
+        }
+        Ok(target)
+    }
+
+    pub(crate) fn reference_target(
+        &self,
+        name: &str,
+    ) -> Result<Option<ObjectId>, GitRepositoryError> {
+        self.repository
+            .try_find_reference(name)
+            .map(|reference| {
+                reference.and_then(|reference| reference.try_id().map(gix::Id::detach))
+            })
+            .map_err(|error| GitRepositoryError::References(error.to_string()))
+    }
+
+    pub(crate) fn update_reference(
+        &self,
+        name: &str,
+        expected: Option<ObjectId>,
+        new: ObjectId,
+    ) -> Result<(), GitRepositoryError> {
+        if new.kind() != self.object_format() {
+            return Err(GitRepositoryError::WrongObjectFormat);
+        }
+        let name = FullName::try_from(name)
+            .map_err(|_| GitRepositoryError::InvalidReference(name.to_owned()))?;
+        let edit = RefEdit {
+            name,
+            deref: false,
+            change: Change::Update {
+                expected: expected.map_or(PreviousValue::MustNotExist, |id| {
+                    PreviousValue::MustExistAndMatch(Target::Object(id))
+                }),
+                new: Target::Object(new),
+                log: LogChange {
+                    mode: RefLog::AndReference,
+                    force_create_reflog: false,
+                    message: "pull request revision".into(),
+                },
+            },
+        };
+        self.repository
+            .edit_references_as([edit], None)
+            .map_err(|error| GitRepositoryError::RefTransaction(error.to_string()))?;
+        Ok(())
     }
 
     pub(crate) fn make_pack(
@@ -299,6 +364,16 @@
     NotBare(PathBuf),
     #[error("cannot read Git references: {0}")]
     References(String),
+    #[error("Git branch name is not valid")]
+    InvalidBranch,
+    #[error("Git reference name is not valid: {0}")]
+    InvalidReference(String),
+    #[error("Git reference does not exist: {0}")]
+    MissingReference(String),
+    #[error("Git branch does not point to a commit")]
+    BranchNotCommit,
+    #[error("cannot update Git references: {0}")]
+    RefTransaction(String),
     #[error("cannot read Git object {id}: {reason}")]
     Object { id: ObjectId, reason: String },
     #[error("Git object does not exist: {0}")]

src/http/mod.rs

Mode 100644100644; object 75668e8aa8292fa36ba8f9f1

@@ -2,6 +2,7 @@
 mod issues;
 mod metadata_search;
 mod public;
+mod pull_requests;
 mod watches;
 
 use std::net::SocketAddr;
@@ -26,6 +27,7 @@
 use crate::domain::repository::validate_slug;
 use crate::feed_token::FeedTokenService;
 use crate::issue::IssueService;
+use crate::pull_request::PullRequestService;
 use crate::repository::{RepositoryService, RepositoryServiceError};
 use crate::search::MetadataSearchService;
 use crate::session::{SessionError, WebLoginService};
@@ -51,6 +53,7 @@
     login: Option<WebLoginService>,
     repositories: Option<RepositoryService>,
     issues: Option<IssueService>,
+    pull_requests: Option<PullRequestService>,
     feeds: Option<FeedTokenService>,
     search: Option<MetadataSearchService>,
     watches: Option<WatchService>,
@@ -87,6 +90,7 @@
                 login: None,
                 repositories: None,
                 issues: None,
+                pull_requests: None,
                 feeds: None,
                 search: None,
                 watches: None,
@@ -126,6 +130,7 @@
         let public = PublicWeb::open(config, Arc::clone(&jobs))?;
         let repositories = RepositoryService::new(public.database(), public.repository_root());
         let issues = IssueService::new(public.database());
+        let pull_requests = PullRequestService::new(public.database(), public.repository_root());
         let feeds = FeedTokenService::new(public.database());
         let search = MetadataSearchService::new(public.database());
         let watches = WatchService::new(public.database());
@@ -139,6 +144,7 @@
                 login: Some(login),
                 repositories: Some(repositories),
                 issues: Some(issues),
+                pull_requests: Some(pull_requests),
                 feeds: Some(feeds),
                 search: Some(search),
                 watches: Some(watches),
@@ -186,6 +192,7 @@
         login: None,
         repositories: None,
         issues: None,
+        pull_requests: None,
         feeds: None,
         search: None,
         watches: None,
@@ -197,6 +204,7 @@
     let repository_routes = metadata_search::routes()
         .merge(watches::routes())
         .merge(issues::routes())
+        .merge(pull_requests::routes())
         .merge(public::routes())
         .layer(middleware::from_fn_with_state(
             state.clone(),

src/http/pull_requests.rs

Mode 100644; object 62794ec6a28f

@@ -1,0 +1,327 @@
+use askama::Template;
+use axum::Router;
+use axum::body::Bytes;
+use axum::extract::{DefaultBodyLimit, Extension, Path, State};
+use axum::http::{HeaderMap, StatusCode, header};
+use axum::response::Response;
+use axum::routing::{get, post};
+use serde::Deserialize;
+
+use crate::markdown::{self, RenderedMarkdown};
+use crate::pull_request::PullRequestError;
+use crate::store::StoreError;
+
+use super::{
+    CSRF_COOKIE, RequestActor, RequestId, WebState, authenticate_mutation, cookie,
+    parse_named_form, render, render_error,
+};
+
+const MAX_PULL_REQUEST_BYTES: usize = 300 * 1024;
+
+pub(super) fn routes() -> Router<WebState> {
+    Router::new()
+        .route(
+            "/{owner}/{repository}/pulls",
+            get(pull_request_list).post(open_pull_request),
+        )
+        .route(
+            "/{owner}/{repository}/pulls/{number}",
+            get(pull_request_detail),
+        )
+        .route(
+            "/{owner}/{repository}/pulls/{number}/revisions",
+            post(revise_pull_request),
+        )
+        .layer(DefaultBodyLimit::max(MAX_PULL_REQUEST_BYTES))
+}
+
+async fn pull_request_list(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    Extension(actor): Extension<RequestActor>,
+    Path(path): Path<RepositoryPath>,
+    headers: HeaderMap,
+) -> Response {
+    let Some(service) = state.pull_requests.clone() else {
+        return internal(&request_id.0);
+    };
+    let owner = path.owner.clone();
+    let repository = path.repository.clone();
+    let result = job(state, move || {
+        service.list(&owner, &repository, actor.0.as_deref())
+    })
+    .await;
+    match result {
+        Ok((record, pull_requests, can_create)) => {
+            let csrf = cookie(&headers, CSRF_COOKIE).unwrap_or_default();
+            render(
+                StatusCode::OK,
+                &PullRequestListTemplate {
+                    request_id: &request_id.0,
+                    owner: &record.owner,
+                    repository: &record.slug,
+                    pull_requests: pull_requests
+                        .iter()
+                        .map(|pull_request| PullRequestListItem {
+                            number: pull_request.number,
+                            title: &pull_request.title,
+                            state: &pull_request.state,
+                            author: &pull_request.author,
+                            updated_at: pull_request.updated_at,
+                        })
+                        .collect(),
+                    csrf: &csrf,
+                    can_create: can_create && !csrf.is_empty(),
+                },
+            )
+        }
+        Err(error) => read_error(error, &request_id.0),
+    }
+}
+
+async fn pull_request_detail(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    Extension(actor): Extension<RequestActor>,
+    Path(path): Path<PullRequestPath>,
+    headers: HeaderMap,
+) -> Response {
+    let Some(service) = state.pull_requests.clone() else {
+        return internal(&request_id.0);
+    };
+    let owner = path.owner.clone();
+    let repository = path.repository.clone();
+    let result = job(state, move || {
+        service.get(&owner, &repository, path.number, actor.0.as_deref())
+    })
+    .await;
+    match result {
+        Ok(detail) => {
+            let csrf = cookie(&headers, CSRF_COOKIE).unwrap_or_default();
+            let pull_request = &detail.pull_request;
+            render(
+                StatusCode::OK,
+                &PullRequestTemplate {
+                    request_id: &request_id.0,
+                    owner: &detail.repository.owner,
+                    repository: &detail.repository.slug,
+                    pull_request,
+                    body_html: markdown::render(&pull_request.body),
+                    revisions: &detail.revisions,
+                    csrf: &csrf,
+                    can_revise: detail.can_revise && !csrf.is_empty(),
+                },
+            )
+        }
+        Err(error) => read_error(error, &request_id.0),
+    }
+}
+
+async fn open_pull_request(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    Path(path): Path<RepositoryPath>,
+    headers: HeaderMap,
+    body: Bytes,
+) -> Response {
+    let fields = match parse_named_form(
+        &headers,
+        &body,
+        &["csrf", "title", "body", "base-ref", "head-ref"],
+    ) {
+        Ok(fields) => fields,
+        Err(()) => return bad_request(&request_id.0),
+    };
+    let actor =
+        match authenticate_mutation(state.clone(), &headers, &fields[0], &request_id.0).await {
+            Ok(actor) => actor,
+            Err(response) => return response,
+        };
+    let Some(service) = state.pull_requests.clone() else {
+        return internal(&request_id.0);
+    };
+    let owner = path.owner.clone();
+    let repository = path.repository.clone();
+    let result = job(state, move || {
+        service.open(
+            &owner,
+            &repository,
+            &actor,
+            &fields[1],
+            &fields[2],
+            &fields[3],
+            &fields[4],
+        )
+    })
+    .await;
+    match result {
+        Ok(pull_request) => redirect(&path.owner, &path.repository, pull_request.number),
+        Err(error) => mutation_error(error, &request_id.0),
+    }
+}
+
+async fn revise_pull_request(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    Path(path): Path<PullRequestPath>,
+    headers: HeaderMap,
+    body: Bytes,
+) -> Response {
+    let fields = match parse_named_form(&headers, &body, &["csrf"]) {
+        Ok(fields) => fields,
+        Err(()) => return bad_request(&request_id.0),
+    };
+    let actor =
+        match authenticate_mutation(state.clone(), &headers, &fields[0], &request_id.0).await {
+            Ok(actor) => actor,
+            Err(response) => return response,
+        };
+    let Some(service) = state.pull_requests.clone() else {
+        return internal(&request_id.0);
+    };
+    let owner = path.owner.clone();
+    let repository = path.repository.clone();
+    let number = path.number;
+    let result = job(state, move || {
+        service.revise(&owner, &repository, number, &actor)
+    })
+    .await;
+    match result {
+        Ok(_) => redirect(&path.owner, &path.repository, number),
+        Err(error) => mutation_error(error, &request_id.0),
+    }
+}
+
+async fn job<T: Send + 'static>(
+    state: WebState,
+    operation: impl FnOnce() -> Result<T, PullRequestError> + Send + 'static,
+) -> Result<T, PullRequestError> {
+    let permit = state.jobs.acquire_owned().await.map_err(|_| {
+        PullRequestError::Store(StoreError::Integrity("Web work queue is closed".to_owned()))
+    })?;
+    tokio::task::spawn_blocking(move || {
+        let _permit = permit;
+        operation()
+    })
+    .await
+    .map_err(|_| PullRequestError::Store(StoreError::Integrity("Web task stopped".to_owned())))?
+}
+
+fn read_error(error: PullRequestError, request_id: &str) -> Response {
+    match error {
+        PullRequestError::Store(
+            StoreError::RepositoryNotFound(_, _)
+            | StoreError::PullRequestNotFound(_, _, _)
+            | StoreError::PullRequestHidden,
+        ) => render_error(
+            StatusCode::NOT_FOUND,
+            request_id,
+            "Not found",
+            "The pull request was not found.",
+        ),
+        PullRequestError::Number
+        | PullRequestError::Auth(_)
+        | PullRequestError::RepositoryName(_) => bad_request(request_id),
+        _ => internal(request_id),
+    }
+}
+
+fn mutation_error(error: PullRequestError, request_id: &str) -> Response {
+    match error {
+        PullRequestError::Store(StoreError::PullRequestDenied) => render_error(
+            StatusCode::FORBIDDEN,
+            request_id,
+            "Pull-request error",
+            "You cannot change pull requests in this repository.",
+        ),
+        PullRequestError::Store(
+            StoreError::RepositoryNotFound(_, _)
+            | StoreError::PullRequestNotFound(_, _, _)
+            | StoreError::PullRequestHidden,
+        ) => read_error(error, request_id),
+        PullRequestError::Title
+        | PullRequestError::Body
+        | PullRequestError::Branch
+        | PullRequestError::Number
+        | PullRequestError::Unchanged
+        | PullRequestError::Git(crate::git::repository::GitRepositoryError::MissingReference(_)) => {
+            bad_request(request_id)
+        }
+        _ => internal(request_id),
+    }
+}
+
+fn bad_request(request_id: &str) -> Response {
+    render_error(
+        StatusCode::BAD_REQUEST,
+        request_id,
+        "Pull-request error",
+        "The pull-request request is not valid.",
+    )
+}
+
+fn internal(request_id: &str) -> Response {
+    render_error(
+        StatusCode::INTERNAL_SERVER_ERROR,
+        request_id,
+        "Pull-request error",
+        "The pull-request request could not be completed.",
+    )
+}
+
+fn redirect(owner: &str, repository: &str, number: i64) -> Response {
+    Response::builder()
+        .status(StatusCode::SEE_OTHER)
+        .header(
+            header::LOCATION,
+            format!("/{owner}/{repository}/pulls/{number}"),
+        )
+        .header(header::CACHE_CONTROL, "no-store")
+        .body(axum::body::Body::empty())
+        .expect("the pull-request redirect is valid")
+}
+
+#[derive(Clone, Deserialize)]
+struct RepositoryPath {
+    owner: String,
+    repository: String,
+}
+
+#[derive(Clone, Deserialize)]
+struct PullRequestPath {
+    owner: String,
+    repository: String,
+    number: i64,
+}
+
+#[derive(Template)]
+#[template(path = "pull_requests.html")]
+struct PullRequestListTemplate<'a> {
+    request_id: &'a str,
+    owner: &'a str,
+    repository: &'a str,
+    pull_requests: Vec<PullRequestListItem<'a>>,
+    csrf: &'a str,
+    can_create: bool,
+}
+
+struct PullRequestListItem<'a> {
+    number: i64,
+    title: &'a str,
+    state: &'a str,
+    author: &'a str,
+    updated_at: i64,
+}
+
+#[derive(Template)]
+#[template(path = "pull_request.html")]
+struct PullRequestTemplate<'a> {
+    request_id: &'a str,
+    owner: &'a str,
+    repository: &'a str,
+    pull_request: &'a crate::store::PullRequestRecord,
+    body_html: RenderedMarkdown,
+    revisions: &'a [crate::store::PullRequestRevisionRecord],
+    csrf: &'a str,
+    can_revise: bool,
+}

src/main.rs

Mode 100644100644; object a17845a010766fca20235d53

@@ -20,6 +20,7 @@
 mod issue;
 mod markdown;
 mod policy;
+mod pull_request;
 mod repository;
 mod search;
 mod serve;

src/pull_request.rs

Mode 100644; object d6bb8eebfa37

@@ -1,0 +1,382 @@
+use std::fs;
+use std::path::{Path, PathBuf};
+use std::sync::{Arc, Mutex};
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use gix::hash::ObjectId;
+use rand::TryRng;
+use thiserror::Error;
+
+use crate::auth::{AuthError, validate_username};
+use crate::domain::repository::{RepositoryNameError, validate_slug};
+use crate::git::repository::{GitRepository, GitRepositoryError};
+use crate::store::{
+    NewPullRequestRefIntent, PullRequestDetail, PullRequestRecord, PullRequestRefIntentRecord,
+    Store, StoreError,
+};
+
+pub(crate) const MAX_TITLE_BYTES: usize = 200;
+pub(crate) const MAX_BODY_BYTES: usize = 256 * 1024;
+
+#[derive(Clone)]
+pub(crate) struct PullRequestService {
+    database: PathBuf,
+    repositories: PathBuf,
+    operations: Arc<Mutex<()>>,
+}
+
+impl PullRequestService {
+    pub(crate) fn new(database: &Path, repositories: &Path) -> Self {
+        Self {
+            database: database.to_owned(),
+            repositories: repositories.to_owned(),
+            operations: Arc::new(Mutex::new(())),
+        }
+    }
+
+    #[allow(
+        clippy::too_many_arguments,
+        reason = "opening a pull request requires its repository, content, and two refs"
+    )]
+    pub(crate) fn open(
+        &self,
+        owner: &str,
+        repository: &str,
+        actor: &str,
+        title: &str,
+        body: &str,
+        base_ref: &str,
+        head_ref: &str,
+    ) -> Result<PullRequestRecord, PullRequestError> {
+        validate_context(owner, repository, actor)?;
+        validate_content(title, body)?;
+        validate_branch(base_ref)?;
+        validate_branch(head_ref)?;
+        let _operation = self
+            .operations
+            .lock()
+            .unwrap_or_else(std::sync::PoisonError::into_inner);
+        self.recover_inner()?;
+
+        let authorization = Store::open(&self.database)?.repository_authorization(
+            owner,
+            repository,
+            Some(actor),
+        )?;
+        let path = self.repository_path(&authorization.repository.id)?;
+        let git = GitRepository::open(&path)?;
+        let base = git.resolve_branch(base_ref)?;
+        let head = git.resolve_branch(head_ref)?;
+        let intent_id = random_id()?;
+        let pull_request_id = random_id()?;
+        let created_at = timestamp()?;
+        let mut store = Store::open(&self.database)?;
+        let intent = store.begin_pull_request_open(&NewPullRequestRefIntent {
+            id: &intent_id,
+            pull_request_id: &pull_request_id,
+            owner,
+            repository,
+            actor,
+            title,
+            body,
+            base_ref,
+            head_ref,
+            base_object_id: &base.to_string(),
+            head_object_id: &head.to_string(),
+            created_at,
+        })?;
+        crash_point("intent");
+        self.apply_intent(&mut store, &git, &intent)?;
+        crash_point("completed");
+        Ok(store
+            .pull_request(owner, repository, intent.pull_request_number, Some(actor))?
+            .pull_request)
+    }
+
+    pub(crate) fn revise(
+        &self,
+        owner: &str,
+        repository: &str,
+        number: i64,
+        actor: &str,
+    ) -> Result<PullRequestRecord, PullRequestError> {
+        validate_context(owner, repository, actor)?;
+        if number < 1 {
+            return Err(PullRequestError::Number);
+        }
+        let _operation = self
+            .operations
+            .lock()
+            .unwrap_or_else(std::sync::PoisonError::into_inner);
+        self.recover_inner()?;
+        let current = Store::open(&self.database)?
+            .pull_request(owner, repository, number, Some(actor))?
+            .pull_request;
+        let authorization = Store::open(&self.database)?.repository_authorization(
+            owner,
+            repository,
+            Some(actor),
+        )?;
+        let path = self.repository_path(&authorization.repository.id)?;
+        let git = GitRepository::open(&path)?;
+        let base = git.resolve_branch(&current.base_ref)?;
+        let head = git.resolve_branch(&current.head_ref)?;
+        if head.to_string() == current.head_object_id && base.to_string() == current.base_object_id
+        {
+            return Err(PullRequestError::Unchanged);
+        }
+        let intent_id = random_id()?;
+        let created_at = timestamp()?;
+        let mut store = Store::open(&self.database)?;
+        let intent = store.begin_pull_request_revision(
+            number,
+            &NewPullRequestRefIntent {
+                id: &intent_id,
+                pull_request_id: &current.id,
+                owner,
+                repository,
+                actor,
+                title: &current.title,
+                body: &current.body,
+                base_ref: &current.base_ref,
+                head_ref: &current.head_ref,
+                base_object_id: &base.to_string(),
+                head_object_id: &head.to_string(),
+                created_at,
+            },
+        )?;
+        crash_point("intent");
+        self.apply_intent(&mut store, &git, &intent)?;
+        crash_point("completed");
+        Ok(store
+            .pull_request(owner, repository, number, Some(actor))?
+            .pull_request)
+    }
+
+    pub(crate) fn get(
+        &self,
+        owner: &str,
+        repository: &str,
+        number: i64,
+        actor: Option<&str>,
+    ) -> Result<PullRequestDetail, PullRequestError> {
+        validate_username(owner)?;
+        validate_slug(repository)?;
+        if let Some(actor) = actor {
+            validate_username(actor)?;
+        }
+        if number < 1 {
+            return Err(PullRequestError::Number);
+        }
+        let _operation = self
+            .operations
+            .lock()
+            .unwrap_or_else(std::sync::PoisonError::into_inner);
+        self.recover_inner()?;
+        Store::open(&self.database)?
+            .pull_request(owner, repository, number, actor)
+            .map_err(Into::into)
+    }
+
+    #[allow(
+        dead_code,
+        reason = "some integration tests compile the service without the Web list route"
+    )]
+    pub(crate) fn list(
+        &self,
+        owner: &str,
+        repository: &str,
+        actor: Option<&str>,
+    ) -> Result<(crate::store::RepositoryRecord, Vec<PullRequestRecord>, bool), PullRequestError>
+    {
+        validate_username(owner)?;
+        validate_slug(repository)?;
+        if let Some(actor) = actor {
+            validate_username(actor)?;
+        }
+        let _operation = self
+            .operations
+            .lock()
+            .unwrap_or_else(std::sync::PoisonError::into_inner);
+        self.recover_inner()?;
+        Store::open(&self.database)?
+            .pull_requests(owner, repository, actor)
+            .map_err(Into::into)
+    }
+
+    pub(crate) fn recover(&self) -> Result<(), PullRequestError> {
+        let _operation = self
+            .operations
+            .lock()
+            .unwrap_or_else(std::sync::PoisonError::into_inner);
+        self.recover_inner()
+    }
+
+    fn recover_inner(&self) -> Result<(), PullRequestError> {
+        let mut store = Store::open(&self.database)?;
+        for intent in store.incomplete_pull_request_ref_intents()? {
+            let path = self.repository_path(&intent.repository_id)?;
+            let git = GitRepository::open(&path)?;
+            self.recover_intent(&mut store, &git, &intent)?;
+        }
+        Ok(())
+    }
+
+    fn apply_intent(
+        &self,
+        store: &mut Store,
+        git: &GitRepository,
+        intent: &PullRequestRefIntentRecord,
+    ) -> Result<(), PullRequestError> {
+        let name = pull_request_ref(intent.pull_request_number);
+        let old = parse_optional_id(intent.old_head_object_id.as_deref())?;
+        let head = parse_id(&intent.head_object_id)?;
+        if let Err(error) = git.update_reference(&name, old, head) {
+            let current = git.reference_target(&name)?;
+            if current == Some(head) {
+                store.complete_pull_request_ref_intent(&intent.id)?;
+                return Ok(());
+            }
+            if current == old {
+                store.abandon_pull_request_ref_intent(&intent.id)?;
+                return Err(error.into());
+            }
+            return Err(PullRequestError::MixedRecovery(intent.id.clone()));
+        }
+        crash_point("ref");
+        store.complete_pull_request_ref_intent(&intent.id)?;
+        Ok(())
+    }
+
+    fn recover_intent(
+        &self,
+        store: &mut Store,
+        git: &GitRepository,
+        intent: &PullRequestRefIntentRecord,
+    ) -> Result<(), PullRequestError> {
+        let name = pull_request_ref(intent.pull_request_number);
+        let old = parse_optional_id(intent.old_head_object_id.as_deref())?;
+        let head = parse_id(&intent.head_object_id)?;
+        match git.reference_target(&name)? {
+            Some(current) if current == head => {
+                store.complete_pull_request_ref_intent(&intent.id)?;
+            }
+            current if current == old => {
+                git.update_reference(&name, old, head)?;
+                store.complete_pull_request_ref_intent(&intent.id)?;
+            }
+            _ => return Err(PullRequestError::MixedRecovery(intent.id.clone())),
+        }
+        Ok(())
+    }
+
+    fn repository_path(&self, repository_id: &str) -> Result<PathBuf, PullRequestError> {
+        let path = fs::canonicalize(self.repositories.join(format!("{repository_id}.git")))?;
+        if path.parent() != Some(self.repositories.as_path()) {
+            return Err(PullRequestError::RepositoryPath);
+        }
+        Ok(path)
+    }
+}
+
+fn validate_context(owner: &str, repository: &str, actor: &str) -> Result<(), PullRequestError> {
+    validate_username(owner)?;
+    validate_slug(repository)?;
+    validate_username(actor)?;
+    Ok(())
+}
+
+fn validate_content(title: &str, body: &str) -> Result<(), PullRequestError> {
+    if title.is_empty() || title.len() > MAX_TITLE_BYTES || title.contains(['\r', '\n']) {
+        return Err(PullRequestError::Title);
+    }
+    if body.len() > MAX_BODY_BYTES {
+        return Err(PullRequestError::Body);
+    }
+    Ok(())
+}
+
+fn validate_branch(name: &str) -> Result<(), PullRequestError> {
+    if !name.starts_with("refs/heads/") || name.len() > 1024 || !name.is_ascii() {
+        return Err(PullRequestError::Branch);
+    }
+    Ok(())
+}
+
+fn pull_request_ref(number: i64) -> String {
+    format!("refs/pull/{number}/head")
+}
+
+fn parse_id(value: &str) -> Result<ObjectId, PullRequestError> {
+    ObjectId::from_hex(value.as_bytes()).map_err(|_| PullRequestError::StoredObjectId)
+}
+
+fn parse_optional_id(value: Option<&str>) -> Result<Option<ObjectId>, PullRequestError> {
+    value.map(parse_id).transpose()
+}
+
+fn random_id() -> Result<String, PullRequestError> {
+    let mut bytes = [0_u8; 16];
+    rand::rngs::SysRng
+        .try_fill_bytes(&mut bytes)
+        .map_err(|_| PullRequestError::Random)?;
+    Ok(bytes.iter().map(|byte| format!("{byte:02x}")).collect())
+}
+
+fn timestamp() -> Result<i64, PullRequestError> {
+    SystemTime::now()
+        .duration_since(UNIX_EPOCH)
+        .map_err(|_| PullRequestError::Clock)?
+        .as_secs()
+        .try_into()
+        .map_err(|_| PullRequestError::Clock)
+}
+
+#[cfg(test)]
+fn crash_point(point: &str) {
+    if std::env::var("TIT_M5_1_CRASH_AFTER").as_deref() != Ok(point) {
+        return;
+    }
+    let ready = std::env::var_os("TIT_M5_1_READY").expect("read the M5.1 ready path");
+    fs::write(ready, point.as_bytes()).expect("write the M5.1 ready file");
+    loop {
+        std::thread::park();
+    }
+}
+
+#[cfg(not(test))]
+fn crash_point(_point: &str) {}
+
+#[derive(Debug, Error)]
+pub(crate) enum PullRequestError {
+    #[error(transparent)]
+    Auth(#[from] AuthError),
+    #[error(transparent)]
+    RepositoryName(#[from] RepositoryNameError),
+    #[error(transparent)]
+    Store(#[from] StoreError),
+    #[error(transparent)]
+    Git(#[from] GitRepositoryError),
+    #[error("pull-request title is not valid")]
+    Title,
+    #[error("pull-request body is too large")]
+    Body,
+    #[error("pull-request branch name is not valid")]
+    Branch,
+    #[error("pull-request number is not valid")]
+    Number,
+    #[error("pull-request refs have not changed")]
+    Unchanged,
+    #[error("stored pull-request object ID is not valid")]
+    StoredObjectId,
+    #[error("pull-request ref intent {0} has mixed Git and metadata state")]
+    MixedRecovery(String),
+    #[error("pull-request repository path is not canonical")]
+    RepositoryPath,
+    #[error("cannot access a pull-request repository: {0}")]
+    Io(#[from] std::io::Error),
+    #[error("cannot create a random pull-request ID")]
+    Random,
+    #[error("the system clock is before the Unix epoch")]
+    Clock,
+}

src/serve.rs

Mode 100644100644; object b618fda981f23c82df98548a

@@ -15,6 +15,7 @@
 use crate::http::{PublicWebConfig, RunningWebServer, WebError};
 use crate::instance::{InstanceError, InstanceLock, prepare_database, prepare_repository_root};
 use crate::policy::PolicyError;
+use crate::pull_request::{PullRequestError, PullRequestService};
 use crate::ssh::{AuthorizedSshKeys, RunningSshServer, SshServerError};
 use crate::store::{Store, StoreError};
 
@@ -22,6 +23,7 @@
     let _lock = InstanceLock::acquire(&config.instance_dir)?;
     let database = prepare_database(&config.instance_dir)?;
     let repository_root = prepare_repository_root(&config.instance_dir)?;
+    PullRequestService::new(&database, &repository_root).recover()?;
     let store = Store::open(&database)?;
     let keys = active_ssh_identities(&store)?;
     let git = GitRepositories::new_managed_authorized(&repository_root, &database)?;
@@ -213,6 +215,8 @@
     Store(#[from] StoreError),
     #[error(transparent)]
     Policy(#[from] PolicyError),
+    #[error(transparent)]
+    PullRequest(#[from] PullRequestError),
     #[error(transparent)]
     Authentication(#[from] AuthError),
     #[error(transparent)]

src/store/event.rs

Mode 100644100644; object dfec264efd1e2fd644f2bd56

@@ -22,6 +22,8 @@
     IssueUnlabeled,
     IssueAssigned,
     IssueUnassigned,
+    PullRequestCreated,
+    PullRequestRevised,
 }
 
 impl EventKind {
@@ -45,7 +47,45 @@
             Self::IssueUnlabeled => "issue-unlabeled",
             Self::IssueAssigned => "issue-assigned",
             Self::IssueUnassigned => "issue-unassigned",
+            Self::PullRequestCreated => "pull-request-created",
+            Self::PullRequestRevised => "pull-request-revised",
         }
+    }
+}
+
+#[allow(
+    clippy::too_many_arguments,
+    reason = "the event payload stores the complete immutable pull-request revision"
+)]
+pub(super) fn pull_request(
+    kind: EventKind,
+    pull_request_id: &str,
+    number: i64,
+    revision: i64,
+    title: &str,
+    base_ref: &str,
+    head_ref: &str,
+    base_object_id: &str,
+    head_object_id: &str,
+) -> VersionedEvent {
+    debug_assert!(matches!(
+        kind,
+        EventKind::PullRequestCreated | EventKind::PullRequestRevised
+    ));
+    VersionedEvent {
+        kind,
+        payload: json!({
+            "version": PAYLOAD_VERSION,
+            "pull_request_id": pull_request_id,
+            "number": number,
+            "revision": revision,
+            "title": title,
+            "base_ref": base_ref,
+            "head_ref": head_ref,
+            "base_object_id": base_object_id,
+            "head_object_id": head_object_id,
+        })
+        .to_string(),
     }
 }
 

src/store/migrations/015_pull_requests.sql

Mode 100644; object 9d7113995265

@@ -1,0 +1,204 @@
+CREATE TABLE pull_request (
+    id TEXT PRIMARY KEY
+        CHECK (
+            length(id) = 32
+            AND id = lower(id)
+            AND id NOT GLOB '*[^0-9a-f]*'
+        ),
+    repository_id TEXT NOT NULL
+        REFERENCES repository (id) ON DELETE RESTRICT,
+    number INTEGER NOT NULL CHECK (number >= 1),
+    title TEXT NOT NULL
+        CHECK (length(CAST(title AS BLOB)) BETWEEN 1 AND 200),
+    body TEXT NOT NULL CHECK (length(CAST(body AS BLOB)) <= 262144),
+    state TEXT NOT NULL CHECK (state IN ('open', 'closed', 'merged')),
+    author_account_id INTEGER NOT NULL
+        REFERENCES account (id) ON DELETE RESTRICT,
+    base_ref TEXT NOT NULL CHECK (length(CAST(base_ref AS BLOB)) BETWEEN 12 AND 1024),
+    head_ref TEXT NOT NULL CHECK (length(CAST(head_ref AS BLOB)) BETWEEN 12 AND 1024),
+    base_object_id TEXT NOT NULL
+        CHECK (length(base_object_id) IN (40, 64) AND base_object_id = lower(base_object_id)
+               AND base_object_id NOT GLOB '*[^0-9a-f]*'),
+    head_object_id TEXT NOT NULL
+        CHECK (length(head_object_id) IN (40, 64) AND head_object_id = lower(head_object_id)
+               AND head_object_id NOT GLOB '*[^0-9a-f]*'),
+    created_at INTEGER NOT NULL CHECK (created_at >= 0),
+    updated_at INTEGER NOT NULL CHECK (updated_at >= created_at),
+    UNIQUE (repository_id, number)
+) STRICT;
+
+CREATE INDEX pull_request_repository_state
+ON pull_request (repository_id, state, number DESC);
+
+CREATE TABLE pull_request_revision (
+    id TEXT PRIMARY KEY
+        CHECK (
+            length(id) = 32
+            AND id = lower(id)
+            AND id NOT GLOB '*[^0-9a-f]*'
+        ),
+    pull_request_id TEXT NOT NULL
+        REFERENCES pull_request (id) ON DELETE RESTRICT,
+    number INTEGER NOT NULL CHECK (number >= 1),
+    author_account_id INTEGER NOT NULL
+        REFERENCES account (id) ON DELETE RESTRICT,
+    base_object_id TEXT NOT NULL
+        CHECK (length(base_object_id) IN (40, 64) AND base_object_id = lower(base_object_id)
+               AND base_object_id NOT GLOB '*[^0-9a-f]*'),
+    head_object_id TEXT NOT NULL
+        CHECK (length(head_object_id) IN (40, 64) AND head_object_id = lower(head_object_id)
+               AND head_object_id NOT GLOB '*[^0-9a-f]*'),
+    created_at INTEGER NOT NULL CHECK (created_at >= 0),
+    UNIQUE (pull_request_id, number)
+) STRICT;
+
+CREATE INDEX pull_request_revision_history
+ON pull_request_revision (pull_request_id, number);
+
+CREATE TABLE pull_request_ref_intent (
+    id TEXT PRIMARY KEY
+        CHECK (
+            length(id) = 32
+            AND id = lower(id)
+            AND id NOT GLOB '*[^0-9a-f]*'
+        ),
+    repository_id TEXT NOT NULL
+        REFERENCES repository (id) ON DELETE RESTRICT,
+    pull_request_id TEXT NOT NULL
+        CHECK (
+            length(pull_request_id) = 32
+            AND pull_request_id = lower(pull_request_id)
+            AND pull_request_id NOT GLOB '*[^0-9a-f]*'
+        ),
+    pull_request_number INTEGER NOT NULL CHECK (pull_request_number >= 1),
+    revision_number INTEGER NOT NULL CHECK (revision_number >= 1),
+    operation TEXT NOT NULL CHECK (operation IN ('open', 'revise')),
+    title TEXT NOT NULL
+        CHECK (length(CAST(title AS BLOB)) BETWEEN 1 AND 200),
+    body TEXT NOT NULL CHECK (length(CAST(body AS BLOB)) <= 262144),
+    author_account_id INTEGER NOT NULL
+        REFERENCES account (id) ON DELETE RESTRICT,
+    actor TEXT NOT NULL CHECK (length(actor) BETWEEN 1 AND 40),
+    base_ref TEXT NOT NULL CHECK (length(CAST(base_ref AS BLOB)) BETWEEN 12 AND 1024),
+    head_ref TEXT NOT NULL CHECK (length(CAST(head_ref AS BLOB)) BETWEEN 12 AND 1024),
+    base_object_id TEXT NOT NULL
+        CHECK (length(base_object_id) IN (40, 64) AND base_object_id = lower(base_object_id)
+               AND base_object_id NOT GLOB '*[^0-9a-f]*'),
+    old_head_object_id TEXT
+        CHECK (
+            old_head_object_id IS NULL
+            OR (length(old_head_object_id) IN (40, 64)
+                AND old_head_object_id = lower(old_head_object_id)
+                AND old_head_object_id NOT GLOB '*[^0-9a-f]*')
+        ),
+    head_object_id TEXT NOT NULL
+        CHECK (length(head_object_id) IN (40, 64) AND head_object_id = lower(head_object_id)
+               AND head_object_id NOT GLOB '*[^0-9a-f]*'),
+    state TEXT NOT NULL CHECK (state IN ('pending', 'completed', 'abandoned')),
+    created_at INTEGER NOT NULL CHECK (created_at >= 0),
+    CHECK (
+        (operation = 'open' AND revision_number = 1 AND old_head_object_id IS NULL)
+        OR (operation = 'revise' AND revision_number > 1 AND old_head_object_id IS NOT NULL)
+    )
+) STRICT;
+
+CREATE INDEX pull_request_ref_intent_incomplete
+ON pull_request_ref_intent (state, created_at, id)
+WHERE state = 'pending';
+
+DROP INDEX repository_event_feed;
+DROP INDEX repository_event_issue_timeline;
+ALTER TABLE repository_event RENAME TO repository_event_v14;
+
+CREATE TABLE repository_event (
+    id INTEGER PRIMARY KEY,
+    event_id TEXT NOT NULL UNIQUE
+        CHECK (
+            length(event_id) = 32
+            AND event_id = lower(event_id)
+            AND event_id NOT GLOB '*[^0-9a-f]*'
+        ),
+    repository_id TEXT NOT NULL
+        REFERENCES repository (id) ON DELETE RESTRICT,
+    sequence INTEGER NOT NULL CHECK (sequence >= 1),
+    source_intent_id TEXT
+        REFERENCES git_operation_intent (id) ON DELETE RESTRICT,
+    source_ordinal INTEGER CHECK (source_ordinal IS NULL OR source_ordinal >= 0),
+    issue_id TEXT REFERENCES issue (id) ON DELETE RESTRICT,
+    pull_request_id TEXT REFERENCES pull_request (id) ON DELETE RESTRICT,
+    kind TEXT NOT NULL
+        CHECK (kind IN (
+            'repository-created', 'repository-imported', 'push',
+            'ref-created', 'ref-updated', 'ref-deleted',
+            'tag-created', 'tag-updated', 'tag-deleted',
+            'issue-created', 'issue-edited', 'issue-commented',
+            'issue-closed', 'issue-reopened',
+            'issue-labeled', 'issue-unlabeled',
+            'issue-assigned', 'issue-unassigned',
+            'pull-request-created', 'pull-request-revised'
+        )),
+    actor TEXT NOT NULL CHECK (length(actor) BETWEEN 1 AND 256),
+    ref_name BLOB,
+    old_target TEXT,
+    new_target TEXT,
+    payload_version INTEGER NOT NULL CHECK (payload_version = 1),
+    payload TEXT NOT NULL
+        CHECK (
+            length(payload) BETWEEN 1 AND 1048576
+            AND CASE WHEN json_valid(payload) THEN
+                json_type(payload) = 'object'
+                AND coalesce(json_extract(payload, '$.version') = payload_version, 0)
+            ELSE 0 END
+        ),
+    created_at INTEGER NOT NULL CHECK (created_at >= 0),
+    UNIQUE (repository_id, sequence),
+    UNIQUE (source_intent_id, source_ordinal),
+    CHECK (
+        (source_intent_id IS NULL AND source_ordinal IS NULL)
+        OR (source_intent_id IS NOT NULL AND source_ordinal IS NOT NULL)
+    ),
+    CHECK (
+        (kind IN ('repository-created', 'repository-imported', 'push')
+            AND issue_id IS NULL AND pull_request_id IS NULL
+            AND ref_name IS NULL AND old_target IS NULL AND new_target IS NULL)
+        OR
+        ((kind LIKE 'ref-%' OR kind LIKE 'tag-%')
+            AND issue_id IS NULL AND pull_request_id IS NULL
+            AND ref_name IS NOT NULL
+            AND (old_target IS NOT NULL OR new_target IS NOT NULL))
+        OR
+        (kind LIKE 'issue-%'
+            AND issue_id IS NOT NULL AND pull_request_id IS NULL
+            AND source_intent_id IS NULL AND source_ordinal IS NULL
+            AND ref_name IS NULL AND old_target IS NULL AND new_target IS NULL)
+        OR
+        (kind LIKE 'pull-request-%'
+            AND issue_id IS NULL AND pull_request_id IS NOT NULL
+            AND source_intent_id IS NULL AND source_ordinal IS NULL
+            AND ref_name IS NULL AND old_target IS NULL AND new_target IS NULL)
+    )
+) STRICT;
+
+INSERT INTO repository_event
+    (id, event_id, repository_id, sequence, source_intent_id, source_ordinal,
+     issue_id, pull_request_id, kind, actor, ref_name, old_target, new_target,
+     payload_version, payload, created_at)
+SELECT
+    id, event_id, repository_id, sequence, source_intent_id, source_ordinal,
+    issue_id, NULL, kind, actor, ref_name, old_target, new_target,
+    payload_version, payload, created_at
+FROM repository_event_v14
+ORDER BY id;
+
+DROP TABLE repository_event_v14;
+
+CREATE INDEX repository_event_feed
+ON repository_event (repository_id, sequence DESC);
+
+CREATE INDEX repository_event_issue_timeline
+ON repository_event (issue_id, sequence)
+WHERE issue_id IS NOT NULL;
+
+CREATE INDEX repository_event_pull_request_timeline
+ON repository_event (pull_request_id, sequence)
+WHERE pull_request_id IS NOT NULL;

src/store/mod.rs

Mode 100644100644; object 6b9c5adaa79c78abc9d772a5

@@ -12,7 +12,7 @@
 const BUSY_TIMEOUT: Duration = Duration::from_secs(5);
 const BUSY_TIMEOUT_MILLISECONDS: i64 = 5_000;
 const MAX_ACTIVE_FEED_TOKENS: i64 = 32;
-const SCHEMA_VERSION: i64 = 14;
+const SCHEMA_VERSION: i64 = 15;
 #[allow(
     dead_code,
     reason = "the integration test imports this module without the CLI operation"
@@ -22,7 +22,7 @@
     dead_code,
     reason = "M1A proves migrations before the M2 server calls them"
 )]
-const MIGRATIONS: [&str; 14] = [
+const MIGRATIONS: [&str; 15] = [
     include_str!("migrations/001_initial.sql"),
     include_str!("migrations/002_state.sql"),
     include_str!("migrations/003_git_intents.sql"),
@@ -37,6 +37,7 @@
     include_str!("migrations/012_issues.sql"),
     include_str!("migrations/013_watches.sql"),
     include_str!("migrations/014_feed_tokens.sql"),
+    include_str!("migrations/015_pull_requests.sql"),
 ];
 
 #[allow(
@@ -133,6 +134,16 @@
     IssueAssigneeState,
     #[error("issue assignee does not exist or cannot read the repository: {0}")]
     IssueAssigneeNotFound(String),
+    #[error("pull request does not exist: {0}/{1}#{2}")]
+    PullRequestNotFound(String, String, i64),
+    #[error("pull-request access is not authorized")]
+    PullRequestDenied,
+    #[error("pull request is hidden by repository access policy")]
+    PullRequestHidden,
+    #[error("pull request is not open")]
+    PullRequestState,
+    #[error("pull-request ref intent {0} is not in the required state")]
+    PullRequestIntentState(String),
     #[error("repository watch access is not authorized")]
     WatchDenied,
     #[error("feed token access is not authorized")]
@@ -980,6 +991,7 @@
                         source_intent_id: None,
                         source_ordinal: None,
                         issue_id: None,
+                        pull_request_id: None,
                         event: &event,
                         actor: repository.owner,
                         ref_name: None,
@@ -1005,6 +1017,7 @@
                             source_intent_id: None,
                             source_ordinal: None,
                             issue_id: None,
+                            pull_request_id: None,
                             event: &event,
                             actor: repository.owner,
                             ref_name: Some(&reference.name),
@@ -1439,6 +1452,7 @@
                 source_intent_id: None,
                 source_ordinal: None,
                 issue_id: Some(&id),
+                pull_request_id: None,
                 event: &event,
                 actor: issue.actor,
                 ref_name: None,
@@ -1459,6 +1473,370 @@
             updated_at: issue.created_at,
             closed_at: None,
         })
+    }
+
+    pub(crate) fn begin_pull_request_open(
+        &mut self,
+        intent: &NewPullRequestRefIntent<'_>,
+    ) -> Result<PullRequestRefIntentRecord, StoreError> {
+        let transaction = self
+            .connection
+            .transaction_with_behavior(TransactionBehavior::Immediate)?;
+        let access = repository_issue_access(
+            &transaction,
+            intent.owner,
+            intent.repository,
+            Some(intent.actor),
+        )?;
+        if !access.can_read() {
+            return Err(StoreError::PullRequestHidden);
+        }
+        if !access.can_write_repository() {
+            return Err(StoreError::PullRequestDenied);
+        }
+        let actor_id = access
+            .active_actor_id
+            .ok_or(StoreError::PullRequestDenied)?;
+        transaction.execute(
+            "INSERT INTO repository_counter (repository_id)
+             VALUES (?1) ON CONFLICT (repository_id) DO NOTHING",
+            [&access.repository.id],
+        )?;
+        let number = transaction.query_row(
+            "UPDATE repository_counter
+             SET next_pull_request_number = next_pull_request_number + 1
+             WHERE repository_id = ?1
+             RETURNING next_pull_request_number - 1",
+            [&access.repository.id],
+            |row| row.get(0),
+        )?;
+        insert_pull_request_intent(
+            &transaction,
+            intent,
+            &access.repository.id,
+            actor_id,
+            number,
+            1,
+            None,
+            "open",
+        )?;
+        transaction.commit()?;
+        Ok(PullRequestRefIntentRecord::from_new(
+            intent,
+            access.repository.id,
+            actor_id,
+            number,
+            1,
+            None,
+            "open",
+        ))
+    }
+
+    pub(crate) fn begin_pull_request_revision(
+        &mut self,
+        number: i64,
+        intent: &NewPullRequestRefIntent<'_>,
+    ) -> Result<PullRequestRefIntentRecord, StoreError> {
+        let transaction = self
+            .connection
+            .transaction_with_behavior(TransactionBehavior::Immediate)?;
+        let access = repository_issue_access(
+            &transaction,
+            intent.owner,
+            intent.repository,
+            Some(intent.actor),
+        )?;
+        if !access.can_read() {
+            return Err(StoreError::PullRequestHidden);
+        }
+        if !access.can_write_repository() {
+            return Err(StoreError::PullRequestDenied);
+        }
+        let actor_id = access
+            .active_actor_id
+            .ok_or(StoreError::PullRequestDenied)?;
+        let stored = transaction
+            .query_row(
+                "SELECT id, title, body, state, base_ref, head_ref, head_object_id,
+                        (SELECT COALESCE(MAX(number), 0) + 1
+                         FROM pull_request_revision
+                         WHERE pull_request_id = pull_request.id)
+                 FROM pull_request
+                 WHERE repository_id = ?1 AND number = ?2",
+                rusqlite::params![access.repository.id, number],
+                |row| {
+                    Ok((
+                        row.get::<_, String>(0)?,
+                        row.get::<_, String>(1)?,
+                        row.get::<_, String>(2)?,
+                        row.get::<_, String>(3)?,
+                        row.get::<_, String>(4)?,
+                        row.get::<_, String>(5)?,
+                        row.get::<_, String>(6)?,
+                        row.get::<_, i64>(7)?,
+                    ))
+                },
+            )
+            .optional()?
+            .ok_or_else(|| {
+                StoreError::PullRequestNotFound(
+                    intent.owner.to_owned(),
+                    intent.repository.to_owned(),
+                    number,
+                )
+            })?;
+        let (pull_request_id, title, body, state, base_ref, head_ref, old_head, revision) = stored;
+        if state != "open" {
+            return Err(StoreError::PullRequestState);
+        }
+        if pull_request_id != intent.pull_request_id
+            || title != intent.title
+            || body != intent.body
+            || base_ref != intent.base_ref
+            || head_ref != intent.head_ref
+        {
+            return Err(StoreError::PullRequestIntentState(intent.id.to_owned()));
+        }
+        insert_pull_request_intent(
+            &transaction,
+            intent,
+            &access.repository.id,
+            actor_id,
+            number,
+            revision,
+            Some(&old_head),
+            "revise",
+        )?;
+        transaction.commit()?;
+        Ok(PullRequestRefIntentRecord::from_new(
+            intent,
+            access.repository.id,
+            actor_id,
+            number,
+            revision,
+            Some(old_head),
+            "revise",
+        ))
+    }
+
+    pub(crate) fn complete_pull_request_ref_intent(&mut self, id: &str) -> Result<(), StoreError> {
+        let transaction = self
+            .connection
+            .transaction_with_behavior(TransactionBehavior::Immediate)?;
+        let intent = pull_request_intent(&transaction, id)?;
+        if intent.state != "pending" {
+            return Err(StoreError::PullRequestIntentState(id.to_owned()));
+        }
+        if intent.operation == "open" {
+            transaction.execute(
+                "INSERT INTO pull_request
+                 (id, repository_id, number, title, body, state, author_account_id,
+                  base_ref, head_ref, base_object_id, head_object_id, created_at, updated_at)
+                 VALUES (?1, ?2, ?3, ?4, ?5, 'open', ?6, ?7, ?8, ?9, ?10, ?11, ?11)",
+                rusqlite::params![
+                    intent.pull_request_id,
+                    intent.repository_id,
+                    intent.pull_request_number,
+                    intent.title,
+                    intent.body,
+                    intent.author_account_id,
+                    intent.base_ref,
+                    intent.head_ref,
+                    intent.base_object_id,
+                    intent.head_object_id,
+                    intent.created_at,
+                ],
+            )?;
+        } else {
+            let changed = transaction.execute(
+                "UPDATE pull_request
+                 SET base_object_id = ?2, head_object_id = ?3, updated_at = ?4
+                 WHERE id = ?1 AND state = 'open' AND head_object_id = ?5",
+                rusqlite::params![
+                    intent.pull_request_id,
+                    intent.base_object_id,
+                    intent.head_object_id,
+                    intent.created_at,
+                    intent.old_head_object_id,
+                ],
+            )?;
+            if changed != 1 {
+                return Err(StoreError::PullRequestIntentState(id.to_owned()));
+            }
+        }
+        let revision_id: String =
+            transaction.query_row("SELECT lower(hex(randomblob(16)))", [], |row| row.get(0))?;
+        transaction.execute(
+            "INSERT INTO pull_request_revision
+             (id, pull_request_id, number, author_account_id, base_object_id,
+              head_object_id, created_at)
+             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
+            rusqlite::params![
+                revision_id,
+                intent.pull_request_id,
+                intent.revision_number,
+                intent.author_account_id,
+                intent.base_object_id,
+                intent.head_object_id,
+                intent.created_at,
+            ],
+        )?;
+        let kind = if intent.operation == "open" {
+            event::EventKind::PullRequestCreated
+        } else {
+            event::EventKind::PullRequestRevised
+        };
+        let event = event::pull_request(
+            kind,
+            &intent.pull_request_id,
+            intent.pull_request_number,
+            intent.revision_number,
+            &intent.title,
+            &intent.base_ref,
+            &intent.head_ref,
+            &intent.base_object_id,
+            &intent.head_object_id,
+        );
+        insert_domain_event(
+            &transaction,
+            &NewDomainEvent {
+                repository_id: &intent.repository_id,
+                source_intent_id: None,
+                source_ordinal: None,
+                issue_id: None,
+                pull_request_id: Some(&intent.pull_request_id),
+                event: &event,
+                actor: &intent.actor,
+                ref_name: None,
+                old_target: None,
+                new_target: None,
+                created_at: intent.created_at,
+            },
+        )?;
+        transaction.execute(
+            "UPDATE pull_request_ref_intent SET state = 'completed'
+             WHERE id = ?1 AND state = 'pending'",
+            [id],
+        )?;
+        transaction.commit()?;
+        Ok(())
+    }
+
+    pub(crate) fn abandon_pull_request_ref_intent(&self, id: &str) -> Result<(), StoreError> {
+        let changed = self.connection.execute(
+            "UPDATE pull_request_ref_intent SET state = 'abandoned'
+             WHERE id = ?1 AND state = 'pending'",
+            [id],
+        )?;
+        if changed == 1 {
+            Ok(())
+        } else {
+            Err(StoreError::PullRequestIntentState(id.to_owned()))
+        }
+    }
+
+    pub(crate) fn incomplete_pull_request_ref_intents(
+        &self,
+    ) -> Result<Vec<PullRequestRefIntentRecord>, StoreError> {
+        let mut statement = self.connection.prepare(
+            "SELECT id, repository_id, pull_request_id, pull_request_number,
+                    revision_number, operation, title, body, author_account_id, actor,
+                    base_ref, head_ref, base_object_id, old_head_object_id,
+                    head_object_id, state, created_at
+             FROM pull_request_ref_intent WHERE state = 'pending'
+             ORDER BY created_at, id",
+        )?;
+        statement
+            .query_map([], pull_request_intent_from_row)?
+            .collect::<Result<Vec<_>, _>>()
+            .map_err(Into::into)
+    }
+
+    pub(crate) fn pull_request(
+        &self,
+        owner: &str,
+        repository: &str,
+        number: i64,
+        actor: Option<&str>,
+    ) -> Result<PullRequestDetail, StoreError> {
+        let access = repository_issue_access(&self.connection, owner, repository, actor)?;
+        if !access.can_read() {
+            return Err(StoreError::PullRequestHidden);
+        }
+        let pull_request = self
+            .connection
+            .query_row(
+                "SELECT pull_request.id, pull_request.number, pull_request.title,
+                        pull_request.body, pull_request.state, author.username,
+                        pull_request.base_ref, pull_request.head_ref,
+                        pull_request.base_object_id, pull_request.head_object_id,
+                        pull_request.created_at, pull_request.updated_at
+                 FROM pull_request
+                 JOIN account AS author ON author.id = pull_request.author_account_id
+                 WHERE pull_request.repository_id = ?1 AND pull_request.number = ?2",
+                rusqlite::params![access.repository.id, number],
+                pull_request_from_row,
+            )
+            .optional()?
+            .ok_or_else(|| {
+                StoreError::PullRequestNotFound(owner.to_owned(), repository.to_owned(), number)
+            })?;
+        let mut statement = self.connection.prepare(
+            "SELECT pull_request_revision.id, pull_request_revision.number,
+                    author.username, pull_request_revision.base_object_id,
+                    pull_request_revision.head_object_id, pull_request_revision.created_at
+             FROM pull_request_revision
+             JOIN account AS author ON author.id = pull_request_revision.author_account_id
+             WHERE pull_request_revision.pull_request_id = ?1
+             ORDER BY pull_request_revision.number",
+        )?;
+        let revisions = statement
+            .query_map([&pull_request.id], |row| {
+                Ok(PullRequestRevisionRecord {
+                    id: row.get(0)?,
+                    number: row.get(1)?,
+                    author: row.get(2)?,
+                    base_object_id: row.get(3)?,
+                    head_object_id: row.get(4)?,
+                    created_at: row.get(5)?,
+                })
+            })?
+            .collect::<Result<Vec<_>, _>>()?;
+        let can_revise = access.can_write_repository();
+        Ok(PullRequestDetail {
+            repository: access.repository,
+            pull_request,
+            revisions,
+            can_revise,
+        })
+    }
+
+    pub(crate) fn pull_requests(
+        &self,
+        owner: &str,
+        repository: &str,
+        actor: Option<&str>,
+    ) -> Result<(RepositoryRecord, Vec<PullRequestRecord>, bool), StoreError> {
+        let access = repository_issue_access(&self.connection, owner, repository, actor)?;
+        if !access.can_read() {
+            return Err(StoreError::PullRequestHidden);
+        }
+        let mut statement = self.connection.prepare(
+            "SELECT pull_request.id, pull_request.number, pull_request.title,
+                    pull_request.body, pull_request.state, author.username,
+                    pull_request.base_ref, pull_request.head_ref,
+                    pull_request.base_object_id, pull_request.head_object_id,
+                    pull_request.created_at, pull_request.updated_at
+             FROM pull_request
+             JOIN account AS author ON author.id = pull_request.author_account_id
+             WHERE pull_request.repository_id = ?1
+             ORDER BY pull_request.number DESC",
+        )?;
+        let pull_requests = statement
+            .query_map([&access.repository.id], pull_request_from_row)?
+            .collect::<Result<Vec<_>, _>>()?;
+        let can_create = access.can_write_repository();
+        Ok((access.repository, pull_requests, can_create))
     }
 
     pub(crate) fn edit_issue(
@@ -2639,6 +3017,108 @@
     pub(crate) created_at: i64,
 }
 
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub(crate) struct PullRequestRecord {
+    pub(crate) id: String,
+    pub(crate) number: i64,
+    pub(crate) title: String,
+    pub(crate) body: String,
+    pub(crate) state: String,
+    pub(crate) author: String,
+    pub(crate) base_ref: String,
+    pub(crate) head_ref: String,
+    pub(crate) base_object_id: String,
+    pub(crate) head_object_id: String,
+    pub(crate) created_at: i64,
+    pub(crate) updated_at: i64,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub(crate) struct PullRequestRevisionRecord {
+    pub(crate) id: String,
+    pub(crate) number: i64,
+    pub(crate) author: String,
+    pub(crate) base_object_id: String,
+    pub(crate) head_object_id: String,
+    pub(crate) created_at: i64,
+}
+
+pub(crate) struct PullRequestDetail {
+    pub(crate) repository: RepositoryRecord,
+    pub(crate) pull_request: PullRequestRecord,
+    pub(crate) revisions: Vec<PullRequestRevisionRecord>,
+    pub(crate) can_revise: bool,
+}
+
+pub(crate) struct NewPullRequestRefIntent<'a> {
+    pub(crate) id: &'a str,
+    pub(crate) pull_request_id: &'a str,
+    pub(crate) owner: &'a str,
+    pub(crate) repository: &'a str,
+    pub(crate) actor: &'a str,
+    pub(crate) title: &'a str,
+    pub(crate) body: &'a str,
+    pub(crate) base_ref: &'a str,
+    pub(crate) head_ref: &'a str,
+    pub(crate) base_object_id: &'a str,
+    pub(crate) head_object_id: &'a str,
+    pub(crate) created_at: i64,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub(crate) struct PullRequestRefIntentRecord {
+    pub(crate) id: String,
+    pub(crate) repository_id: String,
+    pub(crate) pull_request_id: String,
+    pub(crate) pull_request_number: i64,
+    pub(crate) revision_number: i64,
+    pub(crate) operation: String,
+    pub(crate) title: String,
+    pub(crate) body: String,
+    pub(crate) author_account_id: i64,
+    pub(crate) actor: String,
+    pub(crate) base_ref: String,
+    pub(crate) head_ref: String,
+    pub(crate) base_object_id: String,
+    pub(crate) old_head_object_id: Option<String>,
+    pub(crate) head_object_id: String,
+    pub(crate) state: String,
+    pub(crate) created_at: i64,
+}
+
+impl PullRequestRefIntentRecord {
+    #[allow(clippy::too_many_arguments)]
+    fn from_new(
+        intent: &NewPullRequestRefIntent<'_>,
+        repository_id: String,
+        author_account_id: i64,
+        pull_request_number: i64,
+        revision_number: i64,
+        old_head_object_id: Option<String>,
+        operation: &str,
+    ) -> Self {
+        Self {
+            id: intent.id.to_owned(),
+            repository_id,
+            pull_request_id: intent.pull_request_id.to_owned(),
+            pull_request_number,
+            revision_number,
+            operation: operation.to_owned(),
+            title: intent.title.to_owned(),
+            body: intent.body.to_owned(),
+            author_account_id,
+            actor: intent.actor.to_owned(),
+            base_ref: intent.base_ref.to_owned(),
+            head_ref: intent.head_ref.to_owned(),
+            base_object_id: intent.base_object_id.to_owned(),
+            old_head_object_id,
+            head_object_id: intent.head_object_id.to_owned(),
+            state: "pending".to_owned(),
+            created_at: intent.created_at,
+        }
+    }
+}
+
 pub(crate) struct IssueChange<'a> {
     pub(crate) owner: &'a str,
     pub(crate) repository: &'a str,
@@ -2825,6 +3305,14 @@
     fn can_maintain(&self) -> bool {
         self.can_read() && matches!(self.role.as_deref(), Some("owner" | "maintainer"))
     }
+
+    fn can_write_repository(&self) -> bool {
+        self.can_read()
+            && matches!(
+                self.role.as_deref(),
+                Some("owner" | "maintainer" | "writer")
+            )
+    }
 }
 
 struct StoredIssue {
@@ -2872,6 +3360,105 @@
         )),
         Err(error) => Err(error.into()),
     }
+}
+
+#[allow(clippy::too_many_arguments)]
+fn insert_pull_request_intent(
+    transaction: &rusqlite::Transaction<'_>,
+    intent: &NewPullRequestRefIntent<'_>,
+    repository_id: &str,
+    author_account_id: i64,
+    pull_request_number: i64,
+    revision_number: i64,
+    old_head_object_id: Option<&str>,
+    operation: &str,
+) -> Result<(), StoreError> {
+    transaction.execute(
+        "INSERT INTO pull_request_ref_intent
+         (id, repository_id, pull_request_id, pull_request_number, revision_number,
+          operation, title, body, author_account_id, actor, base_ref, head_ref,
+          base_object_id, old_head_object_id, head_object_id, state, created_at)
+         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12,
+                 ?13, ?14, ?15, 'pending', ?16)",
+        rusqlite::params![
+            intent.id,
+            repository_id,
+            intent.pull_request_id,
+            pull_request_number,
+            revision_number,
+            operation,
+            intent.title,
+            intent.body,
+            author_account_id,
+            intent.actor,
+            intent.base_ref,
+            intent.head_ref,
+            intent.base_object_id,
+            old_head_object_id,
+            intent.head_object_id,
+            intent.created_at,
+        ],
+    )?;
+    Ok(())
+}
+
+fn pull_request_intent(
+    connection: &Connection,
+    id: &str,
+) -> Result<PullRequestRefIntentRecord, StoreError> {
+    connection
+        .query_row(
+            "SELECT id, repository_id, pull_request_id, pull_request_number,
+                    revision_number, operation, title, body, author_account_id, actor,
+                    base_ref, head_ref, base_object_id, old_head_object_id,
+                    head_object_id, state, created_at
+             FROM pull_request_ref_intent WHERE id = ?1",
+            [id],
+            pull_request_intent_from_row,
+        )
+        .optional()?
+        .ok_or_else(|| StoreError::PullRequestIntentState(id.to_owned()))
+}
+
+fn pull_request_intent_from_row(
+    row: &rusqlite::Row<'_>,
+) -> rusqlite::Result<PullRequestRefIntentRecord> {
+    Ok(PullRequestRefIntentRecord {
+        id: row.get(0)?,
+        repository_id: row.get(1)?,
+        pull_request_id: row.get(2)?,
+        pull_request_number: row.get(3)?,
+        revision_number: row.get(4)?,
+        operation: row.get(5)?,
+        title: row.get(6)?,
+        body: row.get(7)?,
+        author_account_id: row.get(8)?,
+        actor: row.get(9)?,
+        base_ref: row.get(10)?,
+        head_ref: row.get(11)?,
+        base_object_id: row.get(12)?,
+        old_head_object_id: row.get(13)?,
+        head_object_id: row.get(14)?,
+        state: row.get(15)?,
+        created_at: row.get(16)?,
+    })
+}
+
+fn pull_request_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<PullRequestRecord> {
+    Ok(PullRequestRecord {
+        id: row.get(0)?,
+        number: row.get(1)?,
+        title: row.get(2)?,
+        body: row.get(3)?,
+        state: row.get(4)?,
+        author: row.get(5)?,
+        base_ref: row.get(6)?,
+        head_ref: row.get(7)?,
+        base_object_id: row.get(8)?,
+        head_object_id: row.get(9)?,
+        created_at: row.get(10)?,
+        updated_at: row.get(11)?,
+    })
 }
 
 fn validate_feed_scope(scope: &str, repository: Option<(&str, &str)>) -> Result<(), StoreError> {
@@ -3171,6 +3758,7 @@
             source_intent_id: None,
             source_ordinal: None,
             issue_id: Some(issue_id),
+            pull_request_id: None,
             event,
             actor,
             ref_name: None,
@@ -3186,6 +3774,7 @@
     source_intent_id: Option<&'a str>,
     source_ordinal: Option<i64>,
     issue_id: Option<&'a str>,
+    pull_request_id: Option<&'a str>,
     event: &'a event::VersionedEvent,
     actor: &'a str,
     ref_name: Option<&'a [u8]>,
@@ -3201,20 +3790,22 @@
     transaction.execute(
         "INSERT INTO repository_event
          (event_id, repository_id, sequence, source_intent_id, source_ordinal,
-          issue_id, kind, actor, ref_name, old_target, new_target, payload_version, payload,
+          issue_id, pull_request_id, kind, actor, ref_name, old_target, new_target,
+          payload_version, payload,
           created_at)
          VALUES (
              lower(hex(randomblob(16))),
              ?1,
              (SELECT COALESCE(MAX(sequence), 0) + 1
               FROM repository_event WHERE repository_id = ?1),
-             ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12
+             ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13
          )",
         rusqlite::params![
             event.repository_id,
             event.source_intent_id,
             event.source_ordinal,
             event.issue_id,
+            event.pull_request_id,
             event.event.kind.as_str(),
             event.actor,
             event.ref_name,
@@ -3277,6 +3868,7 @@
             source_intent_id: Some(intent_id),
             source_ordinal: Some(0),
             issue_id: None,
+            pull_request_id: None,
             event: &push,
             actor,
             ref_name: None,
@@ -3330,6 +3922,7 @@
                 source_intent_id: Some(intent_id),
                 source_ordinal: Some(ordinal),
                 issue_id: None,
+                pull_request_id: None,
                 event: &event,
                 actor,
                 ref_name: Some(&old_name),

templates/issue.html

Mode 100644100644; object d148103b95aeba864efceb81

@@ -7,6 +7,7 @@
       <a href="/{{ owner }}/{{ repository }}">Summary</a>
       <a href="/{{ owner }}/{{ repository }}/refs">Refs</a>
       <a href="/{{ owner }}/{{ repository }}/issues">Issues</a>
+      <a href="/{{ owner }}/{{ repository }}/pulls">Pull requests</a>
       <a href="/{{ owner }}/{{ repository }}/watch">Watch</a>
       <a href="/{{ owner }}/{{ repository }}/atom.xml">Atom</a>
       <a href="/{{ owner }}/{{ repository }}/rss.xml">RSS</a>

templates/issues.html

Mode 100644100644; object 23ba41473683eac929adcef6

@@ -7,6 +7,7 @@
       <a href="/{{ owner }}/{{ repository }}">Summary</a>
       <a href="/{{ owner }}/{{ repository }}/refs">Refs</a>
       <a href="/{{ owner }}/{{ repository }}/issues">Issues</a>
+      <a href="/{{ owner }}/{{ repository }}/pulls">Pull requests</a>
       <a href="/{{ owner }}/{{ repository }}/watch">Watch</a>
       <a href="/{{ owner }}/{{ repository }}/issues/atom.xml">Issue Atom</a>
       <a href="/{{ owner }}/{{ repository }}/issues/rss.xml">Issue RSS</a>

templates/pull_request.html

Mode 100644; object 59c8c719cb3a

@@ -1,0 +1,39 @@
+{% extends "base.html" %}
+{% block title %}#{{ pull_request.number }} {{ pull_request.title }} · {{ owner }}/{{ repository }} · tit{% endblock %}
+{% block content %}
+  <header class="repository-header">
+    <h1><a href="/{{ owner }}/{{ repository }}">{{ owner }}/{{ repository }}</a></h1>
+    <nav aria-label="Repository">
+      <a href="/{{ owner }}/{{ repository }}">Summary</a>
+      <a href="/{{ owner }}/{{ repository }}/refs">Refs</a>
+      <a href="/{{ owner }}/{{ repository }}/issues">Issues</a>
+      <a href="/{{ owner }}/{{ repository }}/pulls">Pull requests</a>
+      <a href="/{{ owner }}/{{ repository }}/watch">Watch</a>
+    </nav>
+  </header>
+
+  <article>
+    <h2>#{{ pull_request.number }} {{ pull_request.title }}</h2>
+    <p>{{ pull_request.state }} · opened by {{ pull_request.author }} at <time>{{ pull_request.created_at }}</time> · updated <time>{{ pull_request.updated_at }}</time></p>
+    <p>Base: <code>{{ pull_request.base_ref }}</code> at <code>{{ pull_request.base_object_id }}</code></p>
+    <p>Head: <code>{{ pull_request.head_ref }}</code> at <code>{{ pull_request.head_object_id }}</code></p>
+    <p>Fetch: <code>git fetch origin refs/pull/{{ pull_request.number }}/head</code></p>
+    <div class="markdown">{{ body_html|safe }}</div>
+  </article>
+
+  <section aria-labelledby="revisions-heading">
+    <h2 id="revisions-heading">Revisions</h2>
+    <ol>
+{% for revision in revisions %}
+      <li value="{{ revision.number }}">{{ revision.author }} recorded <code>{{ revision.head_object_id }}</code> against <code>{{ revision.base_object_id }}</code> at <time>{{ revision.created_at }}</time>.</li>
+{% endfor %}
+    </ol>
+  </section>
+
+{% if can_revise %}
+  <form method="post" action="/{{ owner }}/{{ repository }}/pulls/{{ pull_request.number }}/revisions">
+    <input type="hidden" name="csrf" value="{{ csrf }}">
+    <button type="submit">Record current branch heads</button>
+  </form>
+{% endif %}
+{% endblock %}

templates/pull_requests.html

Mode 100644; object 8a8f2a52666a

@@ -1,0 +1,53 @@
+{% extends "base.html" %}
+{% block title %}Pull requests · {{ owner }}/{{ repository }} · tit{% endblock %}
+{% block content %}
+  <header class="repository-header">
+    <h1><a href="/{{ owner }}/{{ repository }}">{{ owner }}/{{ repository }}</a></h1>
+    <nav aria-label="Repository">
+      <a href="/{{ owner }}/{{ repository }}">Summary</a>
+      <a href="/{{ owner }}/{{ repository }}/refs">Refs</a>
+      <a href="/{{ owner }}/{{ repository }}/issues">Issues</a>
+      <a href="/{{ owner }}/{{ repository }}/pulls">Pull requests</a>
+      <a href="/{{ owner }}/{{ repository }}/watch">Watch</a>
+    </nav>
+  </header>
+
+  <h2>Pull requests</h2>
+{% if pull_requests.is_empty() %}
+  <p>This repository has no pull requests.</p>
+{% else %}
+  <ol class="issue-list">
+{% for pull_request in pull_requests %}
+    <li><a href="/{{ owner }}/{{ repository }}/pulls/{{ pull_request.number }}">#{{ pull_request.number }} {{ pull_request.title }}</a> — {{ pull_request.state }}, opened by {{ pull_request.author }}, updated <time>{{ pull_request.updated_at }}</time></li>
+{% endfor %}
+  </ol>
+{% endif %}
+
+{% if can_create %}
+  <section aria-labelledby="new-pull-request-heading">
+    <h2 id="new-pull-request-heading">Open a pull request</h2>
+    <form method="post" action="/{{ owner }}/{{ repository }}/pulls">
+      <input type="hidden" name="csrf" value="{{ csrf }}">
+      <div class="field">
+        <label for="pull-request-title">Title</label>
+        <input id="pull-request-title" name="title" maxlength="200" required>
+      </div>
+      <div class="field">
+        <label for="pull-request-body">Description (Markdown)</label>
+        <textarea id="pull-request-body" name="body" maxlength="262144" rows="10"></textarea>
+      </div>
+      <div class="field">
+        <label for="pull-request-base">Base branch</label>
+        <input id="pull-request-base" name="base-ref" maxlength="1024" value="refs/heads/main" required>
+      </div>
+      <div class="field">
+        <label for="pull-request-head">Head branch</label>
+        <input id="pull-request-head" name="head-ref" maxlength="1024" placeholder="refs/heads/feature" required>
+      </div>
+      <button type="submit">Open pull request</button>
+    </form>
+  </section>
+{% else %}
+  <p>You need write access to open a pull request.</p>
+{% endif %}
+{% endblock %}

templates/repository.html

Mode 100644100644; object 5ae51356ec6b3ac49b6e683a

@@ -7,6 +7,7 @@
       <a href="/{{ owner }}/{{ repository }}">Summary</a>
       <a href="/{{ owner }}/{{ repository }}/refs">Refs</a>
       <a href="/{{ owner }}/{{ repository }}/issues">Issues</a>
+      <a href="/{{ owner }}/{{ repository }}/pulls">Pull requests</a>
       <a href="/{{ owner }}/{{ repository }}/watch">Watch</a>
       <a href="/{{ owner }}/{{ repository }}/atom.xml">Atom</a>
       <a href="/{{ owner }}/{{ repository }}/rss.xml">RSS</a>

templates/watch.html

Mode 100644100644; object 538479d92d60149f5b9092c3

@@ -7,6 +7,7 @@
       <a href="/{{ owner }}/{{ repository }}">Summary</a>
       <a href="/{{ owner }}/{{ repository }}/refs">Refs</a>
       <a href="/{{ owner }}/{{ repository }}/issues">Issues</a>
+      <a href="/{{ owner }}/{{ repository }}/pulls">Pull requests</a>
       <a href="/{{ owner }}/{{ repository }}/watch">Watch</a>
       <a href="/{{ owner }}/{{ repository }}/atom.xml">Atom</a>
       <a href="/{{ owner }}/{{ repository }}/rss.xml">RSS</a>

tests/cli.rs

Mode 100644100644; object f8429dd99777fd0b76536be0

@@ -23,7 +23,8 @@
     include_str!("../src/store/migrations/012_issues.sql"),
     include_str!("../src/store/migrations/013_watches.sql"),
     include_str!("../src/store/migrations/014_feed_tokens.sql"),
-    "PRAGMA user_version = 14;\n",
+    include_str!("../src/store/migrations/015_pull_requests.sql"),
+    "PRAGMA user_version = 15;\n",
 );
 
 #[test]

tests/git_repository.rs

Mode 100644100644; object f43c41209fcd578d3175bb9c

@@ -1,3 +1,4 @@
+#[allow(dead_code, reason = "the repository test exercises selected Git APIs")]
 #[path = "../src/git/repository.rs"]
 mod repository;
 

tests/public_routes.rs

Mode 100644100644; object 820d313f2a616ae83b3d5ab8

@@ -44,6 +44,12 @@
 mod policy;
 #[allow(
     dead_code,
+    reason = "the public-route test does not mutate pull requests"
+)]
+#[path = "../src/pull_request.rs"]
+mod pull_request;
+#[allow(
+    dead_code,
     reason = "the public route test does not create repositories through forms"
 )]
 #[path = "../src/repository.rs"]
@@ -729,6 +735,89 @@
     assert!(final_text.contains("Assignees: <span>alice</span>"));
     assert!(final_text.contains("issue-created"));
     assert!(final_text.contains("issue-reopened"));
+
+    let anonymous_pull_requests =
+        request(server.address(), "GET", "/alice/example/pulls", &[], &[]);
+    assert_eq!(anonymous_pull_requests.status, 200);
+    assert!(
+        anonymous_pull_requests
+            .text()
+            .contains("This repository has no pull requests.")
+    );
+    assert!(
+        !anonymous_pull_requests
+            .text()
+            .contains("Open a pull request</h2>")
+    );
+    let open_pull_request = form(&[
+        ("csrf", csrf.as_str()),
+        ("title", "Review the feature"),
+        ("body", "Keep **each revision**."),
+        ("base-ref", "refs/heads/main"),
+        ("head-ref", "refs/heads/feature"),
+    ]);
+    let opened_pull_request = request(
+        server.address(),
+        "POST",
+        "/alice/example/pulls",
+        &headers,
+        open_pull_request.as_bytes(),
+    );
+    assert_eq!(opened_pull_request.status, 303);
+    assert_eq!(
+        opened_pull_request.header("location"),
+        "/alice/example/pulls/1"
+    );
+    let pull_request_page = request(server.address(), "GET", "/alice/example/pulls/1", &[], &[]);
+    assert_eq!(pull_request_page.status, 200);
+    assert!(pull_request_page.text().contains("#1 Review the feature"));
+    assert!(
+        pull_request_page
+            .text()
+            .contains("<strong>each revision</strong>")
+    );
+    assert!(
+        pull_request_page
+            .text()
+            .contains("git fetch origin refs/pull/1/head")
+    );
+    let worktree = fixture.instance.path().join("worktree");
+    run(Command::new("git")
+        .arg("-C")
+        .arg(&worktree)
+        .args(["switch", "-q", "feature"]));
+    fs::write(worktree.join("pull-request.txt"), b"new revision\n")
+        .expect("write a pull-request revision");
+    commit_all(&worktree, "pull-request revision");
+    let bare = fixture
+        .instance
+        .path()
+        .join("repositories")
+        .join(format!("{}.git", fixture.repository_id));
+    run(Command::new("git")
+        .arg("-C")
+        .arg(&worktree)
+        .args(["push", "-q"])
+        .arg(&bare)
+        .arg("feature"));
+    let revision = form(&[("csrf", csrf.as_str())]);
+    let revised_pull_request = request(
+        server.address(),
+        "POST",
+        "/alice/example/pulls/1/revisions",
+        &headers,
+        revision.as_bytes(),
+    );
+    assert_eq!(revised_pull_request.status, 303);
+    let revised_pull_request_page =
+        request(server.address(), "GET", "/alice/example/pulls/1", &[], &[]);
+    assert_eq!(
+        revised_pull_request_page
+            .text()
+            .matches("recorded <code>")
+            .count(),
+        2
+    );
     let search_page = request(server.address(), "GET", "/search", &[], &[]);
     assert_eq!(search_page.status, 200);
     assert!(
@@ -1142,6 +1231,10 @@
         .expect("update the text file");
         commit_all(&worktree, "<script>alert(3)</script>");
         let head = rev_parse(&worktree, "HEAD");
+        run(Command::new("git")
+            .arg("-C")
+            .arg(&worktree)
+            .args(["branch", "feature"]));
 
         run(Command::new("git")
             .args(["init", "-q", "--bare", "--object-format", format])
@@ -1159,7 +1252,7 @@
             .arg(&worktree)
             .args(["push", "-q"])
             .arg(&bare)
-            .arg("main"));
+            .args(["main", "feature"]));
 
         let database = instance.path().join(store::DATABASE_FILE);
         let mut store = Store::open(&database).expect("open the fixture database");

tests/pull_requests.rs

Mode 100644; object b7cea1371527

@@ -1,0 +1,371 @@
+#[allow(
+    dead_code,
+    reason = "the pull-request test uses only identity validation"
+)]
+#[path = "../src/auth.rs"]
+mod auth;
+#[path = "../src/domain/mod.rs"]
+mod domain;
+#[allow(
+    dead_code,
+    reason = "the pull-request test uses part of the shared Git API"
+)]
+#[path = "../src/git/mod.rs"]
+mod git;
+#[allow(
+    dead_code,
+    reason = "the pull-request test uses repository policy through Git"
+)]
+#[path = "../src/policy.rs"]
+mod policy;
+#[path = "../src/pull_request.rs"]
+mod pull_request;
+#[allow(dead_code, reason = "the pull-request test uses part of the store API")]
+#[path = "../src/store/mod.rs"]
+mod store;
+
+use std::fs;
+use std::path::{Path, PathBuf};
+use std::process::Command;
+use std::sync::Arc;
+
+use git::repository::GitRepository;
+use pull_request::{PullRequestError, PullRequestService};
+use rusqlite::params;
+use store::{NewPullRequestRefIntent, Store, StoreError};
+use tempfile::TempDir;
+
+#[test]
+fn creates_revises_and_recovers_numbered_pull_request_refs_for_both_hashes() {
+    for (index, object_format) in ["sha1", "sha256"].into_iter().enumerate() {
+        let fixture = Fixture::new(object_format, index);
+        let service = PullRequestService::new(&fixture.database, &fixture.repositories);
+        let opened = service
+            .open(
+                "alice",
+                "project",
+                "alice",
+                "Add the feature",
+                "Keep the revision context.",
+                "refs/heads/main",
+                "refs/heads/feature",
+            )
+            .expect("open a pull request");
+        assert_eq!(opened.number, 1);
+        assert_eq!(fixture.pull_ref(1), opened.head_object_id);
+        run(
+            &fixture.worktree,
+            Command::new("git")
+                .args(["fetch", "-q"])
+                .arg(&fixture.bare)
+                .arg("refs/pull/1/head"),
+        );
+        assert_eq!(
+            rev_parse(&fixture.worktree, "FETCH_HEAD"),
+            opened.head_object_id
+        );
+        assert!(matches!(
+            service.open(
+                "alice",
+                "project",
+                "bob",
+                "Reader change",
+                "Readers cannot open pull requests.",
+                "refs/heads/main",
+                "refs/heads/feature",
+            ),
+            Err(PullRequestError::Store(StoreError::PullRequestDenied))
+        ));
+
+        let first = service
+            .get("alice", "project", 1, None)
+            .expect("read a public pull request");
+        assert_eq!(first.revisions.len(), 1);
+        assert_eq!(first.revisions[0].head_object_id, opened.head_object_id);
+
+        fixture.commit_feature("second feature revision");
+        let revised = service
+            .revise("alice", "project", 1, "alice")
+            .expect("revise a pull request");
+        assert_ne!(revised.head_object_id, opened.head_object_id);
+        assert_eq!(fixture.pull_ref(1), revised.head_object_id);
+        let second = service
+            .get("alice", "project", 1, Some("alice"))
+            .expect("read revised pull request");
+        assert_eq!(second.revisions.len(), 2);
+        assert_eq!(second.revisions[0].head_object_id, opened.head_object_id);
+        assert_eq!(second.revisions[1].head_object_id, revised.head_object_id);
+
+        let git = GitRepository::open(&fixture.bare).expect("open the bare fixture");
+        let base = git
+            .resolve_branch("refs/heads/main")
+            .expect("resolve the base");
+        let head = git
+            .resolve_branch("refs/heads/feature")
+            .expect("resolve the head");
+        let mut store = Store::open(&fixture.database).expect("open the store");
+        let pending = store
+            .begin_pull_request_open(&NewPullRequestRefIntent {
+                id: "10000000000000000000000000000000",
+                pull_request_id: "20000000000000000000000000000000",
+                owner: "alice",
+                repository: "project",
+                actor: "alice",
+                title: "Recover the ref",
+                body: "The intent exists before the ref.",
+                base_ref: "refs/heads/main",
+                head_ref: "refs/heads/feature",
+                base_object_id: &base.to_string(),
+                head_object_id: &head.to_string(),
+                created_at: 100,
+            })
+            .expect("begin a pending pull request");
+        assert_eq!(pending.pull_request_number, 2);
+        drop(store);
+        service.recover().expect("recover a pre-ref intent");
+        assert_eq!(fixture.pull_ref(2), head.to_string());
+        let recovered = service
+            .get("alice", "project", 2, None)
+            .expect("read the recovered pull request");
+        assert_eq!(recovered.revisions.len(), 1);
+
+        fixture.commit_feature("recovery after ref update");
+        let git = GitRepository::open(&fixture.bare).expect("reopen the bare fixture");
+        let next_base = git
+            .resolve_branch("refs/heads/main")
+            .expect("resolve the next base");
+        let next_head = git
+            .resolve_branch("refs/heads/feature")
+            .expect("resolve the next head");
+        let mut store = Store::open(&fixture.database).expect("reopen the store");
+        let pending_revision = store
+            .begin_pull_request_revision(
+                2,
+                &NewPullRequestRefIntent {
+                    id: "30000000000000000000000000000000",
+                    pull_request_id: "20000000000000000000000000000000",
+                    owner: "alice",
+                    repository: "project",
+                    actor: "alice",
+                    title: "Recover the ref",
+                    body: "The intent exists before the ref.",
+                    base_ref: "refs/heads/main",
+                    head_ref: "refs/heads/feature",
+                    base_object_id: &next_base.to_string(),
+                    head_object_id: &next_head.to_string(),
+                    created_at: 101,
+                },
+            )
+            .expect("begin a pending revision");
+        git.update_reference("refs/pull/2/head", Some(head), next_head)
+            .expect("apply the ref before metadata");
+        drop(store);
+        service.recover().expect("recover a post-ref intent");
+        let recovered_revision = service
+            .get("alice", "project", 2, None)
+            .expect("read the recovered revision");
+        assert_eq!(recovered_revision.revisions.len(), 2);
+        assert_eq!(
+            recovered_revision.revisions[1].number,
+            pending_revision.revision_number
+        );
+
+        let service = Arc::new(service);
+        let handles = ["Concurrent A", "Concurrent B"].map(|title| {
+            let service = Arc::clone(&service);
+            std::thread::spawn(move || {
+                service
+                    .open(
+                        "alice",
+                        "project",
+                        "alice",
+                        title,
+                        "Use one stable number.",
+                        "refs/heads/main",
+                        "refs/heads/feature",
+                    )
+                    .expect("open a concurrent pull request")
+                    .number
+            })
+        });
+        let mut numbers = handles.map(|handle| handle.join().expect("join an opener"));
+        numbers.sort_unstable();
+        assert_eq!(numbers, [3, 4]);
+        assert_eq!(fixture.pull_ref(3), next_head.to_string());
+        assert_eq!(fixture.pull_ref(4), next_head.to_string());
+
+        let event_kinds: Vec<String> = Store::open(&fixture.database)
+            .expect("open the event store")
+            .connection()
+            .prepare(
+                "SELECT kind FROM repository_event
+                 WHERE kind LIKE 'pull-request-%' ORDER BY sequence",
+            )
+            .expect("prepare the event query")
+            .query_map([], |row| row.get(0))
+            .expect("query pull-request events")
+            .collect::<Result<_, _>>()
+            .expect("read pull-request events");
+        assert_eq!(
+            event_kinds,
+            [
+                "pull-request-created",
+                "pull-request-revised",
+                "pull-request-created",
+                "pull-request-revised",
+                "pull-request-created",
+                "pull-request-created"
+            ]
+        );
+    }
+}
+
+struct Fixture {
+    _directory: TempDir,
+    database: PathBuf,
+    repositories: PathBuf,
+    worktree: PathBuf,
+    bare: PathBuf,
+}
+
+impl Fixture {
+    fn new(object_format: &str, index: usize) -> Self {
+        let directory = TempDir::new().expect("create a fixture directory");
+        let repositories = directory.path().join("repositories");
+        fs::create_dir(&repositories).expect("create a repository directory");
+        let repositories = fs::canonicalize(repositories).expect("canonicalize repositories");
+        let database = directory.path().join("tit.sqlite3");
+        let store = Store::open(&database).expect("create the database");
+        store
+            .connection()
+            .execute(
+                "INSERT INTO account
+                 (id, username, is_administrator, state, created_at)
+                 VALUES (1, 'alice', 1, 'active', 1)",
+                [],
+            )
+            .expect("create the owner");
+        store
+            .connection()
+            .execute(
+                "INSERT INTO account
+                 (id, username, is_administrator, state, created_at)
+                 VALUES (2, 'bob', 0, 'active', 1)",
+                [],
+            )
+            .expect("create a reader");
+        let repository_id = format!("{index:032x}");
+        store
+            .connection()
+            .execute(
+                "INSERT INTO repository
+                 (id, owner_account_id, slug, visibility, state, object_format, created_at)
+                 VALUES (?1, 1, 'project', 'public', 'active', ?2, 2)",
+                params![repository_id, object_format],
+            )
+            .expect("create repository metadata");
+        store
+            .connection()
+            .execute(
+                "INSERT INTO repository_collaborator
+                 (repository_id, account_id, role, created_at)
+                 VALUES (?1, 2, 'reader', 2)",
+                [&repository_id],
+            )
+            .expect("grant reader access");
+        drop(store);
+
+        let worktree = directory.path().join("worktree");
+        run(
+            directory.path(),
+            Command::new("git")
+                .args(["init", "-q", "-b", "main", "--object-format", object_format])
+                .arg(&worktree),
+        );
+        fs::write(worktree.join("README.md"), b"base\n").expect("write base content");
+        git_commit(&worktree, "base");
+        run(
+            &worktree,
+            Command::new("git").args(["switch", "-q", "-c", "feature"]),
+        );
+        fs::write(worktree.join("feature.txt"), b"feature\n").expect("write feature content");
+        git_commit(&worktree, "feature");
+        let bare = repositories.join(format!("{repository_id}.git"));
+        run(
+            directory.path(),
+            Command::new("git")
+                .args(["clone", "-q", "--bare"])
+                .arg(&worktree)
+                .arg(&bare),
+        );
+        Self {
+            _directory: directory,
+            database,
+            repositories,
+            worktree,
+            bare,
+        }
+    }
+
+    fn commit_feature(&self, message: &str) {
+        fs::write(self.worktree.join("feature.txt"), format!("{message}\n"))
+            .expect("write revised feature content");
+        git_commit(&self.worktree, message);
+        run(
+            &self.worktree,
+            Command::new("git")
+                .args(["push", "-q"])
+                .arg(&self.bare)
+                .arg("feature"),
+        );
+    }
+
+    fn pull_ref(&self, number: i64) -> String {
+        GitRepository::open(&self.bare)
+            .expect("open the bare fixture")
+            .reference_target(&format!("refs/pull/{number}/head"))
+            .expect("read a pull-request ref")
+            .expect("find a pull-request ref")
+            .to_string()
+    }
+}
+
+fn git_commit(worktree: &Path, message: &str) {
+    run(worktree, Command::new("git").args(["add", "."]));
+    let mut command = Command::new("git");
+    command
+        .args(["commit", "-q", "-m", message])
+        .env("GIT_AUTHOR_NAME", "Tit Test")
+        .env("GIT_AUTHOR_EMAIL", "tit@example.test")
+        .env("GIT_COMMITTER_NAME", "Tit Test")
+        .env("GIT_COMMITTER_EMAIL", "tit@example.test")
+        .env("GIT_CONFIG_COUNT", "1")
+        .env("GIT_CONFIG_KEY_0", "commit.gpgsign")
+        .env("GIT_CONFIG_VALUE_0", "false");
+    run(worktree, &mut command);
+}
+
+fn rev_parse(repository: &Path, revision: &str) -> String {
+    let output = Command::new("git")
+        .args(["rev-parse", revision])
+        .current_dir(repository)
+        .output()
+        .expect("resolve a fixture revision");
+    assert!(output.status.success(), "resolve a fixture revision");
+    String::from_utf8(output.stdout)
+        .expect("read a fixture object ID")
+        .trim()
+        .to_owned()
+}
+
+fn run(directory: &Path, command: &mut Command) {
+    let output = command
+        .current_dir(directory)
+        .output()
+        .expect("run a fixture command");
+    assert!(
+        output.status.success(),
+        "fixture command failed: {}",
+        String::from_utf8_lossy(&output.stderr)
+    );
+}

tests/sqlite.rs

Mode 100644100644; object 99363a0f2034f93ba12b080d

@@ -1,3 +1,4 @@
+#[allow(dead_code, reason = "the storage test exercises selected store APIs")]
 #[path = "../src/store/mod.rs"]
 mod store;
 
@@ -66,6 +67,17 @@
     include_str!("../src/store/migrations/012_issues.sql"),
     include_str!("../src/store/migrations/013_watches.sql"),
     "PRAGMA user_version = 13;\n",
+);
+const V14_FIXTURE: &str = concat!(
+    include_str!("fixtures/sqlite/v7.sql"),
+    include_str!("../src/store/migrations/008_web_sessions.sql"),
+    include_str!("../src/store/migrations/009_repository_authorization.sql"),
+    include_str!("../src/store/migrations/010_audit_history.sql"),
+    include_str!("../src/store/migrations/011_domain_events.sql"),
+    include_str!("../src/store/migrations/012_issues.sql"),
+    include_str!("../src/store/migrations/013_watches.sql"),
+    include_str!("../src/store/migrations/014_feed_tokens.sql"),
+    "PRAGMA user_version = 14;\n",
 );
 
 fn database(directory: &TempDir, name: &str) -> std::path::PathBuf {
@@ -184,7 +196,7 @@
     let directory = TempDir::new().expect("create a temporary directory");
     let store = Store::open(&database(&directory, "store.sqlite")).expect("open the store");
 
-    assert_eq!(store.schema_version().expect("read the schema version"), 14);
+    assert_eq!(store.schema_version().expect("read the schema version"), 15);
     assert_eq!(
         store
             .connection()
@@ -1546,13 +1558,14 @@
         (V11_FIXTURE, 11),
         (V12_FIXTURE, 12),
         (V13_FIXTURE, 13),
+        (V14_FIXTURE, 14),
     ] {
         let directory = TempDir::new().expect("create a temporary directory");
         let path = database(&directory, "tit.sqlite3");
         create_fixture(&path, fixture);
 
         let store = Store::open(&path).expect("migrate the fixture");
-        assert_eq!(store.schema_version().expect("read the schema version"), 14);
+        assert_eq!(store.schema_version().expect("read the schema version"), 15);
         store.integrity_check().expect("check migrated integrity");
         let state: String = store
             .connection()
@@ -1625,7 +1638,7 @@
 
 #[test]
 fn recovers_complete_schema_versions_after_a_process_kill_during_migration() {
-    for (mode, expected_version) in [("migration-uncommitted", 1), ("migration-committed", 14)] {
+    for (mode, expected_version) in [("migration-uncommitted", 1), ("migration-committed", 15)] {
         let directory = TempDir::new().expect("create a temporary directory");
         let path = database(&directory, "fixture.sqlite");
         create_fixture(&path, V1_FIXTURE);

tests/web_shell.rs

Mode 100644100644; object 167a67e6aa7ded0322cb7412

@@ -40,6 +40,9 @@
 #[allow(dead_code, reason = "the Web shell test has no repository catalog")]
 #[path = "../src/policy.rs"]
 mod policy;
+#[allow(dead_code, reason = "the Web shell test does not use pull requests")]
+#[path = "../src/pull_request.rs"]
+mod pull_request;
 #[allow(dead_code, reason = "the Web shell test does not create repositories")]
 #[path = "../src/repository.rs"]
 mod repository;