From 79a82855abde879d6932650efe72c694ce49ca23 Mon Sep 17 00:00:00 2001
From: Carlo Contavalli
Date: Thu, 17 Nov 2022 11:56:11 -0800
Subject: [PATCH] staco and lib/github: add utility for stable comments. (#772)
Background:
The owner BOT (separate draft PR) requires maintaining
a comment in the PR stream with up to date information
on who the best and necessary reviewers are across all
the changes on the PR. No need to append a new comment
every time a commit is pushed, or something is changed.
But this functionality is useful on it's own to create
"mini dashboards" that are update dynamically in the
comment stream of a PR.
In this PR, I took the code out of that owner bot branch,
and turned it into a free standing utility and library.
Specifically, this PR:
- introduces a "staco" tool to handle stable comments.
See the README under staco to learn how to use it.
- introduces a lib/github directory with some useful
wrappers around github (always enforcing retry, timeout
and making the API slightly more friendly).
- introduces a stablecomment library to programmaticaly
handle stable comments.
If this looks reasonable enough, I want to include
a built in binary of staco in our dev container, and
start updating dashboards from our CI/CD jobs.
---
BUILD.bazel | 1 +
bazel/go_repositories.bzl | 119 +++++--
go.mod | 14 +-
go.sum | 41 +++
lib/github/BUILD.bazel | 31 ++
lib/github/README.md | 15 +
lib/github/stablecomment.go | 503 ++++++++++++++++++++++++++++
lib/github/stablecomment_test.go | 92 +++++
lib/github/wrappers.go | 357 ++++++++++++++++++++
staco/BUILD.bazel | 31 ++
staco/README.md | 362 ++++++++++++++++++++
staco/examples/advanced-closed.png | Bin 0 -> 139896 bytes
staco/examples/advanced-opened.png | Bin 0 -> 421886 bytes
staco/examples/advanced.commands.md | 70 ++++
staco/examples/advanced.json | 107 ++++++
staco/examples/advanced.tpl | 59 ++++
staco/examples/readme-example.json | 25 ++
staco/examples/readme-example.tpl | 11 +
staco/main.go | 338 +++++++++++++++++++
19 files changed, 2152 insertions(+), 24 deletions(-)
create mode 100644 lib/github/BUILD.bazel
create mode 100644 lib/github/README.md
create mode 100644 lib/github/stablecomment.go
create mode 100644 lib/github/stablecomment_test.go
create mode 100644 lib/github/wrappers.go
create mode 100644 staco/BUILD.bazel
create mode 100644 staco/README.md
create mode 100644 staco/examples/advanced-closed.png
create mode 100644 staco/examples/advanced-opened.png
create mode 100644 staco/examples/advanced.commands.md
create mode 100644 staco/examples/advanced.json
create mode 100644 staco/examples/advanced.tpl
create mode 100644 staco/examples/readme-example.json
create mode 100644 staco/examples/readme-example.tpl
create mode 100644 staco/main.go
diff --git a/BUILD.bazel b/BUILD.bazel
index 29923d53..f231dc6c 100644
--- a/BUILD.bazel
+++ b/BUILD.bazel
@@ -60,6 +60,7 @@ multirun(
name = "binaries_release",
commands = [
"//enkit:deploy",
+ "//staco:deploy",
"//faketree:astore_push",
"//flextape/server:astore_push",
# TODO(scott): Move the main to //flextape/client, delete this dir
diff --git a/bazel/go_repositories.bzl b/bazel/go_repositories.bzl
index 30b49e98..02043e2e 100644
--- a/bazel/go_repositories.bzl
+++ b/bazel/go_repositories.bzl
@@ -887,8 +887,8 @@ def go_repositories():
go_repository(
name = "com_github_go_openapi_jsonpointer",
importpath = "github.com/go-openapi/jsonpointer",
- sum = "h1:gihV7YNZK1iK6Tgwwsxo2rJbD1GTbdm72325Bq8FI3w=",
- version = "v0.19.3",
+ sum = "h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=",
+ version = "v0.19.5",
)
go_repository(
name = "com_github_go_openapi_jsonreference",
@@ -905,8 +905,8 @@ def go_repositories():
go_repository(
name = "com_github_go_openapi_swag",
importpath = "github.com/go-openapi/swag",
- sum = "h1:lTz6Ys4CmqqCQmZPBlbQENR1/GucA2bzYTE12Pw4tFY=",
- version = "v0.19.5",
+ sum = "h1:wm0rhTb5z7qpJRHBdPOMuY4QjVUMbF6/kwoYeRAOrKU=",
+ version = "v0.21.1",
)
go_repository(
@@ -1196,6 +1196,11 @@ def go_repositories():
)
go_repository(
name = "com_github_google_cel_go",
+ build_directives = [
+ # We are consistently using proto-generated code, rather than the
+ # genproto pre-generated repo where conflicts exist.
+ "gazelle:resolve go google.golang.org/genproto/googleapis/rpc/status @go_googleapis//google/rpc:status_go_proto",
+ ],
# BUILD file generation needs to be forced on, since:
# * this repo is bazel-aware and comes with BUILD files already
# * we want to override certain import -> dep mappints with directives
@@ -1203,11 +1208,6 @@ def go_repositories():
build_file_generation = "on",
build_naming_convention = "go_default_library",
importpath = "github.com/google/cel-go",
- build_directives = [
- # We are consistently using proto-generated code, rather than the
- # genproto pre-generated repo where conflicts exist.
- "gazelle:resolve go google.golang.org/genproto/googleapis/rpc/status @go_googleapis//google/rpc:status_go_proto",
- ],
sum = "h1:MnUpbcMtr/eA8vRTEYSru+fyCAgGUYLrY/49vUvphbI=",
version = "v0.11.3",
)
@@ -1544,6 +1544,13 @@ def go_repositories():
sum = "h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=",
version = "v1.0.0",
)
+ go_repository(
+ name = "com_github_huandu_xstrings",
+ importpath = "github.com/huandu/xstrings",
+ sum = "h1:4jgBlKK6tLKFvO8u5pmYjG91cqytmDCDvGh7ECVFfFs=",
+ version = "v1.3.1",
+ )
+
go_repository(
name = "com_github_hudl_fargo",
importpath = "github.com/hudl/fargo",
@@ -1561,8 +1568,8 @@ def go_repositories():
go_repository(
name = "com_github_imdario_mergo",
importpath = "github.com/imdario/mergo",
- sum = "h1:UauaLniWCFHWd+Jp9oCEkTBj8VO/9DKg3PV3VCNMDIg=",
- version = "v0.3.9",
+ sum = "h1:3tnifQM4i+fbajXKBHXWEH+KvNHqojZ778UH75j3bGA=",
+ version = "v0.3.11",
)
go_repository(
name = "com_github_improbable_eng_grpc_web",
@@ -1589,6 +1596,18 @@ def go_repositories():
sum = "h1:7tIFeCGmpyrMx9qvT0EgYUi7cxVW48a0mMvnIL17bPM=",
version = "v1.0.7",
)
+ go_repository(
+ name = "com_github_itchyny_gojq",
+ importpath = "github.com/itchyny/gojq",
+ sum = "h1:biKpbKwMxVYhCU1d6mR7qMr3f0Hn9F5k5YykCVb3gmM=",
+ version = "v0.12.9",
+ )
+ go_repository(
+ name = "com_github_itchyny_timefmt_go",
+ importpath = "github.com/itchyny/timefmt-go",
+ sum = "h1:hFEfWVdwsEi+CY8xY2FtgWHGQaBaC3JeHd+cve0ynVM=",
+ version = "v0.1.4",
+ )
go_repository(
name = "com_github_jbenet_go_context",
@@ -1659,6 +1678,19 @@ def go_repositories():
sum = "h1:VKV+ZcuP6l3yW9doeqz6ziZGgcynBVQO+obU0+0hcPo=",
version = "v0.1.0",
)
+ go_repository(
+ name = "com_github_josephburnett_jd",
+ importpath = "github.com/josephburnett/jd",
+ sum = "h1:Uzqhcje4WqvVyp85F3Oj0ezISPTlnhnr/KaLZIy8qh0=",
+ version = "v1.6.1",
+ )
+ go_repository(
+ name = "com_github_josharian_intern",
+ importpath = "github.com/josharian/intern",
+ sum = "h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=",
+ version = "v1.0.0",
+ )
+
go_repository(
name = "com_github_jpillora_backoff",
importpath = "github.com/jpillora/backoff",
@@ -1865,8 +1897,8 @@ def go_repositories():
go_repository(
name = "com_github_mailru_easyjson",
importpath = "github.com/mailru/easyjson",
- sum = "h1:aizVhC/NAAcKWb+5QsU1iNOZb4Yws5UO2I+aIprQITM=",
- version = "v0.7.0",
+ sum = "h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=",
+ version = "v0.7.7",
)
go_repository(
@@ -1875,12 +1907,32 @@ def go_repositories():
sum = "h1:QtJ5ZjqapShm0w5DosRjg0PRlSdAdlx+W6cCKoALdbQ=",
version = "v1.0.1",
)
+ go_repository(
+ name = "com_github_masterminds_goutils",
+ importpath = "github.com/Masterminds/goutils",
+ sum = "h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=",
+ version = "v1.1.1",
+ )
+
go_repository(
name = "com_github_masterminds_semver",
importpath = "github.com/Masterminds/semver",
sum = "h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww=",
version = "v1.5.0",
)
+ go_repository(
+ name = "com_github_masterminds_semver_v3",
+ importpath = "github.com/Masterminds/semver/v3",
+ sum = "h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc=",
+ version = "v3.1.1",
+ )
+ go_repository(
+ name = "com_github_masterminds_sprig_v3",
+ importpath = "github.com/Masterminds/sprig/v3",
+ sum = "h1:17jRggJu518dr3QaafizSXOjKYp94wKfABxUmyxvxX8=",
+ version = "v3.2.2",
+ )
+
go_repository(
name = "com_github_matoous_godox",
importpath = "github.com/matoous/godox",
@@ -1897,15 +1949,15 @@ def go_repositories():
go_repository(
name = "com_github_mattn_go_isatty",
importpath = "github.com/mattn/go-isatty",
- sum = "h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=",
- version = "v0.0.14",
+ sum = "h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=",
+ version = "v0.0.16",
)
go_repository(
name = "com_github_mattn_go_runewidth",
importpath = "github.com/mattn/go-runewidth",
- sum = "h1:Ei8KR0497xHyKJPAv59M1dkC+rOZCMBJ+t3fZ+twI54=",
- version = "v0.0.7",
+ sum = "h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=",
+ version = "v0.0.13",
)
go_repository(
name = "com_github_mattn_go_sqlite3",
@@ -1977,6 +2029,12 @@ def go_repositories():
sum = "h1:tEElEatulEHDeedTxwckzyYMA5c86fbmNIUL1hBIiTg=",
version = "v1.1.0",
)
+ go_repository(
+ name = "com_github_mitchellh_copystructure",
+ importpath = "github.com/mitchellh/copystructure",
+ sum = "h1:Laisrj+bAB6b/yJwB5Bt3ITZhGJdqmxquMKeZ+mmkFQ=",
+ version = "v1.0.0",
+ )
go_repository(
name = "com_github_mitchellh_go_homedir",
@@ -2015,6 +2073,13 @@ def go_repositories():
sum = "h1:6h7AQ0yhTcIsmFmnAwQls75jp2Gzs4iB8W7pjMO+rqo=",
version = "v1.4.2",
)
+ go_repository(
+ name = "com_github_mitchellh_reflectwalk",
+ importpath = "github.com/mitchellh/reflectwalk",
+ sum = "h1:9D+8oIskB4VJBN5SFlmc27fSlIBZaov1Wpk/IfikLNY=",
+ version = "v1.0.0",
+ )
+
go_repository(
name = "com_github_modern_go_concurrent",
importpath = "github.com/modern-go/concurrent",
@@ -2454,6 +2519,12 @@ def go_repositories():
sum = "h1:9ZKAASQSHhDYGoxY8uLVpewe1GDZ2vu2Tr/vTdVAkFQ=",
version = "v0.0.0-20181016184325-3113b8401b8a",
)
+ go_repository(
+ name = "com_github_rivo_uniseg",
+ importpath = "github.com/rivo/uniseg",
+ sum = "h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=",
+ version = "v0.2.0",
+ )
go_repository(
name = "com_github_robertkrimen_godocdown",
@@ -2586,6 +2657,12 @@ def go_repositories():
sum = "h1:TKdv8HiTLgE5wdJuEML90aBgNWsokNbMijUGhmcoBJc=",
version = "v2.1.4+incompatible",
)
+ go_repository(
+ name = "com_github_shopspring_decimal",
+ importpath = "github.com/shopspring/decimal",
+ sum = "h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ=",
+ version = "v1.2.0",
+ )
go_repository(
name = "com_github_shurcool_go",
@@ -2673,8 +2750,8 @@ def go_repositories():
go_repository(
name = "com_github_spf13_cast",
importpath = "github.com/spf13/cast",
- sum = "h1:oget//CVOEoFewqQxwr0Ej5yjygnqGkvggSE/gB35Q8=",
- version = "v1.3.0",
+ sum = "h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng=",
+ version = "v1.3.1",
)
go_repository(
name = "com_github_spf13_cobra",
@@ -3777,8 +3854,8 @@ def go_repositories():
go_repository(
name = "in_gopkg_yaml_v3",
importpath = "gopkg.in/yaml.v3",
- sum = "h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=",
- version = "v3.0.0-20210107192922-496545a6307b",
+ sum = "h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=",
+ version = "v3.0.1",
)
go_repository(
diff --git a/go.mod b/go.mod
index f18022ff..bcc411d8 100644
--- a/go.mod
+++ b/go.mod
@@ -8,6 +8,7 @@ require (
cloud.google.com/go/storage v1.27.0
github.com/GoogleCloudPlatform/cloud-build-notifiers v0.0.0-20221005190102-4a3420d331aa // indirect
github.com/KohlsTechnology/prometheus_bigquery_remote_storage_adapter v0.4.6 // indirect
+ github.com/Masterminds/sprig/v3 v3.2.2 // indirect
github.com/Microsoft/go-winio v0.4.16 // indirect
github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 // indirect
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6 // indirect
@@ -45,9 +46,12 @@ require (
github.com/google/go-querystring v1.0.0 // indirect
github.com/google/uuid v1.3.0
github.com/googleapis/go-type-adapters v1.0.0 // indirect
+ github.com/googleapis/enterprise-certificate-proxy v0.1.0 // indirect
github.com/gorilla/websocket v1.4.2
github.com/improbable-eng/grpc-web v0.13.0
github.com/jackpal/gateway v1.0.7 // indirect
+ github.com/itchyny/gojq v0.12.9 // indirect
+ github.com/josephburnett/jd v1.6.1
github.com/kataras/muxie v1.1.1
github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f
github.com/klauspost/compress v1.15.1 // indirect
@@ -78,11 +82,15 @@ require (
github.com/xor-gate/ar v0.0.0-20170530204233-5c72ae81e2b7
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77 // indirect
go.uber.org/zap v1.19.1
- golang.org/x/crypto v0.0.0-20210921155107-089bfa567519
- golang.org/x/net v0.0.0-20221014081412-f15817d10f9b
- golang.org/x/oauth2 v0.0.0-20221014153046-6fdb5e3db783
+ golang.org/x/crypto v0.0.0-20210915214749-c084706c2272
+ // BUG(INFRA-1801): Last version that supports go1.16 is golang.org/x/net v0.0.0-20211020060615-d418f374d309
+ golang.org/x/net v0.0.0-20211020060615-d418f374d309
+ golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8
+ golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f // indirect
+ // BUG(INFRA-1801): Last version that supports go1.16 is golang.org/x/sys v0.0.0-20220908164124-27713097b956
golang.org/x/sys v0.0.0-20220908164124-27713097b956 // indirect
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect
+ golang.org/x/tools v0.1.9 // indirect
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
google.golang.org/api v0.100.0
google.golang.org/genproto v0.0.0-20221027153422-115e99e71e1c // indirect
diff --git a/go.sum b/go.sum
index 09ba9739..f0db2bd1 100644
--- a/go.sum
+++ b/go.sum
@@ -360,8 +360,15 @@ github.com/Julusian/godocdown v0.0.0-20170816220326-6d19f8ff2df8/go.mod h1:INZr5
github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0=
github.com/KohlsTechnology/prometheus_bigquery_remote_storage_adapter v0.4.6 h1:Yw2ohEXrwpz8oknYWL1F7INyA4OH3b9qY88jxaWMjwU=
github.com/KohlsTechnology/prometheus_bigquery_remote_storage_adapter v0.4.6/go.mod h1:1ajJ2xaTiP286uCASdAPCob25tVU3iiSSY5sh8lP/0Q=
+github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
+github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww=
github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y=
+github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc=
+github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
+github.com/Masterminds/sprig v2.22.0+incompatible h1:z4yfnGrZ7netVz+0EDJ0Wi+5VZCSYp4Z0m2dk6cEM60=
+github.com/Masterminds/sprig/v3 v3.2.2 h1:17jRggJu518dr3QaafizSXOjKYp94wKfABxUmyxvxX8=
+github.com/Masterminds/sprig/v3 v3.2.2/go.mod h1:UoaO7Yp8KlPnJIYWTFkMaqPUYKTfGFPhxNuwnnxkKlk=
github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA=
github.com/Microsoft/go-winio v0.4.16 h1:FtSW/jqD+l4ba5iPBj9CODVtgfYAD8w2wS923g/cFDk=
github.com/Microsoft/go-winio v0.4.16/go.mod h1:XB6nPKklQyQ7GC9LdcBEcBl8PF76WugXOPRXwdLnMv0=
@@ -404,6 +411,9 @@ github.com/andybalholm/brotli v1.0.3/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHG
github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239 h1:kFOfPq6dUM1hTo4JG6LR5AXSUEsOjtdm0kw0FtQtMJA=
github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
+github.com/antlr/antlr4 v0.0.0-20200503195918-621b933c7a7f/go.mod h1:T7PbCXFs94rrTttyxjbyT5+/1V8T2TYDejxUfHJjw1Y=
+github.com/antlr/antlr4 v0.0.0-20210404160547-4dfacf63e228/go.mod h1:T7PbCXFs94rrTttyxjbyT5+/1V8T2TYDejxUfHJjw1Y=
+github.com/antlr/antlr4/runtime/Go/antlr v0.0.0-20220418222510-f25a4f6275ed/go.mod h1:F7bn7fEU90QkQ3tnmaTx3LTKLEDqnwWODIYppRQ5hnY=
github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ=
github.com/apache/thrift v0.13.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ=
github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e h1:QEF07wC0T1rKkctt1RINW/+RMTVmiwxETico2l3gxJA=
@@ -653,6 +663,7 @@ github.com/go-ole/go-ole v1.2.4/go.mod h1:XCwSNxSkXRo4vlyPy93sltvi/qJq0jqQhjqQNI
github.com/go-openapi/jsonpointer v0.0.0-20160704185906-46af16f9f7b1/go.mod h1:+35s3my2LFTysnkMfxsJBAMHj/DoqoB9knIWoYG/Vk0=
github.com/go-openapi/jsonpointer v0.19.2/go.mod h1:3akKfEdA7DF1sugOqz1dVQHBcuDBPKZGEoHC/NkiQRg=
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
+github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonreference v0.0.0-20160704190145-13c6e3589ad9/go.mod h1:W3Z9FmVs9qj+KR4zFKmDPGiLdk1D9Rlm7cyMvf57TTg=
github.com/go-openapi/jsonreference v0.19.2/go.mod h1:jMjeRr2HHw6nAVajTXJ4eiUwohSTlpa0o73RUL1owJc=
github.com/go-openapi/jsonreference v0.19.3/go.mod h1:rjx6GuL8TTa9VaixXglHmQmIL98+wF9xc8zWvFonSJ8=
@@ -661,6 +672,7 @@ github.com/go-openapi/spec v0.19.3/go.mod h1:FpwSN1ksY1eteniUU7X0N/BgJ7a4WvBFVA8
github.com/go-openapi/swag v0.0.0-20160704191624-1d0bd113de87/go.mod h1:DXUve3Dpr1UfpPtxFw+EFuQ41HhCWZfha5jSVRG7C7I=
github.com/go-openapi/swag v0.19.2/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
+github.com/go-openapi/swag v0.21.1/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
github.com/go-sql-driver/mysql v1.4.0 h1:7LxgVwFb2hIQtMm87NdgAVfXjnt4OePseqT1tKx+opk=
github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=
@@ -712,6 +724,7 @@ github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzw
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
+github.com/golang/glog v1.0.0/go.mod h1:EWib/APOK0SL3dFbYqvxE3UYd8E6s1ouQ7iEp/0LWV4=
github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@@ -972,6 +985,8 @@ github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/J
github.com/hashicorp/serf v0.9.5/go.mod h1:UWDWwZeL5cuWDJdl0C6wrvrUwEqtQ4ZKBKKENpqIUyk=
github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
+github.com/huandu/xstrings v1.3.1 h1:4jgBlKK6tLKFvO8u5pmYjG91cqytmDCDvGh7ECVFfFs=
+github.com/huandu/xstrings v1.3.1/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/hudl/fargo v1.3.0/go.mod h1:y3CKSmjA+wD2gak7sUSXTAoopbhU08POFhmITJgmKTg=
github.com/hudl/fargo v1.4.0/go.mod h1:9Ai6uvFy5fQNq6VPKtg+Ceq1+eTY4nKUlR2JElEOcDo=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6 h1:UDMh68UUwekSh5iP2OMhRRZJiiBccgV7axzUG8vi56c=
@@ -980,6 +995,8 @@ github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:
github.com/imdario/mergo v0.3.5/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
github.com/imdario/mergo v0.3.9 h1:UauaLniWCFHWd+Jp9oCEkTBj8VO/9DKg3PV3VCNMDIg=
github.com/imdario/mergo v0.3.9/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
+github.com/imdario/mergo v0.3.11 h1:3tnifQM4i+fbajXKBHXWEH+KvNHqojZ778UH75j3bGA=
+github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
github.com/improbable-eng/grpc-web v0.13.0 h1:7XqtaBWaOCH0cVGKHyvhtcuo6fgW32Y10yRKrDHFHOc=
github.com/improbable-eng/grpc-web v0.13.0/go.mod h1:6hRR09jOEG81ADP5wCQju1z71g6OL4eEvELdran/3cs=
github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
@@ -988,6 +1005,10 @@ github.com/influxdata/influxdb1-client v0.0.0-20191209144304-8bf82d3c094d/go.mod
github.com/influxdata/influxdb1-client v0.0.0-20200827194710-b269163b24ab/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo=
github.com/jackpal/gateway v1.0.7 h1:7tIFeCGmpyrMx9qvT0EgYUi7cxVW48a0mMvnIL17bPM=
github.com/jackpal/gateway v1.0.7/go.mod h1:aRcO0UFKt+MgIZmRmvOmnejdDT4Y1DNiNOsSd1AcIbA=
+github.com/itchyny/gojq v0.12.9 h1:biKpbKwMxVYhCU1d6mR7qMr3f0Hn9F5k5YykCVb3gmM=
+github.com/itchyny/gojq v0.12.9/go.mod h1:T4Ip7AETUXeGpD+436m+UEl3m3tokRgajd5pRfsR5oE=
+github.com/itchyny/timefmt-go v0.1.4 h1:hFEfWVdwsEi+CY8xY2FtgWHGQaBaC3JeHd+cve0ynVM=
+github.com/itchyny/timefmt-go v0.1.4/go.mod h1:nEP7L+2YmAbT2kZ2HfSs1d8Xtw9LY8D2stDBckWakZ8=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/jessevdk/go-flags v1.4.0 h1:4IU2WS7AumrZ/40jfhf4QVDMsQwqA7VEHozFRrGARJA=
@@ -1008,6 +1029,8 @@ github.com/jmoiron/sqlx v1.2.0/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhB
github.com/joefitzgerald/rainbow-reporter v0.1.0/go.mod h1:481CNgqmVHQZzdIbN52CupLJyoVwB10FQ/IQlF1pdL8=
github.com/jonboulle/clockwork v0.1.0 h1:VKV+ZcuP6l3yW9doeqz6ziZGgcynBVQO+obU0+0hcPo=
github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
+github.com/josephburnett/jd v1.6.1/go.mod h1:R8ZnZnLt2D4rhW4NvBc/USTo6mzyNT6fYNIIWOJA9GY=
+github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
github.com/json-iterator/go v1.1.6 h1:MrUvLMLTMxbqFJ9kzlvat/rYZqZnW3u4wkLzWTaFwKs=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
@@ -1091,6 +1114,8 @@ github.com/mailru/easyjson v0.0.0-20160728113105-d5b7844b561a/go.mod h1:C1wdFJiN
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.7.0/go.mod h1:KAzv3t3aY1NaHWoQz1+4F1ccyAH66Jk7yos7ldAVICs=
+github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
+github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/maratori/testpackage v1.0.1 h1:QtJ5ZjqapShm0w5DosRjg0PRlSdAdlx+W6cCKoALdbQ=
github.com/maratori/testpackage v1.0.1/go.mod h1:ddKdw+XG0Phzhx8BFDTKgpWP4i7MpApTE5fXSKAqwDU=
github.com/matoous/godox v0.0.0-20210227103229-6504466cf951 h1:pWxk9e//NbPwfxat7RXkts09K+dEBJWakUWwICVqYbA=
@@ -1111,9 +1136,11 @@ github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHX
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
+github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
github.com/mattn/go-runewidth v0.0.7 h1:Ei8KR0497xHyKJPAv59M1dkC+rOZCMBJ+t3fZ+twI54=
github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
+github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.9.0 h1:pDRiWfl+++eC2FEFRy6jXmQlvp4Yh3z1MJKg4UeYM/4=
github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mattn/goveralls v0.0.2 h1:7eJB6EqsPhRVxvwEXGnqdO2sJI0PTsrWoTMXEk9/OQc=
@@ -1137,6 +1164,8 @@ github.com/minio/highwayhash v1.0.2/go.mod h1:BQskDq+xkJ12lmlUUi7U0M5Swg3EWR+dLT
github.com/mitchellh/cli v1.0.0 h1:iGBIsUe3+HZ/AD/Vd7DErOt5sU9fa8Uj7A2s1aggv1Y=
github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI=
+github.com/mitchellh/copystructure v1.0.0 h1:Laisrj+bAB6b/yJwB5Bt3ITZhGJdqmxquMKeZ+mmkFQ=
+github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw=
github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
@@ -1155,6 +1184,8 @@ github.com/mitchellh/mapstructure v1.3.3 h1:SzB1nHZ2Xi+17FP0zVQBHIZqvwRN9408fJO8
github.com/mitchellh/mapstructure v1.3.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mitchellh/mapstructure v1.4.2 h1:6h7AQ0yhTcIsmFmnAwQls75jp2Gzs4iB8W7pjMO+rqo=
github.com/mitchellh/mapstructure v1.4.2/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/mitchellh/reflectwalk v1.0.0 h1:9D+8oIskB4VJBN5SFlmc27fSlIBZaov1Wpk/IfikLNY=
+github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -1344,6 +1375,7 @@ github.com/quasilyte/go-ruleguard/rules v0.0.0-20201231183845-9e62ed36efe1/go.mo
github.com/quasilyte/regex/syntax v0.0.0-20200407221936-30656e2c4a95 h1:L8QM9bvf68pVdQ3bCFZMDmnt9yqcMBro1pC7F+IPYMY=
github.com/quasilyte/regex/syntax v0.0.0-20200407221936-30656e2c4a95/go.mod h1:rlzQ04UMyJXu/aOvhd8qT+hvDrFpiwqp8MRXDY9szc0=
github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/robertkrimen/godocdown v0.0.0-20130622164427-0bfa04905481/go.mod h1:C9WhFzY47SzYBIvzFqSvHIR6ROgDo4TtdTuRaOMjF/s=
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af h1:gu+uRPtBe88sKxUCEXRoeCvVG90TJmwhiqRpvdhQFng=
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
@@ -1381,6 +1413,8 @@ github.com/shirou/gopsutil/v3 v3.21.1 h1:dA72XXj5WOXIZkAL2iYTKRVcNOOqh4yfLn9Rm7t
github.com/shirou/gopsutil/v3 v3.21.1/go.mod h1:igHnfak0qnw1biGeI2qKQvu0ZkwvEkUcCLlYhZzdr/4=
github.com/shirou/w32 v0.0.0-20160930032740-bb4de0191aa4 h1:udFKJ0aHUL60LboW/A+DfgoHVedieIzIXE8uylPue0U=
github.com/shirou/w32 v0.0.0-20160930032740-bb4de0191aa4/go.mod h1:qsXQc7+bwAM3Q1u/4XEfrquwF8Lw7D7y5cD8CuHnfIc=
+github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ=
+github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e h1:MZM7FHLqUHYI0Y/mQAt3d2aYa0SiNms/hFqC9qJYolM=
github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk=
github.com/shurcooL/go-goon v0.0.0-20170922171312-37c2f522c041 h1:llrF3Fs4018ePo4+G/HV/uQUqEI1HMDjCeOf2V6puPc=
@@ -1415,6 +1449,8 @@ github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTd
github.com/spf13/cast v1.2.0/go.mod h1:r2rcYCSwa1IExKTDiTfzaxqT2FNHs8hODu4LnUfgKEg=
github.com/spf13/cast v1.3.0 h1:oget//CVOEoFewqQxwr0Ej5yjygnqGkvggSE/gB35Q8=
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
+github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng=
+github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
github.com/spf13/cobra v1.0.0 h1:6m/oheQuQ13N9ks4hubMG6BnvwOeaJrqSPLahSnczz8=
github.com/spf13/cobra v1.0.0/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE=
@@ -1578,6 +1614,7 @@ golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/crypto v0.0.0-20200414173820-0848c9571904/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a h1:vclmkQCjlDX5OydZ9wv8rBCcS0QyQY66Mpf/7BZbInM=
golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
@@ -1892,6 +1929,8 @@ golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220624220833-87e55d714810/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220829200755-d48e67d00261/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220908164124-27713097b956 h1:XeJjHH1KiLpKGb6lvMiksZ9l0fVUh+AmGcm0nOMEBOY=
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220909162455-aba9fc2a8ff2 h1:wM1k/lXfpc5HdkJJyW9GELpd8ERGdnh8sMGL6Gzq3Ho=
@@ -2392,8 +2431,10 @@ gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8=
honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
diff --git a/lib/github/BUILD.bazel b/lib/github/BUILD.bazel
new file mode 100644
index 00000000..acf0797f
--- /dev/null
+++ b/lib/github/BUILD.bazel
@@ -0,0 +1,31 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test")
+
+go_library(
+ name = "go_default_library",
+ srcs = [
+ "stablecomment.go",
+ "wrappers.go",
+ ],
+ importpath = "github.com/enfabrica/enkit/lib/github",
+ visibility = ["//visibility:public"],
+ deps = [
+ "//lib/kflags:go_default_library",
+ "//lib/logger:go_default_library",
+ "//lib/retry:go_default_library",
+ "@com_github_google_go_github//github:go_default_library",
+ "@com_github_itchyny_gojq//:go_default_library",
+ "@com_github_josephburnett_jd//lib:go_default_library",
+ "@com_github_masterminds_sprig_v3//:go_default_library",
+ "@org_golang_x_oauth2//:go_default_library",
+ ],
+)
+
+go_test(
+ name = "go_default_test",
+ srcs = ["stablecomment_test.go"],
+ embed = [":go_default_library"],
+ deps = [
+ "@com_github_josephburnett_jd//lib:go_default_library",
+ "@com_github_stretchr_testify//assert:go_default_library",
+ ],
+)
diff --git a/lib/github/README.md b/lib/github/README.md
new file mode 100644
index 00000000..091f80af
--- /dev/null
+++ b/lib/github/README.md
@@ -0,0 +1,15 @@
+[![Go Reference](https://pkg.go.dev/badge/github.com/enfabrica/enkit/lib/github.svg)](https://pkg.go.dev/github.com/enfabrica/enkit/lib/github)
+
+# Overview
+
+This library provides methods and objects to easily perform
+common operations on github.
+
+Specifically, it provides a simplified github client that
+always enforces timeouts, implements retries, and handles
+the pagination.
+
+It also provides a library to handle and create "stable
+comments": comments appended to PRs used as mini-dashboards
+that are updated as your CI/CD or automation progresses
+through its work.
diff --git a/lib/github/stablecomment.go b/lib/github/stablecomment.go
new file mode 100644
index 00000000..cab6a808
--- /dev/null
+++ b/lib/github/stablecomment.go
@@ -0,0 +1,503 @@
+package github
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "time"
+ "github.com/enfabrica/enkit/lib/kflags"
+ "github.com/enfabrica/enkit/lib/logger"
+ "github.com/josephburnett/jd/lib"
+ "github.com/Masterminds/sprig/v3"
+ "github.com/itchyny/gojq"
+ "os"
+ "regexp"
+ "text/template"
+)
+
+// StableComment is an object to manipulate and process stable github comments.
+//
+// A "stable" github comment is a comment attached to a PR that is periodically
+// updated to show some important information about the PR.
+//
+// For example, it can be used by a BOT to compute a list of reviewers, and
+// as the PR is updated with other commits, the list of reviewers is updated.
+//
+// Or, as a CI job progresses, it can be used to post links or information
+// about the status of the BUILD (useful links, detected errors, etc).
+//
+// A naive BOT would just add new comments to a github PR, creating a poor
+// user experience.
+//
+// StableComment will instead post one comment, and then keep updating it.
+//
+// The update operations support rendering a template with json, and allow
+// for the state of the previous comment to be maintained.
+//
+// For example: let's say that a CI job is running. At the beginning of
+// the run it creates a "StableComment", made by a template rendering
+// a list of json operations.
+//
+// As the CI job continues, the "StableComment" API is invoked through
+// independent CLI invocations (a "stateless" job - not a daemon),
+// specifying a PATCH adding an operation to the previously posted
+// json, causing operations to be added. The PATCH could look something
+// like:
+// [{"op":"add","path":"/operations/-","value":"{ ... json ...}"}]
+// Appending an element to an existing list.
+type StableComment struct {
+ marker string
+ matcher *regexp.Regexp
+ log logger.Logger
+
+ id int64
+ jsoncontent string
+ jsonreset bool
+ template string
+}
+
+type CommentPayload struct {
+ Template string
+ Content string
+}
+
+// A unique string to ensure it's a comment added by this software.
+// Note that a unique marker is also appended. Goats are probably enough here.
+const kUniqueEnoughString = "A wise goat once said: "
+
+type StableCommentModifier func(*StableComment) error
+
+type StableCommentModifiers []StableCommentModifier
+
+func (ms StableCommentModifiers) Apply(sc *StableComment) error {
+ for _, mod := range ms {
+ if err := mod(sc); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func WithMarker(marker string) StableCommentModifier {
+ return func(sc *StableComment) error {
+ sc.marker = marker
+ return nil
+ }
+}
+
+func WithTemplate(template string) StableCommentModifier {
+ return func(sc *StableComment) error {
+ sc.template = template
+ return nil
+ }
+}
+
+func WithLogger(logger logger.Logger) StableCommentModifier {
+ return func(sc *StableComment) error {
+ sc.log = logger
+ return nil
+ }
+}
+
+func WithJsonContent(content string) StableCommentModifier {
+ return func(sc *StableComment) error {
+ sc.jsoncontent = content
+ return nil
+ }
+}
+
+func WithJsonReset(reset bool) StableCommentModifier {
+ return func(sc *StableComment) error {
+ sc.jsonreset = reset
+ return nil
+ }
+}
+
+func WithID(id int64) StableCommentModifier {
+ return func(sc *StableComment) error {
+ sc.id = id
+ return nil
+ }
+}
+
+type StableCommentFlags struct {
+ Marker string
+ Template string
+ JsonContent string
+ JsonReset bool
+}
+
+var DefaultMarker = "staco-unfortunate-id"
+
+func DefaultStableCommentFlags() *StableCommentFlags {
+ flags := &StableCommentFlags{
+ Marker: DefaultMarker,
+ JsonContent: "{}",
+ }
+ return flags
+}
+
+func (fl *StableCommentFlags) Register(set kflags.FlagSet, prefix string) *StableCommentFlags {
+ set.StringVar(&fl.Marker, prefix+"marker", fl.Marker, "A unique marker to identify the comment across subsequent runs of this command")
+ set.StringVar(&fl.Template, prefix+"template", fl.Template, "Message to post in the comment, a text/template valorized through the json flag")
+ set.StringVar(&fl.JsonContent, prefix+"json", fl.JsonContent, "JSON providing the default values for the text/template specified")
+ set.BoolVar(&fl.JsonReset, prefix+"reset", fl.JsonReset, "Ignore the JSON parsed from the PR, start over with the default json in --json")
+ return fl
+}
+
+type StableCommentDiffFlags struct {
+ // Native jd format patch - as per https://github.com/josephburnett/jd#diff-language
+ Diff string
+ // RFC 7386 format.
+ Patch string
+ // RFC 6902 format.
+ Merge string
+}
+
+func DefaultStableCommentDiffFlags() *StableCommentDiffFlags {
+ return &StableCommentDiffFlags{}
+}
+
+func (fl *StableCommentDiffFlags) Register(set kflags.FlagSet, prefix string) *StableCommentDiffFlags {
+ set.StringVar(&fl.Diff, prefix+"diff-jd", fl.Diff, "A change to apply in jd format - https://github.com/josephburnett/jd#diff-language")
+ set.StringVar(&fl.Patch, prefix+"diff-patch", fl.Patch, "A change to apply in RFC 7386 format (patch format)")
+ set.StringVar(&fl.Merge, prefix+"diff-merge", fl.Patch, "A change to apply in RFC 6902 format (merge format)")
+
+ return fl
+}
+
+func NewDiffFromFlags(fl *StableCommentDiffFlags) (jd.Diff, error) {
+ if (fl.Diff != "" && fl.Patch != "") || (fl.Diff != "" && fl.Merge != "") || (fl.Patch != "" && fl.Merge != "") {
+ return nil, kflags.NewUsageErrorf("only one of --diff-jd, --diff-patch, and --diff-merge must be specified")
+ }
+
+ if fl.Diff != "" {
+ return jd.ReadDiffString(fl.Diff)
+ }
+ if fl.Patch != "" {
+ return jd.ReadPatchString(fl.Patch)
+ }
+ if fl.Merge != "" {
+ return jd.ReadMergeString(fl.Merge)
+ }
+ return nil, nil
+}
+
+type DiffTransformer jd.Diff
+
+func (dt *DiffTransformer) Apply(ijson string) (string, error) {
+ jc, err := jd.ReadJsonString(ijson)
+ if err != nil {
+ return "", err
+ }
+ if dt == nil {
+ return jc.Json(), nil
+ }
+
+ jp, err := jc.Patch(jd.Diff(*dt))
+ if err != nil {
+ return "", err
+ }
+
+ return jp.Json(), nil
+}
+
+type StableCommentJqFlags struct {
+ Timeout time.Duration
+ Code string
+}
+
+func DefaultStableCommentJqFlags() *StableCommentJqFlags {
+ return &StableCommentJqFlags{
+ Timeout: time.Second * 1,
+ }
+}
+
+func (fl *StableCommentJqFlags) Register(set kflags.FlagSet, prefix string) *StableCommentJqFlags {
+ set.DurationVar(&fl.Timeout, prefix+"jq-timeout", fl.Timeout, "How long to wait at most for the jq program to terminate")
+ set.StringVar(&fl.Code, prefix+"jq-code", fl.Code, "The actual jq program to run")
+ return fl
+}
+
+type JqTransformer struct {
+ code *gojq.Code
+ timeout time.Duration
+}
+
+func (jt *JqTransformer) Apply(ijson string) (string, error) {
+ if jt == nil || jt.code == nil {
+ return ijson, nil
+ }
+
+ var pjson map[string]interface{}
+ if err := json.Unmarshal([]byte(ijson), &pjson); err != nil {
+ return "", err
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), jt.timeout)
+ defer cancel()
+
+ results := jt.code.RunWithContext(ctx, pjson)
+
+ first, ok := results.Next()
+ if !ok {
+ return "", fmt.Errorf("jq script returned no value")
+ }
+ if err, ok := first.(error); ok {
+ return "", fmt.Errorf("jq script execution returned error - %w", err)
+ }
+
+ second, ok := results.Next()
+ if ok {
+ return "", fmt.Errorf("jq script returned too many values - %#v", second)
+ }
+
+ omap, ok := first.(map[string]interface{})
+ if !ok {
+ return "", fmt.Errorf("jq script returned something that's not a map[] - %#v", first)
+ }
+ ojson, err := json.Marshal(omap)
+ if err != nil {
+ return "", fmt.Errorf("jq returned something that cannot be marshalled - %#v", omap)
+ }
+ return string(ojson), nil
+}
+
+func NewJqFromFlags(jqf *StableCommentJqFlags) (*JqTransformer, error) {
+ if jqf.Code == "" {
+ return nil, nil
+ }
+ q, err := gojq.Parse(jqf.Code)
+ if err != nil {
+ return nil, err
+ }
+
+ code, err := gojq.Compile(q, gojq.WithEnvironLoader(os.Environ))
+ if err != nil {
+ return nil, err
+ }
+ return &JqTransformer{code: code, timeout: jqf.Timeout}, nil
+}
+
+type Transformer interface {
+ Apply(inputjson string) (string, error)
+}
+
+type StableCommentTransformerFlags struct {
+ jqFlags *StableCommentJqFlags
+ diffFlags *StableCommentDiffFlags
+}
+
+func DefaultStableCommentTransformerFlags() *StableCommentTransformerFlags {
+ return &StableCommentTransformerFlags{
+ jqFlags: DefaultStableCommentJqFlags(),
+ diffFlags: DefaultStableCommentDiffFlags(),
+ }
+}
+
+func (fl *StableCommentTransformerFlags) Register(
+ set kflags.FlagSet, prefix string) *StableCommentTransformerFlags {
+ fl.jqFlags.Register(set, prefix)
+ fl.diffFlags.Register(set, prefix)
+ return fl
+}
+
+func NewTransformerFromFlags(fl *StableCommentTransformerFlags) (Transformer, error) {
+ jq, err := NewJqFromFlags(fl.jqFlags)
+ if err != nil {
+ return nil, err
+ }
+
+ diff, err := NewDiffFromFlags(fl.diffFlags)
+ if err != nil {
+ return nil, err
+ }
+
+ if jq != nil {
+ return jq, nil
+ }
+ return (*DiffTransformer)(&diff), nil
+}
+
+func NewStableComment(mods ...StableCommentModifier) (*StableComment, error) {
+ sc := &StableComment{
+ jsoncontent: "{}",
+ marker: DefaultMarker,
+ log: logger.Nil,
+ }
+ if err := StableCommentModifiers(mods).Apply(sc); err != nil {
+ return nil, err
+ }
+
+ match, err := regexp.Compile("(?m)")
+ if err != nil {
+ return nil, err
+ }
+ sc.matcher = match
+ return sc, nil
+}
+
+func StableCommentFromFlags(fl *StableCommentFlags) StableCommentModifier {
+ return func(sc *StableComment) error {
+ if fl.Marker != "" {
+ sc.marker = fl.Marker
+ }
+ sc.template = fl.Template
+ sc.jsoncontent = fl.JsonContent
+ sc.jsonreset = fl.JsonReset
+
+ return nil
+ }
+}
+
+func (sc *StableComment) UpdateFromPR(rc *RepoClient, pr int) error {
+ id, payload, template, err := sc.FetchPRState(rc, pr)
+ if err != nil {
+ return err
+ }
+
+ sc.id = id
+ if payload != "" && !sc.jsonreset {
+ sc.jsoncontent = payload
+ }
+ if sc.template == "" {
+ sc.template = template
+ }
+ return nil
+}
+
+func (sc *StableComment) FetchPRState(rc *RepoClient, pr int) (int64, string, string, error) {
+ comments, err := rc.GetPRComments(pr)
+ if err != nil {
+ return 0, "", "", err
+ }
+
+ for _, comment := range comments {
+ if comment.Body == nil || comment.ID == nil {
+ continue
+ }
+
+ payload, template, err := sc.ParseComment(*comment.Body)
+ if err != nil {
+ // If there's a wrapped error, it means parsing json or templates failed.
+ // Log the error, but otherwise re-use this comment. It was corrupted.
+ if errors.Unwrap(err) == nil {
+ continue
+ }
+
+ sc.log.Warnf("PR %d - Corrupted comment %d? %s", pr, *comment.ID, err)
+ }
+ return *comment.ID, payload, template, nil
+ }
+
+ // NOT FOUND - no defaults.
+ return 0, "", "", nil
+}
+
+// ParseComment parses a string comment.
+//
+// The string comment is normally retrieved from github,
+// but this function can be used for scripts or tests.
+//
+// Returns the parsed and validated json content and template.
+//
+// If the error returns nil on Unwrap() it means there was
+// no parsing or validation error - the supplied string
+// did not contain metadata.
+func (sc *StableComment) ParseComment(comment string) (string, string, error) {
+ found := sc.matcher.FindStringSubmatch(comment)
+ if len(found) < 2 {
+ return "", "", fmt.Errorf("marker '%s' not found in:\n%s", sc.matcher, comment)
+ }
+
+ payload := CommentPayload{
+ Content: "{}",
+ Template: "",
+ }
+ if found[1] != "" {
+ if err := json.Unmarshal([]byte(found[1]), &payload); err != nil {
+ return "", "", fmt.Errorf("invalid payload '%w' in:\n%s", err, payload)
+ }
+ }
+
+ if err := json.Unmarshal([]byte(payload.Content), &map[string]interface{}{}); err != nil {
+ return "", "", fmt.Errorf("invalid content payload '%w' in:\n%s", err, payload)
+ }
+
+ if _, err := template.New("template").Funcs(sprig.FuncMap()).Option("missingkey=error").Parse(payload.Template); err != nil {
+ return "", "", fmt.Errorf("invalid template payload '%w' in:\n%s", err, payload)
+ }
+
+ return payload.Content, payload.Template, nil
+}
+
+// PostPayload posts a pre-formatted comment to the specified PR.
+func (sc *StableComment) PostPayload(rc *RepoClient, comment string, prnumber int) error {
+ if sc.id == 0 {
+ _, err := rc.AddPRComment(prnumber, comment)
+ return err
+ }
+
+ return rc.EditPRComment(sc.id, comment)
+}
+
+// PostAction describes the action that needs to be performed for this comment.
+func (sc *StableComment) PostAction() string {
+ if sc.id == 0 {
+ return "create new comment"
+ }
+ return fmt.Sprintf("edit comment ID %d", sc.id)
+}
+
+func (sc *StableComment) PostToPR(rc *RepoClient, tr Transformer, prnumber int) error {
+ payload, err := sc.PreparePayloadFromDiff(tr)
+ if err != nil {
+ return err
+ }
+
+ return sc.PostPayload(rc, payload, prnumber)
+}
+
+func (sc *StableComment) PreparePayloadFromDiff(tr Transformer) (string, error) {
+ ojson, err := tr.Apply(sc.jsoncontent)
+ if err != nil {
+ return "", err
+ }
+
+ return sc.PreparePayload(ojson)
+}
+
+// PreparePayload prepares a comment to post based on the specified jsonvars.
+//
+// jsonvars is a json payload, as a string.
+//
+// Returns the payload, ready to be posted with PostPayload(), or an error.
+func (sc *StableComment) PreparePayload(jsonvars string) (string, error) {
+ vars := map[string]interface{}{}
+ if err := json.Unmarshal([]byte(jsonvars), &vars); err != nil {
+ return "", fmt.Errorf("invalid json supplied: %w -- '%s'", err, jsonvars)
+ }
+
+ tp, err := template.New("template").Funcs(sprig.FuncMap()).Option("missingkey=error").Parse(sc.template)
+ if err != nil {
+ return "", err
+ }
+
+ rendered := bytes.NewBufferString("")
+ if err := tp.Execute(rendered, vars); err != nil {
+ return "", fmt.Errorf("template expansion failed! Template:\n%s\nVariables:\n%s\nError: %w", sc.template, jsonvars, err)
+ }
+
+ payload := CommentPayload{
+ Template: sc.template,
+ Content: jsonvars,
+ }
+ paystr, err := json.Marshal(payload)
+ if err != nil {
+ return "", err
+ }
+
+ return rendered.String() + "\n", nil
+}
diff --git a/lib/github/stablecomment_test.go b/lib/github/stablecomment_test.go
new file mode 100644
index 00000000..a8260508
--- /dev/null
+++ b/lib/github/stablecomment_test.go
@@ -0,0 +1,92 @@
+package github
+
+import (
+ "errors"
+ "github.com/josephburnett/jd/lib"
+ "github.com/stretchr/testify/assert"
+ "testing"
+)
+
+func TestPreparePayloadSimple(t *testing.T) {
+ sc, err := NewStableComment(WithMarker("testing123"), WithID(13), WithTemplate("test"), WithJsonContent(""))
+ assert.NoError(t, err)
+
+ payload, err := sc.PreparePayload("{}")
+ assert.NoError(t, err)
+
+ expected := ("test\n")
+ assert.Equal(t, expected, payload)
+}
+
+func TestParseComment(t *testing.T) {
+ sc, err := NewStableComment(WithMarker("testing123"), WithID(13), WithTemplate("test"), WithJsonContent(""))
+ assert.NoError(t, err)
+
+ valid := ("")
+ content, template, err := sc.ParseComment(valid)
+ assert.NoError(t, err)
+ assert.Equal(t, `{"key": "value"}`, content)
+ assert.Equal(t, "foo", template)
+
+ comment := " bar baz!!\n\nWe are lucky to be here" + valid
+ content, template, err = sc.ParseComment(comment)
+ assert.NoError(t, err)
+ assert.Equal(t, `{"key": "value"}`, content)
+ assert.Equal(t, "foo", template)
+
+ invalid_marker := ("")
+ content, template, err = sc.ParseComment(invalid_marker)
+ assert.Equal(t, "", content)
+ assert.Equal(t, "", template)
+ assert.Error(t, err)
+ assert.Nil(t, errors.Unwrap(err))
+
+ invalid_json := ("")
+ content, template, err = sc.ParseComment(invalid_json)
+ assert.Error(t, err)
+ assert.Equal(t, "", content)
+ assert.Equal(t, "", template)
+ assert.NotNil(t, errors.Unwrap(err))
+
+ invalid_template := ("")
+ content, template, err = sc.ParseComment(invalid_template)
+ assert.Error(t, err)
+ assert.Equal(t, "", content)
+ assert.Equal(t, "", template)
+ assert.NotNil(t, errors.Unwrap(err), "error: %v", err)
+}
+
+func TestPreparePayloadTemplate(t *testing.T) {
+ sc, err := NewStableComment(WithMarker("testing123"), WithID(13), WithTemplate("this is a {{.test}}"))
+ assert.NoError(t, err)
+
+ expected := ("this is a yay\n")
+ result, err := sc.PreparePayload(`{"test": "yay"}`)
+ assert.Equal(t, expected, result)
+ assert.Nil(t, err)
+}
+
+func TestPreparePayloadFromDiff(t *testing.T) {
+ sc, err := NewStableComment(WithMarker("testing123"), WithID(13),
+ WithTemplate("this is a {{range $val := .test}}{{$val}} {{end}}"),
+ WithJsonContent(`{"test": ["republic", "monarchy"]}`))
+
+ // http://play.jd-tool.io/ or install the jd tool.
+ diff, err := jd.ReadPatchString(
+ `[{"op":"add","path":"/test/0","value":"anarchy"}]`,
+ )
+ assert.NoError(t, err)
+ text, err := sc.PreparePayloadFromDiff((*DiffTransformer)(&diff))
+ assert.NoError(t, err)
+ expected := ("this is a anarchy republic monarchy \n")
+ assert.Equal(t, expected, text)
+}
diff --git a/lib/github/wrappers.go b/lib/github/wrappers.go
new file mode 100644
index 00000000..0b05771d
--- /dev/null
+++ b/lib/github/wrappers.go
@@ -0,0 +1,357 @@
+package github
+
+import (
+ "context"
+ "fmt"
+ "github.com/enfabrica/enkit/lib/kflags"
+ "github.com/enfabrica/enkit/lib/retry"
+ "github.com/google/go-github/github"
+ "golang.org/x/oauth2"
+ "os"
+ "time"
+)
+
+// GithubRepo uniquely identifies a git repository on github.
+type GithubRepo struct {
+ Owner string // For example: "enfabrica"
+ Name string // For example: "enkit"
+}
+
+func (fl *GithubRepo) Register(set kflags.FlagSet, prefix string) *GithubRepo {
+ set.StringVar(&fl.Owner, prefix+"github-owner", fl.Owner, "Github repository owner - as in https://github.com/owner/name")
+ set.StringVar(&fl.Name, prefix+"github-repo", fl.Name, "Github repository name - as in https://github.com/owner/name")
+ return fl
+}
+
+// ContextFactory creates a context for use with the github operations.
+type ContextFactory func() (context.Context, context.CancelFunc)
+
+// TimeoutContextFactory returns a ContextFactory that will timeout
+// the operations if they don't complete within the specified duration.
+func TimeoutContextFactory(ctx context.Context, timeout time.Duration) ContextFactory {
+ return func() (context.Context, context.CancelFunc) {
+ return context.WithTimeout(ctx, timeout)
+ }
+}
+
+var DefaultTimeout = time.Second * 30
+
+var DefaultContextFactory = TimeoutContextFactory(context.Background(), DefaultTimeout)
+
+// RepoClient binds a git repository to a github.Client.
+//
+// It provides a simplified API around some common operations.
+type RepoClient struct {
+ client *github.Client
+ repo GithubRepo
+ retry *retry.Options
+ context ContextFactory
+}
+
+type RepoClientFlags struct {
+ Token string
+ Repo GithubRepo
+ Retry *retry.Flags
+ Timeout time.Duration
+}
+
+func DefaultRepoClientFlags() *RepoClientFlags {
+ return &RepoClientFlags{
+ Retry: retry.DefaultFlags(),
+ Timeout: DefaultTimeout,
+ }
+}
+
+func (fl *RepoClientFlags) Register(set kflags.FlagSet, prefix string) *RepoClientFlags {
+ set.StringVar(&fl.Token, prefix+"github-token", fl.Token, "A github API token to access the repository - if unspecified, tries to use GH_TOKEN")
+ set.DurationVar(&fl.Timeout, prefix+"github-timoeut", fl.Timeout, "How long to wait for github operations to complete before retrying")
+ fl.Repo.Register(set, prefix)
+ fl.Retry.Register(set, prefix+"github-")
+ return fl
+}
+
+type RepoClientModifier func(*RepoClient) error
+
+type RepoClientModifiers []RepoClientModifier
+
+func (rcm RepoClientModifiers) Apply(rc *RepoClient) error {
+ for _, mod := range rcm {
+ if err := mod(rc); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// WithToken creates a github client using the supplied static token.
+//
+// The created client is configured with WithClient, the last WithClient
+// specified takes priority over the rest.
+func WithToken(ctx context.Context, token string) RepoClientModifier {
+ return func(rc *RepoClient) error {
+ ts := oauth2.StaticTokenSource(
+ &oauth2.Token{AccessToken: token},
+ )
+ tc := oauth2.NewClient(ctx, ts)
+ return WithClient(github.NewClient(tc))(rc)
+ }
+}
+
+func WithClient(client *github.Client) RepoClientModifier {
+ return func(rc *RepoClient) error {
+ rc.client = client
+ return nil
+ }
+}
+
+func WithRepo(repo GithubRepo) RepoClientModifier {
+ return func(rc *RepoClient) error {
+ rc.repo = repo
+ return nil
+ }
+}
+
+func WithContextFactory(ctx ContextFactory) RepoClientModifier {
+ return func(rc *RepoClient) error {
+ rc.context = ctx
+ return nil
+ }
+}
+
+// WithRetry configures the library to use the specified retry policy.
+func WithRetry(retry *retry.Options) RepoClientModifier {
+ return func(rc *RepoClient) error {
+ rc.retry = retry
+ return nil
+ }
+}
+
+// RepoClientFromFlags initializes a RepoClient from command line flags.
+//
+// To use this function, create a RepoClientFlags object using
+// DefaultRepoClientFlags() and register the corresponding flags
+// with Register().
+func RepoClientFromFlags(ctx context.Context, fl *RepoClientFlags, rmods ...retry.Modifier) RepoClientModifier {
+ return func(rc *RepoClient) error {
+ mods := RepoClientModifiers{}
+
+ token := fl.Token
+ if fl.Token == "" {
+ token = os.Getenv("GH_TOKEN")
+ if token == "" {
+ return kflags.NewUsageErrorf(
+ "A github token must be supplied, either via flags (see --help, --github-token) or via GH_TOKEN")
+ }
+ }
+ mods = append(mods, WithToken(ctx, token))
+
+ if fl.Repo.Owner == "" {
+ return kflags.NewUsageErrorf(
+ "A github repository owner must be supplied, see --help, --github-owner")
+ }
+ if fl.Repo.Name == "" {
+ return kflags.NewUsageErrorf(
+ "A github repository name must be supplied, see --help, --github-repo")
+ }
+ mods = append(mods, WithRepo(fl.Repo))
+
+ rmods = append([]retry.Modifier{retry.FromFlags(fl.Retry)}, rmods...)
+ mods = append(mods, WithRetry(retry.New(rmods...)))
+
+ mods = append(mods, WithContextFactory(TimeoutContextFactory(ctx, fl.Timeout)))
+ return mods.Apply(rc)
+ }
+}
+
+// NewRepoClient instantiates a new RepoClient with the specified options.
+//
+// A RepoClient object wraps a github.Client and a GithubRepo under a single
+// object, and provides some simplified APIs for github access.
+//
+// NewRepoClient accepts functional options. As a bare minimum, you must
+// use WithRepo() and WithClient() or WithToken(), to ensure that a repo
+// has been defined, and a github client initialized.
+//
+// Alternatively, you can use FromFlags() to initialize all parameters
+// from command line flags.
+func NewRepoClient(mods ...RepoClientModifier) (*RepoClient, error) {
+ rc := &RepoClient{
+ retry: retry.New(),
+ context: DefaultContextFactory,
+ }
+ if err := RepoClientModifiers(mods).Apply(rc); err != nil {
+ return nil, err
+ }
+
+ if rc.client == nil {
+ return nil, fmt.Errorf("API usage error - you must supply a client to use - pass WithToken(), WithClient() or RepoClientFromFlags()")
+ }
+ if rc.repo.Name == "" || rc.repo.Owner == "" {
+ return nil, fmt.Errorf("API usage error - you must supply both a Repo.Name and Repo.Owner with WithRepo() or RepoClientFromFlags()")
+ }
+
+ return rc, nil
+}
+
+// GetPRComments returns the comments associated with a PR.
+//
+// In github, PRs can have two kind of comments: those tied to a (commit, file, line) tuple,
+// typically added as part of a review process, and those posted in free form
+// on the PR, normally added at the end of a conversation.
+//
+// This method returns the list of free form comments in a PR.
+func (rc *RepoClient) GetPRComments(pr int) ([]*github.IssueComment, error) {
+ issuelistopts := github.IssueListCommentsOptions{
+ Sort: "created",
+ Direction: "asc",
+ ListOptions: github.ListOptions{
+ PerPage: 100,
+ },
+ }
+
+ var allcomments []*github.IssueComment
+ for {
+
+ var comments []*github.IssueComment
+ var resp *github.Response
+ err := rc.retry.Run(func() error {
+ ctx, cancel := rc.context()
+ defer cancel()
+
+ var err error
+ comments, resp, err = rc.client.Issues.ListComments(ctx, rc.repo.Owner, rc.repo.Name, pr, &issuelistopts)
+ return err
+ })
+ if err != nil {
+ return nil, NewGithubError(resp, err)
+ }
+ allcomments = append(allcomments, comments...)
+
+ if resp.NextPage == 0 {
+ break
+ }
+ issuelistopts.Page = resp.NextPage
+ }
+
+ return allcomments, nil
+}
+
+type GithubError struct {
+ Err error
+ Response *github.Response
+}
+
+func (e *GithubError) Error() string {
+ return "github error: " + e.Err.Error()
+}
+
+func (e *GithubError) Unwrap() error {
+ return e.Err
+}
+
+func NewGithubError(resp *github.Response, err error) error {
+ if err == nil {
+ return err
+ }
+ return &GithubError{
+ Response: resp,
+ Err: err,
+ }
+}
+
+// AddPRComment adds a comment to the PR.
+//
+// In github, PRs can have two kind of comments: those tied to a (commit, file, line) tuple,
+// typically added as part of a review process, and those posted in free form
+// on the PR, normally added at the end of a conversation.
+//
+// This method adds a free form comment to a PR. Returns the comment id, used in other APIs.
+func (rc *RepoClient) AddPRComment(prnumber int, body string) (int64, error) {
+ ic := &github.IssueComment{
+ Body: &body,
+ }
+
+ var added *github.IssueComment
+ var resp *github.Response
+ err := rc.retry.Run(func() error {
+ ctx, cancel := rc.context()
+ defer cancel()
+
+ var err error
+ added, resp, err = rc.client.Issues.CreateComment(ctx, rc.repo.Owner, rc.repo.Name, prnumber, ic)
+ return err
+ })
+ if err != nil {
+ return 0, NewGithubError(resp, err)
+ }
+ if added.ID != nil {
+ return *added.ID, nil
+ }
+ return 0, nil
+}
+
+// EditPRComment adds a comment to the PR.
+//
+// In github, PRs can have two kind of comments: those tied to a (commit, file, line) tuple,
+// typically added as part of a review process, and those posted in free form
+// on the PR, normally added at the end of a conversation.
+//
+// This method edits a free form comment already posted in a PR.
+func (rc *RepoClient) EditPRComment(commentid int64, body string) error {
+ ic := &github.IssueComment{
+ Body: &body,
+ }
+
+ var resp *github.Response
+ err := rc.retry.Run(func() error {
+ ctx, cancel := rc.context()
+ defer cancel()
+
+ var err error
+ _, resp, err = rc.client.Issues.EditComment(ctx, rc.repo.Owner, rc.repo.Name, commentid, ic)
+ return err
+ })
+ return NewGithubError(resp, err)
+}
+
+// GetPRReviewComments returns the review comments of a PR.
+//
+// In github, PRs can have two kind of comments: those tied to a (commit, file, line) tuple,
+// typically added as part of a review process, and those posted in free form
+// on the PR, normally added at the end of a conversation.
+//
+// This method returns the review comments, those tied to a (commit, file, line).
+func (rc *RepoClient) GetPRReviewComments(prnumber int) ([]*github.PullRequestComment, error) {
+ prlistopts := github.PullRequestListCommentsOptions{
+ Sort: "created",
+ Direction: "asc",
+ ListOptions: github.ListOptions{
+ PerPage: 100,
+ },
+ }
+
+ var allcomments []*github.PullRequestComment
+ for {
+ var comments []*github.PullRequestComment
+ var resp *github.Response
+ err := rc.retry.Run(func() error {
+ ctx, cancel := rc.context()
+ defer cancel()
+
+ var err error
+ comments, resp, err = rc.client.PullRequests.ListComments(ctx, rc.repo.Owner, rc.repo.Name, prnumber, &prlistopts)
+ return err
+ })
+ if err != nil {
+ return nil, NewGithubError(resp, err)
+ }
+ allcomments = append(allcomments, comments...)
+
+ if resp.NextPage == 0 {
+ break
+ }
+ prlistopts.Page = resp.NextPage
+ }
+
+ return allcomments, nil
+}
diff --git a/staco/BUILD.bazel b/staco/BUILD.bazel
new file mode 100644
index 00000000..29e728df
--- /dev/null
+++ b/staco/BUILD.bazel
@@ -0,0 +1,31 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
+load("//bazel/astore:defs.bzl", "astore_upload")
+
+go_library(
+ name = "go_default_library",
+ srcs = ["main.go"],
+ importpath = "github.com/enfabrica/enkit/staco",
+ visibility = ["//visibility:private"],
+ deps = [
+ "//lib/github:go_default_library",
+ "//lib/kflags:go_default_library",
+ "//lib/kflags/kcobra:go_default_library",
+ "@com_github_josephburnett_jd//lib:go_default_library",
+ "@com_github_spf13_cobra//:go_default_library",
+ ],
+)
+
+go_binary(
+ name = "staco",
+ embed = [":go_default_library"],
+ visibility = ["//visibility:public"],
+)
+
+astore_upload(
+ name = "deploy",
+ file = "tools/staco",
+ targets = [
+ ":staco",
+ ],
+ visibility = ["//:__pkg__"],
+)
diff --git a/staco/README.md b/staco/README.md
new file mode 100644
index 00000000..2b345436
--- /dev/null
+++ b/staco/README.md
@@ -0,0 +1,362 @@
+[![Go Reference](https://pkg.go.dev/badge/github.com/enfabrica/enkit/lib/github.svg)](https://pkg.go.dev/github.com/enfabrica/enkit/lib/github)
+
+# Overview
+
+staco is a Command Line tool to create and manage "STAble COmments" on github.
+
+Stable comments are comments posted on a PR that can be updated by automation
+and can be used as a mini-dashboard.
+
+Let's say, for example, that you have a BOT that on every PR does some analysis,
+and needs to post the link to a generated report. Every time the PR is updated,
+a new report is generated. But you don't want a new comment to be posted on the
+PR every time! Rather, you'd like a comment to be updated with the
+latest link, and maybe maintain a little bit of history with the links to
+the previous analysis. If the analysis fails, maybe you want to post some
+debug info, or links, or suggestions.
+
+staco combines:
+* text/template - a templating language to define the format of your stable
+ comment, with support for the [srpig extensions](http://masterminds.github.io/sprig/).
+* json - to define the data to be displayed (and updated) in the comment.
+* jq and json patches - in various formats (thanks to the [jd library](https://pkg.go.dev/github.com/josephburnett/jd@v1.6.1/lib)
+ and [gojq library](https://github.com/itchyny/gojq)), describing how to update the
+ data displayed in comments.
+
+All you need to do is:
+1. Define a json format you'd like to use to represent your information. For example,
+ the json could contain a link, an error message, a short history of previous runs...
+ All the metadata you want to show in the dashboard.
+2. Create a template to print it. The library used, text/template, is the same
+ library used by docker, and a lot of the k8 world.
+3. Use staco to render your json data in the template, and post it (together
+ with some metadata) on a github PR.
+3. Use staco to run jq or json patches to update your json, which will cause
+ the template to re-rendered with your new data.
+
+You can follow the demo here, or find some examples in the examples/ directory.
+
+# Example
+
+Let's say you have a CI that - while it is running, and before completion - generates
+various links that are useful for the developer to verify the progress of the CI
+run (for example: analysis logs, coverage, etc).
+
+You want the generated comment to look like this (in github markdown/html):
+
+ Build reports for the latest run (18:56, Monday 15th) available here:
+ * [Static analysis](http://static-analysis)
+ * [Dynamic simulation](http://dynamic-analysis)
+
+
+ Past runs
+
+
+
+
+
+(and thanks to [this gist](https://gist.github.com/seanh/13a93686bf4c2cb16e658b3cf96807f2)
+for providing the list of all supported html and markdown formatting on github)
+
+### Template
+
+The first step is to turn your desired comment into a valid
+[golang template](https://pkg.go.dev/text/template), consuming the
+JSON objects. If you're not familiar with golang templates combined
+with json, all you need to know is that `.Something` refers to the
+top level object (`.`), field `Something`, you can iterate on
+an array with `range` (or get the length with `len`), or get a specific
+element with `index .Something 0` (or 1, or 2).
+
+A template like this would work (watch out that golang templates preserve
+newlines, and github html is sometimes weird around empty lines):
+
+ Build reports for the latest run ({{(index .Run 0).Time}}) available here:
+ {{range (index .Run 0).Links}}* [{{.Description}}]({{.Link}})
+ {{end}}{{$length := len .Run}}{{if ge $length 1 }}
+
+ Past runs
+
+
+ {{range $i, $e := (slice .Run 1)}}- {{.Time}} -{{range .Links}} {{.Description}}{{end}}
+ {{end}}
+
+ {{end}}
+
+### JSON
+
+To fill the template above, we basically defined a JSON providing the data
+that looks like this:
+
+ {
+ "Run": [
+ {
+ "Time": "18:56, Monday 15th",
+ "Links": [
+ {"Link": "http://static-analysis", "Description": "Static analysis"},
+ {"Link": "http://dynamic-analysis", "Description": "Dynamic simulation"}
+ ]
+ },
+ {
+ "Time": "18:56 Wednesday 10th",
+ "Links": [
+ {"Link": "url1", "Description": "desc1"},
+ {"Link": "url2", "Description": "desc2"}
+ ]
+ },
+ {
+ "Time": "18:56 Tuesday 9th",
+ "Links": [
+ {"Link": "url3", "Description": "desc3"},
+ {"Link": "url4", "Description": "desc4"}
+ ]
+ }
+ ]
+ }
+
+### Trying it out
+
+Before moving forward, it's worth validating the template and the json:
+
+1. Save the json in a file, `/tmp/message.json` in the example.
+2. Save the text template in a file, `/tmp/message.template` in the example.
+3. Pick a PR number of choice and a github repository. In the example, we'll use `8448` and `github.com/oktokit/test`.
+3. Show what would be done, without actually posting it (--dry-run flag is important):
+
+ staco post --json "$(< /tmp/message.json)" --template "$(< /tmp/message.template)" \
+ --github-owner oktokit --github-repo test --pr 8448 --dry-run
+
+Running the command above should show something like:
+
+ On PR 8448 - would create new comment - content:
+ ===========
+ Build reports for the latest run (18:56, Monday 15th) available here:
+ * [Static analysis](http://static-analysis)
+ * [Dynamic simulation](http://dynamic-analysis)
+
+
+ Past runs
+
+
+
+
+
+ ==========
+
+Note that the posted comment contains a hidden json at the bottom. Thanks to
+this hidden json, `staco` can **update** the content of the message easily,
+by adding or removing runs, for example, without having to maintain state
+locally.
+
+Another important detail to note is the `staco-unfortunate-id` string:
+in case you need to add multiple stable comments to a PR, all you have
+to do is to supply a different `marker` with the `--marker` option.
+`staco` - when run - will only touch comments with this marker.
+
+### Updating a message
+
+To update a message that was posted before, what you really need to do
+is change the json with the new information to add (or remove) from the
+rendered template.
+
+You can follow two routes here: either just replace the json entirely
+(using the `--reset` flag) or more conveniently, provide a query describing
+how to change the json.
+
+So, why not just replace the json entirely? Depends on your CI/CD pipeline,
+and your automation. In our case, the pipeline is parallelized, made of multiple
+steps and commands that can complete at any time.
+
+If we were to replace the json every time... one step would risk removing/clearing
+the content posted by another step, and it would be hard to incrementally add
+information.
+
+Instead, `staco` allows passing a `query` (or a patch, a diff...) indicating how
+to **change** the json, whatever that change is. So for example, one CI step can
+add a coverage report to the dashboard. Another CI step can add a link to a
+deployment to a dev environment to test, another CI step can suggest/recommend
+reviewers based on some history of the files changed, ... whenever those steps
+complete, the dashboard is updated.
+
+`staco` uses the [jd](https://pkg.go.dev/github.com/josephburnett/jd@v1.6.1/lib) library
+to parse and process `json patches` in different formats, as well as the
+[go jq](https://github.com/itchyny/gojq) library, allowing arbitrary `jq` query.
+
+This patch describes how to change the json that was already posted,
+so that `staco` can use the modified json to re-render the template.
+
+At time of writing, there are 4 formats supported by `staco` to describe a patch:
+
+1. **jd** fomat, native of the jd library.
+2. **Merge** format, defined by [RFC 7386](https://datatracker.ietf.org/doc/html/rfc7386).
+3. **JSON Patch** format, defined by [RFC 6902](https://datatracker.ietf.org/doc/html/rfc6902).
+4. **jq** query format, defined by [the original jq manual](https://stedolan.github.io/jq/manual/#Basicfilters)
+
+You can use the [online tool here](http://play.jd-tool.io/) to generate a patch
+between two jsons, or use the `staco diff --input "$(< /tmp/before.json)" --output "$(< /tmp/after.json)"` to
+generate the diff in any of the first 3 formats. The patch can normally be used and re-used easily from a script.
+
+For `jq`, there is no trick: you'll need to define the query manually.
+
+Watch out though that both the online tool and the `staco diff` command generate a patch
+that is far from optimal: while valid, you will probably want to do some tweaking to make it reasonable.
+
+For example, let's say we wanted to add a new run in the example above, and prepend it to the
+list of runs (first run is always the most recent one).
+
+In the "JSON patch format" this could look like:
+
+ [
+ {"op":"add","path":"/Run/0/Time","value":"13:22, Wednesday 17th"},
+ {"op":"add","path":"/Run/0/Links/0/Description","value":"Static0 analysis"},
+ {"op":"add","path":"/Run/0/Links/0/Link","value":"http://static0-analysis"},
+ {"op":"add","path":"/Run/0/Links/1/Description","value":"Dynamic0 simulation"},
+ {"op":"add","path":"/Run/0/Links/1/Link","value":"http://dynamic0-analysis"}
+ ]
+
+or, more simply:
+
+ [
+ {"op":"add","path":"/Run/0","value": {
+ "Time": "13:22, Wednesday 17th",
+ "Links": [
+ {"Link": "http://static0", "Description": "Static0"},
+ {"Link": "http://static1", "Description": "Static1"}
+ ]
+ }
+ }
+ ]
+
+
+or anything in between (the jd library will actually generate a long
+list of test/remove/add operations, shifting the entire array, rather than
+simply adding at offset 0).
+
+Regardless, once you know how to change the json, all you have to do is
+run staco with this information:
+
+ staco post --diff-patch "$(< /tmp/message.patch.json)" \
+ --github-owner oktokit --github-repo test --pr 8448
+
+After, of course, saving your patch diff in the `/tmp/message.patch.json` file.
+
+### Wrapping it all together
+
+So, let's say your CI/CD pipeline is made of tools that run in parallel or
+at different time. This means that none of the tool knows if it's run for
+the first time, or it was run before.
+
+What you can do is start from an empty json, and grow it through patches.
+
+* Let's keep the template the same, in `/tmp/message.template`.
+* In `/tmp/message.json`, let's make it an empty skeleton instead:
+
+ {"Run": []}
+
+* Now all updates and posts are patches, prepending one element to the
+ `Run` list. Our `/tmp/message.patch.json` would be generated on the
+ fly by our automation, to contain something like:
+
+ [
+ {
+ "op":"add",
+ "path":"/Run/0",
+ "value": {
+ "Time": "13:22, Wednesday 17th",
+ "Links": [
+ {"Link": "http://static0", "Description": "Static0"},
+ {"Link": "http://static1", "Description": "Static1"}
+ ]
+ }
+ }
+ ]
+
+In bash, this could look like:
+
+ #!/bin/bash
+
+ TEMPLATE=$(cat <
+ Past runs
+
+
+ {{range $i, $e := (slice .Run 1)}}- {{.Time}} -{{range .Links}} {{.Description}}{{end}}
+ {{end}}
+
+ {{end}}
+ END)
+
+ PATCH=$(cat <)D}73X*S;-yy@mz`T`~0;|Blz(HVOU@s8gKtJ)s=YN3yd*!4e
zDGF0QPIv$VLkc4e7EyCI_??NA5ot!yU$JoG^QLD$IR*#Fs3Rqwrv%Cqr;trz(5^;V
zptTV#kUt|!$c$rbiSt>rSi3oKTiFuG#NQjuFdFAjy?OHSbsoQHpSXC=-t(F8D0gB;
zONIU^(YwMuzxP<>*j=JaqIKPxdqw|>G6yc@MjlGbuX2f~fS;f5!Qn
zta^Zw{~(Txcx5DXXnPfBslR>uuZu9+SN|C7tFW3JwL-}EKg?bx2N450EkuPdMlqGY
zL@rAF*R%ag5cH`lgFYjJ>y_xf*r<`xeTmN#Q~CQc{PR{NIi!+*36V{jQF^i
zgao)}K2AkbSW&lCs=ctGFWkIm=tx@TZAY>wF9|42be)zvj6(wo!SDV)Hdj$yRZtyK64d)eU7
zHGx)F0uE%y%zS%$+c`A!_4MOzelcWU3*6UBPDYFG95y^SI5;r1sP<=lxNX3&cXWo^
zD#l~kIXHgfXUGh9e>Z>S_~PJC!^tQgn4OY>m%;1v#!922Yxixs!+EdtbP#*s=*r4l
z+v8(eT{ZX-G*4Jb^rHXq>zMYn66%-Ybm8r4tFCSrYUPU8=V`F0>
z5n5ZhYaHmkSBMlmnke4!50`dn>2@OF?&Oc8_7)gtKeS4o2DCNxa
zRgdc@6KF~Z1Ym8LtUI+wB2T|KBYdpB!{zA|=_P))$#PE11N2PbeKBhtpSumIIJ2{`
zut1{Euk3|hy_kDlUxBBoPlpeg$!nc0;Sk`Z-4}(7Bo+0ArE*B1Dih-%uy8HJigu*>
z=l!BFfFmR#j7dp|CLTGMFZ91OY7)X(TjHWb^D@s|BN&6?)c$uu-9m6IBSp*6|9
zUXU#{)jvCegw8QQ&aMb^o{X97%IJnhG%bfegv?A?c?xgd4F-+my|v76MqFHk>>N|i
z#K;7eX#XUOCzgkYM{1mkuI#4uHifZH-u5;YFgPOY!>Zsah+mmUMouna&T3ixb@o_x
z_;w)NSI7RCn(oQ~NyYq6cWm!;H1I0x(Eq4NFDkI8oP3`5{GZer4RbWLXkdW?AzFpj
zch%S_Y`$!*ZLNKLkW~&W-yc7w0oa&G)Ckl(yyiERxd?zh5V-?qMOGd7U8du;!
zzyRsnlz8%QiS+(+*;Wxo?CmjXGdC;hLqX;+!BOI3kw1Qfht`OB?5zkL5!T(XH~30aq$pX<+?B<`1c+1ZDCqzZs
zh4#I@vee9a{Z;I^4$tSD6qLn-4{{1eyT3q%n_`bX%FSSa4BTDM()8;Vv}ekbHX#cn
zV+_@e-`fmNcthnCdTn1OaJ-`sNIa#+6XP-hdvDzn(B9Trxv*nHp1rus466PZBD?@J
zuCP;07kjS|5Zv88P@>Hv(@A;Bl)r^l>1b=5TOz0-qjYv7y@rmHkeS|d6?b#aO&`xY
z_!4PS^Bk-Gi#RoP(-M3}L&=G4^AI@@>a=lsel05F7>1~F$<>VG`*w{%KZ5V1edMR6
zv3CX`iAGU`z^?rGSJ>E<$zP#=OiUh&5wYm~Y1r5>P?;~TFSseQeK%tz2BqYaO@APr
zA)Q{HgZ1=KXUFjAiG>$N0U;?4gHzIhR3V2uhvHE8+L{&d{&gyTYVar1_k9Cv<(&y0
zBS0*|z}f_kAvbVz(a%e@$5sy_QJDmmy24H@Tq1ZOLqj+?xOp?>ufnFdWu7X3);V6-
zJ_%A8dD)(Y8jC)s!I`l!4&`W}ABYD-UqJv$YRYh7XWhFGqeRm;{X?zCoW0w!_Rd(b
zG_o|!|-v&i$U(GHumf{iWh&`=Jw
z@B^}^t?^v_#!yhN)N~UBD2TH1qR+ReOP+`agnBcGs9xMfJ-o+;*Fc?o9
zPH=*6Tqm?}A)~!y2-L-8N;MJArl*RYo=H65+(8R<8@He?*{8ma!6X%$tK7yxeg`Oa|aw=(RXGOGS1Ia0qo
z{*W=PcYr_KtiI;)I_5_s;SFZr8y_i5MxhV*HS;Y;Z-K4EXKoF?;+bAAEJzp&8#|~Z
z1@HEDXOI@|!#rO=)#k&_>E$KZ+nawAW2^FafSZFwos0$?h^o*W$xHH%j~+o;BLZJ+
zWLvRl2f)4+xODyGfBl+@yptIQtKy58Sf%>{hP#J{wjOuY6m)a1f;wIzw>lDo%$WeJ
ztZu>E@+$vJ2w(v6I9>DadRHMn@^R-&UFrX=vfWA9@F!kIUREIffhD}o6az0Mc8Fqz7}TAq)G^Wz)FXpm5(zXb{uI~G
zB|jX5&D^eq%{Wfl%zRr9m53(gp_!zBRpw~MA@F(v3
zqxTW%XSp)=mf-WdIS}T~vKf7154S8%&&tZmCRa0`M9_(ozCQU7foN~dgKZB=FNxON-{2@
z_!?y$op<+FBoxdIb~muUW?F8F#+}#^Rxv8BB%k-rUTM)D-s?
zPy~iZb}ucde$!v9c4f$PJ~&tZ@G1ybM|ZF#d5@-6L``(o}eu
zf!kYl!23_bTknwS1cdPOFQ5IMmOtw?iQW#|*mFs*bnc_GCz(0EOY*3H;bvhxhXsx-
zN=k@pqcX=M4UYC|qTm{Dri4l){i$MNDZ0YRcjsWB~;R0$6=TNC#o-D
ze$9UK#}8Nq2eB2FXH#KPj~jq*9N4Q)Ci3mwTmzF^sP&flHxA?RQ#o0>L``Rs731_lPIE%e9Z(TF$=j$?h>
zRx6u?@;9>mi261Q=_i|}!x0(P+13X3Za%vWv@)L!@9u8zLnbHV+zYy%J}#eb5GZ);
z{NnU}=^&|cTNCjJ#?$y_ib(8rNp@!ffpfz3jvVW-9dXj~@p8r9@E#tGo+_5aT{hUN
z=Z~Ix>xhBKY&KPJUaZ*tlDD2iq3$+(-iT)TXUb#k)z!E@qLoNS
z`eftv(LRVgB$f~?|Ldo)v>(ZWbzdP*&)5<{dAS0J%E36F++-sr_OsQHrPk9B*wXFt
z(!&{#1xH=eJS0d;@(2q&iH0e<5RsEZi0bJvqp>KHG1G;f{ijjR?3_}MD
z)19G57#aDh*fcds&Cu2W4op2Fc#C=>;m&8Lu)aj2kgeRHCAbNtT|`Zdqd$@m)sO&Q
z-GrYGXP%Ay+9rOk75DP}G;hO+_pgP2CV1;>(w(>dH|G5Q`_h*)Zn9v)JgG~&dT4aW
z6mUZuFE6)usUd8~emcbH?HkBHNH^&lTU{H#k6u<5-(_HYE#-I}l3a|!WDpe;9!~z{
zwLolK2J+ta$Bbr-SJaAj35b^i3$mJk&0QEbO-1+Hdoo^1qK#Oj({p?O>
z0D93w?)N1>RB2iTM6}DEFxsb>V@-@#bp+b_^t40LQnJz_3ze1WI*Lu9V*I%RaCza$
zOhzZGK2maW;3mf%d&Q%cnm8}RhJcXgLC*?n?F0sG*oSM9&QMHhAdbcxm&0X~^Wgb5
zFZ5mCD&g*t;bi6zw-7W5DSH+=H5z7%X2%^mG(!Hs{gjVq7~h-}kF2pbPBiR2joV&*7<2M#tauJ5Ks%
zO6LHCgcNf_$S%A`^Hp0su8%w_s;bIWqVPYzxwObTSt5cjb>4Aq>f;ZjvRf#9Q=?th
z>@eUe;wPKvus9LS*jjXRm6)MZSIU=gQ8nBf&qP>2$K%j4c(~j`?_>Y&=`ylPj;iiy
zam%AdV~Atd{#<>)A1r*?m2eYiW-Czu(Ft-iQ!a8&`V6kx
zS|wp?qyLS9PJ<@-yDT!;|JAEaq#haA%SIzaoHpY@
z|9&f8&&L8n#f{sE9!aIc>vuUfXcJ>+jV&*Xf)e5bQomWm>btvT^1T-bPC8oq%|cOx
zK77m%Hh=m2K}g{KEpQg+Su;-Gil{xA>{i^SisS_CqJzDhc-{Q8=;6
z1`vqHW6h9dw?L@K<;yYQVu_Lr-RP~}uk2EavXVm>T_rk&D)n`q=Rfg*O)^P{T_hpV
zQjKZ{lW`>~O?j7!Rj0jQr$K|W-|lV*(<&&R(+3=VtyT<(46?GSpV0~5GfX6fVULXH
z?QwXTX0+HV^KZ~;mXVsUjCx11aIfSFy}$}6%Lc=Zq!pZneA%K_4Bb2!OAviH^fWD5$sQV`QHfd6Y_h$$(!k#IeVZQQo*5?fHAjJ-4(qriN1&k#iLKA&e5t
zCjt*>g9aly;v7+tYhHKlg#y|JCrfNmkF~TvV1X+qidpaBnGFqj_|~*Uvv55Ip
z0xFWJ(TMm$E0W8Ejf3f1A0O>E<=~jIgy5O&{M2YjknPZ^3;MvFGpzI{gHa~9K2lUJ2l11$3YEFcgERaw<&l)obz-m5VHd$6Q@>~!|WF{KHA4x+rDjSGk@#N
z;`9!-!j3M>>H-u+C=kZo?A(8cf?Mg`C!@Ob23?TU#^d`HiMtj~TT`9qHGTx1AXbgw
zBchXFb~57{vN#8^6{1wIaPM3?w_Py0g`f3%?4Ykr**>5=tTDXl!%RbLk+7}9OBIC6
z{i!iy$U@PWF3azg*y;_oh@t+FR(8Nx*02n?UtV>s0U>PO)E3Lld^`oYf&W}4)eE5C
z;`&zC?}5*`WVOxZY@_U;Xj#qc`gdPWSC6p6`0{b{1)5wMk3k?$K*-%g-AOyj#h$OZ
z!xMh`#B#Eh
zoLk`v98Fx!)(cm3V1?kVDbqF4tr%B|+_cm6GW@c3=^Ia%Lu(8oRDZ~gz=)ium*?i@
z4t=$pk7LvX#rh{n*w*1-l7(l)L8
zvhJR(v(FXfLA-Gk<5o4?Oqy_Sy}%kV6r?#ht{BPnx*_SlL>Fr7Djj@mZ8Z;*TGmq_
zWd9w^bb$eJ?~GW;9^!20X1n3n_i)${m;f{wHXLCAzkmRH;oZV(#QH${l>p1_#v#!A
zf}%p1j?u5ZZ~aDg<#$qcN@qJK<|G3_<3pAHKHDB2$_kVDT0fH{eNVXR+G=a#KfBgl
zIo&9}DAbp;X296QJT*_&lq2bWj4^A+4I_Dk5Ai5x(rb-eE)l**J@yCM7B3LR%{K&|I<>`8tci3-TKCc
ze@u=rFt2gyWF;SEIEj|ghh+k%jfcljZyE~+Ph&7JB%0fkXAXLPP5s1F&mJ;6A3A+{
zPk(eK>@#vVMe}K@EtTKnNbezoq}-(4A}~dn!P{@q8Z04BUglh~%#LM8yU6AD3(2FX
zL%_WNQHQsOcg_Zy6LlCwDEGV}om?Y1QG8LlRk_Uv^gqNJsiN+1kaXOAsk>^w-+`t<
z4W|S~vQr6LKF15@n+G46627lpqO{-Jel0`yAz_p$EwXnNh{ksn9^B&I2jsg#E6A-Rd9(N
z4iI-1<|6=SH~J$pS@|AShzYPmtIbAaN>aa@hx7D0VVk1mo%m^m#pVwTCvQ8LNaV`w
z$b85%qe`{?d8#az9=9WKwkgZJwcaI0qgqtE9c6i~rfR)QVykUb2Rg5ZN`eX9i`
zXt`1u@z;+#PQ7+-{Amkc($zQ7g6Ayea_JeIMQEA5Ex|{P9<3F=K0jL;(6YSGLkZwO
zp2c;Z<=+v7Tg&%!8nDjHHTsES=zt7(G2)b;%ML^zMvkPFA}REKDi`JeyP{dj|%L7B96i
zh&2zd)3j>s1)py(6d9g3
z#d8X4Rm^|?Jh9Zh+Chx}##$qaMr_&h*U&&OU$E#Tf!$j`>=#k+Q*&M4!{_1VCSm7|
zO-fV(0cEbk%h?>|oV4pMYS2g4wdn6#)xAICgnO^*f9+jq)t_4cL-rFzA9f$iwA#$u
z5QVnQ#~iIJ!i8&Xw{RF9?;rFYs)E8Dh%Z<~!Z0!pKdIA)mPir^7}1Sqc0l%Y1sOLH
z=l(h-48JRJ(8bn%ok1Yxj5ps9;@$bq>Z^AX%jMx+FeLO6l(#NgRpd3NSwOVsIXHNR
zh=#`T^rHuTO26f1Gqb@fwY7`u={z0rEtW40%vtJWp@koimGGG(GKixQ#Ax!`tJT5l*6g=8S(hMr9M5%k>K8D7sDKCYlL72fjSrVh#r-vLb;=
z#_mMH(w^z~8<5tyIotUNr2LTKcY4*{*hv+S`0)d8xsiN-n=BXLcSnoT_m!(#&4#ZJ
zS{1O)&28h8gjSj|8pbi+BqFw)L<;3e@nzexM6<^Lz;B{!A4)5`LWF>Uy^r*
zw=_CzA$XBK(Wxx*_vRm|$C5m_aUp5YgMZR&>USsGtK2-h{~+$7ou|*N6J(d>*&^p{
z-QGgR>uiDe&ycDCk~`y8&SL8E4xh5-AzwT8~y%slKyR8$Yd
z&xW~iuoda>w3gtCTOt18{I`dcjDpy=QtnoMeUUwUU$UL5x$=I}UUKopA~Vy@p=pxz
z`lEqjADukU?|cqBx7jpHQ=YKKVXCaLKpNHN+OhJp`YZ0;`FA)P|j!4qp-hOMj<9K&2KUEB^ynUG0*wZwWsK{E`xt%gu>v0SB
z6CQiVd3u${yv-SG&8o&gON(^0Xuji^8^dAzQ)?)+#^(@c!|s^1=5|TEM1zw)_V5-d
zzHSAuVFHbMAPVWvMfa1fEUtuV3IOfDdJNdISqj9
z2YnbTjU*5cw#HAt&lqmV?Vrfv5?R#7fZVR{n5X4_ln`nqX}zG;bva&D6fL6pjCXyuhhl#@j|7CJ!E-rzh$>6H
zW;XRe!SBCdM=e*3E-&JYyAVq;3dbxO?bfwxXF~ePhy<1cDa^7{MFOjaS;k5BV!tSb
zu9KbtEN2UCDyrx*ZyRby4(;f3|5ZL`og|S5^sara{app
zfn);P;b2^clMzIY8_e>qf7x)N;gfCE(fm#E$ez+Tr;yvm+!zc)J@ad+6NN&9Wb{4t^$3)X7nt}gO_@D=|&xlX{RU0;qd4!P&kMz0g!
zaHP_^wzC?KaHXeux=mi2d3w7`96R!3R%_OaJmDnK=~00|1`j;^T!-KE9en}T_}PVA
zfB6R7dK6sdZsy7Rieu&Uj(`@i_ZWHIReP7%93_6t=010?q1_t{bLmMNpKqNtc^VtT
zaAU-D*!VM4Hhv@W+xz+OuTfa_C;~s;Je$job6NF|o-zl7yR0$TFmPxSaww
ztIVq~&J`6}tn}H4Hc!Ek=l7!rs;7fJ=&8MUPUyt->~KXIjM;q9<02rljH5F;e5SGaan1?Pprr12jUDKj~^87tO;YM`>f;B
zZA%0yWJ3T54b#-?Ar@pKtX>*8o7KuU{qCrWWcm7FFdVTDL+Ry1=c}hlXuTh*j-x_g
z;2@$0+qe~f8?oz>yh6|r5uLFOVxu(@qne^(6s1)Vt-%FFEqGZ-vR#>pukh!K44
z;!*hexWZ5qtyq%uWZ*2nI%}$Q&N`+_E}ch5^jk#lFRTMvcQ#zS7rYIP21i5u!_=E%
z4|v|5kqH2#X#8I5c#C#R(){qayKv;griCYh&Tv!T9!lFF9_&ZF_+6)Q2JME`*Jgxz
zxV!z{;0N?)S_2PMk6N=;8z975+k()}cbC86Zry7aTZ^G?%uYPH^?v&1Utl&CC2SI0
zUOGMreBPty`6=IfKehU#i$o#Ovk-GDp<&trO$wgXsR{xsL?74~RCk+nauq|J|h
z@A?N=lz-T`8QGw4R7heq;#`Np9bkwZn3^Fvxpc=SGspJN+uCIHydRKKREU$HJ@_0i
zMm2iQ*3-B0y3NoBCDU0DhwBuG25bvjx}_%TnWmR3j|aspu9s`+$WTjbxh8Vhj~2SP
zgiU*zqE2}_D?Ts1gJOnZIX%l-m*3d<^JucJj?)E}R`TmllXHh|Hmv>=Ru`Nrwx!Y=f^^M#5){d%o6cLk+std;-5?b0s
zhRmq_BLR|PU^pt|g%S7QT6czf>&$22;!1Y2$W;Kf0cV|9NB_{mc1GoUS2ouwf-#pkp
z?Is!1N!LUR{Vc
zRhAK|@_v6p`QE2Hp5)OWPFii2x#DBN*o147dZC9Ky^uZ&24RO-QjP5?sr$Bm#NpE@pK(zP41`H-<7;6^PUv-pptJ|5
zN~A5c@<>5a14?}*vbh11rhqTQBE1f?
zEgk+4E-Vm@lva%{HUw3Xe0Rqv1;_8JLA5h}a5N#K{w@}Trhp}3bUp=dkxA;yv7M{<++59h1u8z5UYsKV
zED%0_rqpjd_?y^+tIAtw39kp8v$G6eRo*6orB0<#W^+%SellN^WN1UJ?i056%aNvE
z^j^0(hvls3b9!hSZR-woZ^TfvY#>~&+YQq~zor%}@S>H6msiFBlf3FX(6~Rab(a{7
z73G)|WvXJ_>@l9kKW^^7-=nw}I)^E?R+jpg;(Ob|#*%?+EoVgOOo{k>F;I;>iWcJC
z>pfJ%d&r{wd-?~NF_L5~7C`{~^xdeVEXf|pYPh}(XmS`Pr(UX-pv2&0@|=E>#1InO
zQ(OrTLdMb?89fmL_NQrM0-)FpnC0gyvZw-=mkD%ovsP*a2;y=`zjej>tNaS`51Jj}H
zBaC#9CTKet!s<
z98+uYw)N>Xz+;z2ly2@zd^n0RCPvlax*?CxHMFAyV@H<5kF2%Al@adtK8zgJ!h%3sFf$Zc(xFHcAOaiA0O!jAMIv+Xt#_uGqau&F_`e;P=76vltksI=ee;%@eiyMA{-9^`0l6vWfXY=UN?y{W()(Z
z4o?#x!EH&>``;HHDXbT-d81%4+k6_EtY`Bmne>}ck@^2ZzPW5_MBhggENPbO^*J%O
zLP|<#&S>tB)5V)sANo`FJx(uvVY3;8Wn=lIU~X1PL>vFwzB${;t6<%D8tQ%sIcZOd
z;q|Z|OQpXHHO-(;l1a()a9^2aBq)$c<|GjEDBAvzCv0LHL}@ifN#LR%#m(~dhn|A$IK;vrHfut<1Q5d
zUph0O@o*Kt@7uPI)iV782`I(Hcv9Yg1q_f7HNKj%j}F>XJ5R5rpos^XIG*
z{nTu=Lp6cm_WIwB=O|~`HnsYbUc)rdf@%`>^
zWAW;9vmP+%dyikJ5|P<)W2%rn3r?ZG_84;e-bsaZh}%izsop0Ukt-PHWguH}ee2NX
z+p>8?jbukxV~4>C!MQO0$r9AB=bB0wJ8WqoQGb@rX%4sdDFn2A+YwKP7VC1+mkc!VK+MtC!dB^M;BCfS3eW`-J|nl@u1=EX=5N^8f5fJCb<{k|pOAUR;TeGY
zkq*8l;9;Xlx}o!|tpdtlX@|x*)zsFeGdpFE#h0i-wAYu{1aJz3dkKXrI;Qlw>ak3I
z{cJ+8nk!HKrR{S3HL}S`FgD6goz(NLjtPp$MnmMk>(=!?2OyX^4R{O5wGW?P+AP*4
zZO>lpG6Sk0lsnptN6U9kS`EJW5k36)uF#xRgUss6TUW2m6Wgq(`*a&KvdZg3a5{zN
zktL&SYD=C%NF@k)r_lIayhJi0Qdd^nLjk|fnqb>x^G=OMd^ADgObX1;xdF}Yi+YeCK#vYV`(6Me!X5&+o+*@!?lKOJY;vQ^N*Ku#>AAK-r8)t
zh?~2o0bftdmLBn7fkCK_b*9h*2KsAqyV0@06joUAIpFboXQ*`=OQDH3aUOC@W7Cj}
zWc*cV41xvjjqszZs5oLMWC~KxOu6fmhqN*t2K4JO%f%38$3Upx2{2%fYbBn1a_8|~
zX?~4*lRJkl0Afgm9xN)NordMhmD}j{nyfcOjXsmHpxPKUrqzVksLgLS9l-aU3hEf4
zfPcp)Hqca7=B<#Uub@N83)U<3C@39w266P$`2!mMkWHBmH;F;kQQV!K1F}2MH_>?1
z6KQjXw$YVrxhmRr$K7^N4;XnP(N1op5hu1gLGJ#Ph=vj6db|t|G-5JnXTUy>81~@3
zyU@cD#TNv}QWj6OEi9Tp71t4*U2PV!A^axi5+p|H&|xrJ!;c$WR+1}U^IB&a!v1+R
z6E!$E+Sxy*D}oN%B{fdpSOG2Skx{r?_OLrS+fq)l2PgmQbe)Mk4fLk1t7AD3m
zs}%juu0j7ziOv$dBzAm`4qbaY$J;?=T4S*ZvRyt-{xJ8<{%FXfgWb=`-KIAY;hgO
zIBOO5A1WK``=;uodG1&~2NAW3=;fbtJhjscTNx;Mkt}IxLEzU3I;TvC}!K#qccJU*$3_t%onp%U$c`fjJxk(m|
z^<8OT8+)bDH_ZHVJgVFW9AXVUqZBSNU_L8fVY7>ZHa(sA6~CU78<4UEk41D4())S6CAz
zQ*!N6(nj8_EH48Hu|B`AJBdTbzP7Mk=K0a)vNXPCMsBASbZ+)nSKV$}NI#DMM<~os
zp@{56v||p%CacN}u8wX-DE$CUs{5Oqb=IptYu~&eLD5s1k&2gE+x!IPVuwh!glHob
z4qbRqeI^uFz)+DeskZS<&gAn`1_K<~BGfOm%rmnnILggDJMnGy&19Sg>UO-B%-`7_o7mmtR*bM)x#)vPnNF
z?Y-r>ORP5yZEcGz%jDoz%!8gW5^C(IjLm1tF(_rUuGp&ZzXSzuj|es{TT6B*l$cDC~gla;V#o$po=MBN&0(VqxnNfL>5F?A{SIXpKjd
zXwW9vOH79_G}@x@zEJ7u=_`FRl`cgx(uQXu7kCXtw=`r+oHE2Dqa=so$Ub-<2^kTJ
zflVp&GW6FEAKZAT_TpgEW77xm2%J7Ru(vFoC1LJ(dw!Jxxys5UU<7)>i9uG3-?}IT-0i#iyHv#hMOy(9!irvJdpLPI*!7
z-49Lh;0yLMBk$&l^Jft}HQom5&5`2&ID~lHnxWp|Gsnj|S-09+S%pl{dzRn2ds(#j
zxf?b$H=DMgYo(6N%`N?^@s~ayf(%U6P&HuO^t){DwQsV2Hb$Lju~>V>vK-zqx`W!0
zua%mT5&`39SUNHGXkR;LH@nk|p-9?vswp~c5Yu^H>tYL>ktD|&h^PXe}i
ze+RQ(Xm3ck1}PmC8s*?u9d$G{AinQx(!pD(T_uR0dwbHydX|G-r?<
zC4f~xL}ZjGOkapD7Dt%}26in%>{w-;Q5ETjZ|8~R)QVMjt!QkDuyU1dw-xz#LZ3d?
zw~BZWm%M$RhKp#GmY}TZ{vzUU*Z+G$smPfoWpt%-A$xoP+ID5~>oyi1lz}sQ^-<{G
z)IH%dvJX(-&e}8hZrM=LH-(KqV4v0bdf9{4wd<69(NCOkvku3@AJSov#pg%73VqC%
zFGNls>02?%yTi>hW^}JV^cCszIGw#a{;G}i_SbTh#LY@53HMR&tU$=wUKM+Z
ziXc`+-KU79(gbW~kW+ZOJI=Tp*B9N&BWa>LGajb*$VO(vScAz1PO&A>-fUA-^ROj>
z7Y$X|Sw!#iJ6I%1BiPk1r#i|%!~q7xz8;u)1
z=rYem-CV)Q=O{Yg(`JA=-zXHa1Owu!6}NVh7l&~<|CyvcWan@+`kS
zrZ7dh?#fg{BfXow*sKgh9#dXKvjU2qsXlmxaV8KAWzp+w#s$c81%JGa2t_}bbX5TH
z*=>ioG{p&
z7Kq@>$WK_dy>WDCu_R}1zPVUWvJsz(Sn>J8q&`i$u=5iXTl_lja*B9dp+BvbNy$nJ
zpUq?tU_g{6O|M}Z8r}G%cJkM0xp%V7lc60pE+*qnc|gFIbC(RpNcI08zxN#U4|-0s
zkk@rtLW`S=)5u|SbztTt2iR8kl_75D*b+LDG;IpUi67qT_sCYou8)ryl=F>Sq&fn=
z+3u*ke7yd-AG4XYqqCR=DOo={Y-q-;{|flrtk~H#v{2N(Ktsuk&tPX*Z>>%1U^rO>
zt1N;!bMa@+qa6kf7c8)t8p^0*@R|rSkJDRxlP~3Lc_a#V@BaFDb^r0$8r84nEtGuj
zKXj2~)VI4PZ*g$Gy!G)$?KPpG3-OC?!)vjYS_#pgoD|In5S|2*{5dG4qpHkDSXkH>
zt*+@iGMFp>NASO$(BJsx!c1^siNd{yAH>;r(etQdzbB=p`j@n&1N__(f|-^hP8&@V
zkgo@(*l}wpU5obB>hx$c+OVQ5h|5AXuIRdpZB^ZqDv%H)C3RckT`vjqZj?ndm_~SE
zpbU6U%0;a|b5JWqbLNr2P+ml%{TYbAwRQd1uI)u{eov*+*qY&
zS#~kXs?V_j(CtflJ6f_gM48`G#t{Q!YfJe(2iEa%ee~3Bc+;v9w3?4GeJvxrO^sUp
zuU>#Zj!g;Mr$D+X&N9lt&rfI|*bZ$z9C9+pKTgEK4wz@WC_Flm95>nSerxaSm<0Fp
zl927$)d{w!V;2+`eAwHlNx7L;
zAmsH@?pp;xO>~M*K;G2Ht|(jpr_;`(eCaiocF%Jn>}2Nxh)35Jbj>0|o67
zkx@r$p1yM_`R{8W{W|;ee#0PP>H0$gI&g@i=I6Db9>Da5Vt5oLD0ehas^EJnWb{dP
zoqd--Zz4O$ZPcNlm;yv8_~z@-(CtyD!=uYC@+VD{o)z_x?!K|t<*V)X_70LN!ko8(
zNg_YI8psc#_SR=}*a`Vxudi=9^!&=*Qxer<@b>opt@2~_hoic`l%4UvJ?4MEJ3%Ig
z6cNkH9|E&4jYUM1nHWDf13yIVN_ufqvF>Ar{-c~Qq{4&^ggL$O{t}O{O%&jFg+T~X
z=y{E_$qN@I4MTVslXwTq5uqk@{MfNGm
z9;roaw{wtpdv8yZCQGcpw^UdS(D3=+Imq9P<1+@$HPkjI2c@^Yt%+b~QG>la8RDkd
z)^{>qA;4&EO>dmeay2G5>6`O&@?wY1F?DiY^Xsu;?9|EekdF1Cd|fqL2Vx*wSWtTi
zoDCy2%C!+ZA4h>G`iglwR~C@Y@D(7;`XrW-A)v7i(cEpq;U+zG(jNr>i`CE|!oDE;
zLrAq&BFHQbHi|bIe5fALnYqqF<)p+!mnzmZTRgbTX{Dv5_%Cqzr$I5l4Uw`ad)D_8
zC{giUZAQG|%6}sn-gJyCQ}Z>F=jy2TuC5V8`R`B`jAqRYm&w;6gW(-e_Wy@K^tbK#
z>-`FTk;0~OW~QbTp!~dbUkC2T>#=~jsu?LM>4C&IJ%D9r;d=b&u!!#YDwC>DSIEx4
zZs|PRu5gx@_2g~X*x2khYNBZT%<=j4s^Ri(>Er<}b0bn{Z3wTPpAiueZwEAh@Sy)r
z82%Quu;Bb#zG*T*rc=f@VrYkao_S^EW#e1HWsbWTE*Qjq&*)WW(ulU~3u@(pP%hW&
z2lUPx*>=viwUQ01`_mQN8Ra3#g;f2UzxMfn8f<_e^>otXdK{^%3L5p{6uZH`g}dw)
zDHtpWvgi5p6v~hBf1!ncn*?+T0zV6I1pDccvPl&c7t1ErK;O7fP*Bi0H3hU~vbA?~
z6uq56bUvEJr&i2}IptUkcuU+}mRbX)Q2WSfX(>IFtX&|3AI*$x
z9z8^lJXdJ)-*3G5FE@gm{MVQuazJW+fp9`f0);W)B{MB8jouO$7Z(_mqC42LqW`S_
zbCft<6H2oi$p6#JJ%o|wv;QH67O(>zqG_zXbT%z0wn%KT`09m#Agyxrd)pLQyygXK
z*&F>jr8S)-lKAVNu|vsIJ&!Tru!Pk`ZVYti$lcNfW+XSug5}>W
z7%v)F7bTnzCYwJ+*F#d92wAK7f9T>7g8I_K6bTT=PC_pkoFSO)F8-!){NK6{$Ogd8
z3m5?HPol`?foT*Kn6v&JtN!0s09f!l0WQ$yC6KWLcpQG*Cg=ZQzA=e@KMs6--zL`N
zcpcuq&(T*nw94S#uTXMCQk7g}6;VOu%m3&9{Lfg0AiyhpjRiq4^E>?H7Rcy$_&>C0
zA?N>V+7(4HEdO^Z|Fu`o|Bt7V^9`U@dNnX0X6xV(92;vuq!sSfEG8~4EG+C^FRiJi
zwf5_mWz|MNKmeY$i=(4ob8~9XRZ&sV&&(ggJv}fF2G)Qc=$q{tI6FIM?eH)>hLj&5
zu1A+i85@(KV_-xtxw?5V8hEnxZT$TCLPdq8Tiq{l
z!C+#!CP#8lFHdT1^gg?%P8bprl5GClGi9Ef?3^6C(@7&}7#J`sc|Vv~<9>LK#N6ON
zTmGx+?_Eyw(nFFUxXpO^b<08xKt5x`isYF+NvPN!UZ{ZpTFyfziyvo=_8FgdmK&a5
z-~t@i@*V4yYUDT1P9z$5*5AIFnLVfyU+m7pynLyanwT`NI$iV})pLG(d$)cCD3wgE
z0Xb~OVm`CMiU;BOThHW!ckHdx1Olsjv{v8j?slifwvr$a(J4F3QUbTam%@?nL!y4_
zx;=J(iFG~OOW2(%;aKt9a0>O}oXKYT2CwsQ3FWrB==v?$?d(ghYl#R8AfitmH9B#7
ztV{g#RmUdOi~8wsV2zfXMWx*o_H>0$g&3%L<`nCqbz*O(yxKd_vtd^`IH;w!UT?$L(XA#?>hb-f
z0d0oGeaw%NvJ9&T_zmGD#~&k%xNO^8)S4IJ?-#clmEUpfFIyJ4`3yRGo-Jr{-t`OI
zI9?5>?ycuXuDyOJv1#k-9+*NSB#eYTDv7+!^zrPva52M(-kUn>+e{spI~zOq@&mv!>oc)ykG$a0CQ`-b(e71
zyGH_`5=>no2ikD>c3zS$X+=Kx29NFalFKjpR^@U_H6(&*lt?De>5G&yukA+GTHpV`
z4%t%pza3&i02f)L0IjJnB%5DeK3j`9kV@q_>`QFE{>GwGqkO@OP90T|$am)E02&Df
zgsQq7i8@4#jF>|{H^Sao%+T2%2z-zZm+C*&$w*1`EY$3r_H+t?!-j8fUQGp!pc>{&
zWxN=BPr%y?KbU|l0CX1E19r8QNPTolL5Q5t8-+?wr4~M5`O9@WT5Z>5MvuuJx$^
zt6I2pVbhG^bmD`<=3(#bGV+T(y_kE?<6ec$^7|1msw
z^i#Ic9k-qkENW-HkEn9c_CPBX91INNb*}KS{k+ij7>C2fR@g(1HnQVWHtpw?CVHMF
z=U?5xTg&AV8q1d=5XtFFO}>+qkb&ijFy-c6v2}P{GC1_dQJ_XJ@*#MVIKpywlKWMx
zTE%(wYXNpe2NPkgT>Yaqf1gZR_tY3Sio2Sf7wpabM#CXTZUv6Qst6!re%n#(HuP4|
zG`mnZ0jRfOjFFUw6Sipuq%akC&2l5ICl~De1C2wNe?z)70MfBuJirM$&a7VETc>gO
zXnVJoI(Zk9t5j5xOAW-?Cv8LSADU%J04%jC3Zq|4Dzp
zrM3oVGLf)dtiY1b394sHi=n|clVk7q9NwCp6i4k9Z8PJD=X{4ewz2{AKv?=ixeOPO
z{nFYn3>(etXMWO~R8;!_uybKriI6s4hi({u69Ilzx0-N&T=-eGN}ei%;MZVpLnpa6
zSGHt(JE){~eLdR>Tj6+Q91A;9_M>5zY~ouaAdtS~8E~g(iG`IL!cL-djvF33#rPo0
zOpSDTRAzwSmNhs01h@p*Q7V>MMr&u}=+7&VptoY9r|$rA6f!H}^-TL)#BFbL^8_FV
z2#9cgt<BIP|;(y))LQK2ji&*Q-;hi#dl~`NmlSZ
zohR@{Vt6kEYfMNnBiBC(z_6*va80RaylQm2!6DF+VYDV$r-DiN0S(rs;zEgNv4B
zF8}Zc-KbXtCEw=lGbn7=N;E6tdY_?e2_a!mfo#2qOh#2x9FudO>!#2(J!a;6!@X#;
zOHjo3b;1O3)sqe`4qJd$$vct4Y<*$Nx!RQHs(Gp={IcCDb@m;Xeo_zjFif@)kEcKG
zj7_XFrqOQW#c4t2kWVBb2YQ(XH!*=)3xR`g(DC_s(MV{zpd;Yq1!8TD90%u8xz5ub
zc5>kjP9@LVdx%3{y*fGSuUa?iz2Dg(OdQ-PZv4sYGuj6ZLdN^_TF0kZHG?U%U5)%2
zrM0N-_6YWen+T2P^9cXLxBU+nM=Y{U^yRTx#>lwbL?bUWdzLEV$tT$PyS%}
zpuqSlXZX9Oe;h&BZ%2Urrz2=k0RT#2W#Kkm4-dDE*aZg3_A7^)l{^jFToc2unyNUO
zt=JJIbLDy`-y2Tl@9WoFD~%7`b93{*Dh|Y$-dZBUr&k*`!VCT=J$-Cz_4ulhIO>KV
z$jQSZ_8^qG0$=-pAUMW7Cw+EXs(Iy=(I|wV4%%NR;HiJ8#`17^GUx9(*dg2daf^6z
zeLk|S)h&!EJX^L32_`guVO?Gtn6zb>spGp_kU5tva@_FgY`FuSy`wVP-=p4OGwF2l
z_~@|R@F3eNIB~1JU7JfXFH_0}|?dq&^Bna{W78Cn0Y}?HhZKzfAF*pES
z1-;zC+I*zT2%r&TijbrGG`oHo@DJG8=6Z5o)sA8K!Y}17Lf8lNB}C((aL28;>=Ab(
zuzNUArhQ8rn^wQxr&Oq1Mp>*>2DV7E0IIu&u0^Nwv^!&HYhldJ5BEveJ&E6Pl%-DZ
zDh)lgwHw`$!|}Kf3_jXpK(r4g@qy+TSm)gLa1|{pQb$Y=*BfEFDinpD8@~`IP-^((F*!Mozzu7G^94Lhsw3L|GiSRhWAP5p=`I)X)t-a6}
z#6(r|Wd!qM*%n390P(u4p%h#Ti-_g3*Jm9Gu
zHDI#2A6Gl>kW(w&dr+#~zRHp7_0KLU4N0cvdJwu|h7)-8W9mDfO`F{+#jmg*hvN{X
zx>>(YLiBGZs8E0M_v|=4u{XJmKQDpFOpW1ih<)nu6PmBJu5(4T&2c*UNtDVz%*ri6
zAjngp6?xQpm*P#s%s*V}hHB^k=sJaMdS+-KnS7J6*OV;NuZ#jVsOdrmIjD>eb&QRT
ztq)R=`$E>x4p^)cbmDXlFJj>IgX941n$nqO7;dl*^Aq(tLO5Q1FfNzx8`}CFpt+-w
z*SrKMTczh<**zqQ{%o0Oov+gtA?U|E7pL>P>{OMxh%uX~4ooXi
zN_}f|zOp+ZGF~AggGP|#_+WgD9Q1R1$T#DYFagGPVDjynNTFP-dpXp^$V!77*Q_NL
zBng&^j!yQ-Cy0~Y49#AM%Ww4uJ!I=$SAHTxBLX0#mKZo0WJT
zsn?Piw<+~U!2)pkbI8XpiE-#1>QCFg_Mv5}xFs_I^CORxnq8*Vs@W@L@G-*pX${8I>7RK;s+fdP`Y`Wao8hs)izSrQM1U-S*+0uv2LUea}
zd-kVvT8)-=^|snq9W8vrI7ZeFoVtDo8+|hC>3YN@X`y2h8B^aX9I8|3t+0)K&XR{m
zGVfi~kP=6vK*|l`v27c
z?aAwR2I-)iAhs`Si4yhnt*ATIJ3Us;ght#&Cs&x^itjonW^61S4^Yn&1rC)PbI1$D
zi|^Vyr{cib7u{@`HW@ga2)L`Y
zJIA|TPjr*fG_04@^pxr?@5qbC!@_eE$E)P6Oa~ftQaDvN=kDBeeonESlT1)ne9@+m
ztY0qOi?x3YS_W=<@X58Ow|9_Y)%8&+)>Z#P58(NSe0yH&-JRJ$cJwB-$Qj(s(^~x%
z7;d2=Zm;8kAb4T=)xOx;?uvd*W
zU+WxCp~i+MugFj=0uah~2OQScg_1dP@FR$Ed&+-&u{p)GffYZW7o@WRUlH#bR17hTcCY~3sDlZ
z?sc&;9}V8NpW>_>!5N9hey+#u#K$(bpQB%aZ&OBjI1-
zZv|e4ubF!~%N3Hrx-W3v-6)_7&z~i4ucwQ@+}j$p5skpd?O`MqoOo#2hMFifiJnFm(}n&%|m8u&5OwOyTzL?s9;gsU~IEz
zOqY;m4~iq)(r?Wcb~HyvN9wt(ObE4@$mk$nUVez@n8%bFGtq#xA=y&Ju~!DSmb1wb
z*Exp^na3*E&X1GOpQFp}gN+Vvr~SrTxICUTWf`&BO*ay)Fa?ATVW(sHTsgaA!GQ=t
z3n|>}P-xhkqK)IYZ5KI2>MszRe>AqqKS!9qWuTVapD80vUu=lCG!{x=@`dXZ=KoLt
zXY!)+_7t(wt!sUIzwW?1I5{-20?wS7Xc?wRO5sD`M>qpw)U4ORp%S|Z7vA2YsZAx>
zVAMT2i#tPwkq=U!JMTeg99F|`#V`GPh!!I*Q*GG8DOyoNS5
zA5v$%Fr{sg-4MOX_I;AxQm|ZiRkS~TEL(I&hZTV>Qg6(Tqn6VY_kMIr>M;_8Lc8M?
zT$nKE+gfRbJsTa9p;2`?Uar6{D-8F`$4c+%FDzd3W7A)W@(YA;N;D_+
zY_`>1sMaWh{7yppVam)ncFi~GanjLadRq
z`}5&?2^g$niYq=Dt*esK>sAGlLm=4H>OAx21c=1bmVBXu1T+XxOgv3ad$|#SY`zwT
zAzJ}7nx);d32%VukOQ54qQNU70`k+5Ywu|Rtfnosd2Z=)rC8pYUREHC&q2b7q9Wq9
z2(XUKfAF3Js6qlOJtlL-zjwcJQmsu!6hvFV(gsy;uK-ezQqO2aIYeZ`AG?(r--zAE
zo*(bYVX~7}8WIRBfZ14UXq*dC$?2za6(U1TE*Mi)@E65^fN(vhzBPu_9Bd!|<^nwN
zDXJ95Q797WTcvuOE9L>dAZS6&d@*c+G9HCns0kfc{?>Rjxx&+1cg#7y`iuVeMb`vY
zv#~OWIjT@XXP^mXh-Pzh^H5LC2kJLy$-Dt^y2C2JjG0V%2^DyfSWL-7v~oqWWI{?U
zVAU;j3d&cD2oZ)60|X@b_OZeBch`GG`?hLJpp>q+qOSyzvvXiwN_OzORdZMI*_=6R
zg%t-Xl+r=JhJzTT;RMkDWDHj~2W0A}^}g+#Ucb<m2(J4
z5KrnI!+vb`V?uB3tO{oYwCLwQkDsejyeQ$mP|QD+bniLlc|RD%CopNAP)%n>(W8N81(GJzT3>24z
ziiw!J6+!fwu%bF@Rqkcz9CeU-2sAhsWQN9Z!;_MruUsKrW{M~{yMlK@7y$lNXuN{D
z3>Z}0B3{hY``O7>TotJ3dIN%9jcU_!O7lqLkDG!bpw341eJbpw)#-`?=iEtX@Oka0
zvNti#WsrP<<@dkH1Jf7AheK%uK%LU+-%LiV>bfbb;lMYrd*-Gk3C|#%XX0#&zy`G2
zEG)xF^RRyCY@n?a)hT^(P$6kquj0P)5rP~WFC4@
zHhPh*lescW9#Lw6%p;voeH^U{S?773387#m4W6=ytE3_mq%-rgV|mG3MG3noX~MhY#j@ewG$wXbK
zNjzNcOmo-Hd=(N*eiw^HUoB(BEgO8)i$9O=?U}!gPvD=%2TpKa`XN<#glD1YZSzX0
zdh-*8Z=vVHBqjJYa`UOKN=8}gY|w2U?j>R65XS!T&pAm!lbX&Uy
zShYR;2RoTQazZ6J=5mMn*iS8mfG3*oy;0TF|5U^z;TLxMPF*Y9yR|)b6o1J}zrJ3V
zDe|4)i7yPgGc^5>dOKl6TGXumgF9r6&GWIEeiOCoLamnw^9VkxtLg}$ptdO#
z8uV<;7G;ULuIN%1_Ak?)p;`@eW5Zrw#&DS|NDHg{MMbDL(MIg=ZnEeoO65*NBte~Q
z^J}OSk@H1%-Q#?e>4oYe?tWH#b=d>6cVJKP#f9^?o6~!d&2P;gdEtag)bnB%u_{VL
zMB)j8QgrukWov>KF~6(dGoE&G`oV$Kzf{L7EmM?@Gd_H7;x_FG!Bk#H>=m*isy));
zNOjH^EGCqH8b_u0a^L2Vc)WSk>Ot>*gBt%4R`?Q^84XTNPKZKC1jHXsF^n=SF@3>j
zo{=C7_sO+$^B4vX6Lfjd7aKgIIj$}Voy~N>T)acAexs=2&~6bpb2_|OW#pgYvG{(@
zxvwjd5<aa&2^&l(3D@^Wh~r=+EQk8e?3*;481;c*}T~8=V#g1gL%x
zyPa>|ZJOI}4O584kSO