1use chrono::{DateTime, Utc};
2use sqlx::PgPool;
3use uuid::Uuid;
4
5use dk_core::{RepoId, SymbolId};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum ChangesetState {
10 Draft,
11 Submitted,
12 Verifying,
13 Approved,
14 Rejected,
15 Merged,
16 Closed,
17}
18
19impl ChangesetState {
20 pub fn parse(s: &str) -> Option<Self> {
22 match s {
23 "draft" => Some(Self::Draft),
24 "submitted" => Some(Self::Submitted),
25 "verifying" => Some(Self::Verifying),
26 "approved" => Some(Self::Approved),
27 "rejected" => Some(Self::Rejected),
28 "merged" => Some(Self::Merged),
29 "closed" => Some(Self::Closed),
30 _ => None,
31 }
32 }
33
34 pub fn as_str(&self) -> &'static str {
36 match self {
37 Self::Draft => "draft",
38 Self::Submitted => "submitted",
39 Self::Verifying => "verifying",
40 Self::Approved => "approved",
41 Self::Rejected => "rejected",
42 Self::Merged => "merged",
43 Self::Closed => "closed",
44 }
45 }
46
47 pub fn can_transition_to(&self, target: Self) -> bool {
56 if target == Self::Closed {
57 return true;
58 }
59 matches!(
60 (self, target),
61 (Self::Draft, Self::Submitted)
62 | (Self::Submitted, Self::Verifying)
63 | (Self::Verifying, Self::Approved)
64 | (Self::Verifying, Self::Rejected)
65 | (Self::Approved, Self::Merged)
66 )
67 }
68}
69
70impl std::fmt::Display for ChangesetState {
71 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72 f.write_str(self.as_str())
73 }
74}
75
76#[derive(Debug, Clone, sqlx::FromRow)]
77pub struct Changeset {
78 pub id: Uuid,
79 pub repo_id: RepoId,
80 pub number: i32,
81 pub title: String,
82 pub intent_summary: Option<String>,
83 pub source_branch: String,
84 pub target_branch: String,
85 pub state: String,
86 pub reason: String,
87 pub session_id: Option<Uuid>,
88 pub agent_id: Option<String>,
89 pub agent_name: Option<String>,
90 pub author_id: Option<Uuid>,
91 pub base_version: Option<String>,
92 pub merged_version: Option<String>,
93 pub created_at: DateTime<Utc>,
94 pub updated_at: DateTime<Utc>,
95 pub merged_at: Option<DateTime<Utc>>,
96 pub parent_changeset_id: Option<Uuid>,
100}
101
102impl Changeset {
103 pub fn parsed_state(&self) -> Option<ChangesetState> {
105 ChangesetState::parse(&self.state)
106 }
107
108 pub fn transition(
111 &mut self,
112 target: ChangesetState,
113 reason: impl Into<String>,
114 ) -> dk_core::Result<()> {
115 let current = self.parsed_state().ok_or_else(|| {
116 dk_core::Error::Internal(format!("unknown current state: '{}'", self.state))
117 })?;
118
119 if !current.can_transition_to(target) {
120 return Err(dk_core::Error::InvalidInput(format!(
121 "invalid state transition: '{}' -> '{}'",
122 current, target,
123 )));
124 }
125
126 self.state = target.as_str().to_string();
127 self.reason = reason.into();
128 Ok(())
129 }
130}
131
132#[derive(Debug, Clone)]
133pub struct ChangesetFile {
134 pub changeset_id: Uuid,
135 pub file_path: String,
136 pub operation: String,
137 pub content: Option<String>,
138}
139
140#[derive(Debug, Clone)]
141pub struct ChangesetFileMeta {
142 pub file_path: String,
143 pub operation: String,
144 pub size_bytes: i64,
145}
146
147pub struct ChangesetStore {
148 db: PgPool,
149}
150
151impl ChangesetStore {
152 pub fn new(db: PgPool) -> Self {
153 Self { db }
154 }
155
156 pub async fn create(
162 &self,
163 repo_id: RepoId,
164 session_id: Option<Uuid>,
165 agent_id: &str,
166 intent: &str,
167 base_version: Option<&str>,
168 agent_name: &str,
169 ) -> dk_core::Result<Changeset> {
170 let intent_slug: String = intent
171 .to_lowercase()
172 .chars()
173 .map(|c| if c.is_alphanumeric() || c == '-' { c } else { '-' })
174 .collect::<String>()
175 .trim_matches('-')
176 .to_string();
177 let slug = if intent_slug.len() > 50 {
178 let cut = intent_slug
179 .char_indices()
180 .take_while(|(i, _)| *i < 50)
181 .last()
182 .map(|(i, c)| i + c.len_utf8())
183 .unwrap_or(0);
184 intent_slug[..cut].trim_end_matches('-').to_string()
185 } else {
186 intent_slug
187 };
188 let source_branch = format!("{}/{}", slug, agent_name);
189 let target_branch = "main";
190
191 let mut tx = self.db.begin().await?;
192
193 sqlx::query("SELECT pg_advisory_xact_lock(hashtext('changeset:' || $1::text))")
194 .bind(repo_id)
195 .execute(&mut *tx)
196 .await?;
197
198 let row: (Uuid, i32, String, String, DateTime<Utc>, DateTime<Utc>) = sqlx::query_as(
199 r#"INSERT INTO changesets
200 (repo_id, number, title, intent_summary, source_branch, target_branch,
201 state, reason, session_id, agent_id, agent_name, base_version)
202 SELECT $1, COALESCE(MAX(number), 0) + 1, $2, $2, $3, $4,
203 'draft', 'created via agent connect', $5, $6, $7, $8
204 FROM changesets WHERE repo_id = $1
205 RETURNING id, number, state, reason, created_at, updated_at"#,
206 )
207 .bind(repo_id)
208 .bind(intent)
209 .bind(&source_branch)
210 .bind(target_branch)
211 .bind(session_id)
212 .bind(agent_id)
213 .bind(agent_name)
214 .bind(base_version)
215 .fetch_one(&mut *tx)
216 .await?;
217
218 tx.commit().await?;
219
220 Ok(Changeset {
221 id: row.0,
222 repo_id,
223 number: row.1,
224 title: intent.to_string(),
225 intent_summary: Some(intent.to_string()),
226 source_branch,
227 target_branch: target_branch.to_string(),
228 state: row.2,
229 reason: row.3,
230 session_id,
231 agent_id: Some(agent_id.to_string()),
232 agent_name: Some(agent_name.to_string()),
233 author_id: None,
234 base_version: base_version.map(String::from),
235 merged_version: None,
236 created_at: row.4,
237 updated_at: row.5,
238 merged_at: None,
239 parent_changeset_id: None,
240 })
241 }
242
243 pub async fn get(&self, id: Uuid) -> dk_core::Result<Changeset> {
244 sqlx::query_as::<_, Changeset>(
245 r#"SELECT id, repo_id, number, title, intent_summary,
246 source_branch, target_branch, state, reason,
247 session_id, agent_id, agent_name, author_id,
248 base_version, merged_version,
249 created_at, updated_at, merged_at,
250 parent_changeset_id
251 FROM changesets WHERE id = $1"#,
252 )
253 .bind(id)
254 .fetch_optional(&self.db)
255 .await?
256 .ok_or_else(|| dk_core::Error::Internal(format!("changeset {} not found", id)))
257 }
258
259 pub async fn update_status(&self, id: Uuid, status: &str) -> dk_core::Result<()> {
260 self.update_status_with_reason(id, status, "").await
261 }
262
263 pub async fn update_status_with_reason(
265 &self,
266 id: Uuid,
267 status: &str,
268 reason: &str,
269 ) -> dk_core::Result<()> {
270 sqlx::query(
271 "UPDATE changesets SET state = $1, reason = $2, updated_at = now() WHERE id = $3",
272 )
273 .bind(status)
274 .bind(reason)
275 .bind(id)
276 .execute(&self.db)
277 .await?;
278 Ok(())
279 }
280
281 pub async fn update_status_if(
285 &self,
286 id: Uuid,
287 new_status: &str,
288 expected_states: &[&str],
289 ) -> dk_core::Result<()> {
290 self.update_status_if_with_reason(id, new_status, expected_states, "").await
291 }
292
293 pub async fn update_status_if_with_reason(
295 &self,
296 id: Uuid,
297 new_status: &str,
298 expected_states: &[&str],
299 reason: &str,
300 ) -> dk_core::Result<()> {
301 let states: Vec<String> = expected_states.iter().map(|s| s.to_string()).collect();
302 let result = sqlx::query(
303 "UPDATE changesets SET state = $1, reason = $2, updated_at = now() WHERE id = $3 AND state = ANY($4)",
304 )
305 .bind(new_status)
306 .bind(reason)
307 .bind(id)
308 .bind(&states)
309 .execute(&self.db)
310 .await?;
311
312 if result.rows_affected() == 0 {
313 return Err(dk_core::Error::Internal(format!(
314 "changeset {} not found or not in expected state (expected one of: {:?})",
315 id, expected_states,
316 )));
317 }
318 Ok(())
319 }
320
321 pub async fn set_merged(&self, id: Uuid, commit_hash: &str) -> dk_core::Result<()> {
322 sqlx::query(
323 "UPDATE changesets SET state = 'merged', reason = 'merge completed', merged_version = $1, merged_at = now(), updated_at = now() WHERE id = $2",
324 )
325 .bind(commit_hash)
326 .bind(id)
327 .execute(&self.db)
328 .await?;
329 Ok(())
330 }
331
332 pub async fn upsert_file(
333 &self,
334 changeset_id: Uuid,
335 file_path: &str,
336 operation: &str,
337 content: Option<&str>,
338 ) -> dk_core::Result<()> {
339 sqlx::query(
340 r#"INSERT INTO changeset_files (changeset_id, file_path, operation, content)
341 VALUES ($1, $2, $3, $4)
342 ON CONFLICT (changeset_id, file_path) DO UPDATE SET
343 operation = EXCLUDED.operation,
344 content = EXCLUDED.content"#,
345 )
346 .bind(changeset_id)
347 .bind(file_path)
348 .bind(operation)
349 .bind(content)
350 .execute(&self.db)
351 .await?;
352 Ok(())
353 }
354
355 pub async fn get_files(&self, changeset_id: Uuid) -> dk_core::Result<Vec<ChangesetFile>> {
356 let rows: Vec<(Uuid, String, String, Option<String>)> = sqlx::query_as(
357 "SELECT changeset_id, file_path, operation, content FROM changeset_files WHERE changeset_id = $1",
358 )
359 .bind(changeset_id)
360 .fetch_all(&self.db)
361 .await?;
362
363 Ok(rows
364 .into_iter()
365 .map(|r| ChangesetFile {
366 changeset_id: r.0,
367 file_path: r.1,
368 operation: r.2,
369 content: r.3,
370 })
371 .collect())
372 }
373
374 pub async fn get_files_metadata(&self, changeset_id: Uuid) -> dk_core::Result<Vec<ChangesetFileMeta>> {
377 let rows: Vec<(String, String, i64)> = sqlx::query_as(
378 "SELECT file_path, operation, COALESCE(LENGTH(content), 0)::bigint AS size_bytes FROM changeset_files WHERE changeset_id = $1",
379 )
380 .bind(changeset_id)
381 .fetch_all(&self.db)
382 .await?;
383
384 Ok(rows
385 .into_iter()
386 .map(|r| ChangesetFileMeta {
387 file_path: r.0,
388 operation: r.1,
389 size_bytes: r.2,
390 })
391 .collect())
392 }
393
394 pub async fn record_affected_symbol(
395 &self,
396 changeset_id: Uuid,
397 symbol_id: SymbolId,
398 qualified_name: &str,
399 ) -> dk_core::Result<()> {
400 sqlx::query(
401 r#"INSERT INTO changeset_symbols (changeset_id, symbol_id, symbol_qualified_name)
402 VALUES ($1, $2, $3)
403 ON CONFLICT DO NOTHING"#,
404 )
405 .bind(changeset_id)
406 .bind(symbol_id)
407 .bind(qualified_name)
408 .execute(&self.db)
409 .await?;
410 Ok(())
411 }
412
413 pub async fn get_affected_symbols(&self, changeset_id: Uuid) -> dk_core::Result<Vec<(SymbolId, String)>> {
414 let rows: Vec<(Uuid, String)> = sqlx::query_as(
415 "SELECT symbol_id, symbol_qualified_name FROM changeset_symbols WHERE changeset_id = $1",
416 )
417 .bind(changeset_id)
418 .fetch_all(&self.db)
419 .await?;
420 Ok(rows)
421 }
422
423 pub async fn list_path_competitors(
435 &self,
436 repo_id: RepoId,
437 path: &str,
438 ) -> dk_core::Result<Vec<(Uuid, Option<Uuid>, String, DateTime<Utc>)>> {
439 let rows: Vec<(Uuid, Option<Uuid>, String, DateTime<Utc>)> = sqlx::query_as(
440 r#"SELECT c.id, c.session_id, c.state, c.updated_at
441 FROM changesets c
442 JOIN changeset_files cf ON cf.changeset_id = c.id
443 WHERE c.repo_id = $1
444 AND cf.file_path = $2
445 AND c.state IN ('submitted', 'verifying', 'approved')"#,
446 )
447 .bind(repo_id)
448 .bind(path)
449 .fetch_all(&self.db)
450 .await?;
451 Ok(rows)
452 }
453
454 pub async fn find_conflicting_changesets(
458 &self,
459 repo_id: RepoId,
460 base_version: &str,
461 my_changeset_id: Uuid,
462 ) -> dk_core::Result<Vec<(Uuid, Vec<String>)>> {
463 let rows: Vec<(Uuid, String)> = sqlx::query_as(
464 r#"SELECT DISTINCT cs.changeset_id, cs.symbol_qualified_name
465 FROM changeset_symbols cs
466 JOIN changesets c ON c.id = cs.changeset_id
467 WHERE c.repo_id = $1
468 AND c.state = 'merged'
469 AND c.id != $2
470 AND c.merged_version IS NOT NULL
471 AND c.merged_version != $3
472 AND cs.symbol_qualified_name IN (
473 SELECT symbol_qualified_name FROM changeset_symbols WHERE changeset_id = $2
474 )"#,
475 )
476 .bind(repo_id)
477 .bind(my_changeset_id)
478 .bind(base_version)
479 .fetch_all(&self.db)
480 .await?;
481
482 let mut map: std::collections::HashMap<Uuid, Vec<String>> = std::collections::HashMap::new();
483 for (cs_id, sym_name) in rows {
484 map.entry(cs_id).or_default().push(sym_name);
485 }
486 Ok(map.into_iter().collect())
487 }
488}
489
490#[cfg(test)]
491mod tests {
492 use super::*;
493
494 fn slugify_intent(intent: &str) -> String {
497 let slug: String = intent
498 .to_lowercase()
499 .chars()
500 .map(|c| if c.is_alphanumeric() || c == '-' { c } else { '-' })
501 .collect::<String>()
502 .trim_matches('-')
503 .to_string();
504 if slug.len() > 50 {
505 slug[..50].trim_end_matches('-').to_string()
506 } else {
507 slug
508 }
509 }
510
511 #[test]
512 fn source_branch_format_uses_intent_slug() {
513 let intent = "Fix UI bugs";
514 let agent_name = "agent-1";
515 let source_branch = format!("{}/{}", slugify_intent(intent), agent_name);
516 assert_eq!(source_branch, "fix-ui-bugs/agent-1");
517 }
518
519 #[test]
520 fn source_branch_format_with_custom_name() {
521 let intent = "Add comments endpoint";
522 let agent_name = "feature-bot";
523 let source_branch = format!("{}/{}", slugify_intent(intent), agent_name);
524 assert_eq!(source_branch, "add-comments-endpoint/feature-bot");
525 }
526
527 #[test]
528 fn target_branch_is_main() {
529 let target_branch = "main";
531 assert_eq!(target_branch, "main");
532 }
533
534 fn test_changeset_fixture() -> Changeset {
541 let now = Utc::now();
542 Changeset {
543 id: Uuid::new_v4(),
544 repo_id: Uuid::new_v4(),
545 number: 1,
546 title: String::new(),
547 intent_summary: None,
548 source_branch: "agent/test".to_string(),
549 target_branch: "main".to_string(),
550 state: "draft".to_string(),
551 reason: String::new(),
552 session_id: None,
553 agent_id: None,
554 agent_name: None,
555 author_id: None,
556 base_version: None,
557 merged_version: None,
558 created_at: now,
559 updated_at: now,
560 merged_at: None,
561 parent_changeset_id: None,
562 }
563 }
564
565 #[test]
568 fn changeset_create_shape_has_correct_branches() {
569 let repo_id = Uuid::new_v4();
570 let session_id = Uuid::new_v4();
571 let agent_id = "test-agent";
572 let intent = "fix all the bugs";
573
574 let source_branch = format!("agent/{}", agent_id);
575
576 let cs = Changeset {
577 repo_id,
578 title: intent.to_string(),
579 intent_summary: Some(intent.to_string()),
580 source_branch: source_branch.clone(),
581 session_id: Some(session_id),
582 agent_id: Some(agent_id.to_string()),
583 agent_name: Some(agent_id.to_string()),
584 base_version: Some("abc123".to_string()),
585 ..test_changeset_fixture()
586 };
587
588 assert_eq!(cs.source_branch, "agent/test-agent");
589 assert_eq!(cs.target_branch, "main");
590 assert_eq!(cs.agent_name.as_deref(), Some("test-agent"));
591 assert_eq!(cs.agent_id, cs.agent_name, "agent_name should equal agent_id per create()");
592 assert!(cs.merged_at.is_none());
593 assert!(cs.merged_version.is_none());
594 }
595
596 #[test]
599 fn changeset_all_fields_accessible() {
600 let id = Uuid::new_v4();
601 let repo_id = Uuid::new_v4();
602
603 let cs = Changeset {
604 id,
605 repo_id,
606 number: 42,
607 title: "test".to_string(),
608 source_branch: "agent/a".to_string(),
609 ..test_changeset_fixture()
610 };
611
612 assert_eq!(cs.id, id);
613 assert_eq!(cs.repo_id, repo_id);
614 assert_eq!(cs.number, 42);
615 assert_eq!(cs.title, "test");
616 assert!(cs.intent_summary.is_none());
617 assert!(cs.session_id.is_none());
618 assert!(cs.agent_id.is_none());
619 assert!(cs.agent_name.is_none());
620 assert!(cs.author_id.is_none());
621 assert!(cs.base_version.is_none());
622 assert!(cs.merged_version.is_none());
623 assert!(cs.merged_at.is_none());
624 }
625
626 #[test]
627 fn changeset_file_meta_struct() {
628 let meta = ChangesetFileMeta {
629 file_path: "src/main.rs".to_string(),
630 operation: "modify".to_string(),
631 size_bytes: 1024,
632 };
633 assert_eq!(meta.file_path, "src/main.rs");
634 assert_eq!(meta.operation, "modify");
635 assert_eq!(meta.size_bytes, 1024);
636 }
637
638 #[test]
639 fn changeset_file_struct() {
640 let cf = ChangesetFile {
641 changeset_id: Uuid::new_v4(),
642 file_path: "lib.rs".to_string(),
643 operation: "add".to_string(),
644 content: Some("fn main() {}".to_string()),
645 };
646 assert_eq!(cf.file_path, "lib.rs");
647 assert_eq!(cf.operation, "add");
648 assert!(cf.content.is_some());
649 }
650
651 #[test]
652 fn changeset_clone_produces_equal_values() {
653 let cs = Changeset {
654 title: "clone test".to_string(),
655 intent_summary: Some("intent".to_string()),
656 source_branch: "agent/x".to_string(),
657 agent_id: Some("x".to_string()),
658 agent_name: Some("x".to_string()),
659 ..test_changeset_fixture()
660 };
661
662 let cloned = cs.clone();
663 assert_eq!(cs.id, cloned.id);
664 assert_eq!(cs.source_branch, cloned.source_branch);
665 assert_eq!(cs.target_branch, cloned.target_branch);
666 assert_eq!(cs.state, cloned.state);
667 }
668}