1use std::{
17 collections::HashMap,
18 time::{SystemTime, UNIX_EPOCH},
19};
20
21use gix::{hash::ObjectId, refs::transaction::PreviousValue};
22use objects::object::{State, Status};
23use serde::{Deserialize, Serialize};
24
25use super::git_core::{GitBridgeError, GitResult, git_err, set_reference};
26
27pub const NOTES_REF: &str = "refs/notes/heddle";
30
31#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
33pub struct HeddleNote {
34 pub change_id: String,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
37 pub agent: Option<NoteAgent>,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub confidence: Option<f32>,
40 pub status: String,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub omitted_annotations_breakdown: Option<OmittedBreakdown>,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub signal_counts: Option<SignalCounts>,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub attribution: Option<NoteAttribution>,
56}
57
58#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
59pub struct NoteAgent {
60 pub provider: String,
61 pub model: String,
62}
63
64#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
66pub struct OmittedBreakdown {
67 #[serde(default)]
68 pub internal: u32,
69 #[serde(default)]
70 pub team: u32,
71 #[serde(default)]
72 pub restricted: u32,
73}
74
75#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
77pub struct SignalCounts {
78 #[serde(default)]
79 pub novelty: u32,
80 #[serde(default)]
81 pub test_reachability: u32,
82 #[serde(default)]
83 pub pattern_deviation: u32,
84 #[serde(default)]
85 pub invariant_adjacency: u32,
86 #[serde(default)]
87 pub self_flagged_uncertainty: u32,
88}
89
90#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
91pub struct NoteAttribution {
92 pub principal_name: String,
93 pub principal_email: String,
94 #[serde(default, skip_serializing_if = "Option::is_none")]
95 pub agent: Option<NoteAgent>,
96}
97
98impl HeddleNote {
99 pub fn from_state(state: &State) -> Self {
101 let status = match state.status {
102 Status::Draft => "draft".to_string(),
103 Status::Published => "published".to_string(),
104 };
105 let agent = state.attribution.agent.as_ref().map(|a| NoteAgent {
106 provider: a.provider.clone(),
107 model: a.model.clone(),
108 });
109 Self {
110 change_id: state.change_id.to_string_full(),
111 agent,
112 confidence: state.confidence,
113 status,
114 omitted_annotations_breakdown: None,
115 signal_counts: None,
116 attribution: None,
117 }
118 }
119
120 pub fn with_omitted_breakdown(mut self, breakdown: OmittedBreakdown) -> Self {
122 self.omitted_annotations_breakdown = Some(breakdown);
123 self
124 }
125
126 pub fn with_signal_counts(mut self, counts: SignalCounts) -> Self {
128 self.signal_counts = Some(counts);
129 self
130 }
131
132 pub fn with_attribution(mut self, attribution: NoteAttribution) -> Self {
134 self.attribution = Some(attribution);
135 self
136 }
137
138 pub fn to_json_bytes(&self) -> GitResult<Vec<u8>> {
139 serde_json::to_vec_pretty(self)
140 .map_err(|e| GitBridgeError::Git(format!("note serialize: {e}")))
141 }
142
143 pub fn from_json_bytes(bytes: &[u8]) -> GitResult<Self> {
144 serde_json::from_slice(bytes).map_err(|e| GitBridgeError::Git(format!("note parse: {e}")))
145 }
146}
147
148fn read_notes_head(repo: &gix::Repository) -> GitResult<(Option<ObjectId>, ObjectId)> {
151 let parent = match repo.find_reference(NOTES_REF) {
152 Ok(reference) => {
153 let mut reference = reference;
154 Some(reference.peel_to_id().map_err(git_err)?.detach())
155 }
156 Err(_) => None,
157 };
158 let tree_oid = if let Some(commit_oid) = parent {
159 let commit = repo.find_commit(commit_oid).map_err(git_err)?;
160 commit.tree_id().map_err(git_err)?.detach()
161 } else {
162 gix::hash::ObjectId::empty_tree(repo.object_hash())
163 };
164 Ok((parent, tree_oid))
165}
166
167pub fn write_note(
173 repo: &gix::Repository,
174 commit_oid: ObjectId,
175 note: &HeddleNote,
176) -> GitResult<()> {
177 let json = note.to_json_bytes()?;
178 let blob_oid = repo.write_blob(&json).map_err(git_err)?.detach();
179
180 let (parent_commit, current_tree_oid) = read_notes_head(repo)?;
181 let mut editor = repo.edit_tree(current_tree_oid).map_err(git_err)?;
182 let entry_name = commit_oid.to_hex_with_len(40).to_string();
183 editor
184 .upsert(
185 entry_name.as_str(),
186 gix::object::tree::EntryKind::Blob,
187 blob_oid,
188 )
189 .map_err(git_err)?;
190 let new_tree_oid = editor.write().map_err(git_err)?.detach();
191
192 let signature = bridge_notes_signature();
193 let mut author_buf = gix::date::parse::TimeBuf::default();
194 let mut committer_buf = gix::date::parse::TimeBuf::default();
195 let parents: Vec<ObjectId> = parent_commit.iter().copied().collect();
196 let new_commit = repo
197 .new_commit_as(
198 signature.to_ref(&mut committer_buf),
199 signature.to_ref(&mut author_buf),
200 "heddle: state metadata",
201 new_tree_oid,
202 parents,
203 )
204 .map_err(git_err)?;
205
206 let constraint = match parent_commit {
207 Some(prev) => PreviousValue::ExistingMustMatch(gix::refs::Target::Object(prev)),
208 None => PreviousValue::MustNotExist,
209 };
210 set_reference(
211 repo,
212 NOTES_REF,
213 new_commit.id,
214 constraint,
215 "heddle: write state note",
216 )?;
217 Ok(())
218}
219
220pub fn read_note(repo: &gix::Repository, commit_oid: ObjectId) -> GitResult<Option<HeddleNote>> {
222 let Ok(reference) = repo.find_reference(NOTES_REF) else {
223 return Ok(None);
224 };
225 let mut reference = reference;
226 let notes_commit_oid = reference.peel_to_id().map_err(git_err)?.detach();
227 let notes_commit = repo.find_commit(notes_commit_oid).map_err(git_err)?;
228 let notes_tree_oid = notes_commit.tree_id().map_err(git_err)?.detach();
229 let notes_tree = repo.find_tree(notes_tree_oid).map_err(git_err)?;
230
231 let target_name = commit_oid.to_hex_with_len(40).to_string();
232 for entry in notes_tree.iter() {
233 let entry = entry.map_err(git_err)?;
234 if *entry.filename() == *target_name.as_bytes() {
235 let object = repo.find_object(entry.object_id()).map_err(git_err)?;
236 return HeddleNote::from_json_bytes(&object.data).map(Some);
237 }
238 }
239 Ok(None)
240}
241
242pub fn read_all_notes(repo: &gix::Repository) -> GitResult<HashMap<ObjectId, HeddleNote>> {
245 let mut out = HashMap::new();
246 let Ok(reference) = repo.find_reference(NOTES_REF) else {
247 return Ok(out);
248 };
249 let mut reference = reference;
250 let notes_commit_oid = reference.peel_to_id().map_err(git_err)?.detach();
251 let notes_commit = repo.find_commit(notes_commit_oid).map_err(git_err)?;
252 let notes_tree_oid = notes_commit.tree_id().map_err(git_err)?.detach();
253 let notes_tree = repo.find_tree(notes_tree_oid).map_err(git_err)?;
254
255 for entry in notes_tree.iter() {
256 let entry = entry.map_err(git_err)?;
257 let name = entry.filename().to_string();
258 let Ok(target_oid) = name.parse::<ObjectId>() else {
259 continue;
260 };
261 let object = repo.find_object(entry.object_id()).map_err(git_err)?;
262 if let Ok(note) = HeddleNote::from_json_bytes(&object.data) {
265 out.insert(target_oid, note);
266 }
267 }
268 Ok(out)
269}
270
271fn bridge_notes_signature() -> gix::actor::Signature {
272 let seconds = SystemTime::now()
273 .duration_since(UNIX_EPOCH)
274 .map(|d| d.as_secs() as i64)
275 .unwrap_or(0);
276 gix::actor::Signature {
277 name: "Heddle".into(),
278 email: "heddle@local".into(),
279 time: gix::date::Time { seconds, offset: 0 },
280 }
281}