Stockage flat côté ticket + métadonnées d'attachments, lecture exposée côté backend/MCP, et UI minimale de liste/ajout dans TicketDetail. Traverse le domaine (Issue, ports), l'application (usecases + assistant de ticket), les adaptateurs infra/MCP (issues store, orchestrateur), les DTO backend/web- server/app-tauri, et le frontend (domain/ports/adapters/hooks/UI). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
852 lines
28 KiB
Rust
852 lines
28 KiB
Rust
//! Filesystem issue store.
|
|
//!
|
|
//! Project-scoped issues live under `<root>/.ideai/tickets/`, one directory per
|
|
//! issue number. The Markdown files are the source of truth; `index.json` is a
|
|
//! reconstructible projection kept in sync after writes.
|
|
|
|
use std::collections::BTreeMap;
|
|
use std::path::{Path, PathBuf};
|
|
use std::str::FromStr;
|
|
use std::time::Duration;
|
|
|
|
use async_trait::async_trait;
|
|
use serde::{Deserialize, Serialize};
|
|
use tokio::io::AsyncWriteExt;
|
|
|
|
use domain::{
|
|
AgentIssueRef, Issue, IssueActor, IssueAttachment, IssueAttachmentContent, IssueAttachmentId,
|
|
IssueCarnet, IssueId, IssueIndexEntry, IssueLink, IssueListFilter, IssueNumber,
|
|
IssueNumberAllocator, IssuePriority, IssueRef, IssueStatus, IssueStore, IssueStoreError,
|
|
IssueVersion, LocalPath, MarkdownDoc, ProjectPath, SprintId,
|
|
};
|
|
|
|
const IDEAI_DIR: &str = ".ideai";
|
|
const ISSUES_DIR: &str = "tickets";
|
|
const ISSUE_FILE: &str = "issue.md";
|
|
const CARNET_FILE: &str = "carnet.md";
|
|
const ATTACHMENTS_DIR: &str = "attachments";
|
|
const INDEX_FILE: &str = "index.json";
|
|
const COUNTER_FILE: &str = "counter.json";
|
|
const COUNTER_LOCK: &str = "counter.lock";
|
|
const MUTATION_LOCK: &str = "mutation.lock";
|
|
const INDEX_VERSION: u32 = 1;
|
|
const ATTACHMENT_MAX_BYTES: u64 = 25 * 1024 * 1024;
|
|
|
|
/// Filesystem-backed issue store.
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct FsIssueStore;
|
|
|
|
impl FsIssueStore {
|
|
/// Builds a store.
|
|
#[must_use]
|
|
pub const fn new() -> Self {
|
|
Self
|
|
}
|
|
}
|
|
|
|
/// Filesystem-backed issue number allocator.
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct FsIssueNumberAllocator;
|
|
|
|
impl FsIssueNumberAllocator {
|
|
/// Builds an allocator.
|
|
#[must_use]
|
|
pub const fn new() -> Self {
|
|
Self
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct CounterDoc {
|
|
next_number: u64,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct IndexDoc {
|
|
version: u32,
|
|
issues: Vec<IssueIndexEntry>,
|
|
}
|
|
|
|
fn issue_root(root: &ProjectPath) -> PathBuf {
|
|
PathBuf::from(root.as_str())
|
|
.join(IDEAI_DIR)
|
|
.join(ISSUES_DIR)
|
|
}
|
|
|
|
fn issue_dir(root: &ProjectPath, number: IssueNumber) -> PathBuf {
|
|
issue_root(root).join(number.get().to_string())
|
|
}
|
|
|
|
fn issue_path(root: &ProjectPath, number: IssueNumber) -> PathBuf {
|
|
issue_dir(root, number).join(ISSUE_FILE)
|
|
}
|
|
|
|
fn carnet_path(root: &ProjectPath, number: IssueNumber) -> PathBuf {
|
|
issue_dir(root, number).join(CARNET_FILE)
|
|
}
|
|
|
|
fn attachment_path(root: &ProjectPath, number: IssueNumber, relative: &str) -> PathBuf {
|
|
issue_dir(root, number).join(relative)
|
|
}
|
|
|
|
fn index_path(root: &ProjectPath) -> PathBuf {
|
|
issue_root(root).join(INDEX_FILE)
|
|
}
|
|
|
|
fn counter_path(root: &ProjectPath) -> PathBuf {
|
|
issue_root(root).join(COUNTER_FILE)
|
|
}
|
|
|
|
fn counter_lock_path(root: &ProjectPath) -> PathBuf {
|
|
issue_root(root).join(COUNTER_LOCK)
|
|
}
|
|
|
|
fn mutation_lock_path(root: &ProjectPath) -> PathBuf {
|
|
issue_root(root).join(MUTATION_LOCK)
|
|
}
|
|
|
|
async fn acquire_lock(path: &Path, name: &str) -> Result<tokio::fs::File, IssueStoreError> {
|
|
if let Some(parent) = path.parent() {
|
|
tokio::fs::create_dir_all(parent).await.map_err(io_error)?;
|
|
}
|
|
let mut lock = None;
|
|
for _ in 0..50 {
|
|
match tokio::fs::OpenOptions::new()
|
|
.write(true)
|
|
.create_new(true)
|
|
.open(path)
|
|
.await
|
|
{
|
|
Ok(file) => {
|
|
lock = Some(file);
|
|
break;
|
|
}
|
|
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
|
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
}
|
|
Err(err) => return Err(io_error(err)),
|
|
}
|
|
}
|
|
let Some(mut lock_file) = lock else {
|
|
return Err(IssueStoreError::Store(format!("{name} lock timed out")));
|
|
};
|
|
lock_file.write_all(b"locked\n").await.map_err(io_error)?;
|
|
Ok(lock_file)
|
|
}
|
|
|
|
async fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), IssueStoreError> {
|
|
let parent = path
|
|
.parent()
|
|
.ok_or_else(|| IssueStoreError::Store("path has no parent".to_owned()))?;
|
|
tokio::fs::create_dir_all(parent).await.map_err(io_error)?;
|
|
let tmp = path.with_extension("tmp");
|
|
tokio::fs::write(&tmp, bytes).await.map_err(io_error)?;
|
|
tokio::fs::rename(&tmp, path).await.map_err(io_error)
|
|
}
|
|
|
|
async fn read_string(path: &Path) -> Result<String, IssueStoreError> {
|
|
let bytes = tokio::fs::read(path).await.map_err(|err| {
|
|
if err.kind() == std::io::ErrorKind::NotFound {
|
|
IssueStoreError::NotFound
|
|
} else {
|
|
io_error(err)
|
|
}
|
|
})?;
|
|
String::from_utf8(bytes).map_err(|err| IssueStoreError::Store(err.to_string()))
|
|
}
|
|
|
|
fn io_error(err: std::io::Error) -> IssueStoreError {
|
|
IssueStoreError::Store(err.to_string())
|
|
}
|
|
|
|
fn attachment_by_id(
|
|
issue: &Issue,
|
|
id: &IssueAttachmentId,
|
|
) -> Result<IssueAttachment, IssueStoreError> {
|
|
issue
|
|
.attachments
|
|
.iter()
|
|
.find(|attachment| &attachment.id == id)
|
|
.cloned()
|
|
.ok_or(IssueStoreError::NotFound)
|
|
}
|
|
|
|
async fn load_issue(root: &ProjectPath, issue_ref: IssueRef) -> Result<Issue, IssueStoreError> {
|
|
let number = issue_ref.number();
|
|
let issue_text = read_string(&issue_path(root, number)).await?;
|
|
let mut issue = parse_issue_doc(&issue_text)?;
|
|
let carnet = match read_string(&carnet_path(root, number)).await {
|
|
Ok(text) => parse_carnet_doc(&text)?.carnet,
|
|
Err(IssueStoreError::NotFound) => MarkdownDoc::default(),
|
|
Err(err) => return Err(err),
|
|
};
|
|
issue.carnet = carnet;
|
|
Issue::rehydrate(issue).map_err(|err| IssueStoreError::Invalid(err.to_string()))
|
|
}
|
|
|
|
async fn save_issue(root: &ProjectPath, issue: &Issue) -> Result<(), IssueStoreError> {
|
|
let dir = issue_dir(root, issue.number);
|
|
tokio::fs::create_dir_all(&dir).await.map_err(io_error)?;
|
|
write_atomic(&dir.join(ISSUE_FILE), render_issue_doc(issue).as_bytes()).await?;
|
|
write_atomic(&dir.join(CARNET_FILE), render_carnet_doc(issue).as_bytes()).await
|
|
}
|
|
|
|
async fn rebuild_index(root: &ProjectPath) -> Result<Vec<IssueIndexEntry>, IssueStoreError> {
|
|
let dir = issue_root(root);
|
|
let mut rows = Vec::new();
|
|
let mut entries = match tokio::fs::read_dir(&dir).await {
|
|
Ok(entries) => entries,
|
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
|
write_index(root, &rows).await?;
|
|
return Ok(rows);
|
|
}
|
|
Err(err) => return Err(io_error(err)),
|
|
};
|
|
while let Some(entry) = entries.next_entry().await.map_err(io_error)? {
|
|
let Ok(file_type) = entry.file_type().await else {
|
|
continue;
|
|
};
|
|
if !file_type.is_dir() {
|
|
continue;
|
|
}
|
|
let name = entry.file_name().to_string_lossy().to_string();
|
|
let Ok(number) = name.parse::<u64>() else {
|
|
continue;
|
|
};
|
|
let Ok(number) = IssueNumber::new(number) else {
|
|
continue;
|
|
};
|
|
if let Ok(issue) = load_issue(root, IssueRef::from(number)).await {
|
|
rows.push(IssueIndexEntry::from(&issue));
|
|
}
|
|
}
|
|
rows.sort_by_key(|row| row.issue_ref.number().get());
|
|
write_index(root, &rows).await?;
|
|
Ok(rows)
|
|
}
|
|
|
|
async fn read_index(root: &ProjectPath) -> Result<Vec<IssueIndexEntry>, IssueStoreError> {
|
|
match tokio::fs::read(index_path(root)).await {
|
|
Ok(bytes) => {
|
|
let doc: IndexDoc = serde_json::from_slice(&bytes)
|
|
.map_err(|err| IssueStoreError::Store(err.to_string()))?;
|
|
Ok(doc.issues)
|
|
}
|
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => rebuild_index(root).await,
|
|
Err(err) => Err(io_error(err)),
|
|
}
|
|
}
|
|
|
|
async fn write_index(root: &ProjectPath, rows: &[IssueIndexEntry]) -> Result<(), IssueStoreError> {
|
|
let doc = IndexDoc {
|
|
version: INDEX_VERSION,
|
|
issues: rows.to_vec(),
|
|
};
|
|
let bytes =
|
|
serde_json::to_vec_pretty(&doc).map_err(|err| IssueStoreError::Store(err.to_string()))?;
|
|
write_atomic(&index_path(root), &bytes).await
|
|
}
|
|
|
|
fn filter_matches(row: &IssueIndexEntry, filter: &IssueListFilter) -> bool {
|
|
if !filter.statuses.is_empty() && !filter.statuses.contains(&row.status) {
|
|
return false;
|
|
}
|
|
if !filter.priorities.is_empty() && !filter.priorities.contains(&row.priority) {
|
|
return false;
|
|
}
|
|
if let Some(agent_id) = filter.assigned_agent_id {
|
|
if !row.assigned_agent_ids.contains(&agent_id) {
|
|
return false;
|
|
}
|
|
}
|
|
if let Some(created_by) = &filter.created_by {
|
|
if &row.created_by != created_by {
|
|
return false;
|
|
}
|
|
}
|
|
if filter
|
|
.sprint
|
|
.is_some_and(|sprint| row.sprint != Some(sprint))
|
|
{
|
|
return false;
|
|
}
|
|
if let Some(text) = filter
|
|
.text
|
|
.as_ref()
|
|
.map(|s| s.trim())
|
|
.filter(|s| !s.is_empty())
|
|
{
|
|
if let Some(number) = parse_issue_search_ref(text) {
|
|
return row.issue_ref.number() == number;
|
|
}
|
|
row.title
|
|
.to_ascii_lowercase()
|
|
.contains(&text.to_ascii_lowercase())
|
|
} else {
|
|
true
|
|
}
|
|
}
|
|
|
|
fn parse_issue_search_ref(needle: &str) -> Option<IssueNumber> {
|
|
let trimmed = needle.trim();
|
|
let raw = trimmed.strip_prefix('#').unwrap_or(trimmed);
|
|
if raw.is_empty() || !raw.chars().all(|ch| ch.is_ascii_digit()) {
|
|
return None;
|
|
}
|
|
raw.parse::<u64>()
|
|
.ok()
|
|
.and_then(|number| IssueNumber::new(number).ok())
|
|
}
|
|
|
|
#[async_trait]
|
|
impl IssueStore for FsIssueStore {
|
|
async fn create(&self, root: &ProjectPath, issue: &Issue) -> Result<(), IssueStoreError> {
|
|
let lock_path = mutation_lock_path(root);
|
|
let _lock_file = acquire_lock(&lock_path, "issue mutation").await?;
|
|
let result = async {
|
|
issue
|
|
.validate()
|
|
.map_err(|err| IssueStoreError::Invalid(err.to_string()))?;
|
|
if tokio::fs::try_exists(issue_path(root, issue.number))
|
|
.await
|
|
.map_err(io_error)?
|
|
{
|
|
return Err(IssueStoreError::Invalid(format!(
|
|
"issue {} already exists",
|
|
issue.reference()
|
|
)));
|
|
}
|
|
save_issue(root, issue).await?;
|
|
rebuild_index(root).await?;
|
|
Ok(())
|
|
}
|
|
.await;
|
|
let _ = tokio::fs::remove_file(&lock_path).await;
|
|
result
|
|
}
|
|
|
|
async fn get_by_ref(
|
|
&self,
|
|
root: &ProjectPath,
|
|
issue_ref: IssueRef,
|
|
) -> Result<Issue, IssueStoreError> {
|
|
load_issue(root, issue_ref).await
|
|
}
|
|
|
|
async fn list(
|
|
&self,
|
|
root: &ProjectPath,
|
|
filter: IssueListFilter,
|
|
) -> Result<Vec<IssueIndexEntry>, IssueStoreError> {
|
|
let rows = read_index(root).await?;
|
|
if filter
|
|
.text
|
|
.as_ref()
|
|
.is_some_and(|text| !text.trim().is_empty())
|
|
{
|
|
// Text search may need description/carnet, so fall back to source files.
|
|
let raw_needle = filter.text.as_ref().unwrap().trim();
|
|
let ref_needle = parse_issue_search_ref(raw_needle);
|
|
let needle = raw_needle.to_ascii_lowercase();
|
|
let base_filter = IssueListFilter {
|
|
text: None,
|
|
..filter
|
|
};
|
|
let mut out = Vec::new();
|
|
for row in rows {
|
|
if !filter_matches(&row, &base_filter) {
|
|
continue;
|
|
}
|
|
if let Some(number) = ref_needle {
|
|
if row.issue_ref.number() == number {
|
|
out.push(row);
|
|
}
|
|
continue;
|
|
}
|
|
if row.title.to_ascii_lowercase().contains(&needle) {
|
|
out.push(row);
|
|
continue;
|
|
}
|
|
if let Ok(issue) = load_issue(root, row.issue_ref).await {
|
|
if issue_contains(&issue, &needle) {
|
|
out.push(row);
|
|
}
|
|
}
|
|
}
|
|
return Ok(out);
|
|
}
|
|
let mut rows = rows;
|
|
rows.retain(|row| filter_matches(row, &filter));
|
|
Ok(rows)
|
|
}
|
|
|
|
async fn update(
|
|
&self,
|
|
root: &ProjectPath,
|
|
issue: &Issue,
|
|
expected_version: IssueVersion,
|
|
) -> Result<(), IssueStoreError> {
|
|
let lock_path = mutation_lock_path(root);
|
|
let _lock_file = acquire_lock(&lock_path, "issue mutation").await?;
|
|
let result = async {
|
|
issue
|
|
.validate()
|
|
.map_err(|err| IssueStoreError::Invalid(err.to_string()))?;
|
|
let current = load_issue(root, issue.reference()).await?;
|
|
if current.version != expected_version {
|
|
return Err(IssueStoreError::VersionConflict {
|
|
expected: expected_version,
|
|
actual: current.version,
|
|
});
|
|
}
|
|
save_issue(root, issue).await?;
|
|
rebuild_index(root).await?;
|
|
Ok(())
|
|
}
|
|
.await;
|
|
let _ = tokio::fs::remove_file(&lock_path).await;
|
|
result
|
|
}
|
|
|
|
async fn delete(&self, root: &ProjectPath, issue_ref: IssueRef) -> Result<(), IssueStoreError> {
|
|
let lock_path = mutation_lock_path(root);
|
|
let _lock_file = acquire_lock(&lock_path, "issue mutation").await?;
|
|
let result = async {
|
|
if !tokio::fs::try_exists(issue_path(root, issue_ref.number()))
|
|
.await
|
|
.map_err(io_error)?
|
|
{
|
|
return Err(IssueStoreError::NotFound);
|
|
}
|
|
tokio::fs::remove_dir_all(issue_dir(root, issue_ref.number()))
|
|
.await
|
|
.map_err(io_error)?;
|
|
rebuild_index(root).await?;
|
|
Ok(())
|
|
}
|
|
.await;
|
|
let _ = tokio::fs::remove_file(&lock_path).await;
|
|
result
|
|
}
|
|
|
|
async fn read_carnet(
|
|
&self,
|
|
root: &ProjectPath,
|
|
issue_ref: IssueRef,
|
|
) -> Result<IssueCarnet, IssueStoreError> {
|
|
let text = read_string(&carnet_path(root, issue_ref.number())).await?;
|
|
parse_carnet_doc(&text)
|
|
}
|
|
|
|
async fn write_carnet(
|
|
&self,
|
|
root: &ProjectPath,
|
|
issue_ref: IssueRef,
|
|
carnet: MarkdownDoc,
|
|
actor: IssueActor,
|
|
now_ms: u64,
|
|
expected_version: IssueVersion,
|
|
) -> Result<IssueCarnet, IssueStoreError> {
|
|
let lock_path = mutation_lock_path(root);
|
|
let _lock_file = acquire_lock(&lock_path, "issue mutation").await?;
|
|
let result = async {
|
|
let current = load_issue(root, issue_ref).await?;
|
|
if current.version != expected_version {
|
|
return Err(IssueStoreError::VersionConflict {
|
|
expected: expected_version,
|
|
actual: current.version,
|
|
});
|
|
}
|
|
let updated = current
|
|
.mutate(actor, now_ms, |issue| issue.carnet = carnet)
|
|
.map_err(|err| IssueStoreError::Invalid(err.to_string()))?;
|
|
save_issue(root, &updated).await?;
|
|
rebuild_index(root).await?;
|
|
self.read_carnet(root, issue_ref).await
|
|
}
|
|
.await;
|
|
let _ = tokio::fs::remove_file(&lock_path).await;
|
|
result
|
|
}
|
|
|
|
async fn add_attachment_from_path(
|
|
&self,
|
|
root: &ProjectPath,
|
|
issue_ref: IssueRef,
|
|
source: &LocalPath,
|
|
attachment_id: IssueAttachmentId,
|
|
filename: String,
|
|
mime: String,
|
|
actor: IssueActor,
|
|
now_ms: u64,
|
|
expected_version: IssueVersion,
|
|
) -> Result<Issue, IssueStoreError> {
|
|
let source_path = PathBuf::from(source.as_str());
|
|
let source_meta = tokio::fs::metadata(&source_path).await.map_err(io_error)?;
|
|
if !source_meta.is_file() {
|
|
return Err(IssueStoreError::Invalid(
|
|
"attachment source must be a file".to_owned(),
|
|
));
|
|
}
|
|
let size_bytes = source_meta.len();
|
|
if size_bytes > ATTACHMENT_MAX_BYTES {
|
|
return Err(IssueStoreError::Invalid(format!(
|
|
"attachment exceeds {} bytes",
|
|
ATTACHMENT_MAX_BYTES
|
|
)));
|
|
}
|
|
let stored_name = format!("{}-{}", attachment_id.as_str(), filename);
|
|
let attachment = IssueAttachment {
|
|
id: attachment_id,
|
|
filename,
|
|
path: format!("{ATTACHMENTS_DIR}/{stored_name}"),
|
|
mime,
|
|
size_bytes,
|
|
added_by: actor,
|
|
added_at: now_ms,
|
|
summarized_in_carnet: false,
|
|
summarized_by: None,
|
|
summarized_at: None,
|
|
};
|
|
|
|
let lock_path = mutation_lock_path(root);
|
|
let _lock_file = acquire_lock(&lock_path, "issue mutation").await?;
|
|
let result = async {
|
|
let current = load_issue(root, issue_ref).await?;
|
|
if current.version != expected_version {
|
|
return Err(IssueStoreError::VersionConflict {
|
|
expected: expected_version,
|
|
actual: current.version,
|
|
});
|
|
}
|
|
if current
|
|
.attachments
|
|
.iter()
|
|
.any(|existing| existing.id == attachment.id)
|
|
{
|
|
return Err(IssueStoreError::Invalid(format!(
|
|
"attachment {} already exists",
|
|
attachment.id.as_str()
|
|
)));
|
|
}
|
|
|
|
let dest = attachment_path(root, issue_ref.number(), &attachment.path);
|
|
let parent = dest.parent().ok_or_else(|| {
|
|
IssueStoreError::Store("attachment path has no parent".to_owned())
|
|
})?;
|
|
tokio::fs::create_dir_all(parent).await.map_err(io_error)?;
|
|
tokio::fs::copy(&source_path, &dest)
|
|
.await
|
|
.map_err(io_error)?;
|
|
|
|
let updated = current
|
|
.mutate(attachment.added_by.clone(), attachment.added_at, |issue| {
|
|
issue.attachments.push(attachment);
|
|
})
|
|
.map_err(|err| IssueStoreError::Invalid(err.to_string()))?;
|
|
save_issue(root, &updated).await?;
|
|
rebuild_index(root).await?;
|
|
Ok(updated)
|
|
}
|
|
.await;
|
|
let _ = tokio::fs::remove_file(&lock_path).await;
|
|
result
|
|
}
|
|
|
|
async fn read_attachment(
|
|
&self,
|
|
root: &ProjectPath,
|
|
issue_ref: IssueRef,
|
|
attachment_id: &IssueAttachmentId,
|
|
) -> Result<IssueAttachmentContent, IssueStoreError> {
|
|
let issue = load_issue(root, issue_ref).await?;
|
|
let attachment = attachment_by_id(&issue, attachment_id)?;
|
|
let bytes = tokio::fs::read(attachment_path(root, issue.number, &attachment.path))
|
|
.await
|
|
.map_err(|err| {
|
|
if err.kind() == std::io::ErrorKind::NotFound {
|
|
IssueStoreError::NotFound
|
|
} else {
|
|
io_error(err)
|
|
}
|
|
})?;
|
|
Ok(IssueAttachmentContent { attachment, bytes })
|
|
}
|
|
|
|
async fn mark_attachment_summarized(
|
|
&self,
|
|
root: &ProjectPath,
|
|
issue_ref: IssueRef,
|
|
attachment_id: &IssueAttachmentId,
|
|
actor: IssueActor,
|
|
now_ms: u64,
|
|
expected_version: IssueVersion,
|
|
) -> Result<Issue, IssueStoreError> {
|
|
let lock_path = mutation_lock_path(root);
|
|
let _lock_file = acquire_lock(&lock_path, "issue mutation").await?;
|
|
let result = async {
|
|
let current = load_issue(root, issue_ref).await?;
|
|
if current.version != expected_version {
|
|
return Err(IssueStoreError::VersionConflict {
|
|
expected: expected_version,
|
|
actual: current.version,
|
|
});
|
|
}
|
|
if !current
|
|
.attachments
|
|
.iter()
|
|
.any(|attachment| &attachment.id == attachment_id)
|
|
{
|
|
return Err(IssueStoreError::NotFound);
|
|
}
|
|
let updated = current
|
|
.mutate(actor.clone(), now_ms, |issue| {
|
|
if let Some(attachment) = issue
|
|
.attachments
|
|
.iter_mut()
|
|
.find(|attachment| &attachment.id == attachment_id)
|
|
{
|
|
attachment.summarized_in_carnet = true;
|
|
attachment.summarized_by = Some(actor);
|
|
attachment.summarized_at = Some(now_ms);
|
|
}
|
|
})
|
|
.map_err(|err| IssueStoreError::Invalid(err.to_string()))?;
|
|
save_issue(root, &updated).await?;
|
|
rebuild_index(root).await?;
|
|
Ok(updated)
|
|
}
|
|
.await;
|
|
let _ = tokio::fs::remove_file(&lock_path).await;
|
|
result
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl IssueNumberAllocator for FsIssueNumberAllocator {
|
|
async fn allocate_next(&self, root: &ProjectPath) -> Result<IssueNumber, IssueStoreError> {
|
|
let dir = issue_root(root);
|
|
tokio::fs::create_dir_all(&dir).await.map_err(io_error)?;
|
|
let lock_path = counter_lock_path(root);
|
|
let _lock_file = acquire_lock(&lock_path, "issue number allocator").await?;
|
|
|
|
let result = async {
|
|
let path = counter_path(root);
|
|
let current = match tokio::fs::read(&path).await {
|
|
Ok(bytes) => {
|
|
serde_json::from_slice::<CounterDoc>(&bytes)
|
|
.map_err(|err| IssueStoreError::Store(err.to_string()))?
|
|
.next_number
|
|
}
|
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => 1,
|
|
Err(err) => return Err(io_error(err)),
|
|
};
|
|
let allocated = IssueNumber::new(current)
|
|
.map_err(|err| IssueStoreError::Invalid(err.to_string()))?;
|
|
let next = CounterDoc {
|
|
next_number: current + 1,
|
|
};
|
|
let bytes = serde_json::to_vec_pretty(&next)
|
|
.map_err(|err| IssueStoreError::Store(err.to_string()))?;
|
|
write_atomic(&path, &bytes).await?;
|
|
Ok(allocated)
|
|
}
|
|
.await;
|
|
|
|
let _ = tokio::fs::remove_file(&lock_path).await;
|
|
result
|
|
}
|
|
}
|
|
|
|
fn issue_contains(issue: &Issue, needle: &str) -> bool {
|
|
issue
|
|
.description
|
|
.as_str()
|
|
.to_ascii_lowercase()
|
|
.contains(needle)
|
|
|| issue.carnet.as_str().to_ascii_lowercase().contains(needle)
|
|
}
|
|
|
|
fn render_issue_doc(issue: &Issue) -> String {
|
|
format!(
|
|
"---\n\
|
|
id: {}\n\
|
|
number: {}\n\
|
|
title: {}\n\
|
|
status: {}\n\
|
|
priority: {}\n\
|
|
sprint: {}\n\
|
|
links: {}\n\
|
|
agentRefs: {}\n\
|
|
attachments: {}\n\
|
|
createdBy: {}\n\
|
|
updatedBy: {}\n\
|
|
createdAt: {}\n\
|
|
updatedAt: {}\n\
|
|
version: {}\n\
|
|
---\n{}",
|
|
json_string(&issue.id.to_string()),
|
|
issue.number.get(),
|
|
json_string(&issue.title),
|
|
json_value(&issue.status),
|
|
json_value(&issue.priority),
|
|
json_value(&issue.sprint),
|
|
json_value(&issue.links),
|
|
json_value(&issue.agent_refs),
|
|
json_value(&issue.attachments),
|
|
json_value(&issue.created_by),
|
|
json_value(&issue.updated_by),
|
|
issue.created_at,
|
|
issue.updated_at,
|
|
issue.version.get(),
|
|
issue.description.as_str()
|
|
)
|
|
}
|
|
|
|
fn render_carnet_doc(issue: &Issue) -> String {
|
|
format!(
|
|
"---\n\
|
|
issueRef: {}\n\
|
|
version: {}\n\
|
|
updatedBy: {}\n\
|
|
updatedAt: {}\n\
|
|
---\n{}",
|
|
json_string(&issue.reference().to_string()),
|
|
issue.version.get(),
|
|
json_value(&issue.updated_by),
|
|
issue.updated_at,
|
|
issue.carnet.as_str()
|
|
)
|
|
}
|
|
|
|
fn parse_issue_doc(text: &str) -> Result<Issue, IssueStoreError> {
|
|
let (fm, body) = split_frontmatter(text)?;
|
|
let map = parse_frontmatter_map(fm)?;
|
|
let id_raw: String = parse_json_field(&map, "id")?;
|
|
let id = IssueId::from_uuid(
|
|
uuid::Uuid::parse_str(&id_raw).map_err(|err| IssueStoreError::Invalid(err.to_string()))?,
|
|
);
|
|
let number = IssueNumber::new(parse_plain_u64(&map, "number")?)
|
|
.map_err(|err| IssueStoreError::Invalid(err.to_string()))?;
|
|
let title: String = parse_json_field(&map, "title")?;
|
|
let status: IssueStatus = parse_json_field(&map, "status")?;
|
|
let priority: IssuePriority = parse_json_field(&map, "priority")?;
|
|
let sprint: Option<SprintId> = parse_optional_json_field(&map, "sprint")?.unwrap_or(None);
|
|
let links: Vec<IssueLink> = parse_json_field(&map, "links")?;
|
|
let agent_refs: Vec<AgentIssueRef> = parse_json_field(&map, "agentRefs")?;
|
|
let attachments: Vec<IssueAttachment> =
|
|
parse_optional_json_field(&map, "attachments")?.unwrap_or_default();
|
|
let created_by: IssueActor = parse_json_field(&map, "createdBy")?;
|
|
let updated_by: IssueActor = parse_json_field(&map, "updatedBy")?;
|
|
let created_at = parse_plain_u64(&map, "createdAt")?;
|
|
let updated_at = parse_plain_u64(&map, "updatedAt")?;
|
|
let version = IssueVersion::new(parse_plain_u64(&map, "version")?)
|
|
.map_err(|err| IssueStoreError::Invalid(err.to_string()))?;
|
|
Issue::rehydrate(Issue {
|
|
id,
|
|
number,
|
|
title,
|
|
description: MarkdownDoc::new(body),
|
|
status,
|
|
priority,
|
|
sprint,
|
|
carnet: MarkdownDoc::default(),
|
|
links,
|
|
agent_refs,
|
|
attachments,
|
|
created_by,
|
|
updated_by,
|
|
created_at,
|
|
updated_at,
|
|
version,
|
|
})
|
|
.map_err(|err| IssueStoreError::Invalid(err.to_string()))
|
|
}
|
|
|
|
fn parse_carnet_doc(text: &str) -> Result<IssueCarnet, IssueStoreError> {
|
|
let (fm, body) = split_frontmatter(text)?;
|
|
let map = parse_frontmatter_map(fm)?;
|
|
let raw_ref: String = parse_json_field(&map, "issueRef")?;
|
|
let issue_ref =
|
|
IssueRef::from_str(&raw_ref).map_err(|err| IssueStoreError::Invalid(err.to_string()))?;
|
|
let version = IssueVersion::new(parse_plain_u64(&map, "version")?)
|
|
.map_err(|err| IssueStoreError::Invalid(err.to_string()))?;
|
|
let updated_by: IssueActor = parse_json_field(&map, "updatedBy")?;
|
|
let updated_at = parse_plain_u64(&map, "updatedAt")?;
|
|
Ok(IssueCarnet {
|
|
issue_ref,
|
|
carnet: MarkdownDoc::new(body),
|
|
version,
|
|
updated_by,
|
|
updated_at,
|
|
})
|
|
}
|
|
|
|
fn split_frontmatter(text: &str) -> Result<(&str, &str), IssueStoreError> {
|
|
let rest = text
|
|
.strip_prefix("---\n")
|
|
.or_else(|| text.strip_prefix("---\r\n"))
|
|
.ok_or_else(|| IssueStoreError::Invalid("missing opening frontmatter fence".to_owned()))?;
|
|
let mut offset = 0;
|
|
for line in rest.split_inclusive('\n') {
|
|
if line.trim_end_matches(['\r', '\n']) == "---" {
|
|
let body_start = offset + line.len();
|
|
return Ok((&rest[..offset], &rest[body_start..]));
|
|
}
|
|
offset += line.len();
|
|
}
|
|
Err(IssueStoreError::Invalid(
|
|
"missing closing frontmatter fence".to_owned(),
|
|
))
|
|
}
|
|
|
|
fn parse_frontmatter_map(block: &str) -> Result<BTreeMap<String, String>, IssueStoreError> {
|
|
let mut map = BTreeMap::new();
|
|
for line in block.lines().filter(|line| !line.trim().is_empty()) {
|
|
let (key, value) = line
|
|
.split_once(':')
|
|
.ok_or_else(|| IssueStoreError::Invalid("frontmatter line missing ':'".to_owned()))?;
|
|
map.insert(key.trim().to_owned(), value.trim().to_owned());
|
|
}
|
|
Ok(map)
|
|
}
|
|
|
|
fn parse_plain_u64(map: &BTreeMap<String, String>, key: &str) -> Result<u64, IssueStoreError> {
|
|
map.get(key)
|
|
.ok_or_else(|| IssueStoreError::Invalid(format!("missing frontmatter key `{key}`")))?
|
|
.parse::<u64>()
|
|
.map_err(|err| IssueStoreError::Invalid(err.to_string()))
|
|
}
|
|
|
|
fn parse_json_field<T: serde::de::DeserializeOwned>(
|
|
map: &BTreeMap<String, String>,
|
|
key: &str,
|
|
) -> Result<T, IssueStoreError> {
|
|
let value = map
|
|
.get(key)
|
|
.ok_or_else(|| IssueStoreError::Invalid(format!("missing frontmatter key `{key}`")))?;
|
|
serde_json::from_str(value).map_err(|err| IssueStoreError::Invalid(err.to_string()))
|
|
}
|
|
|
|
fn parse_optional_json_field<T: serde::de::DeserializeOwned>(
|
|
map: &BTreeMap<String, String>,
|
|
key: &str,
|
|
) -> Result<Option<T>, IssueStoreError> {
|
|
let Some(value) = map.get(key) else {
|
|
return Ok(None);
|
|
};
|
|
serde_json::from_str(value)
|
|
.map(Some)
|
|
.map_err(|err| IssueStoreError::Invalid(err.to_string()))
|
|
}
|
|
|
|
fn json_value<T: Serialize>(value: &T) -> String {
|
|
serde_json::to_string(value).expect("serializing issue frontmatter cannot fail")
|
|
}
|
|
|
|
fn json_string(value: &str) -> String {
|
|
serde_json::to_string(value).expect("serializing issue string cannot fail")
|
|
}
|