1use anyhow::Result;
6use serde_json::{Value, json};
7
8use crate::providers::{ReviewProvider, ReviewRequest, ReviewState};
9
10const STACK_SECTION: &str = "stack";
11const CLOSES_SECTION: &str = "closes";
12const DATA_PREFIX: &str = "<!-- git-stk:data ";
13const COMMENT_END: &str = "-->";
14const TOOL_URL: &str = "https://github.com/lararosekelley/git-stk";
15const LOGO_URL: &str =
16 "https://raw.githubusercontent.com/lararosekelley/git-stk/main/assets/logo.svg";
17
18fn marker_start(name: &str) -> String {
19 format!("<!-- git-stk:{name} -->")
20}
21
22fn marker_end(name: &str) -> String {
23 format!("<!-- /git-stk:{name} -->")
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
31struct NoteEntry {
32 id: String,
33 url: String,
34 title: String,
35 state: String,
36}
37
38impl NoteEntry {
39 fn from_review(review: &ReviewRequest) -> Self {
40 Self {
41 id: review.id.clone(),
42 url: review.url.clone(),
43 title: review.title.clone(),
44 state: review.state.to_string(),
45 }
46 }
47
48 fn to_review(&self) -> ReviewRequest {
51 let state = match self.state.as_str() {
52 "open" => ReviewState::Open,
53 "merged" => ReviewState::Merged,
54 "closed" => ReviewState::Closed,
55 other => ReviewState::Unknown(other.to_owned()),
56 };
57 ReviewRequest {
58 id: self.id.clone(),
59 branch: String::new(),
60 base: String::new(),
61 state,
62 url: self.url.clone(),
63 title: self.title.clone(),
64 }
65 }
66
67 fn matches(&self, other: &Self) -> bool {
70 (!self.id.is_empty() && self.id == other.id)
71 || (!self.url.is_empty() && self.url == other.url)
72 }
73}
74
75pub fn update_stack_notes(
82 review_provider: &dyn ReviewProvider,
83 branch_parents: &[(String, String)],
84 dry_run: bool,
85) -> Result<()> {
86 let Some(trunk) = branch_parents.first().map(|(_, parent)| parent.clone()) else {
88 return Ok(());
89 };
90
91 let mut live = Vec::new();
92 for (branch, _) in branch_parents {
93 match review_provider.review_for_branch_including_closed(branch)? {
97 Some(review) if review.branch == *branch => live.push(review),
98 _ => {
99 if !dry_run {
102 println!("skipped stack notes: no review found for {branch}");
103 }
104 return Ok(());
105 }
106 }
107 }
108
109 if dry_run {
110 for review in &live {
111 println!("would update stack note in {}", review.id);
112 }
113 return Ok(());
114 }
115
116 let mut bodies = Vec::new();
120 for review in &live {
121 bodies.push(review_provider.review_body(review)?);
122 }
123
124 let live_entries: Vec<NoteEntry> = live.iter().map(NoteEntry::from_review).collect();
125 let mut historical: Vec<NoteEntry> = Vec::new();
126 for body in &bodies {
127 let Some(section) = extract_section(body, STACK_SECTION) else {
128 continue;
129 };
130 for entry in parse_ledger(section) {
131 let known = live_entries.iter().chain(historical.iter());
132 if !known
133 .into_iter()
134 .any(|entry_known| entry_known.matches(&entry))
135 {
136 historical.push(entry);
137 }
138 }
139 }
140
141 let mut entries = historical.clone();
144 entries.extend(live_entries);
145
146 for (offset, review) in live.iter().enumerate() {
147 let note = build_stack_note(&entries, historical.len() + offset, &trunk);
148 let updated = body_with_section(&bodies[offset], STACK_SECTION, ¬e);
149 if updated == bodies[offset] {
150 continue;
151 }
152
153 review_provider.update_review_body(review, &updated)?;
154 println!("updated stack note in {}", review.id);
155 }
156
157 for (index, entry) in historical.iter().enumerate() {
161 if entry.id.is_empty() {
162 continue;
163 }
164 let review = entry.to_review();
165 let Ok(body) = review_provider.review_body(&review) else {
166 println!("skipped stack note in {}: could not read body", review.id);
167 continue;
168 };
169
170 let note = build_stack_note(&entries, index, &trunk);
171 let updated = body_with_section(&body, STACK_SECTION, ¬e);
172 if updated == body {
173 continue;
174 }
175
176 if review_provider
177 .update_review_body(&review, &updated)
178 .is_err()
179 {
180 println!("skipped stack note in {}: could not update body", review.id);
181 continue;
182 }
183 println!("updated stack note in {}", review.id);
184 }
185
186 Ok(())
187}
188
189pub fn update_closes_notes(
194 review_provider: &dyn ReviewProvider,
195 branches: &[String],
196 dry_run: bool,
197) -> Result<()> {
198 for branch in branches {
199 let Some(issue) = issue_number_from_branch(branch) else {
200 continue;
201 };
202
203 let Some(review) = review_provider.review_for_branch(branch)? else {
204 if dry_run {
207 println!("would link issue #{issue} in the review for {branch}");
208 } else {
209 println!("skipped issue link: no review found for {branch}");
210 }
211 continue;
212 };
213
214 if review.branch != *branch || review.state == ReviewState::Merged {
215 continue;
216 }
217
218 if dry_run {
219 println!("would link issue #{issue} in {}", review.id);
220 continue;
221 }
222
223 let body = review_provider.review_body(&review)?;
224 let updated = body_with_closes_note(&body, &format!("Closes #{issue}"));
225 if updated == body {
226 continue;
227 }
228
229 review_provider.update_review_body(&review, &updated)?;
230 println!("linked issue #{issue} in {}", review.id);
231 }
232
233 Ok(())
234}
235
236fn issue_number_from_branch(branch: &str) -> Option<u64> {
242 for segment in branch.split('/') {
243 let lowered = segment.to_ascii_lowercase();
244 let candidate = lowered
245 .strip_prefix("issue-")
246 .or_else(|| lowered.strip_prefix("issues-"))
247 .unwrap_or(&lowered);
248
249 let end = candidate
250 .find(|character: char| !character.is_ascii_digit())
251 .unwrap_or(candidate.len());
252 let (digits, rest) = candidate.split_at(end);
253 if digits.is_empty() || !(rest.is_empty() || rest.starts_with('-')) {
254 continue;
255 }
256
257 if let Ok(number) = digits.parse::<u64>()
258 && number > 0
259 {
260 return Some(number);
261 }
262 }
263
264 None
265}
266
267fn build_stack_note(entries: &[NoteEntry], current: usize, trunk: &str) -> String {
272 let mut lines = vec![data_line(entries)];
273 for (index, entry) in entries.iter().enumerate().rev() {
274 lines.push(render_entry(entry, index == current));
275 }
276 lines.push(format!("- `{trunk}`"));
277
278 format!(
279 "{}\n\n---\n\nStack managed by \
280 <img src=\"{LOGO_URL}\" width=\"12\" height=\"12\" alt=\"\" /> \
281 [git-stk]({TOOL_URL})",
282 lines.join("\n")
283 )
284}
285
286fn render_entry(entry: &NoteEntry, current: bool) -> String {
289 let label = if entry.title.is_empty() {
290 entry.id.clone()
291 } else {
292 format!("{} ({})", entry.title, entry.id)
293 };
294 let link = format!("[{label}]({})", entry.url);
295
296 let mut line = match entry.state.as_str() {
297 "merged" => format!("- \u{1F7E3} ~~{link}~~ (merged)"),
298 "closed" => format!("- \u{1F534} ~~{link}~~ (closed)"),
299 _ => format!("- \u{1F7E2} {link}"),
300 };
301 if current {
302 line.push_str(" \u{1F448}");
303 }
304 line
305}
306
307fn data_line(entries: &[NoteEntry]) -> String {
310 let data = Value::Array(
311 entries
312 .iter()
313 .map(|entry| {
314 json!({
315 "id": entry.id,
316 "url": entry.url,
317 "title": entry.title,
318 "state": entry.state,
319 })
320 })
321 .collect(),
322 );
323
324 let encoded = data.to_string().replace('>', "\\u003e");
327 format!("{DATA_PREFIX}{encoded} {COMMENT_END}")
328}
329
330fn parse_ledger(section: &str) -> Vec<NoteEntry> {
334 for line in section.lines() {
335 if let Some(rest) = line.trim().strip_prefix(DATA_PREFIX)
336 && let Some(encoded) = rest.trim_end().strip_suffix(COMMENT_END)
337 && let Some(entries) = parse_data_json(encoded.trim())
338 {
339 return entries;
340 }
341 }
342
343 section.lines().filter_map(parse_entry_line).collect()
344}
345
346fn parse_data_json(encoded: &str) -> Option<Vec<NoteEntry>> {
347 let value: Value = serde_json::from_str(encoded).ok()?;
348 let mut entries = Vec::new();
349 for item in value.as_array()? {
350 entries.push(NoteEntry {
351 id: item.get("id")?.as_str()?.to_owned(),
352 url: item.get("url")?.as_str()?.to_owned(),
353 title: item
354 .get("title")
355 .and_then(Value::as_str)
356 .unwrap_or_default()
357 .to_owned(),
358 state: item
359 .get("state")
360 .and_then(Value::as_str)
361 .unwrap_or("open")
362 .to_owned(),
363 });
364 }
365 Some(entries)
366}
367
368fn parse_entry_line(line: &str) -> Option<NoteEntry> {
372 let rest = line.trim().strip_prefix("- ")?;
373 if rest.starts_with('`') {
374 return None;
375 }
376
377 let open = rest.find('[')?;
378 let split = rest[open..].find("](")? + open;
379 let close = rest[split + 2..].find(')')? + split + 2;
380 let label = &rest[open + 1..split];
381 let url = &rest[split + 2..close];
382 let tail = &rest[close + 1..];
383
384 let state = if tail.contains("(merged)") {
385 "merged"
386 } else if tail.contains("(closed)") {
387 "closed"
388 } else {
389 "open"
390 };
391
392 let (title, id) = match rest[open + 1..split].rfind(" (") {
394 Some(position) if label.ends_with(')') => {
395 let id = &label[position + 2..label.len() - 1];
396 if id.starts_with('#') || id.starts_with('!') {
397 (label[..position].to_owned(), id.to_owned())
398 } else {
399 (label.to_owned(), String::new())
400 }
401 }
402 _ if label.starts_with('#') || label.starts_with('!') => (String::new(), label.to_owned()),
403 _ => (label.to_owned(), String::new()),
404 };
405
406 Some(NoteEntry {
407 id,
408 url: url.to_owned(),
409 title,
410 state: state.to_owned(),
411 })
412}
413
414fn extract_section<'body>(body: &'body str, name: &str) -> Option<&'body str> {
416 let start_marker = marker_start(name);
417 let end_marker = marker_end(name);
418 let start = body.find(&start_marker)? + start_marker.len();
419 let length = body[start..].find(&end_marker)?;
420 Some(&body[start..start + length])
421}
422
423fn body_with_section(body: &str, name: &str, content: &str) -> String {
427 let section = format!("{}\n{content}\n{}", marker_start(name), marker_end(name));
428 let cleaned = strip_sections(body, name);
429
430 if cleaned.trim().is_empty() {
431 section
432 } else {
433 format!("{}\n\n{section}", cleaned.trim_end())
434 }
435}
436
437fn body_with_closes_note(body: &str, note: &str) -> String {
440 let section = format!(
441 "{}\n{note}\n{}",
442 marker_start(CLOSES_SECTION),
443 marker_end(CLOSES_SECTION)
444 );
445 let cleaned = strip_sections(body, CLOSES_SECTION);
446
447 if let Some(position) = cleaned.find(&marker_start(STACK_SECTION)) {
448 let head = cleaned[..position].trim_end();
449 let tail = &cleaned[position..];
450 if head.is_empty() {
451 format!("{section}\n\n{tail}")
452 } else {
453 format!("{head}\n\n{section}\n\n{tail}")
454 }
455 } else if cleaned.trim().is_empty() {
456 section
457 } else {
458 format!("{}\n\n{section}", cleaned.trim_end())
459 }
460}
461
462fn strip_sections(body: &str, name: &str) -> String {
464 let start_marker = marker_start(name);
465 let end_marker = marker_end(name);
466 let mut result = body.to_owned();
467
468 while let Some(start) = result.find(&start_marker) {
469 match result[start..].find(&end_marker) {
470 Some(end_offset) => {
471 let end = start + end_offset + end_marker.len();
472 result.replace_range(start..end, "");
473 }
474 None => result.replace_range(start..start + start_marker.len(), ""),
475 }
476 }
477 while let Some(start) = result.find(&end_marker) {
478 result.replace_range(start..start + end_marker.len(), "");
479 }
480
481 while result.contains("\n\n\n") {
483 result = result.replace("\n\n\n", "\n\n");
484 }
485 result
486}
487
488#[cfg(test)]
489mod tests {
490 use super::*;
491 use crate::providers::ReviewState;
492
493 fn entry(id: &str, title: &str, url: &str, state: &str) -> NoteEntry {
494 NoteEntry {
495 id: id.to_owned(),
496 url: url.to_owned(),
497 title: title.to_owned(),
498 state: state.to_owned(),
499 }
500 }
501
502 #[test]
503 fn build_stack_note_lists_ledger_leaf_first_with_pointer_and_trunk() {
504 let entries = vec![
505 entry("#12", "Bottom change", "https://example.com/12", "open"),
506 entry("#13", "Top change", "https://example.com/13", "open"),
507 ];
508
509 let note = build_stack_note(&entries, 0, "main");
510 let lines: Vec<&str> = note.lines().collect();
511 assert!(
512 lines[0].starts_with(DATA_PREFIX),
513 "missing data line: {note}"
514 );
515 assert_eq!(
516 lines[1],
517 "- \u{1F7E2} [Top change (#13)](https://example.com/13)"
518 );
519 assert_eq!(
520 lines[2],
521 "- \u{1F7E2} [Bottom change (#12)](https://example.com/12) \u{1F448}"
522 );
523 assert_eq!(lines[3], "- `main`");
524 assert!(note.ends_with(
525 "Stack managed by \
526 <img src=\"https://raw.githubusercontent.com/lararosekelley/git-stk/main/assets/logo.svg\" \
527 width=\"12\" height=\"12\" alt=\"\" /> \
528 [git-stk](https://github.com/lararosekelley/git-stk)"
529 ));
530 }
531
532 #[test]
533 fn build_stack_note_styles_merged_and_closed_entries() {
534 let entries = vec![
535 entry("#11", "Landed", "https://example.com/11", "merged"),
536 entry("#12", "Abandoned", "https://example.com/12", "closed"),
537 entry("#13", "Live", "https://example.com/13", "open"),
538 ];
539
540 let note = build_stack_note(&entries, 2, "main");
541 assert!(note.contains("- \u{1F7E2} [Live (#13)](https://example.com/13) \u{1F448}"));
542 assert!(
543 note.contains("- \u{1F534} ~~[Abandoned (#12)](https://example.com/12)~~ (closed)")
544 );
545 assert!(note.contains("- \u{1F7E3} ~~[Landed (#11)](https://example.com/11)~~ (merged)"));
546 }
547
548 #[test]
549 fn build_stack_note_falls_back_to_id_without_title() {
550 let entries = vec![entry("#12", "", "https://example.com/12", "open")];
551 let note = build_stack_note(&entries, 0, "main");
552 assert!(note.contains("- \u{1F7E2} [#12](https://example.com/12) \u{1F448}"));
553 }
554
555 #[test]
556 fn parse_ledger_round_trips_the_data_line() {
557 let entries = vec![
558 entry("#11", "Landed", "https://example.com/11", "merged"),
559 entry("#13", "Top -> change", "https://example.com/13", "open"),
560 ];
561
562 let note = build_stack_note(&entries, 1, "main");
563 assert_eq!(parse_ledger(¬e), entries);
564 }
565
566 #[test]
567 fn data_line_survives_a_title_containing_a_comment_terminator() {
568 let entries = vec![entry(
569 "#12",
570 "weird --> title",
571 "https://example.com/12",
572 "open",
573 )];
574 let line = data_line(&entries);
575 assert!(!line[DATA_PREFIX.len()..line.len() - COMMENT_END.len()].contains("-->"));
576 assert_eq!(parse_ledger(&line), entries);
577 }
578
579 #[test]
580 fn parse_ledger_recovers_entries_from_bullets_when_data_line_is_gone() {
581 let entries = vec![
582 entry("#11", "Landed", "https://example.com/11", "merged"),
583 entry("#12", "", "https://example.com/12", "closed"),
584 entry("#13", "Live", "https://example.com/13", "open"),
585 ];
586
587 let note = build_stack_note(&entries, 2, "main");
588 let without_data: String = note
589 .lines()
590 .filter(|line| !line.trim().starts_with(DATA_PREFIX))
591 .collect::<Vec<_>>()
592 .join("\n");
593
594 let mut recovered = parse_ledger(&without_data);
597 recovered.reverse();
598 assert_eq!(recovered, entries);
599 }
600
601 #[test]
602 fn parse_ledger_falls_back_to_bullets_when_data_line_is_corrupt() {
603 let section = "<!-- git-stk:data [{\"id\": -->\n\
604 - \u{1F7E3} ~~[Landed (#11)](https://example.com/11)~~ (merged)\n\
605 - `main`";
606 assert_eq!(
607 parse_ledger(section),
608 vec![entry("#11", "Landed", "https://example.com/11", "merged")]
609 );
610 }
611
612 #[test]
613 fn parse_ledger_reads_the_legacy_unstyled_format() {
614 let section = "- [Top change (#13)](https://example.com/13)\n\
615 - [Bottom change (#12)](https://example.com/12) \u{1F448}\n\
616 - `main`\n\n---\n\nfooter";
617 assert_eq!(
618 parse_ledger(section),
619 vec![
620 entry("#13", "Top change", "https://example.com/13", "open"),
621 entry("#12", "Bottom change", "https://example.com/12", "open"),
622 ]
623 );
624 }
625
626 #[test]
627 fn issue_number_from_branch_reads_supported_shapes() {
628 assert_eq!(issue_number_from_branch("123-fix-thing"), Some(123));
629 assert_eq!(issue_number_from_branch("fix/123-thing"), Some(123));
630 assert_eq!(issue_number_from_branch("fix/issue-123"), Some(123));
631 assert_eq!(issue_number_from_branch("feat/issues-9-cleanup"), Some(9));
632 assert_eq!(issue_number_from_branch("42"), Some(42));
633 }
634
635 #[test]
636 fn issue_number_from_branch_rejects_lookalikes() {
637 assert_eq!(issue_number_from_branch("feature/b"), None);
638 assert_eq!(issue_number_from_branch("fix-thing-123"), None);
639 assert_eq!(issue_number_from_branch("v2-migration"), None);
640 assert_eq!(issue_number_from_branch("2024q1-cleanup"), None);
641 assert_eq!(issue_number_from_branch("0-zero"), None);
642 assert_eq!(issue_number_from_branch("upgrade-issue"), None);
643 }
644
645 #[test]
646 fn body_with_section_appends_to_existing_body() {
647 let updated = body_with_section("Some PR description.\n", STACK_SECTION, "stack list");
648 assert_eq!(
649 updated,
650 "Some PR description.\n\n<!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->"
651 );
652 }
653
654 #[test]
655 fn body_with_section_fills_empty_body() {
656 let updated = body_with_section("", STACK_SECTION, "stack list");
657 assert_eq!(
658 updated,
659 "<!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->"
660 );
661 }
662
663 #[test]
664 fn body_with_section_replaces_existing_section() {
665 let body = "Intro.\n\n<!-- git-stk:stack -->\nold list\n<!-- /git-stk:stack -->\n\nOutro.";
666 let updated = body_with_section(body, STACK_SECTION, "new list");
667 assert_eq!(
668 updated,
669 "Intro.\n\nOutro.\n\n<!-- git-stk:stack -->\nnew list\n<!-- /git-stk:stack -->"
670 );
671 }
672
673 #[test]
674 fn body_with_section_is_idempotent() {
675 let body = body_with_section("Description.", STACK_SECTION, "stack list");
676 assert_eq!(body_with_section(&body, STACK_SECTION, "stack list"), body);
677 }
678
679 #[test]
680 fn body_with_section_keeps_other_sections_intact() {
681 let body = "Intro.\n\n<!-- git-stk:closes -->\nCloses #5\n<!-- /git-stk:closes -->";
682 let updated = body_with_section(body, STACK_SECTION, "stack list");
683 assert!(updated.contains("<!-- git-stk:closes -->\nCloses #5\n<!-- /git-stk:closes -->"));
684 assert!(updated.ends_with("<!-- /git-stk:stack -->"));
685 }
686
687 #[test]
688 fn body_with_section_repairs_orphaned_start_marker() {
689 let body = "Intro.\n\n<!-- git-stk:stack -->\nleftover text";
690 let updated = body_with_section(body, STACK_SECTION, "fresh list");
691 assert_eq!(
692 updated,
693 "Intro.\n\nleftover text\n\n<!-- git-stk:stack -->\nfresh list\n<!-- /git-stk:stack -->"
694 );
695 }
696
697 #[test]
698 fn body_with_section_repairs_orphaned_end_marker() {
699 let body = "Intro.\nstray\n<!-- /git-stk:stack -->\nOutro.";
700 let updated = body_with_section(body, STACK_SECTION, "fresh list");
701 assert!(updated.matches("<!-- git-stk:stack -->").count() == 1);
702 assert!(updated.matches("<!-- /git-stk:stack -->").count() == 1);
703 assert!(updated.contains("Intro.\nstray"));
704 assert!(updated.ends_with("<!-- /git-stk:stack -->"));
705 }
706
707 #[test]
708 fn body_with_section_repairs_reversed_and_duplicate_markers() {
709 let body = "<!-- /git-stk:stack -->\nA\n<!-- git-stk:stack -->\nB\n\
710 <!-- git-stk:stack -->\nC\n<!-- /git-stk:stack -->\nD";
711 let updated = body_with_section(body, STACK_SECTION, "fresh list");
712 assert_eq!(updated.matches("<!-- git-stk:stack -->").count(), 1);
713 assert_eq!(updated.matches("<!-- /git-stk:stack -->").count(), 1);
714 assert!(updated.contains("fresh list"));
715 assert!(updated.ends_with("<!-- /git-stk:stack -->"));
716 }
717
718 #[test]
719 fn body_with_closes_note_appends_without_a_stack_section() {
720 let updated = body_with_closes_note("Description.", "Closes #5");
721 assert_eq!(
722 updated,
723 "Description.\n\n<!-- git-stk:closes -->\nCloses #5\n<!-- /git-stk:closes -->"
724 );
725 }
726
727 #[test]
728 fn body_with_closes_note_lands_above_the_stack_section() {
729 let body = "Description.\n\n<!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->";
730 let updated = body_with_closes_note(body, "Closes #5");
731 assert_eq!(
732 updated,
733 "Description.\n\n\
734 <!-- git-stk:closes -->\nCloses #5\n<!-- /git-stk:closes -->\n\n\
735 <!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->"
736 );
737 }
738
739 #[test]
740 fn body_with_closes_note_replaces_a_stale_note_in_place() {
741 let body = "Intro.\n\n<!-- git-stk:closes -->\nCloses #4\n<!-- /git-stk:closes -->\n\n\
742 <!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->";
743 let updated = body_with_closes_note(body, "Closes #5");
744 assert_eq!(updated.matches("<!-- git-stk:closes -->").count(), 1);
745 assert!(updated.contains("Closes #5"));
746 assert!(!updated.contains("Closes #4"));
747 let closes = updated.find("Closes #5").expect("closes note");
748 let stack = updated.find("stack list").expect("stack note");
749 assert!(
750 closes < stack,
751 "closes note should sit above the stack note"
752 );
753 }
754
755 #[test]
756 fn note_entry_round_trips_through_review() {
757 let landed = entry("#11", "Landed", "https://example.com/11", "merged");
758 let review = landed.to_review();
759 assert_eq!(review.state, ReviewState::Merged);
760 assert_eq!(NoteEntry::from_review(&review), landed);
761 }
762
763 #[test]
764 fn note_entry_matches_by_id_or_url() {
765 let by_id = entry("#11", "", "", "open");
766 let by_url = entry("", "", "https://example.com/11", "open");
767 assert!(by_id.matches(&entry("#11", "x", "y", "merged")));
768 assert!(by_url.matches(&entry("#12", "", "https://example.com/11", "open")));
769 assert!(!by_url.matches(&by_id));
770 }
771}