Skip to content

Commit 9797e09

Browse files
committed
Add anomaly detection Markdown summary report
1 parent cfa9f70 commit 9797e09

12 files changed

+493
-0
lines changed

domains/anomaly-detection/anomalyDetectionCsv.sh

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ anomaly_detection_features() {
7070
# Required Parameters:
7171
# - projection_node_label=...
7272
# Label of the nodes that will be used for the projection. Example: "Package"
73+
# - projection_language=...
74+
# Name of the associated programming language. Default: "Java". Example: "Typescript"
7375
anomaly_detection_queries() {
7476
local nodeLabel
7577
nodeLabel=$( extractQueryParameter "projection_node_label" "${@}" )
@@ -99,6 +101,8 @@ anomaly_detection_queries() {
99101
# Required Parameters:
100102
# - projection_node_label=...
101103
# Label of the nodes that will be used for the projection. Example: "Package"
104+
# - projection_language=...
105+
# Name of the associated programming language. Examples: "Java", "Typescript"
102106
anomaly_detection_labels() {
103107
local nodeLabel
104108
nodeLabel=$( extractQueryParameter "projection_node_label" "${@}" )
@@ -129,6 +133,8 @@ anomaly_detection_labels() {
129133
# Label of the nodes that will be used for the projection. Example: "Package"
130134
# - projection_weight_property=...
131135
# Name of the node property that contains the dependency weight. Example: "weight"
136+
# - projection_language=...
137+
# Name of the associated programming language. Examples: "Java", "Typescript"
132138
anomaly_detection_csv_reports() {
133139
time anomaly_detection_features "${@}"
134140
time anomaly_detection_queries "${@}"
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// Anomaly Detection Summary: Summarizes all labelled archetypes by their anomaly score including examples. Requires all other labels/*.cypher queries to run first. Variables: projection_language, projection_node_label
2+
3+
MATCH (codeUnit)
4+
WHERE $projection_node_label IN labels(codeUnit)
5+
UNWIND keys(codeUnit) AS codeUnitProperty
6+
WITH *
7+
WHERE codeUnitProperty STARTS WITH 'anomaly'
8+
AND codeUnitProperty ENDS WITH 'Rank'
9+
WITH *
10+
,coalesce(codeUnit.fqn, codeUnit.globalFqn, codeUnit.fileName, codeUnit.signature, codeUnit.name) AS codeUnitName
11+
,split(split(codeUnitProperty, 'anomaly')[1], 'Rank')[0] AS archetype
12+
,codeUnit[codeUnitProperty] AS archetypeRank
13+
,codeUnit.anomalyScore AS anomalyScore
14+
WITH *, collect(archetype)[0] AS archetype
15+
ORDER BY codeUnit.anomalyScore DESC, archetypeRank ASC, codeUnitName ASC, archetype ASC
16+
WITH archetype
17+
,anomalyScore
18+
,CASE WHEN codeUnit.anomalyScore <= 0 THEN 'Typical'
19+
WHEN codeUnit.anomalyTopFeature1 IS NULL THEN 'Undetermined'
20+
ELSE 'Anomalous' END AS modelStatus
21+
,codeUnitName
22+
RETURN archetype AS `Archetype`
23+
,count(DISTINCT codeUnitName) AS `Count`
24+
,round(max(anomalyScore), 4, 'HALF_UP') AS `Max. Score`
25+
,modelStatus AS `Model Status`
26+
,apoc.text.join(collect(DISTINCT codeUnitName)[0..3], ', ') AS `Examples`
27+
ORDER BY modelStatus, archetype, `Max. Score` DESC
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// Anomaly Detection DeepDive: Overview of analyzed code units and the number of anomalies detected. Requires all other labels/*.cypher queries to run first. Variables: projection_language, projection_node_label
2+
3+
MATCH (codeUnit)
4+
WHERE $projection_node_label IN labels(codeUnit)
5+
AND (codeUnit.incomingDependencies IS NOT NULL
6+
OR codeUnit.outgoingDependencies IS NOT NULL)
7+
WITH sum(codeUnit.anomalyLabel) AS anomalyCount
8+
,sum(sign(codeUnit.anomalyAuthorityRank)) AS authorityCount
9+
,sum(sign(codeUnit.anomalyBottleneckRank)) AS bottleNeckCount
10+
,sum(sign(codeUnit.anomalyBridgeRank)) AS bridgeCount
11+
,sum(sign(codeUnit.anomalyHubRank)) AS hubCount
12+
,sum(sign(codeUnit.anomalyOutlierRank)) AS outlierCount
13+
//,collect(codeUnit.name)[0..4] AS exampleNames
14+
RETURN anomalyCount AS `Anomalies`
15+
,authorityCount AS `Authorities`
16+
,bottleNeckCount AS `Bottlenecks`
17+
,bridgeCount AS `Bridges`
18+
,hubCount AS `Hubs`
19+
,outlierCount AS `Outliers`
20+
//,exampleNames
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// Anomaly Detection Summary: Overview of all analyzed code units in total. Requires all other labels/*.cypher queries to run first. Variables: projection_language, projection_node_label
2+
3+
MATCH (codeUnit)
4+
WHERE (codeUnit.incomingDependencies IS NOT NULL
5+
OR codeUnit.outgoingDependencies IS NOT NULL)
6+
WITH count(DISTINCT codeUnit) AS codeUnitCount
7+
,sum(codeUnit.anomalyLabel) AS anomalyCount
8+
,sum(sign(codeUnit.anomalyAuthorityRank)) AS authorityCount
9+
,sum(sign(codeUnit.anomalyBottleneckRank)) AS bottleNeckCount
10+
,sum(sign(codeUnit.anomalyBridgeRank)) AS bridgeCount
11+
,sum(sign(codeUnit.anomalyHubRank)) AS hubCount
12+
,sum(sign(codeUnit.anomalyOutlierRank)) AS outlierCount
13+
//,collect(codeUnit.name)[0..4] AS exampleNames
14+
RETURN codeUnitCount AS `Analyzed Units`
15+
,anomalyCount AS `Anomalies`
16+
,authorityCount AS `Authorities`
17+
,bottleNeckCount AS `Bottlenecks`
18+
,bridgeCount AS `Bridges`
19+
,hubCount AS `Hubs`
20+
,outlierCount AS `Outliers`
21+
//,exampleNames
22+
ORDER BY anomalyCount DESC, codeUnitCount DESC
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// Anomaly Detection Summary: Overview of analyzed code units and the number of anomalies detected. Requires all other labels/*.cypher queries to run first. Variables: projection_language, projection_node_label
2+
3+
MATCH (codeUnit)
4+
WHERE (codeUnit.incomingDependencies IS NOT NULL
5+
OR codeUnit.outgoingDependencies IS NOT NULL)
6+
UNWIND labels(codeUnit) AS codeUnitLabel
7+
WITH *
8+
WHERE NOT codeUnitLabel STARTS WITH 'Mark4'
9+
AND NOT codeUnitLabel IN ['File', 'Directory', 'ByteCode', 'GenericDeclaration']
10+
WITH collect(codeUnitLabel) AS codeUnitLabels
11+
,codeUnit
12+
WITH apoc.text.join(codeUnitLabels, ',') AS codeUnitLabels
13+
,count(DISTINCT codeUnit) AS codeUnitCount
14+
,sum(codeUnit.anomalyLabel) AS anomalyCount
15+
,sum(sign(codeUnit.anomalyAuthorityRank)) AS authorityCount
16+
,sum(sign(codeUnit.anomalyBottleneckRank)) AS bottleNeckCount
17+
,sum(sign(codeUnit.anomalyBridgeRank)) AS bridgeCount
18+
,sum(sign(codeUnit.anomalyHubRank)) AS hubCount
19+
,sum(sign(codeUnit.anomalyOutlierRank)) AS outlierCount
20+
//,collect(codeUnit.name)[0..4] AS exampleNames
21+
RETURN codeUnitLabels AS `Abstraction Level`
22+
,codeUnitCount AS `Units`
23+
,anomalyCount AS `Anomalies`
24+
,authorityCount AS `Authorities`
25+
,bottleNeckCount AS `Bottlenecks`
26+
,bridgeCount AS `Bridges`
27+
,hubCount AS `Hubs`
28+
,outlierCount AS `Outliers`
29+
//,exampleNames
30+
ORDER BY anomalyCount DESC, codeUnitCount DESC
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// Anomaly Detection Summary: Lists top anomalies (at most 20), the top 3 features that contributed to the decision and the archetype(s) classification (if available) they are assigned to. Requires all other labels/*.cypher queries to run first. Variables: projection_language, projection_node_label
2+
3+
MATCH (codeUnit)
4+
WHERE $projection_node_label IN labels(codeUnit)
5+
AND codeUnit.anomalyScore > 0
6+
ORDER BY codeUnit.anomalyScore DESC
7+
UNWIND keys(codeUnit) AS codeUnitProperty
8+
WITH codeUnit
9+
,CASE WHEN codeUnitProperty STARTS WITH 'anomaly'
10+
AND codeUnitProperty ENDS WITH 'Rank'
11+
THEN split(split(codeUnitProperty, 'anomaly')[1], 'Rank')[0]
12+
END AS archetype
13+
,CASE WHEN codeUnitProperty STARTS WITH 'anomaly'
14+
AND codeUnitProperty ENDS WITH 'Rank'
15+
THEN codeUnit[codeUnitProperty]
16+
END AS archetypeRank
17+
ORDER BY codeUnit.anomalyScore DESC, archetypeRank ASC
18+
WITH codeUnit
19+
,coalesce(codeUnit.fqn, codeUnit.globalFqn, codeUnit.fileName, codeUnit.signature, codeUnit.name) AS codeUnitName
20+
,apoc.text.join(collect(DISTINCT archetype), ', ') AS archetypes
21+
OPTIONAL MATCH (artifact:Java:Artifact)-[:CONTAINS]->(codeUnit)
22+
WITH *, artifact.name AS artifactName
23+
OPTIONAL MATCH (projectRoot:Directory)<-[:HAS_ROOT]-(proj:TS:Project)-[:CONTAINS]->(codeUnit)
24+
WITH *, last(split(projectRoot.absoluteFileName, '/')) AS projectName
25+
OPTIONAL MATCH (codeDirectory:File:Directory)-[:CONTAINS]->(codeUnit)
26+
WITH *, split(replace(codeDirectory.fileName, './', ''), '/')[-2] AS directoryName
27+
WITH *, coalesce(artifactName, projectName, directoryName, "") AS projectName
28+
RETURN codeUnitName AS `Name`
29+
,projectName AS `Contained in`
30+
,round(codeUnit.anomalyScore, 4, 'HALF_UP') AS `Anomaly Score`
31+
,collect(archetypes)[0] AS `Archetypes`
32+
,nullif(codeUnit.anomalyTopFeature1, "") AS `Top Feature 1`
33+
,nullif(round(codeUnit.anomalyTopFeatureSHAPValue1, 4, 'HALF_UP'), 0.0) AS `Top Feature 1 SHAP`
34+
,nullif(codeUnit.anomalyTopFeature2, "") AS `Top Feature 2`
35+
,nullif(round(codeUnit.anomalyTopFeatureSHAPValue2, 4, 'HALF_UP'), 0.0) AS `Top Feature 2 SHAP`
36+
,nullif(codeUnit.anomalyTopFeature3, "") AS `Top Feature 3`
37+
,nullif(round(codeUnit.anomalyTopFeatureSHAPValue3, 4, 'HALF_UP'), 0.0) AS `Top Feature 3 SHAP`
38+
,CASE WHEN codeUnit.anomalyScore <= 0 THEN 'Typical'
39+
WHEN codeUnit.anomalyTopFeature1 IS NULL THEN 'Undetermined'
40+
ELSE 'Anomalous' END AS `Model Status`
41+
LIMIT 20
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
#!/usr/bin/env bash
2+
3+
# Creates a Markdown report that contains all results of all the anomaly detection methods.
4+
# It requires an already running Neo4j graph database with already scanned and analyzed artifacts.
5+
# The results will be written into the sub directory reports/anomaly-detection.
6+
7+
# Note that "scripts/prepareAnalysis.sh" is required to run prior to this script.
8+
# Note that either "anomalyDetectionCsv.sh" or "anomalyDetectionPython.sh" is required to run prior to this script.
9+
10+
# Requires executeQueryFunctions.sh, cleanupAfterReportGeneration.sh
11+
12+
# Fail on any error ("-e" = exit on first error, "-o pipefail" exist on errors within piped commands)
13+
set -o errexit -o pipefail
14+
15+
# Overrideable Constants (defaults also defined in sub scripts)
16+
REPORTS_DIRECTORY=${REPORTS_DIRECTORY:-"reports"}
17+
MARKDOWN_INCLUDES_DIRECTORY=${MARKDOWN_INCLUDES_DIRECTORY:-"includes"}
18+
19+
## Get this "domains/anomaly-detection/summary" directory if not already set
20+
# Even if $BASH_SOURCE is made for Bourne-like shells it is also supported by others and therefore here the preferred solution.
21+
# CDPATH reduces the scope of the cd command to potentially prevent unintended directory changes.
22+
# This way non-standard tools like readlink aren't needed.
23+
ANOMALY_DETECTION_SUMMARY_DIR=${ANOMALY_DETECTION_SUMMARY_DIR:-$(CDPATH=. cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)}
24+
#echo "anomalyDetectionSummary: ANOMALY_DETECTION_SUMMARY_DIR=${ANOMALY_DETECTION_SUMMARY_DIR}"
25+
# Get the "scripts" directory by taking the path of this script and going one directory up.
26+
SCRIPTS_DIR=${SCRIPTS_DIR:-"${ANOMALY_DETECTION_SUMMARY_DIR}/../../../scripts"} # Repository directory containing the shell scripts
27+
28+
MARKDOWN_INCLUDES_DIRECTORY="includes"
29+
MARKDOWN_SCRIPTS_DIR=${MARKDOWN_SCRIPTS_DIR:-"${SCRIPTS_DIR}/markdown"}
30+
#echo "anomalyDetectionSummary: MARKDOWN_SCRIPTS_DIR=${MARKDOWN_SCRIPTS_DIR}" >&2
31+
32+
# Define functions to execute a cypher query from within a given file (first and only argument) like "execute_cypher" and "execute_cypher_summarized"
33+
source "${SCRIPTS_DIR}/executeQueryFunctions.sh"
34+
35+
# Appends a Markdown table to an existing file and
36+
# removes redundant header + separator rows.
37+
#
38+
# Usage:
39+
# cat newTable.md | append_table myMarkdownFile.md
40+
#
41+
# append_table myMarkdownFile.md <<'EOF'
42+
# | Name | Score | Archetype |
43+
# | --- | --- | --- |
44+
# | Bar | 0.9 | Something |
45+
# EOF
46+
#
47+
# Behavior:
48+
# - Keeps the first header row and its following separator row.
49+
# - Removes all subsequent duplicate header + separator pairs.
50+
# - Leaves all data rows untouched.
51+
append_to_markdown_table() {
52+
local file="$1"
53+
54+
# Append stdin to the target file
55+
cat >> "${file}"
56+
57+
# Clean up duplicate headers (header row + --- row)
58+
awk '!seen[$0]++ || NR <= 2' "${file}" > "${file}.tmp" && mv "${file}.tmp" "${file}"
59+
}
60+
61+
# Run the anomaly detection main report generation.
62+
anomaly_detection_report_first_section() {
63+
local report_markdown_includes_directory="${FULL_REPORT_DIRECTORY}/${MARKDOWN_INCLUDES_DIRECTORY}"
64+
mkdir -p "${report_markdown_includes_directory}"
65+
66+
execute_cypher "${ANOMALY_DETECTION_SUMMARY_DIR}/AnomaliesPerAbstractionLayer.cypher" --output-markdown-table > "${report_markdown_includes_directory}/AnomaliesPerAbstractionLayer.md"
67+
execute_cypher "${ANOMALY_DETECTION_SUMMARY_DIR}/AnomaliesInTotal.cypher" --output-markdown-table > "${report_markdown_includes_directory}/AnomaliesInTotal.md"
68+
}
69+
70+
# Aggregates all results in a Markdown report.
71+
#
72+
# Required Parameters:
73+
# - projection_node_label=...
74+
# Label of the nodes that will be used for the projection. Example: "Package"
75+
# - projection_language=...
76+
# Name of the associated programming language. Examples: "Java", "Typescript"
77+
anomaly_detection_deep_dive_report() {
78+
local nodeLabel
79+
nodeLabel=$( extractQueryParameter "projection_node_label" "${@}" )
80+
81+
local language
82+
language=$( extractQueryParameter "projection_language" "${@}" )
83+
84+
local report_number
85+
report_number=$( extractQueryParameter "report_number" "${@}" )
86+
87+
echo "anomalyDetectionSummary: $(date +'%Y-%m-%dT%H:%M:%S%z') Creating ${language} ${nodeLabel} anomaly summary Markdown report..."
88+
89+
detail_report_directory_name="${language}_${nodeLabel}"
90+
detail_report_directory="${FULL_REPORT_DIRECTORY}/${detail_report_directory_name}"
91+
detail_report_include_directory="${detail_report_directory}/${MARKDOWN_INCLUDES_DIRECTORY}"
92+
mkdir -p "${detail_report_include_directory}"
93+
94+
# Collect dynamic Markdown includes
95+
execute_cypher "${ANOMALY_DETECTION_SUMMARY_DIR}/AnomaliesDeepDiveOverview.cypher" "${@}" --output-markdown-table > "${detail_report_include_directory}/DeepDiveOverview.md"
96+
execute_cypher "${ANOMALY_DETECTION_SUMMARY_DIR}/AnomaliesDeepDiveArchetypes.cypher" "${@}" --output-markdown-table > "${detail_report_include_directory}/DeepDiveArchetypes.md"
97+
execute_cypher "${ANOMALY_DETECTION_SUMMARY_DIR}/AnomalyDeepDiveTopAnomalies.cypher" "${@}" --output-markdown-table > "${detail_report_include_directory}/DeepDiveTopAnomalies.md"
98+
99+
# Remove empty Markdown includes
100+
source "${SCRIPTS_DIR}/cleanupAfterReportGeneration.sh" "${detail_report_include_directory}"
101+
102+
# Collect static Markdown includes (after cleanup to not remove one-liner)
103+
echo "### 2.${report_number} ${language} ${nodeLabel}" > "${detail_report_include_directory}/DeepDiveSectionTitle.md"
104+
echo "" > "${detail_report_include_directory}/empty.md"
105+
cp -f "${ANOMALY_DETECTION_SUMMARY_DIR}/report_no_data_info.template.md" "${detail_report_include_directory}/report_no_data_info.template.md"
106+
cp -f "${detail_report_directory}/Top_anomaly_features.md" "${detail_report_include_directory}" || true
107+
108+
if [ -f "${detail_report_directory}/Anomaly_feature_importance_explained.svg" ] ; then
109+
cp -f "${ANOMALY_DETECTION_SUMMARY_DIR}/report_deep_dive_anomalies_explained.template.md" "${detail_report_include_directory}/report_deep_dive_anomalies_explained.md"
110+
fi
111+
112+
# Use Markdown template to assemble the final deep dive section of the Markdown report and replace variables
113+
cp -f "${ANOMALY_DETECTION_SUMMARY_DIR}/report_deep_dive.template.md" "${detail_report_directory}/report_deep_dive.template.md"
114+
cat "${detail_report_directory}/report_deep_dive.template.md" | "${MARKDOWN_SCRIPTS_DIR}/embedMarkdownIncludes.sh" "${detail_report_include_directory}" > "${detail_report_directory}/report_deep_dive_with_vars.md"
115+
sed "s/{{deep_dive_directory}}/${detail_report_directory_name}/g" "${detail_report_directory}/report_deep_dive_with_vars.md" > "${detail_report_directory}/report_deep_dive_${report_number}.md"
116+
117+
rm -rf "${detail_report_directory}/report_deep_dive_with_vars.md"
118+
rm -rf "${detail_report_directory}/report_deep_dive.template.md"
119+
rm -rf "${detail_report_include_directory}"
120+
121+
# Clean-up after report generation. Empty reports will be deleted.
122+
source "${SCRIPTS_DIR}/cleanupAfterReportGeneration.sh" "${detail_report_directory}"
123+
}
124+
125+
# Run the anomaly detection report generation.
126+
#
127+
# Required Parameters:
128+
# - projection_node_label=...
129+
# Label of the nodes that will be used for the projection. Example: "Package"
130+
# - projection_language=...
131+
# Name of the associated programming language. Examples: "Java", "Typescript"
132+
anomaly_detection_report() {
133+
time anomaly_detection_deep_dive_report "${@}"
134+
}
135+
136+
# Finalize the anomaly detection report by taking the main template, applying includes and appending all deep dive reports
137+
anomaly_detection_finalize_report() {
138+
echo "anomalyDetectionSummary: $(date +'%Y-%m-%dT%H:%M:%S%z') Finalizing ${language} ${nodeLabel} anomaly detection Markdown report..."
139+
140+
report_include_directory="${FULL_REPORT_DIRECTORY}/${MARKDOWN_INCLUDES_DIRECTORY}"
141+
mkdir -p "${report_include_directory}"
142+
143+
# Concatenate all deep dive reports as Markdown include
144+
rm -rf "${report_include_directory}/AnomalyDetectionDeepDive.md"
145+
for markdown_file in $(find . -type f -name 'report_deep_dive_*.md' | sort); do
146+
cat "${markdown_file}" >> "${report_include_directory}/AnomalyDetectionDeepDive.md"
147+
echo "" >> "${report_include_directory}/AnomalyDetectionDeepDive.md"
148+
rm -rf "${markdown_file}"
149+
done
150+
151+
# Remove empty Markdown includes
152+
source "${SCRIPTS_DIR}/cleanupAfterReportGeneration.sh" "${report_include_directory}"
153+
154+
cp -f "${ANOMALY_DETECTION_SUMMARY_DIR}/report.template.md" "${FULL_REPORT_DIRECTORY}/report.template.md"
155+
cat "${FULL_REPORT_DIRECTORY}/report.template.md" | "${MARKDOWN_SCRIPTS_DIR}/embedMarkdownIncludes.sh" "${report_include_directory}" > "${FULL_REPORT_DIRECTORY}/anomaly_detection_report.md"
156+
157+
rm -rf "${FULL_REPORT_DIRECTORY}/report.template.md"
158+
rm -rf "${report_include_directory}"
159+
}
160+
161+
# Create report directory
162+
REPORT_NAME="anomaly-detection"
163+
FULL_REPORT_DIRECTORY="${REPORTS_DIRECTORY}/${REPORT_NAME}"
164+
mkdir -p "${FULL_REPORT_DIRECTORY}"
165+
166+
# Query Parameter key pairs for projection and algorithm side
167+
ALGORITHM_NODE="projection_node_label"
168+
ALGORITHM_LANGUAGE="projection_language"
169+
REPORT_NUMBER="report_number"
170+
171+
# -- Overview Report for all code type -------------------------------
172+
173+
anomaly_detection_report_first_section
174+
175+
# -- Detail Reports for each code type -------------------------------
176+
177+
anomaly_detection_report "${REPORT_NUMBER}=1" "${ALGORITHM_NODE}=Artifact" "${ALGORITHM_LANGUAGE}=Java"
178+
anomaly_detection_report "${REPORT_NUMBER}=2" "${ALGORITHM_NODE}=Package" "${ALGORITHM_LANGUAGE}=Java"
179+
anomaly_detection_report "${REPORT_NUMBER}=3" "${ALGORITHM_NODE}=Type" "${ALGORITHM_LANGUAGE}=Java"
180+
anomaly_detection_report "${REPORT_NUMBER}=4" "${ALGORITHM_NODE}=Module" "${ALGORITHM_LANGUAGE}=Typescript"
181+
182+
# ---------------------------------------------------------------
183+
184+
anomaly_detection_finalize_report
185+
186+
echo "anomalyDetectionSummary: $(date +'%Y-%m-%dT%H:%M:%S%z') Successfully finished."

0 commit comments

Comments
 (0)