onevcs/change.rs
1//! A session's own change request, read and written after it exists.
2//!
3//! Three thin calls over the host, each addressed at **the session's own change
4//! request** — the one `publish` would open or adopt for that session: from the
5//! branch it pushes, into the base it resolves for it, stacked or not — so no caller
6//! ever names a URL: read it back, replace its description, and mark it ready for
7//! review. Each is recorded on the session's own event stream, in the review phase,
8//! exactly as a publication records what it does to the same change request.
9//!
10//! What none of them does is open one. A publication is the only thing that opens a
11//! change request, and `onevcs publish TOKEN --draft` is how a session opens its own
12//! and keeps it open while its work is still being made; everything here refuses a
13//! session with no change request rather than inventing one.
14
15use serde::{Deserialize, Serialize};
16use serde_json::json;
17use url::Url;
18
19use crate::boundary::{Surface, TermScope};
20use crate::error::{Error, Result};
21use crate::event::{ArtifactRef, EventKind};
22use crate::host::{ChangeId, ChangeRequest, RemoteHost};
23use crate::providers::Providers;
24use crate::publish::{self, Subject};
25use crate::session::{SessionRecord, SessionToken};
26use crate::stream::{self, Stream};
27use crate::workspace::{self, object};
28use crate::{git, guidance};
29
30/// What a caller writes to a change request's description.
31///
32/// The body is the whole of it and is written verbatim, exactly as the body a
33/// publication opens a change request with is: prose a host places no shape on, so
34/// nothing here checks or composes one. The title is optional because replacing the
35/// body is the common case — a description is finished off whatever the worker
36/// started, and the title is usually the publication's own — and it is a
37/// [`Subject`] because it is the squash subject a `change-auto` merge lands under,
38/// held to the repository's own `commit-msg` hook exactly as a publication's is.
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub struct ChangeDescription {
41 /// The title to replace the change request's with, or none to leave it.
42 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub title: Option<Subject>,
44 /// The body, verbatim.
45 // llmlint: ignore[invalid_states_unrepresentable] a body is prose and a host places
46 // no shape on it, for the reason `PublishRequest::body` is a plain `String`; an
47 // unusable body does not exist.
48 pub body: String,
49 /// Which private repositories the public boundary check derives its terms from,
50 /// should the change request's repository be public. Unset is every registered
51 /// private one.
52 #[serde(default, skip_serializing_if = "TermScope::is_registry")]
53 pub term_scope: TermScope,
54}
55
56/// A session's change request, as the host holds it right now.
57///
58/// What every verb here answers with, after it has done what it was asked: the
59/// change request's address on the host, its identifier, the base it targets,
60/// whether the host holds it as a draft, and its description. The read is made
61/// *after* the write, so a caller that described the change sees what it wrote as
62/// the host now has it rather than what it sent.
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub struct SessionChange {
65 /// Where a human reads it.
66 pub url: Url,
67 /// The host's identifier for it.
68 pub id: ChangeId,
69 /// The branch it targets.
70 // llmlint: ignore[invalid_states_unrepresentable] the contract declares this field
71 // as `pub base: String`, matching `ChangeRequest.base`, and every value here is read
72 // out of a host response naming a branch the host already has.
73 pub base: String,
74 /// Whether the host is holding it as a draft.
75 pub draft: bool,
76 /// Its title, as the host holds it.
77 pub title: String,
78 /// Its body, as the host holds it — empty where it carries none.
79 pub body: String,
80}
81
82/// The session's change request, or `None` when the host holds no open change
83/// request from the session's branch into its base.
84///
85/// The library form of `onevcs change show`. It never invents one: a session that
86/// has not published is a session with no change request, and a caller that wants
87/// one publishes.
88pub fn session_change(
89 providers: &Providers<'_>,
90 token: &SessionToken,
91) -> Result<Option<SessionChange>> {
92 crate::host::within_one_operation(|| {
93 let session = Addressed::of(providers, token)?;
94 let Some(change) = session.find()? else {
95 return Ok(None);
96 };
97 session.read(&change).map(Some)
98 })
99}
100
101/// Replace the session's change request's description, and report the change as it
102/// stands after the write.
103///
104/// The library form of `onevcs change describe`. It refuses a session with no change
105/// request rather than opening one, and holds a title it is given to the
106/// repository's own `commit-msg` hook exactly as a publication's subject is — it is
107/// the squash subject a `change-auto` merge lands under, and no local hook sees it
108/// otherwise. The body is stored as an artifact and the `change-described` event
109/// names it, so `onevcs artifact cat ID` reads back exactly what was written.
110pub fn describe_change(
111 providers: &Providers<'_>,
112 token: &SessionToken,
113 description: &ChangeDescription,
114) -> Result<SessionChange> {
115 crate::host::within_one_operation(|| describe_within(providers, token, description))
116}
117
118fn describe_within(
119 providers: &Providers<'_>,
120 token: &SessionToken,
121 description: &ChangeDescription,
122) -> Result<SessionChange> {
123 let session = Addressed::of(providers, token)?;
124 let change = session.require()?;
125 if let Some(title) = &description.title {
126 session.hold_to_repository_policy(title)?;
127 }
128 // A description is written to the host as it is, and a drafter's finished text is
129 // exactly what reaches a reviewer: so it is held to the boundary before the write.
130 let mut fields = vec![(Surface::Body, description.body.as_str())];
131 if let Some(title) = &description.title {
132 fields.push((Surface::Title, title));
133 }
134 crate::boundary::evidence::guard_fields(
135 providers.hosting,
136 &session.record.identity,
137 &fields,
138 &description.term_scope,
139 )?;
140 session
141 .host
142 .describe_change(&change, description.title.as_deref(), &description.body)?;
143 record_description(&session, &change, description);
144 session.read(&change)
145}
146
147/// Record one description on the session's stream, best effort.
148///
149/// Best effort, as every capture of a thing that has already happened is: the host
150/// has the description by the time this runs, and reporting the write as failed
151/// because its own record could not be stored would send somebody to write it again.
152/// The body is an artifact rather than a field of the payload because it is prose of
153/// unbounded size and the stream bounds payload text; a body that could not be
154/// stored leaves the event without one, said on stderr, rather than an event that
155/// names an artifact nothing can read.
156fn record_description(
157 session: &Addressed<'_>,
158 change: &ChangeRequest,
159 description: &ChangeDescription,
160) {
161 let mut stream = match session.stream() {
162 Ok(stream) => stream,
163 Err(error) => {
164 eprintln!(
165 "onevcs: warning: the description of {} was written and is not recorded: {error}",
166 change.url
167 );
168 return;
169 }
170 };
171 let mut payload = object(json!({
172 "url": change.url.to_string(),
173 "id": change.id.0,
174 "base": change.base,
175 }));
176 if let Some(title) = &description.title {
177 payload.insert("title".to_owned(), json!(title.to_string()));
178 }
179 let artifacts: Vec<ArtifactRef> = match stream::store_artifact("body", &description.body) {
180 Ok(artifact) => {
181 payload.insert("artifact".to_owned(), json!(artifact.id));
182 vec![artifact]
183 }
184 Err(error) => {
185 eprintln!(
186 "onevcs: warning: the description of {} is recorded without its body: {error}",
187 change.url
188 );
189 Vec::new()
190 }
191 };
192 stream.emit_with(EventKind::ChangeDescribed, payload, artifacts);
193}
194
195/// Mark the session's change request ready for review, and report the change as it
196/// stands.
197///
198/// The library form of `onevcs change ready`, and the lift a publication carrying no
199/// reason performs, asked for as a verb: the same `ready_for_review` on the host and
200/// the same `draft-lifted` on the stream. A change request that is not a draft is
201/// asked for nothing and reported as it stands — idempotent because the host
202/// decides, exactly as the publication's lift is.
203pub fn ready_change(providers: &Providers<'_>, token: &SessionToken) -> Result<SessionChange> {
204 crate::host::within_one_operation(|| {
205 let session = Addressed::of(providers, token)?;
206 let change = session.require()?;
207 let mut stream = session.stream()?;
208 publish::lift_any_draft(session.host.as_ref(), &change, &mut stream)?;
209 session.read(&change)
210 })
211}
212
213/// One session, resolved as far as the host that answers for its change request.
214///
215/// Everything here goes through the seam: the session comes from the [`Vcs`](crate::Vcs)
216/// the caller supplied and the host from its [`Hosting`](crate::Hosting), so a session
217/// a supplied implementation opened is addressed exactly as one `Git` opened.
218pub(crate) struct Addressed<'a> {
219 token: &'a SessionToken,
220 pub(crate) record: SessionRecord,
221 /// The base the session's publication resolves for it, which is what its change
222 /// request targets: the recorded base, or for a stacked session the root once
223 /// the root carries the change below.
224 base: String,
225 pub(crate) host: Box<dyn RemoteHost>,
226}
227
228impl<'a> Addressed<'a> {
229 pub(crate) fn of(providers: &Providers<'_>, token: &'a SessionToken) -> Result<Self> {
230 let record = providers.vcs.session(token)?;
231 // The same question a publication asks of the identity, with the same two
232 // answers: a local identity has no host at all, and a hosted one this build
233 // has no implementation for is the seam `Error::NotImplemented` exists for.
234 let slug = publish::change_host(&record.identity)?;
235 let host = providers.hosting.for_repo(&slug)?;
236 let base = publication_base(token, &record)?;
237 Ok(Self {
238 token,
239 record,
240 base,
241 host,
242 })
243 }
244
245 /// The open change request from the session's branch into the base its
246 /// publication resolves, if the host holds one.
247 fn find(&self) -> Result<Option<ChangeRequest>> {
248 Ok(self
249 .host
250 .find_changes(&self.record.session.branch, &self.base)?
251 .into_iter()
252 .next())
253 }
254
255 /// The session's change request, or the refusal that there is none — naming the
256 /// publication that opens one, because a verb that diagnoses without naming the
257 /// command that advances the work leaves a caller to invent one.
258 pub(crate) fn require(&self) -> Result<ChangeRequest> {
259 self.find()?.ok_or_else(|| Error::Invalid {
260 reason: format!(
261 "session {token} has no open change request from {branch:?} into {base:?} on the \
262 host, so there is nothing to act on. Nothing here opens one: publish the \
263 session first — `{publish}` opens it as a draft the session holds while its \
264 work is still being made",
265 token = self.token.0,
266 branch = self.record.session.branch,
267 base = self.base,
268 publish = guidance::command(["onevcs", "publish", &self.token.0, "--draft"]),
269 ),
270 })
271 }
272
273 /// The change request as the host holds it now.
274 fn read(&self, change: &ChangeRequest) -> Result<SessionChange> {
275 let description = self.host.change_description(change)?;
276 Ok(SessionChange {
277 url: change.url.clone(),
278 id: change.id.clone(),
279 base: change.base.clone(),
280 // A host that cannot say whether it holds the change as a draft cannot
281 // answer this at all, so its refusal is the answer's: `false` from a host
282 // that was never taught to say would report a change somebody held back as
283 // one nothing is holding.
284 draft: self.host.is_draft(change)?,
285 title: description.title,
286 body: description.body,
287 })
288 }
289
290 /// Put a title to the repository's own `commit-msg` hook, where there is a
291 /// repository to ask.
292 ///
293 /// The session's worktree is where a `Git` session keeps its checkout, and the
294 /// hook resolves through it the way it resolves for a publication — so a title
295 /// the hook turns down is refused here, before the host is asked, and hands back
296 /// what the hook wrote. A worktree that is not a git checkout at all is a session a
297 /// supplied [`Vcs`](crate::Vcs) opened, whose repository side has no hook to ask:
298 /// it states no policy, exactly as such a provider publishes a requested title
299 /// without one, and this asks it nothing.
300 fn hold_to_repository_policy(&self, title: &Subject) -> Result<()> {
301 let worktree = &self.record.session.worktree;
302 if !git::is_repo(worktree) {
303 return Ok(());
304 }
305 publish::hold_to_repository_policy(worktree, &self.record.session.branch, title)
306 }
307
308 /// The session's own stream, labelled the way its publication labels it.
309 fn stream(&self) -> Result<Stream> {
310 let mut stream = Stream::open(&self.token.0)?;
311 stream.label("identity", &self.record.identity);
312 Ok(stream)
313 }
314}
315
316/// The base the session's publication resolves for it — what its change request
317/// targets — through the one computation `publish` makes.
318///
319/// A stack is a thing only the `Git` implementation records: its publication moves a
320/// stacked change onto the root once the root carries the change below, and the
321/// [`Vcs`](crate::Vcs) interface has no word for that, so a session's own record
322/// names the base it was opened against and nothing more. The record under the
323/// state root is where `Git` keeps the rest, and it is consulted where it names this
324/// same session — the same worktree on the same branch — which is what a session
325/// `Git` opened always has and a session a supplied `Vcs` opened never does. Those
326/// have no stack to resolve, and publish into the base their record names.
327fn publication_base(token: &SessionToken, record: &SessionRecord) -> Result<String> {
328 match workspace::load(&token.0) {
329 Ok(stored) if stored.session() == record.session => {
330 Ok(publish::session_target(&stored)?.base().to_string())
331 }
332 _ => Ok(record.session.base.clone()),
333 }
334}