1use 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#[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 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 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 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
335pub 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
556pub 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}