Skip to main content

harn_vm/orchestration/playground/
fake_server.rs

1//! Pure fake-GitHub HTTP request handlers for the Merge Captain mock-repos
2//! playground (#1020). The CLI binds these to an axum `Router`; tests can
3//! call them directly to assert per-endpoint behavior without TCP.
4//!
5//! The endpoint surface is the strict subset the issue calls out:
6//! `pulls`, `checks`, `actions/runs/.../logs`, `merge_queue`, `issues`,
7//! `comments`, `pulls/.../merge`. Every response is sourced from the
8//! mutable `PlaygroundState`; mutating endpoints update the state in
9//! place and return the updated representation.
10
11use std::collections::HashMap;
12
13use serde::{Deserialize, Serialize};
14use serde_json::{json, Value};
15
16use super::manifest::ScenarioComment;
17use super::state::{PlaygroundPullRequest, PlaygroundState};
18
19/// Response envelope for any handler.
20#[derive(Clone, Debug, Serialize)]
21pub struct FakeResponse {
22    pub status: u16,
23    pub body: Value,
24}
25
26impl FakeResponse {
27    pub fn ok(body: Value) -> Self {
28        FakeResponse { status: 200, body }
29    }
30    pub fn created(body: Value) -> Self {
31        FakeResponse { status: 201, body }
32    }
33    pub fn not_found(message: &str) -> Self {
34        FakeResponse {
35            status: 404,
36            body: json!({"message": message, "documentation_url": ""}),
37        }
38    }
39    pub fn unprocessable(message: &str) -> Self {
40        FakeResponse {
41            status: 422,
42            body: json!({"message": message, "documentation_url": ""}),
43        }
44    }
45    pub fn bad_request(message: &str) -> Self {
46        FakeResponse {
47            status: 400,
48            body: json!({"message": message, "documentation_url": ""}),
49        }
50    }
51}
52
53#[derive(Clone, Debug, Default, Deserialize)]
54pub struct ListPullsQuery {
55    pub state: Option<String>,
56    pub head: Option<String>,
57    pub base: Option<String>,
58    pub per_page: Option<u32>,
59}
60
61pub fn list_pulls(
62    state: &PlaygroundState,
63    owner: &str,
64    repo: &str,
65    query: &ListPullsQuery,
66) -> FakeResponse {
67    if state.owner != owner {
68        return FakeResponse::not_found(&format!("unknown owner {owner}"));
69    }
70    if !state.repos.contains_key(repo) {
71        return FakeResponse::not_found(&format!("unknown repo {owner}/{repo}"));
72    }
73    let want_state = query
74        .state
75        .clone()
76        .unwrap_or_else(|| "open".to_string())
77        .to_lowercase();
78    let mut prs: Vec<&PlaygroundPullRequest> = state
79        .pull_requests
80        .values()
81        .filter(|pr| pr.repo == repo)
82        .filter(|pr| match want_state.as_str() {
83            "all" => true,
84            other => pr.state.eq_ignore_ascii_case(other),
85        })
86        .filter(|pr| {
87            query
88                .base
89                .as_ref()
90                .map(|b| pr.base_branch == *b)
91                .unwrap_or(true)
92        })
93        .filter(|pr| {
94            query
95                .head
96                .as_ref()
97                .map(|h| {
98                    let parts: Vec<&str> = h.split(':').collect();
99                    let branch = parts.last().copied().unwrap_or("");
100                    pr.head_branch == branch
101                })
102                .unwrap_or(true)
103        })
104        .collect();
105    prs.sort_by_key(|pr| pr.number);
106    let body = Value::Array(prs.iter().map(|pr| pr_to_v3(state, pr)).collect());
107    FakeResponse::ok(body)
108}
109
110pub fn get_pull(state: &PlaygroundState, owner: &str, repo: &str, number: u64) -> FakeResponse {
111    let Some(pr) = pr_lookup(state, owner, repo, number) else {
112        return FakeResponse::not_found(&format!("PR {owner}/{repo}#{number} not found"));
113    };
114    FakeResponse::ok(pr_to_v3(state, pr))
115}
116
117pub fn list_pull_files(
118    state: &PlaygroundState,
119    owner: &str,
120    repo: &str,
121    number: u64,
122) -> FakeResponse {
123    let Some(pr) = pr_lookup(state, owner, repo, number) else {
124        return FakeResponse::not_found(&format!("PR {owner}/{repo}#{number} not found"));
125    };
126    // The fake server doesn't compute real diffs — it returns a stable stub
127    // describing the head_branch/base_branch pair so the consumer can decide
128    // whether to fetch the working tree itself.
129    FakeResponse::ok(json!([
130        {
131            "filename": format!("{}-vs-{}.summary", pr.head_branch, pr.base_branch),
132            "status": "modified",
133            "additions": 1,
134            "deletions": 0,
135            "changes": 1,
136            "patch": ""
137        }
138    ]))
139}
140
141pub fn list_check_runs(
142    state: &PlaygroundState,
143    owner: &str,
144    repo: &str,
145    sha_or_ref: &str,
146) -> FakeResponse {
147    if state.owner != owner || !state.repos.contains_key(repo) {
148        return FakeResponse::not_found("unknown repo");
149    }
150    // Find a PR whose head_sha or head_branch matches the ref/sha.
151    let pr = state.pull_requests.values().find(|pr| {
152        pr.repo == repo
153            && (pr
154                .head_sha
155                .as_deref()
156                .map(|sha| sha == sha_or_ref)
157                .unwrap_or(false)
158                || pr.head_branch == sha_or_ref)
159    });
160    let runs: Vec<Value> = match pr {
161        Some(pr) => pr
162            .checks
163            .iter()
164            .enumerate()
165            .map(|(idx, check)| {
166                json!({
167                    "id": (pr.number * 1000 + idx as u64),
168                    "name": check.name,
169                    "status": check.status,
170                    "conclusion": check.conclusion,
171                    "head_sha": pr.head_sha,
172                    "details_url": check.details_url,
173                    "started_at": check.started_at,
174                    "completed_at": check.completed_at,
175                })
176            })
177            .collect(),
178        None => Vec::new(),
179    };
180    FakeResponse::ok(json!({"total_count": runs.len(), "check_runs": runs}))
181}
182
183pub fn workflow_run_logs(
184    state: &PlaygroundState,
185    owner: &str,
186    repo: &str,
187    run_id: u64,
188) -> FakeResponse {
189    // We don't keep real logs; just return a deterministic synthetic blob
190    // keyed by run_id so connector code that streams logs has something
191    // to assert on.
192    if state.owner != owner || !state.repos.contains_key(repo) {
193        return FakeResponse::not_found("unknown repo");
194    }
195    let body = json!({
196        "run_id": run_id,
197        "log_lines": [
198            format!("[{owner}/{repo}#{run_id}] starting"),
199            format!("[{owner}/{repo}#{run_id}] step 1 / 1 succeeded"),
200            format!("[{owner}/{repo}#{run_id}] finished")
201        ]
202    });
203    FakeResponse::ok(body)
204}
205
206pub fn list_issue_comments(
207    state: &PlaygroundState,
208    owner: &str,
209    repo: &str,
210    number: u64,
211) -> FakeResponse {
212    let Some(pr) = pr_lookup(state, owner, repo, number) else {
213        return FakeResponse::not_found(&format!("PR {owner}/{repo}#{number} not found"));
214    };
215    let body = Value::Array(
216        pr.comments
217            .iter()
218            .enumerate()
219            .map(|(idx, c)| {
220                json!({
221                    "id": pr.number * 1000 + idx as u64,
222                    "body": c.body,
223                    "user": {"login": c.user},
224                    "created_at": c.created_at
225                })
226            })
227            .collect(),
228    );
229    FakeResponse::ok(body)
230}
231
232#[derive(Clone, Debug, Deserialize)]
233pub struct CreateCommentBody {
234    pub body: String,
235    #[serde(default)]
236    pub user: Option<String>,
237}
238
239pub fn create_issue_comment(
240    state: &mut PlaygroundState,
241    owner: &str,
242    repo: &str,
243    number: u64,
244    payload: CreateCommentBody,
245) -> FakeResponse {
246    let pr_key = PlaygroundPullRequest::compose_key(repo, number);
247    if state.owner != owner {
248        return FakeResponse::not_found("unknown owner");
249    }
250    let now_ms = state.now_ms;
251    let id;
252    let user = payload
253        .user
254        .clone()
255        .unwrap_or_else(|| "playground-bot".to_string());
256    let now = format_now(now_ms);
257    {
258        let Some(pr) = state.pull_requests.get_mut(&pr_key) else {
259            return FakeResponse::not_found(&format!("PR {owner}/{repo}#{number} not found"));
260        };
261        pr.comments.push(ScenarioComment {
262            user: user.clone(),
263            body: payload.body.clone(),
264            created_at: Some(now.clone()),
265        });
266        id = pr.number * 1000 + (pr.comments.len() as u64 - 1);
267    }
268    state.record(
269        "fake_server:create_issue_comment",
270        json!({"repo": repo, "number": number, "user": user}),
271    );
272    FakeResponse::created(json!({
273        "id": id,
274        "body": payload.body,
275        "user": {"login": user},
276        "created_at": now
277    }))
278}
279
280#[derive(Clone, Debug, Deserialize, Default)]
281pub struct UpdatePullBody {
282    pub state: Option<String>,
283    pub title: Option<String>,
284    pub body: Option<String>,
285    pub base: Option<String>,
286    pub labels: Option<Vec<String>>,
287}
288
289pub fn patch_pull(
290    state: &mut PlaygroundState,
291    owner: &str,
292    repo: &str,
293    number: u64,
294    payload: UpdatePullBody,
295) -> FakeResponse {
296    let pr_key = PlaygroundPullRequest::compose_key(repo, number);
297    if state.owner != owner {
298        return FakeResponse::not_found("unknown owner");
299    }
300    let pr_clone = {
301        let Some(pr) = state.pull_requests.get_mut(&pr_key) else {
302            return FakeResponse::not_found(&format!("PR {owner}/{repo}#{number} not found"));
303        };
304        if let Some(s) = payload.state {
305            pr.state = s;
306        }
307        if let Some(t) = payload.title {
308            pr.title = t;
309        }
310        if let Some(b) = payload.body {
311            pr.body = b;
312        }
313        if let Some(base) = payload.base {
314            pr.base_branch = base;
315        }
316        if let Some(labels) = payload.labels {
317            pr.labels = labels;
318        }
319        pr.clone()
320    };
321    state.record(
322        "fake_server:patch_pull",
323        json!({"repo": repo, "number": number}),
324    );
325    FakeResponse::ok(pr_to_v3(state, &pr_clone))
326}
327
328#[derive(Clone, Debug, Default, Deserialize)]
329pub struct MergePullBody {
330    pub merge_method: Option<String>,
331    pub commit_title: Option<String>,
332    pub commit_message: Option<String>,
333}
334
335/// `PUT /repos/:owner/:repo/pulls/:number/merge`. Updates the PR state to
336/// `merged` but does NOT produce a real merge commit on the bare remote —
337/// the connector or `mock step --action merge_pull_request` is responsible
338/// for the git mutation. Mirrors GitHub's API which performs the merge
339/// server-side; here it's a pure state update.
340pub fn merge_pull(
341    state: &mut PlaygroundState,
342    owner: &str,
343    repo: &str,
344    number: u64,
345    payload: MergePullBody,
346) -> FakeResponse {
347    let pr_key = PlaygroundPullRequest::compose_key(repo, number);
348    if state.owner != owner {
349        return FakeResponse::not_found("unknown owner");
350    }
351    let now_ms = state.now_ms;
352    let _method = payload.merge_method.unwrap_or_else(|| "merge".to_string());
353    let head_sha = {
354        let Some(pr) = state.pull_requests.get_mut(&pr_key) else {
355            return FakeResponse::not_found(&format!("PR {owner}/{repo}#{number} not found"));
356        };
357        if pr.state != "open" {
358            return FakeResponse::unprocessable(&format!(
359                "PR {owner}/{repo}#{number} is not open (state={})",
360                pr.state
361            ));
362        }
363        if pr.mergeable == Some(false) || pr.mergeable_state == "dirty" {
364            return FakeResponse::unprocessable(&format!(
365                "PR {owner}/{repo}#{number} is not mergeable (state={})",
366                pr.mergeable_state
367            ));
368        }
369        pr.state = "merged".to_string();
370        pr.merged_at = Some(format_now(now_ms));
371        pr.mergeable_state = "clean".to_string();
372        pr.head_sha.clone()
373    };
374    state.record(
375        "fake_server:merge_pull",
376        json!({"repo": repo, "number": number}),
377    );
378    FakeResponse::ok(json!({
379        "sha": head_sha,
380        "merged": true,
381        "message": "Pull Request successfully merged"
382    }))
383}
384
385#[derive(Clone, Debug, Default, Deserialize)]
386pub struct EnqueueMergeQueueBody {
387    pub pull_number: u64,
388    #[serde(default)]
389    pub priority: Option<String>,
390}
391
392pub fn merge_queue_enqueue(
393    state: &mut PlaygroundState,
394    owner: &str,
395    repo: &str,
396    body: EnqueueMergeQueueBody,
397) -> FakeResponse {
398    let pr_key = PlaygroundPullRequest::compose_key(repo, body.pull_number);
399    if state.owner != owner {
400        return FakeResponse::not_found("unknown owner");
401    }
402    {
403        let Some(pr) = state.pull_requests.get_mut(&pr_key) else {
404            return FakeResponse::not_found(&format!(
405                "PR {owner}/{repo}#{} not found",
406                body.pull_number
407            ));
408        };
409        if pr.state != "open" {
410            return FakeResponse::unprocessable("PR is not open");
411        }
412        pr.merge_queue_status = Some("queued".to_string());
413    }
414    state.record(
415        "fake_server:merge_queue_enqueue",
416        json!({"repo": repo, "number": body.pull_number}),
417    );
418    FakeResponse::created(json!({
419        "pull_number": body.pull_number,
420        "status": "queued",
421        "priority": body.priority
422    }))
423}
424
425pub fn merge_queue_status(
426    state: &PlaygroundState,
427    owner: &str,
428    repo: &str,
429    base: &str,
430) -> FakeResponse {
431    if state.owner != owner || !state.repos.contains_key(repo) {
432        return FakeResponse::not_found("unknown repo");
433    }
434    let mut entries: Vec<Value> = state
435        .pull_requests
436        .values()
437        .filter(|pr| pr.repo == repo && pr.base_branch == base && pr.state == "open")
438        .filter_map(|pr| {
439            pr.merge_queue_status.as_ref().map(|status| {
440                json!({
441                    "pull_request_number": pr.number,
442                    "status": status,
443                    "head_branch": pr.head_branch
444                })
445            })
446        })
447        .collect();
448    entries.sort_by_key(|v| v["pull_request_number"].as_u64().unwrap_or(0));
449    FakeResponse::ok(json!({"base": base, "entries": entries}))
450}
451
452#[derive(Clone, Debug, Default, Deserialize)]
453pub struct SetLabelsBody {
454    pub labels: Vec<String>,
455}
456
457pub fn set_labels(
458    state: &mut PlaygroundState,
459    owner: &str,
460    repo: &str,
461    number: u64,
462    body: SetLabelsBody,
463) -> FakeResponse {
464    let pr_key = PlaygroundPullRequest::compose_key(repo, number);
465    if state.owner != owner {
466        return FakeResponse::not_found("unknown owner");
467    }
468    let labels_now = {
469        let Some(pr) = state.pull_requests.get_mut(&pr_key) else {
470            return FakeResponse::not_found(&format!("PR {owner}/{repo}#{number} not found"));
471        };
472        pr.labels = body.labels;
473        pr.labels.clone()
474    };
475    state.record(
476        "fake_server:set_labels",
477        json!({"repo": repo, "number": number}),
478    );
479    FakeResponse::ok(Value::Array(
480        labels_now
481            .iter()
482            .map(|l| json!({"name": l, "color": "ededed"}))
483            .collect(),
484    ))
485}
486
487pub fn get_issue(state: &PlaygroundState, owner: &str, repo: &str, number: u64) -> FakeResponse {
488    let Some(pr) = pr_lookup(state, owner, repo, number) else {
489        return FakeResponse::not_found(&format!("issue {owner}/{repo}#{number} not found"));
490    };
491    FakeResponse::ok(json!({
492        "number": pr.number,
493        "title": pr.title,
494        "body": pr.body,
495        "state": pr.state,
496        "user": {"login": pr.user},
497        "labels": pr.labels.iter().map(|l| json!({"name": l, "color": "ededed"})).collect::<Vec<_>>(),
498        "comments": pr.comments.len(),
499        "html_url": format!("https://github.com/{}/{}/pull/{}", owner, repo, number)
500    }))
501}
502
503fn pr_lookup<'a>(
504    state: &'a PlaygroundState,
505    owner: &str,
506    repo: &str,
507    number: u64,
508) -> Option<&'a PlaygroundPullRequest> {
509    if state.owner != owner {
510        return None;
511    }
512    state
513        .pull_requests
514        .get(&PlaygroundPullRequest::compose_key(repo, number))
515}
516
517fn pr_to_v3(state: &PlaygroundState, pr: &PlaygroundPullRequest) -> Value {
518    json!({
519        "number": pr.number,
520        "title": pr.title,
521        "body": pr.body,
522        "state": pr.state,
523        "draft": pr.draft,
524        "head": {
525            "ref": pr.head_branch,
526            "sha": pr.head_sha,
527            "label": format!("{}:{}", state.owner, pr.head_branch)
528        },
529        "base": {
530            "ref": pr.base_branch,
531            "sha": Value::Null,
532            "label": format!("{}:{}", state.owner, pr.base_branch)
533        },
534        "user": {"login": pr.user},
535        "labels": pr.labels.iter().map(|l| json!({"name": l, "color": "ededed"})).collect::<Vec<_>>(),
536        "mergeable": pr.mergeable,
537        "mergeable_state": pr.mergeable_state,
538        "merged": pr.state == "merged",
539        "merged_at": pr.merged_at,
540        "closed_at": pr.closed_at,
541        "comments": pr.comments.len(),
542        "auto_merge": pr.merge_queue_status.as_ref().map(|status| json!({"merge_method": "merge", "status": status})),
543        "html_url": format!("https://github.com/{}/{}/pull/{}", state.owner, pr.repo, pr.number)
544    })
545}
546
547fn format_now(now_ms: i64) -> String {
548    use chrono::TimeZone;
549    let utc = chrono::Utc
550        .timestamp_millis_opt(now_ms)
551        .single()
552        .unwrap_or_else(chrono::Utc::now);
553    utc.format("%Y-%m-%dT%H:%M:%SZ").to_string()
554}
555
556/// Convenience helper used by the axum-based dispatcher to translate raw
557/// query strings into a typed value.
558pub fn parse_query(query: &str) -> HashMap<String, String> {
559    let mut out = HashMap::new();
560    for pair in query.split('&') {
561        if pair.is_empty() {
562            continue;
563        }
564        let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
565        out.insert(url_decode(k), url_decode(v));
566    }
567    out
568}
569
570fn url_decode(s: &str) -> String {
571    let mut out = String::with_capacity(s.len());
572    let bytes = s.as_bytes();
573    let mut i = 0;
574    while i < bytes.len() {
575        let b = bytes[i];
576        if b == b'+' {
577            out.push(' ');
578            i += 1;
579        } else if b == b'%' && i + 2 < bytes.len() {
580            let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or("00");
581            let v = u8::from_str_radix(hex, 16).unwrap_or(b' ');
582            out.push(v as char);
583            i += 3;
584        } else {
585            out.push(b as char);
586            i += 1;
587        }
588    }
589    out
590}