michal/tit
Browse tree · Show commit · Download archive
Diff
26309595c22d → 7c1635ae5ecf
README.md
Mode 100644 → 100644; object 9d37740daab8 → 2b2c46c24258
@@ -306,3 +306,17 @@ recovery, feed parsing, and sequence pagination. Read the [repository event service architectural decision record](docs/adr/0016-repository-event-service.md) for the event type and payload contracts. + +## Milestone 4.2 gate + +Run the issue workflow gate: + +```text +./scripts/check-m4-2 +``` + +This command tests issue numbers, raw Markdown storage, safe rendering, roles, +comments, state, labels, assignees, the event timeline, transaction rollback, +sessions, CSRF checks, and forms that operate without JavaScript. Read the +[issue workflow architectural decision record](docs/adr/0017-issue-workflow.md) +for the permission and event contracts.
assets/style.css
Mode 100644 → 100644; object 7763f704b376 → a914c92a65b0
@@ -103,6 +103,7 @@
}
input,
+textarea,
select,
button {
max-width: 100%;
@@ -116,6 +117,11 @@
input {
width: 100%;
+}
+
+textarea {
+ width: 100%;
+ resize: vertical;
}
button {
docs/adr/0016-repository-event-service.md
Mode 100644 → 100644; object b450b606f6d0 → d17a00a4512b
@@ -32,6 +32,9 @@ `repository-imported`, `push`, `ref-created`, `ref-updated`, `ref-deleted`, `tag-created`, `tag-updated`, and `tag-deleted`. +Architectural decision record 0017 adds the version 1 issue event types to this +service. + Store `payload_version` as an explicit schema value and store `version` in each JSON payload. The schema accepts only a JSON object whose inner version equals the column value. Limit a payload to 1 MiB. Version 1 repository payloads have
docs/adr/0017-issue-workflow.md
Mode → 100644; object → 840a5bc5aef2
@@ -1,0 +1,88 @@ +# Architectural decision record 0017: Issue workflow + +Status: Accepted + +Date: 2026-07-22 + +## Context + +An issue needs a stable repository number, a Markdown source, comments, labels, +assignees, state changes, and one chronological timeline. An issue mutation and +its event must not have different results. + +The repository roles do not have a separate triage role. The issue workflow +must use the existing roles and must not add a second permission system. + +## Decision + +Store issues and comments in SQLite. Give each issue and comment a random, +non-reassignable ID. Allocate an increasing issue number from one repository +counter. Use the issue number in the public URL: + +```text +/OWNER/REPOSITORY/issues/NUMBER +``` + +Store the exact Markdown source from the request. Render the supported subset +only when the server makes the HTML page. Limit an issue title to 200 bytes. +Limit an issue body or comment to 256 KiB. Reject control characters except +tab and line terminators in Markdown bodies. + +Use these permissions: + +- An authenticated account that can read the repository can create an issue + and add a comment. +- The issue author, an owner, a maintainer, or a writer can edit, close, or + reopen the issue. +- An owner or maintainer can add or remove labels and assignees. +- An assignee must be active and must be able to read the repository. + +Run the permission query, metadata mutation, and event insert in one immediate +SQLite transaction. Use the repository event sequence as the issue timeline +order. Do not add a separate timeline table. + +Add these version 1 event types: `issue-created`, `issue-edited`, +`issue-commented`, `issue-closed`, `issue-reopened`, `issue-labeled`, +`issue-unlabeled`, `issue-assigned`, and `issue-unassigned`. Each event payload +has the issue ID and issue number. An event also has the data that identifies +its change, such as the comment ID, label, assignee, state, title, or body. + +Keep labels in one repository. Compare label names without ASCII case +differences. Keep a label record after its last removal so a subsequent use has +the same identity. + +The Web interface uses server-rendered pages and normal HTML forms. Each +mutation requires an active session and a matching CSRF value. Private issue +reads use the same repository read rule as Git and repository pages. + +## Failure and threat cases + +An invalid repository, issue number, title, body, label, state, or assignee does +not change the database. A suspended account cannot mutate an issue. An +unauthorized account cannot learn whether an issue exists in a private +repository from a read response. + +A database constraint rejects a repeated issue number, an invalid state, an +invalid ID, an invalid label relation, or an event without its issue. If event +insertion fails, SQLite rolls back the issue mutation. The event payload limit +is larger than the permitted Markdown source after JSON escaping of accepted +characters. + +## Evidence + +Storage tests run the complete workflow with reader, writer, maintainer, owner, +stranger, and suspended-account boundaries. They verify increasing numbers, +exact Markdown source, labels, assignees, comments, state, event payloads, and +timeline order. An injected event failure proves that a comment and its event +roll back together. + +A production HTTP test creates, reads, edits, comments on, labels, assigns, +closes, and reopens an issue. It also verifies CSRF rejection, safe Markdown +rendering, the no-JavaScript form flow, and the repository Atom projection. + +## Consequences + +Issue pages and later SSH commands can use one issue service. Watches and +private feeds can select issue events from the repository event stream. A +subsequent triage role requires an explicit change to the common permission +contract.
scripts/check-m4-2
Mode → 100755; object → 1e5e6d6b38e5
@@ -1,0 +1,6 @@ +#!/bin/sh +set -eu + +./scripts/check +cargo test --locked --release --test sqlite runs_the_issue_workflow_with_atomic_events_and_repository_roles +cargo test --locked --release --test public_routes runs_the_complete_issue_workflow_without_javascript
src/feed.rs
Mode 100644 → 100644; object cb163878fc69 → 561ba501b4e0
@@ -143,8 +143,47 @@
"tag-created" => format!("Tag {reference} created"),
"tag-updated" => format!("Tag {reference} updated"),
"tag-deleted" => format!("Tag {reference} deleted"),
+ "issue-created" => issue_title(event, "opened"),
+ "issue-edited" => issue_title(event, "edited"),
+ "issue-commented" => issue_title(event, "commented on"),
+ "issue-closed" => issue_title(event, "closed"),
+ "issue-reopened" => issue_title(event, "reopened"),
+ "issue-labeled" => issue_value_title(event, "added label", "label"),
+ "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"),
_ => "Repository event".to_owned(),
}
+}
+
+fn issue_title(event: &RepositoryEventRecord, action: &str) -> String {
+ let number = issue_payload(event)
+ .and_then(|payload| payload.get("number")?.as_i64())
+ .map(|number| format!("#{number}"))
+ .unwrap_or_else(|| "Issue".to_owned());
+ format!("{} {action} {number}", event.actor)
+}
+
+fn issue_value_title(event: &RepositoryEventRecord, action: &str, field: &str) -> String {
+ let Some(payload) = issue_payload(event) else {
+ return issue_title(event, action);
+ };
+ let number = payload
+ .get("number")
+ .and_then(serde_json::Value::as_i64)
+ .map(|number| format!("#{number}"))
+ .unwrap_or_else(|| "issue".to_owned());
+ let value = payload
+ .get(field)
+ .and_then(serde_json::Value::as_str)
+ .unwrap_or("unknown");
+ format!("{} {action} {value} on {number}", event.actor)
+}
+
+fn issue_payload(event: &RepositoryEventRecord) -> Option<serde_json::Value> {
+ (event.payload_version == 1)
+ .then(|| serde_json::from_str(&event.payload).ok())
+ .flatten()
}
fn event_description(event: &RepositoryEventRecord) -> String {
src/http/issues.rs
Mode → 100644; object → 2cdf1b1a33e3
@@ -1,0 +1,591 @@
+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::issue::{IssueError, IssueService};
+use crate::markdown::{self, RenderedMarkdown};
+use crate::store::{IssueDetail, StoreError};
+
+use super::{
+ CSRF_COOKIE, RequestActor, RequestId, SESSION_COOKIE, WebState, cookie, login_job,
+ login_redirect, parse_named_form, render, render_error,
+};
+
+const MAX_ISSUE_REQUEST_BYTES: usize = 300 * 1024;
+
+pub(super) fn routes() -> Router<WebState> {
+ Router::new()
+ .route(
+ "/{owner}/{repository}/issues",
+ get(issue_list).post(create_issue),
+ )
+ .route("/{owner}/{repository}/issues/{number}", get(issue_detail))
+ .route(
+ "/{owner}/{repository}/issues/{number}/edit",
+ post(edit_issue),
+ )
+ .route(
+ "/{owner}/{repository}/issues/{number}/comments",
+ post(comment_issue),
+ )
+ .route(
+ "/{owner}/{repository}/issues/{number}/state",
+ post(change_state),
+ )
+ .route(
+ "/{owner}/{repository}/issues/{number}/labels",
+ post(change_label),
+ )
+ .route(
+ "/{owner}/{repository}/issues/{number}/assignees",
+ post(change_assignee),
+ )
+ .layer(DefaultBodyLimit::max(MAX_ISSUE_REQUEST_BYTES))
+}
+
+async fn issue_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.issues.clone() else {
+ return issue_read_error(
+ IssueError::Store(StoreError::Integrity(
+ "issue service is unavailable".to_owned(),
+ )),
+ &request_id.0,
+ );
+ };
+ let owner = path.owner.clone();
+ let repository = path.repository.clone();
+ let authenticated = actor.0.is_some();
+ let result = issue_job(state, move || {
+ service.list(&owner, &repository, actor.0.as_deref())
+ })
+ .await;
+ match result {
+ Ok((record, issues)) => {
+ let csrf = cookie(&headers, CSRF_COOKIE).unwrap_or_default();
+ render(
+ StatusCode::OK,
+ &IssueListTemplate {
+ request_id: &request_id.0,
+ owner: &record.owner,
+ repository: &record.slug,
+ issues: issues
+ .iter()
+ .map(|issue| IssueListItem {
+ number: issue.number,
+ title: &issue.title,
+ state: &issue.state,
+ author: &issue.author,
+ updated_at: issue.updated_at,
+ })
+ .collect(),
+ csrf: &csrf,
+ can_create: authenticated && !csrf.is_empty(),
+ },
+ )
+ }
+ Err(error) => issue_read_error(error, &request_id.0),
+ }
+}
+
+async fn issue_detail(
+ State(state): State<WebState>,
+ Extension(request_id): Extension<RequestId>,
+ Extension(actor): Extension<RequestActor>,
+ Path(path): Path<IssuePath>,
+ headers: HeaderMap,
+) -> Response {
+ let Some(service) = state.issues.clone() else {
+ return issue_read_error(
+ IssueError::Store(StoreError::Integrity(
+ "issue service is unavailable".to_owned(),
+ )),
+ &request_id.0,
+ );
+ };
+ let owner = path.owner.clone();
+ let repository = path.repository.clone();
+ let number = path.number;
+ let result = issue_job(state, move || {
+ service.get(&owner, &repository, number, actor.0.as_deref())
+ })
+ .await;
+ match result {
+ Ok(detail) => render_issue(&request_id.0, &headers, &detail),
+ Err(error) => issue_read_error(error, &request_id.0),
+ }
+}
+
+async fn create_issue(
+ 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"]) {
+ Ok(fields) => fields,
+ Err(()) => return issue_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.issues.clone() else {
+ return issue_internal(&request_id.0);
+ };
+ let owner = path.owner.clone();
+ let repository = path.repository.clone();
+ let result = issue_job(state, move || {
+ service.create(&owner, &repository, &actor, &fields[1], &fields[2])
+ })
+ .await;
+ match result {
+ Ok(issue) => issue_redirect(&path.owner, &path.repository, issue.number),
+ Err(error) => issue_mutation_error(error, &request_id.0),
+ }
+}
+
+async fn edit_issue(
+ State(state): State<WebState>,
+ Extension(request_id): Extension<RequestId>,
+ Path(path): Path<IssuePath>,
+ headers: HeaderMap,
+ body: Bytes,
+) -> Response {
+ let fields = match parse_named_form(&headers, &body, &["csrf", "title", "body"]) {
+ Ok(fields) => fields,
+ Err(()) => return issue_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,
+ };
+ mutate(state, request_id, path, move |service, path| {
+ service.edit(
+ &path.owner,
+ &path.repository,
+ path.number,
+ &actor,
+ &fields[1],
+ &fields[2],
+ )
+ })
+ .await
+}
+
+async fn comment_issue(
+ State(state): State<WebState>,
+ Extension(request_id): Extension<RequestId>,
+ Path(path): Path<IssuePath>,
+ headers: HeaderMap,
+ body: Bytes,
+) -> Response {
+ let fields = match parse_named_form(&headers, &body, &["csrf", "body"]) {
+ Ok(fields) => fields,
+ Err(()) => return issue_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,
+ };
+ mutate(state, request_id, path, move |service, path| {
+ service
+ .comment(
+ &path.owner,
+ &path.repository,
+ path.number,
+ &actor,
+ &fields[1],
+ )
+ .map(|_| ())
+ })
+ .await
+}
+
+async fn change_state(
+ State(state): State<WebState>,
+ Extension(request_id): Extension<RequestId>,
+ Path(path): Path<IssuePath>,
+ headers: HeaderMap,
+ body: Bytes,
+) -> Response {
+ let fields = match parse_named_form(&headers, &body, &["csrf", "state"]) {
+ Ok(fields) => fields,
+ Err(()) => return issue_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,
+ };
+ mutate(state, request_id, path, move |service, path| {
+ service.set_state(
+ &path.owner,
+ &path.repository,
+ path.number,
+ &actor,
+ &fields[1],
+ )
+ })
+ .await
+}
+
+async fn change_label(
+ State(state): State<WebState>,
+ Extension(request_id): Extension<RequestId>,
+ Path(path): Path<IssuePath>,
+ headers: HeaderMap,
+ body: Bytes,
+) -> Response {
+ let fields = match parse_named_form(&headers, &body, &["csrf", "label", "operation"]) {
+ Ok(fields) => fields,
+ Err(()) => return issue_bad_request(&request_id.0),
+ };
+ let present = match operation(&fields[2]) {
+ Ok(present) => present,
+ Err(()) => return issue_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,
+ };
+ mutate(state, request_id, path, move |service, path| {
+ service.set_label(
+ &path.owner,
+ &path.repository,
+ path.number,
+ &actor,
+ &fields[1],
+ present,
+ )
+ })
+ .await
+}
+
+async fn change_assignee(
+ State(state): State<WebState>,
+ Extension(request_id): Extension<RequestId>,
+ Path(path): Path<IssuePath>,
+ headers: HeaderMap,
+ body: Bytes,
+) -> Response {
+ let fields = match parse_named_form(&headers, &body, &["csrf", "assignee", "operation"]) {
+ Ok(fields) => fields,
+ Err(()) => return issue_bad_request(&request_id.0),
+ };
+ let present = match operation(&fields[2]) {
+ Ok(present) => present,
+ Err(()) => return issue_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,
+ };
+ mutate(state, request_id, path, move |service, path| {
+ service.set_assignee(
+ &path.owner,
+ &path.repository,
+ path.number,
+ &actor,
+ &fields[1],
+ present,
+ )
+ })
+ .await
+}
+
+async fn mutate(
+ state: WebState,
+ request_id: RequestId,
+ path: IssuePath,
+ operation: impl FnOnce(IssueService, &IssuePath) -> Result<(), IssueError> + Send + 'static,
+) -> Response {
+ let Some(service) = state.issues.clone() else {
+ return issue_internal(&request_id.0);
+ };
+ let redirect_owner = path.owner.clone();
+ let redirect_repository = path.repository.clone();
+ let redirect_number = path.number;
+ let result = issue_job(state, move || operation(service, &path)).await;
+ match result {
+ Ok(()) => issue_redirect(&redirect_owner, &redirect_repository, redirect_number),
+ Err(error) => issue_mutation_error(error, &request_id.0),
+ }
+}
+
+async fn authenticate_mutation(
+ state: WebState,
+ headers: &HeaderMap,
+ submitted_csrf: &str,
+ request_id: &str,
+) -> Result<String, Response> {
+ let Some(session_token) = cookie(headers, SESSION_COOKIE) else {
+ return Err(login_redirect(false));
+ };
+ let Some(csrf) = cookie(headers, CSRF_COOKIE) else {
+ return Err(login_redirect(true));
+ };
+ if submitted_csrf != csrf {
+ return Err(render_error(
+ StatusCode::FORBIDDEN,
+ request_id,
+ "Forbidden",
+ "The request is not authorized.",
+ ));
+ }
+ login_job(state, move |login| {
+ login.authenticate(&session_token, Some(&csrf))
+ })
+ .await
+ .map(|session| session.username)
+ .map_err(|_| login_redirect(true))
+}
+
+async fn issue_job<T: Send + 'static>(
+ state: WebState,
+ operation: impl FnOnce() -> Result<T, IssueError> + Send + 'static,
+) -> Result<T, IssueError> {
+ let permit = state.jobs.acquire_owned().await.map_err(|_| {
+ IssueError::Store(StoreError::Integrity(
+ "issue worker pool is unavailable".to_owned(),
+ ))
+ })?;
+ tokio::task::spawn_blocking(move || {
+ let _permit = permit;
+ operation()
+ })
+ .await
+ .map_err(|_| IssueError::Store(StoreError::Integrity("issue worker failed".to_owned())))?
+}
+
+fn operation(value: &str) -> Result<bool, ()> {
+ match value {
+ "add" => Ok(true),
+ "remove" => Ok(false),
+ _ => Err(()),
+ }
+}
+
+fn render_issue(request_id: &str, headers: &HeaderMap, detail: &IssueDetail) -> Response {
+ let csrf = cookie(headers, CSRF_COOKIE).unwrap_or_default();
+ render(
+ StatusCode::OK,
+ &IssueTemplate {
+ request_id,
+ owner: &detail.repository.owner,
+ repository: &detail.repository.slug,
+ number: detail.issue.number,
+ title: &detail.issue.title,
+ body: &detail.issue.body,
+ body_html: markdown::render(&detail.issue.body),
+ state: &detail.issue.state,
+ author: &detail.issue.author,
+ created_at: detail.issue.created_at,
+ updated_at: detail.issue.updated_at,
+ labels: &detail.labels,
+ assignees: &detail.assignees,
+ comments: detail
+ .comments
+ .iter()
+ .map(|comment| CommentView {
+ id: &comment.id,
+ author: &comment.author,
+ body_html: markdown::render(&comment.body),
+ created_at: comment.created_at,
+ })
+ .collect(),
+ timeline: detail
+ .timeline
+ .iter()
+ .map(|event| TimelineView {
+ sequence: event.sequence,
+ kind: &event.kind,
+ actor: &event.actor,
+ created_at: event.created_at,
+ })
+ .collect(),
+ csrf: &csrf,
+ can_comment: detail.can_comment && !csrf.is_empty(),
+ can_edit: detail.can_edit && !csrf.is_empty(),
+ can_maintain: detail.can_maintain && !csrf.is_empty(),
+ is_open: detail.issue.state == "open",
+ },
+ )
+}
+
+fn issue_read_error(error: IssueError, request_id: &str) -> Response {
+ match error {
+ IssueError::Store(
+ StoreError::RepositoryNotFound(_, _)
+ | StoreError::IssueNotFound(_, _, _)
+ | StoreError::IssueDenied
+ | StoreError::IssueHidden,
+ )
+ | IssueError::Auth(_)
+ | IssueError::RepositoryName(_)
+ | IssueError::Number => render_error(
+ StatusCode::NOT_FOUND,
+ request_id,
+ "Not found",
+ "The issue was not found.",
+ ),
+ _ => issue_internal(request_id),
+ }
+}
+
+fn issue_mutation_error(error: IssueError, request_id: &str) -> Response {
+ match error {
+ IssueError::Auth(_)
+ | IssueError::RepositoryName(_)
+ | IssueError::Number
+ | IssueError::Title
+ | IssueError::Body
+ | IssueError::State
+ | IssueError::Label => issue_bad_request(request_id),
+ IssueError::Store(StoreError::IssueDenied) => render_error(
+ StatusCode::FORBIDDEN,
+ request_id,
+ "Forbidden",
+ "The issue change is not authorized.",
+ ),
+ IssueError::Store(
+ StoreError::IssueState(_)
+ | StoreError::IssueLabelState
+ | StoreError::IssueAssigneeState,
+ ) => render_error(
+ StatusCode::CONFLICT,
+ request_id,
+ "Issue conflict",
+ "The issue already has the requested state.",
+ ),
+ IssueError::Store(
+ StoreError::RepositoryNotFound(_, _)
+ | StoreError::IssueNotFound(_, _, _)
+ | StoreError::IssueAssigneeNotFound(_)
+ | StoreError::IssueHidden,
+ ) => render_error(
+ StatusCode::NOT_FOUND,
+ request_id,
+ "Not found",
+ "The issue or account was not found.",
+ ),
+ _ => issue_internal(request_id),
+ }
+}
+
+fn issue_bad_request(request_id: &str) -> Response {
+ render_error(
+ StatusCode::BAD_REQUEST,
+ request_id,
+ "Issue error",
+ "The issue request is not valid.",
+ )
+}
+
+fn issue_internal(request_id: &str) -> Response {
+ render_error(
+ StatusCode::INTERNAL_SERVER_ERROR,
+ request_id,
+ "Issue error",
+ "The issue request could not be completed.",
+ )
+}
+
+fn issue_redirect(owner: &str, repository: &str, number: i64) -> Response {
+ Response::builder()
+ .status(StatusCode::SEE_OTHER)
+ .header(
+ header::LOCATION,
+ format!("/{owner}/{repository}/issues/{number}"),
+ )
+ .header(header::CACHE_CONTROL, "no-store")
+ .body(axum::body::Body::empty())
+ .expect("the issue redirect is valid")
+}
+
+#[derive(Clone, Deserialize)]
+struct RepositoryPath {
+ owner: String,
+ repository: String,
+}
+
+#[derive(Clone, Deserialize)]
+struct IssuePath {
+ owner: String,
+ repository: String,
+ number: i64,
+}
+
+#[derive(Template)]
+#[template(path = "issues.html")]
+struct IssueListTemplate<'a> {
+ request_id: &'a str,
+ owner: &'a str,
+ repository: &'a str,
+ issues: Vec<IssueListItem<'a>>,
+ csrf: &'a str,
+ can_create: bool,
+}
+
+struct IssueListItem<'a> {
+ number: i64,
+ title: &'a str,
+ state: &'a str,
+ author: &'a str,
+ updated_at: i64,
+}
+
+#[derive(Template)]
+#[template(path = "issue.html")]
+struct IssueTemplate<'a> {
+ request_id: &'a str,
+ owner: &'a str,
+ repository: &'a str,
+ number: i64,
+ title: &'a str,
+ body: &'a str,
+ body_html: RenderedMarkdown,
+ state: &'a str,
+ author: &'a str,
+ created_at: i64,
+ updated_at: i64,
+ labels: &'a [String],
+ assignees: &'a [String],
+ comments: Vec<CommentView<'a>>,
+ timeline: Vec<TimelineView<'a>>,
+ csrf: &'a str,
+ can_comment: bool,
+ can_edit: bool,
+ can_maintain: bool,
+ is_open: bool,
+}
+
+struct CommentView<'a> {
+ id: &'a str,
+ author: &'a str,
+ body_html: RenderedMarkdown,
+ created_at: i64,
+}
+
+struct TimelineView<'a> {
+ sequence: i64,
+ kind: &'a str,
+ actor: &'a str,
+ created_at: i64,
+}
src/http/mod.rs
Mode 100644 → 100644; object a2e3d8caea9b → 327ccf15f8de
@@ -1,3 +1,4 @@
+mod issues;
mod public;
use std::net::SocketAddr;
@@ -20,6 +21,7 @@
use crate::account::{AccountError, AccountService};
use crate::auth::validate_username;
use crate::domain::repository::validate_slug;
+use crate::issue::IssueService;
use crate::repository::{RepositoryService, RepositoryServiceError};
use crate::session::{SessionError, WebLoginService};
use crate::store::StoreError;
@@ -42,6 +44,7 @@
key_reloader: Option<AccountKeyReloader>,
login: Option<WebLoginService>,
repositories: Option<RepositoryService>,
+ issues: Option<IssueService>,
secure_cookies: bool,
}
@@ -74,6 +77,7 @@
key_reloader: None,
login: None,
repositories: None,
+ issues: None,
secure_cookies: false,
},
)
@@ -109,6 +113,7 @@
let login = WebLoginService::new(database, &public_url)?;
let public = PublicWeb::open(config, Arc::clone(&jobs))?;
let repositories = RepositoryService::new(public.database(), public.repository_root());
+ let issues = IssueService::new(public.database());
Self::start_with_state(
address,
WebState {
@@ -118,6 +123,7 @@
key_reloader,
login: Some(login),
repositories: Some(repositories),
+ issues: Some(issues),
secure_cookies,
},
)
@@ -161,15 +167,19 @@
key_reloader: None,
login: None,
repositories: None,
+ issues: None,
secure_cookies: false,
})
}
fn router_with_state(state: WebState) -> Router {
- let repository_routes = public::routes().layer(middleware::from_fn_with_state(
- state.clone(),
- repository_actor,
- ));
+ let repository_routes =
+ issues::routes()
+ .merge(public::routes())
+ .layer(middleware::from_fn_with_state(
+ state.clone(),
+ repository_actor,
+ ));
Router::new()
.route("/", get(home))
.route("/go", get(go_to_repository))
src/issue.rs
Mode → 100644; object → 761ea5ff0bdf
@@ -1,0 +1,273 @@
+use std::path::{Path, PathBuf};
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use thiserror::Error;
+
+use crate::auth::{AuthError, validate_username};
+use crate::domain::repository::{RepositoryNameError, validate_slug};
+use crate::store::{
+ IssueChange, IssueDetail, IssueRecord, NewIssue, RepositoryRecord, Store, StoreError,
+};
+
+const MAX_TITLE_BYTES: usize = 200;
+const MAX_BODY_BYTES: usize = 256 * 1024;
+const MAX_LABEL_BYTES: usize = 80;
+
+#[derive(Clone)]
+pub(crate) struct IssueService {
+ database: PathBuf,
+}
+
+impl IssueService {
+ pub(crate) fn new(database: &Path) -> Self {
+ Self {
+ database: database.to_owned(),
+ }
+ }
+
+ pub(crate) fn create(
+ &self,
+ owner: &str,
+ repository: &str,
+ actor: &str,
+ title: &str,
+ body: &str,
+ ) -> Result<IssueRecord, IssueError> {
+ validate_context(owner, repository, Some(actor))?;
+ validate_title(title)?;
+ validate_body(body, true)?;
+ let mut store = Store::open(&self.database)?;
+ store
+ .create_issue(&NewIssue {
+ owner,
+ repository,
+ actor,
+ title,
+ body,
+ created_at: timestamp()?,
+ })
+ .map_err(Into::into)
+ }
+
+ pub(crate) fn list(
+ &self,
+ owner: &str,
+ repository: &str,
+ actor: Option<&str>,
+ ) -> Result<(RepositoryRecord, Vec<IssueRecord>), IssueError> {
+ validate_context(owner, repository, actor)?;
+ Store::open(&self.database)?
+ .issues(owner, repository, actor)
+ .map_err(Into::into)
+ }
+
+ pub(crate) fn get(
+ &self,
+ owner: &str,
+ repository: &str,
+ number: i64,
+ actor: Option<&str>,
+ ) -> Result<IssueDetail, IssueError> {
+ validate_context(owner, repository, actor)?;
+ validate_number(number)?;
+ Store::open(&self.database)?
+ .issue_detail(owner, repository, number, actor)
+ .map_err(Into::into)
+ }
+
+ pub(crate) fn edit(
+ &self,
+ owner: &str,
+ repository: &str,
+ number: i64,
+ actor: &str,
+ title: &str,
+ body: &str,
+ ) -> Result<(), IssueError> {
+ validate_context(owner, repository, Some(actor))?;
+ validate_number(number)?;
+ validate_title(title)?;
+ validate_body(body, true)?;
+ Store::open(&self.database)?
+ .edit_issue(
+ &IssueChange {
+ owner,
+ repository,
+ number,
+ actor,
+ changed_at: timestamp()?,
+ },
+ title,
+ body,
+ )
+ .map_err(Into::into)
+ }
+
+ pub(crate) fn comment(
+ &self,
+ owner: &str,
+ repository: &str,
+ number: i64,
+ actor: &str,
+ body: &str,
+ ) -> Result<String, IssueError> {
+ validate_context(owner, repository, Some(actor))?;
+ validate_number(number)?;
+ validate_body(body, false)?;
+ Store::open(&self.database)?
+ .comment_issue(owner, repository, number, actor, body, timestamp()?)
+ .map_err(Into::into)
+ }
+
+ pub(crate) fn set_state(
+ &self,
+ owner: &str,
+ repository: &str,
+ number: i64,
+ actor: &str,
+ state: &str,
+ ) -> Result<(), IssueError> {
+ validate_context(owner, repository, Some(actor))?;
+ validate_number(number)?;
+ if !matches!(state, "open" | "closed") {
+ return Err(IssueError::State);
+ }
+ Store::open(&self.database)?
+ .set_issue_state(owner, repository, number, actor, state, timestamp()?)
+ .map_err(Into::into)
+ }
+
+ pub(crate) fn set_label(
+ &self,
+ owner: &str,
+ repository: &str,
+ number: i64,
+ actor: &str,
+ label: &str,
+ present: bool,
+ ) -> Result<(), IssueError> {
+ validate_context(owner, repository, Some(actor))?;
+ validate_number(number)?;
+ validate_label(label)?;
+ Store::open(&self.database)?
+ .set_issue_label(
+ &IssueChange {
+ owner,
+ repository,
+ number,
+ actor,
+ changed_at: timestamp()?,
+ },
+ label,
+ present,
+ )
+ .map_err(Into::into)
+ }
+
+ pub(crate) fn set_assignee(
+ &self,
+ owner: &str,
+ repository: &str,
+ number: i64,
+ actor: &str,
+ assignee: &str,
+ present: bool,
+ ) -> Result<(), IssueError> {
+ validate_context(owner, repository, Some(actor))?;
+ validate_number(number)?;
+ validate_username(assignee)?;
+ Store::open(&self.database)?
+ .set_issue_assignee(
+ &IssueChange {
+ owner,
+ repository,
+ number,
+ actor,
+ changed_at: timestamp()?,
+ },
+ assignee,
+ present,
+ )
+ .map_err(Into::into)
+ }
+}
+
+fn validate_context(owner: &str, repository: &str, actor: Option<&str>) -> Result<(), IssueError> {
+ validate_username(owner)?;
+ validate_slug(repository)?;
+ if let Some(actor) = actor {
+ validate_username(actor)?;
+ }
+ Ok(())
+}
+
+fn validate_number(number: i64) -> Result<(), IssueError> {
+ if number < 1 {
+ return Err(IssueError::Number);
+ }
+ Ok(())
+}
+
+fn validate_title(title: &str) -> Result<(), IssueError> {
+ if title.is_empty()
+ || title.len() > MAX_TITLE_BYTES
+ || title.trim() != title
+ || title.chars().any(char::is_control)
+ {
+ return Err(IssueError::Title);
+ }
+ Ok(())
+}
+
+fn validate_body(body: &str, empty_ok: bool) -> Result<(), IssueError> {
+ if body.len() > MAX_BODY_BYTES
+ || (!empty_ok && body.trim().is_empty())
+ || body
+ .chars()
+ .any(|character| character.is_control() && !matches!(character, '\n' | '\r' | '\t'))
+ {
+ return Err(IssueError::Body);
+ }
+ Ok(())
+}
+
+fn validate_label(label: &str) -> Result<(), IssueError> {
+ if label.is_empty()
+ || label.len() > MAX_LABEL_BYTES
+ || label.trim() != label
+ || label.chars().any(char::is_control)
+ {
+ return Err(IssueError::Label);
+ }
+ Ok(())
+}
+
+fn timestamp() -> Result<i64, IssueError> {
+ let seconds = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map_err(|_| IssueError::Clock)?
+ .as_secs();
+ i64::try_from(seconds).map_err(|_| IssueError::Clock)
+}
+
+#[derive(Debug, Error)]
+pub(crate) enum IssueError {
+ #[error(transparent)]
+ Auth(#[from] AuthError),
+ #[error(transparent)]
+ RepositoryName(#[from] RepositoryNameError),
+ #[error(transparent)]
+ Store(#[from] StoreError),
+ #[error("issue number is not valid")]
+ Number,
+ #[error("issue title is not valid")]
+ Title,
+ #[error("issue body is not valid")]
+ Body,
+ #[error("issue state is not valid")]
+ State,
+ #[error("issue label is not valid")]
+ Label,
+ #[error("system time is not valid")]
+ Clock,
+}
src/main.rs
Mode 100644 → 100644; object 0891b158adb8 → e5632abc59ed
@@ -16,6 +16,7 @@ #[allow(dead_code, reason = "the server uses only part of the shared HTTP API")] mod http; mod instance; +mod issue; mod markdown; mod policy; mod repository;
src/store/event.rs
Mode 100644 → 100644; object 001aea2a97b6 → dfec264efd1e
@@ -13,6 +13,15 @@
TagCreated,
TagUpdated,
TagDeleted,
+ IssueCreated,
+ IssueEdited,
+ IssueCommented,
+ IssueClosed,
+ IssueReopened,
+ IssueLabeled,
+ IssueUnlabeled,
+ IssueAssigned,
+ IssueUnassigned,
}
impl EventKind {
@@ -27,6 +36,15 @@
Self::TagCreated => "tag-created",
Self::TagUpdated => "tag-updated",
Self::TagDeleted => "tag-deleted",
+ Self::IssueCreated => "issue-created",
+ Self::IssueEdited => "issue-edited",
+ Self::IssueCommented => "issue-commented",
+ Self::IssueClosed => "issue-closed",
+ Self::IssueReopened => "issue-reopened",
+ Self::IssueLabeled => "issue-labeled",
+ Self::IssueUnlabeled => "issue-unlabeled",
+ Self::IssueAssigned => "issue-assigned",
+ Self::IssueUnassigned => "issue-unassigned",
}
}
}
@@ -91,6 +109,119 @@
"name_hex": encode_hex(name),
"old_target": old_target,
"new_target": new_target,
+ })
+ .to_string(),
+ }
+}
+
+pub(super) fn issue(
+ kind: EventKind,
+ issue_id: &str,
+ number: i64,
+ title: &str,
+ body: &str,
+) -> VersionedEvent {
+ debug_assert!(matches!(
+ kind,
+ EventKind::IssueCreated | EventKind::IssueEdited
+ ));
+ VersionedEvent {
+ kind,
+ payload: json!({
+ "version": PAYLOAD_VERSION,
+ "issue_id": issue_id,
+ "number": number,
+ "title": title,
+ "body": body,
+ })
+ .to_string(),
+ }
+}
+
+pub(super) fn issue_comment(
+ issue_id: &str,
+ number: i64,
+ comment_id: &str,
+ author: &str,
+ body: &str,
+) -> VersionedEvent {
+ VersionedEvent {
+ kind: EventKind::IssueCommented,
+ payload: json!({
+ "version": PAYLOAD_VERSION,
+ "issue_id": issue_id,
+ "number": number,
+ "comment_id": comment_id,
+ "author": author,
+ "body": body,
+ })
+ .to_string(),
+ }
+}
+
+pub(super) fn issue_state(
+ kind: EventKind,
+ issue_id: &str,
+ number: i64,
+ state: &str,
+) -> VersionedEvent {
+ debug_assert!(matches!(
+ kind,
+ EventKind::IssueClosed | EventKind::IssueReopened
+ ));
+ VersionedEvent {
+ kind,
+ payload: json!({
+ "version": PAYLOAD_VERSION,
+ "issue_id": issue_id,
+ "number": number,
+ "state": state,
+ })
+ .to_string(),
+ }
+}
+
+pub(super) fn issue_label(
+ kind: EventKind,
+ issue_id: &str,
+ number: i64,
+ label_id: &str,
+ label: &str,
+) -> VersionedEvent {
+ debug_assert!(matches!(
+ kind,
+ EventKind::IssueLabeled | EventKind::IssueUnlabeled
+ ));
+ VersionedEvent {
+ kind,
+ payload: json!({
+ "version": PAYLOAD_VERSION,
+ "issue_id": issue_id,
+ "number": number,
+ "label_id": label_id,
+ "label": label,
+ })
+ .to_string(),
+ }
+}
+
+pub(super) fn issue_assignee(
+ kind: EventKind,
+ issue_id: &str,
+ number: i64,
+ assignee: &str,
+) -> VersionedEvent {
+ debug_assert!(matches!(
+ kind,
+ EventKind::IssueAssigned | EventKind::IssueUnassigned
+ ));
+ VersionedEvent {
+ kind,
+ payload: json!({
+ "version": PAYLOAD_VERSION,
+ "issue_id": issue_id,
+ "number": number,
+ "assignee": assignee,
})
.to_string(),
}
src/store/migrations/012_issues.sql
Mode → 100644; object → 3949ce7ba4df
@@ -1,0 +1,185 @@
+CREATE TABLE repository_counter (
+ repository_id TEXT PRIMARY KEY
+ REFERENCES repository (id) ON DELETE RESTRICT,
+ next_issue_number INTEGER NOT NULL DEFAULT 1
+ CHECK (next_issue_number >= 1),
+ next_pull_request_number INTEGER NOT NULL DEFAULT 1
+ CHECK (next_pull_request_number >= 1)
+) STRICT;
+
+CREATE TABLE issue (
+ 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')),
+ author_account_id INTEGER NOT NULL
+ REFERENCES account (id) ON DELETE RESTRICT,
+ created_at INTEGER NOT NULL CHECK (created_at >= 0),
+ updated_at INTEGER NOT NULL CHECK (updated_at >= created_at),
+ closed_at INTEGER CHECK (closed_at IS NULL OR closed_at >= created_at),
+ UNIQUE (repository_id, number),
+ CHECK (
+ (state = 'open' AND closed_at IS NULL)
+ OR (state = 'closed' AND closed_at IS NOT NULL)
+ )
+) STRICT;
+
+CREATE INDEX issue_repository_state
+ON issue (repository_id, state, number DESC);
+
+CREATE TABLE issue_comment (
+ id TEXT PRIMARY KEY
+ CHECK (
+ length(id) = 32
+ AND id = lower(id)
+ AND id NOT GLOB '*[^0-9a-f]*'
+ ),
+ issue_id TEXT NOT NULL
+ REFERENCES issue (id) ON DELETE RESTRICT,
+ author_account_id INTEGER NOT NULL
+ REFERENCES account (id) ON DELETE RESTRICT,
+ body TEXT NOT NULL
+ CHECK (length(CAST(body AS BLOB)) BETWEEN 1 AND 262144),
+ created_at INTEGER NOT NULL CHECK (created_at >= 0)
+) STRICT;
+
+CREATE INDEX issue_comment_history
+ON issue_comment (issue_id, created_at, id);
+
+CREATE TABLE label (
+ 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,
+ name TEXT NOT NULL CHECK (length(CAST(name AS BLOB)) BETWEEN 1 AND 80),
+ created_at INTEGER NOT NULL CHECK (created_at >= 0)
+) STRICT;
+
+CREATE UNIQUE INDEX label_repository_name
+ON label (repository_id, name COLLATE NOCASE);
+
+CREATE TABLE issue_label (
+ issue_id TEXT NOT NULL
+ REFERENCES issue (id) ON DELETE RESTRICT,
+ label_id TEXT NOT NULL
+ REFERENCES label (id) ON DELETE RESTRICT,
+ actor_account_id INTEGER NOT NULL
+ REFERENCES account (id) ON DELETE RESTRICT,
+ created_at INTEGER NOT NULL CHECK (created_at >= 0),
+ PRIMARY KEY (issue_id, label_id)
+) STRICT;
+
+CREATE INDEX issue_label_label
+ON issue_label (label_id, issue_id);
+
+CREATE TABLE issue_assignee (
+ issue_id TEXT NOT NULL
+ REFERENCES issue (id) ON DELETE RESTRICT,
+ account_id INTEGER NOT NULL
+ REFERENCES account (id) ON DELETE RESTRICT,
+ actor_account_id INTEGER NOT NULL
+ REFERENCES account (id) ON DELETE RESTRICT,
+ created_at INTEGER NOT NULL CHECK (created_at >= 0),
+ PRIMARY KEY (issue_id, account_id)
+) STRICT;
+
+CREATE INDEX issue_assignee_account
+ON issue_assignee (account_id, issue_id);
+
+DROP INDEX repository_event_feed;
+ALTER TABLE repository_event RENAME TO repository_event_v11;
+
+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,
+ 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'
+ )),
+ 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 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 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 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, 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,
+ NULL, kind, actor, ref_name, old_target, new_target, payload_version, payload,
+ created_at
+FROM repository_event_v11
+ORDER BY id;
+
+DROP TABLE repository_event_v11;
+
+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;
src/store/mod.rs
Mode 100644 → 100644; object b05170aa762a → be514e0df138
@@ -11,7 +11,7 @@
const BUSY_TIMEOUT: Duration = Duration::from_secs(5);
const BUSY_TIMEOUT_MILLISECONDS: i64 = 5_000;
-const SCHEMA_VERSION: i64 = 11;
+const SCHEMA_VERSION: i64 = 12;
#[allow(
dead_code,
reason = "the integration test imports this module without the CLI operation"
@@ -21,7 +21,7 @@
dead_code,
reason = "M1A proves migrations before the M2 server calls them"
)]
-const MIGRATIONS: [&str; 11] = [
+const MIGRATIONS: [&str; 12] = [
include_str!("migrations/001_initial.sql"),
include_str!("migrations/002_state.sql"),
include_str!("migrations/003_git_intents.sql"),
@@ -33,6 +33,7 @@
include_str!("migrations/009_repository_authorization.sql"),
include_str!("migrations/010_audit_history.sql"),
include_str!("migrations/011_domain_events.sql"),
+ include_str!("migrations/012_issues.sql"),
];
#[allow(
@@ -115,6 +116,20 @@
EventPayload,
#[error("audit event page limit is too large")]
AuditLimit,
+ #[error("issue does not exist: {0}/{1}#{2}")]
+ IssueNotFound(String, String, i64),
+ #[error("issue access is not authorized")]
+ IssueDenied,
+ #[error("issue is hidden by repository access policy")]
+ IssueHidden,
+ #[error("issue state is already {0}")]
+ IssueState(String),
+ #[error("issue label already has the requested state")]
+ IssueLabelState,
+ #[error("issue assignee already has the requested state")]
+ IssueAssigneeState,
+ #[error("issue assignee does not exist or cannot read the repository: {0}")]
+ IssueAssigneeNotFound(String),
}
pub(crate) struct Store {
@@ -951,6 +966,7 @@
repository_id: repository.id,
source_intent_id: None,
source_ordinal: None,
+ issue_id: None,
event: &event,
actor: repository.owner,
ref_name: None,
@@ -975,6 +991,7 @@
repository_id: repository.id,
source_intent_id: None,
source_ordinal: None,
+ issue_id: None,
event: &event,
actor: repository.owner,
ref_name: Some(&reference.name),
@@ -1351,6 +1368,477 @@
}
}
+ pub(crate) fn create_issue(&mut self, issue: &NewIssue<'_>) -> Result<IssueRecord, StoreError> {
+ let transaction = self
+ .connection
+ .transaction_with_behavior(TransactionBehavior::Immediate)?;
+ let access = repository_issue_access(
+ &transaction,
+ issue.owner,
+ issue.repository,
+ Some(issue.actor),
+ )?;
+ if !access.can_read() {
+ return Err(StoreError::IssueHidden);
+ }
+ let actor_id = access.active_actor_id.ok_or(StoreError::IssueDenied)?;
+ 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_issue_number = next_issue_number + 1
+ WHERE repository_id = ?1
+ RETURNING next_issue_number - 1",
+ [&access.repository.id],
+ |row| row.get(0),
+ )?;
+ let id: String =
+ transaction.query_row("SELECT lower(hex(randomblob(16)))", [], |row| row.get(0))?;
+ transaction.execute(
+ "INSERT INTO issue
+ (id, repository_id, number, title, body, state, author_account_id,
+ created_at, updated_at)
+ VALUES (?1, ?2, ?3, ?4, ?5, 'open', ?6, ?7, ?7)",
+ rusqlite::params![
+ id,
+ access.repository.id,
+ number,
+ issue.title,
+ issue.body,
+ actor_id,
+ issue.created_at,
+ ],
+ )?;
+ let event = event::issue(
+ event::EventKind::IssueCreated,
+ &id,
+ number,
+ issue.title,
+ issue.body,
+ );
+ insert_domain_event(
+ &transaction,
+ &NewDomainEvent {
+ repository_id: &access.repository.id,
+ source_intent_id: None,
+ source_ordinal: None,
+ issue_id: Some(&id),
+ event: &event,
+ actor: issue.actor,
+ ref_name: None,
+ old_target: None,
+ new_target: None,
+ created_at: issue.created_at,
+ },
+ )?;
+ transaction.commit()?;
+ Ok(IssueRecord {
+ id,
+ number,
+ title: issue.title.to_owned(),
+ body: issue.body.to_owned(),
+ state: "open".to_owned(),
+ author: issue.actor.to_owned(),
+ created_at: issue.created_at,
+ updated_at: issue.created_at,
+ closed_at: None,
+ })
+ }
+
+ pub(crate) fn edit_issue(
+ &mut self,
+ change: &IssueChange<'_>,
+ title: &str,
+ body: &str,
+ ) -> Result<(), StoreError> {
+ let transaction = self
+ .connection
+ .transaction_with_behavior(TransactionBehavior::Immediate)?;
+ let (access, current) = issue_mutation_context(
+ &transaction,
+ change.owner,
+ change.repository,
+ change.number,
+ change.actor,
+ )?;
+ if !access.can_write_issue(current.author_account_id) {
+ return Err(StoreError::IssueDenied);
+ }
+ transaction.execute(
+ "UPDATE issue SET title = ?2, body = ?3, updated_at = ?4 WHERE id = ?1",
+ rusqlite::params![current.issue.id, title, body, change.changed_at],
+ )?;
+ let event = event::issue(
+ event::EventKind::IssueEdited,
+ ¤t.issue.id,
+ change.number,
+ title,
+ body,
+ );
+ insert_issue_event(
+ &transaction,
+ &access.repository.id,
+ ¤t.issue.id,
+ change.actor,
+ change.changed_at,
+ &event,
+ )?;
+ transaction.commit()?;
+ Ok(())
+ }
+
+ pub(crate) fn comment_issue(
+ &mut self,
+ owner: &str,
+ repository: &str,
+ number: i64,
+ actor: &str,
+ body: &str,
+ created_at: i64,
+ ) -> Result<String, StoreError> {
+ let transaction = self
+ .connection
+ .transaction_with_behavior(TransactionBehavior::Immediate)?;
+ let (access, current) =
+ issue_mutation_context(&transaction, owner, repository, number, actor)?;
+ let actor_id = access.active_actor_id.ok_or(StoreError::IssueDenied)?;
+ if !access.can_read() {
+ return Err(StoreError::IssueDenied);
+ }
+ let comment_id: String =
+ transaction.query_row("SELECT lower(hex(randomblob(16)))", [], |row| row.get(0))?;
+ transaction.execute(
+ "INSERT INTO issue_comment
+ (id, issue_id, author_account_id, body, created_at)
+ VALUES (?1, ?2, ?3, ?4, ?5)",
+ rusqlite::params![comment_id, current.issue.id, actor_id, body, created_at],
+ )?;
+ transaction.execute(
+ "UPDATE issue SET updated_at = ?2 WHERE id = ?1",
+ rusqlite::params![current.issue.id, created_at],
+ )?;
+ let event = event::issue_comment(¤t.issue.id, number, &comment_id, actor, body);
+ insert_issue_event(
+ &transaction,
+ &access.repository.id,
+ ¤t.issue.id,
+ actor,
+ created_at,
+ &event,
+ )?;
+ transaction.commit()?;
+ Ok(comment_id)
+ }
+
+ pub(crate) fn set_issue_state(
+ &mut self,
+ owner: &str,
+ repository: &str,
+ number: i64,
+ actor: &str,
+ state: &str,
+ changed_at: i64,
+ ) -> Result<(), StoreError> {
+ let transaction = self
+ .connection
+ .transaction_with_behavior(TransactionBehavior::Immediate)?;
+ let (access, current) =
+ issue_mutation_context(&transaction, owner, repository, number, actor)?;
+ if !access.can_write_issue(current.author_account_id) {
+ return Err(StoreError::IssueDenied);
+ }
+ if current.issue.state == state {
+ return Err(StoreError::IssueState(state.to_owned()));
+ }
+ let (closed_at, kind) = match state {
+ "closed" => (Some(changed_at), event::EventKind::IssueClosed),
+ "open" => (None, event::EventKind::IssueReopened),
+ _ => return Err(StoreError::IssueState(state.to_owned())),
+ };
+ transaction.execute(
+ "UPDATE issue
+ SET state = ?2, updated_at = ?3, closed_at = ?4
+ WHERE id = ?1",
+ rusqlite::params![current.issue.id, state, changed_at, closed_at],
+ )?;
+ let event = event::issue_state(kind, ¤t.issue.id, number, state);
+ insert_issue_event(
+ &transaction,
+ &access.repository.id,
+ ¤t.issue.id,
+ actor,
+ changed_at,
+ &event,
+ )?;
+ transaction.commit()?;
+ Ok(())
+ }
+
+ pub(crate) fn set_issue_label(
+ &mut self,
+ change: &IssueChange<'_>,
+ label: &str,
+ present: bool,
+ ) -> Result<(), StoreError> {
+ let transaction = self
+ .connection
+ .transaction_with_behavior(TransactionBehavior::Immediate)?;
+ let (access, current) = issue_mutation_context(
+ &transaction,
+ change.owner,
+ change.repository,
+ change.number,
+ change.actor,
+ )?;
+ let actor_id = access.active_actor_id.ok_or(StoreError::IssueDenied)?;
+ if !access.can_maintain() {
+ return Err(StoreError::IssueDenied);
+ }
+ let (label_id, stored_label) = if present {
+ transaction.execute(
+ "INSERT INTO label (id, repository_id, name, created_at)
+ VALUES (lower(hex(randomblob(16))), ?1, ?2, ?3)
+ ON CONFLICT DO NOTHING",
+ rusqlite::params![access.repository.id, label, change.changed_at],
+ )?;
+ transaction.query_row(
+ "SELECT id, name FROM label
+ WHERE repository_id = ?1 AND name = ?2 COLLATE NOCASE",
+ rusqlite::params![access.repository.id, label],
+ |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
+ )?
+ } else {
+ transaction
+ .query_row(
+ "SELECT id, name FROM label
+ WHERE repository_id = ?1 AND name = ?2 COLLATE NOCASE",
+ rusqlite::params![access.repository.id, label],
+ |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
+ )
+ .optional()?
+ .ok_or(StoreError::IssueLabelState)?
+ };
+ let changed = if present {
+ transaction.execute(
+ "INSERT INTO issue_label (issue_id, label_id, actor_account_id, created_at)
+ VALUES (?1, ?2, ?3, ?4) ON CONFLICT DO NOTHING",
+ rusqlite::params![current.issue.id, label_id, actor_id, change.changed_at],
+ )?
+ } else {
+ transaction.execute(
+ "DELETE FROM issue_label WHERE issue_id = ?1 AND label_id = ?2",
+ rusqlite::params![current.issue.id, label_id],
+ )?
+ };
+ if changed == 0 {
+ return Err(StoreError::IssueLabelState);
+ }
+ transaction.execute(
+ "UPDATE issue SET updated_at = ?2 WHERE id = ?1",
+ rusqlite::params![current.issue.id, change.changed_at],
+ )?;
+ let kind = if present {
+ event::EventKind::IssueLabeled
+ } else {
+ event::EventKind::IssueUnlabeled
+ };
+ let event = event::issue_label(
+ kind,
+ ¤t.issue.id,
+ change.number,
+ &label_id,
+ &stored_label,
+ );
+ insert_issue_event(
+ &transaction,
+ &access.repository.id,
+ ¤t.issue.id,
+ change.actor,
+ change.changed_at,
+ &event,
+ )?;
+ transaction.commit()?;
+ Ok(())
+ }
+
+ pub(crate) fn set_issue_assignee(
+ &mut self,
+ change: &IssueChange<'_>,
+ assignee: &str,
+ present: bool,
+ ) -> Result<(), StoreError> {
+ let transaction = self
+ .connection
+ .transaction_with_behavior(TransactionBehavior::Immediate)?;
+ let (access, current) = issue_mutation_context(
+ &transaction,
+ change.owner,
+ change.repository,
+ change.number,
+ change.actor,
+ )?;
+ let actor_id = access.active_actor_id.ok_or(StoreError::IssueDenied)?;
+ if !access.can_maintain() {
+ return Err(StoreError::IssueDenied);
+ }
+ let assignee_access = repository_issue_access(
+ &transaction,
+ change.owner,
+ change.repository,
+ Some(assignee),
+ )?;
+ let assignee_id = assignee_access
+ .active_actor_id
+ .filter(|_| assignee_access.can_read())
+ .ok_or_else(|| StoreError::IssueAssigneeNotFound(assignee.to_owned()))?;
+ let changed = if present {
+ transaction.execute(
+ "INSERT INTO issue_assignee
+ (issue_id, account_id, actor_account_id, created_at)
+ VALUES (?1, ?2, ?3, ?4) ON CONFLICT DO NOTHING",
+ rusqlite::params![current.issue.id, assignee_id, actor_id, change.changed_at],
+ )?
+ } else {
+ transaction.execute(
+ "DELETE FROM issue_assignee WHERE issue_id = ?1 AND account_id = ?2",
+ rusqlite::params![current.issue.id, assignee_id],
+ )?
+ };
+ if changed == 0 {
+ return Err(StoreError::IssueAssigneeState);
+ }
+ transaction.execute(
+ "UPDATE issue SET updated_at = ?2 WHERE id = ?1",
+ rusqlite::params![current.issue.id, change.changed_at],
+ )?;
+ let kind = if present {
+ event::EventKind::IssueAssigned
+ } else {
+ event::EventKind::IssueUnassigned
+ };
+ let event = event::issue_assignee(kind, ¤t.issue.id, change.number, assignee);
+ insert_issue_event(
+ &transaction,
+ &access.repository.id,
+ ¤t.issue.id,
+ change.actor,
+ change.changed_at,
+ &event,
+ )?;
+ transaction.commit()?;
+ Ok(())
+ }
+
+ pub(crate) fn issues(
+ &self,
+ owner: &str,
+ repository: &str,
+ actor: Option<&str>,
+ ) -> Result<(RepositoryRecord, Vec<IssueRecord>), StoreError> {
+ let access = repository_issue_access(&self.connection, owner, repository, actor)?;
+ if !access.can_read() {
+ return Err(StoreError::IssueHidden);
+ }
+ let mut statement = self.connection.prepare(
+ "SELECT issue.id, issue.number, issue.title, issue.body, issue.state,
+ account.username, issue.created_at, issue.updated_at, issue.closed_at
+ FROM issue
+ JOIN account ON account.id = issue.author_account_id
+ WHERE issue.repository_id = ?1
+ ORDER BY issue.number DESC LIMIT 1000",
+ )?;
+ let issues = statement
+ .query_map([&access.repository.id], issue_from_row)?
+ .collect::<Result<Vec<_>, _>>()?;
+ drop(statement);
+ Ok((access.repository, issues))
+ }
+
+ pub(crate) fn issue_detail(
+ &self,
+ owner: &str,
+ repository: &str,
+ number: i64,
+ actor: Option<&str>,
+ ) -> Result<IssueDetail, StoreError> {
+ let access = repository_issue_access(&self.connection, owner, repository, actor)?;
+ if !access.can_read() {
+ return Err(StoreError::IssueHidden);
+ }
+ let issue = find_issue(
+ &self.connection,
+ &access.repository,
+ owner,
+ repository,
+ number,
+ )?;
+ let comments = {
+ let mut statement = self.connection.prepare(
+ "SELECT issue_comment.id, account.username, issue_comment.body,
+ issue_comment.created_at
+ FROM issue_comment
+ JOIN account ON account.id = issue_comment.author_account_id
+ WHERE issue_comment.issue_id = ?1
+ ORDER BY issue_comment.created_at, issue_comment.id",
+ )?;
+ statement
+ .query_map([&issue.issue.id], |row| {
+ Ok(IssueCommentRecord {
+ id: row.get(0)?,
+ author: row.get(1)?,
+ body: row.get(2)?,
+ created_at: row.get(3)?,
+ })
+ })?
+ .collect::<Result<Vec<_>, _>>()?
+ };
+ let labels = issue_names(
+ &self.connection,
+ "SELECT label.name FROM issue_label
+ JOIN label ON label.id = issue_label.label_id
+ WHERE issue_label.issue_id = ?1 ORDER BY label.name COLLATE NOCASE",
+ &issue.issue.id,
+ )?;
+ let assignees = issue_names(
+ &self.connection,
+ "SELECT account.username FROM issue_assignee
+ JOIN account ON account.id = issue_assignee.account_id
+ WHERE issue_assignee.issue_id = ?1 ORDER BY account.username",
+ &issue.issue.id,
+ )?;
+ let timeline = {
+ let mut statement = self.connection.prepare(
+ "SELECT sequence, kind, actor, payload, created_at
+ FROM repository_event WHERE issue_id = ?1 ORDER BY sequence",
+ )?;
+ statement
+ .query_map([&issue.issue.id], |row| {
+ Ok(IssueTimelineRecord {
+ sequence: row.get(0)?,
+ kind: row.get(1)?,
+ actor: row.get(2)?,
+ payload: row.get(3)?,
+ created_at: row.get(4)?,
+ })
+ })?
+ .collect::<Result<Vec<_>, _>>()?
+ };
+ Ok(IssueDetail {
+ can_comment: access.active_actor_id.is_some() && access.can_read(),
+ can_edit: access.can_write_issue(issue.author_account_id),
+ can_maintain: access.can_maintain(),
+ repository: access.repository,
+ issue: issue.issue,
+ comments,
+ labels,
+ assignees,
+ timeline,
+ })
+ }
+
#[allow(
dead_code,
reason = "some integration tests compile storage without authorization"
@@ -1703,6 +2191,65 @@
pub(crate) created_at: i64,
}
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub(crate) struct IssueRecord {
+ 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) created_at: i64,
+ pub(crate) updated_at: i64,
+ pub(crate) closed_at: Option<i64>,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub(crate) struct IssueCommentRecord {
+ pub(crate) id: String,
+ pub(crate) author: String,
+ pub(crate) body: String,
+ pub(crate) created_at: i64,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub(crate) struct IssueTimelineRecord {
+ pub(crate) sequence: i64,
+ pub(crate) kind: String,
+ pub(crate) actor: String,
+ pub(crate) payload: String,
+ pub(crate) created_at: i64,
+}
+
+pub(crate) struct IssueDetail {
+ pub(crate) repository: RepositoryRecord,
+ pub(crate) issue: IssueRecord,
+ pub(crate) comments: Vec<IssueCommentRecord>,
+ pub(crate) labels: Vec<String>,
+ pub(crate) assignees: Vec<String>,
+ pub(crate) timeline: Vec<IssueTimelineRecord>,
+ pub(crate) can_comment: bool,
+ pub(crate) can_edit: bool,
+ pub(crate) can_maintain: bool,
+}
+
+pub(crate) struct NewIssue<'a> {
+ 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) created_at: i64,
+}
+
+pub(crate) struct IssueChange<'a> {
+ pub(crate) owner: &'a str,
+ pub(crate) repository: &'a str,
+ pub(crate) number: i64,
+ pub(crate) actor: &'a str,
+ pub(crate) changed_at: i64,
+}
+
pub(crate) struct GitOperationIntent<'a> {
pub(crate) id: &'a str,
pub(crate) repository_path: &'a str,
@@ -1802,10 +2349,186 @@
Ok(())
}
+struct RepositoryIssueAccess {
+ repository: RepositoryRecord,
+ active_actor_id: Option<i64>,
+ role: Option<String>,
+}
+
+impl RepositoryIssueAccess {
+ fn can_read(&self) -> bool {
+ self.repository.state == "active"
+ && (self.repository.visibility == "public" || self.role.is_some())
+ }
+
+ fn can_write_issue(&self, author_account_id: i64) -> bool {
+ self.can_read()
+ && (self.active_actor_id == Some(author_account_id)
+ || matches!(
+ self.role.as_deref(),
+ Some("owner" | "maintainer" | "writer")
+ ))
+ }
+
+ fn can_maintain(&self) -> bool {
+ self.can_read() && matches!(self.role.as_deref(), Some("owner" | "maintainer"))
+ }
+}
+
+struct StoredIssue {
+ issue: IssueRecord,
+ author_account_id: i64,
+}
+
+fn repository_issue_access(
+ connection: &Connection,
+ owner: &str,
+ repository: &str,
+ actor: Option<&str>,
+) -> Result<RepositoryIssueAccess, StoreError> {
+ let result = connection.query_row(
+ "SELECT repository.id, owner.username, repository.slug,
+ repository.visibility, repository.state, repository.object_format,
+ repository.created_at, repository.archived_at,
+ CASE WHEN actor.state = 'active' THEN actor.id END,
+ CASE
+ WHEN actor.state != 'active' THEN NULL
+ WHEN actor.id = repository.owner_account_id THEN 'owner'
+ ELSE repository_collaborator.role
+ END
+ FROM repository
+ JOIN account AS owner ON owner.id = repository.owner_account_id
+ LEFT JOIN account AS actor ON actor.username = ?3
+ LEFT JOIN repository_collaborator
+ ON repository_collaborator.repository_id = repository.id
+ AND repository_collaborator.account_id = actor.id
+ WHERE owner.username = ?1 AND repository.slug = ?2",
+ rusqlite::params![owner, repository, actor],
+ |row| {
+ Ok(RepositoryIssueAccess {
+ repository: repository_from_row(row)?,
+ active_actor_id: row.get(8)?,
+ role: row.get(9)?,
+ })
+ },
+ );
+ match result {
+ Ok(access) => Ok(access),
+ Err(rusqlite::Error::QueryReturnedNoRows) => Err(StoreError::RepositoryNotFound(
+ owner.to_owned(),
+ repository.to_owned(),
+ )),
+ Err(error) => Err(error.into()),
+ }
+}
+
+fn find_issue(
+ connection: &Connection,
+ repository: &RepositoryRecord,
+ owner: &str,
+ slug: &str,
+ number: i64,
+) -> Result<StoredIssue, StoreError> {
+ let result = connection.query_row(
+ "SELECT issue.id, issue.number, issue.title, issue.body, issue.state,
+ account.username, issue.created_at, issue.updated_at, issue.closed_at,
+ issue.author_account_id
+ FROM issue
+ JOIN account ON account.id = issue.author_account_id
+ WHERE issue.repository_id = ?1 AND issue.number = ?2",
+ rusqlite::params![repository.id, number],
+ |row| {
+ Ok(StoredIssue {
+ issue: issue_from_row(row)?,
+ author_account_id: row.get(9)?,
+ })
+ },
+ );
+ match result {
+ Ok(issue) => Ok(issue),
+ Err(rusqlite::Error::QueryReturnedNoRows) => Err(StoreError::IssueNotFound(
+ owner.to_owned(),
+ slug.to_owned(),
+ number,
+ )),
+ Err(error) => Err(error.into()),
+ }
+}
+
+fn issue_mutation_context(
+ connection: &Connection,
+ owner: &str,
+ repository: &str,
+ number: i64,
+ actor: &str,
+) -> Result<(RepositoryIssueAccess, StoredIssue), StoreError> {
+ let access = repository_issue_access(connection, owner, repository, Some(actor))?;
+ if !access.can_read() {
+ return Err(StoreError::IssueHidden);
+ }
+ if access.active_actor_id.is_none() {
+ return Err(StoreError::IssueDenied);
+ }
+ let issue = find_issue(connection, &access.repository, owner, repository, number)?;
+ Ok((access, issue))
+}
+
+fn issue_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<IssueRecord> {
+ Ok(IssueRecord {
+ id: row.get(0)?,
+ number: row.get(1)?,
+ title: row.get(2)?,
+ body: row.get(3)?,
+ state: row.get(4)?,
+ author: row.get(5)?,
+ created_at: row.get(6)?,
+ updated_at: row.get(7)?,
+ closed_at: row.get(8)?,
+ })
+}
+
+fn issue_names(
+ connection: &Connection,
+ sql: &str,
+ issue_id: &str,
+) -> Result<Vec<String>, StoreError> {
+ let mut statement = connection.prepare(sql)?;
+ statement
+ .query_map([issue_id], |row| row.get(0))?
+ .collect::<Result<Vec<_>, _>>()
+ .map_err(Into::into)
+}
+
+fn insert_issue_event(
+ transaction: &rusqlite::Transaction<'_>,
+ repository_id: &str,
+ issue_id: &str,
+ actor: &str,
+ created_at: i64,
+ event: &event::VersionedEvent,
+) -> Result<(), StoreError> {
+ insert_domain_event(
+ transaction,
+ &NewDomainEvent {
+ repository_id,
+ source_intent_id: None,
+ source_ordinal: None,
+ issue_id: Some(issue_id),
+ event,
+ actor,
+ ref_name: None,
+ old_target: None,
+ new_target: None,
+ created_at,
+ },
+ )
+}
+
struct NewDomainEvent<'a> {
repository_id: &'a str,
source_intent_id: Option<&'a str>,
source_ordinal: Option<i64>,
+ issue_id: Option<&'a str>,
event: &'a event::VersionedEvent,
actor: &'a str,
ref_name: Option<&'a [u8]>,
@@ -1821,19 +2544,20 @@
transaction.execute(
"INSERT INTO repository_event
(event_id, repository_id, sequence, source_intent_id, source_ordinal,
- kind, actor, ref_name, old_target, new_target, payload_version, payload,
+ issue_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
+ ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12
)",
rusqlite::params![
event.repository_id,
event.source_intent_id,
event.source_ordinal,
+ event.issue_id,
event.event.kind.as_str(),
event.actor,
event.ref_name,
@@ -1895,6 +2619,7 @@
repository_id,
source_intent_id: Some(intent_id),
source_ordinal: Some(0),
+ issue_id: None,
event: &push,
actor,
ref_name: None,
@@ -1947,6 +2672,7 @@
repository_id,
source_intent_id: Some(intent_id),
source_ordinal: Some(ordinal),
+ issue_id: None,
event: &event,
actor,
ref_name: Some(&old_name),
templates/issue.html
Mode → 100644; object → 7a861e999648
@@ -1,0 +1,115 @@
+{% extends "base.html" %}
+{% block title %}#{{ number }} {{ 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 }}/atom.xml">Atom</a>
+ <a href="/{{ owner }}/{{ repository }}/rss.xml">RSS</a>
+ </nav>
+ </header>
+
+ <article>
+ <h2>#{{ number }} {{ title }}</h2>
+ <p>{{ state }} · opened by {{ author }} at <time>{{ created_at }}</time> · updated <time>{{ updated_at }}</time></p>
+{% if labels.is_empty() %}{% else %}
+ <p>Labels:{% for label in labels %} <span>{{ label }}</span>{% endfor %}</p>
+{% endif %}
+{% if assignees.is_empty() %}{% else %}
+ <p>Assignees:{% for assignee in assignees %} <span>{{ assignee }}</span>{% endfor %}</p>
+{% endif %}
+ <div class="markdown">{{ body_html|safe }}</div>
+ </article>
+
+ <section aria-labelledby="comments-heading">
+ <h2 id="comments-heading">Comments</h2>
+{% if comments.is_empty() %}
+ <p>This issue has no comments.</p>
+{% else %}
+{% for comment in comments %}
+ <article id="comment-{{ comment.id }}">
+ <h3>{{ comment.author }} commented at <time>{{ comment.created_at }}</time></h3>
+ <div class="markdown">{{ comment.body_html|safe }}</div>
+ </article>
+{% endfor %}
+{% endif %}
+ </section>
+
+ <section aria-labelledby="timeline-heading">
+ <h2 id="timeline-heading">Timeline</h2>
+ <ol>
+{% for event in timeline %}
+ <li value="{{ event.sequence }}">{{ event.actor }}: {{ event.kind }} at <time>{{ event.created_at }}</time></li>
+{% endfor %}
+ </ol>
+ </section>
+
+{% if can_comment %}
+ <section aria-labelledby="comment-heading">
+ <h2 id="comment-heading">Add a comment</h2>
+ <form method="post" action="/{{ owner }}/{{ repository }}/issues/{{ number }}/comments">
+ <input type="hidden" name="csrf" value="{{ csrf }}">
+ <div class="field">
+ <label for="comment-body">Comment (Markdown)</label>
+ <textarea id="comment-body" name="body" maxlength="262144" rows="8" required></textarea>
+ </div>
+ <button type="submit">Add comment</button>
+ </form>
+ </section>
+{% endif %}
+
+{% if can_edit %}
+ <section aria-labelledby="edit-heading">
+ <h2 id="edit-heading">Edit this issue</h2>
+ <form method="post" action="/{{ owner }}/{{ repository }}/issues/{{ number }}/edit">
+ <input type="hidden" name="csrf" value="{{ csrf }}">
+ <div class="field">
+ <label for="edit-title">Title</label>
+ <input id="edit-title" name="title" maxlength="200" value="{{ title }}" required>
+ </div>
+ <div class="field">
+ <label for="edit-body">Description (Markdown)</label>
+ <textarea id="edit-body" name="body" maxlength="262144" rows="10">{{ body }}</textarea>
+ </div>
+ <button type="submit">Save issue</button>
+ </form>
+ <form method="post" action="/{{ owner }}/{{ repository }}/issues/{{ number }}/state">
+ <input type="hidden" name="csrf" value="{{ csrf }}">
+{% if is_open %}
+ <input type="hidden" name="state" value="closed">
+ <button type="submit">Close issue</button>
+{% else %}
+ <input type="hidden" name="state" value="open">
+ <button type="submit">Reopen issue</button>
+{% endif %}
+ </form>
+ </section>
+{% endif %}
+
+{% if can_maintain %}
+ <section aria-labelledby="organize-heading">
+ <h2 id="organize-heading">Organize this issue</h2>
+ <form method="post" action="/{{ owner }}/{{ repository }}/issues/{{ number }}/labels">
+ <input type="hidden" name="csrf" value="{{ csrf }}">
+ <div class="field">
+ <label for="label-name">Label</label>
+ <input id="label-name" name="label" maxlength="80" required>
+ </div>
+ <button name="operation" value="add" type="submit">Add label</button>
+ <button name="operation" value="remove" type="submit">Remove label</button>
+ </form>
+ <form method="post" action="/{{ owner }}/{{ repository }}/issues/{{ number }}/assignees">
+ <input type="hidden" name="csrf" value="{{ csrf }}">
+ <div class="field">
+ <label for="assignee-name">Assignee</label>
+ <input id="assignee-name" name="assignee" maxlength="39" required>
+ </div>
+ <button name="operation" value="add" type="submit">Assign</button>
+ <button name="operation" value="remove" type="submit">Unassign</button>
+ </form>
+ </section>
+{% endif %}
+{% endblock %}
templates/issues.html
Mode → 100644; object → 7d5be43d1092
@@ -1,0 +1,45 @@
+{% extends "base.html" %}
+{% block title %}Issues · {{ 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 }}/atom.xml">Atom</a>
+ <a href="/{{ owner }}/{{ repository }}/rss.xml">RSS</a>
+ </nav>
+ </header>
+
+ <h2>Issues</h2>
+{% if issues.is_empty() %}
+ <p>This repository has no issues.</p>
+{% else %}
+ <ol class="issue-list">
+{% for issue in issues %}
+ <li><a href="/{{ owner }}/{{ repository }}/issues/{{ issue.number }}">#{{ issue.number }} {{ issue.title }}</a> — {{ issue.state }}, opened by {{ issue.author }}, updated <time>{{ issue.updated_at }}</time></li>
+{% endfor %}
+ </ol>
+{% endif %}
+
+{% if can_create %}
+ <section aria-labelledby="new-issue-heading">
+ <h2 id="new-issue-heading">Create an issue</h2>
+ <form method="post" action="/{{ owner }}/{{ repository }}/issues">
+ <input type="hidden" name="csrf" value="{{ csrf }}">
+ <div class="field">
+ <label for="issue-title">Title</label>
+ <input id="issue-title" name="title" maxlength="200" required>
+ </div>
+ <div class="field">
+ <label for="issue-body">Description (Markdown)</label>
+ <textarea id="issue-body" name="body" maxlength="262144" rows="10"></textarea>
+ </div>
+ <button type="submit">Create issue</button>
+ </form>
+ </section>
+{% else %}
+ <p><a href="/login">Log in</a> to create an issue.</p>
+{% endif %}
+{% endblock %}
templates/repository.html
Mode 100644 → 100644; object 933d7c5a09ff → bcff56df13b0
@@ -6,6 +6,7 @@
<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 }}/atom.xml">Atom</a>
<a href="/{{ owner }}/{{ repository }}/rss.xml">RSS</a>
<a href="/{{ owner }}/{{ repository }}/search">Search</a>
tests/cli.rs
Mode 100644 → 100644; object 1255ad2d777f → 643afe290085
@@ -20,7 +20,8 @@
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"),
- "PRAGMA user_version = 11;\n",
+ include_str!("../src/store/migrations/012_issues.sql"),
+ "PRAGMA user_version = 12;\n",
);
#[test]
tests/public_routes.rs
Mode 100644 → 100644; object 052d69b9b0b9 → 07446caa6e1c
@@ -32,6 +32,9 @@
)]
#[path = "../src/instance.rs"]
mod instance;
+#[allow(dead_code, reason = "the public-route test does not mutate issues")]
+#[path = "../src/issue.rs"]
+mod issue;
#[path = "../src/markdown.rs"]
mod markdown;
#[allow(dead_code, reason = "the public-route test uses anonymous policy only")]
@@ -59,8 +62,10 @@
use std::net::{Ipv4Addr, SocketAddr, TcpStream};
use std::path::Path;
use std::process::Command;
+use std::time::{SystemTime, UNIX_EPOCH};
use http::{PublicWebConfig, RunningWebServer};
+use sha2::{Digest, Sha256};
use store::{InitialAdministrator, NewRepository, RepositoryOrigin, Store};
use tempfile::TempDir;
@@ -563,6 +568,168 @@
server.shutdown().await.expect("stop the public Web server");
}
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+async fn runs_the_complete_issue_workflow_without_javascript() {
+ let fixture = Fixture::new("sha1");
+ let database = fixture.instance.path().join(store::DATABASE_FILE);
+ let token = "11".repeat(32);
+ let csrf = "22".repeat(32);
+ let session_hash: [u8; 32] = Sha256::digest(token.as_bytes()).into();
+ let csrf_hash: [u8; 32] = Sha256::digest(csrf.as_bytes()).into();
+ let now = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .expect("read current time")
+ .as_secs() as i64;
+ let store = Store::open(&database).expect("open the issue fixture database");
+ store
+ .connection()
+ .execute(
+ "INSERT INTO web_session
+ (session_hash, csrf_hash, account_id, created_at, expires_at)
+ SELECT ?1, ?2, id, ?3, ?4 FROM account WHERE username = 'alice'",
+ rusqlite::params![session_hash, csrf_hash, now, now + 3600,],
+ )
+ .expect("create a Web session");
+ drop(store);
+
+ let server = RunningWebServer::start_public(
+ SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
+ PublicWebConfig {
+ instance_dir: fixture.instance.path().to_owned(),
+ http_clone_base: "http://127.0.0.1".to_owned(),
+ ssh_clone_base: "ssh://tit.example:2222".to_owned(),
+ },
+ )
+ .await
+ .expect("start the issue Web server");
+
+ let anonymous = request(server.address(), "GET", "/alice/example/issues", &[], &[]);
+ assert_eq!(anonymous.status, 200);
+ assert!(anonymous.text().contains("This repository has no issues."));
+ assert!(!anonymous.text().contains("Create an issue</h2>"));
+
+ let cookie = format!("tit-session={token}; tit-csrf={csrf}");
+ let headers = [
+ ("Content-Type", "application/x-www-form-urlencoded"),
+ ("Cookie", cookie.as_str()),
+ ];
+ let create = form(&[
+ ("csrf", &csrf),
+ ("title", "Unsafe rendering check"),
+ ("body", "**safe**\n\n<script>alert(1)</script>"),
+ ]);
+ let created = request(
+ server.address(),
+ "POST",
+ "/alice/example/issues",
+ &headers,
+ create.as_bytes(),
+ );
+ assert_eq!(created.status, 303);
+ assert_eq!(created.header("location"), "/alice/example/issues/1");
+
+ let detail = request(
+ server.address(),
+ "GET",
+ "/alice/example/issues/1",
+ &[("Cookie", cookie.as_str())],
+ &[],
+ );
+ assert_eq!(detail.status, 200);
+ assert!(detail.text().contains("<strong>safe</strong>"));
+ assert!(!detail.text().contains("<script>"));
+ assert!(detail.text().contains("Add a comment"));
+ assert!(detail.text().contains("Organize this issue"));
+
+ let bad_csrf = form(&[("csrf", &"33".repeat(32)), ("state", "closed")]);
+ assert_eq!(
+ request(
+ server.address(),
+ "POST",
+ "/alice/example/issues/1/state",
+ &headers,
+ bad_csrf.as_bytes(),
+ )
+ .status,
+ 403
+ );
+ for (path, fields) in [
+ (
+ "/alice/example/issues/1/comments",
+ vec![("csrf", csrf.as_str()), ("body", "A **comment**.")],
+ ),
+ (
+ "/alice/example/issues/1/edit",
+ vec![
+ ("csrf", csrf.as_str()),
+ ("title", "Edited issue"),
+ ("body", "Preserved _Markdown_."),
+ ],
+ ),
+ (
+ "/alice/example/issues/1/labels",
+ vec![
+ ("csrf", csrf.as_str()),
+ ("label", "bug"),
+ ("operation", "add"),
+ ],
+ ),
+ (
+ "/alice/example/issues/1/assignees",
+ vec![
+ ("csrf", csrf.as_str()),
+ ("assignee", "alice"),
+ ("operation", "add"),
+ ],
+ ),
+ (
+ "/alice/example/issues/1/state",
+ vec![("csrf", csrf.as_str()), ("state", "closed")],
+ ),
+ (
+ "/alice/example/issues/1/state",
+ vec![("csrf", csrf.as_str()), ("state", "open")],
+ ),
+ ] {
+ let response = request(
+ server.address(),
+ "POST",
+ path,
+ &headers,
+ form(&fields).as_bytes(),
+ );
+ assert_eq!(response.status, 303, "issue mutation failed at {path}");
+ }
+
+ let final_page = request(
+ server.address(),
+ "GET",
+ "/alice/example/issues/1",
+ &[("Cookie", cookie.as_str())],
+ &[],
+ );
+ let final_text = final_page.text();
+ assert_eq!(final_page.status, 200);
+ assert!(final_text.contains("#1 Edited issue"));
+ assert!(final_text.contains("<em>Markdown</em>"));
+ assert!(final_text.contains("<strong>comment</strong>"));
+ assert!(final_text.contains("Labels: <span>bug</span>"));
+ assert!(final_text.contains("Assignees: <span>alice</span>"));
+ assert!(final_text.contains("issue-created"));
+ assert!(final_text.contains("issue-reopened"));
+ let feed = request(server.address(), "GET", "/alice/example/atom.xml", &[], &[]);
+ assert_eq!(feed.status, 200);
+ assert!(feed.text().contains("alice reopened #1"));
+
+ server.shutdown().await.expect("stop the issue Web server");
+}
+
+fn form(fields: &[(&str, &str)]) -> String {
+ let mut serializer = url::form_urlencoded::Serializer::new(String::new());
+ serializer.extend_pairs(fields.iter().copied());
+ serializer.finish()
}
fn assert_hidden(address: SocketAddr, head: &str) {
tests/sqlite.rs
Mode 100644 → 100644; object 890c6879b579 → 7b1da56c7495
@@ -10,8 +10,8 @@
use rusqlite::{Connection, ErrorCode, TransactionBehavior, params};
use store::{
- GitOperationIntent, InitialAdministrator, NewAuditEvent, NewRepository, NewRepositoryReference,
- RepositoryOrigin, Store, StoreError,
+ GitOperationIntent, InitialAdministrator, IssueChange, NewAuditEvent, NewIssue, NewRepository,
+ NewRepositoryReference, RepositoryOrigin, Store, StoreError,
};
use tempfile::TempDir;
@@ -39,6 +39,14 @@
include_str!("../src/store/migrations/009_repository_authorization.sql"),
include_str!("../src/store/migrations/010_audit_history.sql"),
"PRAGMA user_version = 10;\n",
+);
+const V11_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"),
+ "PRAGMA user_version = 11;\n",
);
fn database(directory: &TempDir, name: &str) -> std::path::PathBuf {
@@ -157,7 +165,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"), 11);
+ assert_eq!(store.schema_version().expect("read the schema version"), 12);
assert_eq!(
store
.connection()
@@ -678,6 +686,221 @@
}
#[test]
+fn runs_the_issue_workflow_with_atomic_events_and_repository_roles() {
+ let directory = TempDir::new().expect("create an issue fixture directory");
+ let mut store = Store::open(&database(&directory, "issues.sqlite")).expect("open the store");
+ store
+ .create_initial_administrator(&InitialAdministrator {
+ username: "alice",
+ canonical_key: "ssh-ed25519 AAAAalice",
+ fingerprint: "SHA256:alice",
+ recovery_hash: &[7; 32],
+ created_at: 1,
+ })
+ .expect("create the repository owner");
+ store
+ .connection()
+ .execute_batch(
+ "INSERT INTO account (id, username, is_administrator, state, created_at) VALUES
+ (2, 'bob', 0, 'active', 1),
+ (3, 'carol', 0, 'active', 1),
+ (4, 'maintainer', 0, 'active', 1),
+ (5, 'stranger', 0, 'active', 1),
+ (6, 'suspended', 0, 'suspended', 1);",
+ )
+ .expect("create issue actors");
+ store
+ .create_repository(&NewRepository {
+ id: "00112233445566778899aabbccddeeff",
+ owner: "alice",
+ slug: "project",
+ object_format: "sha1",
+ created_at: 2,
+ origin: RepositoryOrigin::Created,
+ initial_references: &[],
+ actor: "alice",
+ correlation_id: "issue-repository",
+ })
+ .expect("create an issue repository");
+ store
+ .connection()
+ .execute_batch(
+ "UPDATE repository SET visibility = 'private';
+ INSERT INTO repository_collaborator
+ (repository_id, account_id, role, created_at)
+ VALUES
+ ('00112233445566778899aabbccddeeff', 2, 'reader', 3),
+ ('00112233445566778899aabbccddeeff', 3, 'writer', 3),
+ ('00112233445566778899aabbccddeeff', 4, 'maintainer', 3);",
+ )
+ .expect("configure issue roles");
+
+ let source = "Use **the supplied Markdown**.\n\n<script>do not run</script>";
+ let issue = store
+ .create_issue(&NewIssue {
+ owner: "alice",
+ repository: "project",
+ actor: "bob",
+ title: "Preserve Markdown",
+ body: source,
+ created_at: 4,
+ })
+ .expect("create an issue as a reader");
+ assert_eq!(issue.number, 1);
+ assert_eq!(issue.body, source);
+ let second = store
+ .create_issue(&NewIssue {
+ owner: "alice",
+ repository: "project",
+ actor: "carol",
+ title: "Second issue",
+ body: "",
+ created_at: 5,
+ })
+ .expect("allocate the next issue number");
+ assert_eq!(second.number, 2);
+ assert!(matches!(
+ store.issues("alice", "project", None),
+ Err(StoreError::IssueHidden)
+ ));
+ assert_eq!(
+ store
+ .issues("alice", "project", Some("bob"))
+ .expect("list private issues as a reader")
+ .1
+ .len(),
+ 2
+ );
+ let change = |actor, changed_at| IssueChange {
+ owner: "alice",
+ repository: "project",
+ number: 1,
+ actor,
+ changed_at,
+ };
+ assert!(matches!(
+ store.edit_issue(
+ &IssueChange {
+ number: 2,
+ ..change("bob", 6)
+ },
+ "Reader edit",
+ "denied",
+ ),
+ Err(StoreError::IssueDenied)
+ ));
+ store
+ .edit_issue(&change("bob", 7), "Preserve the source", source)
+ .expect("edit an authored issue");
+ store
+ .edit_issue(&change("carol", 8), "Writer edit", source)
+ .expect("edit an issue as a writer");
+ let comment_id = store
+ .comment_issue(
+ "alice",
+ "project",
+ 1,
+ "bob",
+ "A comment with [text](https://example.com).",
+ 9,
+ )
+ .expect("comment as a reader");
+ assert_eq!(comment_id.len(), 32);
+ assert!(matches!(
+ store.set_issue_label(&change("bob", 10), "bug", true),
+ Err(StoreError::IssueDenied)
+ ));
+ store
+ .set_issue_label(&change("maintainer", 11), "bug", true)
+ .expect("label as a maintainer");
+ store
+ .set_issue_assignee(&change("maintainer", 12), "bob", true)
+ .expect("assign a repository reader");
+ assert!(matches!(
+ store.set_issue_assignee(&change("maintainer", 13), "stranger", true),
+ Err(StoreError::IssueAssigneeNotFound(username)) if username == "stranger"
+ ));
+ store
+ .set_issue_state("alice", "project", 1, "bob", "closed", 14)
+ .expect("close an authored issue");
+ store
+ .set_issue_state("alice", "project", 1, "carol", "open", 15)
+ .expect("reopen an issue as a writer");
+
+ let detail = store
+ .issue_detail("alice", "project", 1, Some("maintainer"))
+ .expect("read the issue timeline");
+ assert_eq!(detail.repository.slug, "project");
+ assert_eq!(detail.issue.title, "Writer edit");
+ assert_eq!(detail.issue.body, source);
+ assert_eq!(detail.issue.state, "open");
+ assert_eq!(detail.comments.len(), 1);
+ assert_eq!(detail.labels, ["bug"]);
+ assert_eq!(detail.assignees, ["bob"]);
+ assert!(detail.can_comment);
+ assert!(detail.can_edit);
+ assert!(detail.can_maintain);
+ assert_eq!(
+ detail
+ .timeline
+ .iter()
+ .map(|event| event.kind.as_str())
+ .collect::<Vec<_>>(),
+ [
+ "issue-created",
+ "issue-edited",
+ "issue-edited",
+ "issue-commented",
+ "issue-labeled",
+ "issue-assigned",
+ "issue-closed",
+ "issue-reopened",
+ ]
+ );
+ assert!(
+ detail
+ .timeline
+ .windows(2)
+ .all(|events| events[0].sequence < events[1].sequence)
+ );
+ for event in &detail.timeline {
+ let payload: serde_json::Value =
+ serde_json::from_str(&event.payload).expect("parse an issue event payload");
+ assert_eq!(payload["version"], 1);
+ assert_eq!(payload["issue_id"], issue.id);
+ assert!(event.created_at >= 4);
+ assert!(!event.actor.is_empty());
+ }
+
+ let comments_before: i64 = store
+ .connection()
+ .query_row("SELECT count(*) FROM issue_comment", [], |row| row.get(0))
+ .expect("count issue comments");
+ store
+ .connection()
+ .execute_batch(
+ "CREATE TEMP TRIGGER reject_issue_event
+ BEFORE INSERT ON repository_event
+ BEGIN
+ SELECT RAISE(ABORT, 'injected issue event failure');
+ END;",
+ )
+ .expect("inject an issue event failure");
+ assert!(matches!(
+ store.comment_issue("alice", "project", 1, "bob", "must roll back", 16),
+ Err(StoreError::Sqlite(_))
+ ));
+ assert_eq!(
+ store
+ .connection()
+ .query_row("SELECT count(*) FROM issue_comment", [], |row| row
+ .get::<_, i64>(0))
+ .expect("count comments after rollback"),
+ comments_before
+ );
+}
+
+#[test]
fn enforces_constraints_and_supports_crud_and_indexed_scans() {
let directory = TempDir::new().expect("create a temporary directory");
let mut store = Store::open(&database(&directory, "store.sqlite")).expect("open the store");
@@ -1015,13 +1238,14 @@
(V8_FIXTURE, 8),
(V9_FIXTURE, 9),
(V10_FIXTURE, 10),
+ (V11_FIXTURE, 11),
] {
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"), 11);
+ assert_eq!(store.schema_version().expect("read the schema version"), 12);
store.integrity_check().expect("check migrated integrity");
let state: String = store
.connection()
@@ -1094,7 +1318,7 @@
#[test]
fn recovers_complete_schema_versions_after_a_process_kill_during_migration() {
- for (mode, expected_version) in [("migration-uncommitted", 1), ("migration-committed", 11)] {
+ for (mode, expected_version) in [("migration-uncommitted", 1), ("migration-committed", 12)] {
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 3db8b42848b7 → b8461ac1635a
@@ -30,6 +30,9 @@ )] #[path = "../src/instance.rs"] mod instance; +#[allow(dead_code, reason = "the Web shell test does not use issue workflows")] +#[path = "../src/issue.rs"] +mod issue; #[path = "../src/markdown.rs"] mod markdown; #[allow(dead_code, reason = "the Web shell test has no repository catalog")]