feat(transport): import name-map plumbing via CLI + ManagementActor (M8 D3)
This commit is contained in:
@@ -222,6 +222,11 @@ public static class BundleCommands
|
||||
timeout: BundleCommandTimeout,
|
||||
onSuccess: jsonOk =>
|
||||
{
|
||||
// Surface the required site/connection mappings so an operator
|
||||
// sees which references need resolving on `bundle import`. The
|
||||
// full PreviewBundleResult JSON (including the new fields) is
|
||||
// still printed verbatim afterwards for tooling/jq.
|
||||
PrintRequiredMappingSummary(jsonOk);
|
||||
Console.WriteLine(jsonOk);
|
||||
return 0;
|
||||
});
|
||||
@@ -229,6 +234,60 @@ public static class BundleCommands
|
||||
return cmd;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the <see cref="PreviewBundleResult"/> JSON envelope and writes a
|
||||
/// human-readable summary of the <c>requiredSiteMappings</c> /
|
||||
/// <c>requiredConnectionMappings</c> arrays to stdout. Best-effort: any parse
|
||||
/// hiccup is swallowed so the raw JSON (printed by the caller) remains the
|
||||
/// authoritative output.
|
||||
/// </summary>
|
||||
/// <param name="jsonOk">The success-body JSON returned by the management endpoint.</param>
|
||||
private static void PrintRequiredMappingSummary(string jsonOk)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(jsonOk);
|
||||
var root = doc.RootElement;
|
||||
var sites = root.TryGetProperty("requiredSiteMappings", out var s)
|
||||
&& s.ValueKind == JsonValueKind.Array ? s : default;
|
||||
var conns = root.TryGetProperty("requiredConnectionMappings", out var c)
|
||||
&& c.ValueKind == JsonValueKind.Array ? c : default;
|
||||
|
||||
var siteCount = sites.ValueKind == JsonValueKind.Array ? sites.GetArrayLength() : 0;
|
||||
var connCount = conns.ValueKind == JsonValueKind.Array ? conns.GetArrayLength() : 0;
|
||||
if (siteCount == 0 && connCount == 0) return;
|
||||
|
||||
Console.WriteLine("Mappings required before import:");
|
||||
foreach (var site in sites.EnumerateArray())
|
||||
{
|
||||
var src = site.TryGetProperty("sourceSiteIdentifier", out var v) ? v.GetString() : null;
|
||||
var name = site.TryGetProperty("sourceSiteName", out var n) ? n.GetString() : null;
|
||||
var auto = site.TryGetProperty("autoMatchTargetIdentifier", out var a)
|
||||
&& a.ValueKind == JsonValueKind.String ? a.GetString() : null;
|
||||
var hint = auto is null
|
||||
? "no auto-match (use --map-site or --create-missing-sites)"
|
||||
: $"auto-matches '{auto}'";
|
||||
Console.WriteLine($" site: {src} ({name}) — {hint}");
|
||||
}
|
||||
foreach (var conn in conns.EnumerateArray())
|
||||
{
|
||||
var src = conn.TryGetProperty("sourceSiteIdentifier", out var v) ? v.GetString() : null;
|
||||
var cname = conn.TryGetProperty("sourceConnectionName", out var n) ? n.GetString() : null;
|
||||
var auto = conn.TryGetProperty("autoMatchTargetName", out var a)
|
||||
&& a.ValueKind == JsonValueKind.String ? a.GetString() : null;
|
||||
var hint = auto is null
|
||||
? "no auto-match (use --map-connection or --create-missing-connections)"
|
||||
: $"auto-matches '{auto}'";
|
||||
Console.WriteLine($" connection: {src}/{cname} — {hint}");
|
||||
}
|
||||
Console.WriteLine();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Best-effort: the raw JSON is still printed by the caller.
|
||||
}
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// bundle import
|
||||
// ====================================================================
|
||||
@@ -250,6 +309,30 @@ public static class BundleCommands
|
||||
Description = "Resolution policy applied to every Modified row: skip, overwrite, or rename. Default: overwrite.",
|
||||
DefaultValueFactory = _ => "overwrite",
|
||||
};
|
||||
// M8 (D3): site/connection name mapping. Repeatable. A token with no
|
||||
// "=dst" part, or "=" with an empty right-hand side, means CreateNew (the
|
||||
// destination is created from the bundle payload); otherwise the source
|
||||
// is mapped to the named existing destination.
|
||||
var mapSiteOption = new Option<string[]>("--map-site")
|
||||
{
|
||||
Description = "Map a source site to a destination: 'srcIdentifier=dstIdentifier'. " +
|
||||
"'srcIdentifier' or 'srcIdentifier=' (no/empty dst) means create-new. Repeatable.",
|
||||
AllowMultipleArgumentsPerToken = true,
|
||||
};
|
||||
var mapConnectionOption = new Option<string[]>("--map-connection")
|
||||
{
|
||||
Description = "Map a source connection to a destination: 'srcSiteIdentifier/srcName=dstName'. " +
|
||||
"'srcSiteIdentifier/srcName' or '…=' (no/empty dst) means create-new. Repeatable.",
|
||||
AllowMultipleArgumentsPerToken = true,
|
||||
};
|
||||
var createMissingSitesOption = new Option<bool>("--create-missing-sites")
|
||||
{
|
||||
Description = "Create any unmapped/unmatched source site from the bundle payload instead of failing.",
|
||||
};
|
||||
var createMissingConnectionsOption = new Option<bool>("--create-missing-connections")
|
||||
{
|
||||
Description = "Create any unmapped/unmatched source connection from the bundle payload instead of failing.",
|
||||
};
|
||||
|
||||
var cmd = new Command("import")
|
||||
{
|
||||
@@ -258,6 +341,10 @@ public static class BundleCommands
|
||||
cmd.Add(inputOption);
|
||||
cmd.Add(passphraseOption);
|
||||
cmd.Add(onConflictOption);
|
||||
cmd.Add(mapSiteOption);
|
||||
cmd.Add(mapConnectionOption);
|
||||
cmd.Add(createMissingSitesOption);
|
||||
cmd.Add(createMissingConnectionsOption);
|
||||
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
@@ -267,11 +354,29 @@ public static class BundleCommands
|
||||
OutputFormatter.WriteError($"Bundle file not found: {input}", "FILE_NOT_FOUND");
|
||||
return 1;
|
||||
}
|
||||
|
||||
List<SiteMappingSpec> siteMappings;
|
||||
List<ConnectionMappingSpec> connectionMappings;
|
||||
try
|
||||
{
|
||||
siteMappings = ParseSiteMappings(result.GetValue(mapSiteOption));
|
||||
connectionMappings = ParseConnectionMappings(result.GetValue(mapConnectionOption));
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
OutputFormatter.WriteError(ex.Message, "INVALID_ARGUMENT");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var bytes = await File.ReadAllBytesAsync(input);
|
||||
var payload = new ImportBundleCommand(
|
||||
Base64Bundle: Convert.ToBase64String(bytes),
|
||||
Passphrase: result.GetValue(passphraseOption),
|
||||
DefaultConflictPolicy: result.GetValue(onConflictOption)!);
|
||||
DefaultConflictPolicy: result.GetValue(onConflictOption)!,
|
||||
SiteMappings: siteMappings.Count == 0 ? null : siteMappings,
|
||||
ConnectionMappings: connectionMappings.Count == 0 ? null : connectionMappings,
|
||||
CreateMissingSites: result.GetValue(createMissingSitesOption),
|
||||
CreateMissingConnections: result.GetValue(createMissingConnectionsOption));
|
||||
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
@@ -389,4 +494,82 @@ public static class BundleCommands
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the repeatable <c>--map-site</c> tokens into
|
||||
/// <see cref="SiteMappingSpec"/>s. Each token is
|
||||
/// <c>srcIdentifier=dstIdentifier</c> (map to an existing destination) or
|
||||
/// <c>srcIdentifier</c> / <c>srcIdentifier=</c> (no/empty right-hand side →
|
||||
/// CreateNew, surfaced as a null target). Exposed <c>internal</c> so the
|
||||
/// flag-parse tests can assert the split + CreateNew convention.
|
||||
/// </summary>
|
||||
/// <param name="tokens">Raw <c>--map-site</c> token values, or <c>null</c>.</param>
|
||||
/// <returns>The parsed spec list (possibly empty).</returns>
|
||||
/// <exception cref="FormatException">A token has an empty source identifier.</exception>
|
||||
internal static List<SiteMappingSpec> ParseSiteMappings(string[]? tokens)
|
||||
{
|
||||
var result = new List<SiteMappingSpec>();
|
||||
if (tokens is null) return result;
|
||||
foreach (var token in tokens)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token)) continue;
|
||||
var (left, right) = SplitOnFirst(token, '=');
|
||||
var src = left.Trim();
|
||||
if (src.Length == 0)
|
||||
{
|
||||
throw new FormatException(
|
||||
$"Invalid --map-site '{token}': source identifier is required (use 'src=dst' or 'src' for create-new).");
|
||||
}
|
||||
// null target == CreateNew, per the documented convention.
|
||||
result.Add(new SiteMappingSpec(src, string.IsNullOrWhiteSpace(right) ? null : right!.Trim()));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the repeatable <c>--map-connection</c> tokens into
|
||||
/// <see cref="ConnectionMappingSpec"/>s. Each token is
|
||||
/// <c>srcSiteIdentifier/srcName=dstName</c> (map to an existing destination)
|
||||
/// or <c>srcSiteIdentifier/srcName</c> / <c>…=</c> (no/empty right-hand side →
|
||||
/// CreateNew, surfaced as a null target). Exposed <c>internal</c> for the
|
||||
/// flag-parse tests.
|
||||
/// </summary>
|
||||
/// <param name="tokens">Raw <c>--map-connection</c> token values, or <c>null</c>.</param>
|
||||
/// <returns>The parsed spec list (possibly empty).</returns>
|
||||
/// <exception cref="FormatException">A token is missing the <c>site/name</c> source shape.</exception>
|
||||
internal static List<ConnectionMappingSpec> ParseConnectionMappings(string[]? tokens)
|
||||
{
|
||||
var result = new List<ConnectionMappingSpec>();
|
||||
if (tokens is null) return result;
|
||||
foreach (var token in tokens)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token)) continue;
|
||||
var (lhs, right) = SplitOnFirst(token, '=');
|
||||
var (site, name) = SplitOnFirst(lhs, '/');
|
||||
var srcSite = site.Trim();
|
||||
var srcName = (name ?? string.Empty).Trim();
|
||||
if (srcSite.Length == 0 || name is null || srcName.Length == 0)
|
||||
{
|
||||
throw new FormatException(
|
||||
$"Invalid --map-connection '{token}': expected 'srcSiteIdentifier/srcName[=dstName]'.");
|
||||
}
|
||||
// null target == CreateNew, per the documented convention.
|
||||
result.Add(new ConnectionMappingSpec(srcSite, srcName, string.IsNullOrWhiteSpace(right) ? null : right!.Trim()));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Splits <paramref name="value"/> on the first occurrence of
|
||||
/// <paramref name="separator"/>. When the separator is absent the right side
|
||||
/// is <c>null</c> (distinguishing "src" from "src=" — both mean CreateNew here,
|
||||
/// but callers that need to detect the separator's presence can).
|
||||
/// </summary>
|
||||
private static (string Left, string? Right) SplitOnFirst(string value, char separator)
|
||||
{
|
||||
var idx = value.IndexOf(separator);
|
||||
return idx < 0
|
||||
? (value, null)
|
||||
: (value[..idx], value[(idx + 1)..]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,43 @@ public sealed record PreviewBundleResult(
|
||||
int AddCount,
|
||||
int ModifiedCount,
|
||||
int IdenticalCount,
|
||||
int BlockerCount);
|
||||
int BlockerCount,
|
||||
// Additive (M8 D3): site/connection references the operator must resolve
|
||||
// before import, carried verbatim from ImportPreview. Defaulted to empty so
|
||||
// every existing positional caller keeps compiling.
|
||||
IReadOnlyList<RequiredSiteMapping>? RequiredSiteMappings = null,
|
||||
IReadOnlyList<RequiredConnectionMapping>? RequiredConnectionMappings = null)
|
||||
{
|
||||
/// <summary>Site references the operator must resolve before import (M8). Never null.</summary>
|
||||
public IReadOnlyList<RequiredSiteMapping> RequiredSiteMappings { get; init; } =
|
||||
RequiredSiteMappings ?? Array.Empty<RequiredSiteMapping>();
|
||||
|
||||
/// <summary>Connection references the operator must resolve before import (M8). Never null.</summary>
|
||||
public IReadOnlyList<RequiredConnectionMapping> RequiredConnectionMappings { get; init; } =
|
||||
RequiredConnectionMappings ?? Array.Empty<RequiredConnectionMapping>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One source-environment site → destination resolution carried on
|
||||
/// <see cref="ImportBundleCommand"/> across the CLI ⇆ ManagementActor boundary.
|
||||
/// A null/blank <paramref name="TargetSiteIdentifier"/> means CreateNew; a
|
||||
/// present value means MapToExisting that target. Serializable via
|
||||
/// System.Text.Json.
|
||||
/// </summary>
|
||||
public sealed record SiteMappingSpec(
|
||||
string SourceSiteIdentifier,
|
||||
string? TargetSiteIdentifier);
|
||||
|
||||
/// <summary>
|
||||
/// One source-environment data-connection (scoped to a source site) →
|
||||
/// destination resolution carried on <see cref="ImportBundleCommand"/>. A
|
||||
/// null/blank <paramref name="TargetConnectionName"/> means CreateNew; a present
|
||||
/// value means MapToExisting that target. Serializable via System.Text.Json.
|
||||
/// </summary>
|
||||
public sealed record ConnectionMappingSpec(
|
||||
string SourceSiteIdentifier,
|
||||
string SourceConnectionName,
|
||||
string? TargetConnectionName);
|
||||
|
||||
/// <summary>
|
||||
/// Loads, previews, and applies a bundle in a single call. The diff is built
|
||||
@@ -66,8 +102,22 @@ public sealed record PreviewBundleResult(
|
||||
/// Valid <see cref="DefaultConflictPolicy"/> values: <c>"skip"</c>,
|
||||
/// <c>"overwrite"</c>, <c>"rename"</c>. Rename mints a unique suffix per row.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// M8 (D3): <paramref name="SiteMappings"/> / <paramref name="ConnectionMappings"/>
|
||||
/// supply explicit operator resolutions for the source-environment site/connection
|
||||
/// references the bundle carries; the handler merges them with the preview's
|
||||
/// auto-matches and the <paramref name="CreateMissingSites"/> /
|
||||
/// <paramref name="CreateMissingConnections"/> flags into a
|
||||
/// <see cref="BundleNameMap"/> passed to Apply. All four are defaulted so every
|
||||
/// existing positional caller keeps compiling and central-config-only bundles
|
||||
/// (no site/instance payload) carry an empty map.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed record ImportBundleCommand(
|
||||
string Base64Bundle,
|
||||
string? Passphrase,
|
||||
string DefaultConflictPolicy);
|
||||
string DefaultConflictPolicy,
|
||||
IReadOnlyList<SiteMappingSpec>? SiteMappings = null,
|
||||
IReadOnlyList<ConnectionMappingSpec>? ConnectionMappings = null,
|
||||
bool CreateMissingSites = false,
|
||||
bool CreateMissingConnections = false);
|
||||
|
||||
@@ -2421,7 +2421,11 @@ public class ManagementActor : ReceiveActor
|
||||
var mods = preview.Items.Count(i => i.Kind == ConflictKind.Modified);
|
||||
var ids = preview.Items.Count(i => i.Kind == ConflictKind.Identical);
|
||||
var blocks = preview.Items.Count(i => i.Kind == ConflictKind.Blocker);
|
||||
return new PreviewBundleResult(preview.Items, adds, mods, ids, blocks);
|
||||
// M8 (D3): surface the required site/connection mappings so an operator
|
||||
// (CLI or UI) sees which references need resolving before applying.
|
||||
return new PreviewBundleResult(
|
||||
preview.Items, adds, mods, ids, blocks,
|
||||
preview.RequiredSiteMappings, preview.RequiredConnectionMappings);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -2462,10 +2466,19 @@ public class ManagementActor : ReceiveActor
|
||||
$"Bundle has {blockers.Count} blocker(s); import aborted. {details}");
|
||||
}
|
||||
|
||||
// M8 (D3): resolve every required site/connection reference into a
|
||||
// BundleNameMap. Precedence: an explicit operator spec wins; otherwise
|
||||
// fall back to the preview's auto-match; otherwise (no match) create-new
|
||||
// ONLY if the create-missing flag is set, else fail with a clear message.
|
||||
var nameMap = BuildNameMap(cmd, preview);
|
||||
|
||||
// Dedupe by (EntityType, Name) -- the preview can emit multiple rows per
|
||||
// entity (e.g. one row per modified member of a template), but ApplyAsync
|
||||
// requires a unique resolution per key. Last-write-wins matches the
|
||||
// Central UI's TransportImport.BuildDefaultResolutions behavior.
|
||||
// Central UI's TransportImport.BuildDefaultResolutions behavior. For
|
||||
// DataConnection rows the preview item Name is already site-qualified
|
||||
// ({site}/{name}, D1-FIX), so keying generically by (EntityType, Name) is
|
||||
// automatically correct — no bare-connection-name special case needed.
|
||||
var renameStamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss");
|
||||
var resolutionsMap = new Dictionary<(string, string), ImportResolution>();
|
||||
foreach (var item in preview.Items)
|
||||
@@ -2484,7 +2497,96 @@ public class ManagementActor : ReceiveActor
|
||||
item.EntityType, item.Name, action, renameTo);
|
||||
}
|
||||
|
||||
return await importer.ApplyAsync(session.SessionId, resolutionsMap.Values.ToList(), username);
|
||||
return await importer.ApplyAsync(
|
||||
session.SessionId, resolutionsMap.Values.ToList(), username, nameMap: nameMap);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges the operator-supplied <see cref="SiteMappingSpec"/> /
|
||||
/// <see cref="ConnectionMappingSpec"/> lists with the preview's auto-matches
|
||||
/// and the create-missing flags into a <see cref="BundleNameMap"/>.
|
||||
/// <para>
|
||||
/// Per required site reference: an explicit spec wins (target present →
|
||||
/// <see cref="MappingAction.MapToExisting"/>; null/blank target →
|
||||
/// <see cref="MappingAction.CreateNew"/>); otherwise the preview's
|
||||
/// <see cref="RequiredSiteMapping.AutoMatchTargetIdentifier"/> is used when
|
||||
/// present (MapToExisting); otherwise CreateNew only when
|
||||
/// <see cref="ImportBundleCommand.CreateMissingSites"/> is set, else the
|
||||
/// reference is unresolved and the import fails. Connections mirror this with
|
||||
/// <see cref="ImportBundleCommand.CreateMissingConnections"/>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private static BundleNameMap BuildNameMap(ImportBundleCommand cmd, ImportPreview preview)
|
||||
{
|
||||
var siteSpecs = (cmd.SiteMappings ?? Array.Empty<SiteMappingSpec>())
|
||||
.ToDictionary(s => s.SourceSiteIdentifier, StringComparer.Ordinal);
|
||||
var connSpecs = (cmd.ConnectionMappings ?? Array.Empty<ConnectionMappingSpec>())
|
||||
.ToDictionary(
|
||||
c => (c.SourceSiteIdentifier, c.SourceConnectionName),
|
||||
c => c);
|
||||
|
||||
var siteMappings = new List<SiteMapping>();
|
||||
var unresolved = new List<string>();
|
||||
foreach (var required in preview.RequiredSiteMappings)
|
||||
{
|
||||
if (siteSpecs.TryGetValue(required.SourceSiteIdentifier, out var spec))
|
||||
{
|
||||
siteMappings.Add(string.IsNullOrWhiteSpace(spec.TargetSiteIdentifier)
|
||||
? new SiteMapping(required.SourceSiteIdentifier, MappingAction.CreateNew, null)
|
||||
: new SiteMapping(required.SourceSiteIdentifier, MappingAction.MapToExisting, spec.TargetSiteIdentifier));
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(required.AutoMatchTargetIdentifier))
|
||||
{
|
||||
siteMappings.Add(new SiteMapping(
|
||||
required.SourceSiteIdentifier, MappingAction.MapToExisting, required.AutoMatchTargetIdentifier));
|
||||
}
|
||||
else if (cmd.CreateMissingSites)
|
||||
{
|
||||
siteMappings.Add(new SiteMapping(
|
||||
required.SourceSiteIdentifier, MappingAction.CreateNew, null));
|
||||
}
|
||||
else
|
||||
{
|
||||
unresolved.Add($"site '{required.SourceSiteIdentifier}'");
|
||||
}
|
||||
}
|
||||
|
||||
var connMappings = new List<ConnectionMapping>();
|
||||
foreach (var required in preview.RequiredConnectionMappings)
|
||||
{
|
||||
var key = (required.SourceSiteIdentifier, required.SourceConnectionName);
|
||||
if (connSpecs.TryGetValue(key, out var spec))
|
||||
{
|
||||
connMappings.Add(string.IsNullOrWhiteSpace(spec.TargetConnectionName)
|
||||
? new ConnectionMapping(required.SourceSiteIdentifier, required.SourceConnectionName, MappingAction.CreateNew, null)
|
||||
: new ConnectionMapping(required.SourceSiteIdentifier, required.SourceConnectionName, MappingAction.MapToExisting, spec.TargetConnectionName));
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(required.AutoMatchTargetName))
|
||||
{
|
||||
connMappings.Add(new ConnectionMapping(
|
||||
required.SourceSiteIdentifier, required.SourceConnectionName, MappingAction.MapToExisting, required.AutoMatchTargetName));
|
||||
}
|
||||
else if (cmd.CreateMissingConnections)
|
||||
{
|
||||
connMappings.Add(new ConnectionMapping(
|
||||
required.SourceSiteIdentifier, required.SourceConnectionName, MappingAction.CreateNew, null));
|
||||
}
|
||||
else
|
||||
{
|
||||
unresolved.Add($"connection '{required.SourceSiteIdentifier}/{required.SourceConnectionName}'");
|
||||
}
|
||||
}
|
||||
|
||||
if (unresolved.Count > 0)
|
||||
{
|
||||
throw new ManagementCommandException(
|
||||
$"Import has {unresolved.Count} unresolved mapping(s): " +
|
||||
$"{string.Join(", ", unresolved.OrderBy(u => u, StringComparer.Ordinal))}. " +
|
||||
"Supply --map-site/--map-connection for each, or pass " +
|
||||
"--create-missing-sites/--create-missing-connections to create them from the bundle.");
|
||||
}
|
||||
|
||||
return new BundleNameMap(siteMappings, connMappings);
|
||||
}
|
||||
|
||||
private static byte[] DecodeBundle(string base64)
|
||||
|
||||
Reference in New Issue
Block a user