1use std::collections::BTreeSet;
4use std::io::{Error as IoError, ErrorKind};
5use std::path::PathBuf;
6use std::sync::Arc;
7use std::time::Duration;
8
9use chrono::{DateTime, Utc};
10use serde::Deserialize;
11use serde::de::DeserializeOwned;
12
13use crate::backend_client::{BackendRuntime, is_retriable_status};
14use ito_domain::backend::{
15 ArchiveResult, ArtifactBundle, BackendArchiveClient, BackendChangeReader, BackendModuleReader,
16 BackendSpecReader, BackendSyncClient, PushResult,
17};
18use ito_domain::changes::{Change, ChangeLifecycleFilter, ChangeSummary, Spec};
19use ito_domain::errors::{DomainError, DomainResult};
20use ito_domain::modules::{Module, ModuleSummary};
21use ito_domain::specs::{SpecDocument, SpecSummary};
22use ito_domain::tasks::{
23 DiagnosticLevel, ProgressInfo, TaskDiagnostic, TaskInitResult, TaskItem, TaskKind,
24 TaskMutationError, TaskMutationResult, TaskMutationService, TaskMutationServiceResult,
25 TaskStatus, TasksFormat, TasksParseResult, WaveInfo,
26};
27
28#[derive(Debug, Clone)]
30pub struct BackendHttpClient {
31 inner: Arc<BackendHttpClientInner>,
32}
33
34#[derive(Debug)]
35struct BackendHttpClientInner {
36 runtime: BackendRuntime,
37 agent: ureq::Agent,
38}
39
40impl BackendHttpClient {
41 pub fn new(runtime: BackendRuntime) -> Self {
43 let agent = ureq::Agent::config_builder()
44 .timeout_global(Some(runtime.timeout))
45 .http_status_as_error(false)
46 .build()
47 .into();
48 Self {
49 inner: Arc::new(BackendHttpClientInner { runtime, agent }),
50 }
51 }
52
53 pub(crate) fn load_tasks_parse_result(
54 &self,
55 change_id: &str,
56 ) -> DomainResult<TasksParseResult> {
57 let url = format!(
58 "{}/changes/{change_id}/tasks",
59 self.inner.runtime.project_api_prefix()
60 );
61 let list: ApiTaskList = self.get_json(&url, "task", Some(change_id))?;
62 Ok(task_list_to_parse_result(list))
63 }
64
65 fn get_json<T: DeserializeOwned>(
66 &self,
67 url: &str,
68 entity: &'static str,
69 id: Option<&str>,
70 ) -> DomainResult<T> {
71 let response = self.request_with_retry("GET", url, None)?;
72 let status = response.status().as_u16();
73 let body = read_response_body(response)?;
74 if status != 200 {
75 return Err(map_status_to_domain_error(status, entity, id, &body));
76 }
77 serde_json::from_str(&body)
78 .map_err(|err| DomainError::io("parsing backend response", IoError::other(err)))
79 }
80
81 fn task_get_json<T: DeserializeOwned>(&self, url: &str) -> TaskMutationServiceResult<T> {
82 let response = self
83 .request_with_retry("GET", url, None)
84 .map_err(task_error_from_domain)?;
85 parse_task_response(response)
86 }
87
88 fn task_post_json<T: DeserializeOwned>(
89 &self,
90 url: &str,
91 body: Option<&str>,
92 ) -> TaskMutationServiceResult<T> {
93 let response = self
94 .request_with_retry("POST", url, body)
95 .map_err(task_error_from_domain)?;
96 parse_task_response(response)
97 }
98
99 fn backend_get_json<T: DeserializeOwned>(
100 &self,
101 url: &str,
102 ) -> Result<T, ito_domain::backend::BackendError> {
103 let response = self
104 .request_with_retry("GET", url, None)
105 .map_err(backend_error_from_domain)?;
106 parse_backend_response(response)
107 }
108
109 fn backend_post_json<T: DeserializeOwned>(
110 &self,
111 url: &str,
112 body: Option<&str>,
113 ) -> Result<T, ito_domain::backend::BackendError> {
114 let response = self
115 .request_with_retry("POST", url, body)
116 .map_err(backend_error_from_domain)?;
117 parse_backend_response(response)
118 }
119
120 fn request_with_retry(
121 &self,
122 method: &str,
123 url: &str,
124 body: Option<&str>,
125 ) -> DomainResult<ureq::http::Response<ureq::Body>> {
126 let max_retries = self.inner.runtime.max_retries;
127 let retries_enabled = retries_enabled_by_default(method);
128 let mut attempt = 0u32;
129 loop {
130 let response: Result<ureq::http::Response<ureq::Body>, ureq::Error> = match method {
131 "GET" => self
132 .inner
133 .agent
134 .get(url)
135 .header(
136 "Authorization",
137 &format!("Bearer {}", self.inner.runtime.token),
138 )
139 .call(),
140 "POST" => {
141 let payload = body.unwrap_or("{}");
143 self.inner
144 .agent
145 .post(url)
146 .header(
147 "Authorization",
148 &format!("Bearer {}", self.inner.runtime.token),
149 )
150 .header("Content-Type", "application/json")
151 .send(payload)
152 }
153 _ => unreachable!("unsupported backend http method"),
154 };
155
156 match response {
157 Ok(resp) => {
158 let status: u16 = resp.status().as_u16();
159 if retries_enabled && is_retriable_status(status) && attempt < max_retries {
160 attempt += 1;
161 sleep_backoff(attempt);
162 continue;
163 }
164 return Ok(resp);
165 }
166 Err(err) => {
167 if retries_enabled && attempt < max_retries {
168 attempt += 1;
169 sleep_backoff(attempt);
170 continue;
171 }
172 return Err(DomainError::io(
173 "backend request",
174 IoError::other(err.to_string()),
175 ));
176 }
177 }
178 }
179 }
180}
181
182fn optional_task_text_body(field: &str, value: Option<String>) -> String {
183 match value {
184 Some(value) => serde_json::json!({ field: value }).to_string(),
185 None => "{}".to_string(),
186 }
187}
188
189fn retries_enabled_by_default(method: &str) -> bool {
190 match method {
191 "GET" => true,
192 "POST" => false,
193 "PUT" => false,
194 "PATCH" => false,
195 "DELETE" => false,
196 "HEAD" => false,
197 "OPTIONS" => false,
198 "TRACE" => false,
199 _ => false,
200 }
201}
202
203fn is_not_found_error(err: &DomainError) -> bool {
204 matches!(err, DomainError::NotFound { .. })
205}
206
207impl BackendChangeReader for BackendHttpClient {
208 fn list_changes(&self, filter: ChangeLifecycleFilter) -> DomainResult<Vec<ChangeSummary>> {
209 let url = format!(
210 "{}/changes?lifecycle={}",
211 self.inner.runtime.project_api_prefix(),
212 filter.as_str()
213 );
214 let summaries: Vec<ApiChangeSummary> = self.get_json(&url, "change", None)?;
215 let mut out = Vec::with_capacity(summaries.len());
216 for summary in summaries {
217 let last_modified = parse_timestamp(&summary.last_modified)?;
218 out.push(ChangeSummary {
219 id: summary.id,
220 module_id: summary.module_id,
221 completed_tasks: summary.completed_tasks,
222 shelved_tasks: summary.shelved_tasks,
223 in_progress_tasks: summary.in_progress_tasks,
224 pending_tasks: summary.pending_tasks,
225 total_tasks: summary.total_tasks,
226 last_modified,
227 has_proposal: summary.has_proposal,
228 has_design: summary.has_design,
229 has_specs: summary.has_specs,
230 has_tasks: summary.has_tasks,
231 });
232 }
233 Ok(out)
234 }
235
236 fn get_change(&self, change_id: &str, filter: ChangeLifecycleFilter) -> DomainResult<Change> {
237 let url = format!(
238 "{}/changes/{change_id}?lifecycle={}",
239 self.inner.runtime.project_api_prefix(),
240 filter.as_str()
241 );
242 let change: ApiChange = self.get_json(&url, "change", Some(change_id))?;
243 let tasks = match self.load_tasks_parse_result(change_id) {
244 Ok(tasks) => tasks,
245 Err(err) => {
246 if filter.includes_archived() && is_not_found_error(&err) {
247 tasks_from_progress(&change.progress)
248 } else {
249 return Err(err);
250 }
251 }
252 };
253 let last_modified = parse_timestamp(&change.last_modified)?;
254 Ok(Change {
255 id: change.id,
256 module_id: change.module_id,
257 path: PathBuf::new(),
258 proposal: change.proposal,
259 design: change.design,
260 specs: {
261 let mut specs = Vec::with_capacity(change.specs.len());
262 for spec in change.specs {
263 specs.push(Spec {
264 name: spec.name,
265 content: spec.content,
266 });
267 }
268 specs
269 },
270 tasks,
271 last_modified,
272 })
273 }
274}
275
276impl BackendModuleReader for BackendHttpClient {
277 fn list_modules(&self) -> DomainResult<Vec<ModuleSummary>> {
278 let url = format!("{}/modules", self.inner.runtime.project_api_prefix());
279 let modules: Vec<ApiModuleSummary> = self.get_json(&url, "module", None)?;
280 let mut out = Vec::with_capacity(modules.len());
281 for m in modules {
282 out.push(ModuleSummary {
283 id: m.id,
284 name: m.name,
285 change_count: m.change_count,
286 });
287 }
288 Ok(out)
289 }
290
291 fn get_module(&self, module_id: &str) -> DomainResult<Module> {
292 let url = format!(
293 "{}/modules/{module_id}",
294 self.inner.runtime.project_api_prefix()
295 );
296 let module: ApiModule = self.get_json(&url, "module", Some(module_id))?;
297 Ok(Module {
298 id: module.id,
299 name: module.name,
300 description: module.description,
301 path: PathBuf::new(),
302 })
303 }
304}
305
306impl BackendSpecReader for BackendHttpClient {
307 fn list_specs(&self) -> DomainResult<Vec<SpecSummary>> {
308 let url = format!("{}/specs", self.inner.runtime.project_api_prefix());
309 let specs: Vec<ApiSpecSummary> = self.get_json(&url, "spec", None)?;
310 let mut out = Vec::with_capacity(specs.len());
311 for spec in specs {
312 out.push(SpecSummary {
313 id: spec.id,
314 path: PathBuf::from(spec.path),
315 last_modified: parse_timestamp(&spec.last_modified)?,
316 });
317 }
318 Ok(out)
319 }
320
321 fn get_spec(&self, spec_id: &str) -> DomainResult<SpecDocument> {
322 let url = format!(
323 "{}/specs/{spec_id}",
324 self.inner.runtime.project_api_prefix()
325 );
326 let spec: ApiSpecDocument = self.get_json(&url, "spec", Some(spec_id))?;
327 Ok(SpecDocument {
328 id: spec.id,
329 path: PathBuf::from(spec.path),
330 markdown: spec.markdown,
331 last_modified: parse_timestamp(&spec.last_modified)?,
332 })
333 }
334}
335
336impl TaskMutationService for BackendHttpClient {
337 fn load_tasks_markdown(&self, change_id: &str) -> TaskMutationServiceResult<Option<String>> {
338 let url = format!(
339 "{}/changes/{change_id}/tasks/raw",
340 self.inner.runtime.project_api_prefix()
341 );
342 let response: ApiTaskMarkdown = self.task_get_json(&url)?;
343 Ok(response.content)
344 }
345
346 fn init_tasks(&self, change_id: &str) -> TaskMutationServiceResult<TaskInitResult> {
347 let url = format!(
348 "{}/changes/{change_id}/tasks/init",
349 self.inner.runtime.project_api_prefix()
350 );
351 let response: ApiTaskInitResult = self.task_post_json(&url, Some("{}"))?;
352 Ok(TaskInitResult {
353 change_id: response.change_id,
354 path: response.path.map(PathBuf::from),
355 existed: response.existed,
356 revision: response.revision,
357 })
358 }
359
360 fn start_task(
361 &self,
362 change_id: &str,
363 task_id: &str,
364 ) -> TaskMutationServiceResult<TaskMutationResult> {
365 let url = format!(
366 "{}/changes/{change_id}/tasks/{task_id}/start",
367 self.inner.runtime.project_api_prefix()
368 );
369 let response: ApiTaskMutationEnvelope = self.task_post_json(&url, Some("{}"))?;
370 Ok(task_mutation_from_api(response))
371 }
372
373 fn complete_task(
374 &self,
375 change_id: &str,
376 task_id: &str,
377 note: Option<String>,
378 ) -> TaskMutationServiceResult<TaskMutationResult> {
379 let url = format!(
380 "{}/changes/{change_id}/tasks/{task_id}/complete",
381 self.inner.runtime.project_api_prefix()
382 );
383 let body = optional_task_text_body("note", note);
384 let response: ApiTaskMutationEnvelope = self.task_post_json(&url, Some(&body))?;
385 Ok(task_mutation_from_api(response))
386 }
387
388 fn shelve_task(
389 &self,
390 change_id: &str,
391 task_id: &str,
392 reason: Option<String>,
393 ) -> TaskMutationServiceResult<TaskMutationResult> {
394 let url = format!(
395 "{}/changes/{change_id}/tasks/{task_id}/shelve",
396 self.inner.runtime.project_api_prefix()
397 );
398 let body = optional_task_text_body("reason", reason);
399 let response: ApiTaskMutationEnvelope = self.task_post_json(&url, Some(&body))?;
400 Ok(task_mutation_from_api(response))
401 }
402
403 fn unshelve_task(
404 &self,
405 change_id: &str,
406 task_id: &str,
407 ) -> TaskMutationServiceResult<TaskMutationResult> {
408 let url = format!(
409 "{}/changes/{change_id}/tasks/{task_id}/unshelve",
410 self.inner.runtime.project_api_prefix()
411 );
412 let response: ApiTaskMutationEnvelope = self.task_post_json(&url, Some("{}"))?;
413 Ok(task_mutation_from_api(response))
414 }
415
416 fn add_task(
417 &self,
418 change_id: &str,
419 title: &str,
420 wave: Option<u32>,
421 ) -> TaskMutationServiceResult<TaskMutationResult> {
422 let url = format!(
423 "{}/changes/{change_id}/tasks/add",
424 self.inner.runtime.project_api_prefix()
425 );
426 let body = serde_json::json!({ "title": title, "wave": wave }).to_string();
427 let response: ApiTaskMutationEnvelope = self.task_post_json(&url, Some(&body))?;
428 Ok(task_mutation_from_api(response))
429 }
430}
431
432impl BackendSyncClient for BackendHttpClient {
433 fn pull(&self, change_id: &str) -> Result<ArtifactBundle, ito_domain::backend::BackendError> {
434 let url = format!(
435 "{}/changes/{change_id}/sync",
436 self.inner.runtime.project_api_prefix()
437 );
438 self.backend_get_json(&url)
439 }
440
441 fn push(
442 &self,
443 change_id: &str,
444 bundle: &ArtifactBundle,
445 ) -> Result<PushResult, ito_domain::backend::BackendError> {
446 let url = format!(
447 "{}/changes/{change_id}/sync",
448 self.inner.runtime.project_api_prefix()
449 );
450 let body = serde_json::to_string(bundle)
451 .map_err(|err| ito_domain::backend::BackendError::Other(err.to_string()))?;
452 self.backend_post_json(&url, Some(&body))
453 }
454}
455
456impl BackendArchiveClient for BackendHttpClient {
457 fn mark_archived(
458 &self,
459 change_id: &str,
460 ) -> Result<ArchiveResult, ito_domain::backend::BackendError> {
461 let url = format!(
462 "{}/changes/{change_id}/archive",
463 self.inner.runtime.project_api_prefix()
464 );
465 self.backend_post_json(&url, Some("{}"))
466 }
467}
468
469fn read_response_body(response: ureq::http::Response<ureq::Body>) -> DomainResult<String> {
470 let body = response
471 .into_body()
472 .read_to_string()
473 .map_err(|err| DomainError::io("reading backend response", IoError::other(err)))?;
474 Ok(body)
475}
476
477fn parse_task_response<T: DeserializeOwned>(
478 response: ureq::http::Response<ureq::Body>,
479) -> TaskMutationServiceResult<T> {
480 let status = response.status().as_u16();
481 let body = response
482 .into_body()
483 .read_to_string()
484 .map_err(|err| TaskMutationError::io("reading backend response", IoError::other(err)))?;
485 if !(200..300).contains(&status) {
486 return Err(map_status_to_task_error(status, &body));
487 }
488 serde_json::from_str(&body)
489 .map_err(|err| TaskMutationError::other(format!("Failed to parse backend response: {err}")))
490}
491
492fn parse_backend_response<T: DeserializeOwned>(
493 response: ureq::http::Response<ureq::Body>,
494) -> Result<T, ito_domain::backend::BackendError> {
495 let status = response.status().as_u16();
496 let body = response
497 .into_body()
498 .read_to_string()
499 .map_err(|err| ito_domain::backend::BackendError::Other(err.to_string()))?;
500 if !(200..300).contains(&status) {
501 return Err(map_status_to_backend_error(status, &body));
502 }
503 serde_json::from_str(&body)
504 .map_err(|err| ito_domain::backend::BackendError::Other(err.to_string()))
505}
506
507fn map_status_to_domain_error(
508 status: u16,
509 entity: &'static str,
510 id: Option<&str>,
511 body: &str,
512) -> DomainError {
513 if status == 404 {
514 return DomainError::not_found(entity, id.unwrap_or("unknown"));
515 }
516
517 let kind = if status == 401 || status == 403 {
518 ErrorKind::PermissionDenied
519 } else if status >= 500 {
520 ErrorKind::Other
521 } else {
522 ErrorKind::InvalidData
523 };
524
525 let msg = if body.trim().is_empty() {
526 format!("backend returned HTTP {status}")
527 } else {
528 format!("backend returned HTTP {status}: {body}")
529 };
530 DomainError::io("backend request", IoError::new(kind, msg))
531}
532
533fn map_status_to_task_error(status: u16, body: &str) -> TaskMutationError {
534 if let Ok(api_error) = serde_json::from_str::<ApiErrorBody>(body) {
535 return match api_error.code.as_str() {
536 "not_found" => TaskMutationError::not_found(api_error.error),
537 "bad_request" => TaskMutationError::validation(api_error.error),
538 _ => TaskMutationError::other(api_error.error),
539 };
540 }
541
542 let message = if body.trim().is_empty() {
543 format!("backend returned HTTP {status}")
544 } else {
545 format!("backend returned HTTP {status}: {body}")
546 };
547 match status {
548 404 => TaskMutationError::not_found(message),
549 400..=499 => TaskMutationError::validation(message),
550 _ => TaskMutationError::other(message),
551 }
552}
553
554fn map_status_to_backend_error(status: u16, body: &str) -> ito_domain::backend::BackendError {
555 let message = if body.trim().is_empty() {
556 format!("backend returned HTTP {status}")
557 } else {
558 body.to_string()
559 };
560 match status {
561 401 | 403 => ito_domain::backend::BackendError::Unauthorized(message),
562 404 => ito_domain::backend::BackendError::NotFound(message),
563 409 => ito_domain::backend::BackendError::Other(message),
564 500..=599 => ito_domain::backend::BackendError::Unavailable(message),
565 _ => ito_domain::backend::BackendError::Other(message),
566 }
567}
568
569fn task_error_from_domain(err: DomainError) -> TaskMutationError {
570 match err {
571 DomainError::Io { context, source } => TaskMutationError::io(context, source),
572 DomainError::NotFound { entity, id } => {
573 TaskMutationError::not_found(format!("{entity} not found: {id}"))
574 }
575 DomainError::AmbiguousTarget {
576 entity,
577 input,
578 matches,
579 } => TaskMutationError::validation(format!(
580 "Ambiguous {entity} target '{input}'. Matches: {matches}"
581 )),
582 }
583}
584
585fn backend_error_from_domain(err: DomainError) -> ito_domain::backend::BackendError {
586 match err {
587 DomainError::Io { source, .. } => {
588 ito_domain::backend::BackendError::Other(source.to_string())
589 }
590 DomainError::NotFound { entity, id } => {
591 ito_domain::backend::BackendError::NotFound(format!("{entity} not found: {id}"))
592 }
593 DomainError::AmbiguousTarget {
594 entity,
595 input,
596 matches,
597 } => ito_domain::backend::BackendError::Other(format!(
598 "Ambiguous {entity} target '{input}'. Matches: {matches}"
599 )),
600 }
601}
602
603fn parse_timestamp(raw: &str) -> DomainResult<DateTime<Utc>> {
604 DateTime::parse_from_rfc3339(raw)
605 .map(|dt| dt.with_timezone(&Utc))
606 .map_err(|err| DomainError::io("parsing backend timestamp", IoError::other(err)))
607}
608
609fn sleep_backoff(attempt: u32) {
610 let delay_ms = 150u64.saturating_mul(attempt as u64);
611 std::thread::sleep(Duration::from_millis(delay_ms));
612}
613
614fn task_list_to_parse_result(list: ApiTaskList) -> TasksParseResult {
615 let format = match list.format.as_str() {
616 "enhanced" => TasksFormat::Enhanced,
617 "checkbox" => TasksFormat::Checkbox,
618 other => {
619 tracing::warn!(
620 format = %other,
621 "unknown backend task format; falling back to checkbox parsing"
622 );
623 TasksFormat::Checkbox
624 }
625 };
626
627 let mut tasks = Vec::with_capacity(list.tasks.len());
628 let mut missing_dependencies = false;
629 for item in list.tasks {
630 let status = TaskStatus::from_enhanced_label(&item.status).unwrap_or(TaskStatus::Pending);
631 let dependencies = match item.dependencies {
632 Some(deps) => deps,
633 None => {
634 missing_dependencies = true;
635 Vec::new()
636 }
637 };
638 tasks.push(TaskItem {
639 id: item.id,
640 name: item.name,
641 wave: item.wave,
642 status,
643 updated_at: None,
644 dependencies,
645 files: Vec::new(),
646 action: String::new(),
647 verify: None,
648 done_when: None,
649 kind: TaskKind::Normal,
650 header_line_index: 0,
651 });
652 }
653
654 let progress = ProgressInfo {
655 total: list.progress.total,
656 complete: list.progress.complete,
657 shelved: list.progress.shelved,
658 in_progress: list.progress.in_progress,
659 pending: list.progress.pending,
660 remaining: list.progress.remaining,
661 };
662
663 let waves = if format == TasksFormat::Enhanced {
664 let mut unique = BTreeSet::new();
665 for task in &tasks {
666 if let Some(wave) = task.wave {
667 unique.insert(wave);
668 }
669 }
670 unique
671 .into_iter()
672 .map(|wave| WaveInfo {
673 wave,
674 depends_on: Vec::new(),
675 header_line_index: 0,
676 depends_on_line_index: None,
677 })
678 .collect()
679 } else {
680 Vec::new()
681 };
682
683 let mut diagnostics = Vec::new();
684 if missing_dependencies && format == TasksFormat::Enhanced {
685 diagnostics.push(TaskDiagnostic {
686 level: DiagnosticLevel::Warning,
687 message: "Backend task payload missing dependencies; readiness may be inaccurate"
688 .to_string(),
689 task_id: None,
690 line: None,
691 });
692 }
693
694 TasksParseResult {
695 format,
696 tasks,
697 waves,
698 diagnostics,
699 progress,
700 }
701}
702
703fn tasks_from_progress(progress: &ApiProgress) -> TasksParseResult {
704 TasksParseResult {
705 format: TasksFormat::Checkbox,
706 tasks: Vec::new(),
707 waves: Vec::new(),
708 diagnostics: Vec::new(),
709 progress: ProgressInfo {
710 total: progress.total,
711 complete: progress.complete,
712 shelved: progress.shelved,
713 in_progress: progress.in_progress,
714 pending: progress.pending,
715 remaining: progress.remaining,
716 },
717 }
718}
719
720fn task_mutation_from_api(response: ApiTaskMutationEnvelope) -> TaskMutationResult {
721 TaskMutationResult {
722 change_id: response.change_id,
723 task: TaskItem {
724 id: response.task.id,
725 name: response.task.name,
726 wave: response.task.wave,
727 status: TaskStatus::from_enhanced_label(&response.task.status)
728 .unwrap_or(TaskStatus::Pending),
729 updated_at: response.task.updated_at,
730 dependencies: response.task.dependencies,
731 files: response.task.files,
732 action: response.task.action,
733 verify: response.task.verify,
734 done_when: response.task.done_when,
735 kind: match response.task.kind.as_str() {
736 "checkpoint" => TaskKind::Checkpoint,
737 _ => TaskKind::Normal,
738 },
739 header_line_index: response.task.header_line_index,
740 },
741 revision: response.revision,
742 }
743}
744
745#[derive(Debug, Deserialize)]
746struct ApiChangeSummary {
747 id: String,
748 module_id: Option<String>,
749 completed_tasks: u32,
750 shelved_tasks: u32,
751 in_progress_tasks: u32,
752 pending_tasks: u32,
753 total_tasks: u32,
754 has_proposal: bool,
755 has_design: bool,
756 has_specs: bool,
757 has_tasks: bool,
758 #[allow(dead_code)]
759 work_status: String,
760 last_modified: String,
761}
762
763#[derive(Debug, Deserialize)]
764struct ApiChange {
765 id: String,
766 module_id: Option<String>,
767 proposal: Option<String>,
768 design: Option<String>,
769 specs: Vec<ApiSpec>,
770 progress: ApiProgress,
771 last_modified: String,
772}
773
774#[derive(Debug, Deserialize)]
775struct ApiSpec {
776 name: String,
777 content: String,
778}
779
780#[derive(Debug, Deserialize)]
781struct ApiProgress {
782 total: usize,
783 complete: usize,
784 shelved: usize,
785 in_progress: usize,
786 pending: usize,
787 remaining: usize,
788}
789
790#[derive(Debug, Deserialize)]
791struct ApiTaskList {
792 #[allow(dead_code)]
793 change_id: String,
794 tasks: Vec<ApiTaskItem>,
795 progress: ApiProgress,
796 format: String,
797}
798
799#[derive(Debug, Deserialize)]
800struct ApiTaskItem {
801 id: String,
802 name: String,
803 wave: Option<u32>,
804 status: String,
805 #[serde(default)]
806 dependencies: Option<Vec<String>>,
807}
808
809#[derive(Debug, Deserialize)]
810struct ApiTaskMarkdown {
811 #[allow(dead_code)]
812 change_id: String,
813 content: Option<String>,
814}
815
816#[derive(Debug, Deserialize)]
817struct ApiTaskInitResult {
818 change_id: String,
819 path: Option<String>,
820 existed: bool,
821 revision: Option<String>,
822}
823
824#[derive(Debug, Deserialize)]
825struct ApiTaskMutationEnvelope {
826 change_id: String,
827 task: ApiTaskDetail,
828 revision: Option<String>,
829}
830
831#[derive(Debug, Deserialize)]
832struct ApiTaskDetail {
833 id: String,
834 name: String,
835 wave: Option<u32>,
836 status: String,
837 updated_at: Option<String>,
838 dependencies: Vec<String>,
839 files: Vec<String>,
840 action: String,
841 verify: Option<String>,
842 done_when: Option<String>,
843 kind: String,
844 header_line_index: usize,
845}
846
847#[derive(Debug, Deserialize)]
848struct ApiSpecSummary {
849 id: String,
850 path: String,
851 last_modified: String,
852}
853
854#[derive(Debug, Deserialize)]
855struct ApiSpecDocument {
856 id: String,
857 path: String,
858 markdown: String,
859 last_modified: String,
860}
861
862#[derive(Debug, Deserialize)]
863struct ApiModuleSummary {
864 id: String,
865 name: String,
866 change_count: u32,
867}
868
869#[derive(Debug, Deserialize)]
870struct ApiModule {
871 id: String,
872 name: String,
873 description: Option<String>,
874}
875
876#[derive(Debug, Deserialize)]
877struct ApiErrorBody {
878 error: String,
879 code: String,
880}
881
882#[cfg(test)]
883mod backend_http_tests;