add push
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str::FromStr;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Context as AnyhowContext, Error, anyhow, bail};
|
||||
@@ -96,6 +97,13 @@ pub struct Backend {
|
||||
/// so duplicate requests within [`FETCH_DEDUP_WINDOW`] collapse into
|
||||
/// one. Entries are pruned lazily on the next request.
|
||||
recent_fetches: HashMap<u64, Instant>,
|
||||
/// Repositories a push (mirror or checkout) is currently in flight
|
||||
/// for. Concurrent pushes of the same refs — two panels of the same
|
||||
/// repository, or the banner push racing the header's Republish — make
|
||||
/// the losing push fail server-side with a compare-and-swap rejection
|
||||
/// ("cannot lock ref … is at … but expected …"), so pushes are
|
||||
/// single-flight per repository.
|
||||
pushing_repos: Arc<Mutex<HashSet<RepoAddr>>>,
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
}
|
||||
|
||||
@@ -103,6 +111,22 @@ struct GlobalBackend(Entity<Backend>);
|
||||
|
||||
impl Global for GlobalBackend {}
|
||||
|
||||
/// Removes its repository from the in-flight push set when dropped, so a
|
||||
/// push task that is cancelled (e.g. its panel closed mid-push) can never
|
||||
/// leave the repository locked for the rest of the session.
|
||||
struct PushGuard {
|
||||
repos: Arc<Mutex<HashSet<RepoAddr>>>,
|
||||
addr: RepoAddr,
|
||||
}
|
||||
|
||||
impl Drop for PushGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Ok(mut repos) = self.repos.lock() {
|
||||
repos.remove(&self.addr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<BackendEvent> for Backend {}
|
||||
|
||||
impl Backend {
|
||||
@@ -147,6 +171,7 @@ impl Backend {
|
||||
sync_progress: None,
|
||||
passphrase_required: false,
|
||||
recent_fetches: HashMap::new(),
|
||||
pushing_repos: Arc::new(Mutex::new(HashSet::new())),
|
||||
tasks: vec![pump],
|
||||
};
|
||||
|
||||
@@ -758,9 +783,56 @@ impl Backend {
|
||||
announcement: Announcement,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<(), Error>> {
|
||||
let addr = announcement.addr();
|
||||
let cache = GitStore::global(cx).cache().clone();
|
||||
let path = cache.repo_path(&addr);
|
||||
let path = cache.repo_path(&announcement.addr());
|
||||
self.push_repo_from(announcement, path, None, cx)
|
||||
}
|
||||
|
||||
/// Push the refs of a local checkout (the working copy of the user's
|
||||
/// own repository) to the grasp servers announced in the `relays` tag:
|
||||
/// publishes a fresh state event, then pushes every branch and tag of
|
||||
/// the checkout, like the init flow. `announced_head` keeps the state
|
||||
/// event's `HEAD` on the repository's announced default branch when the
|
||||
/// checkout is on a different branch.
|
||||
pub fn push_checkout(
|
||||
&mut self,
|
||||
announcement: Announcement,
|
||||
checkout: PathBuf,
|
||||
announced_head: Option<String>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<(), Error>> {
|
||||
self.push_repo_from(announcement, checkout, announced_head, cx)
|
||||
}
|
||||
|
||||
/// Shared body of the mirror-based and checkout-based pushes: publish
|
||||
/// the repository state (the push authorization), then push every
|
||||
/// branch and tag of `path` to each announced grasp server. Pushes are
|
||||
/// single-flight per repository: two concurrent pushes of the same refs
|
||||
/// (e.g. two panels of the same repository) make the losing push fail
|
||||
/// server-side with a compare-and-swap rejection.
|
||||
fn push_repo_from(
|
||||
&mut self,
|
||||
announcement: Announcement,
|
||||
path: PathBuf,
|
||||
announced_head: Option<String>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<(), Error>> {
|
||||
let addr = announcement.addr();
|
||||
let guard = {
|
||||
let mut pushing = self
|
||||
.pushing_repos
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
if !pushing.insert(addr.clone()) {
|
||||
return Task::ready(Err(anyhow!(
|
||||
"A push to this repository is already in progress"
|
||||
)));
|
||||
}
|
||||
PushGuard {
|
||||
repos: self.pushing_repos.clone(),
|
||||
addr: addr.clone(),
|
||||
}
|
||||
};
|
||||
let owner = announcement
|
||||
.owner
|
||||
.to_bech32()
|
||||
@@ -769,11 +841,32 @@ impl Backend {
|
||||
let relays = announcement.relays.clone();
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
let work = cx.background_spawn({
|
||||
let path = path.clone();
|
||||
async move { signed_git::worktree_ref_state(&path) }
|
||||
});
|
||||
let state = work.await?;
|
||||
// Held for the whole task; dropped (and the lock released) on
|
||||
// completion, on error and on cancellation alike.
|
||||
let _guard = guard;
|
||||
|
||||
let mut state = {
|
||||
let work = cx.background_spawn({
|
||||
let path = path.clone();
|
||||
async move { signed_git::worktree_ref_state(&path) }
|
||||
});
|
||||
work.await?
|
||||
};
|
||||
|
||||
// The state event announces the pushed refs. When the source is
|
||||
// a checkout on a side branch, keep the repository's announced
|
||||
// default branch (its `HEAD`) when that branch is among the
|
||||
// pushed refs; otherwise the checkout's current branch.
|
||||
let heads: Vec<&str> = state
|
||||
.refs
|
||||
.iter()
|
||||
.filter_map(|(name, _)| name.strip_prefix("refs/heads/"))
|
||||
.collect();
|
||||
if let Some(head) = announced_head
|
||||
&& heads.iter().any(|branch| *branch == head)
|
||||
{
|
||||
state.head = Some(head);
|
||||
}
|
||||
|
||||
// Grasp servers authorize a push by the state they have seen.
|
||||
let refs = state.refs.clone();
|
||||
|
||||
@@ -11,8 +11,15 @@
|
||||
//! URL (scheme-insensitive), or whose root commit equals an announcement
|
||||
//! EUC, is a checkout of that announced repository.
|
||||
//!
|
||||
//! The store also computes per-checkout "ready to contribute" statuses
|
||||
//! (branch, base and commits ahead) for the pull-request list banner.
|
||||
//! The store also computes per-checkout statuses for two surfaces:
|
||||
//!
|
||||
//! - **"Ready to contribute"** (pull-request banner of repositories the
|
||||
//! user does not own): branch, base and commits ahead of the base.
|
||||
//! - **"Ready to push"** (sidebar badge and banner of the user's own
|
||||
//! repositories): the checked-out branch has commits the grasp servers
|
||||
//! do not have yet (counted against the refreshed remote-tracking
|
||||
//! refs), so the user can push their local work from the app.
|
||||
//!
|
||||
//! Everything is resolved on background threads and swapped in as
|
||||
//! [`Arc`]s; the UI never waits for git.
|
||||
|
||||
@@ -28,6 +35,7 @@ use nostr::prelude::*;
|
||||
use settings::{CheckoutRecord, SettingsStore};
|
||||
use signed_core::{Announcement, RepoAddr};
|
||||
|
||||
use crate::backend::{Backend, BackendEvent};
|
||||
use crate::git_store::GitStore;
|
||||
use crate::local_repos::LocalReposStore;
|
||||
use crate::repo_list::RepoListStore;
|
||||
@@ -41,6 +49,11 @@ const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
|
||||
/// without reopening the panel.
|
||||
const STATUS_POLL: Duration = Duration::from_secs(15);
|
||||
|
||||
/// Background poll interval for the "ready to push" badges of the user's
|
||||
/// own repositories when no repository panel is open (each cycle refreshes
|
||||
/// the remote view of the checkouts with a git fetch).
|
||||
const PUSH_POLL: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Maximum checkouts considered per repository when computing statuses.
|
||||
const MAX_STATUS_CHECKOUTS: usize = 8;
|
||||
|
||||
@@ -58,8 +71,12 @@ pub struct CheckoutStatus {
|
||||
pub branch: String,
|
||||
/// Commit the branch points at, for tip-based PR dedupe.
|
||||
pub head: String,
|
||||
/// The branch this checkout is compared against (announced HEAD branch,
|
||||
/// else `main`, else the first local branch).
|
||||
/// What the branch is compared against. For ready-to-contribute
|
||||
/// statuses: the announced HEAD branch (else `main`, else the first
|
||||
/// local branch). For ready-to-push statuses: the remote-tracking ref
|
||||
/// the unpushed commits are counted against
|
||||
/// (`refs/remotes/origin/<branch>`, or `origin/HEAD` for branches the
|
||||
/// remote does not have yet).
|
||||
pub base: String,
|
||||
/// Commits in `base..branch`; always > 0 (even checkouts are dropped).
|
||||
pub ahead: u32,
|
||||
@@ -83,6 +100,12 @@ pub struct CheckoutsStore {
|
||||
/// Repositories whose statuses are recomputed whenever the inputs
|
||||
/// change (the repository detail panels currently open).
|
||||
status_requested: HashSet<RepoAddr>,
|
||||
/// Repositories whose "ready to push" statuses are recomputed on the
|
||||
/// same cycle (the sidebar rows of the user's own repositories, plus
|
||||
/// the detail panels of those repositories).
|
||||
push_requested: HashSet<RepoAddr>,
|
||||
/// Ready-to-push statuses of the requested own repositories.
|
||||
push_statuses: Arc<HashMap<RepoAddr, Vec<CheckoutStatus>>>,
|
||||
/// Announced head branch last provided per requested repository, so a
|
||||
/// recompute defaults the base the same way.
|
||||
requested_head: HashMap<RepoAddr, Option<String>>,
|
||||
@@ -105,7 +128,8 @@ impl CheckoutsStore {
|
||||
}
|
||||
|
||||
/// Create the store: observe the inputs (settings records, the local
|
||||
/// scan, the announcement list) and resolve the associations right away.
|
||||
/// scan, the announcement list, signer changes) and resolve the
|
||||
/// associations right away.
|
||||
pub fn new(cx: &mut Context<Self>) -> Self {
|
||||
let mut subscriptions = Vec::new();
|
||||
|
||||
@@ -113,6 +137,7 @@ impl CheckoutsStore {
|
||||
let settings = SettingsStore::global(cx);
|
||||
let local = LocalReposStore::global(cx);
|
||||
let repos = RepoListStore::global(cx);
|
||||
let backend = Backend::global(cx);
|
||||
|
||||
subscriptions.push(cx.observe(&settings, |this, _settings, cx| {
|
||||
this.refresh(cx);
|
||||
@@ -123,12 +148,26 @@ impl CheckoutsStore {
|
||||
subscriptions.push(cx.observe(&repos, |this, _repos, cx| {
|
||||
this.refresh(cx);
|
||||
}));
|
||||
// Another identity's repositories must not keep the previous
|
||||
// user's statuses (or polls) alive.
|
||||
subscriptions.push(cx.subscribe(&backend, |this, _backend, event, cx| {
|
||||
if matches!(event, BackendEvent::SignerChanged) {
|
||||
this.status_requested.clear();
|
||||
this.push_requested.clear();
|
||||
this.requested_head.clear();
|
||||
this.statuses = Arc::new(HashMap::new());
|
||||
this.push_statuses = Arc::new(HashMap::new());
|
||||
this.refresh(cx);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
let mut store = Self {
|
||||
by_repo: Arc::new(HashMap::new()),
|
||||
statuses: Arc::new(HashMap::new()),
|
||||
status_requested: HashSet::new(),
|
||||
push_requested: HashSet::new(),
|
||||
push_statuses: Arc::new(HashMap::new()),
|
||||
requested_head: HashMap::new(),
|
||||
refreshing: false,
|
||||
refresh_dirty: false,
|
||||
@@ -202,6 +241,24 @@ impl CheckoutsStore {
|
||||
self.statuses.get(addr).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Ask for the "ready to push" statuses of `addr` to be kept current
|
||||
/// (called by the sidebar for the signed-in user's own repositories and
|
||||
/// by the detail panels of those repositories). Recomputed on every
|
||||
/// input change and on a background poll; each cycle refreshes the
|
||||
/// remote view of the checkouts first, so a commit made in external
|
||||
/// git surfaces within one poll interval.
|
||||
pub fn request_push_statuses(&mut self, addr: &RepoAddr, cx: &mut Context<Self>) {
|
||||
self.push_requested.insert(addr.clone());
|
||||
self.refresh(cx);
|
||||
}
|
||||
|
||||
/// The ready-to-push statuses of `addr` (only meaningful for
|
||||
/// repositories announced by the signed-in user); empty while none are
|
||||
/// known or nothing is unpushed.
|
||||
pub fn push_statuses_of(&self, addr: &RepoAddr) -> Vec<CheckoutStatus> {
|
||||
self.push_statuses.get(addr).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Re-resolve associations (and the requested statuses). Debounced:
|
||||
/// bursts of notifications collapse into one pass; requests arriving
|
||||
/// while a pass runs are folded into a follow-up.
|
||||
@@ -260,6 +317,8 @@ impl CheckoutsStore {
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let push_requested: Vec<RepoAddr> = self.push_requested.iter().cloned().collect();
|
||||
let poll = !self.status_requested.is_empty() || !self.push_requested.is_empty();
|
||||
|
||||
let work = cx.background_spawn(async move {
|
||||
// Read the git facts of every scanned repository off the main
|
||||
@@ -301,11 +360,26 @@ impl CheckoutsStore {
|
||||
}
|
||||
}
|
||||
|
||||
Ok::<_, Error>((associations, statuses))
|
||||
let mut push_statuses: HashMap<RepoAddr, Vec<CheckoutStatus>> = HashMap::new();
|
||||
for addr in &push_requested {
|
||||
let Some(paths) = associations.get(addr) else {
|
||||
continue;
|
||||
};
|
||||
let list: Vec<CheckoutStatus> = paths
|
||||
.iter()
|
||||
.take(MAX_STATUS_CHECKOUTS)
|
||||
.filter_map(|path| checkout_push_status(path))
|
||||
.collect();
|
||||
if !list.is_empty() {
|
||||
push_statuses.insert(addr.clone(), list);
|
||||
}
|
||||
}
|
||||
|
||||
Ok::<_, Error>((associations, statuses, push_statuses))
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
let (associations, statuses) = match work.await {
|
||||
let (associations, statuses, push_statuses) = match work.await {
|
||||
Ok(results) => results,
|
||||
Err(_) => {
|
||||
// Git reads are best-effort; keep the last results.
|
||||
@@ -318,6 +392,7 @@ impl CheckoutsStore {
|
||||
let again = this.update(cx, |this, cx| {
|
||||
this.by_repo = Arc::new(associations);
|
||||
this.statuses = Arc::new(statuses);
|
||||
this.push_statuses = Arc::new(push_statuses);
|
||||
cx.notify();
|
||||
|
||||
this.refreshing = false;
|
||||
@@ -333,14 +408,23 @@ impl CheckoutsStore {
|
||||
this.update(cx, |this, cx| this.refresh(cx))?;
|
||||
}
|
||||
|
||||
// While any repository panel is open, keep its statuses
|
||||
// current: local commits, pulls and branch switches happen
|
||||
// outside the app and are not otherwise observable.
|
||||
// While any repository panel is open (or any of the user's own
|
||||
// repositories is watched for the sidebar badge), keep the
|
||||
// statuses current: local commits, pulls and branch switches
|
||||
// happen outside the app and are not otherwise observable.
|
||||
this.update(cx, |this, cx| {
|
||||
if !this.status_requested.is_empty() && !this.debouncing && !this.refreshing {
|
||||
if poll && !this.debouncing && !this.refreshing {
|
||||
this.debouncing = true;
|
||||
// Open panels get the fast cadence; the sidebar badges
|
||||
// alone poll less aggressively (each cycle fetches
|
||||
// every watched checkout's remote).
|
||||
let delay = if this.status_requested.is_empty() {
|
||||
PUSH_POLL
|
||||
} else {
|
||||
STATUS_POLL
|
||||
};
|
||||
let task = cx.spawn(async move |this, cx| {
|
||||
cx.background_executor().timer(STATUS_POLL).await;
|
||||
cx.background_executor().timer(delay).await;
|
||||
this.update(cx, |this, cx| {
|
||||
this.debouncing = false;
|
||||
this.run_refresh(cx);
|
||||
@@ -499,6 +583,57 @@ fn checkout_status(path: &Path, announced_head: Option<&str>) -> Option<Checkout
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the reference `name` (e.g. `refs/remotes/origin/main`) exists
|
||||
/// in the checkout at `path`.
|
||||
fn ref_exists(path: &Path, name: &str) -> bool {
|
||||
let output = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(path)
|
||||
.args(["rev-parse", "--verify", "--quiet", name])
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.output();
|
||||
matches!(output, Ok(output) if output.status.success())
|
||||
}
|
||||
|
||||
/// The "ready to push" status of one checkout of the user's own
|
||||
/// repository: the checked-out branch has commits the grasp servers do not
|
||||
/// have yet. The remote view is refreshed first (best-effort: offline, the
|
||||
/// last known remote state still counts the commits made since). Detached
|
||||
/// checkouts, dirty worktrees and branches with no remote state at all
|
||||
/// (the remote HEAD is unknown) are never suggested; branches the remote
|
||||
/// does not have yet are counted against the remote HEAD.
|
||||
fn checkout_push_status(path: &Path) -> Option<CheckoutStatus> {
|
||||
if worktree_dirty(path) {
|
||||
return None;
|
||||
}
|
||||
let branch = current_branch_of(path)?;
|
||||
let head = signed_git::head_commit_id(path).ok().flatten()?;
|
||||
let origin = signed_git::origin_url(path).ok().flatten()?;
|
||||
|
||||
// Refresh the remote heads so a commit made elsewhere (or pushed from
|
||||
// another machine) does not show as "to push" forever.
|
||||
signed_git::fetch_repo_refs(path, &[origin], "+refs/heads/*:refs/remotes/origin/*").ok();
|
||||
|
||||
let remote = format!("refs/remotes/origin/{branch}");
|
||||
// A branch that has never been fetched/pushed yet is compared against
|
||||
// the remote HEAD (its fork point in practice).
|
||||
let base = if ref_exists(path, &remote) {
|
||||
remote
|
||||
} else if ref_exists(path, "refs/remotes/origin/HEAD") {
|
||||
"refs/remotes/origin/HEAD".to_owned()
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
let ahead = commits_ahead(path, &base, &branch);
|
||||
(ahead > 0).then_some(CheckoutStatus {
|
||||
path: path.to_path_buf(),
|
||||
branch,
|
||||
head,
|
||||
base,
|
||||
ahead,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the pull request `pr` (a kind-1618 root, resolved `open` by the
|
||||
/// caller) already proposes the same change as `checkout`: authored by
|
||||
/// `user`, with a matching `branch-name` tag, or — for renamed branches — a
|
||||
@@ -733,6 +868,85 @@ mod tests {
|
||||
assert_eq!(checkout_status(&path, Some("main")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkout_push_status_counts_unpushed_commits_only() {
|
||||
// The "grasp remote": a plain repository the checkout clones from
|
||||
// (origin URL = local path, so the whole cycle runs offline). Git
|
||||
// refuses pushes to its checked-out branch by default; act like a
|
||||
// grasp server and allow them.
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let remote = dir.path().join("remote");
|
||||
signed_git::init_repository(&remote, "My Repo", "").expect("init");
|
||||
let config = Command::new("git")
|
||||
.args(["config", "receive.denyCurrentBranch", "ignore"])
|
||||
.current_dir(&remote)
|
||||
.status()
|
||||
.expect("git config");
|
||||
assert!(config.success(), "git config failed");
|
||||
|
||||
let checkout = dir.path().join("checkout");
|
||||
let status = Command::new("git")
|
||||
.args([
|
||||
"clone",
|
||||
"-q",
|
||||
remote.to_str().unwrap(),
|
||||
checkout.to_str().unwrap(),
|
||||
])
|
||||
.status()
|
||||
.expect("git clone");
|
||||
assert!(status.success(), "git clone failed");
|
||||
|
||||
let run = |args: &[&str]| {
|
||||
let status = Command::new("git")
|
||||
.current_dir(&checkout)
|
||||
.env("GIT_AUTHOR_NAME", "Test Author")
|
||||
.env("GIT_AUTHOR_EMAIL", "test@example.com")
|
||||
.env("GIT_COMMITTER_NAME", "Test Author")
|
||||
.env("GIT_COMMITTER_EMAIL", "test@example.com")
|
||||
.env("GIT_EDITOR", "true")
|
||||
.args(args)
|
||||
.status()
|
||||
.expect("git");
|
||||
assert!(status.success(), "git {args:?} failed");
|
||||
};
|
||||
|
||||
// A fresh clone has nothing to push.
|
||||
assert_eq!(checkout_push_status(&checkout), None);
|
||||
|
||||
// One local commit: ready to push, counted against the remote.
|
||||
std::fs::write(checkout.join("work.txt"), "x\n").expect("write");
|
||||
run(&["add", "-A"]);
|
||||
run(&["commit", "-m", "local work"]);
|
||||
let status = checkout_push_status(&checkout).expect("status");
|
||||
assert_eq!(status.branch, "main");
|
||||
assert_eq!(status.base, "refs/remotes/origin/main");
|
||||
assert_eq!(status.ahead, 1);
|
||||
assert_eq!(status.head.len(), 40);
|
||||
|
||||
// After the push the same commit is on the remote: idle again.
|
||||
run(&["push", "origin", "main"]);
|
||||
assert_eq!(checkout_push_status(&checkout), None);
|
||||
|
||||
// A commit made by someone else on the remote must not count as
|
||||
// local work (it is behind, not ahead).
|
||||
let remote_run = |args: &[&str]| {
|
||||
let status = Command::new("git")
|
||||
.current_dir(&remote)
|
||||
.env("GIT_AUTHOR_NAME", "Other Author")
|
||||
.env("GIT_AUTHOR_EMAIL", "other@example.com")
|
||||
.env("GIT_COMMITTER_NAME", "Other Author")
|
||||
.env("GIT_COMMITTER_EMAIL", "other@example.com")
|
||||
.args(args)
|
||||
.status()
|
||||
.expect("git");
|
||||
assert!(status.success(), "git {args:?} failed");
|
||||
};
|
||||
std::fs::write(remote.join("other.txt"), "y\n").expect("write");
|
||||
remote_run(&["add", "-A"]);
|
||||
remote_run(&["commit", "-m", "remote work"]);
|
||||
assert_eq!(checkout_push_status(&checkout), None);
|
||||
}
|
||||
|
||||
fn pr_event(author: &str, tags: &[&[&str]]) -> Event {
|
||||
let keys = Keys::new(SecretKey::from_hex(author).expect("secret"));
|
||||
let tags: Vec<Tag> = tags
|
||||
|
||||
Reference in New Issue
Block a user