Skip to main content

teamcraft/mcp/tools/
request_tools.rs

1//! Change requests: list, overview, and the write tools that raise and comment on one.
2
3use super::*;
4
5pub(super) fn request_brief(r: &ChangeRequest) -> Value {
6    json!({
7        "number": r.number,
8        "title": r.title,
9        "status": r.status,
10        "reason": r.reason,
11        "priority": r.priority,
12        "requested_by": r.requested_by_name,
13        "assignee": r.assignee_name,
14        "due_date": r.due_date,
15        "item_count": r.item_count,
16        "created_at": r.created_at,
17        "updated_at": r.updated_at,
18        "closed_at": r.closed_at,
19    })
20}
21
22/// The `status` argument of `list_change_requests`: a status, `open` (not
23/// finished) or `all` (default) → (status filter, open only).
24pub fn request_status_filter(raw: Option<&str>) -> AppResult<(Option<RequestStatus>, bool)> {
25    match raw.map(str::trim).unwrap_or("all") {
26        "" | "all" => Ok((None, false)),
27        "open" => Ok((None, true)),
28        s => RequestStatus::parse(s)
29            .map(|status| (Some(status), false))
30            .ok_or_else(|| {
31                bad(format!(
32                    "Unknown status '{s}' ({}).",
33                    REQUEST_STATUS_FILTERS.join(", ")
34                ))
35            }),
36    }
37}
38
39/// The `scope` argument: `mine` → (requested by me, –), `assigned` →
40/// (–, assigned to me), none → no filter.
41pub fn request_scope(
42    raw: Option<&str>,
43    me: uuid::Uuid,
44) -> AppResult<(Option<uuid::Uuid>, Option<uuid::Uuid>)> {
45    match raw.map(str::trim).unwrap_or("") {
46        "" => Ok((None, None)),
47        "mine" => Ok((Some(me), None)),
48        "assigned" => Ok((None, Some(me))),
49        s => Err(bad(format!("Unknown scope '{s}' (mine or assigned)."))),
50    }
51}
52
53/// Keeps the newest `max` entries of a list ordered oldest first.
54pub(super) fn newest<T>(mut list: Vec<T>, max: usize) -> (Vec<T>, bool) {
55    let truncated = list.len() > max;
56    if truncated {
57        list.drain(..list.len() - max);
58    }
59    (list, truncated)
60}
61
62pub(super) async fn list_change_requests(
63    st: &AppState,
64    me: &Actor,
65    a: &Args<'_>,
66) -> AppResult<Value> {
67    let (status, open_only) = request_status_filter(a.opt_str("status")?.as_deref())?;
68    let (requested_by, assignee) = request_scope(a.opt_str("scope")?.as_deref(), me.id)?;
69    let item_id = match a.opt_str("item")? {
70        Some(number) => Some(items::get_by_number(&st.db, me, &number).await?.id),
71        None => None,
72    };
73    let limit = a.limit("limit", 20, 100)?;
74    let filter = requests::RequestFilter {
75        status,
76        open_only,
77        requested_by,
78        assignee,
79        item_id,
80    };
81    let (rows, total) = requests::list(&st.db, me, &filter, Page::new(Some(limit), None)).await?;
82    Ok(json!({
83        "total": total,
84        "truncated": total > rows.len() as i64,
85        "change_requests": rows.iter().map(request_brief).collect::<Vec<_>>(),
86    }))
87}
88
89/// A change request with everything an assistant needs to discuss it.
90async fn request_overview(st: &AppState, me: &Actor, request: &ChangeRequest) -> AppResult<Value> {
91    let id = request.id;
92    let ids = [id];
93    let (affected, comments, attachments, linked, history, can) = tokio::try_join!(
94        requests::items(&st.db, me, id),
95        requests::comments(&st.db, id),
96        requests::attachments(&st.db, id),
97        requests::linked_changes(&st.db, me, id),
98        // one more than shown tells whether older entries exist
99        audit::for_entities(&st.db, me, &ids, REQUEST_HISTORY + 1),
100        requests::abilities_of(&st.db, me, request),
101    )?;
102    let (comments, comments_cut) = newest(comments, MAX_COMMENTS);
103    let (history, history_cut) = capped(history, REQUEST_HISTORY as usize);
104    let mut brief = request_brief(request);
105    brief["description"] = json!(request.description);
106    if request.decided_at.is_some() {
107        brief["decision"] = json!({
108            "by": request.decided_by_name,
109            "at": request.decided_at,
110            "comment": request.decision_comment,
111        });
112    }
113    Ok(json!({
114        "change_request": brief,
115        "items": affected.iter().map(|i| json!({
116            "item": i.item_number,
117            "name": i.name,
118            "type": i.item_type,
119            "project": i.project_code,
120            "found_in_revision": i.revision_code,
121            "found_in_state": i.revision_state,
122            "latest_revision": i.latest_code,
123            "latest_state": i.latest_state,
124            "note": i.note,
125        })).collect::<Vec<_>>(),
126        "comments": comments.iter().map(|c| json!({
127            "author": c.author_name,
128            "at": c.created_at,
129            "body": c.body,
130        })).collect::<Vec<_>>(),
131        "comments_truncated": comments_cut,
132        "attachments": attachments.iter().map(|f| json!({
133            "filename": f.filename,
134            "content_type": f.content_type,
135            "size_bytes": f.size_bytes,
136            "uploaded_by": f.uploaded_by_name,
137            "uploaded_at": f.uploaded_at,
138        })).collect::<Vec<_>>(),
139        "changes": linked.0.iter().map(change_brief).collect::<Vec<_>>(),
140        "hidden_changes": linked.1,
141        "history": history.iter().map(|e| json!({
142            "at": e.at,
143            "user": e.username,
144            "action": e.action,
145            "summary": e.summary,
146        })).collect::<Vec<_>>(),
147        "history_truncated": history_cut,
148        "you_may": can,
149        "url": web_url(st, &format!("/requests/{}", request.id)),
150    }))
151}
152
153pub(super) async fn get_change_request(
154    st: &AppState,
155    me: &Actor,
156    a: &Args<'_>,
157) -> AppResult<Value> {
158    let request = requests::get_by_number(&st.db, me, &a.req_str("request")?).await?;
159    request_overview(st, me, &request).await
160}
161
162/// Open change requests on an item, for `get_item` / `get_revision`.
163pub(super) async fn open_requests_of(
164    st: &AppState,
165    me: &Actor,
166    item_id: uuid::Uuid,
167) -> AppResult<(Vec<Value>, bool)> {
168    let (rows, cut) = capped(
169        requests::open_for_item(&st.db, me, item_id).await?,
170        MAX_RELATED,
171    );
172    Ok((rows.iter().map(request_brief).collect(), cut))
173}
174
175pub fn request_reason(raw: Option<&str>) -> AppResult<RequestReason> {
176    let raw = raw
177        .map(str::trim)
178        .filter(|s| !s.is_empty())
179        .unwrap_or("other");
180    RequestReason::parse(raw).ok_or_else(|| {
181        bad(format!(
182            "Unknown reason '{raw}' ({}).",
183            REQUEST_REASONS.join(", ")
184        ))
185    })
186}
187
188/// An affected item of a new request: `PRT-000012` or `PRT-000012/B`.
189async fn affected_item(
190    st: &AppState,
191    me: &Actor,
192    reference: &str,
193) -> AppResult<requests::AffectedItem> {
194    let (number, code) = parse_item_ref(reference);
195    Ok(match code {
196        Some(code) => {
197            let rev = revision_at(st, me, &number, Some(&code)).await?;
198            requests::AffectedItem {
199                item_id: rev.item_id,
200                revision_id: Some(rev.id),
201                note: String::new(),
202            }
203        }
204        None => requests::AffectedItem {
205            item_id: items::get_by_number(&st.db, me, &number).await?.id,
206            revision_id: None,
207            note: String::new(),
208        },
209    })
210}
211
212const REQUEST_NEXT_STEPS: &str = "The change request is new. CAD editors of the affected project groups \
213    triage it in TeamCraft and accept, defer or reject it; an accepted request is implemented by a change \
214    order. The assistant cannot decide it.";
215
216pub(super) async fn create_change_request(
217    st: &AppState,
218    me: &Actor,
219    a: &Args<'_>,
220) -> AppResult<Value> {
221    let title = a.req_str("title")?;
222    let description = a.opt_str("description")?.unwrap_or_default();
223    let reason = request_reason(a.opt_str("reason")?.as_deref())?;
224    let priority = a.opt_str("priority")?.unwrap_or_else(|| "normal".into());
225    let priority = Priority::parse(&priority)
226        .ok_or_else(|| bad("priority must be low, normal, high or critical."))?;
227    let due_date = requests::parse_due_date(a.opt_str("due_date")?.as_deref())?;
228    let refs = a.list("items", requests::MAX_ITEMS as usize)?;
229    // Resolve every item before anything is written.
230    let mut affected = Vec::with_capacity(refs.len());
231    for r in &refs {
232        affected.push(affected_item(st, me, r).await?);
233    }
234    let request = requests::create(
235        &st.db,
236        me,
237        requests::RequestInput {
238            title,
239            description,
240            reason,
241            priority,
242            due_date,
243        },
244        affected,
245    )
246    .await?;
247    let mut value = request_overview(st, me, &request).await?;
248    value["next_steps"] = json!(REQUEST_NEXT_STEPS);
249    Ok(value)
250}
251
252pub(super) async fn comment_change_request(
253    st: &AppState,
254    me: &Actor,
255    a: &Args<'_>,
256) -> AppResult<Value> {
257    let request = requests::get_by_number(&st.db, me, &a.req_str("request")?).await?;
258    let body = a.req_str("body")?;
259    let comment = requests::comment(&st.db, me, request.id, &body).await?;
260    Ok(json!({
261        "change_request": request.number,
262        "status": request.status,
263        "comment": {
264            "author": comment.author_name,
265            "at": comment.created_at,
266            "body": comment.body,
267        },
268        "url": web_url(st, &format!("/requests/{}", request.id)),
269    }))
270}