Skip to content

Commit

Mirrors in work, Actions, deployments and the inbox

A standby mirror takes no issues, pull requests or agents (its settings, rules and commit checks stay open), runs no workflows unless CI is kept warm, and deploys nothing. In CI failover and during a takeover Actions reads .github/workflows too, g1t's own winning a name; a workflow that deploys waits for approval, and a push copied in that changes workflows waits before it may use secrets. mirror.* events reach webhooks and, when a link asks for it, the owners' inbox.

syntaqxcommitted Parent68158abBrowse files
9 files+357−450/9 viewed
+3−1
1010
1111 /// Where workflows live: GitHub's `.github/workflows`, under g1t's own
1212 /// folder, so moving a repository to g1t is renaming `.github` to `.g1t`.
13−/// g1t never reads `.github`, which stays GitHub's.
13+/// g1t reads `.github` only for a mirror of a GitHub repository in CI
14+/// failover or taken over (services/actions/src/mirrored.rs); otherwise it
15+/// stays GitHub's.
1416 pub const FOLDER: &str = ".g1t/workflows";
1517
1618 /// The events a workflow can name that g1t starts runs for.
+4−0
117117 "vulnerability_alert.fixed",
118118 "vulnerability_alert.dismissed",
119119 "vulnerability_alert.reopened",
120+ "mirror.unreachable",
121+ "mirror.reachable",
122+ "mirror.state_changed",
123+ "mirror.moved_in",
120124 ];
121125
122126 /// What a webhook belongs to.
+4−0
110110 "vulnerability_alert.fixed",
111111 "vulnerability_alert.dismissed",
112112 "vulnerability_alert.reopened",
113+ "mirror.unreachable",
114+ "mirror.reachable",
115+ "mirror.state_changed",
116+ "mirror.moved_in",
113117 ] as const;
114118
115119 export type Hook = {
+1−0
2424
2525 mod artifacts;
2626 mod cache;
27+mod mirrored;
2728 mod payload;
2829 mod plan;
2930 mod protection;
+171−0
1+//! What runs on a mirror (see `g1t_contracts::mirrors`).
2+//!
3+//! A mirror standing by runs nothing: its workflows run where it is
4+//! mirrored from. Its owners can keep CI warm, which runs `.g1t/workflows`
5+//! on the pushes copied in. In CI failover the remote keeps the code and
6+//! g1t runs its workflows, GitHub's `.github/workflows` as well as g1t's
7+//! own; during a takeover it does too, unless the link says not to. A
8+//! workflow that deploys waits for approval in both, unless the link says
9+//! otherwise, so nothing deploys from two places. While a takeover is
10+//! handed back, nothing starts.
11+
12+use g1t_actions::workflow::Workflow;
13+use g1t_contracts::mirrors::{MirrorState, RepoMirror};
14+
15+/// GitHub's own folder, read on a mirror in CI failover or taken over.
16+pub const GITHUB_FOLDER: &str = ".github/workflows";
17+
18+#[derive(Clone, Debug, Default, PartialEq, Eq)]
19+pub struct Policy {
20+ /// Whether workflows start at all.
21+ pub runs: bool,
22+ /// Whether `.github/workflows` is read as well as `.g1t/workflows`.
23+ pub github: bool,
24+ /// Set when a workflow that deploys waits for approval: the remote, as
25+ /// people name it.
26+ pub hold: Option<String>,
27+}
28+
29+/// What runs on a repository with `mirror`, for an event that was a push
30+/// copied in from the remote (`copied_in`) or anything else.
31+pub fn policy(mirror: Option<&RepoMirror>, copied_in: bool) -> Policy {
32+ let Some(mirror) = mirror else {
33+ return Policy { runs: true, github: false, hold: None };
34+ };
35+ let hold = mirror.hold_deploys.then(|| mirror.remote.clone());
36+ match mirror.state {
37+ MirrorState::Standby => Policy { runs: copied_in && mirror.warm, github: false, hold: None },
38+ MirrorState::Ci => Policy { runs: true, github: true, hold },
39+ MirrorState::Takeover => Policy { runs: true, github: mirror.github_workflows, hold },
40+ MirrorState::HandingBack => Policy::default(),
41+ }
42+}
43+
44+/// Why nothing runs, for someone who asked for a run.
45+pub fn refused(namespace: &str, name: &str, mirror: &RepoMirror) -> String {
46+ match mirror.state {
47+ MirrorState::HandingBack => format!("{namespace}/{name} is handing back to {}. Workflows start again once that is done.", mirror.remote),
48+ _ => format!(
49+ "{namespace}/{name} is a standby mirror of {remote}: its workflows run there. Start CI failover or take over in Settings → Mirroring to run them on g1t.",
50+ remote = mirror.remote
51+ ),
52+ }
53+}
54+
55+/// The environment a workflow deploys to, if any job names one.
56+pub fn deploys_to(workflow: &Workflow) -> Option<String> {
57+ workflow.jobs.iter().find_map(|job| match &job.raw["environment"] {
58+ serde_json::Value::String(name) => Some(name.clone()),
59+ serde_json::Value::Object(environment) => {
60+ Some(environment.get("name").and_then(|name| name.as_str()).unwrap_or("an environment").to_owned())
61+ }
62+ _ => None,
63+ })
64+}
65+
66+/// Why a deploying workflow waits.
67+pub fn held(remote: &str, environment: &str) -> String {
68+ format!(
69+ "This workflow deploys to {environment}. While {remote} may still deploy too, runs that deploy wait for approval so nothing deploys twice."
70+ )
71+}
72+
73+/// Whether a change touches what runs: workflows or local actions.
74+pub fn touches_workflows(paths: &[String]) -> bool {
75+ paths.iter().any(|path| path.starts_with(".g1t/") || path.starts_with(".github/workflows/") || path.starts_with(".github/actions/"))
76+}
77+
78+/// Why a run from a push copied in waits.
79+pub fn copied_workflows(remote: &str) -> String {
80+ format!(
81+ "This push came from {remote} and changes workflows. Approve the run to let it use this repository's secrets and variables."
82+ )
83+}
84+
85+/// Drops `.github` workflows that a `.g1t` one of the same name stands in
86+/// for: g1t's own wins.
87+pub fn prefer_g1t(files: Vec<crate::sync::WorkflowFile>) -> Vec<crate::sync::WorkflowFile> {
88+ let name = |file: &crate::sync::WorkflowFile| {
89+ g1t_actions::workflow::parse(&file.source)
90+ .map(|workflow| workflow.display_name(&file.path))
91+ .unwrap_or_else(|_| file.path.clone())
92+ };
93+ let ours: Vec<String> = files.iter().filter(|file| !file.path.starts_with(GITHUB_FOLDER)).map(name).collect();
94+ files
95+ .into_iter()
96+ .filter(|file| !file.path.starts_with(GITHUB_FOLDER) || !ours.contains(&name(file)))
97+ .collect()
98+}
99+
100+#[cfg(test)]
101+mod tests {
102+ use super::*;
103+
104+ fn mirror(state: MirrorState) -> RepoMirror {
105+ RepoMirror {
106+ state,
107+ remote: "github.com/acme/web".into(),
108+ warm: false,
109+ github_workflows: true,
110+ hold_deploys: true,
111+ ..RepoMirror::default()
112+ }
113+ }
114+
115+ #[test]
116+ fn a_standby_mirror_runs_nothing_unless_kept_warm() {
117+ assert_eq!(policy(None, false), Policy { runs: true, github: false, hold: None });
118+ assert!(!policy(Some(&mirror(MirrorState::Standby)), true).runs);
119+ let warm = RepoMirror { warm: true, ..mirror(MirrorState::Standby) };
120+ assert_eq!(policy(Some(&warm), true), Policy { runs: true, github: false, hold: None });
121+ assert!(!policy(Some(&warm), false).runs, "only the pushes copied in");
122+ assert!(!policy(Some(&mirror(MirrorState::HandingBack)), true).runs);
123+ }
124+
125+ #[test]
126+ fn ci_failover_and_takeover_run_githubs_workflows_and_hold_deploys() {
127+ let ci = policy(Some(&mirror(MirrorState::Ci)), true);
128+ assert!(ci.runs && ci.github);
129+ assert_eq!(ci.hold.as_deref(), Some("github.com/acme/web"));
130+ let quiet = RepoMirror { github_workflows: false, hold_deploys: false, ..mirror(MirrorState::Takeover) };
131+ assert_eq!(policy(Some(&quiet), false), Policy { runs: true, github: false, hold: None });
132+ assert!(refused("acme", "web", &mirror(MirrorState::Standby)).contains("standby mirror of github.com/acme/web"));
133+ }
134+
135+ #[test]
136+ fn a_workflow_deploys_when_a_job_names_an_environment() {
137+ let parse = |text: &str| g1t_actions::workflow::parse(text).unwrap();
138+ let build = parse("on: push\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: echo\n");
139+ assert_eq!(deploys_to(&build), None);
140+ let deploy = parse("on: push\njobs:\n ship:\n runs-on: ubuntu-latest\n environment: production\n steps:\n - run: echo\n");
141+ assert_eq!(deploys_to(&deploy).as_deref(), Some("production"));
142+ let named = parse(
143+ "on: push\njobs:\n ship:\n runs-on: ubuntu-latest\n environment:\n name: staging\n url: https://x\n steps:\n - run: echo\n",
144+ );
145+ assert_eq!(deploys_to(&named).as_deref(), Some("staging"));
146+ }
147+
148+ #[test]
149+ fn a_copied_push_that_changes_workflows_waits() {
150+ let paths = |list: &[&str]| list.iter().map(|path| path.to_string()).collect::<Vec<_>>();
151+ assert!(touches_workflows(&paths(&["src/a.rs", ".g1t/workflows/ci.yml"])));
152+ assert!(touches_workflows(&paths(&[".github/workflows/deploy.yml"])));
153+ assert!(touches_workflows(&paths(&[".github/actions/setup/action.yml"])));
154+ assert!(!touches_workflows(&paths(&[".github/CODEOWNERS", "README.md"])));
155+ }
156+
157+ #[test]
158+ fn g1ts_own_workflow_stands_in_for_githubs_of_the_same_name() {
159+ let file = |path: &str, name: &str| crate::sync::WorkflowFile {
160+ path: path.into(),
161+ source: format!("name: {name}\non: push\njobs:\n a:\n runs-on: x\n steps:\n - run: echo\n"),
162+ };
163+ let kept = prefer_g1t(vec![
164+ file(".g1t/workflows/ci.yml", "CI"),
165+ file(".github/workflows/ci.yml", "CI"),
166+ file(".github/workflows/lint.yml", "Lint"),
167+ ]);
168+ let paths: Vec<&str> = kept.iter().map(|file| file.path.as_str()).collect();
169+ assert_eq!(paths, [".g1t/workflows/ci.yml", ".github/workflows/lint.yml"]);
170+ }
171+}
+60−37
5757 /// The workflow files of `path` as of `git_ref` (the default branch
5858 /// when absent).
5959 pub async fn read_workflows(&self, path: &RepoPath, actor: &User, git_ref: Option<&str>) -> Result<Read> {
60− let viewer = Some(actor.clone());
61− let tree: Outcome<TreeView> = g1t_kit::call(
62− &self.repos,
63− "tree",
64− &TreeArgs {
65− path: path.clone(),
66− viewer: viewer.clone(),
67− git_ref: git_ref.map(str::to_owned),
68− tree_path: FOLDER.to_owned(),
69− },
70− )
71− .await?;
72− let (entries, head, resolved) = match tree {
73− Outcome::Ok(tree) => (tree.entries, tree.head.map(|commit| commit.hash), tree.git_ref),
74− // No folder: no workflows.
75− Outcome::Fail(_) => return Ok(Read { files: Vec::new(), head: None }),
60+ self.read_folders(path, actor, git_ref, &[FOLDER]).await
61+ }
62+
63+ /// The workflow files a repository runs: `.g1t/workflows`, and on a
64+ /// mirror in CI failover or taken over, `.github/workflows` too (see
65+ /// mirrored.rs), where g1t's own stands in for one of the same name.
66+ pub async fn read_for(&self, repo: &Repo, actor: &User, git_ref: Option<&str>, github: bool) -> Result<Read> {
67+ let path = RepoPath {
68+ namespace: repo.namespace.clone(),
69+ name: repo.name.clone(),
7670 };
77− let at = head.clone().unwrap_or(resolved);
71+ if !github {
72+ return self.read_workflows(&path, actor, git_ref).await;
73+ }
74+ let read = self.read_folders(&path, actor, git_ref, &[FOLDER, crate::mirrored::GITHUB_FOLDER]).await?;
75+ Ok(Read {
76+ files: crate::mirrored::prefer_g1t(read.files),
77+ head: read.head,
78+ })
79+ }
80+
81+ async fn read_folders(&self, path: &RepoPath, actor: &User, git_ref: Option<&str>, folders: &[&str]) -> Result<Read> {
82+ let viewer = Some(actor.clone());
7883 let mut files = Vec::new();
79− for entry in entries
80− .into_iter()
81− .filter(|entry| matches!(entry.kind, EntryKind::Blob | EntryKind::Exec))
82− .filter(|entry| entry.name.ends_with(".yml") || entry.name.ends_with(".yaml"))
83− .take(MAX_WORKFLOWS)
84− {
85− let file_path = format!("{FOLDER}/{}", entry.name);
86− let blob: Outcome<BlobView> = g1t_kit::call(
84+ let mut found_head = None;
85+ for folder in folders {
86+ let tree: Outcome<TreeView> = g1t_kit::call(
8787 &self.repos,
88− "blob",
89− &BlobArgs {
88+ "tree",
89+ &TreeArgs {
9090 path: path.clone(),
9191 viewer: viewer.clone(),
92− git_ref: at.clone(),
93− file_path: file_path.clone(),
92+ git_ref: git_ref.map(str::to_owned),
93+ tree_path: (*folder).to_owned(),
9494 },
9595 )
9696 .await?;
97− if let Outcome::Ok(BlobView { text: Some(source), .. }) = blob {
98− files.push(WorkflowFile { path: file_path, source });
97+ let (entries, head, resolved) = match tree {
98+ Outcome::Ok(tree) => (tree.entries, tree.head.map(|commit| commit.hash), tree.git_ref),
99+ // No folder: no workflows from it.
100+ Outcome::Fail(_) => continue,
101+ };
102+ let at = head.clone().unwrap_or(resolved);
103+ found_head = found_head.or(head);
104+ for entry in entries
105+ .into_iter()
106+ .filter(|entry| matches!(entry.kind, EntryKind::Blob | EntryKind::Exec))
107+ .filter(|entry| entry.name.ends_with(".yml") || entry.name.ends_with(".yaml"))
108+ .take(MAX_WORKFLOWS.saturating_sub(files.len()))
109+ {
110+ let file_path = format!("{folder}/{}", entry.name);
111+ let blob: Outcome<BlobView> = g1t_kit::call(
112+ &self.repos,
113+ "blob",
114+ &BlobArgs {
115+ path: path.clone(),
116+ viewer: viewer.clone(),
117+ git_ref: at.clone(),
118+ file_path: file_path.clone(),
119+ },
120+ )
121+ .await?;
122+ if let Outcome::Ok(BlobView { text: Some(source), .. }) = blob {
123+ files.push(WorkflowFile { path: file_path, source });
124+ }
99125 }
100126 }
101− Ok(Read { files, head })
127+ Ok(Read { files, head: found_head })
102128 }
103129
104130 /// Keeps the `workflows` table in step with the default branch.
105131 pub async fn sync(&self, repo: &Repo, actor: &User) -> Result<()> {
106− let path = RepoPath {
107− namespace: repo.namespace.clone(),
108− name: repo.name.clone(),
109− };
110− let read = self.read_workflows(&path, actor, None).await?;
132+ let github = crate::mirrored::policy(repo.mirror.as_ref(), false).github;
133+ let read = self.read_for(repo, actor, None, github).await?;
111134 let full_name = format!("{}/{}", repo.namespace, repo.name);
112135 let now = rfc3339(now_ms());
113136 let mut statements = Vec::new();
+52−6
526526 if pushed_default {
527527 self.sync(&repo, &ws).await?;
528528 }
529+ // A mirror runs only what its state says (mirrored.rs).
530+ let copied_in = event.kind == "git.push" && event.data["mirrored"].as_bool() == Some(true);
531+ let policy = crate::mirrored::policy(repo.mirror.as_ref(), copied_in);
532+ if !policy.runs {
533+ return Ok(());
534+ }
529535 // What a workflow job's own token did starts no workflows, as on
530536 // GitHub, so a workflow cannot set itself off; only
531537 // `workflow_dispatch` and `repository_dispatch` do.
548554 let Some(mut subject) = self.subject(event, event_name, action, &repo, &ws, &sender).await? else {
549555 continue;
550556 };
551− let read = self.read_workflows(&subject.source, &ws, subject.source_ref.as_deref()).await?;
557+ // A change to workflows made on the remote, by someone g1t
558+ // knows nothing of, waits before it may use this repository's
559+ // secrets.
560+ if copied_in && matches!(event_name, "push" | "create") {
561+ if subject.paths.is_none() {
562+ let (base, head) = subject.compare.clone().unwrap_or((None, subject.sha.clone()));
563+ subject.paths = Some(self.changed_paths(&repo, &ws, base, head).await?);
564+ }
565+ if crate::mirrored::touches_workflows(subject.paths.as_deref().unwrap_or_default())
566+ && let Some(mirror) = &repo.mirror
567+ {
568+ subject.approval = subject.approval.take().or_else(|| Some(crate::mirrored::copied_workflows(&mirror.remote)));
569+ }
570+ }
571+ // A pull request from a fork reads the fork's files.
572+ let read = if subject.source == Self::repo_path(&repo) {
573+ self.read_for(&repo, &ws, subject.source_ref.as_deref(), policy.github).await?
574+ } else {
575+ self.read_workflows(&subject.source, &ws, subject.source_ref.as_deref()).await?
576+ };
552577 // A pull request's head runs each workflow once, however many
553578 // events say it is there (marked ready, and pushed).
554579 let key = match subject.pull {
611636 if self.disabled(&repo.id, &file.path).await? {
612637 continue;
613638 }
639+ // On a mirror the remote may deploy too: a workflow that deploys
640+ // waits for approval (mirrored.rs).
641+ let held = crate::mirrored::policy(repo.mirror.as_ref(), false)
642+ .hold
643+ .zip(crate::mirrored::deploys_to(&workflow))
644+ .map(|(remote, environment)| crate::mirrored::held(&remote, &environment));
614645 self.create_run(NewRun {
615646 repo: repo.clone(),
616647 path: file.path,
625656 actor_id: actor_id.map(str::to_owned),
626657 actor: Some(sender.to_owned()),
627658 trusted: subject.trusted,
628− approval: subject.approval.clone(),
659+ approval: subject.approval.clone().or(held),
629660 })
630661 .await?;
631662 }
731762 continue;
732763 };
733764 let Ok(workflow) = workflow::parse(&row.source) else { continue };
734− // Schedules wait while a repository is archived; a deleted one is not found.
735− let Some((repo, _ws)) = self.repo_by_id(&row.repo_id).await?.filter(|(repo, _)| !repo.archived()) else { continue };
765+ // Schedules wait while a repository is archived, or a mirror runs
766+ // nothing; a deleted one is not found.
767+ let Some((repo, _ws)) = self
768+ .repo_by_id(&row.repo_id)
769+ .await?
770+ .filter(|(repo, _)| !repo.archived() && crate::mirrored::policy(repo.mirror.as_ref(), false).runs)
771+ else {
772+ continue;
773+ };
736774 let Some(sha) = self.default_head(&repo).await? else { continue };
737775 let payload = json!({ "schedule": cron, "repository": payload::repository(&repo), "workflow": row.path });
738776 let mut subject = Subject {
802840 format!("refs/{}/{git_ref}", if is_branch { "heads" } else { "tags" })
803841 };
804842 let short = full_ref.trim_start_matches("refs/heads/").trim_start_matches("refs/tags/").to_owned();
805− let read = self.read_workflows(&Self::repo_path(&repo), &ws, Some(&short)).await?;
843+ let policy = crate::mirrored::policy(repo.mirror.as_ref(), false);
844+ if let Some(mirror) = repo.mirror.as_ref().filter(|_| !policy.runs) {
845+ return Ok(fail(FailureCode::Forbidden, crate::mirrored::refused(&repo.namespace, &repo.name, mirror)));
846+ }
847+ let read = self.read_for(&repo, &ws, Some(&short), policy.github).await?;
806848 let Some(sha) = read.head.clone() else {
807849 return Ok(fail(FailureCode::NotFound, format!("There is no branch or tag called {short}.")));
808850 };
906948 let Some(ws) = self.workspace_actor(&repo.namespace).await? else {
907949 return Ok(fail(FailureCode::NotFound, "There is no such workspace."));
908950 };
909− let read = self.read_workflows(&Self::repo_path(&repo), &ws, Some(&repo.default_branch)).await?;
951+ let policy = crate::mirrored::policy(repo.mirror.as_ref(), false);
952+ if let Some(mirror) = repo.mirror.as_ref().filter(|_| !policy.runs) {
953+ return Ok(fail(FailureCode::Forbidden, crate::mirrored::refused(&repo.namespace, &repo.name, mirror)));
954+ }
955+ let read = self.read_for(&repo, &ws, Some(&repo.default_branch), policy.github).await?;
910956 let Some(sha) = read.head.clone() else {
911957 return Ok(fail(FailureCode::NotFound, "The repository has no default branch to run on yet."));
912958 };
+4−0
4444 fail,
4545 identityClient,
4646 isProtectedWorkspace,
47+ mirrorWritable,
4748 newId,
4849 ok,
4950 openD1,
14041405 break;
14051406 case "git.push":
14061407 if (!event.data.defaultBranch) break;
1408+ // A mirror deploys only while g1t leads it: standing by, or in CI
1409+ // failover, the remote's own deploys stand (see contracts mirrors).
1410+ if (!mirrorWritable(event.data.mirror)) break;
14071411 for (const project of await this.projects.byRepo(event.data.repoId)) {
14081412 await this.deployProduction(project, event.data.after, event.actor ?? "g1t");
14091413 }
+58−1
148148 "comment.created" => on(Some(number("number")?), Some(text("commentId")?)),
149149 // Security alerts are threads of their own, not of an issue.
150150 kind if SECURITY_EVENTS.contains(&kind) => on(None, None),
151+ // So is a repository's mirror, told to whom its settings name.
152+ kind if MIRROR_EVENTS.contains(&kind) => on(None, None),
151153 _ => None,
152154 }
153155 }
163165 "secret_scanning.bypass_reviewed",
164166 ];
165167
168+/// The integrations service's mirroring events (see
169+/// `g1t_contracts::mirrors::MirrorEvent`). People are told only when the
170+/// link's settings ask for the inbox, and the event names them.
171+pub const MIRROR_EVENTS: [&str; 4] = ["mirror.unreachable", "mirror.reachable", "mirror.state_changed", "mirror.moved_in"];
172+
166173 /// Where an event's items go: which thread, what it is, and where it is.
167174 #[derive(Clone, Debug, PartialEq, Eq)]
168175 pub struct Thread {
221228 link: None,
222229 }
223230 }
231+ // A repository's mirror: one thread, its settings page the link.
232+ kind if MIRROR_EVENTS.contains(&kind) => Thread {
233+ key: format!("{}/mirror", wanted.repo_id),
234+ kind: None,
235+ number: None,
236+ run_id: None,
237+ link: text("link"),
238+ },
224239 // One alert, or one bypass request: its page is the link.
225240 kind if SECURITY_EVENTS.contains(&kind) => {
226241 let which = text("requestId").or_else(|| text("alertId")).unwrap_or_else(|| event.id.clone());
396411 | "deployment.failed"
397412 | "deployment.succeeded"
398413 | "deployment.review_requested"
399− ) || SECURITY_EVENTS.contains(&event.kind.as_str());
414+ ) || SECURITY_EVENTS.contains(&event.kind.as_str())
415+ || MIRROR_EVENTS.contains(&event.kind.as_str());
400416 let mut told = Told {
401417 actor: if outcome { &nobody } else { actor },
402418 audience,
574590 told.tell(&name, Reason::ReviewRequested, Severity::Warning, &title, body, true);
575591 }
576592 }
593+ (kind, None) if MIRROR_EVENTS.contains(&kind) => {
594+ let severity = match kind {
595+ "mirror.unreachable" => Severity::Warning,
596+ "mirror.state_changed" if data["state"].as_str() == Some("takeover") => Severity::Warning,
597+ "mirror.state_changed" if data["state"].as_str() == Some("standby") => Severity::Success,
598+ _ => Severity::Info,
599+ };
600+ let title = data["title"].as_str().unwrap_or("A mirror changed");
601+ let body = data["detail"].as_str().unwrap_or_default();
602+ for name in names(data, "notify") {
603+ told.tell(&name, Reason::StateChange, severity, title, body, true);
604+ }
605+ }
577606 (kind, None) if SECURITY_EVENTS.contains(&kind) => {
578607 let severity = match data["severity"].as_str() {
579608 _ if kind == "secret_scanning.bypass_requested" => Severity::Warning,
20302059 }
20312060
20322061 #[test]
2062+ fn a_mirror_tells_only_the_people_its_settings_name() {
2063+ let down = event(
2064+ "mirror.unreachable",
2065+ None,
2066+ json!({
2067+ "repoId": "rep_1", "repo": "acme/rocket", "remoteId": "rmt_1", "remote": "github.com/acme/rocket",
2068+ "state": "standby", "title": "github.com/acme/rocket is not answering. acme/rocket keeps its copy.",
2069+ "detail": "Take over acme/rocket to keep working on g1t until it is back.",
2070+ "notify": ["ana"], "link": "/acme/rocket/settings/mirroring"
2071+ }),
2072+ );
2073+ assert_eq!(wants(&down), Some(Wanted { repo_id: "rep_1".into(), number: None, comment_id: None }));
2074+ // Watchers of everything hear nothing: only those named.
2075+ let audience = watched(&[("eve", WatchLevel::All, &[])]);
2076+ let notices_ = notices(&down, "acme/rocket", &Actor::default(), None, &audience);
2077+ assert_eq!(told(&notices_), vec![("ana", Reason::StateChange, Severity::Warning)]);
2078+ assert!(notices_[0].body.contains("Take over"));
2079+ let thread = thread_of(&down, &wants(&down).unwrap(), None);
2080+ assert_eq!(thread.key, "rep_1/mirror");
2081+ assert_eq!(url(Some("acme/rocket"), thread.kind, None, None, thread.link.as_deref()), "/acme/rocket/settings/mirroring");
2082+ // The banner-only default names nobody, so nobody is told.
2083+ let quiet = event("mirror.unreachable", None, json!({ "repoId": "rep_1", "title": "x", "link": "/x" }));
2084+ assert!(notices(&quiet, "acme/rocket", &Actor::default(), None, &audience).is_empty());
2085+ let back = event("mirror.state_changed", None, json!({ "repoId": "rep_1", "state": "standby", "title": "handed back", "notify": ["ana"], "link": "/x" }));
2086+ assert_eq!(told(&notices(&back, "acme/rocket", &Actor::default(), None, &audience)), vec![("ana", Reason::StateChange, Severity::Success)]);
2087+ }
2088+
2089+ #[test]
20332090 fn security_alerts_tell_the_pusher_the_owners_and_member_watchers() {
20342091 let blocked = event(
20352092 "secret_scanning_alert.created",