michal/tit
Browse tree · Show commit · Download archive
Diff
0de923f9b007 → 66c575b0a117
README.md
Mode 100644 → 100644; object 1e9f5f1ca5c9 → db597178f0bf
@@ -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 100644 → 100644; object 4a813d991157 → fd8f4f6926b4
@@ -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 100644 → 100644; object 3adddd388cc6 → 4295f119092f
@@ -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 100644 → 100644; object 75668e8aa829 → 2fa36ba8f9f1
@@ -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 100644 → 100644; object a17845a01076 → 6fca20235d53
@@ -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(¤t.base_ref)?;
+ let head = git.resolve_branch(¤t.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: ¤t.id,
+ owner,
+ repository,
+ actor,
+ title: ¤t.title,
+ body: ¤t.body,
+ base_ref: ¤t.base_ref,
+ head_ref: ¤t.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 100644 → 100644; object b618fda981f2 → 3c82df98548a
@@ -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 100644 → 100644; object dfec264efd1e → 2fd644f2bd56
@@ -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 100644 → 100644; object 6b9c5adaa79c → 78abc9d772a5
@@ -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 100644 → 100644; object d148103b95ae → ba864efceb81
@@ -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 100644 → 100644; object 23ba41473683 → eac929adcef6
@@ -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 100644 → 100644; object 5ae51356ec6b → 3ac49b6e683a
@@ -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 100644 → 100644; object 538479d92d60 → 149f5b9092c3
@@ -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 100644 → 100644; object f8429dd99777 → fd0b76536be0
@@ -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 100644 → 100644; object f43c41209fcd → 578d3175bb9c
@@ -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 100644 → 100644; object 820d313f2a61 → 6ae83b3d5ab8
@@ -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 100644 → 100644; object 99363a0f2034 → f93ba12b080d
@@ -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 100644 → 100644; object 167a67e6aa7d → ed0322cb7412
@@ -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;