Skip to main content

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}