Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions PanoramicData.IssueAssessment/Mapping/AdfFlattener.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
using System.Collections.Frozen;
using System.Text;
using static PanoramicData.IssueAssessment.Mapping.JsonReading;

namespace PanoramicData.IssueAssessment.Mapping;

/// <summary>
/// Flattens Atlassian Document Format (the description format of Jira REST API v3) to wiki-style text: headings become
/// <c>h2.</c> lines and list items <c>*</c> or <c>#</c> lines, so section rules read v2 and v3 descriptions the same way.
/// </summary>
internal static class AdfFlattener
{
// ADF list types, and the wiki marker each nesting level adds.
private static readonly FrozenDictionary<string, string> ListMarkers = new Dictionary<string, string>(StringComparer.Ordinal)
{
["bulletList"] = "*",
["orderedList"] = "#",
}.ToFrozenDictionary(StringComparer.Ordinal);

// ADF nodes written as one line of inline text.
private static readonly FrozenSet<string> LineBlockTypes = FrozenSet.Create(
StringComparer.Ordinal,
"paragraph", "blockquote", "panel", "text", "mention", "inlineCard", "hardBreak");

public static string Flatten(JsonElement document)
{
StringBuilder text = new();
AppendBlock(document, text, string.Empty);
return text.ToString().TrimEnd();
}

private static void AppendBlock(JsonElement node, StringBuilder text, string listPrefix)
{
string type = StringAt(node, "type") ?? string.Empty;
if (ListMarkers.TryGetValue(type, out string? listMarker))
{
AppendList(node, text, listPrefix + listMarker);
}
else if (type == "heading")
{
text.Append(CultureInfo.InvariantCulture, $"h{AdfHeadingLevel(node)}. ").Append(Inline(node)).Append('\n');
}
else if (type == "codeBlock")
{
text.Append("{code}\n").Append(Inline(node)).Append("\n{code}\n");
}
else if (LineBlockTypes.Contains(type))
{
text.Append(Inline(node)).Append('\n');
}
else
{
foreach (JsonElement child in Children(node))
{
AppendBlock(child, text, listPrefix);
}
}
}

private static void AppendList(JsonElement list, StringBuilder text, string marker)
{
foreach (JsonElement item in Children(list))
{
AppendListItem(item, text, marker);
}
}

private static int AdfHeadingLevel(JsonElement heading)
=> heading.TryGetProperty("attrs", out JsonElement attrs) && attrs.TryGetProperty("level", out JsonElement value) && value.TryGetInt32(out int level)
? level
: 2;

private static void AppendListItem(JsonElement item, StringBuilder text, string marker)
{
bool first = true;
foreach (JsonElement child in Children(item))
{
if (StringAt(child, "type") is "bulletList" or "orderedList")
{
AppendBlock(child, text, marker);
}
else
{
text.Append(first ? marker + " " : string.Empty).Append(Inline(child)).Append('\n');
first = false;
}
}
}

private static string Inline(JsonElement node) => StringAt(node, "type") switch
{
"text" => StringAt(node, "text") ?? string.Empty,
"hardBreak" => "\n",
"mention" or "emoji" => node.TryGetProperty("attrs", out JsonElement attrs) ? StringAt(attrs, "text") ?? string.Empty : string.Empty,
"inlineCard" => node.TryGetProperty("attrs", out JsonElement attrs) ? StringAt(attrs, "url") ?? string.Empty : string.Empty,
_ => string.Concat(Children(node).Select(Inline)),
};

private static JsonElement[] Children(JsonElement node)
=> node.ValueKind == JsonValueKind.Object && node.TryGetProperty("content", out JsonElement content) && content.ValueKind == JsonValueKind.Array
? [.. content.EnumerateArray()]
: [];
}
91 changes: 1 addition & 90 deletions PanoramicData.IssueAssessment/Mapping/JiraIssueMapper.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
using System.Collections.Frozen;
using System.Text;
using static PanoramicData.IssueAssessment.Mapping.JsonReading;

namespace PanoramicData.IssueAssessment.Mapping;
Expand All @@ -15,18 +13,6 @@ namespace PanoramicData.IssueAssessment.Mapping;
/// </remarks>
public static class JiraIssueMapper
{
// ADF list types, and the wiki marker each nesting level adds.
private static readonly FrozenDictionary<string, string> ListMarkers = new Dictionary<string, string>(StringComparer.Ordinal)
{
["bulletList"] = "*",
["orderedList"] = "#",
}.ToFrozenDictionary(StringComparer.Ordinal);

// ADF nodes written as one line of inline text.
private static readonly FrozenSet<string> LineBlockTypes = FrozenSet.Create(
StringComparer.Ordinal,
"paragraph", "blockquote", "panel", "text", "mention", "inlineCard", "hardBreak");

/// <summary>
/// Maps a Jira REST issue.
/// </summary>
Expand Down Expand Up @@ -92,9 +78,7 @@ private static int ArrayLength(JsonElement element, string name)
/// <returns>The text.</returns>
public static string FlattenAdf(JsonElement document)
{
StringBuilder text = new();
AppendBlock(document, text, string.Empty);
return text.ToString().TrimEnd();
return AdfFlattener.Flatten(document);
}

private static string? Description(JsonElement fields)
Expand All @@ -105,79 +89,6 @@ public static string FlattenAdf(JsonElement document)
_ => null,
} : null;

