|
1 |
+ |
//! Turning `refs/notes/*` into what the browse templates render.
|
|
2 |
+ |
//!
|
|
3 |
+ |
//! <!-- wiki: mnw-server-git-notes -->
|
|
4 |
+ |
//!
|
|
5 |
+ |
//! The read core is `crate::git::notes`, which is generic over an engine and
|
|
6 |
+ |
//! names no gix type. This module is where the two meet: it opens a
|
|
7 |
+ |
//! [`GixEngine`] over an already-resolved repository, converts hex ids across
|
|
8 |
+ |
//! the `notes::Oid` boundary, and renders note content to HTML.
|
|
9 |
+ |
//!
|
|
10 |
+ |
//! A repository nobody has annotated is the ordinary case, so everything here
|
|
11 |
+ |
//! returns empty rather than erroring, and a namespace whose tree cannot be
|
|
12 |
+ |
//! read is skipped with a warning instead of failing the page it appears on. A
|
|
13 |
+ |
//! note is decoration on a commit view; it must never be the reason the commit
|
|
14 |
+ |
//! view 500s.
|
|
15 |
+ |
|
|
16 |
+ |
use crate::git::notes::{self, Attribution, GixEngine, Oid};
|
|
17 |
+ |
|
|
18 |
+ |
/// How far back the attribution walk may look on a detail view. Bounded because
|
|
19 |
+ |
/// a busy notes ref is long; see `notes::attribution`, which reports
|
|
20 |
+ |
/// `exact: false` when it runs out rather than guessing.
|
|
21 |
+ |
const ATTRIBUTION_MAX_COMMITS: usize = 50;
|
|
22 |
+ |
|
|
23 |
+ |
/// How much of an object id the templates show.
|
|
24 |
+ |
const SHORT_OID_LEN: usize = 8;
|
|
25 |
+ |
|
|
26 |
+ |
/// One namespace's note on the object being viewed, rendered.
|
|
27 |
+ |
pub struct CommitNote {
|
|
28 |
+ |
/// Namespace as a person says it: `commits`, `mnw/builds`.
|
|
29 |
+ |
pub namespace: String,
|
|
30 |
+ |
/// Note content rendered through docengine, sanitized.
|
|
31 |
+ |
pub html: String,
|
|
32 |
+ |
/// Who last changed it, when the walk could say.
|
|
33 |
+ |
pub attribution: Option<NoteAttribution>,
|
|
34 |
+ |
}
|
|
35 |
+ |
|
|
36 |
+ |
/// Who last changed a note, in a shape the templates can print directly.
|
|
37 |
+ |
///
|
|
38 |
+ |
/// `exact` is the load-bearing field: when it is false the walk hit its budget
|
|
39 |
+ |
/// and `commit` is the oldest commit it examined rather than the one it proved
|
|
40 |
+ |
/// responsible, so the template says "edited since" and never names a person.
|
|
41 |
+ |
pub struct NoteAttribution {
|
|
42 |
+ |
pub short_commit: String,
|
|
43 |
+ |
pub by: String,
|
|
44 |
+ |
pub when: String,
|
|
45 |
+ |
pub exact: bool,
|
|
46 |
+ |
}
|
|
47 |
+ |
|
|
48 |
+ |
/// Every note on `target`, across every namespace, rendered for display.
|
|
49 |
+ |
///
|
|
50 |
+ |
/// `target` is a hex object id; anything unparseable yields no notes rather
|
|
51 |
+ |
/// than an error, because the caller has already resolved it against the
|
|
52 |
+ |
/// repository and a second opinion here would only be a worse error message.
|
|
53 |
+ |
pub fn notes_on_object(repo: &gix::Repository, target: &str) -> Vec<CommitNote> {
|
|
54 |
+ |
let Ok(target) = Oid::from_hex(target.as_bytes()) else {
|
|
55 |
+ |
return Vec::new();
|
|
56 |
+ |
};
|
|
57 |
+ |
let engine = GixEngine::new(repo);
|
|
58 |
+ |
|
|
59 |
+ |
let namespaces = match notes::list_namespaces(&engine) {
|
|
60 |
+ |
Ok(ns) => ns,
|
|
61 |
+ |
Err(e) => {
|
|
62 |
+ |
tracing::warn!(error = %e, "listing notes namespaces failed");
|
|
63 |
+ |
return Vec::new();
|
|
64 |
+ |
}
|
|
65 |
+ |
};
|
|
66 |
+ |
|
|
67 |
+ |
let mut out = Vec::new();
|
|
68 |
+ |
for ns in namespaces {
|
|
69 |
+ |
let note = match notes::note_for(&engine, ns.tip, target) {
|
|
70 |
+ |
Ok(Some(note)) => note,
|
|
71 |
+ |
Ok(None) => continue,
|
|
72 |
+ |
Err(e) => {
|
|
73 |
+ |
tracing::warn!(namespace = %ns.name, error = %e, "reading note failed");
|
|
74 |
+ |
continue;
|
|
75 |
+ |
}
|
|
76 |
+ |
};
|
|
77 |
+ |
|
|
78 |
+ |
// Attribution is a second walk over the notes ref, so it only runs for
|
|
79 |
+ |
// a namespace that actually has something to attribute.
|
|
80 |
+ |
let attribution = match notes::attribution(&engine, ns.tip, target, ATTRIBUTION_MAX_COMMITS)
|
|
81 |
+ |
{
|
|
82 |
+ |
Ok(a) => a.map(render_attribution),
|
|
83 |
+ |
Err(e) => {
|
|
84 |
+ |
tracing::warn!(namespace = %ns.name, error = %e, "note attribution failed");
|
|
85 |
+ |
None
|
|
86 |
+ |
}
|
|
87 |
+ |
};
|
|
88 |
+ |
|
|
89 |
+ |
out.push(CommitNote {
|
|
90 |
+ |
namespace: ns.name,
|
|
91 |
+ |
html: docengine::render_permissive(¬e.content_lossy()),
|
|
92 |
+ |
attribution,
|
|
93 |
+ |
});
|
|
94 |
+ |
}
|
|
95 |
+ |
out
|
|
96 |
+ |
}
|
|
97 |
+ |
|
|
98 |
+ |
fn render_attribution(a: Attribution) -> NoteAttribution {
|
|
99 |
+ |
NoteAttribution {
|
|
100 |
+ |
short_commit: a.note_commit.to_short_hex(SHORT_OID_LEN),
|
|
101 |
+ |
by: a.by.name,
|
|
102 |
+ |
when: a.by.time.format("%Y-%m-%d %H:%M UTC").to_string(),
|
|
103 |
+ |
exact: a.exact,
|
|
104 |
+ |
}
|
|
105 |
+ |
}
|
|
106 |
+ |
|
|
107 |
+ |
#[cfg(test)]
|
|
108 |
+ |
mod tests {
|
|
109 |
+ |
use gix::objs::tree::EntryKind;
|
|
110 |
+ |
|
|
111 |
+ |
use super::*;
|
|
112 |
+ |
|
|
113 |
+ |
/// Well-formed hex; nothing dereferences it, since a notes tree is keyed by
|
|
114 |
+ |
/// id and the reader never looks the target up as an object.
|
|
115 |
+ |
const TARGET: &str = "aabbccddeeff00112233445566778899aabbccdd";
|
|
116 |
+ |
|
|
117 |
+ |
/// A bare repo carrying one note on `TARGET` in each of two namespaces, so
|
|
118 |
+ |
/// the ordering and the per-namespace split are both exercised.
|
|
119 |
+ |
fn annotated_repo() -> (tempfile::TempDir, gix::Repository) {
|
|
120 |
+ |
let tmp = tempfile::TempDir::new().unwrap();
|
|
121 |
+ |
let path = tmp.path().join("owner").join("view-test.git");
|
|
122 |
+ |
std::fs::create_dir_all(&path).unwrap();
|
|
123 |
+ |
let repo = gix::init_bare(&path).unwrap();
|
|
124 |
+ |
|
|
125 |
+ |
for (ns, body) in [
|
|
126 |
+ |
("commits", "reviewed by *hand*"),
|
|
127 |
+ |
("mnw/builds", "build ok"),
|
|
128 |
+ |
] {
|
|
129 |
+ |
let blob = repo.write_blob(body.as_bytes()).unwrap().detach();
|
|
130 |
+ |
let mut tree = gix::objs::Tree::empty();
|
|
131 |
+ |
tree.entries.push(gix::objs::tree::Entry {
|
|
132 |
+ |
mode: EntryKind::Blob.into(),
|
|
133 |
+ |
filename: TARGET.into(),
|
|
134 |
+ |
oid: blob,
|
|
135 |
+ |
});
|
|
136 |
+ |
let tree = repo.write_object(&tree).unwrap().detach();
|
|
137 |
+ |
let who = gix::actor::SignatureRef {
|
|
138 |
+ |
name: "Fixture".into(),
|
|
139 |
+ |
email: "notes@example.com".into(),
|
|
140 |
+ |
time: "1700000000 +0000",
|
|
141 |
+ |
};
|
|
142 |
+ |
repo.commit_as(
|
|
143 |
+ |
who,
|
|
144 |
+ |
who,
|
|
145 |
+ |
format!("refs/notes/{ns}"),
|
|
146 |
+ |
"notes: fixture",
|
|
147 |
+ |
tree,
|
|
148 |
+ |
Vec::<gix::ObjectId>::new(),
|
|
149 |
+ |
)
|
|
150 |
+ |
.unwrap();
|
|
151 |
+ |
}
|
|
152 |
+ |
(tmp, repo)
|
|
153 |
+ |
}
|
|
154 |
+ |
|
|
155 |
+ |
#[test]
|
|
156 |
+ |
fn renders_one_block_per_namespace_with_default_first() {
|
|
157 |
+ |
let (_tmp, repo) = annotated_repo();
|
|
158 |
+ |
let notes = notes_on_object(&repo, TARGET);
|
|
159 |
+ |
|
|
160 |
+ |
let names: Vec<&str> = notes.iter().map(|n| n.namespace.as_str()).collect();
|
|
161 |
+ |
assert_eq!(names, ["commits", "mnw/builds"]);
|
|
162 |
+ |
assert!(notes[0].html.contains("<em>hand</em>"), "{}", notes[0].html);
|
|
163 |
+ |
}
|
|
164 |
+ |
|
|
165 |
+ |
#[test]
|
|
166 |
+ |
fn attribution_of_a_root_notes_commit_is_exact() {
|
|
167 |
+ |
let (_tmp, repo) = annotated_repo();
|
|
168 |
+ |
let notes = notes_on_object(&repo, TARGET);
|
|
169 |
+ |
|
|
170 |
+ |
let a = notes[0].attribution.as_ref().expect("attributed");
|
|
171 |
+ |
assert!(a.exact, "a root commit introduced the note by definition");
|
|
172 |
+ |
assert_eq!(a.by, "Fixture");
|
|
173 |
+ |
}
|
|
174 |
+ |
|
|
175 |
+ |
#[test]
|
|
176 |
+ |
fn an_unannotated_target_and_a_malformed_one_both_render_nothing() {
|
|
177 |
+ |
let (_tmp, repo) = annotated_repo();
|
|
178 |
+ |
assert!(notes_on_object(&repo, &"0".repeat(40)).is_empty());
|
|
179 |
+ |
assert!(notes_on_object(&repo, "not-an-object-id").is_empty());
|
|
180 |
+ |
}
|
|
181 |
+ |
}
|