private static void AppendBlock(JsonElement node, StringBuilder text, string listPrefix)
{
string type = StringAt(node, "type") ?? string.Empty;
if (ListMarkers.TryGetValue(type, out string? listMarker))
{
AppendList(node, text, listPrefix + listMarker);
}
else if (type == "heading")
{
text.Append(CultureInfo.InvariantCulture, $"h{AdfHeadingLevel(node)}. ").Append(Inline(node)).Append('\n');
}
else if (type == "codeBlock")
{
text.Append("{code}\n").Append(Inline(node)).Append("\n{code}\n");
}
else if (LineBlockTypes.Contains(type))
{
text.Append(Inline(node)).Append('\n');
}
else
{
foreach (JsonElement child in Children(node))
{
AppendBlock(child, text, listPrefix);
}
}
}

private static void AppendList(JsonElement list, StringBuilder text, string marker)
{
foreach (JsonElement item in Children(list))
{
AppendListItem(item, text, marker);
}
}

private static int AdfHeadingLevel(JsonElement heading)
=> heading.TryGetProperty("attrs", out JsonElement attrs) && attrs.TryGetProperty("level", out JsonElement value) && value.TryGetInt32(out int level)
? level
: 2;

private static void AppendListItem(JsonElement item, StringBuilder text, string marker)
{
bool first = true;
foreach (JsonElement child in Children(item))
{
if (StringAt(child, "type") is "bulletList" or "orderedList")
{
AppendBlock(child, text, marker);
}
else
{
text.Append(first ? marker + " " : string.Empty).Append(Inline(child)).Append('\n');
first = false;
}
}
}

private static string Inline(JsonElement node) => StringAt(node, "type") switch
{
"text" => StringAt(node, "text") ?? string.Empty,
"hardBreak" => "\n",
"mention" or "emoji" => node.TryGetProperty("attrs", out JsonElement attrs) ? StringAt(attrs, "text") ?? string.Empty : string.Empty,
"inlineCard" => node.TryGetProperty("attrs", out JsonElement attrs) ? StringAt(attrs, "url") ?? string.Empty : string.Empty,
_ => string.Concat(Children(node).Select(Inline)),
};

private static JsonElement[] Children(JsonElement node)
=> node.ValueKind == JsonValueKind.Object && node.TryGetProperty("content", out JsonElement content) && content.ValueKind == JsonValueKind.Array
? [.. content.EnumerateArray()]
: [];


private static ImmutableList<string> Names(JsonElement fields, string property)
=> fields.TryGetProperty(property, out JsonElement values) && values.ValueKind == JsonValueKind.Array
? [.. values.EnumerateArray().Select(value => StringAt(value, "name")).OfType<string>()]
Expand Down
82 changes: 82 additions & 0 deletions PanoramicData.IssueAssessment/Text/IssueText.Panels.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
using System.Text.RegularExpressions;

namespace PanoramicData.IssueAssessment.Text;

/// <summary>
/// Reads how Jira wiki sections are laid out in panels.
/// </summary>
public static partial class IssueText
{
/// <summary>
/// Reads how the <c>h2.</c> sections of Jira wiki text are laid out in panels.
/// </summary>
/// <param name="text">The ticket text.</param>
/// <returns>The headings of the sections that could be panelled, are not, or carry a repeated panel title.</returns>
/// <remarks>
/// A section is panelled when its first line is <c>{panel}</c>. It could be panelled when it has content and no table
/// or code block, since those do not sit well in a panel. A panel directly under a heading should carry no
/// <c>title=</c>, because the title bar only repeats the heading above it.
/// </remarks>
public static PanelLayout PanelLayoutOf(string? text)
{
if (string.IsNullOrWhiteSpace(text))
{
return PanelLayout.Empty;
}

List<string> panelable = [];
List<string> unpanelled = [];
List<string> titled = [];
string? heading = null;
List<string> body = [];
foreach (string line in LineBreak.Split(text))
{
Match opening = PanelSectionHeading.Match(line);
if (opening.Success)
{
Classify(heading, body, panelable, unpanelled, titled);
heading = opening.Groups[1].Value == "2" ? opening.Groups[2].Value.Trim() : null;
body = [];
}
else if (heading is not null)
{
body.Add(line);
}
}

Classify(heading, body, panelable, unpanelled, titled);
return new PanelLayout(panelable, unpanelled, titled);
}

private static void Classify(string? heading, List<string> body, List<string> panelable, List<string> unpanelled, List<string> titled)
{
if (heading is null)
{
return;
}

string? first = body.Find(line => line.Trim().Length > 0);
Match panel = first is null ? Match.Empty : PanelOpening.Match(first);
if (panel.Success)
{
panelable.Add(heading);
AddIf(titled, heading, panel.Groups[1].Value.Contains("title=", StringComparison.OrdinalIgnoreCase));
return;
}

bool hasContent = first is not null && !IsPlaceholder(string.Join('\n', body));
if (hasContent && !body.Exists(line => PanelBlocker.IsMatch(line)))
{
panelable.Add(heading);
unpanelled.Add(heading);
}
}

private static void AddIf(List<string> list, string item, bool condition)
{
if (condition)
{
list.Add(item);
}
}
}
44 changes: 44 additions & 0 deletions PanoramicData.IssueAssessment/Text/IssueText.Patterns.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using System.Text.RegularExpressions;

namespace PanoramicData.IssueAssessment.Text;

/// <summary>
/// The regular expressions the readers use.
/// </summary>
public static partial class IssueText
{
private const RegexOptions Options = RegexOptions.CultureInvariant;
private static readonly TimeSpan Timeout = RegexCache.MatchTimeout;

private static readonly Regex WikiHeadingAnywhere = new(@"(?im)^[ \t]*h[1-6]\.", Options, Timeout);
private static readonly Regex WikiHeadingLevel = new(@"(?im)^[ \t]*h([1-6])\.", Options, Timeout);
private static readonly Regex AtxHeadingLevel = new(@"(?im)^[ \t]*(#{1,6})[ \t]", Options, Timeout);
private static readonly Regex StartsWithHash = new(@"^[ \t]*#", Options | RegexOptions.IgnoreCase, Timeout);
private static readonly Regex StartsWithSingleHashItem = new(@"^[ \t]*#[ \t]", Options | RegexOptions.IgnoreCase, Timeout);
private static readonly Regex HashListLine = new(@"^[ \t]*#+[ \t]", Options | RegexOptions.IgnoreCase, Timeout);
private static readonly Regex StartsWithWiki = new(@"^[ \t]*h[1-6]\.", Options | RegexOptions.IgnoreCase, Timeout);
private static readonly Regex StartsWithDoubleAsterisk = new(@"^[ \t]*\*\*", Options | RegexOptions.IgnoreCase, Timeout);
private static readonly Regex StartsWithAsterisk = new(@"^[ \t]*\*", Options | RegexOptions.IgnoreCase, Timeout);
private static readonly Regex LineBreak = new(@"\r?\n", Options, Timeout);

private static readonly Regex WikiNext = new(@"(?im)^[ \t]*(?:h[1-6]\.|#{2,6}[ \t])", Options, Timeout);
private static readonly Regex AtxNext = new(@"(?im)^[ \t]*(?:h[1-6]\.|#{1,6}[ \t])", Options, Timeout);
private static readonly Regex Bold2Next = new(@"(?im)^[ \t]*(?:h[1-6]\.|#{1,6}[ \t]|\*{1,2}[^\r\n*]+\*{1,2}[ \t]*\r?$)", Options, Timeout);
private static readonly Regex Bold1Next = new(@"(?im)^[ \t]*(?:h[1-6]\.|\*{1,2}[^\r\n*]+\*{1,2}[ \t]*\r?$)", Options, Timeout);
private static readonly Regex LabelNext = new(@"(?im)^[ \t]*(?:h[1-6]\.|\*{1,2}[^\r\n*]+\*{1,2}[ \t]*\r?$|(?=[A-Za-z])[^\r\n:#*]{1,60}:[ \t]*\r?$)", Options, Timeout);

private static readonly Regex CriteriaHeadingLine = new(@"^[ \t]*(?:h([1-6])\.|(#{1,6})[ \t]|\*{1,2}(?=[^*\r\n]+\*{1,2}[ \t]*:?[ \t]*$))(.*)$", Options, Timeout);
private static readonly Regex CriteriaHeadingText = new(@"(?i)criteria|definition\s+of\s+done|verification\s+evidence", Options, Timeout);
private static readonly Regex CriteriaMappingTable = new(@"(?im)^[ \t]*\|.*criteri.*\|.*\btests?\b", Options, Timeout);

private static readonly Regex PanelSectionHeading = new(@"^[ \t]*h([12])\.(.*)$", Options, Timeout);
private static readonly Regex PanelOpening = new(@"^[ \t]*\{panel(?::([^}]*))?\}", Options | RegexOptions.IgnoreCase, Timeout);
private static readonly Regex PanelBlocker = new(@"^[ \t]*\||\{(?:code|noformat)\b", Options | RegexOptions.IgnoreCase, Timeout);

private static readonly Regex PlaceholderListMarker = new(@"(?m)^[ \t]*(?:[-*#>]+|\d+\.)[ \t]*", Options, Timeout);
private static readonly Regex PlaceholderPunctuation = new(@"[\*_`{}\[\]\(\)\|:;,\.\-–—/\\]", Options, Timeout);
private static readonly Regex PlaceholderWords = new(@"^\s*(tbd|to\s*be\s*(done|decided|determined|confirmed|advised)|n\s*/?\s*a|none|nil|todo|to\s*do|pending|unknown|see\s+above)\s*$", Options | RegexOptions.IgnoreCase, Timeout);

private static readonly Regex Whitespace = new(@"\s+", Options, Timeout);
private static readonly Regex Alphanumeric = new("[A-Za-z0-9]", Options, Timeout);
}
Loading
Loading