Compare commits
415 commits
Author | SHA1 | Date | |
---|---|---|---|
|
0f78fc731a | ||
|
1784f3f28b | ||
|
b794a21dd9 | ||
|
d989da659c | ||
23de987444 | |||
|
9d6271a176 | ||
|
ddc22867b3 | ||
|
5e4e287562 | ||
|
6a0e0580dc | ||
|
7bd6347dd8 | ||
|
360b7f2cf7 | ||
f3a5b9cb4c | |||
18a537b18e | |||
ef6474ef9f | |||
dbf1a0db27 | |||
|
91c5973e31 | ||
978205b823 | |||
8359d0d7ca | |||
93cf6f83df | |||
681d85aac1 | |||
d4f16e6f5e | |||
048111202a | |||
3ea7f36c98 | |||
6c60e3fb7a | |||
313d6d79c5 | |||
189d579d33 | |||
a77c6335a6 | |||
|
82241de0dd | ||
664e424d1a | |||
df6b96fbfd | |||
566125f5c0 | |||
80af909ab0 | |||
ecd460cdfb | |||
|
35c2057f05 | ||
d2e2d00fe1 | |||
e759e495fd | |||
|
3672fd5d45 | ||
1f0b5e867c | |||
8ca7aad3c3 | |||
d923e831f0 | |||
5e0cf270dd | |||
b4a4631a1d | |||
181ec8eb0f | |||
47cbbad8e7 | |||
e793c18215 | |||
|
224b63deb1 | ||
3c01a822fd | |||
4160b6d6ee | |||
853b862f10 | |||
ca70c03e8b | |||
f475095f4a | |||
eae351d8a4 | |||
c1564807f8 | |||
b24005c3fe | |||
22ce2d431a | |||
be41842dae | |||
9720fd01fc | |||
8550adf79e | |||
2b09872131 | |||
d2048d8a34 | |||
02609fdc11 | |||
01f54d79ae | |||
1df982005e | |||
2abb36ad6c | |||
576eaaf990 | |||
97b685363a | |||
a2940ec753 | |||
d5d313064a | |||
f9e305afa4 | |||
4555b3ae09 | |||
64da1d8a34 | |||
a650996ecd | |||
eca556f976 | |||
a3dd82705f | |||
7504ab5a2d | |||
eb42745383 | |||
126d5d3ef5 | |||
4f6669548c | |||
52bd9cc30b | |||
2959bdfad4 | |||
f85cbce4c6 | |||
4517fe62e4 | |||
c6bf287ed1 | |||
35e3676930 | |||
95e9209e17 | |||
d1a685ae34 | |||
e489fb36e9 | |||
09ff4c3a54 | |||
48a5ed7a3b | |||
|
76ae37a9a5 | ||
5d9915bdbd | |||
|
f504b62ff6 | ||
47dbc1a8a4 | |||
7a4f9a45a6 | |||
622e144986 | |||
0632225752 | |||
08d2377404 | |||
e34c6e0ef6 | |||
|
6c5e1c1a80 | ||
ac069551d8 | |||
f5a3524cb9 | |||
f0daac76b4 | |||
|
bbbb010762 | ||
60d05c8b3b | |||
|
ad916cb845 | ||
ef24cb583c | |||
8c19680056 | |||
ad358ad7c2 | |||
067296ffab | |||
6acefda5d3 | |||
24251f0c9c | |||
281c07c849 | |||
9a1756bf6c | |||
34019b5c4a | |||
|
180e8ed0e0 | ||
d63b712007 | |||
3895246b72 | |||
a9783807b3 | |||
ee135eb5fe | |||
f363f9eb17 | |||
74437e90f2 | |||
2156780a3d | |||
5534160aaf | |||
f4a399e24b | |||
c6e3d714a7 | |||
c27d891b65 | |||
347b9cb4dc | |||
5d64692f31 | |||
b1d5344cfa | |||
58d9a993e9 | |||
6a31b4687e | |||
6de8e4f988 | |||
850e990ab4 | |||
421e85cb0b | |||
9a2fa2e8a5 | |||
2b77eae508 | |||
|
bcc64c7205 | ||
dc3c55f570 | |||
637abb50cd | |||
6bc4b7b143 | |||
9fb70f98d6 | |||
|
7b056cb879 | ||
|
6f1e80daf5 | ||
cd2e918247 | |||
|
e5eafc42f0 | ||
474a9b5aaa | |||
355176ce69 | |||
6ac44fa5c0 | |||
12a2981ab5 | |||
538728c491 | |||
8df7600053 | |||
7b64e300b6 | |||
f6bc2e1283 | |||
1650e93920 | |||
9a9c73d929 | |||
03ae9153b4 | |||
dd0a1ca41f | |||
e58ba94d97 | |||
bf12712bca | |||
b7abe949e2 | |||
e56d6a3ebb | |||
691a733fc3 | |||
b89431b779 | |||
d2ea93f05e | |||
991d0d1a08 | |||
a56c6df3f1 | |||
22faa851dc | |||
b24675d48a | |||
11de4efae6 | |||
57458173d0 | |||
c1981d862c | |||
12ecc308d5 | |||
366930bcfc | |||
9ca532a2b4 | |||
a679abeafc | |||
1427284c2a | |||
5a595ec9ee | |||
3ae113212a | |||
656ec4a534 | |||
2ec5ae1d51 | |||
fa5fa809d9 | |||
b8f4adeb50 | |||
768ec6ae02 | |||
ab728c7364 | |||
48c968db2d | |||
758ca5c2dc | |||
9e12f5eb5d | |||
288c20c24b | |||
4978400ece | |||
bcf57bc728 | |||
e9877ca9fa | |||
c86d890c2c | |||
1690e1bff6 | |||
8f95ae0058 | |||
ba67b1ebcb | |||
92ebd45307 | |||
c104dfedc1 | |||
06292c2711 | |||
f8fefcdedd | |||
95129ddeef | |||
33907a1d32 | |||
619e1d517d | |||
f44865fa92 | |||
b715755b91 | |||
6c92f64f8b | |||
6981a7b5e3 | |||
69211a87a3 | |||
050e1171b3 | |||
e2b545ae39 | |||
639e561be6 | |||
41c8a319b1 | |||
adf56c1b38 | |||
fa7f78c734 | |||
d24bcd9ab1 | |||
cd93d047cb | |||
59e8fc050d | |||
c289617ba9 | |||
4c2e122346 | |||
fe23d3fe0a | |||
0981355f28 | |||
0c7a060211 | |||
e410cfc4f1 | |||
19d1f77065 | |||
10e63894c2 | |||
9d11bb0e1f | |||
43c6e812dc | |||
57a614bad3 | |||
5f36282667 | |||
fd762e2bd2 | |||
681b2c4c10 | |||
7578ab3144 | |||
7212154037 | |||
4276964f4d | |||
9c20e780d0 | |||
df352443b7 | |||
425241196d | |||
4e60be61f7 | |||
5ab075c181 | |||
56756cab70 | |||
d9feaeaa7b | |||
2e374d317a | |||
6a8d1bf817 | |||
f61c556f5b | |||
6bbc89490a | |||
cbf6c3b73c | |||
b0be0f636c | |||
694135a10b | |||
2483e85196 | |||
c2953adba5 | |||
7b19f3b66f | |||
6c24a36476 | |||
12849d5a69 | |||
3dec12de20 | |||
007a5bd13c | |||
209b29d217 | |||
99d8672f55 | |||
90420052cf | |||
8beef49b3e | |||
d0c731fc01 | |||
83ce95776e | |||
7fdea2913a | |||
dfc0c1dc80 | |||
77d35e8a09 | |||
c85eee85e9 | |||
40c61fa9ff | |||
73ab149aba | |||
ae7933c79e | |||
c9efbb9936 | |||
68005d757d | |||
55d8ccb0bd | |||
8fceafc3e1 | |||
73b416e3a0 | |||
52df2114e5 | |||
3e137c6480 | |||
db90280641 | |||
975c9e315c | |||
880fa0cc0e | |||
0796df00d4 | |||
c571e9ee8d | |||
f038ab508b | |||
32fb92fb8d | |||
717cc8b0bc | |||
0fd33739c1 | |||
113192042b | |||
52d442f2b0 | |||
2008afa4dd | |||
eba00a112f | |||
6d9eb0ab86 | |||
f460cd4b49 | |||
e585b07f6b | |||
ffab1986a7 | |||
601e400300 | |||
2cdaf39c0f | |||
b9940cd205 | |||
8ce4528c88 | |||
94ad2c441c | |||
ea20afee12 | |||
5e9f9eb80f | |||
2e71e40378 | |||
be78597331 | |||
2acc43d3d6 | |||
|
cb1ba07148 | ||
9b970835c8 | |||
588666ffe1 | |||
926851db19 | |||
dcd94736a9 | |||
c6a1d2c21b | |||
65e9ddf5db | |||
b5c0f5bd36 | |||
aa817a8e95 | |||
271f4ec1dc | |||
ea9a858f48 | |||
309e523cfe | |||
|
87ca73e57a | ||
7b280a2a0a | |||
46e2871e17 | |||
8609652928 | |||
d67b821130 | |||
e29c274aaf | |||
621e599b31 | |||
0b8e41a8ec | |||
235aee8b11 | |||
c189aa3ad3 | |||
98839c8a00 | |||
1010eaec64 | |||
9462957c5e | |||
8d42945c37 | |||
044790a019 | |||
c1c62e7659 | |||
1eb4ed6d23 | |||
46b6d8680c | |||
206e64cd5b | |||
17148e74b6 | |||
9f04b1ae6c | |||
012668dd0a | |||
f259179274 | |||
d0638fdbc4 | |||
f10dc25aeb | |||
dd5532d3a3 | |||
8a35911d00 | |||
e62f5e2319 | |||
942a71efd4 | |||
3642b2cdd1 | |||
6cab8bb2ba | |||
7a0247ea03 | |||
7818b25a5c | |||
0202be19fe | |||
4cd797ac0a | |||
5253e136cc | |||
9e37c073c2 | |||
17b1629cdf | |||
64cbe36dac | |||
639223fcaa | |||
f2af849d0b | |||
ebbb655bfc | |||
d76be1197a | |||
4053563b30 | |||
564e14a370 | |||
c92e41ee56 | |||
8616225a28 | |||
db9b4220ee | |||
|
4c2bc19139 | ||
2dbd42163a | |||
ac25c9985e | |||
df8ebc6af7 | |||
341dc97a51 | |||
e5744e85ad | |||
eabeeeda47 | |||
ba92f23b41 | |||
692a860f6c | |||
c6c8dcedc5 | |||
4977619c70 | |||
c3c4c41c73 | |||
70100f6dc5 | |||
155497c97f | |||
7b1575eb09 | |||
f4b8401bb1 | |||
5e5445f45d | |||
d241273345 | |||
8e22a472cb | |||
7b0d56746f | |||
f302f7a5f8 | |||
f398fb3b6a | |||
9d0f2d1ba1 | |||
ee83def127 | |||
76472fa74a | |||
6c06d63c57 | |||
d6ca6ea3b2 | |||
f1a6d5089b | |||
f56ac321f4 | |||
85d38ede56 | |||
58e991b2b7 | |||
6757723b77 | |||
c3a5e50ad5 | |||
896e1cba42 | |||
6889235b07 | |||
b7416c7434 | |||
e3205af094 | |||
2cefe07a80 | |||
4924e9b3f0 | |||
38f236fc37 | |||
29c03b936d | |||
c90aef3796 | |||
446300e786 | |||
8107411935 | |||
0f7d1a8d42 | |||
d87af324d6 | |||
f8375ed1fc | |||
eb7d14bc33 | |||
d909d427c7 | |||
45e172e181 | |||
d70baa4350 | |||
c374076323 | |||
ac3e1be261 | |||
a7e7d12928 |
271 changed files with 23724 additions and 4033 deletions
|
@ -1,13 +1,7 @@
|
|||
# ./cargo/config
|
||||
[target.x86_64-unknown-linux-gnu]
|
||||
linker = "/usr/bin/clang-15"
|
||||
rustflags = ["-C", "link-arg=--ld-path=/usr/bin/mold", "--cfg", "tokio_unstable"]
|
||||
|
||||
# ./cargo/config.toml
|
||||
[profile.dev]
|
||||
debug = 0
|
||||
strip = "debuginfo"
|
||||
|
||||
[env]
|
||||
RUSTLOG = "hyper=warn"
|
||||
RUSTFLAGS = "--cfg tokio_unstable"
|
||||
|
||||
RUST_LOG = "hyper=warn,git_url_parse=warn,debug"
|
||||
|
|
35
.forgejo/workflows/push-main.yml
Normal file
35
.forgejo/workflows/push-main.yml
Normal file
|
@ -0,0 +1,35 @@
|
|||
name: Release Please
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: write
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
release-plz:
|
||||
name: Release-plz
|
||||
runs-on: docker
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Run release-plz release-pr
|
||||
uses: https://git.kemitix.net/kemitix/rust@v2.3.0
|
||||
with:
|
||||
args: release-plz release-pr --backend gitea --git-token ${{ secrets.FORGEJO_TOKEN }}
|
||||
env:
|
||||
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
|
||||
- name: Run release-plz release
|
||||
uses: https://git.kemitix.net/kemitix/rust@v2.3.0
|
||||
with:
|
||||
args: release-plz release --backend gitea --git-token ${{ secrets.FORGEJO_TOKEN }}
|
||||
env:
|
||||
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
|
52
.forgejo/workflows/push-next.yml
Normal file
52
.forgejo/workflows/push-next.yml
Normal file
|
@ -0,0 +1,52 @@
|
|||
name: Rust
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["next"]
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: docker
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
toolchain:
|
||||
- name: stable
|
||||
- name: nightly
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Check TODOs
|
||||
uses: kemitix/todo-checker@v1.1.0
|
||||
|
||||
- name: Machete
|
||||
uses: https://git.kemitix.net/kemitix/rust@v2.3.0
|
||||
with:
|
||||
args: ${{ matrix.toolchain.name }} cargo machete
|
||||
|
||||
- name: Format
|
||||
uses: https://git.kemitix.net/kemitix/rust@v2.3.0
|
||||
with:
|
||||
args: ${{ matrix.toolchain.name }} cargo fmt --all -- --check
|
||||
|
||||
- name: Clippy
|
||||
uses: https://git.kemitix.net/kemitix/rust@v2.3.0
|
||||
with:
|
||||
args: ${{ matrix.toolchain.name }} cargo hack --feature-powerset clippy
|
||||
|
||||
- name: Build
|
||||
uses: https://git.kemitix.net/kemitix/rust@v2.3.0
|
||||
with:
|
||||
args: ${{ matrix.toolchain.name }} cargo hack --feature-powerset build
|
||||
|
||||
- name: Test
|
||||
uses: https://git.kemitix.net/kemitix/rust@v2.3.0
|
||||
with:
|
||||
args: ${{ matrix.toolchain.name }} cargo hack --feature-powerset test
|
|
@ -1,6 +0,0 @@
|
|||
[branches]
|
||||
main = "main"
|
||||
next = "next"
|
||||
dev = "dev"
|
||||
|
||||
[options]
|
6
.gitignore
vendored
6
.gitignore
vendored
|
@ -1,3 +1,6 @@
|
|||
# git-next ui logs
|
||||
.local/
|
||||
|
||||
# ---> Rust
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
|
@ -9,7 +12,7 @@ target/
|
|||
|
||||
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
|
||||
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
|
||||
Cargo.lock
|
||||
# Cargo.lock
|
||||
|
||||
# These are backup files generated by rustfmt
|
||||
**/*.rs.bk
|
||||
|
@ -22,3 +25,4 @@ git-next-server.toml
|
|||
data/
|
||||
.git-next.toml
|
||||
.envrc
|
||||
*.profraw
|
||||
|
|
|
@ -1,34 +1,13 @@
|
|||
steps:
|
||||
todo_check:
|
||||
# INFO: https://woodpecker-ci.org/plugins/TODO-Checker
|
||||
image: codeberg.org/epsilon_02/todo-checker:1.1
|
||||
docker-build:
|
||||
when:
|
||||
- event: push
|
||||
branch: next
|
||||
# INFO: https://woodpecker-ci.org/plugins/Docker%20Buildx
|
||||
image: docker.io/woodpeckerci/plugin-docker-buildx:5.0.0
|
||||
settings:
|
||||
# git-next-woodpecker-todo-checker - read:issue
|
||||
repository_token: "776a3b928b852472c2af727a360c85c00af64b9f"
|
||||
prefix_regex: "(#|//) (TODO|FIXME): "
|
||||
debug: false
|
||||
|
||||
lint_and_build:
|
||||
when:
|
||||
- event: push
|
||||
branch: next
|
||||
image: git.kemitix.net/kemitix/git-next-builder:latest
|
||||
environment:
|
||||
CARGO_TERM_COLOR: always
|
||||
commands:
|
||||
- cargo fmt --all -- --check
|
||||
- cargo clippy -- -D warnings
|
||||
- cargo build
|
||||
|
||||
test:
|
||||
when:
|
||||
- event: push
|
||||
branch: next
|
||||
image: git.kemitix.net/kemitix/git-next-builder:latest
|
||||
environment:
|
||||
CARGO_TERM_COLOR: always
|
||||
commands:
|
||||
- cargo test
|
||||
username: kemitix
|
||||
repo: git.kemitix.net/kemitix/git-next
|
||||
dockerfile: Dockerfile
|
||||
auto_tag: false
|
||||
dry-run: true # don't push to remote repo
|
||||
|
|
|
@ -1,23 +1,10 @@
|
|||
steps:
|
||||
publish-to-forgejo:
|
||||
when:
|
||||
- event: tag
|
||||
ref: refs/tags/v*
|
||||
# INFO: https://woodpecker-ci.org/plugins/Gitea%20Release
|
||||
image: docker.io/woodpeckerci/plugin-gitea-release:0.3.1
|
||||
settings:
|
||||
base_url: https://git.kemitix.net
|
||||
api_key:
|
||||
from_secret: FORGEJO_RELEASE_PLUGIN
|
||||
target: main
|
||||
prerelease: true
|
||||
|
||||
docker-build:
|
||||
when:
|
||||
- event: tag
|
||||
ref: refs/tags/v*
|
||||
# INFO: https://woodpecker-ci.org/plugins/Docker%20Buildx
|
||||
image: docker.io/woodpeckerci/plugin-docker-buildx:4.0.0
|
||||
image: docker.io/woodpeckerci/plugin-docker-buildx:5.0.0
|
||||
settings:
|
||||
username: kemitix
|
||||
repo: git.kemitix.net/kemitix/git-next
|
||||
|
|
1175
CHANGELOG.md
Normal file
1175
CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load diff
5060
Cargo.lock
generated
Normal file
5060
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
102
Cargo.toml
102
Cargo.toml
|
@ -1,48 +1,66 @@
|
|||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["crates/cli", "crates/server", "crates/config", "crates/git"]
|
||||
members = ["crates/*"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.3.3"
|
||||
edition = "2021"
|
||||
version = "0.13.11"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
nursery = { level = "warn", priority = -1 }
|
||||
# pedantic = "warn"
|
||||
unwrap_used = "warn"
|
||||
expect_used = "warn"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
repository = "https://git.kemitix.net/kemitix/git-next"
|
||||
authors = ["Paul Campbell <pcampbell@kemitix.net>"]
|
||||
rust-version = "1.76"
|
||||
description = "trunk-based development manager"
|
||||
documentation = "https://git.kemitix.net/kemitix/git-next/src/branch/main/README.md"
|
||||
keywords = ["git", "cli", "server", "tool"]
|
||||
categories = ["development-tools"]
|
||||
|
||||
# [workspace.lints.clippy]
|
||||
# pedantic = { level = "warn", priority = -1 }
|
||||
# nursery = { level = "warn", priority = -1 }
|
||||
# unwrap_used = "warn"
|
||||
# expect_used = "warn"
|
||||
|
||||
[workspace.dependencies]
|
||||
git-next-server = { path = "crates/server" }
|
||||
git-next-config = { path = "crates/config" }
|
||||
git-next-git = { path = "crates/git" }
|
||||
git-next-core = { path = "crates/core", version = "0.13" }
|
||||
git-next-forge-forgejo = { path = "crates/forge-forgejo", version = "0.13" }
|
||||
git-next-forge-github = { path = "crates/forge-github", version = "0.13" }
|
||||
|
||||
# TUI
|
||||
ratatui = "0.29"
|
||||
directories = "5.0"
|
||||
lazy_static = "1.5"
|
||||
color-eyre = "0.6"
|
||||
tui-scrollview = "0.5"
|
||||
regex = "1.10"
|
||||
chrono = "0.4"
|
||||
|
||||
# CLI parsing
|
||||
clap = { version = "4.5", features = ["cargo", "derive"] }
|
||||
|
||||
# logging
|
||||
console-subscriber = "0.2"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = "0.3"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
tracing-error = "0.2.0"
|
||||
|
||||
# base64 decoding
|
||||
base64 = "0.22"
|
||||
|
||||
# sha256 encoding (e.g. verify github webhooks)
|
||||
hmac = "0.12"
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
|
||||
# git
|
||||
# gix = "0.62"
|
||||
gix = { version = "0.62", features = [
|
||||
"basic",
|
||||
"extras",
|
||||
"comfort",
|
||||
gix = { version = "0.67", features = [
|
||||
"dirwalk",
|
||||
"blocking-http-transport-reqwest-rust-tls",
|
||||
] }
|
||||
async-trait = "0.1"
|
||||
git-url-parse = "0.4"
|
||||
|
||||
# fs/network
|
||||
kxio = { version = "1.1" }
|
||||
|
||||
# fs
|
||||
tempfile = "3.10"
|
||||
kxio = { version = "1.2" }
|
||||
|
||||
# TOML parsing
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
|
@ -50,7 +68,7 @@ serde_json = "1.0"
|
|||
toml = "0.8"
|
||||
|
||||
# Secrets and Password
|
||||
secrecy = "0.8"
|
||||
secrecy = "0.10"
|
||||
|
||||
# Conventional Commit check
|
||||
git-conventional = "0.12"
|
||||
|
@ -59,21 +77,45 @@ git-conventional = "0.12"
|
|||
bytes = "1.6"
|
||||
ulid = "1.1"
|
||||
warp = "0.3"
|
||||
time = "0.3"
|
||||
standardwebhooks = "1.0"
|
||||
|
||||
# error handling
|
||||
derive_more = { version = "1.0.0-beta.6", features = ["from", "display"] }
|
||||
terrors = "0.3"
|
||||
# boilerplate
|
||||
bon = "2.0"
|
||||
derive_more = { version = "1.0.0-beta", features = [
|
||||
"as_ref",
|
||||
"constructor",
|
||||
"display",
|
||||
"deref",
|
||||
"from",
|
||||
] }
|
||||
derive-with = "0.5"
|
||||
anyhow = "1.0"
|
||||
thiserror = "1.0"
|
||||
pike = "0.1"
|
||||
|
||||
# iters
|
||||
take-until = "0.2"
|
||||
|
||||
# file watcher
|
||||
inotify = "0.10"
|
||||
notify = "7.0"
|
||||
|
||||
# Actors
|
||||
actix = "0.13"
|
||||
actix-rt = "2.9"
|
||||
tokio = { version = "1.37", features = ["full"] }
|
||||
tokio = { version = "1.37", features = ["rt", "macros"] }
|
||||
|
||||
# email
|
||||
lettre = "0.11"
|
||||
sendmail = "2.0"
|
||||
|
||||
# desktop notifications
|
||||
notifica = "3.0"
|
||||
|
||||
# Testing
|
||||
assert2 = "0.3"
|
||||
pretty_assertions = "1.4"
|
||||
test-log = { version = "0.2", features = ["env_logger", "trace"] }
|
||||
anyhow = "1.0"
|
||||
rand = "0.8"
|
||||
mockall = "0.13"
|
||||
test-log = "0.2"
|
||||
rstest = { version = "0.23", features = ["async-timeout"] }
|
||||
|
|
20
Dockerfile
20
Dockerfile
|
@ -1,30 +1,30 @@
|
|||
# Leveraging the pre-built Docker images with
|
||||
# cargo-chef and the Rust toolchain
|
||||
FROM git.kemitix.net/kemitix/git-next-builder:latest AS chef
|
||||
FROM git.kemitix.net/kemitix/git-next-builder:2024.08.04 AS chef
|
||||
WORKDIR /app
|
||||
|
||||
FROM chef as planner
|
||||
FROM chef AS planner
|
||||
COPY Cargo.toml ./
|
||||
COPY crates crates
|
||||
RUN cargo chef prepare --recipe-path recipe.json
|
||||
|
||||
FROM chef as builder
|
||||
FROM chef AS builder
|
||||
COPY --from=planner /app/recipe.json recipe.json
|
||||
RUN cargo chef cook --profile release --recipe-path recipe.json
|
||||
COPY . .
|
||||
RUN cargo build --release --bin git-next && \
|
||||
RUN cargo build --release --bin git-next --all-features && \
|
||||
strip target/release/git-next
|
||||
|
||||
FROM docker.io/debian:stable-20240423-slim AS runtime
|
||||
FROM docker.io/debian:stable-20240904-slim AS runtime
|
||||
WORKDIR /app
|
||||
RUN apt-get update && \
|
||||
apt-get install --no-install-recommends -y \
|
||||
git=1:2.39.2-1.1 \
|
||||
libssl3=3.0.11-1~deb12u2 \
|
||||
ca-certificates=20230311 \
|
||||
apt-get satisfy -y "git (>=2.39), libssl3 (>=3.0.14), libdbus-1-dev (>=1.14.10), ca-certificates (>=20230311)" \
|
||||
&& \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
USER 1000
|
||||
COPY --from=builder /app/target/release/git-next /usr/local/bin
|
||||
|
||||
ENTRYPOINT [ "/usr/local/bin/git-next", "server", "start" ]
|
||||
ENV HOME=/app
|
||||
|
||||
ENTRYPOINT [ "/usr/local/bin/git-next" ]
|
||||
CMD [ "server", "start" ]
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
FROM docker.io/rust:latest
|
||||
FROM docker.io/rust:1.82.0-bookworm
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y clang-15 mold && \
|
||||
apt-get install -y libdbus-1-dev && \
|
||||
curl -L https://github.com/cargo-bins/cargo-binstall/releases/latest/download/cargo-binstall-x86_64-unknown-linux-musl.tgz -o cargo-binstall.tgz && \
|
||||
tar -xzf cargo-binstall.tgz && \
|
||||
rm cargo-binstall.tgz && \
|
||||
|
@ -15,8 +15,6 @@ RUN cargo chef --version
|
|||
RUN rustfmt --version
|
||||
RUN cargo fmt --version
|
||||
RUN cargo clippy --version
|
||||
RUN mold --version
|
||||
RUN clang-15 --version
|
||||
RUN cargo --version
|
||||
RUN rustc --version
|
||||
RUN rustup --version
|
||||
|
|
164
README.md
164
README.md
|
@ -1,164 +1,12 @@
|
|||
# git-next
|
||||
|
||||
`git-next` is a combined server and command-line tool that enables trunk-based development workflows
|
||||
where each commit must pass CI before being included in the main branch.
|
||||
## Trunk-based developement manager.
|
||||
|
||||
[![status-badge](https://ci.kemitix.net/api/badges/52/status.svg)](https://ci.kemitix.net/repos/52)
|
||||
`git-next` is a combined server and command-line tool that enables trunk-based
|
||||
development workflows where each commit must pass CI before being included in
|
||||
the main branch.
|
||||
|
||||
## Features
|
||||
![Demo](./demo.gif)
|
||||
|
||||
- Enforce the requirement for each commit to pass the CI pipeline before being included in the main branch
|
||||
- Provide a server component that manages the trunk-based development process
|
||||
- Ensure a consistent, high-quality codebase by preventing untested changes from being merged
|
||||
|
||||
## Installation
|
||||
|
||||
You can install `git-next` using Cargo:
|
||||
|
||||
```shell
|
||||
cargo install git-next
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
- The repo has a `.git-next.toml` file in it's root. (N.B. see [#28](https://git.kemitix.net/kemitix/git-next/issues/28) for a planned alternative)
|
||||
- CI checks should be configured to run when the `next` branch is `pushed`.
|
||||
- The `dev` branch _must_ have the `main` branch as an ancestor.
|
||||
- The `next` branch _must_ have the `main` branch as an ancestor.
|
||||
|
||||
## Behaviour
|
||||
|
||||
Development happens on the `dev` branch, where each commit is expected to be able to pass the CI checks.
|
||||
|
||||
(Note: in the diagrams, mermaid isn't capable of showing `main` and `next` on the same commit, so we show `next` as empty)
|
||||
|
||||
```mermaid
|
||||
gitGraph
|
||||
commit
|
||||
commit
|
||||
|
||||
branch next
|
||||
|
||||
branch dev
|
||||
commit
|
||||
commit
|
||||
commit
|
||||
```
|
||||
|
||||
When the `git-next` server sees that the `dev` branch is ahead of the `next` branch, it will push the `next` branch
|
||||
fast-forward one commit along the `dev` branch.
|
||||
|
||||
```mermaid
|
||||
gitGraph
|
||||
commit
|
||||
commit
|
||||
|
||||
branch next
|
||||
commit
|
||||
|
||||
branch dev
|
||||
commit
|
||||
commit
|
||||
```
|
||||
|
||||
It will then wait for the CI checks to pass for the newly updated `next` branch.
|
||||
When the CI checks for the `next` branch pass, it will push the `main` branch fast-forward to the `next` branch.
|
||||
|
||||
```mermaid
|
||||
gitGraph
|
||||
commit
|
||||
commit
|
||||
commit
|
||||
|
||||
branch next
|
||||
|
||||
branch dev
|
||||
commit
|
||||
commit
|
||||
```
|
||||
|
||||
If the CI checks should fail for the `next` branch, the developer should **amend** that commit in the history of their `dev` branch.
|
||||
They should then force-push their rebased `dev` branch.
|
||||
|
||||
```mermaid
|
||||
gitGraph
|
||||
commit
|
||||
commit
|
||||
|
||||
branch next
|
||||
commit
|
||||
|
||||
checkout main
|
||||
|
||||
branch dev
|
||||
commit
|
||||
|
||||
commit
|
||||
commit
|
||||
```
|
||||
|
||||
`git-next` will then detect that the `next` branch is no longer part of the `dev` branch ancestory, and reset `next` back to `main`.
|
||||
We then return to the top, where `git-next` sees that `dev` is ahead of `next`.
|
||||
|
||||
### Important
|
||||
|
||||
The `dev` branch _should_ have the `next` branch as an ancestor.
|
||||
|
||||
If the `git-next` server finds that this isn't the case, it will **force-push** the `next` branch back to the same commit as the `main` branch.
|
||||
|
||||
In short, the `next` branch **belongs** to `git-next`.
|
||||
|
||||
## Getting Started
|
||||
|
||||
To use `git-next` for trunk-based development, follow these steps:
|
||||
|
||||
### Initialise the repo
|
||||
|
||||
You need to specify which branches you are using
|
||||
|
||||
To create the default config file, run this command in the root of your repo:
|
||||
|
||||
```shell
|
||||
git next init
|
||||
```
|
||||
|
||||
This will create a `.git-next.toml` file. [Default](./default.toml)
|
||||
|
||||
By default the expected branches are `main`, `next` and `dev`. Each of these three branches _must_ exist in your repo.
|
||||
|
||||
### Initialise the server
|
||||
|
||||
The server uses the file `git-next-server.toml` for configuration.
|
||||
|
||||
The create the default config file, run this command:
|
||||
|
||||
```shell
|
||||
git next server init
|
||||
```
|
||||
|
||||
This will create a `git-next-server.toml` file. [Default](./server-default.toml)
|
||||
|
||||
Edit this file to your needs.
|
||||
|
||||
Specify the access token.
|
||||
|
||||
The `branch` parameter for the repo identies the branch where the `.git-next.toml` file should be found.
|
||||
|
||||
### Run the server
|
||||
|
||||
In the directory with your `git-next-server.toml` file, run the command:
|
||||
|
||||
```shell
|
||||
git next server start
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions to `git-next` are welcome! If you find a bug or have a feature request, please [create an issue](https://git.kemitix.net/kemitix/git-next/issues/new).
|
||||
If you'd like to contribute code, feel free to submit a merge request.
|
||||
|
||||
Before you start committing, run the `just install-hooks` command to setup the Git Hooks. ([Get Just](https://just.systems/man/en/chapter_3.html))
|
||||
|
||||
## License
|
||||
|
||||
`git-next` is released under the [MIT License](./LICENSE).
|
||||
See [README.md](https://git.kemitix.net/kemitix/git-next/src/branch/main/crates/cli/README.md) for more information.
|
||||
|
|
18
RELEASE.md
Normal file
18
RELEASE.md
Normal file
|
@ -0,0 +1,18 @@
|
|||
# How to create a new Release
|
||||
|
||||
## TLDR
|
||||
|
||||
1. Merge PR `chore: release`
|
||||
2. Wait for [`push-main` workflow](https://git.kemitix.net/kemitix/git-next/actions?workflow=push-main.yml) to complete
|
||||
3. Replace [Release Notes](https://git.kemitix.net/kemitix/git-next/releases) body with details from [CHANGELOG](https://git.kemitix.net/kemitix/git-next/src/branch/main/CHANGELOG.md) (remove crates and duplicates)
|
||||
4. Update thread: <https://mitra.kemitix.net/post/01907ef5-5bd9-b0b6-2b8a-e29762541d78>
|
||||
|
||||
## Detail
|
||||
|
||||
The workflow in `.forgejo/workflows/push-main.yaml` will create or update a PR whenever `main` branch is updated.
|
||||
|
||||
The create the new release, merge this PR. It will automatically create the Release on the `git-next` repo, and publish crates to <https://crates/io>.
|
||||
|
||||
The Release notes that are included with the create Release are currently incorrect, and will need to be manually updated from the `CHANGELOG.md`. Remove crate headers and any resulting duplicates.
|
||||
|
||||
Post an update to the Fediverse. See link above.
|
|
@ -4,7 +4,7 @@
|
|||
# Complete help on configuration: https://dystroy.org/bacon/config/
|
||||
|
||||
default_job = "check"
|
||||
reverse = true
|
||||
# reverse = true
|
||||
|
||||
[jobs.check]
|
||||
command = ["cargo", "check", "--color", "always"]
|
||||
|
@ -33,7 +33,6 @@ command = [
|
|||
"--",
|
||||
"-Dwarnings",
|
||||
]
|
||||
# "-Wclippy::pedantic",
|
||||
need_stdout = false
|
||||
|
||||
[jobs.test]
|
||||
|
@ -48,7 +47,7 @@ need_stdout = false
|
|||
[jobs.doc-open]
|
||||
command = ["cargo", "doc", "--color", "always", "--no-deps", "--open"]
|
||||
need_stdout = false
|
||||
on_success = "back" # so that we don't open the browser at each change
|
||||
on_success = "back" # so that we don't open the browser at each change
|
||||
|
||||
# You can run your application and have the result displayed in bacon,
|
||||
# *if* it makes sense for this crate. You can run an example the same
|
||||
|
@ -57,7 +56,7 @@ on_success = "back" # so that we don't open the browser at each change
|
|||
# If you want to pass options to your program, a `--` separator
|
||||
# will be needed.
|
||||
[jobs.run]
|
||||
command = [ "cargo", "run", "--color", "always" ]
|
||||
command = ["cargo", "run", "--color", "always", "--", "server", "start"]
|
||||
need_stdout = true
|
||||
allow_warnings = true
|
||||
|
||||
|
|
77
cliff.toml
Normal file
77
cliff.toml
Normal file
|
@ -0,0 +1,77 @@
|
|||
# git-cliff ~ configuration file
|
||||
# https://git-cliff.org/docs/configuration
|
||||
|
||||
[changelog]
|
||||
# changelog header
|
||||
header = """
|
||||
# Changelog\n
|
||||
All notable changes to this project will be documented in this file.\n
|
||||
"""
|
||||
# template for the changelog body
|
||||
# https://keats.github.io/tera/docs/#introduction
|
||||
body = """
|
||||
{% if version %}\
|
||||
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
|
||||
{% else %}\
|
||||
## [unreleased]
|
||||
{% endif %}\
|
||||
{% if previous %}\
|
||||
{% if previous.commit_id and commit_id %}
|
||||
[{{ previous.commit_id | truncate(length=7, end="") }}](https://git.kemitix.net/kemitix/git-next/commit/{{ previous.commit_id }})...\
|
||||
[{{ commit_id | truncate(length=7, end="") }}](https://git.kemitix.net/kemitix/git-next/commit/{{ commit_id }})
|
||||
{% endif %}\
|
||||
{% endif %}\
|
||||
{% for group, commits in commits | group_by(attribute="group") %}
|
||||
### {{ group | upper_first }}
|
||||
{% for commit in commits %}
|
||||
- {{ commit.message | upper_first }} ([{{ commit.id | truncate(length=7, end="") }}](https://git.kemitix.net/kemitix/git-next/commit/{{ commit.id }}))\
|
||||
{% for footer in commit.footers -%}
|
||||
, {{ footer.token }}{{ footer.separator }}{{ footer.value }}\
|
||||
{% endfor %}\
|
||||
{% endfor %}
|
||||
{% endfor %}\n
|
||||
"""
|
||||
# template for the changelog footer
|
||||
footer = """
|
||||
<!-- generated by git-cliff -->
|
||||
"""
|
||||
# remove the leading and trailing whitespace from the templates
|
||||
trim = true
|
||||
|
||||
[git]
|
||||
# parse the commits based on https://www.conventionalcommits.org
|
||||
conventional_commits = true
|
||||
# filter out the commits that are not conventional
|
||||
filter_unconventional = true
|
||||
# process each line of a commit as an individual commit
|
||||
split_commits = false
|
||||
# regex for parsing and grouping commits
|
||||
commit_parsers = [
|
||||
{ message = "^feat", group = "Features" },
|
||||
{ message = "^fix", group = "Bug Fixes" },
|
||||
{ message = "^doc", group = "Documentation" },
|
||||
{ message = "^perf", group = "Performance" },
|
||||
{ message = "^refactor", group = "Refactor" },
|
||||
{ message = "^style", group = "Styling" },
|
||||
{ message = "^test", group = "Testing" },
|
||||
{ message = "^chore\\(deps.*\\)", skip = true },
|
||||
{ message = "^chore\\(pr\\)", skip = true },
|
||||
{ message = "^chore\\(pull\\)", skip = true },
|
||||
{ message = "^chore\\(release\\): prepare for", skip = true },
|
||||
{ message = "^chore|^ci", group = "Miscellaneous Tasks" },
|
||||
{ body = ".*security", group = "Security" },
|
||||
]
|
||||
# protect breaking changes from being skipped due to matching a skipping commit_parser
|
||||
protect_breaking_commits = false
|
||||
# filter out the commits that are not matched by commit parsers
|
||||
filter_commits = false
|
||||
# regex for matching git tags
|
||||
tag_pattern = "v[0-9].*"
|
||||
# regex for skipping tags
|
||||
skip_tags = "v0.1.0-beta.1"
|
||||
# regex for ignoring tags
|
||||
ignore_tags = ""
|
||||
# sort the tags topologically
|
||||
topo_order = false
|
||||
# sort the commits inside sections by oldest/newest order
|
||||
sort_commits = "oldest"
|
|
@ -2,9 +2,35 @@
|
|||
name = "git-next"
|
||||
version = { workspace = true }
|
||||
edition = { workspace = true }
|
||||
license = { workspace = true }
|
||||
repository = { workspace = true }
|
||||
description = "git-next, the trunk-based development manager"
|
||||
authors = { workspace = true }
|
||||
rust-version = { workspace = true }
|
||||
documentation = { workspace = true }
|
||||
keywords = { workspace = true }
|
||||
categories = { workspace = true }
|
||||
|
||||
[features]
|
||||
# default = ["forgejo", "github"]
|
||||
default = ["forgejo", "github", "tui"]
|
||||
forgejo = ["git-next-forge-forgejo"]
|
||||
github = ["git-next-forge-github"]
|
||||
tui = ["ratatui", "directories", "lazy_static", "tui-scrollview", "regex", "chrono"]
|
||||
|
||||
[dependencies]
|
||||
git-next-server = { workspace = true }
|
||||
git-next-core = { workspace = true }
|
||||
git-next-forge-forgejo = { workspace = true, optional = true }
|
||||
git-next-forge-github = { workspace = true, optional = true }
|
||||
|
||||
# TUI
|
||||
ratatui = { workspace = true, optional = true }
|
||||
directories = { workspace = true, optional = true }
|
||||
lazy_static = { workspace = true, optional = true }
|
||||
color-eyre = { workspace = true }
|
||||
tui-scrollview = { workspace = true, optional = true }
|
||||
regex = { workspace = true, optional = true }
|
||||
chrono = { workspace = true, optional = true }
|
||||
|
||||
# CLI parsing
|
||||
clap = { workspace = true }
|
||||
|
@ -12,11 +38,62 @@ clap = { workspace = true }
|
|||
# fs/network
|
||||
kxio = { workspace = true }
|
||||
|
||||
# logging
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
tracing-error.workspace = true
|
||||
|
||||
# Conventional Commit check
|
||||
git-conventional = { workspace = true }
|
||||
|
||||
# TOML parsing
|
||||
toml = { workspace = true }
|
||||
|
||||
# Actors
|
||||
actix = { workspace = true }
|
||||
actix-rt = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
|
||||
# boilerplate
|
||||
bon = { workspace = true }
|
||||
derive_more = { workspace = true }
|
||||
derive-with = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
# Webhooks
|
||||
serde_json = { workspace = true }
|
||||
ulid = { workspace = true }
|
||||
time = { workspace = true }
|
||||
secrecy = { workspace = true }
|
||||
standardwebhooks = { workspace = true }
|
||||
bytes = { workspace = true }
|
||||
warp = { workspace = true }
|
||||
|
||||
# file watcher (linux)
|
||||
notify = { workspace = true }
|
||||
|
||||
# email
|
||||
lettre = { workspace = true }
|
||||
sendmail = { workspace = true }
|
||||
|
||||
# desktop notifications
|
||||
notifica = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
# Testing
|
||||
assert2 = { workspace = true }
|
||||
test-log = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
pretty_assertions = { workspace = true }
|
||||
mockall = { workspace = true }
|
||||
rstest = { workspace = true }
|
||||
|
||||
[lints.clippy]
|
||||
nursery = { level = "warn", priority = -1 }
|
||||
# pedantic = "warn"
|
||||
pedantic = { level = "warn", priority = -1 }
|
||||
unwrap_used = "warn"
|
||||
expect_used = "warn"
|
||||
|
||||
[lints.rust]
|
||||
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(tarpaulin_include)'] }
|
||||
|
|
660
crates/cli/README.md
Normal file
660
crates/cli/README.md
Normal file
|
@ -0,0 +1,660 @@
|
|||
# git-next
|
||||
|
||||
## Trunk-based developement manager.
|
||||
|
||||
> A source-control branching model, where developers collaborate on code in a single branch
|
||||
> called ‘trunk’, resist any pressure to create other long-lived development branches by
|
||||
> employing documented techniques. They therefore avoid merge hell, do not break the build,
|
||||
> and live happily ever after. - [source](https://trunkbaseddevelopment.com)
|
||||
|
||||
- Status: **BETA** - dog-fooding
|
||||
|
||||
`git-next` is a combined server and command-line tool that enables trunk-based
|
||||
development workflows where each commit must pass CI before being included in
|
||||
the main branch.
|
||||
|
||||
## Features
|
||||
|
||||
- Allows enforcing the requirement for each commit to pass the CI pipeline before being
|
||||
included in the main branch
|
||||
- Provides a server component that manages the trunk-based development process
|
||||
- Ensure a consistent, high-quality codebase by preventing untested changes
|
||||
from being added to main
|
||||
- Requires each commit uses conventional commit format.
|
||||
|
||||
See [Behaviour](#behaviour) to learn how we do this.
|
||||
|
||||
## Prerequisits
|
||||
|
||||
- Rust 1.76.0 or later - https://www.rust-lang.org
|
||||
- pgk-config
|
||||
- libssl-dev
|
||||
- libdbus-1-dev (ubuntu/debian)
|
||||
- dbus-devel (fedora)
|
||||
|
||||
See `.cargo/config.toml` for how they are configured.
|
||||
|
||||
## Installation
|
||||
|
||||
You can install `git-next` from <https://crates.io/>:
|
||||
|
||||
```shell
|
||||
cargo install git-next
|
||||
```
|
||||
|
||||
If you use [mise](https://mise.jdx.dev):
|
||||
|
||||
```shell
|
||||
mise use -g cargo:git-next
|
||||
```
|
||||
|
||||
Or you can install `git-next` from source after cloning:
|
||||
|
||||
```shell
|
||||
cargo install --path crates/cli
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [x] cli
|
||||
- [x] server
|
||||
- [x] notifications - notify user when intervention required (e.g. to rebase)
|
||||
- [x] tui overview
|
||||
- [ ] webui overview
|
||||
|
||||
## Branch Names
|
||||
|
||||
`git-next` uses three branches, `main`, `next` and `dev`, although they do not
|
||||
need to have those names. In the documentation we will use those names, but
|
||||
each repo must specify the names of the branches to use for each, even if they
|
||||
happen to have those same names.
|
||||
|
||||
## Configuration
|
||||
|
||||
- The branches to use for `main`, `next` and `dev` must be specified in either
|
||||
the `.git-next.toml` in the repo itself, or in the server configuration file,
|
||||
`git-next-server.toml`. See below for details.
|
||||
- CI checks should be configured to run when the `next` branch is `pushed`.
|
||||
- The `dev` branch _must_ have the `main` branch as an ancestor.
|
||||
- The `next` branch _must_ have the `main` branch as an ancestor.
|
||||
|
||||
### Server
|
||||
|
||||
The server is configured by the `git-next-server.toml` file.
|
||||
|
||||
#### listen
|
||||
|
||||
The server should listen for webhook notifications from each forge.
|
||||
|
||||
```toml
|
||||
[listen]
|
||||
http = { addr = "0.0.0.0", port = 8080 }
|
||||
url = "https://localhost:8080"
|
||||
```
|
||||
|
||||
##### http
|
||||
|
||||
The server needs to be able to receive webhook notifications from your forge,
|
||||
(e.g. github.com). You can do this via any method that suits your environment,
|
||||
e.g. ngrok or a reverse proxy from a web server that itself can route traffic
|
||||
to the machine you are running the git-next server on.
|
||||
|
||||
Specify the address and port the server should listen to for incoming webhooks.
|
||||
This is the address and port that your reverse proxy should route traffic to.
|
||||
|
||||
- **addr** - the IP address the server should bind to
|
||||
- **port** - the IP port the server should bind to
|
||||
|
||||
##### url
|
||||
|
||||
The HTTPS URL for forges to send webhooks to.
|
||||
|
||||
Your forges need to know where they should route webhooks to. This should be
|
||||
an address this is accessible to the forge. So, for github.com, it would need
|
||||
to be a publicly accessible HTTPS URL. For a self-hosted forge, e.g. ForgeJo,
|
||||
on your own network, then it only needs to be accessible from the server your
|
||||
forge is running on.
|
||||
|
||||
#### shout
|
||||
|
||||
The server should be able to notify the user when manual intervention is required.
|
||||
|
||||
```toml
|
||||
[shout]
|
||||
desktop = true
|
||||
|
||||
[shout.webhook]
|
||||
url = "https//localhost:9090"
|
||||
secret = "secret-password"
|
||||
|
||||
[shout.email]
|
||||
from = "git-next@example.com"
|
||||
to = "developer@example.com"
|
||||
|
||||
[shout.email.smtp]
|
||||
hostname = "smtp.example.com"
|
||||
username = "git-next@example.com"
|
||||
password = "MySecretEmailPassword42"
|
||||
```
|
||||
|
||||
##### desktop
|
||||
|
||||
When specified as `true`, desktop notifications will be sent for some events.
|
||||
|
||||
##### webhook
|
||||
|
||||
Will send a POST request for some events.
|
||||
|
||||
- **url** - the URL to POST the notification to and the
|
||||
- **secret** - the sync key used to sign the webhook payload
|
||||
|
||||
See [Notifications](#notifications) for more details.
|
||||
|
||||
##### email
|
||||
|
||||
Will send an email for some events.
|
||||
|
||||
- **from** - the email address to send the email from
|
||||
- **to** - the email address to send the email to
|
||||
|
||||
With just `from` and `to` specified, `git-next` will attempt to send emails
|
||||
with `sendmail` if it is configured.
|
||||
|
||||
Alternativly, you can use an SMTP relay.
|
||||
|
||||
###### smtp
|
||||
|
||||
Will send emails using an SMTP relay.
|
||||
|
||||
- **hostname** - the SMTP relay server
|
||||
- **username** - the account to authenticate as
|
||||
- **password** - the password to authenticate with
|
||||
|
||||
#### storage
|
||||
|
||||
```toml
|
||||
[storage]
|
||||
path = "./data"
|
||||
```
|
||||
|
||||
`git-next` will create a bare clone of each repo that you configure it to
|
||||
monitor. They will all be created in the directory specified here. This data
|
||||
does not need to be backed up, as any missing information will be cloned when
|
||||
the server starts up.
|
||||
|
||||
- **path** - directory to store local copies of monitored repos
|
||||
|
||||
#### forge
|
||||
|
||||
Within the forge tree, specify each forge you want to monitor repos on.
|
||||
|
||||
Give your forge an alias, e.g. `default`, `gh`, `github`.
|
||||
|
||||
e.g.
|
||||
|
||||
```toml
|
||||
[forge.github]
|
||||
forge_type = "GitHub"
|
||||
hostname = "github.com"
|
||||
user = "username"
|
||||
token = "api-key"
|
||||
max_dev_commits = 25
|
||||
```
|
||||
|
||||
- **forge_type** - one of: `ForgeJo` or `GitHub`
|
||||
- **hostname** - the hostname for the forge.
|
||||
- **user** - the user to authenticate as
|
||||
- **token** - application token for the user. See [Forges](#forges) below for the permissions required for each forge.
|
||||
- **max_dev_commits** - [optional] the maximum number of commits allowed between `dev` and `main`. Defaults to 25.
|
||||
|
||||
Generally, the `user` will need to be able to push to `main` and to _force-push_
|
||||
to `next`.
|
||||
|
||||
#### repos
|
||||
|
||||
For each forge, you need to specify which repos on the forge you want to
|
||||
monitor. They do not need to be owned by the `user`, but they `user` must have
|
||||
the `push` and `force-push` permissions as mentioned above for each of the
|
||||
repositories.
|
||||
|
||||
e.g.
|
||||
|
||||
```toml
|
||||
[forge.github.repos]
|
||||
my-repo = { repo = "owner/repo", branch = "main", gitdir = "/home/pcampbell/project/my-repo" }
|
||||
|
||||
[forge.github.repos.other-repo]
|
||||
repo = "user/other"
|
||||
branch = "master"
|
||||
main = "master"
|
||||
next = "ci-testing"
|
||||
dev = "trunk"
|
||||
```
|
||||
|
||||
Note that toml allows specifying the values on one line, or across multiple
|
||||
lines. Both are equivalent. What is not equivalent between `my-repo` and
|
||||
`other-repo`, is that one will require a configuration file within the repo
|
||||
itself. `other-repo` specifies the `main`, `next` and `dev` branches to be
|
||||
used, but `my-repo` doesn't.
|
||||
|
||||
A sample `.git-next-toml` file that would need to exist in `my-repo`'s `owner/repo`
|
||||
repo, on the `main` branch:
|
||||
|
||||
```toml
|
||||
[branches]
|
||||
main = "main"
|
||||
next = "next"
|
||||
dev = "dev"
|
||||
```
|
||||
|
||||
- **repo** - the owner and name of the repo to be monitored
|
||||
- **branch** - the branch to look for a `.git-next.toml` file if needed
|
||||
- **gitdir** - (optional) you can use a local copy of the repo
|
||||
- **main** - the branch to use as `main`
|
||||
- **next** - the branch to use as `next`
|
||||
- **dev** - the branch to use as `dev`
|
||||
|
||||
##### gitdir
|
||||
|
||||
Additional notes on using `gitdir`:
|
||||
|
||||
When you specify the `gitdir` value, the repo cloned in that directory will
|
||||
be used for perform the equivalent of `git fetch`, `git push` and `git push
|
||||
--force-with-lease`.
|
||||
|
||||
These commands will not affect the contents of your working tree, nor will
|
||||
it change any local branches. Only the details about branches on the remote
|
||||
forge will be updated.
|
||||
|
||||
Currently `git-next` can only use a `gitdir` if the forge and repo is the
|
||||
same one specified as the `origin` remote. Otherwise the behaviour is
|
||||
untested and undefined.
|
||||
|
||||
## Webhook Notifications
|
||||
|
||||
When sending a Webhook Notification to a user they are sent using the
|
||||
Standard Webhooks format. That means all POST messages have the
|
||||
following headers:
|
||||
|
||||
- `Webhook-Id`
|
||||
- `Webhook-Signature`
|
||||
- `Webhook-Timestamp`
|
||||
|
||||
### Events
|
||||
|
||||
#### Dev Not Based on Main
|
||||
|
||||
This message `type` indicates that the `dev` branch is not based on `main`.
|
||||
|
||||
**Action Required**: Rebase the `dev` branch onto the `main` branch.
|
||||
|
||||
Sample payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"branches": {
|
||||
"dev": "dev",
|
||||
"main": "main"
|
||||
},
|
||||
"forge_alias": "jo",
|
||||
"repo_alias": "kxio",
|
||||
"log": [
|
||||
"* 9bfce91 (origin/dev) fix: add log graph to notifications",
|
||||
"| * c37bd2c (origin/next, origin/main) feat: add log graph to notifications",
|
||||
"|/",
|
||||
"* 8c19680 refactor: macros use a more common syntax"
|
||||
]
|
||||
},
|
||||
"timestamp": "1721760933",
|
||||
"type": "branch.dev.not-on-main"
|
||||
}
|
||||
```
|
||||
|
||||
#### CI Check Failed
|
||||
|
||||
This message `type` indicates that the commit on the tip of the `next` branch has failed the
|
||||
configured CI checks.
|
||||
|
||||
**Action Required**: Either update the commit to correct the issue CI raised, or, if the issue
|
||||
is transient (e.g. a network issue), re-run/re-start the job in your CI.
|
||||
|
||||
Sample payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"commit": {
|
||||
"sha": "c37bd2caf6825f9770d725a681e5cfc09d7fd4f2",
|
||||
"message": "feat: add log graph to notifications (1 of 2)"
|
||||
},
|
||||
"forge_alias": "jo",
|
||||
"repo_alias": "kxio",
|
||||
"log": [
|
||||
"* 9bfce91 (origin/dev) feat: add log graph to notifications (2 of 2)",
|
||||
"* c37bd2c (origin/next) feat: add log graph to notifications (1 of 2)",
|
||||
"* 8c19680 (origin/main) refactor: macros use a more common syntax"
|
||||
]
|
||||
},
|
||||
"timestamp": "1721760933",
|
||||
"type": "cicheck.failed"
|
||||
}
|
||||
```
|
||||
|
||||
#### Repo Config Load Failed
|
||||
|
||||
This message `type` indicates that `git-next` wasn't able to load the configuration for the
|
||||
repo from the `git-next.toml` file in the repository.
|
||||
|
||||
**Action Required**: Review the `reason` provided.
|
||||
|
||||
Sample payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"reason": "File not found: .git-next.toml",
|
||||
"forge_alias": "jo",
|
||||
"repo_alias": "kxio"
|
||||
},
|
||||
"timestamp": "1721760933",
|
||||
"type": "config.load.failed"
|
||||
}
|
||||
```
|
||||
|
||||
#### Webhook Registration Failed
|
||||
|
||||
This message `type` indicates that `git-next` wasn't able to register it's webhook with the
|
||||
forge repository, so will not receive updates when the branches in the repo are updated.
|
||||
|
||||
**Action Required**: Review the `reason` provided.
|
||||
|
||||
Sample payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"reason": "repo config not loaded",
|
||||
"forge_alias": "jo",
|
||||
"repo_alias": "kxio"
|
||||
},
|
||||
"timestamp": "1721760933",
|
||||
"type": "webhook.registration.failed"
|
||||
}
|
||||
```
|
||||
|
||||
## Behaviour
|
||||
|
||||
The branch names are configurable, but we will talk about `main`, `next` and `dev`.
|
||||
|
||||
Development happens on the `dev` branch, where each commit is expected to
|
||||
be able to pass the CI checks.
|
||||
|
||||
(Note: in the diagrams, mermaid isn't capable of showing `main` and `next` on
|
||||
the same commit, so we show `next` as empty)
|
||||
|
||||
```mermaid
|
||||
gitGraph
|
||||
commit
|
||||
commit
|
||||
|
||||
branch next
|
||||
|
||||
branch dev
|
||||
commit
|
||||
commit
|
||||
commit
|
||||
```
|
||||
|
||||
When the `git-next` server sees that the `dev` branch is ahead of the `next`
|
||||
branch, it will push the `next` branch fast-forward one commit along the `dev`
|
||||
branch.
|
||||
|
||||
```mermaid
|
||||
gitGraph
|
||||
commit
|
||||
commit
|
||||
|
||||
branch next
|
||||
commit
|
||||
|
||||
branch dev
|
||||
commit
|
||||
commit
|
||||
```
|
||||
|
||||
It will then wait for the CI checks to pass for the newly updated `next` branch.
|
||||
When the CI checks for the `next` branch pass, it will push the `main` branch
|
||||
fast-forward to the `next` branch. We return to the top and start again.
|
||||
|
||||
```mermaid
|
||||
gitGraph
|
||||
commit
|
||||
commit
|
||||
commit
|
||||
|
||||
branch next
|
||||
|
||||
branch dev
|
||||
commit
|
||||
commit
|
||||
```
|
||||
|
||||
If the CI checks should fail for the `next` branch, the developer should
|
||||
**amend** that commit **in the history of their `dev` branch**.
|
||||
They should then force-push their rebased `dev` branch.
|
||||
|
||||
```mermaid
|
||||
gitGraph
|
||||
commit
|
||||
commit
|
||||
|
||||
branch next
|
||||
commit
|
||||
|
||||
checkout main
|
||||
|
||||
branch dev
|
||||
commit
|
||||
|
||||
commit
|
||||
commit
|
||||
```
|
||||
|
||||
`git-next` will then detect that the `next` branch is no longer part of the
|
||||
`dev` branch ancestory, and will reset `next` back to `main`.
|
||||
We then return to the top, where `git-next` sees that `dev` is ahead of `next`.
|
||||
|
||||
When the `dev` branch is on the same commit as the `main` branch, then there
|
||||
are no pending commits and `git-next` will wait until it receives a webhook
|
||||
indicating that there has been a push to one of the branches. At which point
|
||||
it will start at the top again.
|
||||
|
||||
### Important
|
||||
|
||||
The `dev` branch _should_ have the `next` branch as an ancestor.
|
||||
|
||||
However, when the commit on tip of the `next` branch has failed CI and is
|
||||
amended, this will not be the case. When this happens `git-next` will
|
||||
**force-push** the `next` branch back to the same commit as the `main` branch.
|
||||
|
||||
This is the only time a force-push will happen in `git-next`.
|
||||
|
||||
In short, the `next` branch **belongs** to `git-next`. Don't try to update it
|
||||
yourself. `git-next` will update the `next` it as it sees fit.
|
||||
|
||||
## Getting Started
|
||||
|
||||
To use `git-next` for trunk-based development, follow these steps:
|
||||
|
||||
### Initialise the repo (optional)
|
||||
|
||||
You need to specify which branches you are using. You can do this in the repo,
|
||||
or in the server configuration.
|
||||
|
||||
To create a default config file for the repo, run this command in the root of
|
||||
your repo:
|
||||
|
||||
```shell
|
||||
git next init
|
||||
```
|
||||
|
||||
This will create a `.git-next.toml` file. [Default](./crates/cli/default.toml)
|
||||
|
||||
By default the expected branches are `main`, `next` and `dev`. Each of these
|
||||
three branches _must_ exist in your repo.
|
||||
|
||||
### Initialise the server
|
||||
|
||||
The server uses the file `git-next-server.toml` for configuration. It expects
|
||||
to find this file the the current directory when executed.
|
||||
|
||||
The create the default config file, run this command:
|
||||
|
||||
```shell
|
||||
git next server init
|
||||
```
|
||||
|
||||
This will create a `git-next-server.toml` file. [Default](./crates/server/server-default.toml)
|
||||
|
||||
Edit this file to your needs. See the [Configuration](#configuration) section above.
|
||||
|
||||
### Run the server
|
||||
|
||||
In the directory with your `git-next-server.toml` file, run the command:
|
||||
|
||||
```shell
|
||||
git next server start
|
||||
```
|
||||
|
||||
### Forges
|
||||
|
||||
The following forges are supported:
|
||||
|
||||
- [ForgeJo](https://forgejo.org) (probably compatible with Gitea, but not tested)
|
||||
- [GitHub](https://github.com/)
|
||||
|
||||
Note: ForgeJo is a hard fork of Gitea, but currently they are largely compatible.
|
||||
For now using a `forge_type` of `ForgeJo` with a Gitea instance will probably work
|
||||
okay. The only API calls we make are around registering and unregistering webhooks.
|
||||
So, as long as those APIs remain the same, they should be compatible.
|
||||
|
||||
#### ForgeJo
|
||||
|
||||
Configure the forge in `git-next-server.toml` like:
|
||||
|
||||
```toml
|
||||
[forge.jo]
|
||||
forge_type = "ForgeJo"
|
||||
hostname = "git.myforgejo.com"
|
||||
user = "bob"
|
||||
token = "..."
|
||||
|
||||
[forge.jo.repos]
|
||||
hello = { repo = "user/hello", branch = "main", gitdir = "/opt/git/projects/user/hello.git" } # maps to https://git.example.net/user/hello on the branch 'main'
|
||||
world = { repo = "user/world", branch = "master", main = "master", next = "upcoming", "dev" = "develop" } # maps to the 'master' branch
|
||||
```
|
||||
|
||||
The token is created on your ForgeJo instance at (for example)
|
||||
`https://git.myforgejo.com/user/settings/applications`
|
||||
and requires the `write:repository` permission.
|
||||
|
||||
#### GitHub
|
||||
|
||||
Configure the forge in `git-next-server.toml` like:
|
||||
|
||||
```toml
|
||||
[forge.gh]
|
||||
forge_type = "GitHub"
|
||||
hostname = "github.com" # required even for GitHub
|
||||
user = "bob"
|
||||
token = "..."
|
||||
|
||||
[forge.gh.repos]
|
||||
hello = { repo = "user/hello", branch = "main", gitdir = "/opt/git/projects/user/hello.git" } # maps to https://github.com/user/hello on the branch 'main'
|
||||
world = { repo = "user/world", branch = "master", main = "master", next = "upcoming", "dev" = "develop" } # maps to the 'master' branch
|
||||
```
|
||||
|
||||
The token is created [here](https://github.com/settings/tokens/new) and requires the `repo` and `admin:repo_hook` permissions.
|
||||
|
||||
## Docker
|
||||
|
||||
`git-next` is available as a [Docker image](https://git.kemitix.net/kemitix/-/packages/container/git-next/).
|
||||
|
||||
```shell
|
||||
docker pull docker pull git.kemitix.net/kemitix/git-next:latest
|
||||
```
|
||||
|
||||
### Docker Compose
|
||||
|
||||
Here is an example `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
server:
|
||||
image: git.kemitix.net/kemitix/git-next:latest
|
||||
container_name: git-next-server
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
RUST_LOG: "hyper=warn,info"
|
||||
ports:
|
||||
- 8080:8092
|
||||
volumes:
|
||||
- ./:/app/
|
||||
```
|
||||
|
||||
Note: this assumes the `git-next-server.toml` has a `listen.http.port` of
|
||||
`8092` and that you are using a reverse proxy to route traffic arriving at
|
||||
`listen.url` to port `8080`.
|
||||
|
||||
### Docker Run
|
||||
|
||||
This will run with the `server start` options:
|
||||
|
||||
```shell
|
||||
docker run -it -p "8080:8092" -v .:/app/ git.kemitix.net/kemitix/git-next:latest
|
||||
```
|
||||
|
||||
To perform `server init`:
|
||||
|
||||
```shell
|
||||
docker run -it -v .:/app/ git.kemitix.net/kemitix/git-next:latest server init
|
||||
```
|
||||
|
||||
To perform repo `init`:
|
||||
|
||||
```shell
|
||||
docker run -it -v .:/app/ git.kemitix.net/kemitix/git-next:latest init
|
||||
```
|
||||
|
||||
TUI support is not available in the docker container. See [kemitix/git-next#154](https://git.kemitix.net/kemitix/git-next/issues/154).
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions to `git-next` are welcome! If you find a bug or have a feature
|
||||
request, please
|
||||
[create an issue](https://git.kemitix.net/kemitix/git-next/issues/new).
|
||||
If you'd like to contribute code, feel free to submit changes.
|
||||
|
||||
Before you start committing, run the `just install-hooks` command to setup the
|
||||
Git Hooks. ([Get Just](https://just.systems/man/en/chapter_3.html))
|
||||
|
||||
## Crate Dependency
|
||||
|
||||
The following diagram shows the dependency between the crates that make up `git-next`:
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
|
||||
cli --> core
|
||||
cli --> forge_forgejo
|
||||
cli --> forge_github
|
||||
|
||||
forge_forgejo --> core
|
||||
|
||||
forge_github --> core
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
`git-next` is released under the [MIT License](./LICENSE).
|
10
crates/cli/src/alerts/desktop.rs
Normal file
10
crates/cli/src/alerts/desktop.rs
Normal file
|
@ -0,0 +1,10 @@
|
|||
//
|
||||
use crate::alerts::short_message;
|
||||
use git_next_core::git::UserNotification;
|
||||
|
||||
pub(super) fn send_desktop_notification(user_notification: &UserNotification) {
|
||||
let message = short_message(user_notification);
|
||||
if let Err(err) = notifica::notify("git-next", &message) {
|
||||
tracing::warn!(?err, "failed to send desktop notification");
|
||||
}
|
||||
}
|
71
crates/cli/src/alerts/email.rs
Normal file
71
crates/cli/src/alerts/email.rs
Normal file
|
@ -0,0 +1,71 @@
|
|||
//
|
||||
use git_next_core::{
|
||||
git::UserNotification,
|
||||
server::{EmailConfig, SmtpConfig},
|
||||
};
|
||||
|
||||
use crate::alerts::{full_message, short_message};
|
||||
|
||||
#[derive(Debug)]
|
||||
struct EmailMessage {
|
||||
from: String,
|
||||
to: String,
|
||||
subject: String,
|
||||
body: String,
|
||||
}
|
||||
|
||||
pub(super) fn send_email(user_notification: &UserNotification, email_config: &EmailConfig) {
|
||||
let email_message = EmailMessage {
|
||||
from: email_config.from().to_string(),
|
||||
to: email_config.to().to_string(),
|
||||
subject: short_message(user_notification),
|
||||
body: full_message(user_notification),
|
||||
};
|
||||
match email_config.smtp() {
|
||||
Some(smtp) => send_email_smtp(email_message, smtp),
|
||||
None => send_email_sendmail(&email_message),
|
||||
}
|
||||
}
|
||||
|
||||
fn send_email_sendmail(email_message: &EmailMessage) {
|
||||
use sendmail::email;
|
||||
match email::send(
|
||||
&email_message.from,
|
||||
[email_message.to.as_ref()],
|
||||
&email_message.subject,
|
||||
&email_message.body,
|
||||
) {
|
||||
Ok(()) => tracing::info!("Email sent successfully!"),
|
||||
Err(e) => tracing::warn!("Could not send email: {:?}", e),
|
||||
}
|
||||
}
|
||||
|
||||
fn send_email_smtp(email_message: EmailMessage, smtp: &SmtpConfig) {
|
||||
if let Err(err) = do_send_email_smtp(email_message, smtp) {
|
||||
tracing::warn!(?err, "sending email");
|
||||
}
|
||||
}
|
||||
|
||||
fn do_send_email_smtp(email_message: EmailMessage, smtp: &SmtpConfig) -> Result<(), anyhow::Error> {
|
||||
use lettre::{transport::smtp::authentication::Credentials, Message, SmtpTransport, Transport};
|
||||
let email = Message::builder()
|
||||
.from(email_message.from.parse()?)
|
||||
.to(email_message.to.parse()?)
|
||||
.subject(email_message.subject)
|
||||
.body(email_message.body)?;
|
||||
let creds = Credentials::new(smtp.username().to_string(), smtp.password().to_string());
|
||||
let mailer = SmtpTransport::relay(smtp.hostname())?
|
||||
.credentials(creds)
|
||||
.build();
|
||||
Ok(mailer
|
||||
.send(&email)
|
||||
.map(|response| {
|
||||
response
|
||||
.message()
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.map(|response| {
|
||||
tracing::info!(?response, "email sent via smtp");
|
||||
})?)
|
||||
}
|
2
crates/cli/src/alerts/handlers/mod.rs
Normal file
2
crates/cli/src/alerts/handlers/mod.rs
Normal file
|
@ -0,0 +1,2 @@
|
|||
mod notify_user;
|
||||
mod update_shout;
|
41
crates/cli/src/alerts/handlers/notify_user.rs
Normal file
41
crates/cli/src/alerts/handlers/notify_user.rs
Normal file
|
@ -0,0 +1,41 @@
|
|||
//
|
||||
use actix::prelude::*;
|
||||
|
||||
use tracing::{info, Instrument as _};
|
||||
|
||||
use crate::alerts::{
|
||||
desktop::send_desktop_notification, email::send_email, messages::NotifyUser,
|
||||
webhook::send_webhook, AlertsActor,
|
||||
};
|
||||
|
||||
impl Handler<NotifyUser> for AlertsActor {
|
||||
type Result = ();
|
||||
|
||||
fn handle(&mut self, msg: NotifyUser, ctx: &mut Self::Context) -> Self::Result {
|
||||
let Some(shout) = &self.shout else {
|
||||
info!("No shout config available");
|
||||
return;
|
||||
};
|
||||
let net = self.net.clone();
|
||||
let shout = shout.clone();
|
||||
let Some(user_notification) = self.history.sendable(msg.peel()) else {
|
||||
return;
|
||||
};
|
||||
async move {
|
||||
if let Some(webhook_config) = shout.webhook() {
|
||||
send_webhook(&user_notification, webhook_config, &net).await;
|
||||
}
|
||||
if let Some(email_config) = shout.email() {
|
||||
send_email(&user_notification, email_config);
|
||||
}
|
||||
if let Some(desktop) = shout.desktop() {
|
||||
if desktop {
|
||||
send_desktop_notification(&user_notification);
|
||||
}
|
||||
}
|
||||
}
|
||||
.in_current_span()
|
||||
.into_actor(self)
|
||||
.wait(ctx);
|
||||
}
|
||||
}
|
12
crates/cli/src/alerts/handlers/update_shout.rs
Normal file
12
crates/cli/src/alerts/handlers/update_shout.rs
Normal file
|
@ -0,0 +1,12 @@
|
|||
//
|
||||
use actix::prelude::*;
|
||||
|
||||
use crate::alerts::{messages::UpdateShout, AlertsActor};
|
||||
|
||||
impl Handler<UpdateShout> for AlertsActor {
|
||||
type Result = ();
|
||||
|
||||
fn handle(&mut self, msg: UpdateShout, _ctx: &mut Self::Context) -> Self::Result {
|
||||
self.shout.replace(msg.peel());
|
||||
}
|
||||
}
|
50
crates/cli/src/alerts/history.rs
Normal file
50
crates/cli/src/alerts/history.rs
Normal file
|
@ -0,0 +1,50 @@
|
|||
//
|
||||
use git_next_core::git::UserNotification;
|
||||
use tracing::info;
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct History {
|
||||
/// The maximum age of an item in the history.
|
||||
///
|
||||
/// Items older than this will be dropped.
|
||||
max_age_seconds: Duration,
|
||||
|
||||
/// Maps a user notification to when it was last seen.
|
||||
///
|
||||
/// The user notification will not be sent until after `max_age_seconds` from last seen.
|
||||
///
|
||||
/// Each time we see a given user notification, the last seen time will be updated.
|
||||
items: HashMap<UserNotification, Instant>,
|
||||
}
|
||||
impl History {
|
||||
pub fn new(max_age_seconds: Duration) -> Self {
|
||||
Self {
|
||||
max_age_seconds,
|
||||
items: HashMap::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sendable(&mut self, user_notification: UserNotification) -> Option<UserNotification> {
|
||||
let now = Instant::now();
|
||||
self.prune(&now); // remove expired items first
|
||||
let contains_key = self.items.contains_key(&user_notification);
|
||||
self.items.insert(user_notification.clone(), now);
|
||||
if contains_key {
|
||||
info!("previously sent");
|
||||
return None;
|
||||
}
|
||||
info!("not sent before");
|
||||
Some(user_notification)
|
||||
}
|
||||
|
||||
pub fn prune(&mut self, now: &Instant) {
|
||||
if let Some(threshold) = now.checked_sub(self.max_age_seconds) {
|
||||
self.items.retain(|_, last_seen| *last_seen > threshold);
|
||||
};
|
||||
}
|
||||
}
|
10
crates/cli/src/alerts/messages.rs
Normal file
10
crates/cli/src/alerts/messages.rs
Normal file
|
@ -0,0 +1,10 @@
|
|||
//
|
||||
use git_next_core::{git::UserNotification, message, server::Shout};
|
||||
|
||||
message!(UpdateShout, Shout, "Updated Shout configuration");
|
||||
|
||||
message!(
|
||||
NotifyUser,
|
||||
UserNotification,
|
||||
"Request to send the message payload to the notification webhook"
|
||||
);
|
93
crates/cli/src/alerts/mod.rs
Normal file
93
crates/cli/src/alerts/mod.rs
Normal file
|
@ -0,0 +1,93 @@
|
|||
//
|
||||
use actix::prelude::*;
|
||||
|
||||
use derive_more::derive::Constructor;
|
||||
|
||||
use git_next_core::{git::UserNotification, server::Shout};
|
||||
|
||||
pub use history::History;
|
||||
|
||||
mod desktop;
|
||||
mod email;
|
||||
mod handlers;
|
||||
mod history;
|
||||
pub mod messages;
|
||||
mod webhook;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
#[allow(clippy::module_name_repetitions)]
|
||||
#[derive(Debug, Constructor)]
|
||||
pub struct AlertsActor {
|
||||
shout: Option<Shout>, // config for sending alerts to users
|
||||
history: History, // record of alerts sent recently (e.g. 24 hours)
|
||||
net: kxio::network::Network,
|
||||
}
|
||||
|
||||
impl Actor for AlertsActor {
|
||||
type Context = Context<Self>;
|
||||
}
|
||||
|
||||
fn short_message(user_notification: &UserNotification) -> String {
|
||||
let (forge_alias, repo_alias) = user_notification.aliases();
|
||||
format!("[git-next] {forge_alias}/{repo_alias}: {user_notification}")
|
||||
}
|
||||
|
||||
fn full_message(user_notification: &UserNotification) -> String {
|
||||
match user_notification {
|
||||
UserNotification::CICheckFailed {
|
||||
forge_alias,
|
||||
repo_alias,
|
||||
commit,
|
||||
log,
|
||||
} => {
|
||||
let sha = commit.sha();
|
||||
let message = commit.message();
|
||||
[
|
||||
"CI Checks have Failed".to_string(),
|
||||
format!("Forge: {forge_alias}\nRepo : {repo_alias}"),
|
||||
format!("Commit:\n - {sha}\n - {message}"),
|
||||
"Log:".to_string(),
|
||||
log.join("\n"),
|
||||
]
|
||||
.join("\n\n")
|
||||
}
|
||||
UserNotification::RepoConfigLoadFailure {
|
||||
forge_alias,
|
||||
repo_alias,
|
||||
reason,
|
||||
} => [
|
||||
"Failed to read or parse the .git-next.toml file from repo".to_string(),
|
||||
format!(" - {reason}"),
|
||||
format!("Forge: {forge_alias}\nRepo : {repo_alias}"),
|
||||
]
|
||||
.join("\n\n"),
|
||||
UserNotification::WebhookRegistration {
|
||||
forge_alias,
|
||||
repo_alias,
|
||||
reason,
|
||||
} => [
|
||||
"Failed to register webhook with the forge".to_string(),
|
||||
format!(" - {reason}"),
|
||||
format!("Forge: {forge_alias}\nRepo : {repo_alias}"),
|
||||
]
|
||||
.join("\n\n"),
|
||||
UserNotification::DevNotBasedOnMain {
|
||||
forge_alias,
|
||||
repo_alias,
|
||||
dev_branch,
|
||||
main_branch,
|
||||
dev_commit: _,
|
||||
main_commit: _,
|
||||
log,
|
||||
} => [
|
||||
format!("The branch '{dev_branch}' is not based on the branch '{main_branch}'."),
|
||||
format!("TODO: Rebase '{dev_branch}' onto '{main_branch}'."),
|
||||
format!("Forge: {forge_alias}\nRepo : {repo_alias}"),
|
||||
"Log:".to_string(),
|
||||
log.join("\n"),
|
||||
]
|
||||
.join("\n\n"),
|
||||
}
|
||||
}
|
68
crates/cli/src/alerts/tests/history.rs
Normal file
68
crates/cli/src/alerts/tests/history.rs
Normal file
|
@ -0,0 +1,68 @@
|
|||
use std::time::Duration;
|
||||
|
||||
use assert2::let_assert;
|
||||
use git_next_core::git::UserNotification;
|
||||
|
||||
use crate::{alerts::History, repo::tests::given};
|
||||
|
||||
#[test]
|
||||
fn when_history_is_empty_then_message_is_passed() {
|
||||
let mut history = History::new(Duration::from_millis(1));
|
||||
|
||||
let user_notification = UserNotification::RepoConfigLoadFailure {
|
||||
forge_alias: given::a_forge_alias(),
|
||||
repo_alias: given::a_repo_alias(),
|
||||
reason: given::a_name(),
|
||||
};
|
||||
|
||||
let result = history.sendable(user_notification);
|
||||
|
||||
assert!(result.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn when_history_has_expired_then_message_is_passed() {
|
||||
let dur = Duration::from_millis(1);
|
||||
let mut history = History::new(dur);
|
||||
|
||||
let user_notification = UserNotification::RepoConfigLoadFailure {
|
||||
forge_alias: given::a_forge_alias(),
|
||||
repo_alias: given::a_repo_alias(),
|
||||
reason: given::a_name(),
|
||||
};
|
||||
|
||||
// add message to history
|
||||
let result = history.sendable(user_notification);
|
||||
let_assert!(Some(user_notification) = result);
|
||||
|
||||
std::thread::sleep(dur);
|
||||
// twice the expiry duration to avoid things happening in the wrong order
|
||||
std::thread::sleep(dur);
|
||||
|
||||
// after dur, message has expired, so is still valid
|
||||
let result = history.sendable(user_notification);
|
||||
assert!(result.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn when_history_has_unexpired_then_message_is_blocked() {
|
||||
let dur = Duration::from_secs(1);
|
||||
let mut history = History::new(dur);
|
||||
|
||||
let user_notification = UserNotification::RepoConfigLoadFailure {
|
||||
forge_alias: given::a_forge_alias(),
|
||||
repo_alias: given::a_repo_alias(),
|
||||
reason: given::a_name(),
|
||||
};
|
||||
|
||||
// add message to history
|
||||
let result = history.sendable(user_notification);
|
||||
let_assert!(Some(user_notification) = result);
|
||||
|
||||
// no time passed
|
||||
// std::thread::sleep(dur);
|
||||
|
||||
// after dur, message has expired, so is still valid
|
||||
let result = history.sendable(user_notification);
|
||||
assert!(result.is_none());
|
||||
}
|
1
crates/cli/src/alerts/tests/mod.rs
Normal file
1
crates/cli/src/alerts/tests/mod.rs
Normal file
|
@ -0,0 +1 @@
|
|||
mod history;
|
54
crates/cli/src/alerts/webhook.rs
Normal file
54
crates/cli/src/alerts/webhook.rs
Normal file
|
@ -0,0 +1,54 @@
|
|||
//
|
||||
use git_next_core::{git::UserNotification, server::OutboundWebhook};
|
||||
use kxio::network::{NetRequest, NetUrl, RequestBody, ResponseType};
|
||||
use secrecy::ExposeSecret as _;
|
||||
use standardwebhooks::Webhook;
|
||||
|
||||
pub(super) async fn send_webhook(
|
||||
user_notification: &UserNotification,
|
||||
webhook_config: &OutboundWebhook,
|
||||
net: &kxio::network::Network,
|
||||
) {
|
||||
let Ok(webhook) =
|
||||
Webhook::from_bytes(webhook_config.secret().expose_secret().as_bytes().into())
|
||||
else {
|
||||
tracing::warn!("Invalid notification configuration (signer) - can't sent notification");
|
||||
return;
|
||||
};
|
||||
do_send_webhook(user_notification, webhook, webhook_config, net).await;
|
||||
}
|
||||
|
||||
async fn do_send_webhook(
|
||||
user_notification: &UserNotification,
|
||||
webhook: Webhook,
|
||||
webhook_config: &OutboundWebhook,
|
||||
net: &kxio::network::Network,
|
||||
) {
|
||||
let message_id = format!("msg_{}", ulid::Ulid::new());
|
||||
let timestamp = time::OffsetDateTime::now_utc();
|
||||
let payload = user_notification.as_json(timestamp);
|
||||
let timestamp = timestamp.unix_timestamp();
|
||||
let to_sign = format!("{message_id}.{timestamp}.{payload}");
|
||||
tracing::info!(?to_sign, "");
|
||||
#[allow(clippy::expect_used)]
|
||||
let signature = webhook
|
||||
.sign(&message_id, timestamp, payload.to_string().as_ref())
|
||||
.expect("signature");
|
||||
tracing::info!(?signature, "");
|
||||
let url = webhook_config.url();
|
||||
|
||||
let net_url = NetUrl::new(url.to_string());
|
||||
let request = NetRequest::post(net_url)
|
||||
.body(RequestBody::Json(payload))
|
||||
.header("webhook-id", &message_id)
|
||||
.header("webhook-timestamp", ×tamp.to_string())
|
||||
.header("webhook-signature", &signature)
|
||||
.response_type(ResponseType::None)
|
||||
.build();
|
||||
net.post_json::<()>(request).await.map_or_else(
|
||||
|err| {
|
||||
tracing::warn!(?err, "sending webhook");
|
||||
},
|
||||
|_| (),
|
||||
);
|
||||
}
|
66
crates/cli/src/file_watcher.rs
Normal file
66
crates/cli/src/file_watcher.rs
Normal file
|
@ -0,0 +1,66 @@
|
|||
//
|
||||
use actix::prelude::*;
|
||||
|
||||
use actix::Recipient;
|
||||
use anyhow::{Context, Result};
|
||||
use notify::{event::ModifyKind, Watcher};
|
||||
use tracing::{error, info};
|
||||
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
#[derive(Debug, Message)]
|
||||
#[rtype(result = "()")]
|
||||
pub struct FileUpdated;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("io")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
pub fn watch_file(path: PathBuf, recipient: Recipient<FileUpdated>) -> Result<Arc<AtomicBool>> {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let shutdown = Arc::new(AtomicBool::default());
|
||||
|
||||
let mut handler = notify::recommended_watcher(tx).context("file watcher")?;
|
||||
handler
|
||||
.watch(&path, notify::RecursiveMode::NonRecursive)
|
||||
.with_context(|| format!("Watching: {path:?}"))?;
|
||||
let thread_shutdown = shutdown.clone();
|
||||
actix_rt::task::spawn_blocking(move || {
|
||||
loop {
|
||||
if thread_shutdown.load(Ordering::Relaxed) {
|
||||
drop(handler);
|
||||
break;
|
||||
}
|
||||
for result in rx.try_iter() {
|
||||
match result {
|
||||
Ok(event) => match event.kind {
|
||||
notify::EventKind::Modify(ModifyKind::Data(_)) => {
|
||||
info!("File modified");
|
||||
recipient.do_send(FileUpdated);
|
||||
break;
|
||||
}
|
||||
notify::EventKind::Modify(_)
|
||||
| notify::EventKind::Create(_)
|
||||
| notify::EventKind::Remove(_)
|
||||
| notify::EventKind::Any
|
||||
| notify::EventKind::Access(_)
|
||||
| notify::EventKind::Other => { /* do nothing */ }
|
||||
},
|
||||
Err(err) => {
|
||||
error!(?err, "Watching file: {path:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(1000));
|
||||
}
|
||||
});
|
||||
Ok(shutdown)
|
||||
}
|
32
crates/cli/src/forge/mod.rs
Normal file
32
crates/cli/src/forge/mod.rs
Normal file
|
@ -0,0 +1,32 @@
|
|||
//
|
||||
use git_next_core::git::{ForgeLike, RepoDetails};
|
||||
|
||||
#[cfg(feature = "forgejo")]
|
||||
use git_next_forge_forgejo::ForgeJo;
|
||||
|
||||
#[cfg(feature = "github")]
|
||||
use git_next_forge_github::Github;
|
||||
|
||||
use kxio::network::Network;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Forge;
|
||||
|
||||
impl Forge {
|
||||
pub fn create(repo_details: RepoDetails, net: Network) -> Box<dyn ForgeLike> {
|
||||
match repo_details.forge.forge_type() {
|
||||
#[cfg(feature = "forgejo")]
|
||||
git_next_core::ForgeType::ForgeJo => Box::new(ForgeJo::new(repo_details, net)),
|
||||
#[cfg(feature = "github")]
|
||||
git_next_core::ForgeType::GitHub => Box::new(Github::new(repo_details, net)),
|
||||
_ => {
|
||||
drop(repo_details);
|
||||
drop(net);
|
||||
unreachable!();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub mod tests;
|
42
crates/cli/src/forge/tests.rs
Normal file
42
crates/cli/src/forge/tests.rs
Normal file
|
@ -0,0 +1,42 @@
|
|||
//
|
||||
#[cfg(any(feature = "forgejo", feature = "github"))]
|
||||
use super::*;
|
||||
|
||||
use git_next_core::{
|
||||
self as core,
|
||||
git::{self, RepoDetails},
|
||||
GitDir, RepoConfigSource, StoragePathType,
|
||||
};
|
||||
|
||||
#[cfg(feature = "forgejo")]
|
||||
#[test]
|
||||
fn test_forgejo_name() {
|
||||
let net = Network::new_mock();
|
||||
let repo_details = given_repo_details(git_next_core::ForgeType::ForgeJo);
|
||||
let forge = Forge::create(repo_details, net);
|
||||
assert_eq!(forge.name(), "forgejo");
|
||||
}
|
||||
|
||||
#[cfg(feature = "github")]
|
||||
#[test]
|
||||
fn test_github_name() {
|
||||
let net = Network::new_mock();
|
||||
let repo_details = given_repo_details(git_next_core::ForgeType::GitHub);
|
||||
let forge = Forge::create(repo_details, net);
|
||||
assert_eq!(forge.name(), "github");
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn given_repo_details(forge_type: git_next_core::ForgeType) -> RepoDetails {
|
||||
let fs = kxio::fs::temp().unwrap_or_else(|e| {
|
||||
println!("{e}");
|
||||
panic!("fs")
|
||||
});
|
||||
git::repo_details(
|
||||
1,
|
||||
git::Generation::default(),
|
||||
core::common::forge_details(1, forge_type),
|
||||
Some(core::common::repo_config(1, RepoConfigSource::Repo)),
|
||||
GitDir::new(fs.base().to_path_buf(), StoragePathType::Internal),
|
||||
)
|
||||
}
|
|
@ -1,27 +1,18 @@
|
|||
use std::path::PathBuf;
|
||||
|
||||
//
|
||||
use color_eyre::{eyre::Context, Result};
|
||||
use kxio::fs::FileSystem;
|
||||
|
||||
pub fn run(fs: FileSystem) {
|
||||
let file_name = ".git-next.toml";
|
||||
let pathbuf = PathBuf::from(file_name);
|
||||
let Ok(exists) = fs.path_exists(&pathbuf) else {
|
||||
eprintln!("Could not check if file exist: {}", file_name);
|
||||
return;
|
||||
};
|
||||
if exists {
|
||||
eprintln!(
|
||||
"The configuration file already exists at {} - not overwritting it.",
|
||||
file_name
|
||||
);
|
||||
pub fn run(fs: &FileSystem) -> Result<()> {
|
||||
let pathbuf = fs.base().join(".git-next.toml");
|
||||
if fs
|
||||
.path_exists(&pathbuf)
|
||||
.with_context(|| format!("Checking for existing file: {pathbuf:?}"))?
|
||||
{
|
||||
eprintln!("The configuration file already exists at {pathbuf:?} - not overwritting it.",);
|
||||
} else {
|
||||
match fs.file_write(&pathbuf, include_str!("../../../default.toml")) {
|
||||
Ok(_) => {
|
||||
println!("Created a default configuration file at {}", file_name);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to write to the configuration file: {}", e)
|
||||
}
|
||||
}
|
||||
fs.file_write(&pathbuf, include_str!("../default.toml"))
|
||||
.with_context(|| format!("Writing file: {pathbuf:?}"))?;
|
||||
println!("Created a default configuration file at {pathbuf:?}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
@ -1,8 +1,26 @@
|
|||
//
|
||||
#![allow(clippy::module_name_repetitions)]
|
||||
|
||||
mod alerts;
|
||||
mod file_watcher;
|
||||
mod forge;
|
||||
mod init;
|
||||
mod repo;
|
||||
mod server;
|
||||
|
||||
#[cfg(feature = "tui")]
|
||||
mod tui;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
mod webhook;
|
||||
|
||||
use git_next_core::git;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::Parser;
|
||||
use color_eyre::Result;
|
||||
use kxio::{fs, network::Network};
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
|
@ -20,26 +38,40 @@ enum Command {
|
|||
#[derive(Parser, Debug)]
|
||||
enum Server {
|
||||
Init,
|
||||
Start,
|
||||
Start {
|
||||
/// Display a UI (experimental)
|
||||
#[cfg(feature = "tui")]
|
||||
#[arg(long, required = false)]
|
||||
ui: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[actix_rt::main]
|
||||
async fn main() {
|
||||
fn main() -> Result<()> {
|
||||
let fs = fs::new(PathBuf::default());
|
||||
let net = Network::new_real();
|
||||
let repository_factory = git::repository::factory::real();
|
||||
let commands = Commands::parse();
|
||||
|
||||
match commands.command {
|
||||
Command::Init => {
|
||||
init::run(fs);
|
||||
}
|
||||
Command::Init => init::run(&fs),
|
||||
Command::Server(server) => match server {
|
||||
Server::Init => {
|
||||
git_next_server::init(fs);
|
||||
}
|
||||
Server::Start => {
|
||||
git_next_server::start(fs, net).await;
|
||||
}
|
||||
Server::Init => server::init(&fs),
|
||||
#[cfg(not(feature = "tui"))]
|
||||
Server::Start {} => server::start(
|
||||
false,
|
||||
fs,
|
||||
net,
|
||||
repository_factory,
|
||||
std::time::Duration::from_secs(10),
|
||||
),
|
||||
#[cfg(feature = "tui")]
|
||||
Server::Start { ui } => server::start(
|
||||
ui,
|
||||
fs,
|
||||
net,
|
||||
repository_factory,
|
||||
std::time::Duration::from_secs(10),
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
37
crates/cli/src/repo/MESSAGES.md
Normal file
37
crates/cli/src/repo/MESSAGES.md
Normal file
|
@ -0,0 +1,37 @@
|
|||
```mermaid
|
||||
stateDiagram-v2
|
||||
SERVER --> CloneRepo :on start
|
||||
SERVER --> UnRegisterWebhook :on shutdown
|
||||
|
||||
CloneRepo --> LoadConfigFromRepo :on repo config
|
||||
CloneRepo --> RegisterWebhook :on server config
|
||||
|
||||
LoadConfigFromRepo --> ReceiveRepoConfig
|
||||
|
||||
ValidateRepo --> CheckCIStatus :on next ahead of main
|
||||
ValidateRepo --> AdvanceNext :on dev ahead of next
|
||||
ValidateRepo --> [*] :on dev == next == main
|
||||
ValidateRepo --> USER :on non-retryable error
|
||||
ValidateRepo --> ValidateRepo :on retryable error
|
||||
|
||||
CheckCIStatus --> ReceiveCIStatus
|
||||
|
||||
ReceiveCIStatus --> AdvanceMain :on Pass
|
||||
ReceiveCIStatus --> ValidateRepo :on Pending
|
||||
ReceiveCIStatus --> USER :on Fail
|
||||
|
||||
AdvanceNext --> ValidateRepo
|
||||
|
||||
ReceiveRepoConfig --> RegisterWebhook
|
||||
|
||||
RegisterWebhook --> WebhookRegistered
|
||||
|
||||
WebhookRegistered --> ValidateRepo
|
||||
|
||||
AdvanceMain --> LoadConfigFromRepo :on repo config
|
||||
AdvanceMain --> ValidateRepo :on server config
|
||||
|
||||
FORGE --> WebhookNotification :on push
|
||||
|
||||
WebhookNotification --> ValidateRepo
|
||||
```
|
114
crates/cli/src/repo/branch.rs
Normal file
114
crates/cli/src/repo/branch.rs
Normal file
|
@ -0,0 +1,114 @@
|
|||
//
|
||||
use crate::repo::messages::MessageToken;
|
||||
|
||||
use git_next_core::{
|
||||
git::{
|
||||
commit::Message,
|
||||
push::{reset, Force},
|
||||
repository::open::OpenRepositoryLike,
|
||||
Commit, GitRef, RepoDetails,
|
||||
},
|
||||
RepoConfig,
|
||||
};
|
||||
|
||||
use derive_more::Display;
|
||||
use tracing::{info, instrument, warn};
|
||||
|
||||
// advance next to the next commit towards the head of the dev branch
|
||||
#[instrument(fields(next), skip_all)]
|
||||
pub fn advance_next(
|
||||
commit: Option<Commit>,
|
||||
force: git_next_core::git::push::Force,
|
||||
repo_details: &RepoDetails,
|
||||
repo_config: RepoConfig,
|
||||
open_repository: &dyn OpenRepositoryLike,
|
||||
message_token: MessageToken,
|
||||
) -> Result<MessageToken> {
|
||||
let commit = commit.ok_or_else(|| Error::NextAtDev)?;
|
||||
validate_commit_message(commit.message())?;
|
||||
info!("Advancing next to commit '{}'", commit);
|
||||
reset(
|
||||
open_repository,
|
||||
repo_details,
|
||||
&repo_config.branches().next(),
|
||||
&commit.into(),
|
||||
&force,
|
||||
)?;
|
||||
Ok(message_token)
|
||||
}
|
||||
|
||||
#[instrument]
|
||||
fn validate_commit_message(message: &Message) -> Result<()> {
|
||||
let message = &message.to_string();
|
||||
if message.to_ascii_lowercase().starts_with("wip") {
|
||||
return Err(Error::IsWorkInProgress);
|
||||
}
|
||||
match ::git_conventional::Commit::parse(message) {
|
||||
Ok(commit) => {
|
||||
info!(?commit, "Pass");
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(?err, "Fail");
|
||||
Err(Error::InvalidCommitMessage {
|
||||
reason: err.kind().to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn find_next_commit_on_dev(
|
||||
next: &Commit,
|
||||
main: &Commit,
|
||||
dev_commit_history: &[Commit],
|
||||
) -> (Option<Commit>, Force) {
|
||||
let mut next_commit: Option<&Commit> = None;
|
||||
let mut force = Force::No;
|
||||
for commit in dev_commit_history {
|
||||
if commit == next {
|
||||
break;
|
||||
};
|
||||
if commit == main {
|
||||
force = Force::From(GitRef::from(next.sha().clone()));
|
||||
break;
|
||||
};
|
||||
next_commit.replace(commit);
|
||||
}
|
||||
(next_commit.cloned(), force)
|
||||
}
|
||||
|
||||
// advance main branch to the commit 'next'
|
||||
#[instrument(fields(next), skip_all)]
|
||||
pub fn advance_main(
|
||||
next: Commit,
|
||||
repo_details: &RepoDetails,
|
||||
repo_config: &RepoConfig,
|
||||
open_repository: &dyn OpenRepositoryLike,
|
||||
) -> Result<()> {
|
||||
info!("Advancing main to next");
|
||||
reset(
|
||||
open_repository,
|
||||
repo_details,
|
||||
&repo_config.branches().main(),
|
||||
&next.into(),
|
||||
&Force::No,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
#[derive(Debug, thiserror::Error, Display)]
|
||||
pub enum Error {
|
||||
#[display("push: {}", 0)]
|
||||
Push(#[from] crate::git::push::Error),
|
||||
|
||||
#[display("no commits to advance next to")]
|
||||
NextAtDev,
|
||||
|
||||
#[display("commit is a Work-in-progress")]
|
||||
IsWorkInProgress,
|
||||
|
||||
#[display("commit message is not in conventional commit format: {reason}")]
|
||||
InvalidCommitMessage { reason: String },
|
||||
}
|
59
crates/cli/src/repo/handlers/advance_main.rs
Normal file
59
crates/cli/src/repo/handlers/advance_main.rs
Normal file
|
@ -0,0 +1,59 @@
|
|||
//
|
||||
use actix::prelude::*;
|
||||
|
||||
use git_next_core::{git, RepoConfigSource};
|
||||
|
||||
use tracing::warn;
|
||||
|
||||
use crate::{
|
||||
repo::{
|
||||
branch::advance_main,
|
||||
do_send,
|
||||
messages::{AdvanceMain, LoadConfigFromRepo, ValidateRepo},
|
||||
RepoActor,
|
||||
},
|
||||
server::actor::messages::RepoUpdate,
|
||||
};
|
||||
|
||||
impl Handler<AdvanceMain> for RepoActor {
|
||||
type Result = ();
|
||||
#[tracing::instrument(name = "RepoActor::AdvanceMainTo", skip_all, fields(repo = %self.repo_details, commit = ?msg))]
|
||||
fn handle(&mut self, msg: AdvanceMain, ctx: &mut Self::Context) -> Self::Result {
|
||||
let Some(repo_config) = self.repo_details.repo_config.clone() else {
|
||||
warn!("No config loaded");
|
||||
return;
|
||||
};
|
||||
let Some(open_repository) = &self.open_repository else {
|
||||
return;
|
||||
};
|
||||
let repo_details = self.repo_details.clone();
|
||||
let addr = ctx.address();
|
||||
let message_token = self.message_token;
|
||||
let commit = msg.peel();
|
||||
|
||||
self.update_tui(RepoUpdate::AdvancingMain {
|
||||
commit: commit.clone(),
|
||||
});
|
||||
|
||||
if let Err(err) = advance_main(commit, &repo_details, &repo_config, &**open_repository) {
|
||||
warn!("advance main: {err}");
|
||||
self.alert_tui(format!("advance main: {err}"));
|
||||
} else {
|
||||
self.update_tui(RepoUpdate::MainUpdated);
|
||||
if let Some(open_repository) = &self.open_repository {
|
||||
match open_repository.fetch() {
|
||||
Ok(()) => self.update_tui_log(git::graph::log(&self.repo_details)),
|
||||
Err(err) => self.alert_tui(format!("fetching: {err}")),
|
||||
}
|
||||
}
|
||||
match repo_config.source() {
|
||||
RepoConfigSource::Repo => {
|
||||
do_send(&addr, LoadConfigFromRepo, self.log.as_ref());
|
||||
}
|
||||
RepoConfigSource::Server => {
|
||||
do_send(&addr, ValidateRepo::new(message_token), self.log.as_ref());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
75
crates/cli/src/repo/handlers/advance_next.rs
Normal file
75
crates/cli/src/repo/handlers/advance_next.rs
Normal file
|
@ -0,0 +1,75 @@
|
|||
//
|
||||
use actix::prelude::*;
|
||||
|
||||
use git_next_core::git;
|
||||
use tracing::{warn, Instrument};
|
||||
|
||||
use crate::{
|
||||
repo::{
|
||||
branch::{advance_next, find_next_commit_on_dev},
|
||||
do_send,
|
||||
messages::{AdvanceNext, AdvanceNextPayload, ValidateRepo},
|
||||
RepoActor,
|
||||
},
|
||||
server::actor::messages::RepoUpdate,
|
||||
};
|
||||
|
||||
impl Handler<AdvanceNext> for RepoActor {
|
||||
type Result = ();
|
||||
|
||||
fn handle(&mut self, msg: AdvanceNext, ctx: &mut Self::Context) -> Self::Result {
|
||||
let Some(repo_config) = &self.repo_details.repo_config else {
|
||||
return;
|
||||
};
|
||||
let Some(open_repository) = &self.open_repository else {
|
||||
return;
|
||||
};
|
||||
|
||||
let AdvanceNextPayload {
|
||||
next,
|
||||
main,
|
||||
dev_commit_history,
|
||||
} = msg.peel();
|
||||
let repo_details = self.repo_details.clone();
|
||||
let repo_config = repo_config.clone();
|
||||
let addr = ctx.address();
|
||||
|
||||
let (commit, force) = find_next_commit_on_dev(&next, &main, &dev_commit_history);
|
||||
if let Some(commit) = &commit {
|
||||
self.update_tui(RepoUpdate::AdvancingNext {
|
||||
commit: commit.clone(),
|
||||
force: force.clone(),
|
||||
});
|
||||
};
|
||||
match advance_next(
|
||||
commit,
|
||||
force,
|
||||
&repo_details,
|
||||
repo_config,
|
||||
&**open_repository,
|
||||
self.message_token,
|
||||
) {
|
||||
Ok(message_token) => {
|
||||
self.update_tui(RepoUpdate::NextUpdated);
|
||||
match open_repository.fetch() {
|
||||
Ok(()) => self.update_tui_log(git::graph::log(&self.repo_details)),
|
||||
Err(err) => self.alert_tui(format!("fetching: {err}")),
|
||||
}
|
||||
// INFO: pause to allow any CI checks to be started
|
||||
let sleep_duration = self.sleep_duration;
|
||||
let log = self.log.clone();
|
||||
async move {
|
||||
actix_rt::time::sleep(sleep_duration).await;
|
||||
do_send(&addr, ValidateRepo::new(message_token), log.as_ref());
|
||||
}
|
||||
.in_current_span()
|
||||
.into_actor(self)
|
||||
.wait(ctx);
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("advance next: {err}");
|
||||
self.alert_tui(err.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
37
crates/cli/src/repo/handlers/check_ci_status.rs
Normal file
37
crates/cli/src/repo/handlers/check_ci_status.rs
Normal file
|
@ -0,0 +1,37 @@
|
|||
//
|
||||
use actix::prelude::*;
|
||||
|
||||
use tracing::{debug, Instrument as _};
|
||||
|
||||
use crate::{
|
||||
repo::{
|
||||
do_send,
|
||||
messages::{CheckCIStatus, ReceiveCIStatus},
|
||||
RepoActor,
|
||||
},
|
||||
server::actor::messages::RepoUpdate,
|
||||
};
|
||||
|
||||
impl Handler<CheckCIStatus> for RepoActor {
|
||||
type Result = ();
|
||||
|
||||
fn handle(&mut self, msg: CheckCIStatus, ctx: &mut Self::Context) -> Self::Result {
|
||||
crate::repo::logger(self.log.as_ref(), "start: CheckCIStatus");
|
||||
|
||||
let addr = ctx.address();
|
||||
let forge = self.forge.duplicate();
|
||||
let next = msg.peel();
|
||||
let log = self.log.clone();
|
||||
|
||||
self.update_tui(RepoUpdate::CheckingCI);
|
||||
// get the status - pass, fail, pending (all others map to fail, e.g. error)
|
||||
async move {
|
||||
let status = forge.commit_status(&next).await;
|
||||
debug!("got status: {status:?}");
|
||||
do_send(&addr, ReceiveCIStatus::new((next, status)), log.as_ref());
|
||||
}
|
||||
.in_current_span()
|
||||
.into_actor(self)
|
||||
.wait(ctx);
|
||||
}
|
||||
}
|
43
crates/cli/src/repo/handlers/clone_repo.rs
Normal file
43
crates/cli/src/repo/handlers/clone_repo.rs
Normal file
|
@ -0,0 +1,43 @@
|
|||
//
|
||||
use actix::prelude::*;
|
||||
|
||||
use git_next_core::git;
|
||||
use tracing::{debug, instrument, warn};
|
||||
|
||||
use crate::{
|
||||
repo::{
|
||||
do_send, logger,
|
||||
messages::{CloneRepo, LoadConfigFromRepo, RegisterWebhook},
|
||||
RepoActor,
|
||||
},
|
||||
server::actor::messages::RepoUpdate,
|
||||
};
|
||||
|
||||
impl Handler<CloneRepo> for RepoActor {
|
||||
type Result = ();
|
||||
#[instrument(name = "RepoActor::CloneRepo", skip_all, fields(repo = %self.repo_details))]
|
||||
fn handle(&mut self, _msg: CloneRepo, ctx: &mut Self::Context) -> Self::Result {
|
||||
logger(self.log.as_ref(), "Handler: CloneRepo: start");
|
||||
self.update_tui(RepoUpdate::Opening);
|
||||
debug!("Handler: CloneRepo: start");
|
||||
match git::repository::open(&*self.repository_factory, &self.repo_details) {
|
||||
Ok(repository) => {
|
||||
logger(self.log.as_ref(), "open okay");
|
||||
debug!("open okay");
|
||||
self.update_tui(RepoUpdate::Opened);
|
||||
self.open_repository.replace(repository);
|
||||
if self.repo_details.repo_config.is_none() {
|
||||
do_send(&ctx.address(), LoadConfigFromRepo, self.log.as_ref());
|
||||
} else {
|
||||
do_send(&ctx.address(), RegisterWebhook::new(), self.log.as_ref());
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
logger(self.log.as_ref(), "open failed");
|
||||
warn!("Could not open repo: {err:?}");
|
||||
self.alert_tui(err.to_string());
|
||||
}
|
||||
}
|
||||
debug!("Handler: CloneRepo: finish");
|
||||
}
|
||||
}
|
54
crates/cli/src/repo/handlers/load_config_from_repo.rs
Normal file
54
crates/cli/src/repo/handlers/load_config_from_repo.rs
Normal file
|
@ -0,0 +1,54 @@
|
|||
//
|
||||
use actix::prelude::*;
|
||||
|
||||
use git_next_core::git::UserNotification;
|
||||
|
||||
use tracing::{debug, instrument, Instrument as _};
|
||||
|
||||
use crate::{
|
||||
repo::{
|
||||
do_send, load,
|
||||
messages::{LoadConfigFromRepo, ReceiveRepoConfig},
|
||||
notify_user, RepoActor,
|
||||
},
|
||||
server::actor::messages::RepoUpdate,
|
||||
};
|
||||
|
||||
impl Handler<LoadConfigFromRepo> for RepoActor {
|
||||
type Result = ();
|
||||
#[instrument(name = "Repocrate::repo::LoadConfigFromRepo", skip_all, fields(repo = %self.repo_details))]
|
||||
fn handle(&mut self, _msg: LoadConfigFromRepo, ctx: &mut Self::Context) -> Self::Result {
|
||||
debug!("Handler: LoadConfigFromRepo: start");
|
||||
self.update_tui(RepoUpdate::LoadingConfigFromRepo);
|
||||
let Some(open_repository) = &self.open_repository else {
|
||||
return;
|
||||
};
|
||||
let open_repository = open_repository.duplicate();
|
||||
let repo_details = self.repo_details.clone();
|
||||
let forge_alias = repo_details.forge.forge_alias().clone();
|
||||
let repo_alias = repo_details.repo_alias.clone();
|
||||
let addr = ctx.address();
|
||||
let notify_user_recipient = self.notify_user_recipient.clone();
|
||||
let log = self.log.clone();
|
||||
async move {
|
||||
match load::config_from_repository(repo_details, &*open_repository).await {
|
||||
Ok(repo_config) => {
|
||||
do_send(&addr, ReceiveRepoConfig::new(repo_config), log.as_ref());
|
||||
}
|
||||
Err(err) => notify_user(
|
||||
notify_user_recipient.as_ref(),
|
||||
UserNotification::RepoConfigLoadFailure {
|
||||
forge_alias,
|
||||
repo_alias,
|
||||
reason: err.to_string(),
|
||||
},
|
||||
log.as_ref(),
|
||||
),
|
||||
}
|
||||
}
|
||||
.in_current_span()
|
||||
.into_actor(self)
|
||||
.wait(ctx);
|
||||
debug!("Handler: LoadConfigFromRepo: finish");
|
||||
}
|
||||
}
|
12
crates/cli/src/repo/handlers/mod.rs
Normal file
12
crates/cli/src/repo/handlers/mod.rs
Normal file
|
@ -0,0 +1,12 @@
|
|||
pub mod advance_main;
|
||||
pub mod advance_next;
|
||||
pub mod check_ci_status;
|
||||
pub mod clone_repo;
|
||||
pub mod load_config_from_repo;
|
||||
pub mod receive_ci_status;
|
||||
pub mod receive_repo_config;
|
||||
pub mod register_webhook;
|
||||
pub mod unregister_webhook;
|
||||
pub mod validate_repo;
|
||||
pub mod webhook_notification;
|
||||
pub mod webhook_registered;
|
75
crates/cli/src/repo/handlers/receive_ci_status.rs
Normal file
75
crates/cli/src/repo/handlers/receive_ci_status.rs
Normal file
|
@ -0,0 +1,75 @@
|
|||
//
|
||||
use actix::prelude::*;
|
||||
|
||||
use git_next_core::git::{forge::commit::Status, graph, UserNotification};
|
||||
use tracing::{debug, Instrument};
|
||||
|
||||
use crate::{
|
||||
repo::{
|
||||
do_send, logger,
|
||||
messages::{AdvanceMain, ReceiveCIStatus, ValidateRepo},
|
||||
notify_user, RepoActor,
|
||||
},
|
||||
server::actor::messages::RepoUpdate,
|
||||
};
|
||||
|
||||
impl Handler<ReceiveCIStatus> for RepoActor {
|
||||
type Result = ();
|
||||
|
||||
fn handle(&mut self, msg: ReceiveCIStatus, ctx: &mut Self::Context) -> Self::Result {
|
||||
logger(self.log.as_ref(), "start: ReceiveCIStatus");
|
||||
let (next, status) = msg.peel();
|
||||
self.update_tui(RepoUpdate::ReceiveCIStatus {
|
||||
status: status.clone(),
|
||||
});
|
||||
debug!(?status, "");
|
||||
let graph_log = graph::log(&self.repo_details);
|
||||
self.update_tui_log(graph_log.clone());
|
||||
|
||||
let addr = ctx.address();
|
||||
let forge_alias = self.repo_details.forge.forge_alias().clone();
|
||||
let repo_alias = self.repo_details.repo_alias.clone();
|
||||
let message_token = self.message_token;
|
||||
let sleep_duration = self.sleep_duration;
|
||||
match status {
|
||||
Status::Pass => {
|
||||
do_send(&addr, AdvanceMain::new(next), self.log.as_ref());
|
||||
}
|
||||
Status::Pending => {
|
||||
let log = self.log.clone();
|
||||
async move {
|
||||
actix_rt::time::sleep(sleep_duration).await;
|
||||
do_send(&addr, ValidateRepo::new(message_token), log.as_ref());
|
||||
}
|
||||
.in_current_span()
|
||||
.into_actor(self)
|
||||
.wait(ctx);
|
||||
}
|
||||
Status::Fail => {
|
||||
tracing::warn!("Checks have failed");
|
||||
|
||||
notify_user(
|
||||
self.notify_user_recipient.as_ref(),
|
||||
UserNotification::CICheckFailed {
|
||||
forge_alias,
|
||||
repo_alias,
|
||||
commit: next,
|
||||
log: graph_log,
|
||||
},
|
||||
self.log.as_ref(),
|
||||
);
|
||||
let log = self.log.clone();
|
||||
async move {
|
||||
debug!("sleeping before retrying...");
|
||||
logger(log.as_ref(), "before sleep");
|
||||
actix_rt::time::sleep(sleep_duration).await;
|
||||
logger(log.as_ref(), "after sleep");
|
||||
do_send(&addr, ValidateRepo::new(message_token), log.as_ref());
|
||||
}
|
||||
.in_current_span()
|
||||
.into_actor(self)
|
||||
.wait(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
26
crates/cli/src/repo/handlers/receive_repo_config.rs
Normal file
26
crates/cli/src/repo/handlers/receive_repo_config.rs
Normal file
|
@ -0,0 +1,26 @@
|
|||
//
|
||||
use actix::prelude::*;
|
||||
use tracing::instrument;
|
||||
|
||||
use crate::{
|
||||
repo::{
|
||||
do_send,
|
||||
messages::{ReceiveRepoConfig, RegisterWebhook},
|
||||
RepoActor,
|
||||
},
|
||||
server::actor::messages::RepoUpdate,
|
||||
};
|
||||
|
||||
impl Handler<ReceiveRepoConfig> for RepoActor {
|
||||
type Result = ();
|
||||
#[instrument(name = "RepoActor::ReceiveRepoConfig", skip_all, fields(repo = %self.repo_details, branches = ?msg))]
|
||||
fn handle(&mut self, msg: ReceiveRepoConfig, ctx: &mut Self::Context) -> Self::Result {
|
||||
let repo_config = msg.peel();
|
||||
self.update_tui(RepoUpdate::ReceiveRepoConfig {
|
||||
repo_config: repo_config.clone(),
|
||||
});
|
||||
self.repo_details.repo_config.replace(repo_config);
|
||||
self.update_tui_branches();
|
||||
do_send(&ctx.address(), RegisterWebhook::new(), self.log.as_ref());
|
||||
}
|
||||
}
|
64
crates/cli/src/repo/handlers/register_webhook.rs
Normal file
64
crates/cli/src/repo/handlers/register_webhook.rs
Normal file
|
@ -0,0 +1,64 @@
|
|||
//
|
||||
use actix::prelude::*;
|
||||
|
||||
use tracing::{debug, error, Instrument as _};
|
||||
|
||||
use crate::{
|
||||
repo::{
|
||||
do_send,
|
||||
messages::{RegisterWebhook, WebhookRegistered},
|
||||
notify_user, RepoActor,
|
||||
},
|
||||
server::actor::messages::RepoUpdate,
|
||||
};
|
||||
|
||||
use git_next_core::git::UserNotification;
|
||||
|
||||
impl Handler<RegisterWebhook> for RepoActor {
|
||||
type Result = ();
|
||||
|
||||
fn handle(&mut self, _msg: RegisterWebhook, ctx: &mut Self::Context) -> Self::Result {
|
||||
if self.webhook_id.is_none() {
|
||||
let forge_alias = self.repo_details.forge.forge_alias().clone();
|
||||
let repo_alias = self.repo_details.repo_alias.clone();
|
||||
let repo_listen_url = self
|
||||
.listen_url
|
||||
.repo_url(forge_alias.clone(), repo_alias.clone());
|
||||
let forge = self.forge.duplicate();
|
||||
let addr = ctx.address();
|
||||
let notify_user_recipient = self.notify_user_recipient.clone();
|
||||
let log = self.log.clone();
|
||||
self.update_tui(RepoUpdate::RegisteringWebhook);
|
||||
debug!("registering webhook");
|
||||
async move {
|
||||
match forge.register_webhook(&repo_listen_url).await {
|
||||
Ok(registered_webhook) => {
|
||||
debug!(?registered_webhook, "webhook registered");
|
||||
do_send(
|
||||
&addr,
|
||||
WebhookRegistered::from(registered_webhook),
|
||||
log.as_ref(),
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
error!(?err, "failed to register webhook");
|
||||
notify_user(
|
||||
notify_user_recipient.as_ref(),
|
||||
UserNotification::WebhookRegistration {
|
||||
forge_alias,
|
||||
repo_alias,
|
||||
reason: err.to_string(),
|
||||
},
|
||||
log.as_ref(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
.in_current_span()
|
||||
.into_actor(self)
|
||||
.wait(ctx);
|
||||
} else {
|
||||
self.alert_tui("already have a webhook id - cant register webhook");
|
||||
}
|
||||
}
|
||||
}
|
32
crates/cli/src/repo/handlers/unregister_webhook.rs
Normal file
32
crates/cli/src/repo/handlers/unregister_webhook.rs
Normal file
|
@ -0,0 +1,32 @@
|
|||
//
|
||||
use actix::prelude::*;
|
||||
|
||||
use tracing::{debug, warn, Instrument as _};
|
||||
|
||||
use crate::{
|
||||
repo::{messages::UnRegisterWebhook, RepoActor},
|
||||
server::actor::messages::RepoUpdate,
|
||||
};
|
||||
|
||||
impl Handler<UnRegisterWebhook> for RepoActor {
|
||||
type Result = ();
|
||||
|
||||
fn handle(&mut self, _msg: UnRegisterWebhook, ctx: &mut Self::Context) -> Self::Result {
|
||||
let Some(webhook_id) = self.webhook_id.take() else {
|
||||
return;
|
||||
};
|
||||
self.update_tui(RepoUpdate::UnregisteringWebhook);
|
||||
let forge = self.forge.duplicate();
|
||||
debug!("unregistering webhook");
|
||||
async move {
|
||||
match forge.unregister_webhook(&webhook_id).await {
|
||||
Ok(()) => debug!("unregistered webhook"),
|
||||
Err(err) => warn!(?err, "unregistering webhook"),
|
||||
}
|
||||
}
|
||||
.in_current_span()
|
||||
.into_actor(self)
|
||||
.wait(ctx);
|
||||
debug!("unregistering webhook done");
|
||||
}
|
||||
}
|
161
crates/cli/src/repo/handlers/validate_repo.rs
Normal file
161
crates/cli/src/repo/handlers/validate_repo.rs
Normal file
|
@ -0,0 +1,161 @@
|
|||
//
|
||||
use actix::prelude::*;
|
||||
|
||||
use tracing::{info, instrument, Instrument as _};
|
||||
|
||||
use crate::{
|
||||
repo::{
|
||||
do_send, logger,
|
||||
messages::{AdvanceNext, AdvanceNextPayload, CheckCIStatus, MessageToken, ValidateRepo},
|
||||
notify_user, RepoActor,
|
||||
},
|
||||
server::actor::messages::RepoUpdate,
|
||||
};
|
||||
|
||||
use git_next_core::git::{
|
||||
push::Force,
|
||||
validation::positions::{validate, Error, Positions},
|
||||
UserNotification,
|
||||
};
|
||||
|
||||
impl Handler<ValidateRepo> for RepoActor {
|
||||
type Result = ();
|
||||
|
||||
#[instrument(name = "RepoActor::ValidateRepo", skip_all, fields(repo = %self.repo_details, token = %&*msg))]
|
||||
fn handle(&mut self, msg: ValidateRepo, ctx: &mut Self::Context) -> Self::Result {
|
||||
logger(self.log.as_ref(), "start: ValidateRepo");
|
||||
|
||||
// Message Token - make sure we are only triggered for the latest/current token
|
||||
match self.token_status(msg.peel()) {
|
||||
TokenStatus::Current => {} // do nothing
|
||||
TokenStatus::Expired => {
|
||||
logger(
|
||||
self.log.as_ref(),
|
||||
format!("discarded: old message token: {}", self.message_token),
|
||||
);
|
||||
return; // message is expired
|
||||
}
|
||||
TokenStatus::New(message_token) => {
|
||||
self.message_token = message_token;
|
||||
logger(
|
||||
self.log.as_ref(),
|
||||
format!("new message token: {}", self.message_token),
|
||||
);
|
||||
}
|
||||
}
|
||||
logger(
|
||||
self.log.as_ref(),
|
||||
format!("accepted token: {}", self.message_token),
|
||||
);
|
||||
|
||||
self.update_tui(RepoUpdate::ValidateRepo);
|
||||
|
||||
// Repository positions
|
||||
let Some(ref open_repository) = self.open_repository else {
|
||||
logger(self.log.as_ref(), "no open repository");
|
||||
self.alert_tui("repo not open");
|
||||
return;
|
||||
};
|
||||
logger(self.log.as_ref(), "have open repository");
|
||||
let Some(repo_config) = self.repo_details.repo_config.clone() else {
|
||||
logger(self.log.as_ref(), "no repo config");
|
||||
self.alert_tui("no repo config");
|
||||
return;
|
||||
};
|
||||
logger(self.log.as_ref(), "have repo config");
|
||||
|
||||
match validate(&**open_repository, &self.repo_details, &repo_config) {
|
||||
Ok((
|
||||
Positions {
|
||||
main,
|
||||
next,
|
||||
dev,
|
||||
dev_commit_history,
|
||||
next_is_valid,
|
||||
},
|
||||
git_log,
|
||||
)) => {
|
||||
info!(%main, %next, %dev, "positions");
|
||||
self.update_tui_log(git_log);
|
||||
if next_is_valid && next != main {
|
||||
info!("Checking CI");
|
||||
do_send(&ctx.address(), CheckCIStatus::new(next), self.log.as_ref());
|
||||
} else if next != dev {
|
||||
info!("Advance next");
|
||||
self.update_tui(RepoUpdate::AdvancingNext {
|
||||
commit: next.clone(),
|
||||
force: Force::No,
|
||||
});
|
||||
do_send(
|
||||
&ctx.address(),
|
||||
AdvanceNext::new(AdvanceNextPayload {
|
||||
next,
|
||||
main,
|
||||
dev_commit_history,
|
||||
}),
|
||||
self.log.as_ref(),
|
||||
);
|
||||
} else {
|
||||
info!("do nothing");
|
||||
self.update_tui(RepoUpdate::Okay { main, next, dev });
|
||||
}
|
||||
}
|
||||
Err(Error::Retryable(message)) => {
|
||||
info!(?message, "Retryable");
|
||||
self.alert_tui(format!("retryable: {message}"));
|
||||
logger(self.log.as_ref(), message);
|
||||
let addr = ctx.address();
|
||||
let message_token = self.message_token;
|
||||
let sleep_duration = self.sleep_duration;
|
||||
let log = self.log.clone();
|
||||
async move {
|
||||
info!("sleeping before retrying...");
|
||||
logger(log.as_ref(), "before sleep");
|
||||
actix_rt::time::sleep(sleep_duration).await;
|
||||
logger(log.as_ref(), "after sleep");
|
||||
do_send(&addr, ValidateRepo::new(message_token), log.as_ref());
|
||||
}
|
||||
.in_current_span()
|
||||
.into_actor(self)
|
||||
.wait(ctx);
|
||||
}
|
||||
Err(Error::UserIntervention(user_notification)) => {
|
||||
info!(?user_notification, "User Intervention");
|
||||
self.alert_tui(format!("USER INTERVENTION: {user_notification}"));
|
||||
if let UserNotification::CICheckFailed { log, .. }
|
||||
| UserNotification::DevNotBasedOnMain { log, .. } = &user_notification
|
||||
{
|
||||
self.update_tui_log(log.clone());
|
||||
}
|
||||
notify_user(
|
||||
self.notify_user_recipient.as_ref(),
|
||||
user_notification,
|
||||
self.log.as_ref(),
|
||||
);
|
||||
}
|
||||
Err(Error::NonRetryable(message)) => {
|
||||
info!(?message, "NonRetryable");
|
||||
self.alert_tui(format!("Error: {message}"));
|
||||
logger(self.log.as_ref(), message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum TokenStatus {
|
||||
Current,
|
||||
Expired,
|
||||
New(MessageToken),
|
||||
}
|
||||
impl RepoActor {
|
||||
fn token_status(&self, new: MessageToken) -> TokenStatus {
|
||||
let current = &self.message_token;
|
||||
if &new > current {
|
||||
return TokenStatus::New(new);
|
||||
}
|
||||
if current > &new {
|
||||
return TokenStatus::Expired;
|
||||
}
|
||||
TokenStatus::Current
|
||||
}
|
||||
}
|
166
crates/cli/src/repo/handlers/webhook_notification.rs
Normal file
166
crates/cli/src/repo/handlers/webhook_notification.rs
Normal file
|
@ -0,0 +1,166 @@
|
|||
//
|
||||
use actix::prelude::*;
|
||||
|
||||
use tracing::{info, instrument, warn};
|
||||
|
||||
use crate::{
|
||||
repo::{
|
||||
do_send, logger,
|
||||
messages::{ValidateRepo, WebhookNotification},
|
||||
ActorLog, RepoActor,
|
||||
},
|
||||
server::actor::messages::RepoUpdate,
|
||||
};
|
||||
|
||||
use git_next_core::{
|
||||
git::{Commit, ForgeLike},
|
||||
webhook::{push::Branch, Push},
|
||||
BranchName, WebhookAuth,
|
||||
};
|
||||
|
||||
impl Handler<WebhookNotification> for RepoActor {
|
||||
type Result = ();
|
||||
|
||||
#[instrument(name = "RepoActor::WebhookMessage", skip_all, fields(token = %self.message_token, repo = %self.repo_details))]
|
||||
fn handle(&mut self, msg: WebhookNotification, ctx: &mut Self::Context) -> Self::Result {
|
||||
let Some(config) = &self.repo_details.repo_config else {
|
||||
logger(self.log.as_ref(), "server has no repo config");
|
||||
warn!("No repo config");
|
||||
return;
|
||||
};
|
||||
if validate_notification(
|
||||
&msg,
|
||||
self.webhook_auth.as_ref(),
|
||||
&*self.forge,
|
||||
self.log.as_ref(),
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let body = msg.body();
|
||||
match self.forge.parse_webhook_body(body) {
|
||||
Err(err) => {
|
||||
logger(self.log.as_ref(), "message parse error - not a push");
|
||||
warn!(?err, "Not a 'push'");
|
||||
return;
|
||||
}
|
||||
Ok(push) => match push.branch(config.branches()) {
|
||||
None => {
|
||||
logger(self.log.as_ref(), "unknown branch");
|
||||
warn!(
|
||||
?push,
|
||||
"Unrecognised branch, we should be filtering to only the ones we want"
|
||||
);
|
||||
return;
|
||||
}
|
||||
Some(Branch::Main) => {
|
||||
self.update_tui(RepoUpdate::WebhookReceived {
|
||||
branch: Branch::Main,
|
||||
push: push.clone(),
|
||||
});
|
||||
if handle_push(
|
||||
push,
|
||||
&config.branches().main(),
|
||||
&mut self.last_main_commit,
|
||||
self.log.as_ref(),
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
};
|
||||
}
|
||||
Some(Branch::Next) => {
|
||||
self.update_tui(RepoUpdate::WebhookReceived {
|
||||
branch: Branch::Next,
|
||||
push: push.clone(),
|
||||
});
|
||||
if handle_push(
|
||||
push,
|
||||
&config.branches().next(),
|
||||
&mut self.last_next_commit,
|
||||
self.log.as_ref(),
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
};
|
||||
}
|
||||
Some(Branch::Dev) => {
|
||||
self.update_tui(RepoUpdate::WebhookReceived {
|
||||
branch: Branch::Dev,
|
||||
push: push.clone(),
|
||||
});
|
||||
if handle_push(
|
||||
push,
|
||||
&config.branches().dev(),
|
||||
&mut self.last_dev_commit,
|
||||
self.log.as_ref(),
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
};
|
||||
}
|
||||
},
|
||||
}
|
||||
let message_token = self.message_token.next();
|
||||
info!(
|
||||
token = %message_token,
|
||||
"New commit"
|
||||
);
|
||||
do_send(
|
||||
&ctx.address(),
|
||||
ValidateRepo::new(message_token),
|
||||
self.log.as_ref(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_notification(
|
||||
msg: &WebhookNotification,
|
||||
webhook_auth: Option<&WebhookAuth>,
|
||||
forge: &dyn ForgeLike,
|
||||
log: Option<&ActorLog>,
|
||||
) -> Result<(), ()> {
|
||||
let Some(expected_authorization) = webhook_auth else {
|
||||
logger(log, "server has no auth token");
|
||||
warn!("Don't know what authorization to expect");
|
||||
return Err(());
|
||||
};
|
||||
|
||||
if !forge.is_message_authorised(msg, expected_authorization) {
|
||||
logger(log, "message authorisation is invalid");
|
||||
warn!(
|
||||
"Invalid authorization - expected {}",
|
||||
expected_authorization
|
||||
);
|
||||
return Err(());
|
||||
}
|
||||
if forge.should_ignore_message(msg) {
|
||||
logger(log, "forge sent ignorable message");
|
||||
return Err(());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_push(
|
||||
push: Push,
|
||||
branch: &BranchName,
|
||||
last_commit: &mut Option<Commit>,
|
||||
log: Option<&ActorLog>,
|
||||
) -> Result<(), ()> {
|
||||
logger(log, format!("message is for {branch} branch"));
|
||||
let commit = Commit::from(push);
|
||||
if last_commit.as_ref() == Some(&commit) {
|
||||
logger(log, format!("not a new commit on {branch}"));
|
||||
info!(
|
||||
%branch ,
|
||||
%commit,
|
||||
"Ignoring - already aware of branch at commit",
|
||||
);
|
||||
return Err(());
|
||||
}
|
||||
last_commit.replace(commit);
|
||||
Ok(())
|
||||
}
|
27
crates/cli/src/repo/handlers/webhook_registered.rs
Normal file
27
crates/cli/src/repo/handlers/webhook_registered.rs
Normal file
|
@ -0,0 +1,27 @@
|
|||
//
|
||||
use actix::prelude::*;
|
||||
use tracing::instrument;
|
||||
|
||||
use crate::{
|
||||
repo::{
|
||||
do_send,
|
||||
messages::{ValidateRepo, WebhookRegistered},
|
||||
RepoActor,
|
||||
},
|
||||
server::actor::messages::RepoUpdate,
|
||||
};
|
||||
|
||||
impl Handler<WebhookRegistered> for RepoActor {
|
||||
type Result = ();
|
||||
#[instrument(name = "RepoActor::WebhookRegistered", skip_all, fields(repo = %self.repo_details, webhook_id = %msg.webhook_id()))]
|
||||
fn handle(&mut self, msg: WebhookRegistered, ctx: &mut Self::Context) -> Self::Result {
|
||||
self.update_tui(RepoUpdate::RegisteredWebhook);
|
||||
self.webhook_id.replace(msg.webhook_id().clone());
|
||||
self.webhook_auth.replace(msg.webhook_auth().clone());
|
||||
do_send(
|
||||
&ctx.address(),
|
||||
ValidateRepo::new(self.message_token),
|
||||
self.log.as_ref(),
|
||||
);
|
||||
}
|
||||
}
|
55
crates/cli/src/repo/load.rs
Normal file
55
crates/cli/src/repo/load.rs
Normal file
|
@ -0,0 +1,55 @@
|
|||
//
|
||||
use git_next_core::{
|
||||
git::{repository::open::OpenRepositoryLike, RepoDetails},
|
||||
BranchName, RepoConfig,
|
||||
};
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use derive_more::Display;
|
||||
use tracing::{info, instrument};
|
||||
|
||||
/// Loads the [RepoConfig] from the `.git-next.toml` file in the repository
|
||||
#[instrument(skip_all, fields(branch = %repo_details.branch))]
|
||||
pub async fn config_from_repository(
|
||||
repo_details: RepoDetails,
|
||||
open_repository: &dyn OpenRepositoryLike,
|
||||
) -> Result<RepoConfig> {
|
||||
info!("Loading .git-next.toml from repo");
|
||||
let contents =
|
||||
open_repository.read_file(&repo_details.branch, &PathBuf::from(".git-next.toml"))?;
|
||||
let config = RepoConfig::parse(&contents)?;
|
||||
let branches = open_repository.remote_branches()?;
|
||||
required_branch(&config.branches().main(), &branches)?;
|
||||
required_branch(&config.branches().next(), &branches)?;
|
||||
required_branch(&config.branches().dev(), &branches)?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn required_branch(branch_name: &BranchName, branches: &[BranchName]) -> Result<()> {
|
||||
branches
|
||||
.iter()
|
||||
.find(|branch| *branch == branch_name)
|
||||
.ok_or_else(|| Error::BranchNotFound(branch_name.clone()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
#[derive(Debug, thiserror::Error, Display)]
|
||||
pub enum Error {
|
||||
#[display("file")]
|
||||
File(#[from] crate::git::file::Error),
|
||||
|
||||
#[display("config")]
|
||||
Config(#[from] git_next_core::server::Error),
|
||||
|
||||
#[display("toml")]
|
||||
Toml(#[from] toml::de::Error),
|
||||
|
||||
#[display("push")]
|
||||
Push(#[from] crate::git::push::Error),
|
||||
|
||||
#[display("branch not found: {}", 0)]
|
||||
BranchNotFound(BranchName),
|
||||
}
|
127
crates/cli/src/repo/messages.rs
Normal file
127
crates/cli/src/repo/messages.rs
Normal file
|
@ -0,0 +1,127 @@
|
|||
//
|
||||
use derive_more::Display;
|
||||
|
||||
use git_next_core::{
|
||||
git::{forge::commit::Status, Commit, UserNotification},
|
||||
message, newtype, ForgeNotification, RegisteredWebhook, RepoConfig, WebhookAuth, WebhookId,
|
||||
};
|
||||
|
||||
message!(
|
||||
LoadConfigFromRepo,
|
||||
"Request to load the `git-next.toml` from the git repo."
|
||||
);
|
||||
message!(CloneRepo, "Request to clone (or open) the git repo.");
|
||||
message!(
|
||||
ReceiveRepoConfig,
|
||||
RepoConfig,
|
||||
r#"Notification that the `git-next.toml` file has been loaded from the repo and parsed.
|
||||
|
||||
Contains the parsed contents of the `git-next.toml` file."#
|
||||
);
|
||||
message!(
|
||||
ValidateRepo,
|
||||
MessageToken,
|
||||
r#"Request that the state of the branches in the git repo be assessed and generate any followup actions.
|
||||
|
||||
This is the main function of `git-next` where decisions are made on what branches need to be updated and when.
|
||||
|
||||
Contains a [MessageToken] to reduce duplicate messages being sent. Only messages with the latest [MessageToken] are handled,
|
||||
all others are dropped."#
|
||||
);
|
||||
|
||||
message!(
|
||||
WebhookRegistered,
|
||||
(WebhookId, WebhookAuth),
|
||||
r#"Notification that a webhook has been registered with a forge.
|
||||
|
||||
Contains a tuple of the ID for the webhook returned from the forge, and the unique authorisation token that
|
||||
incoming messages from the forge must provide."#
|
||||
);
|
||||
impl WebhookRegistered {
|
||||
pub const fn webhook_id(&self) -> &WebhookId {
|
||||
&self.0 .0
|
||||
}
|
||||
pub const fn webhook_auth(&self) -> &WebhookAuth {
|
||||
&self.0 .1
|
||||
}
|
||||
}
|
||||
impl From<RegisteredWebhook> for WebhookRegistered {
|
||||
fn from(value: RegisteredWebhook) -> Self {
|
||||
let webhook_id = value.id().clone();
|
||||
let webhook_auth = value.auth().clone();
|
||||
Self::from((webhook_id, webhook_auth))
|
||||
}
|
||||
}
|
||||
|
||||
message!(
|
||||
UnRegisterWebhook,
|
||||
"Request that the webhook be removed from the forge, so they will stop notifying us."
|
||||
);
|
||||
|
||||
newtype!(
|
||||
MessageToken,
|
||||
u32,
|
||||
Copy,
|
||||
Default,
|
||||
Display,
|
||||
PartialOrd,
|
||||
Ord,
|
||||
r#"An incremental token used to identify the current set of messages.
|
||||
|
||||
Primarily used by [ValidateRepo] to reduce duplicate messages. The token is incremented when a new Webhook message is
|
||||
received, marking that message the latest, and causing any previous messages still being processed to be dropped when
|
||||
they next send a [ValidateRepo] message."#
|
||||
);
|
||||
impl MessageToken {
|
||||
pub const fn next(self) -> Self {
|
||||
Self(self.0 + 1)
|
||||
}
|
||||
}
|
||||
|
||||
message!(
|
||||
RegisterWebhook,
|
||||
"Requests that a Webhook be registered with the forge."
|
||||
);
|
||||
message!(
|
||||
CheckCIStatus,
|
||||
Commit,
|
||||
r#"Requests that the CI status for the commit be checked.
|
||||
|
||||
Once the CI Status has been received it will be sent via a [ReceiveCIStatus] message.
|
||||
|
||||
Contains the commit from the tip of the `next` branch."#
|
||||
); // next commit
|
||||
message!(
|
||||
ReceiveCIStatus,
|
||||
(Commit, Status),
|
||||
r#"Notification of the status of the CI checks for the commit.
|
||||
|
||||
Contains a tuple of the commit that was checked (the tip of the `next` branch) and the status."#
|
||||
); // commit and it's status
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct AdvanceNextPayload {
|
||||
pub next: Commit,
|
||||
pub main: Commit,
|
||||
pub dev_commit_history: Vec<Commit>,
|
||||
}
|
||||
message!(
|
||||
AdvanceNext,
|
||||
AdvanceNextPayload,
|
||||
"Request to advance the `next` branch on to the next commit on the `dev branch."
|
||||
); // next commit and the dev commit history
|
||||
message!(
|
||||
AdvanceMain,
|
||||
Commit,
|
||||
"Request to advance the `main` branch on to same commit as the `next` branch."
|
||||
); // next commit
|
||||
message!(
|
||||
WebhookNotification,
|
||||
ForgeNotification,
|
||||
"Notification of a webhook message from the forge."
|
||||
);
|
||||
message!(
|
||||
NotifyUser,
|
||||
UserNotification,
|
||||
"Request to send the message payload to the notification webhook"
|
||||
);
|
204
crates/cli/src/repo/mod.rs
Normal file
204
crates/cli/src/repo/mod.rs
Normal file
|
@ -0,0 +1,204 @@
|
|||
//
|
||||
use actix::prelude::*;
|
||||
|
||||
use crate::{
|
||||
alerts::messages::NotifyUser,
|
||||
server::{actor::messages::RepoUpdate, ServerActor},
|
||||
};
|
||||
use derive_more::Deref;
|
||||
use kxio::network::Network;
|
||||
use tracing::{info, instrument, warn, Instrument};
|
||||
|
||||
use git_next_core::{
|
||||
git::{
|
||||
self,
|
||||
repository::{factory::RepositoryFactory, open::OpenRepositoryLike},
|
||||
UserNotification,
|
||||
},
|
||||
server::ListenUrl,
|
||||
WebhookAuth, WebhookId,
|
||||
};
|
||||
|
||||
mod branch;
|
||||
pub mod handlers;
|
||||
mod load;
|
||||
pub mod messages;
|
||||
mod notifications;
|
||||
|
||||
#[cfg(test)]
|
||||
pub mod tests;
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ActorLog(std::sync::Arc<std::sync::RwLock<Vec<String>>>);
|
||||
impl Deref for ActorLog {
|
||||
type Target = std::sync::Arc<std::sync::RwLock<Vec<String>>>;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// An actor that represents a Git Repository.
|
||||
///
|
||||
/// When this actor is started it is sent the `CloneRepo` message.
|
||||
#[allow(clippy::module_name_repetitions)]
|
||||
#[derive(Debug, derive_more::Display, derive_with::With)]
|
||||
#[display("{}:{}:{}", generation, repo_details.forge.forge_alias(), repo_details.repo_alias)]
|
||||
pub struct RepoActor {
|
||||
sleep_duration: std::time::Duration,
|
||||
generation: git::Generation,
|
||||
message_token: messages::MessageToken,
|
||||
repo_details: git::RepoDetails,
|
||||
listen_url: ListenUrl,
|
||||
webhook_id: Option<WebhookId>, // INFO: if [None] then no webhook is configured
|
||||
webhook_auth: Option<WebhookAuth>, // INFO: if [None] then no webhook is configured
|
||||
last_main_commit: Option<git::Commit>,
|
||||
last_next_commit: Option<git::Commit>,
|
||||
last_dev_commit: Option<git::Commit>,
|
||||
repository_factory: Box<dyn RepositoryFactory>,
|
||||
open_repository: Option<Box<dyn OpenRepositoryLike>>,
|
||||
net: Network,
|
||||
forge: Box<dyn git::ForgeLike>,
|
||||
log: Option<ActorLog>,
|
||||
notify_user_recipient: Option<Recipient<NotifyUser>>,
|
||||
server_addr: Option<Addr<ServerActor>>,
|
||||
}
|
||||
impl RepoActor {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
repo_details: git::RepoDetails,
|
||||
forge: Box<dyn git::ForgeLike>,
|
||||
listen_url: ListenUrl,
|
||||
generation: git::Generation,
|
||||
net: Network,
|
||||
repository_factory: Box<dyn RepositoryFactory>,
|
||||
sleep_duration: std::time::Duration,
|
||||
notify_user_recipient: Option<Recipient<NotifyUser>>,
|
||||
server_addr: Option<Addr<ServerActor>>,
|
||||
) -> Self {
|
||||
let message_token = messages::MessageToken::default();
|
||||
Self {
|
||||
generation,
|
||||
message_token,
|
||||
repo_details,
|
||||
listen_url,
|
||||
webhook_id: None,
|
||||
webhook_auth: None,
|
||||
last_main_commit: None,
|
||||
last_next_commit: None,
|
||||
last_dev_commit: None,
|
||||
repository_factory,
|
||||
open_repository: None,
|
||||
forge,
|
||||
net,
|
||||
sleep_duration,
|
||||
log: None,
|
||||
notify_user_recipient,
|
||||
server_addr,
|
||||
}
|
||||
}
|
||||
|
||||
fn update_tui_branches(&self) {
|
||||
if cfg!(feature = "tui") {
|
||||
use crate::server::actor::messages::RepoUpdate;
|
||||
let Some(repo_config) = &self.repo_details.repo_config else {
|
||||
return;
|
||||
};
|
||||
let branches = repo_config.branches().clone();
|
||||
self.update_tui(RepoUpdate::Branches { branches });
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(unused_variables)]
|
||||
fn update_tui_log(&self, log: git::graph::Log) {
|
||||
if cfg!(feature = "tui") {
|
||||
self.update_tui(RepoUpdate::Log { log });
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(unused_variables)]
|
||||
fn alert_tui(&self, alert: impl Into<String>) {
|
||||
if cfg!(feature = "tui") {
|
||||
self.update_tui(RepoUpdate::Alert {
|
||||
alert: alert.into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(unused_variables)]
|
||||
fn update_tui(&self, repo_update: RepoUpdate) {
|
||||
if cfg!(feature = "tui") {
|
||||
let Some(server_addr) = &self.server_addr else {
|
||||
return;
|
||||
};
|
||||
|
||||
let update = crate::server::actor::messages::ServerUpdate::RepoUpdate {
|
||||
forge_alias: self.repo_details.forge.forge_alias().clone(),
|
||||
repo_alias: self.repo_details.repo_alias.clone(),
|
||||
repo_update,
|
||||
};
|
||||
server_addr.do_send(update);
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Actor for RepoActor {
|
||||
type Context = Context<Self>;
|
||||
#[instrument(name = "RepoActor::stopping", skip_all, fields(repo = %self.repo_details))]
|
||||
fn stopping(&mut self, ctx: &mut Self::Context) -> Running {
|
||||
tracing::debug!("stopping");
|
||||
info!("Checking webhook");
|
||||
match self.webhook_id.take() {
|
||||
Some(webhook_id) => {
|
||||
tracing::warn!("stopping - unregistering webhook");
|
||||
info!(%webhook_id, "Unregistring webhook");
|
||||
let forge = self.forge.duplicate();
|
||||
async move {
|
||||
if let Err(err) = forge.unregister_webhook(&webhook_id).await {
|
||||
warn!("unregistering webhook: {err}");
|
||||
}
|
||||
}
|
||||
.in_current_span()
|
||||
.into_actor(self)
|
||||
.wait(ctx);
|
||||
Running::Continue
|
||||
}
|
||||
None => Running::Stop,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn do_send<M>(addr: &Addr<RepoActor>, msg: M, log: Option<&ActorLog>)
|
||||
where
|
||||
M: actix::Message + Send + 'static + std::fmt::Debug,
|
||||
RepoActor: actix::Handler<M>,
|
||||
<M as actix::Message>::Result: Send,
|
||||
{
|
||||
let log_message = format!("send: {msg:?}");
|
||||
info!(log_message);
|
||||
logger(log, log_message);
|
||||
if cfg!(not(test)) {
|
||||
// #[cfg(not(test))]
|
||||
addr.do_send(msg);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn logger(log: Option<&ActorLog>, message: impl Into<String>) {
|
||||
if let Some(log) = log {
|
||||
let message: String = message.into();
|
||||
tracing::debug!(message);
|
||||
let _ = log.write().map(|mut l| l.push(message));
|
||||
}
|
||||
}
|
||||
pub fn notify_user(
|
||||
recipient: Option<&Recipient<NotifyUser>>,
|
||||
user_notification: UserNotification,
|
||||
log: Option<&ActorLog>,
|
||||
) {
|
||||
let msg = NotifyUser::from(user_notification);
|
||||
let log_message = format!("send: {msg:?}");
|
||||
tracing::debug!(log_message);
|
||||
logger(log, log_message);
|
||||
if let Some(recipient) = &recipient {
|
||||
recipient.do_send(msg);
|
||||
}
|
||||
}
|
90
crates/cli/src/repo/notifications.rs
Normal file
90
crates/cli/src/repo/notifications.rs
Normal file
|
@ -0,0 +1,90 @@
|
|||
//
|
||||
|
||||
use crate::repo::messages::NotifyUser;
|
||||
|
||||
use git_next_core::git::UserNotification;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
impl NotifyUser {
|
||||
pub fn as_json(&self, timestamp: time::OffsetDateTime) -> serde_json::Value {
|
||||
let timestamp = timestamp.unix_timestamp().to_string();
|
||||
match &**self {
|
||||
UserNotification::CICheckFailed {
|
||||
forge_alias,
|
||||
repo_alias,
|
||||
commit,
|
||||
log,
|
||||
} => json!({
|
||||
"type": "cicheck.failed",
|
||||
"timestamp": timestamp,
|
||||
"data": {
|
||||
"forge_alias": forge_alias,
|
||||
"repo_alias": repo_alias,
|
||||
"commit": {
|
||||
"sha": commit.sha(),
|
||||
"message": commit.message()
|
||||
},
|
||||
"log": **log
|
||||
}
|
||||
}),
|
||||
UserNotification::RepoConfigLoadFailure {
|
||||
forge_alias,
|
||||
repo_alias,
|
||||
reason,
|
||||
} => json!({
|
||||
"type": "config.load.failed",
|
||||
"timestamp": timestamp,
|
||||
"data": {
|
||||
"forge_alias": forge_alias,
|
||||
"repo_alias": repo_alias,
|
||||
"reason": reason
|
||||
}
|
||||
}),
|
||||
UserNotification::WebhookRegistration {
|
||||
forge_alias,
|
||||
repo_alias,
|
||||
reason,
|
||||
} => json!({
|
||||
"type": "webhook.registration.failed",
|
||||
"timestamp": timestamp,
|
||||
"data": {
|
||||
"forge_alias": forge_alias,
|
||||
"repo_alias": repo_alias,
|
||||
"reason": reason
|
||||
}
|
||||
}),
|
||||
UserNotification::DevNotBasedOnMain {
|
||||
forge_alias,
|
||||
repo_alias,
|
||||
dev_branch,
|
||||
main_branch,
|
||||
dev_commit,
|
||||
main_commit,
|
||||
log,
|
||||
} => json!({
|
||||
"type": "branch.dev.not-on-main",
|
||||
"timestamp": timestamp,
|
||||
"data": {
|
||||
"forge_alias": forge_alias,
|
||||
"repo_alias": repo_alias,
|
||||
"branches": {
|
||||
"dev": dev_branch,
|
||||
"main": main_branch
|
||||
},
|
||||
"commits": {
|
||||
"dev": {
|
||||
"sha": dev_commit.sha(),
|
||||
"message": dev_commit.message()
|
||||
},
|
||||
"main": {
|
||||
"sha": main_commit.sha(),
|
||||
"message": main_commit.message()
|
||||
}
|
||||
},
|
||||
"log": **log
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
32
crates/cli/src/repo/tests/branch/advance_main.rs
Normal file
32
crates/cli/src/repo/tests/branch/advance_main.rs
Normal file
|
@ -0,0 +1,32 @@
|
|||
use crate::git;
|
||||
|
||||
//
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn push_is_error_should_error() {
|
||||
let commit = given::a_commit();
|
||||
let fs = given::a_filesystem();
|
||||
let repo_config = given::a_repo_config();
|
||||
let (mut open_repository, repo_details) = given::an_open_repository(&fs);
|
||||
expect::fetch_ok(&mut open_repository);
|
||||
expect::push(&mut open_repository, Err(git::push::Error::Lock));
|
||||
let_assert!(
|
||||
Err(err) = branch::advance_main(commit, &repo_details, &repo_config, &open_repository)
|
||||
);
|
||||
assert!(matches!(
|
||||
err,
|
||||
branch::Error::Push(crate::git::push::Error::Lock)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_is_ok_should_ok() {
|
||||
let commit = given::a_commit();
|
||||
let fs = given::a_filesystem();
|
||||
let repo_config = given::a_repo_config();
|
||||
let (mut open_repository, repo_details) = given::an_open_repository(&fs);
|
||||
expect::fetch_ok(&mut open_repository);
|
||||
expect::push_ok(&mut open_repository);
|
||||
assert!(branch::advance_main(commit, &repo_details, &repo_config, &open_repository).is_ok());
|
||||
}
|
194
crates/cli/src/repo/tests/branch/advance_next.rs
Normal file
194
crates/cli/src/repo/tests/branch/advance_next.rs
Normal file
|
@ -0,0 +1,194 @@
|
|||
//
|
||||
use crate::repo::branch::find_next_commit_on_dev;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn advance_next_sut(
|
||||
next: &Commit,
|
||||
main: &Commit,
|
||||
dev_commit_history: &[Commit],
|
||||
repo_details: &RepoDetails,
|
||||
repo_config: RepoConfig,
|
||||
open_repository: &dyn OpenRepositoryLike,
|
||||
message_token: MessageToken,
|
||||
) -> branch::Result<MessageToken> {
|
||||
let (commit, force) = find_next_commit_on_dev(next, main, dev_commit_history);
|
||||
branch::advance_next(
|
||||
commit,
|
||||
force,
|
||||
repo_details,
|
||||
repo_config,
|
||||
open_repository,
|
||||
message_token,
|
||||
)
|
||||
}
|
||||
|
||||
mod when_at_dev {
|
||||
// next and dev branches are the same
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn should_not_push() {
|
||||
let next = given::a_commit();
|
||||
let main = &next;
|
||||
let dev_commit_history = &[next.clone()];
|
||||
let fs = given::a_filesystem();
|
||||
let repo_config = given::a_repo_config();
|
||||
let (open_repository, repo_details) = given::an_open_repository(&fs);
|
||||
// no on_push defined - so any call to push will cause an error
|
||||
let message_token = given::a_message_token();
|
||||
let_assert!(
|
||||
Err(err) = advance_next_sut(
|
||||
&next,
|
||||
main,
|
||||
dev_commit_history,
|
||||
&repo_details,
|
||||
repo_config,
|
||||
&open_repository,
|
||||
message_token,
|
||||
)
|
||||
);
|
||||
tracing::debug!("Got: {err}");
|
||||
assert!(matches!(err, branch::Error::NextAtDev));
|
||||
}
|
||||
}
|
||||
|
||||
mod can_advance {
|
||||
// dev has at least one commit ahead of next
|
||||
use super::*;
|
||||
|
||||
mod to_wip_commit {
|
||||
// commit on dev is either invalid message or a WIP
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn should_not_push() {
|
||||
let next = given::a_commit();
|
||||
let main = &next;
|
||||
let dev = given::a_commit_with_message("wip: test: message".to_string());
|
||||
let dev_commit_history = &[dev, next.clone()];
|
||||
let fs = given::a_filesystem();
|
||||
let repo_config = given::a_repo_config();
|
||||
let (open_repository, repo_details) = given::an_open_repository(&fs);
|
||||
// no on_push defined - so any call to push will cause an error
|
||||
let message_token = given::a_message_token();
|
||||
let_assert!(
|
||||
Err(err) = advance_next_sut(
|
||||
&next,
|
||||
main,
|
||||
dev_commit_history,
|
||||
&repo_details,
|
||||
repo_config,
|
||||
&open_repository,
|
||||
message_token,
|
||||
)
|
||||
);
|
||||
tracing::debug!("Got: {err}");
|
||||
assert!(matches!(err, branch::Error::IsWorkInProgress));
|
||||
}
|
||||
}
|
||||
|
||||
mod to_invalid_commit {
|
||||
// commit on dev is either invalid message or a WIP
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn should_not_push_and_error() {
|
||||
let next = given::a_commit();
|
||||
let main = &next;
|
||||
let dev = given::a_commit();
|
||||
let dev_commit_history = &[dev, next.clone()];
|
||||
let fs = given::a_filesystem();
|
||||
let repo_config = given::a_repo_config();
|
||||
let (open_repository, repo_details) = given::an_open_repository(&fs);
|
||||
// no on_push defined - so any call to push will cause an error
|
||||
let message_token = given::a_message_token();
|
||||
let_assert!(
|
||||
Err(err) = advance_next_sut(
|
||||
&next,
|
||||
main,
|
||||
dev_commit_history,
|
||||
&repo_details,
|
||||
repo_config,
|
||||
&open_repository,
|
||||
message_token,
|
||||
)
|
||||
);
|
||||
tracing::debug!("Got: {err}");
|
||||
assert!(matches!(
|
||||
err,
|
||||
branch::Error::InvalidCommitMessage{reason}
|
||||
if reason == "Missing type in the commit summary, expected `type: description`"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
mod to_valid_commit {
|
||||
// commit on dev is valid conventional commit message
|
||||
use super::*;
|
||||
|
||||
mod push_is_err {
|
||||
// the git push command fails
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn should_error() {
|
||||
let next = given::a_commit();
|
||||
let main = &next;
|
||||
let dev = given::a_commit_with_message("test: message".to_string());
|
||||
let dev_commit_history = &[dev, next.clone()];
|
||||
let fs = given::a_filesystem();
|
||||
let repo_config = given::a_repo_config();
|
||||
let (mut open_repository, repo_details) = given::an_open_repository(&fs);
|
||||
expect::fetch_ok(&mut open_repository);
|
||||
expect::push(&mut open_repository, Err(git::push::Error::Lock));
|
||||
let message_token = given::a_message_token();
|
||||
let_assert!(
|
||||
Err(err) = advance_next_sut(
|
||||
&next,
|
||||
main,
|
||||
dev_commit_history,
|
||||
&repo_details,
|
||||
repo_config,
|
||||
&open_repository,
|
||||
message_token,
|
||||
)
|
||||
);
|
||||
tracing::debug!("Got: {err:?}");
|
||||
assert!(matches!(err, branch::Error::Push(git::push::Error::Lock)));
|
||||
}
|
||||
}
|
||||
|
||||
mod push_is_ok {
|
||||
// the git push command succeeds
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn should_ok() {
|
||||
let next = given::a_commit();
|
||||
let main = &next;
|
||||
let dev = given::a_commit_with_message("test: message".to_string());
|
||||
let dev_commit_history = &[dev, next.clone()];
|
||||
let fs = given::a_filesystem();
|
||||
let repo_config = given::a_repo_config();
|
||||
let (mut open_repository, repo_details) = given::an_open_repository(&fs);
|
||||
expect::fetch_ok(&mut open_repository);
|
||||
expect::push_ok(&mut open_repository);
|
||||
let message_token = given::a_message_token();
|
||||
let_assert!(
|
||||
Ok(mt) = advance_next_sut(
|
||||
&next,
|
||||
main,
|
||||
dev_commit_history,
|
||||
&repo_details,
|
||||
repo_config,
|
||||
&open_repository,
|
||||
message_token,
|
||||
)
|
||||
);
|
||||
tracing::debug!("Got: {mt:?}");
|
||||
assert_eq!(mt, message_token);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
50
crates/cli/src/repo/tests/branch/mod.rs
Normal file
50
crates/cli/src/repo/tests/branch/mod.rs
Normal file
|
@ -0,0 +1,50 @@
|
|||
use git_next_core::git::push::Force;
|
||||
use git_next_core::git::GitRef;
|
||||
|
||||
//
|
||||
use super::*;
|
||||
|
||||
mod advance_main;
|
||||
mod advance_next;
|
||||
|
||||
use crate::git;
|
||||
use crate::repo::branch;
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn test_find_next_commit_on_dev_when_next_is_at_main() {
|
||||
let next = given::a_commit(); // and main
|
||||
let expected = given::a_commit();
|
||||
let dev_commit_history = vec![
|
||||
given::a_commit(), // dev HEAD
|
||||
expected.clone(),
|
||||
next.clone(), // next - advancing towards dev HEAD
|
||||
given::a_commit(), // parent of next
|
||||
];
|
||||
|
||||
let (next_commit, force) = branch::find_next_commit_on_dev(&next, &next, &dev_commit_history);
|
||||
|
||||
assert_eq!(next_commit, Some(expected), "Found the wrong commit");
|
||||
assert_eq!(force, Force::No, "should not try to force");
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn test_find_next_commit_on_dev_when_next_is_not_on_dev() {
|
||||
let next = given::a_commit();
|
||||
let main = given::a_commit();
|
||||
let expected = given::a_commit();
|
||||
let dev_commit_history = vec![
|
||||
given::a_commit(), // dev HEAD
|
||||
expected.clone(),
|
||||
main.clone(), // main - advancing towards dev HEAD
|
||||
given::a_commit(), // parent of next
|
||||
];
|
||||
|
||||
let (next_commit, force) = branch::find_next_commit_on_dev(&next, &main, &dev_commit_history);
|
||||
|
||||
assert_eq!(next_commit, Some(expected), "Found the wrong commit");
|
||||
assert_eq!(
|
||||
force,
|
||||
Force::From(GitRef::from(next.sha().clone())),
|
||||
"should force back onto dev branch"
|
||||
);
|
||||
}
|
53
crates/cli/src/repo/tests/expect.rs
Normal file
53
crates/cli/src/repo/tests/expect.rs
Normal file
|
@ -0,0 +1,53 @@
|
|||
use git_next_core::git::fetch;
|
||||
|
||||
//
|
||||
use super::*;
|
||||
|
||||
pub fn fetch_ok(open_repository: &mut MockOpenRepositoryLike) {
|
||||
expect::fetch(open_repository, Ok(()));
|
||||
}
|
||||
|
||||
pub fn fetch(open_repository: &mut MockOpenRepositoryLike, result: Result<(), fetch::Error>) {
|
||||
open_repository
|
||||
.expect_fetch()
|
||||
.times(1)
|
||||
.return_once(|| result);
|
||||
}
|
||||
|
||||
pub fn push_ok(open_repository: &mut MockOpenRepositoryLike) {
|
||||
expect::push(open_repository, Ok(()));
|
||||
}
|
||||
|
||||
pub fn push(
|
||||
open_repository: &mut MockOpenRepositoryLike,
|
||||
result: Result<(), crate::git::push::Error>,
|
||||
) {
|
||||
open_repository
|
||||
.expect_push()
|
||||
.times(1)
|
||||
.return_once(move |_, _, _, _| result);
|
||||
}
|
||||
|
||||
pub fn open_repository(
|
||||
repository_factory: &mut MockRepositoryFactory,
|
||||
open_repository: MockOpenRepositoryLike,
|
||||
) {
|
||||
repository_factory
|
||||
.expect_open()
|
||||
.times(1)
|
||||
.return_once(move |_| Ok(Box::new(open_repository)));
|
||||
}
|
||||
|
||||
pub fn main_commit_log(
|
||||
validation_repo: &mut MockOpenRepositoryLike,
|
||||
main_branch: BranchName,
|
||||
) -> Commit {
|
||||
let main_commit = given::a_commit();
|
||||
let main_branch_log = vec![main_commit.clone()];
|
||||
validation_repo
|
||||
.expect_commit_log()
|
||||
.times(1)
|
||||
.with(eq(main_branch), eq([]))
|
||||
.return_once(move |_, _| Ok(main_branch_log));
|
||||
main_commit
|
||||
}
|
236
crates/cli/src/repo/tests/given.rs
Normal file
236
crates/cli/src/repo/tests/given.rs
Normal file
|
@ -0,0 +1,236 @@
|
|||
use git_next_core::server::ListenUrl;
|
||||
|
||||
//
|
||||
use super::*;
|
||||
|
||||
pub fn has_all_valid_remote_defaults(
|
||||
open_repository: &mut MockOpenRepositoryLike,
|
||||
repo_details: &RepoDetails,
|
||||
) {
|
||||
has_remote_defaults(
|
||||
open_repository,
|
||||
HashMap::from([
|
||||
(Direction::Push, repo_details.remote_url()),
|
||||
(Direction::Fetch, repo_details.remote_url()),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
pub fn has_remote_defaults(
|
||||
open_repository: &mut MockOpenRepositoryLike,
|
||||
remotes: HashMap<Direction, Option<RemoteUrl>>,
|
||||
) {
|
||||
for (direction, remote) in remotes {
|
||||
open_repository
|
||||
.expect_find_default_remote()
|
||||
.with(eq(direction))
|
||||
.return_once(|_| remote);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn a_webhook_auth() -> WebhookAuth {
|
||||
WebhookAuth::generate()
|
||||
}
|
||||
|
||||
pub fn repo_branches() -> RepoBranches {
|
||||
RepoBranches::new(
|
||||
format!("main-{}", a_name()),
|
||||
format!("next-{}", a_name()),
|
||||
format!("dev-{}", a_name()),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn a_forge_alias() -> ForgeAlias {
|
||||
ForgeAlias::new(a_name())
|
||||
}
|
||||
|
||||
pub fn a_repo_alias() -> RepoAlias {
|
||||
RepoAlias::new(a_name())
|
||||
}
|
||||
|
||||
pub fn a_network() -> kxio::network::MockNetwork {
|
||||
kxio::network::MockNetwork::new()
|
||||
}
|
||||
|
||||
pub fn a_listen_url() -> ListenUrl {
|
||||
ListenUrl::new(a_name())
|
||||
}
|
||||
|
||||
pub fn a_name() -> String {
|
||||
use rand::Rng;
|
||||
use std::iter;
|
||||
|
||||
fn generate(len: usize) -> String {
|
||||
const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
let mut rng = rand::thread_rng();
|
||||
let one_char = || CHARSET[rng.gen_range(0..CHARSET.len())] as char;
|
||||
iter::repeat_with(one_char).take(len).collect()
|
||||
}
|
||||
generate(5)
|
||||
}
|
||||
|
||||
pub fn maybe_a_number() -> Option<u32> {
|
||||
use rand::Rng;
|
||||
let mut rng = rand::thread_rng();
|
||||
if Rng::gen_ratio(&mut rng, 1, 2) {
|
||||
Some(a_number())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn a_number() -> u32 {
|
||||
use rand::Rng;
|
||||
let mut rng = rand::thread_rng();
|
||||
rng.gen_range(0..100)
|
||||
}
|
||||
|
||||
pub fn a_webhook_id() -> WebhookId {
|
||||
WebhookId::new(a_name())
|
||||
}
|
||||
|
||||
pub fn a_branch_name(prefix: impl Into<String>) -> BranchName {
|
||||
BranchName::new(format!("{}-{}", prefix.into(), a_name()))
|
||||
}
|
||||
|
||||
pub fn a_git_dir(fs: &kxio::fs::FileSystem) -> GitDir {
|
||||
let dir_name = a_name();
|
||||
let dir = fs.base().join(dir_name);
|
||||
GitDir::new(dir, StoragePathType::Internal)
|
||||
}
|
||||
|
||||
pub fn a_forge_config() -> ForgeConfig {
|
||||
ForgeConfig::new(
|
||||
ForgeType::MockForge,
|
||||
a_name(),
|
||||
a_name(),
|
||||
a_name(),
|
||||
maybe_a_number(),
|
||||
BTreeMap::default(), // no repos
|
||||
)
|
||||
}
|
||||
|
||||
pub fn a_server_repo_config() -> ServerRepoConfig {
|
||||
let main = a_branch_name("main").to_string();
|
||||
let next = a_branch_name("next").to_string();
|
||||
let dev = a_branch_name("dev").to_string();
|
||||
ServerRepoConfig::new(
|
||||
format!("{}/{}", a_name(), a_name()),
|
||||
main.clone(),
|
||||
None,
|
||||
Some(main),
|
||||
Some(next),
|
||||
Some(dev),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn a_repo_config() -> RepoConfig {
|
||||
RepoConfig::new(given::repo_branches(), RepoConfigSource::Repo)
|
||||
}
|
||||
|
||||
pub fn a_named_commit(name: impl Into<String>) -> Commit {
|
||||
Commit::new(a_named_commit_sha(name), a_commit_message())
|
||||
}
|
||||
|
||||
pub fn a_commit() -> Commit {
|
||||
Commit::new(a_commit_sha(), a_commit_message())
|
||||
}
|
||||
|
||||
pub fn a_commit_with_message(message: impl Into<crate::git::commit::Message>) -> Commit {
|
||||
Commit::new(a_commit_sha(), message.into())
|
||||
}
|
||||
|
||||
pub fn a_commit_message() -> crate::git::commit::Message {
|
||||
crate::git::commit::Message::new(a_name())
|
||||
}
|
||||
|
||||
pub fn a_named_commit_sha(name: impl Into<String>) -> Sha {
|
||||
Sha::new(format!("{}-{}", name.into(), a_name()))
|
||||
}
|
||||
|
||||
pub fn a_commit_sha() -> Sha {
|
||||
Sha::new(a_name())
|
||||
}
|
||||
|
||||
pub fn a_filesystem() -> kxio::fs::FileSystem {
|
||||
kxio::fs::temp().unwrap_or_else(|e| panic!("{}", e))
|
||||
}
|
||||
|
||||
pub fn repo_details(fs: &kxio::fs::FileSystem) -> RepoDetails {
|
||||
let generation = Generation::default();
|
||||
let repo_alias = a_repo_alias();
|
||||
let server_repo_config = a_server_repo_config();
|
||||
let forge_alias = a_forge_alias();
|
||||
let forge_config = a_forge_config();
|
||||
let gitdir = a_git_dir(fs);
|
||||
RepoDetails::new(
|
||||
generation,
|
||||
&repo_alias,
|
||||
&server_repo_config,
|
||||
&forge_alias,
|
||||
&forge_config,
|
||||
gitdir,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn an_open_repository(fs: &kxio::fs::FileSystem) -> (MockOpenRepositoryLike, RepoDetails) {
|
||||
let open_repository = MockOpenRepositoryLike::new();
|
||||
let gitdir = given::a_git_dir(fs);
|
||||
let hostname = given::a_hostname();
|
||||
let repo_details = given::repo_details(fs)
|
||||
.with_gitdir(gitdir)
|
||||
.with_hostname(hostname);
|
||||
(open_repository, repo_details)
|
||||
}
|
||||
|
||||
pub fn a_message_token() -> MessageToken {
|
||||
MessageToken::default()
|
||||
}
|
||||
|
||||
#[allow(clippy::unnecessary_box_returns)]
|
||||
pub fn a_forge() -> Box<MockForgeLike> {
|
||||
Box::new(MockForgeLike::new())
|
||||
}
|
||||
|
||||
pub fn a_repo_actor(
|
||||
repo_details: RepoDetails,
|
||||
repository_factory: Box<dyn RepositoryFactory>,
|
||||
forge: Box<dyn ForgeLike>,
|
||||
net: kxio::network::Network,
|
||||
) -> (RepoActor, ActorLog) {
|
||||
let listen_url = given::a_listen_url();
|
||||
let generation = Generation::default();
|
||||
let log = ActorLog::default();
|
||||
let actors_log = log.clone();
|
||||
(
|
||||
RepoActor::new(
|
||||
repo_details,
|
||||
forge,
|
||||
listen_url,
|
||||
generation,
|
||||
net,
|
||||
repository_factory,
|
||||
std::time::Duration::from_nanos(1),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.with_log(actors_log),
|
||||
log,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn a_hostname() -> Hostname {
|
||||
Hostname::new(given::a_name())
|
||||
}
|
||||
|
||||
pub fn a_registered_webhook() -> RegisteredWebhook {
|
||||
RegisteredWebhook::new(given::a_webhook_id(), given::a_webhook_auth())
|
||||
}
|
||||
|
||||
pub fn a_push() -> Push {
|
||||
Push::new(
|
||||
given::a_branch_name("push"),
|
||||
given::a_name(),
|
||||
given::a_name(),
|
||||
)
|
||||
}
|
98
crates/cli/src/repo/tests/handlers/advance_main.rs
Normal file
98
crates/cli/src/repo/tests/handlers/advance_main.rs
Normal file
|
@ -0,0 +1,98 @@
|
|||
//
|
||||
use super::*;
|
||||
|
||||
#[actix::test]
|
||||
async fn when_repo_config_should_fetch_then_push_then_revalidate() -> TestResult {
|
||||
//given
|
||||
let fs = given::a_filesystem();
|
||||
let (mut open_repository, mut repo_details) = given::an_open_repository(&fs);
|
||||
|
||||
// config from repo
|
||||
#[allow(clippy::unwrap_used)]
|
||||
let repo_config = repo_details.repo_config.take().unwrap();
|
||||
repo_details.repo_config = Some(repo_config.with_source(RepoConfigSource::Repo));
|
||||
|
||||
let next_commit = given::a_commit_with_message("feat: next".to_string());
|
||||
|
||||
let mut seq = mockall::Sequence::new();
|
||||
open_repository
|
||||
.expect_fetch()
|
||||
.times(1)
|
||||
.in_sequence(&mut seq)
|
||||
.return_once(|| Ok(()));
|
||||
open_repository
|
||||
.expect_push()
|
||||
.times(1)
|
||||
.in_sequence(&mut seq)
|
||||
.return_once(|_, _, _, _| Ok(()));
|
||||
open_repository
|
||||
.expect_fetch()
|
||||
.times(1)
|
||||
.in_sequence(&mut seq)
|
||||
.return_once(|| Ok(()));
|
||||
|
||||
//when
|
||||
let (addr, log) = when::start_actor_with_open_repository(
|
||||
Box::new(open_repository),
|
||||
repo_details,
|
||||
given::a_forge(),
|
||||
);
|
||||
addr.send(crate::repo::messages::AdvanceMain::new(next_commit.clone()))
|
||||
.await?;
|
||||
System::current().stop();
|
||||
|
||||
//then
|
||||
tracing::debug!(?log, "");
|
||||
log.read().map_err(|e| e.to_string()).map(|l| {
|
||||
assert!(l
|
||||
.iter()
|
||||
.any(|message| message.contains("send: LoadConfigFromRepo")));
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[actix::test]
|
||||
async fn when_app_config_should_fetch_then_push_then_revalidate() -> TestResult {
|
||||
//given
|
||||
let fs = given::a_filesystem();
|
||||
let (mut open_repository, mut repo_details) = given::an_open_repository(&fs);
|
||||
|
||||
// config from server
|
||||
#[allow(clippy::unwrap_used)]
|
||||
let repo_config = repo_details.repo_config.take().unwrap();
|
||||
repo_details.repo_config = Some(repo_config.with_source(RepoConfigSource::Server));
|
||||
|
||||
let next_commit = given::a_commit_with_message("feat: next".to_string());
|
||||
|
||||
let mut seq = mockall::Sequence::new();
|
||||
open_repository
|
||||
.expect_fetch()
|
||||
.times(1)
|
||||
.in_sequence(&mut seq)
|
||||
.return_once(|| Ok(()));
|
||||
open_repository
|
||||
.expect_push()
|
||||
.times(1)
|
||||
.in_sequence(&mut seq)
|
||||
.return_once(|_, _, _, _| Ok(()));
|
||||
open_repository
|
||||
.expect_fetch()
|
||||
.times(1)
|
||||
.in_sequence(&mut seq)
|
||||
.return_once(|| Ok(()));
|
||||
|
||||
//when
|
||||
let (addr, log) = when::start_actor_with_open_repository(
|
||||
Box::new(open_repository),
|
||||
repo_details,
|
||||
given::a_forge(),
|
||||
);
|
||||
addr.send(crate::repo::messages::AdvanceMain::new(next_commit.clone()))
|
||||
.await?;
|
||||
System::current().stop();
|
||||
|
||||
//then
|
||||
tracing::debug!(?log, "");
|
||||
log.require_message_containing("send: ValidateRepo")?;
|
||||
Ok(())
|
||||
}
|
58
crates/cli/src/repo/tests/handlers/advance_next.rs
Normal file
58
crates/cli/src/repo/tests/handlers/advance_next.rs
Normal file
|
@ -0,0 +1,58 @@
|
|||
use std::time::Duration;
|
||||
|
||||
use crate::repo::messages::AdvanceNextPayload;
|
||||
|
||||
//
|
||||
use super::*;
|
||||
|
||||
#[test_log::test(actix::test)]
|
||||
async fn should_fetch_then_push_then_revalidate() -> TestResult {
|
||||
//given
|
||||
let fs = given::a_filesystem();
|
||||
let (mut open_repository, repo_details) = given::an_open_repository(&fs);
|
||||
let next_commit = given::a_commit_with_message("feat: next".to_string());
|
||||
let dev_commit_history = vec![
|
||||
given::a_commit_with_message("feat: dev".to_string()),
|
||||
given::a_commit_with_message("feat: target".to_string()),
|
||||
next_commit.clone(),
|
||||
];
|
||||
|
||||
let mut seq = mockall::Sequence::new();
|
||||
open_repository
|
||||
.expect_fetch()
|
||||
.times(1)
|
||||
.in_sequence(&mut seq)
|
||||
.return_once(|| Ok(()));
|
||||
open_repository
|
||||
.expect_push()
|
||||
.times(1)
|
||||
.in_sequence(&mut seq)
|
||||
.return_once(|_, _, _, _| Ok(()));
|
||||
open_repository
|
||||
.expect_fetch()
|
||||
.times(1)
|
||||
.in_sequence(&mut seq)
|
||||
.return_once(|| Ok(()));
|
||||
|
||||
//when
|
||||
let (addr, log) = when::start_actor_with_open_repository(
|
||||
Box::new(open_repository),
|
||||
repo_details,
|
||||
given::a_forge(),
|
||||
);
|
||||
addr.send(crate::repo::messages::AdvanceNext::new(
|
||||
AdvanceNextPayload {
|
||||
next: next_commit.clone(),
|
||||
main: next_commit.clone(),
|
||||
dev_commit_history,
|
||||
},
|
||||
))
|
||||
.await?;
|
||||
actix_rt::time::sleep(Duration::from_millis(9)).await;
|
||||
System::current().stop();
|
||||
|
||||
//then
|
||||
tracing::debug!(?log, "");
|
||||
log.require_message_containing("send: ValidateRepo")?;
|
||||
Ok(())
|
||||
}
|
37
crates/cli/src/repo/tests/handlers/check_ci_status.rs
Normal file
37
crates/cli/src/repo/tests/handlers/check_ci_status.rs
Normal file
|
@ -0,0 +1,37 @@
|
|||
//
|
||||
use super::*;
|
||||
|
||||
#[actix::test]
|
||||
async fn should_passthrough_to_receive_ci_status() -> TestResult {
|
||||
//given
|
||||
let fs = given::a_filesystem();
|
||||
let (open_repository, repo_details) = given::an_open_repository(&fs);
|
||||
let next_commit = given::a_named_commit("next");
|
||||
let mut forge = git::MockForgeLike::new();
|
||||
when::commit_status(
|
||||
&mut forge,
|
||||
next_commit.clone(),
|
||||
git::forge::commit::Status::Pass,
|
||||
);
|
||||
|
||||
//when
|
||||
let (addr, log) = when::start_actor_with_open_repository(
|
||||
Box::new(open_repository),
|
||||
repo_details,
|
||||
Box::new(forge),
|
||||
);
|
||||
addr.send(crate::repo::messages::CheckCIStatus::new(
|
||||
next_commit.clone(),
|
||||
))
|
||||
.await?;
|
||||
System::current().stop();
|
||||
|
||||
//then
|
||||
tracing::debug!(?log, "");
|
||||
log.read().map_err(|e| e.to_string()).map(|l| {
|
||||
assert!(l
|
||||
.iter()
|
||||
.any(|message| message.contains("send: ReceiveCIStatus")));
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
194
crates/cli/src/repo/tests/handlers/clone_repo.rs
Normal file
194
crates/cli/src/repo/tests/handlers/clone_repo.rs
Normal file
|
@ -0,0 +1,194 @@
|
|||
//
|
||||
use super::*;
|
||||
|
||||
#[actix::test]
|
||||
async fn should_clone() -> TestResult {
|
||||
//given
|
||||
let fs = given::a_filesystem();
|
||||
let (mut open_repository, /* mut */ repo_details) = given::an_open_repository(&fs);
|
||||
// #[allow(clippy::unwrap_used)]
|
||||
// let repo_config = repo_details.repo_config.take().unwrap();
|
||||
|
||||
given::has_all_valid_remote_defaults(&mut open_repository, &repo_details);
|
||||
// handles_validate_repo_message(&mut open_repository, repo_config.branches());
|
||||
|
||||
// factory clones an open repository
|
||||
let mut repository_factory = MockRepositoryFactory::new();
|
||||
let cloned = Arc::new(RwLock::new(vec![]));
|
||||
let cloned_ref = cloned.clone();
|
||||
repository_factory
|
||||
.expect_git_clone()
|
||||
.times(2)
|
||||
.return_once(move |_| {
|
||||
let _ = cloned_ref.write().map(|mut l| l.push(()));
|
||||
Ok(Box::new(open_repository))
|
||||
});
|
||||
|
||||
//when
|
||||
let (addr, _log) = when::start_actor(repository_factory, repo_details, given::a_forge());
|
||||
addr.send(CloneRepo::new()).await?;
|
||||
System::current().stop();
|
||||
|
||||
//then
|
||||
cloned
|
||||
.read()
|
||||
.map_err(|e| e.to_string())
|
||||
.map(|o| assert_eq!(o.len(), 1))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[actix::test]
|
||||
async fn should_open() -> TestResult {
|
||||
//given
|
||||
let fs = given::a_filesystem();
|
||||
let (mut open_repository, repo_details) = given::an_open_repository(&fs);
|
||||
open_repository
|
||||
.expect_fetch()
|
||||
.times(1)
|
||||
.return_once(|| Ok(()));
|
||||
given::has_all_valid_remote_defaults(&mut open_repository, &repo_details);
|
||||
// factory opens a repository
|
||||
let mut repository_factory = MockRepositoryFactory::new();
|
||||
let opened = Arc::new(RwLock::new(vec![]));
|
||||
let opened_ref = opened.clone();
|
||||
repository_factory
|
||||
.expect_open()
|
||||
.times(1)
|
||||
.return_once(move |_| {
|
||||
let _ = opened_ref.write().map(|mut l| l.push(()));
|
||||
Ok(Box::new(open_repository))
|
||||
});
|
||||
fs.dir_create(&repo_details.gitdir)?;
|
||||
|
||||
//when
|
||||
let (addr, _log) = when::start_actor(repository_factory, repo_details, given::a_forge());
|
||||
addr.send(CloneRepo::new()).await?;
|
||||
System::current().stop();
|
||||
|
||||
//then
|
||||
opened
|
||||
.read()
|
||||
.map_err(|e| e.to_string())
|
||||
.map(|o| assert_eq!(o.len(), 1))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The server config can optionally include the names of the main, next and dev
|
||||
/// branches. When it doesn't we should load the `.git-next.yaml` from from the
|
||||
/// repo and get the branch names from there by sending a [LoadConfigFromRepo] message.
|
||||
#[actix::test]
|
||||
async fn when_server_has_no_repo_config_should_send_load_from_repo() -> TestResult {
|
||||
//given
|
||||
let fs = given::a_filesystem();
|
||||
let (mut open_repository, mut repo_details) = given::an_open_repository(&fs);
|
||||
open_repository
|
||||
.expect_fetch()
|
||||
.times(1)
|
||||
.return_once(|| Ok(()));
|
||||
#[allow(clippy::unwrap_used)]
|
||||
let _repo_config = repo_details.repo_config.take().unwrap();
|
||||
|
||||
given::has_all_valid_remote_defaults(&mut open_repository, &repo_details);
|
||||
|
||||
let mut repository_factory = MockRepositoryFactory::new();
|
||||
expect::open_repository(&mut repository_factory, open_repository);
|
||||
fs.dir_create(&repo_details.gitdir)?;
|
||||
|
||||
//when
|
||||
let (addr, log) = when::start_actor(repository_factory, repo_details, given::a_forge());
|
||||
addr.send(CloneRepo::new()).await?;
|
||||
System::current().stop();
|
||||
|
||||
//then
|
||||
log.require_message_containing("send: LoadConfigFromRepo")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The server config can optionally include the names of the main, next and dev
|
||||
/// branches. When it does we should register the webhook by sending [RegisterWebhook] message.
|
||||
#[actix::test]
|
||||
async fn when_server_has_repo_config_should_send_register_webhook() -> TestResult {
|
||||
//given
|
||||
let fs = given::a_filesystem();
|
||||
let (mut open_repository, repo_details) = given::an_open_repository(&fs);
|
||||
open_repository
|
||||
.expect_fetch()
|
||||
.times(1)
|
||||
.return_once(|| Ok(()));
|
||||
#[allow(clippy::unwrap_used)]
|
||||
given::has_all_valid_remote_defaults(&mut open_repository, &repo_details);
|
||||
|
||||
let mut repository_factory = MockRepositoryFactory::new();
|
||||
expect::open_repository(&mut repository_factory, open_repository);
|
||||
fs.dir_create(&repo_details.gitdir)?;
|
||||
|
||||
//when
|
||||
let (addr, log) = when::start_actor(repository_factory, repo_details, given::a_forge());
|
||||
addr.send(CloneRepo::new()).await?;
|
||||
System::current().stop();
|
||||
|
||||
//then
|
||||
log.require_message_containing("send: RegisterWebhook")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test_log::test(actix::test)]
|
||||
async fn opened_repo_with_no_default_push_should_not_proceed() -> TestResult {
|
||||
//given
|
||||
let fs = given::a_filesystem();
|
||||
let (mut open_repository, repo_details) = given::an_open_repository(&fs);
|
||||
open_repository
|
||||
.expect_fetch()
|
||||
.times(1)
|
||||
.return_once(|| Ok(()));
|
||||
|
||||
given::has_remote_defaults(
|
||||
&mut open_repository,
|
||||
HashMap::from([
|
||||
(Direction::Push, None),
|
||||
(Direction::Fetch, repo_details.remote_url()),
|
||||
]),
|
||||
);
|
||||
|
||||
let mut repository_factory = MockRepositoryFactory::new();
|
||||
expect::open_repository(&mut repository_factory, open_repository);
|
||||
fs.dir_create(&repo_details.gitdir)?;
|
||||
|
||||
//when
|
||||
let (addr, log) = when::start_actor(repository_factory, repo_details, given::a_forge());
|
||||
addr.send(CloneRepo::new()).await?;
|
||||
System::current().stop();
|
||||
|
||||
//then
|
||||
log.require_message_containing("open failed")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test_log::test(actix::test)]
|
||||
async fn opened_repo_with_no_default_fetch_should_not_proceed() -> TestResult {
|
||||
//given
|
||||
let fs = given::a_filesystem();
|
||||
let (mut open_repository, repo_details) = given::an_open_repository(&fs);
|
||||
open_repository
|
||||
.expect_fetch()
|
||||
.times(1)
|
||||
.return_once(|| Err(git::fetch::Error::NoFetchRemoteFound));
|
||||
let mut repository_factory = MockRepositoryFactory::new();
|
||||
expect::open_repository(&mut repository_factory, open_repository);
|
||||
fs.dir_create(&repo_details.gitdir)?;
|
||||
|
||||
//when
|
||||
let (addr, log) = when::start_actor(repository_factory, repo_details, given::a_forge());
|
||||
addr.send(CloneRepo::new()).await?;
|
||||
System::current().stop();
|
||||
|
||||
//then
|
||||
log.require_message_containing("open failed")?;
|
||||
|
||||
Ok(())
|
||||
}
|
82
crates/cli/src/repo/tests/handlers/load_config_from_repo.rs
Normal file
82
crates/cli/src/repo/tests/handlers/load_config_from_repo.rs
Normal file
|
@ -0,0 +1,82 @@
|
|||
//
|
||||
use super::*;
|
||||
|
||||
#[actix::test]
|
||||
async fn when_read_file_ok_should_send_config_loaded() -> TestResult {
|
||||
//given
|
||||
let fs = given::a_filesystem();
|
||||
let (mut open_repository, repo_details) = given::an_open_repository(&fs);
|
||||
let mut load_config_open_repo = MockOpenRepositoryLike::new();
|
||||
let branches = given::repo_branches();
|
||||
let remote_branches = vec![branches.main(), branches.next(), branches.dev()];
|
||||
load_config_open_repo
|
||||
.expect_read_file()
|
||||
.return_once(move |_, _| {
|
||||
Ok(format!(
|
||||
r#"
|
||||
[branches]
|
||||
main = "{}"
|
||||
next = "{}"
|
||||
dev = "{}"
|
||||
"#,
|
||||
branches.main(),
|
||||
branches.next(),
|
||||
branches.dev()
|
||||
))
|
||||
});
|
||||
|
||||
load_config_open_repo
|
||||
.expect_remote_branches()
|
||||
.return_once(|| Ok(remote_branches));
|
||||
|
||||
open_repository
|
||||
.expect_duplicate()
|
||||
.return_once(|| Box::new(load_config_open_repo));
|
||||
|
||||
//when
|
||||
let (addr, log) = when::start_actor_with_open_repository(
|
||||
Box::new(open_repository),
|
||||
repo_details,
|
||||
given::a_forge(),
|
||||
);
|
||||
addr.send(crate::repo::messages::LoadConfigFromRepo::new())
|
||||
.await?;
|
||||
System::current().stop();
|
||||
|
||||
//then
|
||||
tracing::debug!(?log, "");
|
||||
log.require_message_containing("send: ReceiveRepoConfig")?;
|
||||
log.no_message_contains("send: NotifyUsers")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[actix::test]
|
||||
async fn when_read_file_err_should_notify_user() -> TestResult {
|
||||
//given
|
||||
let fs = given::a_filesystem();
|
||||
let (mut open_repository, repo_details) = given::an_open_repository(&fs);
|
||||
let mut load_config_open_repo = MockOpenRepositoryLike::new();
|
||||
load_config_open_repo
|
||||
.expect_read_file()
|
||||
.return_once(move |_, _| Err(git::file::Error::FileNotFound));
|
||||
|
||||
open_repository
|
||||
.expect_duplicate()
|
||||
.return_once(|| Box::new(load_config_open_repo));
|
||||
|
||||
//when
|
||||
let (addr, log) = when::start_actor_with_open_repository(
|
||||
Box::new(open_repository),
|
||||
repo_details,
|
||||
given::a_forge(),
|
||||
);
|
||||
addr.send(crate::repo::messages::LoadConfigFromRepo::new())
|
||||
.await?;
|
||||
System::current().stop();
|
||||
|
||||
//then
|
||||
tracing::debug!(?log, "");
|
||||
log.require_message_containing("send: NotifyUser")?;
|
||||
log.no_message_contains("send: ReceiveRepoConfig")?;
|
||||
Ok(())
|
||||
}
|
59
crates/cli/src/repo/tests/handlers/loaded_config.rs
Normal file
59
crates/cli/src/repo/tests/handlers/loaded_config.rs
Normal file
|
@ -0,0 +1,59 @@
|
|||
//
|
||||
use super::*;
|
||||
|
||||
#[actix::test]
|
||||
async fn should_store_repo_config_in_actor() -> TestResult {
|
||||
//given
|
||||
let fs = given::a_filesystem();
|
||||
let (open_repository, repo_details) = given::an_open_repository(&fs);
|
||||
|
||||
let new_repo_config = given::a_repo_config();
|
||||
|
||||
//when
|
||||
let (addr, log) = when::start_actor_with_open_repository(
|
||||
Box::new(open_repository),
|
||||
repo_details,
|
||||
given::a_forge(),
|
||||
);
|
||||
addr.send(crate::repo::messages::ReceiveRepoConfig::new(
|
||||
new_repo_config.clone(),
|
||||
))
|
||||
.await?;
|
||||
System::current().stop();
|
||||
|
||||
//then
|
||||
tracing::debug!(?log, "");
|
||||
|
||||
let reo_actor_view = addr.send(ExamineActor).await?;
|
||||
assert_eq!(
|
||||
reo_actor_view.repo_details.repo_config,
|
||||
Some(new_repo_config)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test_log::test(actix::test)]
|
||||
async fn should_register_webhook() -> TestResult {
|
||||
//given
|
||||
let fs = given::a_filesystem();
|
||||
let (open_repository, repo_details) = given::an_open_repository(&fs);
|
||||
|
||||
let new_repo_config = given::a_repo_config();
|
||||
|
||||
//when
|
||||
let (addr, log) = when::start_actor_with_open_repository(
|
||||
Box::new(open_repository),
|
||||
repo_details,
|
||||
given::a_forge(),
|
||||
);
|
||||
addr.send(crate::repo::messages::ReceiveRepoConfig::new(
|
||||
new_repo_config.clone(),
|
||||
))
|
||||
.await?;
|
||||
System::current().stop();
|
||||
|
||||
//then
|
||||
tracing::debug!(?log, "");
|
||||
log.require_message_containing("send: RegisterWebhook")?;
|
||||
Ok(())
|
||||
}
|
14
crates/cli/src/repo/tests/handlers/mod.rs
Normal file
14
crates/cli/src/repo/tests/handlers/mod.rs
Normal file
|
@ -0,0 +1,14 @@
|
|||
//
|
||||
use super::*;
|
||||
|
||||
mod advance_main;
|
||||
mod advance_next;
|
||||
mod check_ci_status;
|
||||
mod clone_repo;
|
||||
mod load_config_from_repo;
|
||||
mod loaded_config;
|
||||
mod receive_ci_status;
|
||||
mod register_webhook;
|
||||
mod validate_repo;
|
||||
mod webhook_notification;
|
||||
mod webhook_registered;
|
112
crates/cli/src/repo/tests/handlers/receive_ci_status.rs
Normal file
112
crates/cli/src/repo/tests/handlers/receive_ci_status.rs
Normal file
|
@ -0,0 +1,112 @@
|
|||
use std::time::Duration;
|
||||
|
||||
//
|
||||
use super::*;
|
||||
|
||||
#[test_log::test(actix::test)]
|
||||
async fn when_pass_should_advance_main_to_next() -> TestResult {
|
||||
//given
|
||||
let fs = given::a_filesystem();
|
||||
let (open_repository, repo_details) = given::an_open_repository(&fs);
|
||||
let next_commit = given::a_named_commit("next");
|
||||
|
||||
//when
|
||||
let (addr, log) = when::start_actor_with_open_repository(
|
||||
Box::new(open_repository),
|
||||
repo_details,
|
||||
given::a_forge(),
|
||||
);
|
||||
addr.send(crate::repo::messages::ReceiveCIStatus::new((
|
||||
next_commit.clone(),
|
||||
git::forge::commit::Status::Pass,
|
||||
)))
|
||||
.await?;
|
||||
System::current().stop();
|
||||
|
||||
//then
|
||||
tracing::debug!(?log, "");
|
||||
log.read().map_err(|e| e.to_string()).map(|l| {
|
||||
let expected = format!("send: AdvanceMain({next_commit:?})");
|
||||
tracing::debug!(%expected,"");
|
||||
assert!(l.iter().any(|message| message.contains(&expected)));
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test_log::test(actix::test)]
|
||||
async fn when_pending_should_recheck_ci_status() -> TestResult {
|
||||
//given
|
||||
let fs = given::a_filesystem();
|
||||
let (open_repository, repo_details) = given::an_open_repository(&fs);
|
||||
let next_commit = given::a_named_commit("next");
|
||||
|
||||
//when
|
||||
let (addr, log) = when::start_actor_with_open_repository(
|
||||
Box::new(open_repository),
|
||||
repo_details,
|
||||
given::a_forge(),
|
||||
);
|
||||
addr.send(crate::repo::messages::ReceiveCIStatus::new((
|
||||
next_commit.clone(),
|
||||
git::forge::commit::Status::Pending,
|
||||
)))
|
||||
.await?;
|
||||
actix_rt::time::sleep(Duration::from_millis(9)).await;
|
||||
System::current().stop();
|
||||
|
||||
//then
|
||||
tracing::debug!(?log, "");
|
||||
log.require_message_containing("send: ValidateRepo")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test_log::test(actix::test)]
|
||||
async fn when_fail_should_recheck_after_delay() -> TestResult {
|
||||
//given
|
||||
let fs = given::a_filesystem();
|
||||
let (open_repository, repo_details) = given::an_open_repository(&fs);
|
||||
let next_commit = given::a_named_commit("next");
|
||||
|
||||
//when
|
||||
let (addr, log) = when::start_actor_with_open_repository(
|
||||
Box::new(open_repository),
|
||||
repo_details,
|
||||
given::a_forge(),
|
||||
);
|
||||
addr.send(crate::repo::messages::ReceiveCIStatus::new((
|
||||
next_commit.clone(),
|
||||
git::forge::commit::Status::Fail,
|
||||
)))
|
||||
.await?;
|
||||
actix_rt::time::sleep(Duration::from_millis(9)).await;
|
||||
System::current().stop();
|
||||
|
||||
//then
|
||||
log.require_message_containing("send: ValidateRepo")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test_log::test(actix::test)]
|
||||
async fn when_fail_should_notify_user() -> TestResult {
|
||||
//given
|
||||
let fs = given::a_filesystem();
|
||||
let (open_repository, repo_details) = given::an_open_repository(&fs);
|
||||
let next_commit = given::a_named_commit("next");
|
||||
|
||||
//when
|
||||
let (addr, log) = when::start_actor_with_open_repository(
|
||||
Box::new(open_repository),
|
||||
repo_details,
|
||||
given::a_forge(),
|
||||
);
|
||||
addr.send(crate::repo::messages::ReceiveCIStatus::new((
|
||||
next_commit.clone(),
|
||||
git::forge::commit::Status::Fail,
|
||||
)))
|
||||
.await?;
|
||||
System::current().stop();
|
||||
|
||||
//then
|
||||
log.require_message_containing("send: NotifyUser")?;
|
||||
Ok(())
|
||||
}
|
71
crates/cli/src/repo/tests/handlers/register_webhook.rs
Normal file
71
crates/cli/src/repo/tests/handlers/register_webhook.rs
Normal file
|
@ -0,0 +1,71 @@
|
|||
//
|
||||
use super::*;
|
||||
|
||||
#[actix::test]
|
||||
async fn when_registered_ok_should_send_webhook_registered() -> TestResult {
|
||||
//given
|
||||
let fs = given::a_filesystem();
|
||||
let (open_repository, repo_details) = given::an_open_repository(&fs);
|
||||
|
||||
let registered_webhook = given::a_registered_webhook();
|
||||
let mut my_forge = git::MockForgeLike::new();
|
||||
my_forge
|
||||
.expect_register_webhook()
|
||||
.return_once(move |_| Ok(registered_webhook));
|
||||
|
||||
let mut forge = git::MockForgeLike::new();
|
||||
forge.expect_duplicate().return_once(|| Box::new(my_forge));
|
||||
|
||||
//when
|
||||
let (addr, log) = when::start_actor_with_open_repository(
|
||||
Box::new(open_repository),
|
||||
repo_details,
|
||||
Box::new(forge),
|
||||
);
|
||||
addr.send(crate::repo::messages::RegisterWebhook::new())
|
||||
.await?;
|
||||
System::current().stop();
|
||||
|
||||
//then
|
||||
tracing::debug!(?log, "");
|
||||
log.read().map_err(|e| e.to_string()).map(|l| {
|
||||
assert!(l
|
||||
.iter()
|
||||
.any(|message| message.contains("send: WebhookRegistered")));
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[actix::test]
|
||||
async fn when_registered_error_should_send_notify_user() -> TestResult {
|
||||
//given
|
||||
let fs = given::a_filesystem();
|
||||
let (open_repository, repo_details) = given::an_open_repository(&fs);
|
||||
|
||||
let mut my_forge = git::MockForgeLike::new();
|
||||
my_forge.expect_register_webhook().return_once(move |_| {
|
||||
Err(git::forge::webhook::Error::FailedToRegister(
|
||||
"foo".to_string(),
|
||||
))
|
||||
});
|
||||
|
||||
let mut forge = git::MockForgeLike::new();
|
||||
forge.expect_duplicate().return_once(|| Box::new(my_forge));
|
||||
|
||||
//when
|
||||
let (addr, log) = when::start_actor_with_open_repository(
|
||||
Box::new(open_repository),
|
||||
repo_details,
|
||||
Box::new(forge),
|
||||
);
|
||||
addr.send(crate::repo::messages::RegisterWebhook::new())
|
||||
.await?;
|
||||
System::current().stop();
|
||||
|
||||
//then
|
||||
tracing::debug!(?log, "");
|
||||
log.read()
|
||||
.map_err(|e| e.to_string())
|
||||
.map(|l| assert!(l.iter().any(|message| message.contains("send: NotifyUser"))))?;
|
||||
Ok(())
|
||||
}
|
560
crates/cli/src/repo/tests/handlers/validate_repo.rs
Normal file
560
crates/cli/src/repo/tests/handlers/validate_repo.rs
Normal file
|
@ -0,0 +1,560 @@
|
|||
use crate::repo::messages::{AdvanceNext, AdvanceNextPayload};
|
||||
|
||||
//
|
||||
use super::*;
|
||||
|
||||
#[test_log::test(actix::test)]
|
||||
async fn repo_with_next_not_an_ancestor_of_dev_and_dev_on_main_should_be_reset_to_main(
|
||||
) -> TestResult {
|
||||
//given
|
||||
let fs = given::a_filesystem();
|
||||
let (mut open_repository, repo_details) = given::an_open_repository(&fs);
|
||||
#[allow(clippy::unwrap_used)]
|
||||
let repo_config = repo_details.repo_config.clone().unwrap();
|
||||
expect::fetch_ok(&mut open_repository);
|
||||
let branches = repo_config.branches();
|
||||
// commit_log main
|
||||
let main_commit = expect::main_commit_log(&mut open_repository, branches.main());
|
||||
// next - based on main
|
||||
let next_branch_log = vec![given::a_commit(), main_commit.clone()];
|
||||
// dev - based on main, but not on next
|
||||
let dev_branch_log = vec![main_commit.clone()];
|
||||
// commit_log next - based on main, but not a parent of dev
|
||||
open_repository
|
||||
.expect_commit_log()
|
||||
.times(1)
|
||||
.with(eq(branches.next()), eq([main_commit.clone()]))
|
||||
.return_once(move |_, _| Ok(next_branch_log));
|
||||
// commit_log dev
|
||||
open_repository
|
||||
.expect_commit_log()
|
||||
.times(1)
|
||||
.with(eq(branches.dev()), eq([main_commit]))
|
||||
.return_once(|_, _| Ok(dev_branch_log));
|
||||
// expect to reset the branch
|
||||
expect::fetch_ok(&mut open_repository);
|
||||
expect::push_ok(&mut open_repository);
|
||||
|
||||
//when
|
||||
let (addr, log) = when::start_actor_with_open_repository(
|
||||
Box::new(open_repository),
|
||||
repo_details,
|
||||
given::a_forge(),
|
||||
);
|
||||
addr.send(crate::repo::messages::ValidateRepo::new(
|
||||
MessageToken::default(),
|
||||
))
|
||||
.await?;
|
||||
System::current().stop();
|
||||
|
||||
//then
|
||||
log.require_message_containing(format!("Branch {} has been reset", branches.next()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test_log::test(actix::test)]
|
||||
async fn repo_with_next_not_an_ancestor_of_dev_and_dev_ahead_of_main_should_be_reset_to_dev(
|
||||
) -> TestResult {
|
||||
//given
|
||||
let fs = given::a_filesystem();
|
||||
let (mut open_repository, repo_details) = given::an_open_repository(&fs);
|
||||
#[allow(clippy::unwrap_used)]
|
||||
let repo_config = repo_details.repo_config.clone().unwrap();
|
||||
expect::fetch_ok(&mut open_repository);
|
||||
let branches = repo_config.branches();
|
||||
// commit_log main
|
||||
let main_commit = expect::main_commit_log(&mut open_repository, branches.main());
|
||||
// next - based on main
|
||||
let next_commit = given::a_commit();
|
||||
let next_branch_log = vec![next_commit.clone(), main_commit.clone()];
|
||||
// dev - based on main, but not on next
|
||||
let dev_branch_log = vec![given::a_commit(), main_commit.clone()];
|
||||
// commit_log next - based on main, but not a parent of dev
|
||||
open_repository
|
||||
.expect_commit_log()
|
||||
.times(1)
|
||||
.with(eq(branches.next()), eq([main_commit.clone()]))
|
||||
.return_once(move |_, _| Ok(next_branch_log));
|
||||
// commit_log dev
|
||||
let dev_branch_log_clone = dev_branch_log.clone();
|
||||
open_repository
|
||||
.expect_commit_log()
|
||||
.times(1)
|
||||
.with(eq(branches.dev()), eq([main_commit.clone()]))
|
||||
.return_once(|_, _| Ok(dev_branch_log_clone));
|
||||
// expect to reset the branch
|
||||
expect::fetch_ok(&mut open_repository);
|
||||
expect::push_ok(&mut open_repository);
|
||||
|
||||
//when
|
||||
let (addr, log) = when::start_actor_with_open_repository(
|
||||
Box::new(open_repository),
|
||||
repo_details,
|
||||
given::a_forge(),
|
||||
);
|
||||
addr.send(crate::repo::messages::ValidateRepo::new(
|
||||
MessageToken::default(),
|
||||
))
|
||||
.await?;
|
||||
System::current().stop();
|
||||
|
||||
//then
|
||||
let expected = AdvanceNext::new(AdvanceNextPayload {
|
||||
next: next_commit,
|
||||
main: main_commit,
|
||||
dev_commit_history: dev_branch_log,
|
||||
});
|
||||
log.require_message_containing(format!("send: {expected:?}",))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test_log::test(actix::test)]
|
||||
async fn repo_with_next_not_on_or_near_main_should_be_reset() -> TestResult {
|
||||
//given
|
||||
let fs = given::a_filesystem();
|
||||
let (mut open_repository, repo_details) = given::an_open_repository(&fs);
|
||||
#[allow(clippy::unwrap_used)]
|
||||
let repo_config = repo_details.repo_config.clone().unwrap();
|
||||
expect::fetch_ok(&mut open_repository);
|
||||
let branches = repo_config.branches();
|
||||
// commit_log main
|
||||
let main_commit = expect::main_commit_log(&mut open_repository, branches.main());
|
||||
// next - based on main, but too far in advance
|
||||
let next_branch_log = vec![given::a_commit(), given::a_commit(), main_commit.clone()];
|
||||
// dev - based on next
|
||||
let mut dev_branch_log = vec![given::a_commit()];
|
||||
dev_branch_log.extend(next_branch_log.clone());
|
||||
// commit_log next
|
||||
open_repository
|
||||
.expect_commit_log()
|
||||
.times(1)
|
||||
.with(eq(branches.next()), eq([main_commit.clone()]))
|
||||
.return_once(move |_, _| Ok(next_branch_log));
|
||||
// commit_log dev
|
||||
open_repository
|
||||
.expect_commit_log()
|
||||
.times(1)
|
||||
.with(eq(branches.dev()), eq([main_commit]))
|
||||
.return_once(|_, _| Ok(dev_branch_log));
|
||||
expect::fetch_ok(&mut open_repository);
|
||||
expect::push_ok(&mut open_repository);
|
||||
|
||||
//when
|
||||
let (addr, log) = when::start_actor_with_open_repository(
|
||||
Box::new(open_repository),
|
||||
repo_details,
|
||||
given::a_forge(),
|
||||
);
|
||||
addr.send(crate::repo::messages::ValidateRepo::new(
|
||||
MessageToken::default(),
|
||||
))
|
||||
.await?;
|
||||
System::current().stop();
|
||||
|
||||
//then
|
||||
log.require_message_containing(format!("Branch {} has been reset", branches.next()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test_log::test(actix::test)]
|
||||
async fn repo_with_next_not_based_on_main_should_be_reset() -> TestResult {
|
||||
//given
|
||||
let fs = given::a_filesystem();
|
||||
let (mut open_repository, repo_details) = given::an_open_repository(&fs);
|
||||
#[allow(clippy::unwrap_used)]
|
||||
let repo_config = repo_details.repo_config.clone().unwrap();
|
||||
// given::has_all_valid_remote_defaults(&mut open_repository, &repo_details);
|
||||
expect::fetch_ok(&mut open_repository);
|
||||
let branches = repo_config.branches();
|
||||
// commit_log main
|
||||
let main_commit = expect::main_commit_log(&mut open_repository, branches.main());
|
||||
// next - not based on main
|
||||
let next_branch_log = vec![given::a_commit(), given::a_commit(), given::a_commit()];
|
||||
// dev - based on main
|
||||
let dev_branch_log = vec![given::a_commit(), main_commit.clone()];
|
||||
// commit_log next
|
||||
open_repository
|
||||
.expect_commit_log()
|
||||
.times(1)
|
||||
.with(eq(branches.next()), eq([main_commit.clone()]))
|
||||
.return_once(move |_, _| Ok(next_branch_log));
|
||||
// commit_log dev
|
||||
open_repository
|
||||
.expect_commit_log()
|
||||
.times(1)
|
||||
.with(eq(branches.dev()), eq([main_commit]))
|
||||
.return_once(|_, _| Ok(dev_branch_log));
|
||||
expect::fetch_ok(&mut open_repository);
|
||||
expect::push_ok(&mut open_repository);
|
||||
|
||||
//when
|
||||
let (addr, log) = when::start_actor_with_open_repository(
|
||||
Box::new(open_repository),
|
||||
repo_details,
|
||||
given::a_forge(),
|
||||
);
|
||||
addr.send(crate::repo::messages::ValidateRepo::new(
|
||||
MessageToken::default(),
|
||||
))
|
||||
.await?;
|
||||
System::current().stop();
|
||||
|
||||
//then
|
||||
log.require_message_containing(format!("Branch {} has been reset", branches.next()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test_log::test(actix::test)]
|
||||
async fn repo_with_next_ahead_of_main_should_check_ci_status() -> TestResult {
|
||||
//given
|
||||
let fs = given::a_filesystem();
|
||||
let (mut open_repository, repo_details) = given::an_open_repository(&fs);
|
||||
#[allow(clippy::unwrap_used)]
|
||||
let repo_config = repo_details.repo_config.clone().unwrap();
|
||||
// Validate repo branches
|
||||
expect::fetch_ok(&mut open_repository);
|
||||
let branches = repo_config.branches();
|
||||
// commit_log main
|
||||
let main_commit = expect::main_commit_log(&mut open_repository, branches.main());
|
||||
// next - ahead of main
|
||||
let next_commit = given::a_named_commit("next");
|
||||
let next_branch_log = vec![next_commit.clone(), main_commit.clone()];
|
||||
// dev - on next
|
||||
let dev_branch_log = next_branch_log.clone();
|
||||
// commit_log next
|
||||
open_repository
|
||||
.expect_commit_log()
|
||||
.times(1)
|
||||
.with(eq(branches.next()), eq([main_commit.clone()]))
|
||||
.return_once(move |_, _| Ok(next_branch_log));
|
||||
// commit_log dev
|
||||
open_repository
|
||||
.expect_commit_log()
|
||||
.times(1)
|
||||
.with(eq(branches.dev()), eq([main_commit]))
|
||||
.return_once(|_, _| Ok(dev_branch_log));
|
||||
|
||||
//when
|
||||
let (addr, log) = when::start_actor_with_open_repository(
|
||||
Box::new(open_repository),
|
||||
repo_details,
|
||||
given::a_forge(),
|
||||
);
|
||||
addr.send(crate::repo::messages::ValidateRepo::new(
|
||||
MessageToken::default(),
|
||||
))
|
||||
.await?;
|
||||
System::current().stop();
|
||||
|
||||
//then
|
||||
log.require_message_containing(format!("send: CheckCIStatus({next_commit:?})"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test_log::test(actix::test)]
|
||||
async fn repo_with_dev_and_next_on_main_should_do_nothing() -> TestResult {
|
||||
// Do nothing, when the situation changes we will hear about it via a webhook
|
||||
//given
|
||||
let fs = given::a_filesystem();
|
||||
let (mut open_repository, repo_details) = given::an_open_repository(&fs);
|
||||
#[allow(clippy::unwrap_used)]
|
||||
let repo_config = repo_details.repo_config.clone().unwrap();
|
||||
// Validate repo branches
|
||||
expect::fetch_ok(&mut open_repository);
|
||||
let branches = repo_config.branches();
|
||||
// commit_log main
|
||||
let main_commit = expect::main_commit_log(&mut open_repository, branches.main());
|
||||
// next - on main
|
||||
let next_branch_log = vec![main_commit.clone(), given::a_commit()];
|
||||
// dev - on next
|
||||
let dev_branch_log = next_branch_log.clone();
|
||||
// commit_log next
|
||||
open_repository
|
||||
.expect_commit_log()
|
||||
.times(1)
|
||||
.with(eq(branches.next()), eq([main_commit.clone()]))
|
||||
.return_once(move |_, _| Ok(next_branch_log));
|
||||
// commit_log dev
|
||||
let dev_commit_log = dev_branch_log.clone();
|
||||
open_repository
|
||||
.expect_commit_log()
|
||||
.times(1)
|
||||
.with(eq(branches.dev()), eq([main_commit]))
|
||||
.return_once(move |_, _| Ok(dev_commit_log));
|
||||
|
||||
//when
|
||||
let (addr, log) = when::start_actor_with_open_repository(
|
||||
Box::new(open_repository),
|
||||
repo_details,
|
||||
given::a_forge(),
|
||||
);
|
||||
addr.send(crate::repo::messages::ValidateRepo::new(
|
||||
MessageToken::default(),
|
||||
))
|
||||
.await?;
|
||||
System::current().stop();
|
||||
|
||||
//then
|
||||
log.no_message_contains("send:")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test_log::test(actix::test)]
|
||||
async fn repo_with_dev_ahead_of_next_should_advance_next() -> TestResult {
|
||||
//given
|
||||
let fs = given::a_filesystem();
|
||||
let (mut open_repository, repo_details) = given::an_open_repository(&fs);
|
||||
#[allow(clippy::unwrap_used)]
|
||||
let repo_config = repo_details.repo_config.clone().unwrap();
|
||||
// Validate repo branches
|
||||
expect::fetch_ok(&mut open_repository);
|
||||
let branches = repo_config.branches();
|
||||
// commit_log main
|
||||
let main_commit = expect::main_commit_log(&mut open_repository, branches.main());
|
||||
// next - on main
|
||||
let next_commit = main_commit.clone();
|
||||
let next_branch_log = vec![main_commit.clone(), given::a_commit()];
|
||||
// dev - ahead of next
|
||||
let dev_commit = given::a_named_commit("dev");
|
||||
let dev_branch_log = vec![dev_commit, main_commit.clone()];
|
||||
// commit_log next
|
||||
open_repository
|
||||
.expect_commit_log()
|
||||
.times(1)
|
||||
.with(eq(branches.next()), eq([main_commit.clone()]))
|
||||
.return_once(move |_, _| Ok(next_branch_log));
|
||||
// commit_log dev
|
||||
let dev_commit_log = dev_branch_log.clone();
|
||||
open_repository
|
||||
.expect_commit_log()
|
||||
.times(1)
|
||||
.with(eq(branches.dev()), eq([main_commit.clone()]))
|
||||
.return_once(move |_, _| Ok(dev_commit_log));
|
||||
|
||||
//when
|
||||
let (addr, log) = when::start_actor_with_open_repository(
|
||||
Box::new(open_repository),
|
||||
repo_details,
|
||||
given::a_forge(),
|
||||
);
|
||||
addr.send(crate::repo::messages::ValidateRepo::new(
|
||||
MessageToken::default(),
|
||||
))
|
||||
.await?;
|
||||
System::current().stop();
|
||||
|
||||
//then
|
||||
let expected = AdvanceNext::new(AdvanceNextPayload {
|
||||
next: next_commit,
|
||||
main: main_commit,
|
||||
dev_commit_history: dev_branch_log,
|
||||
});
|
||||
log.require_message_containing(format!("send: {expected:?}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test_log::test(actix::test)]
|
||||
async fn repo_with_dev_not_ahead_of_main_should_notify_user() -> TestResult {
|
||||
//given
|
||||
let fs = given::a_filesystem();
|
||||
let (mut open_repository, repo_details) = given::an_open_repository(&fs);
|
||||
#[allow(clippy::unwrap_used)]
|
||||
let repo_config = repo_details.repo_config.clone().unwrap();
|
||||
// Validate repo branches
|
||||
expect::fetch_ok(&mut open_repository);
|
||||
let branches = repo_config.branches();
|
||||
// commit_log main
|
||||
let main_commit = expect::main_commit_log(&mut open_repository, branches.main());
|
||||
// next - on main
|
||||
let next_commit = main_commit.clone();
|
||||
let next_branch_log = vec![next_commit.clone(), given::a_commit()];
|
||||
// dev - not ahead of next
|
||||
let dev_commit = given::a_named_commit("dev");
|
||||
let dev_branch_log = vec![dev_commit];
|
||||
// commit_log next
|
||||
open_repository
|
||||
.expect_commit_log()
|
||||
.times(1)
|
||||
.with(eq(branches.next()), eq([main_commit.clone()]))
|
||||
.return_once(move |_, _| Ok(next_branch_log));
|
||||
// commit_log dev
|
||||
let dev_commit_log = dev_branch_log.clone();
|
||||
open_repository
|
||||
.expect_commit_log()
|
||||
.times(1)
|
||||
.with(eq(branches.dev()), eq([main_commit]))
|
||||
.return_once(move |_, _| Ok(dev_commit_log));
|
||||
|
||||
//when
|
||||
let (addr, log) = when::start_actor_with_open_repository(
|
||||
Box::new(open_repository),
|
||||
repo_details,
|
||||
given::a_forge(),
|
||||
);
|
||||
addr.send(crate::repo::messages::ValidateRepo::new(
|
||||
MessageToken::default(),
|
||||
))
|
||||
.await?;
|
||||
System::current().stop();
|
||||
|
||||
//then
|
||||
log.require_message_containing("send: NotifyUser")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test_log::test(actix::test)]
|
||||
async fn should_accept_message_with_current_token() -> TestResult {
|
||||
//given
|
||||
let fs = given::a_filesystem();
|
||||
let repo_details = given::repo_details(&fs);
|
||||
|
||||
//when
|
||||
let (actor, log) = given::a_repo_actor(
|
||||
repo_details,
|
||||
git::repository::factory::mock(),
|
||||
given::a_forge(),
|
||||
given::a_network().into(),
|
||||
);
|
||||
let actor = actor.with_message_token(MessageToken::new(2_u32));
|
||||
let addr = actor.start();
|
||||
addr.send(crate::repo::messages::ValidateRepo::new(MessageToken::new(
|
||||
2_u32,
|
||||
)))
|
||||
.await?;
|
||||
System::current().stop();
|
||||
|
||||
//then
|
||||
log.require_message_containing("accepted token: 2")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test_log::test(actix::test)]
|
||||
async fn should_accept_message_with_new_token() -> TestResult {
|
||||
//given
|
||||
let fs = given::a_filesystem();
|
||||
let repo_details = given::repo_details(&fs);
|
||||
|
||||
//when
|
||||
let (actor, log) = given::a_repo_actor(
|
||||
repo_details,
|
||||
git::repository::factory::mock(),
|
||||
given::a_forge(),
|
||||
given::a_network().into(),
|
||||
);
|
||||
let actor = actor.with_message_token(MessageToken::new(2_u32));
|
||||
let addr = actor.start();
|
||||
addr.send(crate::repo::messages::ValidateRepo::new(MessageToken::new(
|
||||
3_u32,
|
||||
)))
|
||||
.await?;
|
||||
System::current().stop();
|
||||
|
||||
//then
|
||||
log.require_message_containing("accepted token: 3")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test_log::test(actix::test)]
|
||||
async fn should_reject_message_with_expired_token() -> TestResult {
|
||||
//given
|
||||
let fs = given::a_filesystem();
|
||||
let repo_details = given::repo_details(&fs);
|
||||
|
||||
//when
|
||||
let (actor, log) = given::a_repo_actor(
|
||||
repo_details,
|
||||
git::repository::factory::mock(),
|
||||
given::a_forge(),
|
||||
given::a_network().into(),
|
||||
);
|
||||
let actor = actor.with_message_token(MessageToken::new(4_u32));
|
||||
let addr = actor.start();
|
||||
addr.send(crate::repo::messages::ValidateRepo::new(MessageToken::new(
|
||||
3_u32,
|
||||
)))
|
||||
.await?;
|
||||
System::current().stop();
|
||||
|
||||
//then
|
||||
log.no_message_contains("accepted token")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test_log::test(actix::test)]
|
||||
// NOTE: failed then passed on retry: count = 6
|
||||
async fn should_send_validate_repo_when_retryable_error() -> TestResult {
|
||||
//given
|
||||
let fs = given::a_filesystem();
|
||||
let (mut open_repository, repo_details) = given::an_open_repository(&fs);
|
||||
open_repository.expect_fetch().return_once(|| Ok(()));
|
||||
open_repository
|
||||
.expect_commit_log()
|
||||
.return_once(|_, _| Err(git::commit::log::Error::Lock));
|
||||
|
||||
//when
|
||||
let (addr, log) = when::start_actor_with_open_repository(
|
||||
Box::new(open_repository),
|
||||
repo_details,
|
||||
given::a_forge(),
|
||||
);
|
||||
addr.send(crate::repo::messages::ValidateRepo::new(
|
||||
MessageToken::default(),
|
||||
))
|
||||
.await?;
|
||||
actix_rt::time::sleep(std::time::Duration::from_millis(2)).await;
|
||||
System::current().stop();
|
||||
|
||||
//then
|
||||
log.require_message_containing("accepted token: 0")?;
|
||||
log.require_message_containing("send: ValidateRepo")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test_log::test(actix::test)]
|
||||
async fn should_send_notify_user_when_non_retryable_error() -> TestResult {
|
||||
//given
|
||||
let fs = given::a_filesystem();
|
||||
let (mut open_repository, repo_details) = given::an_open_repository(&fs);
|
||||
#[allow(clippy::unwrap_used)]
|
||||
let repo_config = repo_details.repo_config.clone().unwrap();
|
||||
open_repository.expect_fetch().return_once(|| Ok(()));
|
||||
|
||||
// branches are all unrelated - non-retryable until each branch is updated
|
||||
let branches = repo_config.branches();
|
||||
// commit_log main
|
||||
let main_commit = expect::main_commit_log(&mut open_repository, branches.main());
|
||||
// next
|
||||
let next_branch_log = vec![given::a_commit()];
|
||||
// dev
|
||||
let dev_branch_log = vec![given::a_commit()];
|
||||
// commit_log next
|
||||
open_repository
|
||||
.expect_commit_log()
|
||||
.times(1)
|
||||
.with(eq(branches.next()), eq([main_commit.clone()]))
|
||||
.return_once(move |_, _| Ok(next_branch_log));
|
||||
// commit_log dev
|
||||
open_repository
|
||||
.expect_commit_log()
|
||||
.times(1)
|
||||
.with(eq(branches.dev()), eq([main_commit]))
|
||||
.return_once(|_, _| Ok(dev_branch_log));
|
||||
|
||||
//when
|
||||
let (addr, log) = when::start_actor_with_open_repository(
|
||||
Box::new(open_repository),
|
||||
repo_details,
|
||||
given::a_forge(),
|
||||
);
|
||||
addr.send(crate::repo::messages::ValidateRepo::new(
|
||||
MessageToken::default(),
|
||||
))
|
||||
.await?;
|
||||
actix_rt::time::sleep(std::time::Duration::from_millis(1)).await;
|
||||
System::current().stop();
|
||||
|
||||
//then
|
||||
log.require_message_containing("accepted token")?;
|
||||
log.require_message_containing("send: NotifyUser")?;
|
||||
Ok(())
|
||||
}
|
531
crates/cli/src/repo/tests/handlers/webhook_notification.rs
Normal file
531
crates/cli/src/repo/tests/handlers/webhook_notification.rs
Normal file
|
@ -0,0 +1,531 @@
|
|||
//
|
||||
use super::*;
|
||||
|
||||
#[actix::test]
|
||||
async fn when_no_expected_auth_token_drop_notification() -> TestResult {
|
||||
//given
|
||||
let fs = given::a_filesystem();
|
||||
let repo_details = given::repo_details(&fs);
|
||||
let forge_alias = given::a_forge_alias();
|
||||
let repo_alias = given::a_repo_alias();
|
||||
let headers = BTreeMap::new();
|
||||
let body = Body::new(String::new());
|
||||
let forge_notification = ForgeNotification::new(forge_alias, repo_alias, headers, body);
|
||||
let repository_factory = MockRepositoryFactory::new();
|
||||
let (actor, log) = given::a_repo_actor(
|
||||
repo_details,
|
||||
Box::new(repository_factory),
|
||||
given::a_forge(),
|
||||
given::a_network().into(),
|
||||
);
|
||||
let actor = actor.with_webhook_auth(None);
|
||||
|
||||
//when
|
||||
actor
|
||||
.start()
|
||||
.send(crate::repo::messages::WebhookNotification::new(
|
||||
forge_notification,
|
||||
))
|
||||
.await?;
|
||||
System::current().stop();
|
||||
|
||||
//then
|
||||
log.no_message_contains("send")?;
|
||||
log.require_message_containing("server has no auth token")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[actix::test]
|
||||
async fn when_no_repo_config_drop_notification() -> TestResult {
|
||||
//given
|
||||
let fs = given::a_filesystem();
|
||||
let repo_details = given::repo_details(&fs).with_repo_config(None);
|
||||
let forge_alias = given::a_forge_alias();
|
||||
let repo_alias = given::a_repo_alias();
|
||||
let headers = BTreeMap::new();
|
||||
let body = Body::new(String::new());
|
||||
let forge_notification = ForgeNotification::new(forge_alias, repo_alias, headers, body);
|
||||
let repository_factory = MockRepositoryFactory::new();
|
||||
let (actor, log) = given::a_repo_actor(
|
||||
repo_details,
|
||||
Box::new(repository_factory),
|
||||
given::a_forge(),
|
||||
given::a_network().into(),
|
||||
);
|
||||
let actor = actor.with_webhook_auth(Some(given::a_webhook_auth()));
|
||||
|
||||
//when
|
||||
actor
|
||||
.start()
|
||||
.send(crate::repo::messages::WebhookNotification::new(
|
||||
forge_notification,
|
||||
))
|
||||
.await?;
|
||||
System::current().stop();
|
||||
|
||||
//then
|
||||
log.no_message_contains("send")?;
|
||||
log.require_message_containing("server has no repo config")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[actix::test]
|
||||
async fn when_message_auth_is_invalid_drop_notification() -> TestResult {
|
||||
//given
|
||||
let fs = given::a_filesystem();
|
||||
let repo_details = given::repo_details(&fs);
|
||||
let forge_alias = given::a_forge_alias();
|
||||
let repo_alias = given::a_repo_alias();
|
||||
let headers = BTreeMap::new();
|
||||
let body = Body::new(String::new());
|
||||
let forge_notification = ForgeNotification::new(forge_alias, repo_alias, headers, body);
|
||||
let repository_factory = MockRepositoryFactory::new();
|
||||
let mut forge = given::a_forge();
|
||||
forge
|
||||
.expect_is_message_authorised()
|
||||
.return_once(|_, _| false); // is not valid
|
||||
let (actor, log) = given::a_repo_actor(
|
||||
repo_details,
|
||||
Box::new(repository_factory),
|
||||
forge,
|
||||
given::a_network().into(),
|
||||
);
|
||||
let actor = actor.with_webhook_auth(Some(given::a_webhook_auth()));
|
||||
|
||||
//when
|
||||
actor
|
||||
.start()
|
||||
.send(crate::repo::messages::WebhookNotification::new(
|
||||
forge_notification,
|
||||
))
|
||||
.await?;
|
||||
System::current().stop();
|
||||
|
||||
//then
|
||||
log.no_message_contains("send")?;
|
||||
log.require_message_containing("message authorisation is invalid")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[actix::test]
|
||||
async fn when_message_is_ignorable_drop_notification() -> TestResult {
|
||||
//given
|
||||
let fs = given::a_filesystem();
|
||||
let repo_details = given::repo_details(&fs);
|
||||
let forge_alias = given::a_forge_alias();
|
||||
let repo_alias = given::a_repo_alias();
|
||||
let headers = BTreeMap::new();
|
||||
let body = Body::new(String::new());
|
||||
let forge_notification = ForgeNotification::new(forge_alias, repo_alias, headers, body);
|
||||
let repository_factory = MockRepositoryFactory::new();
|
||||
let mut forge = given::a_forge();
|
||||
forge
|
||||
.expect_is_message_authorised()
|
||||
.return_once(|_, _| true); // is valid
|
||||
forge.expect_should_ignore_message().returning(|_| true);
|
||||
forge
|
||||
.expect_parse_webhook_body()
|
||||
.return_once(|_| Err(git::forge::webhook::Error::NetworkResponseEmpty));
|
||||
let (actor, log) = given::a_repo_actor(
|
||||
repo_details,
|
||||
Box::new(repository_factory),
|
||||
forge,
|
||||
given::a_network().into(),
|
||||
);
|
||||
let actor = actor.with_webhook_auth(Some(given::a_webhook_auth()));
|
||||
|
||||
//when
|
||||
actor
|
||||
.start()
|
||||
.send(crate::repo::messages::WebhookNotification::new(
|
||||
forge_notification,
|
||||
))
|
||||
.await?;
|
||||
System::current().stop();
|
||||
|
||||
//then
|
||||
log.no_message_contains("send")?;
|
||||
log.require_message_containing("forge sent ignorable message")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[actix::test]
|
||||
async fn when_message_is_not_a_push_drop_notification() -> TestResult {
|
||||
//given
|
||||
let fs = given::a_filesystem();
|
||||
let repo_details = given::repo_details(&fs);
|
||||
let forge_alias = given::a_forge_alias();
|
||||
let repo_alias = given::a_repo_alias();
|
||||
let headers = BTreeMap::new();
|
||||
let body = Body::new(String::new());
|
||||
let forge_notification = ForgeNotification::new(forge_alias, repo_alias, headers, body);
|
||||
let repository_factory = MockRepositoryFactory::new();
|
||||
let mut forge = given::a_forge();
|
||||
forge
|
||||
.expect_is_message_authorised()
|
||||
.return_once(|_, _| true); // is valid
|
||||
forge.expect_should_ignore_message().returning(|_| false);
|
||||
forge
|
||||
.expect_parse_webhook_body()
|
||||
.return_once(|_| Err(git::forge::webhook::Error::NetworkResponseEmpty));
|
||||
let (actor, log) = given::a_repo_actor(
|
||||
repo_details,
|
||||
Box::new(repository_factory),
|
||||
forge,
|
||||
given::a_network().into(),
|
||||
);
|
||||
let actor = actor.with_webhook_auth(Some(given::a_webhook_auth()));
|
||||
|
||||
//when
|
||||
actor
|
||||
.start()
|
||||
.send(crate::repo::messages::WebhookNotification::new(
|
||||
forge_notification,
|
||||
))
|
||||
.await?;
|
||||
System::current().stop();
|
||||
|
||||
//then
|
||||
log.no_message_contains("send")?;
|
||||
log.require_message_containing("message parse error - not a push")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[actix::test]
|
||||
async fn when_message_is_push_on_unknown_branch_drop_notification() -> TestResult {
|
||||
//given
|
||||
let fs = given::a_filesystem();
|
||||
let repo_config = given::a_repo_config();
|
||||
let repo_details = given::repo_details(&fs).with_repo_config(Some(repo_config.clone()));
|
||||
let forge_alias = given::a_forge_alias();
|
||||
let repo_alias = given::a_repo_alias();
|
||||
let headers = BTreeMap::new();
|
||||
let body = Body::new(String::new());
|
||||
let forge_notification = ForgeNotification::new(forge_alias, repo_alias, headers, body);
|
||||
let repository_factory = MockRepositoryFactory::new();
|
||||
let commit = given::a_commit();
|
||||
let push = given::a_push()
|
||||
.with_branch(given::a_branch_name("unknown"))
|
||||
.with_sha(commit.sha().to_string())
|
||||
.with_message(commit.message().to_string());
|
||||
let mut forge = given::a_forge();
|
||||
forge
|
||||
.expect_is_message_authorised()
|
||||
.return_once(|_, _| true); // is valid
|
||||
forge.expect_should_ignore_message().returning(|_| false);
|
||||
forge.expect_parse_webhook_body().return_once(|_| Ok(push));
|
||||
let (actor, log) = given::a_repo_actor(
|
||||
repo_details,
|
||||
Box::new(repository_factory),
|
||||
forge,
|
||||
given::a_network().into(),
|
||||
);
|
||||
let actor = actor
|
||||
.with_webhook_auth(Some(given::a_webhook_auth()))
|
||||
.with_last_main_commit(commit);
|
||||
|
||||
//when
|
||||
actor
|
||||
.start()
|
||||
.send(crate::repo::messages::WebhookNotification::new(
|
||||
forge_notification,
|
||||
))
|
||||
.await?;
|
||||
System::current().stop();
|
||||
|
||||
//then
|
||||
log.no_message_contains("send")?;
|
||||
log.require_message_containing("unknown branch")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[actix::test]
|
||||
async fn when_message_is_push_already_seen_commit_to_main() -> TestResult {
|
||||
//given
|
||||
let fs = given::a_filesystem();
|
||||
let repo_config = given::a_repo_config();
|
||||
let repo_details = given::repo_details(&fs).with_repo_config(Some(repo_config.clone()));
|
||||
let forge_alias = given::a_forge_alias();
|
||||
let repo_alias = given::a_repo_alias();
|
||||
let headers = BTreeMap::new();
|
||||
let body = Body::new(String::new());
|
||||
let forge_notification = ForgeNotification::new(forge_alias, repo_alias, headers, body);
|
||||
let repository_factory = MockRepositoryFactory::new();
|
||||
let commit = given::a_commit();
|
||||
let main = repo_config.branches().main();
|
||||
let push = given::a_push()
|
||||
.with_branch(main.clone())
|
||||
.with_sha(commit.sha().to_string())
|
||||
.with_message(commit.message().to_string());
|
||||
let mut forge = given::a_forge();
|
||||
forge
|
||||
.expect_is_message_authorised()
|
||||
.return_once(|_, _| true); // is valid
|
||||
forge.expect_should_ignore_message().returning(|_| false);
|
||||
forge.expect_parse_webhook_body().return_once(|_| Ok(push));
|
||||
let (actor, log) = given::a_repo_actor(
|
||||
repo_details,
|
||||
Box::new(repository_factory),
|
||||
forge,
|
||||
given::a_network().into(),
|
||||
);
|
||||
let actor = actor
|
||||
.with_webhook_auth(Some(given::a_webhook_auth()))
|
||||
.with_last_main_commit(commit);
|
||||
|
||||
//when
|
||||
actor
|
||||
.start()
|
||||
.send(crate::repo::messages::WebhookNotification::new(
|
||||
forge_notification,
|
||||
))
|
||||
.await?;
|
||||
System::current().stop();
|
||||
|
||||
//then
|
||||
log.no_message_contains("send")?;
|
||||
log.require_message_containing(format!("not a new commit on {main}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[actix::test]
|
||||
async fn when_message_is_push_already_seen_commit_to_next() -> TestResult {
|
||||
//given
|
||||
let fs = given::a_filesystem();
|
||||
let repo_config = given::a_repo_config();
|
||||
let repo_details = given::repo_details(&fs).with_repo_config(Some(repo_config.clone()));
|
||||
let forge_alias = given::a_forge_alias();
|
||||
let repo_alias = given::a_repo_alias();
|
||||
let headers = BTreeMap::new();
|
||||
let body = Body::new(String::new());
|
||||
let forge_notification = ForgeNotification::new(forge_alias, repo_alias, headers, body);
|
||||
let repository_factory = MockRepositoryFactory::new();
|
||||
let commit = given::a_commit();
|
||||
let next = repo_config.branches().next();
|
||||
let push = given::a_push()
|
||||
.with_branch(next.clone())
|
||||
.with_sha(commit.sha().to_string())
|
||||
.with_message(commit.message().to_string());
|
||||
let mut forge = given::a_forge();
|
||||
forge
|
||||
.expect_is_message_authorised()
|
||||
.return_once(|_, _| true); // is valid
|
||||
forge.expect_should_ignore_message().returning(|_| false);
|
||||
forge.expect_parse_webhook_body().return_once(|_| Ok(push));
|
||||
let (actor, log) = given::a_repo_actor(
|
||||
repo_details,
|
||||
Box::new(repository_factory),
|
||||
forge,
|
||||
given::a_network().into(),
|
||||
);
|
||||
let actor = actor
|
||||
.with_webhook_auth(Some(given::a_webhook_auth()))
|
||||
.with_last_next_commit(commit);
|
||||
|
||||
//when
|
||||
actor
|
||||
.start()
|
||||
.send(crate::repo::messages::WebhookNotification::new(
|
||||
forge_notification,
|
||||
))
|
||||
.await?;
|
||||
System::current().stop();
|
||||
|
||||
//then
|
||||
log.no_message_contains("send")?;
|
||||
log.require_message_containing(format!("not a new commit on {next}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[actix::test]
|
||||
async fn when_message_is_push_already_seen_commit_to_dev() -> TestResult {
|
||||
//given
|
||||
let fs = given::a_filesystem();
|
||||
let repo_config = given::a_repo_config();
|
||||
let repo_details = given::repo_details(&fs).with_repo_config(Some(repo_config.clone()));
|
||||
let forge_alias = given::a_forge_alias();
|
||||
let repo_alias = given::a_repo_alias();
|
||||
let headers = BTreeMap::new();
|
||||
let body = Body::new(String::new());
|
||||
let forge_notification = ForgeNotification::new(forge_alias, repo_alias, headers, body);
|
||||
let repository_factory = MockRepositoryFactory::new();
|
||||
let commit = given::a_commit();
|
||||
let dev = repo_config.branches().dev();
|
||||
let push = given::a_push()
|
||||
.with_branch(dev.clone())
|
||||
.with_sha(commit.sha().to_string())
|
||||
.with_message(commit.message().to_string());
|
||||
let mut forge = given::a_forge();
|
||||
forge
|
||||
.expect_is_message_authorised()
|
||||
.return_once(|_, _| true); // is valid
|
||||
forge.expect_should_ignore_message().returning(|_| false);
|
||||
forge.expect_parse_webhook_body().return_once(|_| Ok(push));
|
||||
let (actor, log) = given::a_repo_actor(
|
||||
repo_details,
|
||||
Box::new(repository_factory),
|
||||
forge,
|
||||
given::a_network().into(),
|
||||
);
|
||||
let actor = actor
|
||||
.with_webhook_auth(Some(given::a_webhook_auth()))
|
||||
.with_last_dev_commit(commit);
|
||||
|
||||
//when
|
||||
actor
|
||||
.start()
|
||||
.send(crate::repo::messages::WebhookNotification::new(
|
||||
forge_notification,
|
||||
))
|
||||
.await?;
|
||||
System::current().stop();
|
||||
|
||||
//then
|
||||
log.no_message_contains("send")?;
|
||||
log.require_message_containing(format!("not a new commit on {dev}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[actix::test]
|
||||
async fn when_message_is_push_new_commit_to_main_should_stash_and_validate_repo() -> TestResult {
|
||||
//given
|
||||
let fs = given::a_filesystem();
|
||||
let repo_config = given::a_repo_config();
|
||||
let repo_details = given::repo_details(&fs).with_repo_config(Some(repo_config.clone()));
|
||||
let forge_alias = given::a_forge_alias();
|
||||
let repo_alias = given::a_repo_alias();
|
||||
let headers = BTreeMap::new();
|
||||
let body = Body::new(String::new());
|
||||
let forge_notification = ForgeNotification::new(forge_alias, repo_alias, headers, body);
|
||||
let repository_factory = MockRepositoryFactory::new();
|
||||
let push_commit = given::a_commit();
|
||||
let push = given::a_push()
|
||||
.with_branch(repo_config.branches().main())
|
||||
.with_sha(push_commit.sha().to_string())
|
||||
.with_message(push_commit.message().to_string());
|
||||
let mut forge = given::a_forge();
|
||||
forge
|
||||
.expect_is_message_authorised()
|
||||
.return_once(|_, _| true); // is valid
|
||||
forge.expect_should_ignore_message().returning(|_| false);
|
||||
forge.expect_parse_webhook_body().return_once(|_| Ok(push));
|
||||
let (actor, log) = given::a_repo_actor(
|
||||
repo_details,
|
||||
Box::new(repository_factory),
|
||||
forge,
|
||||
given::a_network().into(),
|
||||
);
|
||||
let actor = actor
|
||||
.with_webhook_auth(Some(given::a_webhook_auth()))
|
||||
.with_last_main_commit(given::a_commit());
|
||||
|
||||
//when
|
||||
let addr = actor.start();
|
||||
addr.send(crate::repo::messages::WebhookNotification::new(
|
||||
forge_notification,
|
||||
))
|
||||
.await?;
|
||||
System::current().stop();
|
||||
|
||||
//then
|
||||
let view = addr.send(ExamineActor).await?;
|
||||
assert_eq!(view.last_main_commit, Some(push_commit));
|
||||
log.require_message_containing("send: ValidateRepo")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[actix::test]
|
||||
async fn when_message_is_push_new_commit_to_next_should_stash_and_validate_repo() -> TestResult {
|
||||
//given
|
||||
let fs = given::a_filesystem();
|
||||
let repo_config = given::a_repo_config();
|
||||
let repo_details = given::repo_details(&fs).with_repo_config(Some(repo_config.clone()));
|
||||
let forge_alias = given::a_forge_alias();
|
||||
let repo_alias = given::a_repo_alias();
|
||||
let headers = BTreeMap::new();
|
||||
let body = Body::new(String::new());
|
||||
let forge_notification = ForgeNotification::new(forge_alias, repo_alias, headers, body);
|
||||
let repository_factory = MockRepositoryFactory::new();
|
||||
let push_commit = given::a_commit();
|
||||
let push = given::a_push()
|
||||
.with_branch(repo_config.branches().next())
|
||||
.with_sha(push_commit.sha().to_string())
|
||||
.with_message(push_commit.message().to_string());
|
||||
let mut forge = given::a_forge();
|
||||
forge
|
||||
.expect_is_message_authorised()
|
||||
.return_once(|_, _| true); // is valid
|
||||
forge.expect_should_ignore_message().returning(|_| false);
|
||||
forge.expect_parse_webhook_body().return_once(|_| Ok(push));
|
||||
let (actor, log) = given::a_repo_actor(
|
||||
repo_details,
|
||||
Box::new(repository_factory),
|
||||
forge,
|
||||
given::a_network().into(),
|
||||
);
|
||||
let actor = actor
|
||||
.with_webhook_auth(Some(given::a_webhook_auth()))
|
||||
.with_last_next_commit(given::a_commit());
|
||||
|
||||
//when
|
||||
let addr = actor.start();
|
||||
addr.send(crate::repo::messages::WebhookNotification::new(
|
||||
forge_notification,
|
||||
))
|
||||
.await?;
|
||||
System::current().stop();
|
||||
|
||||
//then
|
||||
let view = addr.send(ExamineActor).await?;
|
||||
assert_eq!(view.last_next_commit, Some(push_commit));
|
||||
log.require_message_containing("send: ValidateRepo")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[actix::test]
|
||||
async fn when_message_is_push_new_commit_to_dev_should_stash_and_validate_repo() -> TestResult {
|
||||
//given
|
||||
let fs = given::a_filesystem();
|
||||
let repo_config = given::a_repo_config();
|
||||
let repo_details = given::repo_details(&fs).with_repo_config(Some(repo_config.clone()));
|
||||
let forge_alias = given::a_forge_alias();
|
||||
let repo_alias = given::a_repo_alias();
|
||||
let headers = BTreeMap::new();
|
||||
let body = Body::new(String::new());
|
||||
let forge_notification = ForgeNotification::new(forge_alias, repo_alias, headers, body);
|
||||
let repository_factory = MockRepositoryFactory::new();
|
||||
let push_commit = given::a_commit();
|
||||
let push = given::a_push()
|
||||
.with_branch(repo_config.branches().dev())
|
||||
.with_sha(push_commit.sha().to_string())
|
||||
.with_message(push_commit.message().to_string());
|
||||
let mut forge = given::a_forge();
|
||||
forge
|
||||
.expect_is_message_authorised()
|
||||
.return_once(|_, _| true); // is valid
|
||||
forge.expect_should_ignore_message().returning(|_| false);
|
||||
forge.expect_parse_webhook_body().return_once(|_| Ok(push));
|
||||
let (actor, log) = given::a_repo_actor(
|
||||
repo_details,
|
||||
Box::new(repository_factory),
|
||||
forge,
|
||||
given::a_network().into(),
|
||||
);
|
||||
let actor = actor
|
||||
.with_webhook_auth(Some(given::a_webhook_auth()))
|
||||
.with_last_dev_commit(given::a_commit());
|
||||
|
||||
//when
|
||||
let addr = actor.start();
|
||||
addr.send(crate::repo::messages::WebhookNotification::new(
|
||||
forge_notification,
|
||||
))
|
||||
.await?;
|
||||
System::current().stop();
|
||||
|
||||
//then
|
||||
let view = addr.send(ExamineActor).await?;
|
||||
assert_eq!(view.last_dev_commit, Some(push_commit));
|
||||
log.require_message_containing("send: ValidateRepo")?;
|
||||
Ok(())
|
||||
}
|
56
crates/cli/src/repo/tests/handlers/webhook_registered.rs
Normal file
56
crates/cli/src/repo/tests/handlers/webhook_registered.rs
Normal file
|
@ -0,0 +1,56 @@
|
|||
//
|
||||
use super::*;
|
||||
|
||||
#[actix::test]
|
||||
async fn should_store_webhook_details() -> TestResult {
|
||||
//given
|
||||
let fs = given::a_filesystem();
|
||||
let (open_repository, repo_details) = given::an_open_repository(&fs);
|
||||
let webhook_id = given::a_webhook_id();
|
||||
let webhook_auth = given::a_webhook_auth();
|
||||
|
||||
//when
|
||||
let (addr, _log) = when::start_actor_with_open_repository(
|
||||
Box::new(open_repository),
|
||||
repo_details,
|
||||
given::a_forge(),
|
||||
);
|
||||
addr.send(crate::repo::messages::WebhookRegistered::new((
|
||||
webhook_id.clone(),
|
||||
webhook_auth.clone(),
|
||||
)))
|
||||
.await?;
|
||||
System::current().stop();
|
||||
|
||||
//then
|
||||
let view = addr.send(ExamineActor).await?;
|
||||
assert_eq!(view.webhook_id, Some(webhook_id));
|
||||
assert_eq!(view.webhook_auth, Some(webhook_auth));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[actix::test]
|
||||
async fn should_send_validate_repo_message() -> TestResult {
|
||||
//given
|
||||
let fs = given::a_filesystem();
|
||||
let (open_repository, repo_details) = given::an_open_repository(&fs);
|
||||
let webhook_id = given::a_webhook_id();
|
||||
let webhook_auth = given::a_webhook_auth();
|
||||
|
||||
//when
|
||||
let (addr, log) = when::start_actor_with_open_repository(
|
||||
Box::new(open_repository),
|
||||
repo_details,
|
||||
given::a_forge(),
|
||||
);
|
||||
addr.send(crate::repo::messages::WebhookRegistered::new((
|
||||
webhook_id.clone(),
|
||||
webhook_auth.clone(),
|
||||
)))
|
||||
.await?;
|
||||
System::current().stop();
|
||||
|
||||
//then
|
||||
log.require_message_containing("send: ValidateRepo")?;
|
||||
Ok(())
|
||||
}
|
169
crates/cli/src/repo/tests/load.rs
Normal file
169
crates/cli/src/repo/tests/load.rs
Normal file
|
@ -0,0 +1,169 @@
|
|||
//
|
||||
use super::*;
|
||||
|
||||
use crate::git::file;
|
||||
use crate::repo::load;
|
||||
|
||||
#[actix::test]
|
||||
async fn when_file_not_found_should_error() -> TestResult {
|
||||
//given
|
||||
let fs = given::a_filesystem();
|
||||
let (mut open_repository, repo_details) = given::an_open_repository(&fs);
|
||||
open_repository
|
||||
.expect_read_file()
|
||||
.returning(|_, _| Err(file::Error::FileNotFound));
|
||||
//when
|
||||
let_assert!(Err(err) = load::config_from_repository(repo_details, &open_repository).await);
|
||||
//then
|
||||
debug!("Got: {err:?}");
|
||||
assert!(matches!(
|
||||
err,
|
||||
load::Error::File(crate::git::file::Error::FileNotFound)
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
#[actix::test]
|
||||
async fn when_file_format_invalid_should_error() -> TestResult {
|
||||
//given
|
||||
let fs = given::a_filesystem();
|
||||
let (mut open_repository, repo_details) = given::an_open_repository(&fs);
|
||||
let contents = given::a_name(); // not a valid file content
|
||||
open_repository
|
||||
.expect_read_file()
|
||||
.return_once(move |_, _| Ok(contents));
|
||||
//when
|
||||
let_assert!(Err(err) = load::config_from_repository(repo_details, &open_repository).await);
|
||||
//then
|
||||
debug!("Got: {err:?}");
|
||||
assert!(matches!(err, load::Error::Toml(_)));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[actix::test]
|
||||
async fn when_main_branch_is_missing_should_error() -> TestResult {
|
||||
//given
|
||||
let fs = given::a_filesystem();
|
||||
let (mut open_repository, repo_details) = given::an_open_repository(&fs);
|
||||
let branches = given::repo_branches();
|
||||
let main = branches.main();
|
||||
let next = branches.next();
|
||||
let dev = branches.dev();
|
||||
let contents = format!(
|
||||
r#"
|
||||
[branches]
|
||||
main = "{main}"
|
||||
next = "{next}"
|
||||
dev = "{dev}"
|
||||
"#
|
||||
);
|
||||
open_repository
|
||||
.expect_read_file()
|
||||
.return_once(|_, _| Ok(contents));
|
||||
let branches = vec![next, dev];
|
||||
open_repository
|
||||
.expect_remote_branches()
|
||||
.return_once(move || Ok(branches));
|
||||
//when
|
||||
let_assert!(Err(err) = load::config_from_repository(repo_details, &open_repository).await);
|
||||
//then
|
||||
debug!("Got: {err:?}");
|
||||
assert!(matches!(err, load::Error::BranchNotFound(branch) if branch == main));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[actix::test]
|
||||
async fn when_next_branch_is_missing_should_error() -> TestResult {
|
||||
//given
|
||||
let fs = given::a_filesystem();
|
||||
let (mut open_repository, repo_details) = given::an_open_repository(&fs);
|
||||
let branches = given::repo_branches();
|
||||
let main = branches.main();
|
||||
let next = branches.next();
|
||||
let dev = branches.dev();
|
||||
let contents = format!(
|
||||
r#"
|
||||
[branches]
|
||||
main = "{main}"
|
||||
next = "{next}"
|
||||
dev = "{dev}"
|
||||
"#
|
||||
);
|
||||
open_repository
|
||||
.expect_read_file()
|
||||
.return_once(|_, _| Ok(contents));
|
||||
let branches = vec![main, dev];
|
||||
open_repository
|
||||
.expect_remote_branches()
|
||||
.return_once(move || Ok(branches));
|
||||
//when
|
||||
let_assert!(Err(err) = load::config_from_repository(repo_details, &open_repository).await);
|
||||
//then
|
||||
debug!("Got: {err:?}");
|
||||
assert!(matches!(err, load::Error::BranchNotFound(branch) if branch == next));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[actix::test]
|
||||
async fn when_dev_branch_is_missing_should_error() -> TestResult {
|
||||
//given
|
||||
let fs = given::a_filesystem();
|
||||
let (mut open_repository, repo_details) = given::an_open_repository(&fs);
|
||||
let branches = given::repo_branches();
|
||||
let main = branches.main();
|
||||
let next = branches.next();
|
||||
let dev = branches.dev();
|
||||
let contents = format!(
|
||||
r#"
|
||||
[branches]
|
||||
main = "{main}"
|
||||
next = "{next}"
|
||||
dev = "{dev}"
|
||||
"#
|
||||
);
|
||||
open_repository
|
||||
.expect_read_file()
|
||||
.return_once(move |_, _| Ok(contents));
|
||||
let branches = vec![main, next];
|
||||
open_repository
|
||||
.expect_remote_branches()
|
||||
.return_once(move || Ok(branches));
|
||||
//when
|
||||
let_assert!(Err(err) = load::config_from_repository(repo_details, &open_repository).await);
|
||||
//then
|
||||
debug!("Got: {err:?}");
|
||||
assert!(matches!(err, load::Error::BranchNotFound(branch) if branch == dev));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[actix::test]
|
||||
async fn when_valid_file_should_return_repo_config() -> TestResult {
|
||||
//given
|
||||
let fs = given::a_filesystem();
|
||||
let (mut open_repository, repo_details) = given::an_open_repository(&fs);
|
||||
let repo_config = given::a_repo_config();
|
||||
let branches = repo_config.branches();
|
||||
let main = branches.main();
|
||||
let next = branches.next();
|
||||
let dev = branches.dev();
|
||||
let contents = format!(
|
||||
r#"
|
||||
[branches]
|
||||
main = "{main}"
|
||||
next = "{next}"
|
||||
dev = "{dev}"
|
||||
"#
|
||||
);
|
||||
open_repository
|
||||
.expect_read_file()
|
||||
.return_once(move |_, _| Ok(contents));
|
||||
let branches = vec![main, next, dev];
|
||||
open_repository
|
||||
.expect_remote_branches()
|
||||
.return_once(move || Ok(branches));
|
||||
//when
|
||||
let_assert!(Ok(result) = load::config_from_repository(repo_details, &open_repository).await);
|
||||
//then
|
||||
debug!("Got: {result:?}");
|
||||
assert_eq!(result, repo_config);
|
||||
Ok(())
|
||||
}
|
110
crates/cli/src/repo/tests/mod.rs
Normal file
110
crates/cli/src/repo/tests/mod.rs
Normal file
|
@ -0,0 +1,110 @@
|
|||
//
|
||||
use actix::prelude::*;
|
||||
|
||||
use crate::{
|
||||
git,
|
||||
repo::{
|
||||
messages::{CloneRepo, MessageToken},
|
||||
ActorLog, RepoActor,
|
||||
},
|
||||
};
|
||||
|
||||
use git_next_core::{
|
||||
git::{
|
||||
commit::Sha,
|
||||
forge::commit::Status,
|
||||
repository::{
|
||||
factory::{mock, MockRepositoryFactory, RepositoryFactory},
|
||||
open::{MockOpenRepositoryLike, OpenRepositoryLike},
|
||||
Direction,
|
||||
},
|
||||
Commit, ForgeLike, Generation, MockForgeLike, RepoDetails,
|
||||
},
|
||||
message,
|
||||
webhook::{forge_notification::Body, Push},
|
||||
BranchName, ForgeAlias, ForgeConfig, ForgeNotification, ForgeType, GitDir, Hostname,
|
||||
RegisteredWebhook, RemoteUrl, RepoAlias, RepoBranches, RepoConfig, RepoConfigSource,
|
||||
ServerRepoConfig, StoragePathType, WebhookAuth, WebhookId,
|
||||
};
|
||||
|
||||
use assert2::let_assert;
|
||||
use mockall::predicate::eq;
|
||||
use tracing::{debug, error};
|
||||
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap},
|
||||
sync::{Arc, RwLock},
|
||||
};
|
||||
|
||||
type TestResult = Result<(), Box<dyn std::error::Error>>;
|
||||
|
||||
mod branch;
|
||||
mod expect;
|
||||
pub mod given;
|
||||
mod handlers;
|
||||
mod load;
|
||||
mod when;
|
||||
|
||||
impl ActorLog {
|
||||
pub fn no_message_contains(&self, needle: impl AsRef<str> + std::fmt::Display) -> TestResult {
|
||||
if self.find_in_messages(needle.as_ref())? {
|
||||
error!(?self, "");
|
||||
panic!("found unexpected message: {needle}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn require_message_containing(
|
||||
&self,
|
||||
needle: impl AsRef<str> + std::fmt::Display,
|
||||
) -> TestResult {
|
||||
if !self.find_in_messages(needle.as_ref())? {
|
||||
error!(?self, "");
|
||||
panic!("expected message not found: {needle}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn find_in_messages(
|
||||
&self,
|
||||
needle: impl AsRef<str>,
|
||||
) -> Result<bool, Box<dyn std::error::Error>> {
|
||||
let found = self
|
||||
.read()
|
||||
.map_err(|e| e.to_string())?
|
||||
.iter()
|
||||
.any(|message| message.contains(needle.as_ref()));
|
||||
Ok(found)
|
||||
}
|
||||
}
|
||||
|
||||
message!(ExamineActor => RepoActorView, "Request a view of the current state of the [RepoActor].");
|
||||
impl Handler<ExamineActor> for RepoActor {
|
||||
type Result = RepoActorView;
|
||||
|
||||
fn handle(&mut self, _msg: ExamineActor, _ctx: &mut Self::Context) -> Self::Result {
|
||||
let repo_actor: &Self = self;
|
||||
Self::Result::from(repo_actor)
|
||||
}
|
||||
}
|
||||
#[derive(Debug, MessageResponse)]
|
||||
pub struct RepoActorView {
|
||||
pub repo_details: RepoDetails,
|
||||
pub webhook_id: Option<WebhookId>, // INFO: if [None] then no webhook is configured
|
||||
pub webhook_auth: Option<WebhookAuth>, // INFO: if [None] then no webhook is configured
|
||||
pub last_main_commit: Option<Commit>,
|
||||
pub last_next_commit: Option<Commit>,
|
||||
pub last_dev_commit: Option<Commit>,
|
||||
}
|
||||
impl From<&RepoActor> for RepoActorView {
|
||||
fn from(repo_actor: &RepoActor) -> Self {
|
||||
Self {
|
||||
repo_details: repo_actor.repo_details.clone(),
|
||||
webhook_id: repo_actor.webhook_id.clone(),
|
||||
webhook_auth: repo_actor.webhook_auth.clone(),
|
||||
last_main_commit: repo_actor.last_main_commit.clone(),
|
||||
last_next_commit: repo_actor.last_next_commit.clone(),
|
||||
last_dev_commit: repo_actor.last_dev_commit.clone(),
|
||||
}
|
||||
}
|
||||
}
|
37
crates/cli/src/repo/tests/when.rs
Normal file
37
crates/cli/src/repo/tests/when.rs
Normal file
|
@ -0,0 +1,37 @@
|
|||
//
|
||||
use super::*;
|
||||
|
||||
pub fn start_actor(
|
||||
repository_factory: MockRepositoryFactory,
|
||||
repo_details: RepoDetails,
|
||||
forge: Box<dyn ForgeLike>,
|
||||
) -> (actix::Addr<RepoActor>, ActorLog) {
|
||||
let (actor, log) = given::a_repo_actor(
|
||||
repo_details,
|
||||
Box::new(repository_factory),
|
||||
forge,
|
||||
given::a_network().into(),
|
||||
);
|
||||
(actor.start(), log)
|
||||
}
|
||||
|
||||
pub fn start_actor_with_open_repository(
|
||||
open_repository: Box<dyn OpenRepositoryLike>,
|
||||
repo_details: RepoDetails,
|
||||
forge: Box<dyn ForgeLike>,
|
||||
) -> (actix::Addr<RepoActor>, ActorLog) {
|
||||
let (actor, log) = given::a_repo_actor(repo_details, mock(), forge, given::a_network().into());
|
||||
let actor = actor.with_open_repository(Some(open_repository));
|
||||
(actor.start(), log)
|
||||
}
|
||||
|
||||
pub fn commit_status(forge: &mut MockForgeLike, commit: Commit, status: Status) {
|
||||
let mut commit_status_forge = MockForgeLike::new();
|
||||
commit_status_forge
|
||||
.expect_commit_status()
|
||||
.with(mockall::predicate::eq(commit))
|
||||
.return_once(|_| status);
|
||||
forge
|
||||
.expect_duplicate()
|
||||
.return_once(move || Box::new(commit_status_forge));
|
||||
}
|
17
crates/cli/src/server/actor/MESSAGES.md
Normal file
17
crates/cli/src/server/actor/MESSAGES.md
Normal file
|
@ -0,0 +1,17 @@
|
|||
```mermaid
|
||||
stateDiagram-v2
|
||||
SERVER --> FileUpdated :on start
|
||||
FILE_WATCHER_ACTOR --> FileUpdated : WatchFile
|
||||
|
||||
FileUpdated --> ReceiveServerConfig
|
||||
|
||||
ReceiveServerConfig --> ReceiveValidServerConfig
|
||||
|
||||
ReceiveValidServerConfig --> WEBHOOK_ACTOR:ShutdownWebhook
|
||||
ReceiveValidServerConfig --> REPO_ACTOR:START
|
||||
ReceiveValidServerConfig --> REPO_ACTOR:CloneRepo
|
||||
ReceiveValidServerConfig --> WEBHOOK_ROUTER:START
|
||||
ReceiveValidServerConfig --> WEBHOOK_ROUTER:AddWebhookRecipient
|
||||
ReceiveValidServerConfig --> WEBHOOK_ACTOR:START
|
||||
|
||||
```
|
20
crates/cli/src/server/actor/handlers/file_updated.rs
Normal file
20
crates/cli/src/server/actor/handlers/file_updated.rs
Normal file
|
@ -0,0 +1,20 @@
|
|||
//
|
||||
use actix::prelude::*;
|
||||
|
||||
use git_next_core::server::AppConfig;
|
||||
|
||||
use crate::{
|
||||
file_watcher::FileUpdated,
|
||||
server::actor::{messages::ReceiveAppConfig, ServerActor},
|
||||
};
|
||||
|
||||
impl Handler<FileUpdated> for ServerActor {
|
||||
type Result = ();
|
||||
|
||||
fn handle(&mut self, _msg: FileUpdated, ctx: &mut Self::Context) -> Self::Result {
|
||||
match AppConfig::load(&self.fs) {
|
||||
Ok(app_config) => self.do_send(ReceiveAppConfig::new(app_config), ctx),
|
||||
Err(err) => self.abort(ctx, format!("Failed to load config file. Error: {err}")),
|
||||
};
|
||||
}
|
||||
}
|
7
crates/cli/src/server/actor/handlers/mod.rs
Normal file
7
crates/cli/src/server/actor/handlers/mod.rs
Normal file
|
@ -0,0 +1,7 @@
|
|||
mod file_updated;
|
||||
mod receive_app_config;
|
||||
mod receive_valid_app_config;
|
||||
mod server_update;
|
||||
mod shutdown;
|
||||
mod shutdown_trigger;
|
||||
mod subscribe_updates;
|
35
crates/cli/src/server/actor/handlers/receive_app_config.rs
Normal file
35
crates/cli/src/server/actor/handlers/receive_app_config.rs
Normal file
|
@ -0,0 +1,35 @@
|
|||
use actix::prelude::*;
|
||||
|
||||
use crate::server::actor::{
|
||||
messages::{ReceiveAppConfig, ReceiveValidAppConfig, ValidAppConfig},
|
||||
ServerActor,
|
||||
};
|
||||
|
||||
impl Handler<ReceiveAppConfig> for ServerActor {
|
||||
type Result = ();
|
||||
|
||||
#[allow(clippy::cognitive_complexity)]
|
||||
fn handle(&mut self, msg: ReceiveAppConfig, ctx: &mut Self::Context) -> Self::Result {
|
||||
tracing::info!("recieved server config");
|
||||
let Ok(socket_addr) = msg.listen_socket_addr() else {
|
||||
return self.abort(ctx, "Unable to parse http.addr");
|
||||
};
|
||||
|
||||
let Some(server_storage) = self.server_storage(&msg) else {
|
||||
return self.abort(ctx, "Server storage not available");
|
||||
};
|
||||
|
||||
if msg.listen().url().ends_with('/') {
|
||||
return self.abort(ctx, "webhook.url must not end with a '/'");
|
||||
}
|
||||
|
||||
self.do_send(
|
||||
ReceiveValidAppConfig::new(ValidAppConfig::new(
|
||||
msg.peel(),
|
||||
socket_addr,
|
||||
server_storage,
|
||||
)),
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,97 @@
|
|||
//
|
||||
use actix::prelude::*;
|
||||
|
||||
use git_next_core::{ForgeAlias, RepoAlias};
|
||||
use tracing::info;
|
||||
|
||||
use crate::{
|
||||
alerts::messages::UpdateShout,
|
||||
repo::{messages::CloneRepo, RepoActor},
|
||||
server::actor::{
|
||||
messages::{ReceiveValidAppConfig, ServerUpdate, ValidAppConfig},
|
||||
ServerActor,
|
||||
},
|
||||
webhook::{
|
||||
messages::ShutdownWebhook,
|
||||
router::{AddWebhookRecipient, WebhookRouterActor},
|
||||
WebhookActor,
|
||||
},
|
||||
};
|
||||
|
||||
impl Handler<ReceiveValidAppConfig> for ServerActor {
|
||||
type Result = ();
|
||||
|
||||
fn handle(&mut self, msg: ReceiveValidAppConfig, ctx: &mut Self::Context) -> Self::Result {
|
||||
let ValidAppConfig {
|
||||
app_config,
|
||||
socket_address,
|
||||
storage: server_storage,
|
||||
} = msg.peel();
|
||||
// shutdown any existing webhook actor
|
||||
if let Some(webhook_actor_addr) = self.webhook_actor_addr.take() {
|
||||
webhook_actor_addr.do_send(ShutdownWebhook);
|
||||
}
|
||||
self.generation.inc();
|
||||
// Webhook Server
|
||||
info!("Starting Webhook Server...");
|
||||
let webhook_router = WebhookRouterActor::default().start();
|
||||
let listen_url = app_config.listen().url();
|
||||
let notify_user_recipient = self.alerts.clone().recipient();
|
||||
let server_addr = Some(ctx.address());
|
||||
// Forge Actors
|
||||
for (forge_alias, forge_config) in app_config.forges() {
|
||||
let repo_actors = self
|
||||
.create_forge_repos(
|
||||
forge_config,
|
||||
forge_alias.clone(),
|
||||
&server_storage,
|
||||
listen_url,
|
||||
¬ify_user_recipient,
|
||||
server_addr.clone(),
|
||||
)
|
||||
.into_iter()
|
||||
.map(start_repo_actor)
|
||||
.collect::<Vec<_>>();
|
||||
repo_actors
|
||||
.iter()
|
||||
.map(|(repo_alias, addr)| {
|
||||
AddWebhookRecipient::new(
|
||||
forge_alias.clone(),
|
||||
repo_alias.clone(),
|
||||
addr.clone().recipient(),
|
||||
)
|
||||
})
|
||||
.for_each(|msg| webhook_router.do_send(msg));
|
||||
for (repo_alias, addr) in repo_actors {
|
||||
self.repo_actors
|
||||
.insert((forge_alias.clone(), repo_alias), addr);
|
||||
}
|
||||
}
|
||||
let webhook_actor_addr =
|
||||
WebhookActor::new(socket_address, webhook_router.recipient()).start();
|
||||
self.webhook_actor_addr.replace(webhook_actor_addr);
|
||||
let shout = app_config.shout().clone();
|
||||
self.app_config.replace(app_config.clone());
|
||||
self.do_send(
|
||||
ServerUpdate::AppConfigLoaded {
|
||||
app_config: ValidAppConfig {
|
||||
app_config,
|
||||
socket_address,
|
||||
storage: server_storage,
|
||||
},
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
self.alerts.do_send(UpdateShout::new(shout));
|
||||
}
|
||||
}
|
||||
|
||||
fn start_repo_actor(actor: (ForgeAlias, RepoAlias, RepoActor)) -> (RepoAlias, Addr<RepoActor>) {
|
||||
let (forge_name, repo_alias, actor) = actor;
|
||||
let span = tracing::info_span!("start_repo_actor", forge = %forge_name, repo = %repo_alias);
|
||||
let _guard = span.enter();
|
||||
let addr = actor.start();
|
||||
addr.do_send(CloneRepo);
|
||||
tracing::info!("Started");
|
||||
(repo_alias, addr)
|
||||
}
|
14
crates/cli/src/server/actor/handlers/server_update.rs
Normal file
14
crates/cli/src/server/actor/handlers/server_update.rs
Normal file
|
@ -0,0 +1,14 @@
|
|||
use actix::Handler;
|
||||
|
||||
//
|
||||
use crate::server::{actor::messages::ServerUpdate, ServerActor};
|
||||
|
||||
impl Handler<ServerUpdate> for ServerActor {
|
||||
type Result = ();
|
||||
|
||||
fn handle(&mut self, msg: ServerUpdate, _ctx: &mut Self::Context) -> Self::Result {
|
||||
self.subscribers.iter().for_each(move |subscriber| {
|
||||
subscriber.do_send(msg.clone());
|
||||
});
|
||||
}
|
||||
}
|
30
crates/cli/src/server/actor/handlers/shutdown.rs
Normal file
30
crates/cli/src/server/actor/handlers/shutdown.rs
Normal file
|
@ -0,0 +1,30 @@
|
|||
//-
|
||||
|
||||
use actix::prelude::*;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::{
|
||||
repo::messages::UnRegisterWebhook,
|
||||
server::actor::{messages::Shutdown, ServerActor},
|
||||
webhook::messages::ShutdownWebhook,
|
||||
};
|
||||
|
||||
impl Handler<Shutdown> for ServerActor {
|
||||
type Result = ();
|
||||
|
||||
fn handle(&mut self, _msg: Shutdown, _ctx: &mut Self::Context) -> Self::Result {
|
||||
self.repo_actors
|
||||
.iter()
|
||||
.for_each(|((forge_alias, repo_alias), addr)| {
|
||||
debug!(%forge_alias, %repo_alias, "removing webhook");
|
||||
addr.do_send(UnRegisterWebhook::new());
|
||||
debug!(%forge_alias, %repo_alias, "removed webhook");
|
||||
});
|
||||
debug!("server shutdown");
|
||||
if let Some(webhook) = self.webhook_actor_addr.take() {
|
||||
debug!("shutting down webhook");
|
||||
webhook.do_send(ShutdownWebhook);
|
||||
debug!("webhook shutdown");
|
||||
}
|
||||
}
|
||||
}
|
12
crates/cli/src/server/actor/handlers/shutdown_trigger.rs
Normal file
12
crates/cli/src/server/actor/handlers/shutdown_trigger.rs
Normal file
|
@ -0,0 +1,12 @@
|
|||
//
|
||||
use actix::Handler;
|
||||
|
||||
use crate::server::{actor::messages::ShutdownTrigger, ServerActor};
|
||||
|
||||
impl Handler<ShutdownTrigger> for ServerActor {
|
||||
type Result = ();
|
||||
|
||||
fn handle(&mut self, msg: ShutdownTrigger, _ctx: &mut Self::Context) -> Self::Result {
|
||||
self.shutdown_trigger.replace(msg.peel());
|
||||
}
|
||||
}
|
10
crates/cli/src/server/actor/handlers/subscribe_updates.rs
Normal file
10
crates/cli/src/server/actor/handlers/subscribe_updates.rs
Normal file
|
@ -0,0 +1,10 @@
|
|||
use crate::server::actor::{messages::SubscribeToUpdates, ServerActor};
|
||||
|
||||
//
|
||||
impl actix::Handler<SubscribeToUpdates> for ServerActor {
|
||||
type Result = ();
|
||||
|
||||
fn handle(&mut self, msg: SubscribeToUpdates, _ctx: &mut Self::Context) -> Self::Result {
|
||||
self.subscribers.push(msg.peel());
|
||||
}
|
||||
}
|
113
crates/cli/src/server/actor/messages.rs
Normal file
113
crates/cli/src/server/actor/messages.rs
Normal file
|
@ -0,0 +1,113 @@
|
|||
//
|
||||
use actix::{Message, Recipient};
|
||||
use derive_more::Constructor;
|
||||
|
||||
use git_next_core::{
|
||||
git::{self, forge::commit::Status, graph::Log, Commit},
|
||||
message,
|
||||
server::{AppConfig, Storage},
|
||||
webhook::{push::Branch, Push},
|
||||
ForgeAlias, RepoAlias, RepoBranches, RepoConfig,
|
||||
};
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
||||
// receive server config
|
||||
message!(
|
||||
ReceiveAppConfig,
|
||||
AppConfig,
|
||||
"Notification of newly loaded server configuration.
|
||||
|
||||
This message will prompt the `git-next` server to stop and restart all repo-actors.
|
||||
|
||||
Contains the new server configuration."
|
||||
);
|
||||
|
||||
// receive valid server config
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Constructor)]
|
||||
pub struct ValidAppConfig {
|
||||
pub app_config: AppConfig,
|
||||
pub socket_address: SocketAddr,
|
||||
pub storage: Storage,
|
||||
}
|
||||
message!(
|
||||
ReceiveValidAppConfig,
|
||||
ValidAppConfig,
|
||||
"Notification of validated server configuration."
|
||||
);
|
||||
|
||||
message!(Shutdown, "Notification to shutdown the server actor");
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Message)]
|
||||
#[rtype(result = "()")]
|
||||
pub enum ServerUpdate {
|
||||
/// List of all configured forges and aliases
|
||||
AppConfigLoaded { app_config: ValidAppConfig },
|
||||
|
||||
RepoUpdate {
|
||||
forge_alias: ForgeAlias,
|
||||
repo_alias: RepoAlias,
|
||||
repo_update: RepoUpdate,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum RepoUpdate {
|
||||
Branches {
|
||||
branches: RepoBranches,
|
||||
},
|
||||
Log {
|
||||
log: Log,
|
||||
},
|
||||
ValidateRepo,
|
||||
Okay {
|
||||
main: Commit,
|
||||
next: Commit,
|
||||
dev: Commit,
|
||||
},
|
||||
Alert {
|
||||
alert: String,
|
||||
},
|
||||
CheckingCI,
|
||||
AdvancingNext {
|
||||
commit: git::Commit,
|
||||
force: git::push::Force,
|
||||
},
|
||||
AdvancingMain {
|
||||
commit: git::Commit,
|
||||
},
|
||||
Opening,
|
||||
LoadingConfigFromRepo,
|
||||
ReceiveCIStatus {
|
||||
status: Status,
|
||||
},
|
||||
ReceiveRepoConfig {
|
||||
repo_config: RepoConfig,
|
||||
},
|
||||
RegisteringWebhook,
|
||||
UnregisteringWebhook,
|
||||
WebhookReceived {
|
||||
branch: Branch,
|
||||
push: Push,
|
||||
},
|
||||
RegisteredWebhook,
|
||||
Opened,
|
||||
NextUpdated,
|
||||
MainUpdated,
|
||||
}
|
||||
|
||||
message!(
|
||||
SubscribeToUpdates,
|
||||
Recipient<ServerUpdate>,
|
||||
"Subscribe to receive updates from the server"
|
||||
);
|
||||
|
||||
/// Sends a channel to be used to shutdown the server
|
||||
#[derive(Message, Constructor)]
|
||||
#[rtype(result = "()")]
|
||||
pub struct ShutdownTrigger(std::sync::mpsc::Sender<String>);
|
||||
impl ShutdownTrigger {
|
||||
pub fn peel(self) -> std::sync::mpsc::Sender<String> {
|
||||
self.0
|
||||
}
|
||||
}
|
260
crates/cli/src/server/actor/mod.rs
Normal file
260
crates/cli/src/server/actor/mod.rs
Normal file
|
@ -0,0 +1,260 @@
|
|||
//
|
||||
use actix::prelude::*;
|
||||
use messages::{ReceiveAppConfig, ServerUpdate, Shutdown};
|
||||
use tracing::error;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
mod handlers;
|
||||
pub mod messages;
|
||||
|
||||
use crate::{
|
||||
alerts::messages::NotifyUser, alerts::AlertsActor, forge::Forge, repo::RepoActor,
|
||||
webhook::WebhookActor,
|
||||
};
|
||||
|
||||
use git_next_core::{
|
||||
git::{repository::factory::RepositoryFactory, Generation, RepoDetails},
|
||||
server::{self, AppConfig, ListenUrl, Storage},
|
||||
ForgeAlias, ForgeConfig, GitDir, RepoAlias, ServerRepoConfig, StoragePathType,
|
||||
};
|
||||
|
||||
use kxio::{fs::FileSystem, network::Network};
|
||||
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
path::PathBuf,
|
||||
sync::{Arc, RwLock},
|
||||
};
|
||||
|
||||
#[derive(Debug, derive_more::Display, derive_more::From)]
|
||||
pub enum Error {
|
||||
#[display("Failed to create data directories")]
|
||||
FailedToCreateDataDirectory(kxio::fs::Error),
|
||||
|
||||
#[display("The forge data path is not a directory: {path:?}")]
|
||||
ForgeDirIsNotDirectory {
|
||||
path: PathBuf,
|
||||
},
|
||||
|
||||
Config(server::Error),
|
||||
|
||||
Io(std::io::Error),
|
||||
}
|
||||
type Result<T> = core::result::Result<T, Error>;
|
||||
|
||||
#[allow(clippy::module_name_repetitions)]
|
||||
#[derive(derive_with::With)]
|
||||
#[with(message_log)]
|
||||
pub struct ServerActor {
|
||||
app_config: Option<AppConfig>,
|
||||
generation: Generation,
|
||||
webhook_actor_addr: Option<Addr<WebhookActor>>,
|
||||
fs: FileSystem,
|
||||
net: Network,
|
||||
alerts: Addr<AlertsActor>,
|
||||
repository_factory: Box<dyn RepositoryFactory>,
|
||||
sleep_duration: std::time::Duration,
|
||||
repo_actors: BTreeMap<(ForgeAlias, RepoAlias), Addr<RepoActor>>,
|
||||
|
||||
shutdown_trigger: Option<std::sync::mpsc::Sender<String>>,
|
||||
subscribers: Vec<Recipient<ServerUpdate>>,
|
||||
|
||||
// testing
|
||||
message_log: Option<Arc<RwLock<Vec<String>>>>,
|
||||
}
|
||||
impl Actor for ServerActor {
|
||||
type Context = Context<Self>;
|
||||
}
|
||||
|
||||
impl ServerActor {
|
||||
pub fn new(
|
||||
fs: FileSystem,
|
||||
net: Network,
|
||||
alerts: Addr<AlertsActor>,
|
||||
repo: Box<dyn RepositoryFactory>,
|
||||
sleep_duration: std::time::Duration,
|
||||
) -> Self {
|
||||
let generation = Generation::default();
|
||||
Self {
|
||||
app_config: None,
|
||||
generation,
|
||||
webhook_actor_addr: None,
|
||||
fs,
|
||||
net,
|
||||
alerts,
|
||||
repository_factory: repo,
|
||||
shutdown_trigger: None,
|
||||
subscribers: Vec::default(),
|
||||
sleep_duration,
|
||||
repo_actors: BTreeMap::new(),
|
||||
message_log: None,
|
||||
}
|
||||
}
|
||||
fn create_forge_data_directories(
|
||||
&self,
|
||||
app_config: &AppConfig,
|
||||
server_dir: &std::path::Path,
|
||||
) -> Result<()> {
|
||||
for (forge_name, _forge_config) in app_config.forges() {
|
||||
let forge_dir: PathBuf = (&forge_name).into();
|
||||
let path = server_dir.join(&forge_dir);
|
||||
if self.fs.path_exists(&path)? {
|
||||
if !self.fs.path_is_dir(&path)? {
|
||||
return Err(Error::ForgeDirIsNotDirectory { path });
|
||||
}
|
||||
} else {
|
||||
tracing::info!(%forge_name, ?path, "creating storage");
|
||||
self.fs.dir_create_all(&path)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_forge_repos(
|
||||
&self,
|
||||
forge_config: &ForgeConfig,
|
||||
forge_name: ForgeAlias,
|
||||
server_storage: &Storage,
|
||||
listen_url: &ListenUrl,
|
||||
notify_user_recipient: &Recipient<NotifyUser>,
|
||||
server_addr: Option<Addr<Self>>,
|
||||
) -> Vec<(ForgeAlias, RepoAlias, RepoActor)> {
|
||||
let span =
|
||||
tracing::info_span!("create_forge_repos", name = %forge_name, config = %forge_config);
|
||||
|
||||
let _guard = span.enter();
|
||||
tracing::info!("Creating Forge");
|
||||
let mut repos = vec![];
|
||||
let creator = self.create_actor(
|
||||
forge_name,
|
||||
forge_config.clone(),
|
||||
server_storage,
|
||||
listen_url,
|
||||
server_addr,
|
||||
);
|
||||
for (repo_alias, server_repo_config) in forge_config.repos() {
|
||||
let forge_repo = creator((
|
||||
repo_alias,
|
||||
server_repo_config,
|
||||
notify_user_recipient.clone(),
|
||||
));
|
||||
tracing::info!(
|
||||
alias = %forge_repo.1,
|
||||
"Created Repo"
|
||||
);
|
||||
repos.push(forge_repo);
|
||||
}
|
||||
repos
|
||||
}
|
||||
|
||||
fn create_actor(
|
||||
&self,
|
||||
forge_name: ForgeAlias,
|
||||
forge_config: ForgeConfig,
|
||||
server_storage: &Storage,
|
||||
listen_url: &ListenUrl,
|
||||
server_addr: Option<Addr<Self>>,
|
||||
) -> impl Fn(
|
||||
(RepoAlias, &ServerRepoConfig, Recipient<NotifyUser>),
|
||||
) -> (ForgeAlias, RepoAlias, RepoActor) {
|
||||
let server_storage = server_storage.clone();
|
||||
let listen_url = listen_url.clone();
|
||||
let net = self.net.clone();
|
||||
let repository_factory = self.repository_factory.duplicate();
|
||||
let generation = self.generation;
|
||||
let sleep_duration = self.sleep_duration;
|
||||
move |(repo_alias, server_repo_config, notify_user_recipient)| {
|
||||
let span = tracing::info_span!("create_actor", alias = %repo_alias, config = %server_repo_config);
|
||||
let _guard = span.enter();
|
||||
tracing::info!("Creating Repo");
|
||||
let gitdir = server_repo_config.gitdir().map_or_else(
|
||||
|| {
|
||||
GitDir::new(
|
||||
server_storage
|
||||
.path()
|
||||
.join(forge_name.to_string())
|
||||
.join(repo_alias.to_string()),
|
||||
StoragePathType::Internal,
|
||||
)
|
||||
},
|
||||
|gitdir| gitdir,
|
||||
);
|
||||
// INFO: can't canonicalise gitdir as the path needs to exist to do that and we may not
|
||||
// have cloned the repo yet
|
||||
let repo_details = RepoDetails::new(
|
||||
generation,
|
||||
&repo_alias,
|
||||
server_repo_config,
|
||||
&forge_name,
|
||||
&forge_config,
|
||||
gitdir,
|
||||
);
|
||||
let forge = Forge::create(repo_details.clone(), net.clone());
|
||||
tracing::info!("Starting Repo Actor");
|
||||
let actor = RepoActor::new(
|
||||
repo_details,
|
||||
forge,
|
||||
listen_url.clone(),
|
||||
generation,
|
||||
net.clone(),
|
||||
repository_factory.duplicate(),
|
||||
sleep_duration,
|
||||
Some(notify_user_recipient),
|
||||
server_addr.clone(),
|
||||
);
|
||||
(forge_name.clone(), repo_alias, actor)
|
||||
}
|
||||
}
|
||||
|
||||
fn server_storage(&self, app_config: &ReceiveAppConfig) -> Option<Storage> {
|
||||
let server_storage = app_config.storage().clone();
|
||||
let dir = server_storage.path();
|
||||
if !dir.exists() {
|
||||
if let Err(err) = self.fs.dir_create(dir) {
|
||||
error!(?err, ?dir, "Failed to create server storage");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let Ok(canon) = dir.canonicalize() else {
|
||||
error!(?dir, "Failed to confirm server storage");
|
||||
return None;
|
||||
};
|
||||
if let Err(err) = self.create_forge_data_directories(app_config, &canon) {
|
||||
error!(?err, "Failure creating forge storage");
|
||||
return None;
|
||||
}
|
||||
Some(server_storage)
|
||||
}
|
||||
|
||||
/// Attempts to gracefully shutdown the server before stopping the system.
|
||||
fn abort(&mut self, ctx: &<Self as actix::Actor>::Context, message: impl Into<String>) {
|
||||
self.do_send(crate::server::actor::messages::Shutdown, ctx);
|
||||
if let Some(t) = self.shutdown_trigger.take() {
|
||||
let _ = t.send(message.into());
|
||||
} else {
|
||||
error!("{}", message.into());
|
||||
self.do_send(Shutdown, ctx);
|
||||
// System::current().stop_with_code(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn do_send<M>(&self, msg: M, ctx: &<Self as actix::Actor>::Context)
|
||||
where
|
||||
M: actix::Message + Send + 'static + std::fmt::Debug,
|
||||
Self: actix::Handler<M>,
|
||||
<M as actix::Message>::Result: Send,
|
||||
{
|
||||
if let Some(message_log) = &self.message_log {
|
||||
let log_message = format!("send: {msg:?}");
|
||||
if let Ok(mut log) = message_log.write() {
|
||||
log.push(log_message);
|
||||
}
|
||||
}
|
||||
if cfg!(not(test)) {
|
||||
ctx.address().do_send(msg);
|
||||
}
|
||||
}
|
||||
}
|
18
crates/cli/src/server/actor/tests/given.rs
Normal file
18
crates/cli/src/server/actor/tests/given.rs
Normal file
|
@ -0,0 +1,18 @@
|
|||
use std::time::Duration;
|
||||
|
||||
use actix::prelude::*;
|
||||
|
||||
use crate::alerts::{AlertsActor, History};
|
||||
|
||||
//
|
||||
pub fn a_filesystem() -> kxio::fs::FileSystem {
|
||||
kxio::fs::temp().unwrap_or_else(|e| panic!("{}", e))
|
||||
}
|
||||
|
||||
pub fn a_network() -> kxio::network::MockNetwork {
|
||||
kxio::network::MockNetwork::new()
|
||||
}
|
||||
|
||||
pub fn an_alerts_actor(net: kxio::network::Network) -> Addr<AlertsActor> {
|
||||
AlertsActor::new(None, History::new(Duration::from_millis(1)), net).start()
|
||||
}
|
3
crates/cli/src/server/actor/tests/mod.rs
Normal file
3
crates/cli/src/server/actor/tests/mod.rs
Normal file
|
@ -0,0 +1,3 @@
|
|||
mod receive_app_config;
|
||||
|
||||
mod given;
|
56
crates/cli/src/server/actor/tests/receive_app_config.rs
Normal file
56
crates/cli/src/server/actor/tests/receive_app_config.rs
Normal file
|
@ -0,0 +1,56 @@
|
|||
//
|
||||
use actix::prelude::*;
|
||||
|
||||
use crate::server::actor::{tests::given, ReceiveAppConfig, ServerActor};
|
||||
use git_next_core::{
|
||||
git,
|
||||
server::{AppConfig, Http, Listen, ListenUrl, Shout, Storage},
|
||||
};
|
||||
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
sync::{Arc, RwLock},
|
||||
};
|
||||
|
||||
#[test_log::test(actix::test)]
|
||||
async fn when_webhook_url_has_trailing_slash_should_not_send() {
|
||||
//given
|
||||
// parameters
|
||||
let fs = given::a_filesystem();
|
||||
let net = given::a_network();
|
||||
let alerts = given::an_alerts_actor(net.clone().into());
|
||||
let repo = git::repository::factory::mock();
|
||||
let duration = std::time::Duration::from_millis(1);
|
||||
|
||||
// sut
|
||||
let server = ServerActor::new(fs.clone(), net.into(), alerts, repo, duration);
|
||||
|
||||
// collaborators
|
||||
let listen = Listen::new(
|
||||
Http::new("0.0.0.0".to_string(), 80),
|
||||
ListenUrl::new("http://localhost/".to_string()), // with trailing slash
|
||||
);
|
||||
let shout = Shout::default();
|
||||
let server_storage = Storage::new((fs.base()).to_path_buf());
|
||||
let repos = BTreeMap::default();
|
||||
|
||||
// debugging
|
||||
let message_log: Arc<RwLock<Vec<String>>> = Arc::new(RwLock::new(vec![]));
|
||||
let server = server.with_message_log(Some(message_log.clone()));
|
||||
|
||||
//when
|
||||
server.start().do_send(ReceiveAppConfig::new(AppConfig::new(
|
||||
listen,
|
||||
shout,
|
||||
server_storage,
|
||||
repos,
|
||||
)));
|
||||
actix_rt::time::sleep(std::time::Duration::from_millis(1)).await;
|
||||
|
||||
//then
|
||||
// INFO: assert that ReceiveValidServerConfig is NOT sent
|
||||
tracing::debug!(?message_log, "");
|
||||
assert!(message_log.read().iter().any(|log| !log
|
||||
.iter()
|
||||
.any(|line| line == "send: ReceiveValidServerConfig")));
|
||||
}
|
190
crates/cli/src/server/mod.rs
Normal file
190
crates/cli/src/server/mod.rs
Normal file
|
@ -0,0 +1,190 @@
|
|||
//
|
||||
pub mod actor;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use actix::prelude::*;
|
||||
use actix_rt::signal;
|
||||
use actor::messages::ShutdownTrigger;
|
||||
|
||||
use crate::{
|
||||
alerts::{AlertsActor, History},
|
||||
file_watcher::{watch_file, FileUpdated},
|
||||
};
|
||||
|
||||
#[allow(clippy::module_name_repetitions)]
|
||||
pub use actor::ServerActor;
|
||||
|
||||
use git_next_core::git::RepositoryFactory;
|
||||
|
||||
use color_eyre::{eyre::Context, Result};
|
||||
use kxio::{fs::FileSystem, network::Network};
|
||||
use tracing::info;
|
||||
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
sync::{atomic::Ordering, mpsc::channel, Arc, RwLock},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
const A_DAY: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
|
||||
pub fn init(fs: &FileSystem) -> Result<()> {
|
||||
let file_name = "git-next-server.toml";
|
||||
let pathbuf = PathBuf::from(file_name);
|
||||
if fs
|
||||
.path_exists(&pathbuf)
|
||||
.with_context(|| format!("Checking for existing file: {pathbuf:?}"))?
|
||||
{
|
||||
eprintln!("The configuration file already exists at {pathbuf:?} - not overwritting it.",);
|
||||
} else {
|
||||
fs.file_write(&pathbuf, include_str!("server-default.toml"))
|
||||
.with_context(|| format!("Writing file: {pathbuf:?}"))?;
|
||||
println!("Created a default configuration file at {pathbuf:?}",);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub fn start(
|
||||
ui: bool,
|
||||
fs: FileSystem,
|
||||
net: Network,
|
||||
repo: Box<dyn RepositoryFactory>,
|
||||
sleep_duration: std::time::Duration,
|
||||
) -> Result<()> {
|
||||
if ui {
|
||||
#[cfg(feature = "tui")]
|
||||
{
|
||||
crate::tui::logging::initialize_logging()?;
|
||||
}
|
||||
} else {
|
||||
init_logging();
|
||||
}
|
||||
|
||||
let shutdown_message_holder: Arc<RwLock<Option<String>>> = Arc::new(RwLock::new(None));
|
||||
let shutdown_message_holder_exec = shutdown_message_holder.clone();
|
||||
let file_watcher_err_holder: Arc<RwLock<Option<anyhow::Error>>> = Arc::new(RwLock::new(None));
|
||||
let file_watcher_err_holder_exec = file_watcher_err_holder.clone();
|
||||
let execution = async move {
|
||||
info!("Starting Alert Dispatcher...");
|
||||
let alerts_addr = AlertsActor::new(None, History::new(A_DAY), net.clone()).start();
|
||||
|
||||
info!("Starting Server...");
|
||||
let server =
|
||||
ServerActor::new(fs.clone(), net.clone(), alerts_addr, repo, sleep_duration).start();
|
||||
|
||||
info!("Starting File Watcher...");
|
||||
let watch_file = watch_file("git-next-server.toml".into(), server.clone().recipient());
|
||||
let fw_shutdown = match watch_file {
|
||||
Ok(fw_shutdown) => fw_shutdown,
|
||||
Err(err) => {
|
||||
// shutdown now
|
||||
server.do_send(crate::server::actor::messages::Shutdown);
|
||||
actix_rt::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
System::current().stop();
|
||||
let _ = file_watcher_err_holder_exec
|
||||
.write()
|
||||
.map(|mut o| o.replace(err));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let (tx_shutdown, rx_shutdown) = channel::<String>();
|
||||
if ui {
|
||||
#[cfg(feature = "tui")]
|
||||
{
|
||||
use crate::server::actor::messages::SubscribeToUpdates;
|
||||
use crate::tui;
|
||||
|
||||
let tui_addr = tui::Tui::new(tx_shutdown.clone()).start();
|
||||
server.do_send(SubscribeToUpdates::new(tui_addr.clone().recipient()));
|
||||
server.do_send(ShutdownTrigger::new(tx_shutdown));
|
||||
server.do_send(FileUpdated); // update file after ui subscription in place
|
||||
loop {
|
||||
let _ = tui_addr.send(tui::Tick).await;
|
||||
if let Ok(message) = rx_shutdown.try_recv() {
|
||||
let _ = shutdown_message_holder_exec
|
||||
.write()
|
||||
.map(|mut o| o.replace(message));
|
||||
break;
|
||||
}
|
||||
actix_rt::time::sleep(Duration::from_millis(16)).await;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
server.do_send(ShutdownTrigger::new(tx_shutdown.clone()));
|
||||
server.do_send(FileUpdated);
|
||||
|
||||
info!("Server running - Press Ctrl-C to stop...");
|
||||
tokio::select! {
|
||||
_r = signal::ctrl_c() => {
|
||||
info!("Ctrl-C received, shutting down...");
|
||||
}
|
||||
_x = async move {
|
||||
loop{
|
||||
if let Ok(message) = rx_shutdown.try_recv() {
|
||||
let _ = shutdown_message_holder_exec
|
||||
.write()
|
||||
.map(|mut o| o.replace(message));
|
||||
break;
|
||||
}
|
||||
actix_rt::task::yield_now().await;
|
||||
}
|
||||
} => {
|
||||
info!("signaled shutdown");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// shutdown
|
||||
fw_shutdown.store(true, Ordering::Relaxed);
|
||||
server.do_send(crate::server::actor::messages::Shutdown);
|
||||
actix_rt::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
System::current().stop();
|
||||
};
|
||||
|
||||
let system = System::new();
|
||||
Arbiter::current().spawn(execution);
|
||||
system.run()?;
|
||||
|
||||
// check for error from server thread
|
||||
#[allow(clippy::unwrap_used)]
|
||||
if let Some(err) = &*shutdown_message_holder.read().unwrap() {
|
||||
#[cfg(feature = "tui")]
|
||||
if ui {
|
||||
ratatui::restore();
|
||||
}
|
||||
if !err.is_empty() {
|
||||
return Err(color_eyre::eyre::eyre!(format!("{err}")));
|
||||
}
|
||||
}
|
||||
|
||||
// check for error from file watcher thread
|
||||
#[allow(clippy::unwrap_used)]
|
||||
if let Some(err) = &*file_watcher_err_holder.read().unwrap() {
|
||||
#[cfg(feature = "tui")]
|
||||
if ui {
|
||||
ratatui::restore();
|
||||
}
|
||||
return Err(color_eyre::eyre::eyre!(format!("{err}")));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn init_logging() {
|
||||
use tracing::Level;
|
||||
use tracing_subscriber::FmtSubscriber;
|
||||
|
||||
let subscriber = FmtSubscriber::builder()
|
||||
.with_target(false)
|
||||
.with_file(true)
|
||||
.with_line_number(true)
|
||||
.with_max_level(Level::INFO)
|
||||
.finish();
|
||||
|
||||
#[allow(clippy::expect_used)]
|
||||
tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber failed");
|
||||
}
|
35
crates/cli/src/server/server-default.toml
Normal file
35
crates/cli/src/server/server-default.toml
Normal file
|
@ -0,0 +1,35 @@
|
|||
[listen]
|
||||
# The address and port to listen to for incoming webhooks from forges.
|
||||
http = { addr = "0.0.0.0", port = 8080 }
|
||||
|
||||
# The URL where forge should send updates to.
|
||||
# This should be route to 'http.addr:http.port' above (e.g. using a reverse proxy)
|
||||
url = "https://localhost:8080" # don't include any query path or a trailing slash
|
||||
|
||||
[shout] # where updates from git-next should be sent to alert the user
|
||||
# webhook = { url = "https//localhost:9090", secret = "secret-password" }
|
||||
# desktop = true # enable desktop notifications
|
||||
|
||||
# [shout.email]
|
||||
# from = "git-next@example.com"
|
||||
# to = "developer@example.com"
|
||||
#
|
||||
# [shout.email.smtp]
|
||||
# hostname = "smtp.example.com"
|
||||
# username = "git-next@example.com"
|
||||
# password = "MySecretEmailPassword42"
|
||||
|
||||
[storage] # where local copies of repositories will be cloned (bare) into
|
||||
path = "./data"
|
||||
|
||||
[forge] # the forges to connect to
|
||||
|
||||
# [forge.default]
|
||||
# forge_type = "ForgeJo"
|
||||
# hostname = "git.example.net"
|
||||
# user = "bob" # the user to perform actions as
|
||||
# token = "API-Token"
|
||||
#
|
||||
# [forge.default.repos] # the repos at the forge to manage
|
||||
# hello = { repo = "bob/hello", branch = "main", gitdir = "/opt/git/projects/bob/hello.git" }
|
||||
# world = { repo = "bob/world", branch = "master", main = "master", next = "upcoming", "dev" = "develop" }
|
151
crates/cli/src/server/tests.rs
Normal file
151
crates/cli/src/server/tests.rs
Normal file
|
@ -0,0 +1,151 @@
|
|||
//
|
||||
use assert2::let_assert;
|
||||
use git_next_core::{
|
||||
self as core,
|
||||
git::{self, repository::Direction, validation::remotes::validate_default_remotes},
|
||||
ApiToken, ForgeType, GitDir, Hostname, RepoBranches, RepoConfig, RepoConfigSource, RepoPath,
|
||||
StoragePathType, User,
|
||||
};
|
||||
use secrecy::SecretString;
|
||||
|
||||
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
|
||||
|
||||
#[test]
|
||||
fn test_repo_config_load() -> Result<()> {
|
||||
let toml = r#"[branches]
|
||||
main = "main"
|
||||
next = "next"
|
||||
dev = "dev"
|
||||
|
||||
[options]
|
||||
"#;
|
||||
let config = RepoConfig::parse(toml)?;
|
||||
|
||||
assert_eq!(
|
||||
config,
|
||||
RepoConfig::new(
|
||||
RepoBranches::new("main".to_string(), "next".to_string(), "dev".to_string(),),
|
||||
RepoConfigSource::Repo
|
||||
)
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gitdir_should_display_as_pathbuf() {
|
||||
//given
|
||||
let gitdir = GitDir::new("foo/dir".into(), StoragePathType::External);
|
||||
//when
|
||||
let result = format!("{gitdir}");
|
||||
//then
|
||||
assert_eq!(result, "foo/dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
// NOTE: this test assumes it is being run in a cloned worktree from the project's home repo:
|
||||
// git.kemitix.net:kemitix/git-next
|
||||
// If the default push remote is something else, then this test will fail
|
||||
fn repo_details_find_default_push_remote_finds_correct_remote() -> Result<()> {
|
||||
let cli_crate_dir = std::env::current_dir().map_err(git::validation::remotes::Error::Io)?;
|
||||
let_assert!(Some(Some(root)) = cli_crate_dir.parent().map(|p| p.parent()));
|
||||
let mut repo_details = git::repo_details(
|
||||
1,
|
||||
git::Generation::default(),
|
||||
core::common::forge_details(1, ForgeType::MockForge),
|
||||
None,
|
||||
GitDir::new(root.to_path_buf(), StoragePathType::External), // Server GitDir - should be ignored
|
||||
);
|
||||
repo_details.forge = repo_details
|
||||
.forge
|
||||
.with_user(User::new("git".to_string()))
|
||||
.with_token(ApiToken::new(SecretString::from(String::new())))
|
||||
.with_hostname(Hostname::new("git.kemitix.net"));
|
||||
repo_details.repo_path = RepoPath::new("kemitix/git-next".to_string());
|
||||
let Ok(open_repository) = git::repository::factory::real().open(&repo_details) else {
|
||||
// .git directory may not be present on dev environment
|
||||
return Ok(());
|
||||
};
|
||||
let_assert!(
|
||||
Some(found_git_remote) = open_repository.find_default_remote(Direction::Push),
|
||||
"Default Push Remote not found"
|
||||
);
|
||||
let_assert!(Some(config_git_remote) = repo_details.remote_url());
|
||||
|
||||
assert!(
|
||||
found_git_remote.matches(&config_git_remote),
|
||||
"Default Push Remote must match config"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test_log::test]
|
||||
fn gitdir_validate_should_pass_a_valid_git_repo() -> Result<()> {
|
||||
let cli_crate_dir = std::env::current_dir().map_err(git::validation::remotes::Error::Io)?;
|
||||
let_assert!(Some(Some(root)) = cli_crate_dir.parent().map(|p| p.parent()));
|
||||
let mut repo_details = git::repo_details(
|
||||
1,
|
||||
git::Generation::default(),
|
||||
core::common::forge_details(1, ForgeType::MockForge),
|
||||
None,
|
||||
GitDir::new(root.to_path_buf(), StoragePathType::External), // Server GitDir - should be ignored
|
||||
)
|
||||
.with_repo_path(RepoPath::new("kemitix/git-next".to_string()));
|
||||
repo_details.forge = repo_details
|
||||
.forge
|
||||
.with_user(User::new("git".to_string()))
|
||||
.with_token(ApiToken::new(SecretString::from(String::new())))
|
||||
.with_hostname(Hostname::new("git.kemitix.net"));
|
||||
tracing::debug!("opening...");
|
||||
let Ok(repository) = git::repository::factory::real().open(&repo_details) else {
|
||||
// .git directory may not be present on dev environment
|
||||
return Ok(());
|
||||
};
|
||||
tracing::debug!("open okay");
|
||||
tracing::info!(?repository, "FOO");
|
||||
tracing::info!(?repo_details, "BAR");
|
||||
validate_default_remotes(&*repository, &repo_details)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gitdir_validate_should_fail_a_git_repo_with_wrong_remote() {
|
||||
let_assert!(
|
||||
Ok(cli_crate_dir) = std::env::current_dir().map_err(git::validation::remotes::Error::Io)
|
||||
);
|
||||
eprintln!("cli_crate_dir: {cli_crate_dir:?}");
|
||||
let_assert!(Some(Some(root)) = cli_crate_dir.parent().map(|p| p.parent()));
|
||||
eprintln!("root: {root:?}");
|
||||
let mut repo_details = git::repo_details(
|
||||
1,
|
||||
git::Generation::default(),
|
||||
core::common::forge_details(1, ForgeType::MockForge),
|
||||
None,
|
||||
GitDir::new(root.to_path_buf(), StoragePathType::External), // Server GitDir - should be ignored
|
||||
)
|
||||
.with_repo_path(RepoPath::new("kemitix/git-next".to_string()));
|
||||
repo_details.forge = repo_details
|
||||
.forge
|
||||
.with_user(User::new("git".to_string()))
|
||||
.with_token(ApiToken::new(SecretString::from(String::new())))
|
||||
.with_hostname(Hostname::new("git.kemitix.net"));
|
||||
let Ok(repository) = git::repository::factory::real().open(&repo_details) else {
|
||||
// .git directory may not be present on dev environment
|
||||
return;
|
||||
};
|
||||
let mut repo_details = repo_details.clone();
|
||||
repo_details.forge = repo_details
|
||||
.forge
|
||||
.with_hostname(Hostname::new("code.kemitix.net"));
|
||||
let_assert!(Err(_) = validate_default_remotes(&*repository, &repo_details));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_remote_to_string_is_as_expected() {
|
||||
let git_remote = git::GitRemote::new(Hostname::new("foo"), RepoPath::new("bar".to_string()));
|
||||
let as_string = git_remote.to_string();
|
||||
|
||||
assert_eq!(as_string, "foo:bar");
|
||||
}
|
81
crates/cli/src/tests.rs
Normal file
81
crates/cli/src/tests.rs
Normal file
|
@ -0,0 +1,81 @@
|
|||
type TestResult = Result<(), Box<dyn std::error::Error>>;
|
||||
|
||||
mod init {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn should_not_update_file_if_it_exists() -> TestResult {
|
||||
let fs = kxio::fs::temp()?;
|
||||
let file = fs.base().join(".git-next.toml");
|
||||
fs.file_write(&file, "contents")?;
|
||||
|
||||
crate::init::run(&fs)?;
|
||||
|
||||
assert_eq!(
|
||||
fs.file_read_to_string(&file)?,
|
||||
"contents",
|
||||
"The file has been changed"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_create_default_file_if_not_exists() -> TestResult {
|
||||
let fs = kxio::fs::temp()?;
|
||||
|
||||
crate::init::run(&fs)?;
|
||||
|
||||
let file = fs.base().join(".git-next.toml");
|
||||
|
||||
assert!(fs.path_exists(&file)?, "The file has not been created");
|
||||
|
||||
assert_eq!(
|
||||
fs.file_read_to_string(&file)?,
|
||||
include_str!("../default.toml"),
|
||||
"The file does not match the default template"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
mod file_watcher {
|
||||
use std::{sync::atomic::Ordering, time::Duration};
|
||||
|
||||
use actix::{Actor, Context, Handler};
|
||||
use rstest::*;
|
||||
|
||||
use crate::file_watcher::{self, FileUpdated};
|
||||
|
||||
use super::TestResult;
|
||||
|
||||
#[rstest]
|
||||
#[actix::test]
|
||||
#[timeout(Duration::from_millis(80))]
|
||||
async fn should_not_block_calling_thread() -> TestResult {
|
||||
let fs = kxio::fs::temp()?;
|
||||
let path = fs.base().join("file");
|
||||
fs.file_write(&path, "foo")?;
|
||||
|
||||
let listener = Listener;
|
||||
let l_addr = listener.start();
|
||||
let recipient = l_addr.recipient();
|
||||
|
||||
let fw_shutdown = file_watcher::watch_file(path, recipient)?;
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
fw_shutdown.store(true, Ordering::Relaxed);
|
||||
|
||||
Ok(()) // was not blocked
|
||||
}
|
||||
|
||||
struct Listener;
|
||||
impl Actor for Listener {
|
||||
type Context = Context<Self>;
|
||||
}
|
||||
impl Handler<FileUpdated> for Listener {
|
||||
type Result = ();
|
||||
|
||||
fn handle(&mut self, _msg: FileUpdated, _ctx: &mut Self::Context) -> Self::Result {
|
||||
// todo!()
|
||||
}
|
||||
}
|
||||
}
|
40
crates/cli/src/tui/README.md
Normal file
40
crates/cli/src/tui/README.md
Normal file
|
@ -0,0 +1,40 @@
|
|||
# Terminal UI
|
||||
|
||||
Currently the Terminal UI is an experimental feature, controlled by the feature flag `tui`.
|
||||
|
||||
## Build & Run
|
||||
|
||||
The build `git-next` with the Terminal UI use: `cargo install git-next --features tui`
|
||||
|
||||
To run `git-next` with the Terminal UI use: `git-next server start --ui`
|
||||
|
||||
### Docker
|
||||
|
||||
If using the docker image you will need to create a directory to mount that contains the
|
||||
`git-next-server.toml` file. Mount this directory as `/app`. In the example below we use
|
||||
the current directory for this.
|
||||
|
||||
If you want to persist the clones of your monitored repos then point `storage.path` in
|
||||
`git-next-server.toml` to the the directory `/app`, (e.g. `path = "/app/data"`).
|
||||
|
||||
Map the port your webhook notifications are arriving on to the port specified in `listen.http.port`.
|
||||
|
||||
`docker run -it -p "8080:8092" -v .:/app/ git.kemitix.net/kemitix/git-next:latest server start --ui`
|
||||
|
||||
## logs
|
||||
|
||||
When the Terminal UI is enabled via the `--ui` parameter, logs are written to the file:
|
||||
|
||||
- `???` on Linux
|
||||
- `~/Library/Application Support/net.kemitix.git-next/git-next.log` on MacOS
|
||||
- `???` on Windows
|
||||
|
||||
## Keys
|
||||
|
||||
- `q` - Quit
|
||||
- `j` - Down
|
||||
- `k` - Up
|
||||
- `f` - Page Down
|
||||
- `b` - Page Up
|
||||
- `g` - Top/Home
|
||||
- `G` - Bottom/End
|
3
crates/cli/src/tui/actor/handlers/mod.rs
Normal file
3
crates/cli/src/tui/actor/handlers/mod.rs
Normal file
|
@ -0,0 +1,3 @@
|
|||
//
|
||||
mod server_update;
|
||||
mod tick;
|
104
crates/cli/src/tui/actor/handlers/server_update.rs
Normal file
104
crates/cli/src/tui/actor/handlers/server_update.rs
Normal file
|
@ -0,0 +1,104 @@
|
|||
//
|
||||
use actix::Handler;
|
||||
use ratatui::style::Color;
|
||||
|
||||
use crate::{
|
||||
server::actor::messages::{RepoUpdate, ServerUpdate},
|
||||
tui::{actor::ServerState, Tui},
|
||||
};
|
||||
|
||||
static OKAY: Color = Color::Green;
|
||||
static PREP: Color = Color::Gray;
|
||||
static ACTING: Color = Color::LightBlue;
|
||||
static WARN: Color = Color::Red;
|
||||
|
||||
impl Handler<ServerUpdate> for Tui {
|
||||
type Result = ();
|
||||
|
||||
fn handle(&mut self, msg: ServerUpdate, _ctx: &mut Self::Context) -> Self::Result {
|
||||
self.state.tap();
|
||||
match msg {
|
||||
ServerUpdate::AppConfigLoaded { app_config } => {
|
||||
self.state.mode = ServerState::from(app_config);
|
||||
}
|
||||
|
||||
ServerUpdate::RepoUpdate {
|
||||
forge_alias,
|
||||
repo_alias,
|
||||
repo_update,
|
||||
} => {
|
||||
if let ServerState::Configured { forges } = &mut self.state.mode {
|
||||
let Some(forge_state) = forges.get_mut(&forge_alias) else {
|
||||
return;
|
||||
};
|
||||
let Some(repo_state) = forge_state.repos.get_mut(&repo_alias) else {
|
||||
return;
|
||||
};
|
||||
repo_state.clear_alert();
|
||||
match repo_update {
|
||||
RepoUpdate::Branches { branches } => {
|
||||
repo_state.update_branches(branches);
|
||||
}
|
||||
RepoUpdate::Log { log } => {
|
||||
repo_state.update_log(log);
|
||||
}
|
||||
RepoUpdate::ValidateRepo => repo_state.update_message("polling...", ACTING),
|
||||
RepoUpdate::Okay { main, next, dev } => {
|
||||
repo_state.clear_alert();
|
||||
repo_state.update_message("okay", OKAY);
|
||||
*repo_state = repo_state.clone().ready(main, next, dev);
|
||||
}
|
||||
RepoUpdate::Alert { alert } => {
|
||||
repo_state.alert(alert);
|
||||
}
|
||||
RepoUpdate::CheckingCI => {
|
||||
repo_state.update_message("Checking CI status", ACTING);
|
||||
}
|
||||
RepoUpdate::AdvancingNext { commit, force: _ } => {
|
||||
repo_state
|
||||
.update_message(format!("advancing next to {commit}"), ACTING);
|
||||
}
|
||||
RepoUpdate::NextUpdated => {
|
||||
repo_state.update_message("next updated - pause while CI starts", OKAY);
|
||||
}
|
||||
RepoUpdate::AdvancingMain { commit } => {
|
||||
repo_state
|
||||
.update_message(format!("advancing main to {commit}"), ACTING);
|
||||
}
|
||||
RepoUpdate::MainUpdated => {
|
||||
repo_state.update_message("main updated", OKAY);
|
||||
}
|
||||
RepoUpdate::Opening => {
|
||||
repo_state.update_message("opening...", PREP);
|
||||
}
|
||||
RepoUpdate::Opened => {
|
||||
repo_state.update_message("opened", PREP);
|
||||
}
|
||||
RepoUpdate::LoadingConfigFromRepo => {
|
||||
repo_state.update_message("loading config from repo...", PREP);
|
||||
}
|
||||
RepoUpdate::ReceiveCIStatus { status } => {
|
||||
repo_state.update_message(format!("ci status: {status:?}"), WARN);
|
||||
}
|
||||
RepoUpdate::ReceiveRepoConfig { repo_config: _ } => {
|
||||
repo_state.update_message("loaded config from repo", PREP);
|
||||
}
|
||||
RepoUpdate::RegisteringWebhook => {
|
||||
repo_state.update_message("registering webhook...", PREP);
|
||||
}
|
||||
RepoUpdate::UnregisteringWebhook => {
|
||||
repo_state.update_message("unregistering webhook...", PREP);
|
||||
}
|
||||
RepoUpdate::WebhookReceived { branch, push: _ } => {
|
||||
repo_state
|
||||
.update_message(format!("webhook update: {branch:?}"), ACTING);
|
||||
}
|
||||
RepoUpdate::RegisteredWebhook => {
|
||||
repo_state.update_message("registered webhook", PREP);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
15
crates/cli/src/tui/actor/handlers/tick.rs
Normal file
15
crates/cli/src/tui/actor/handlers/tick.rs
Normal file
|
@ -0,0 +1,15 @@
|
|||
//
|
||||
use actix::Handler;
|
||||
|
||||
use crate::tui::actor::{messages::Tick, Tui};
|
||||
|
||||
impl Handler<Tick> for Tui {
|
||||
type Result = std::io::Result<()>;
|
||||
|
||||
fn handle(&mut self, _msg: Tick, ctx: &mut Self::Context) -> Self::Result {
|
||||
self.state.tap();
|
||||
self.draw()?;
|
||||
self.handle_input(ctx)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
4
crates/cli/src/tui/actor/messages.rs
Normal file
4
crates/cli/src/tui/actor/messages.rs
Normal file
|
@ -0,0 +1,4 @@
|
|||
//
|
||||
use git_next_core::message;
|
||||
|
||||
message!(Tick => std::io::Result<()>, "Update the TUI");
|
97
crates/cli/src/tui/actor/mod.rs
Normal file
97
crates/cli/src/tui/actor/mod.rs
Normal file
|
@ -0,0 +1,97 @@
|
|||
//
|
||||
mod handlers;
|
||||
pub mod messages;
|
||||
mod model;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use std::sync::mpsc::Sender;
|
||||
|
||||
use actix::{Actor, ActorContext as _, Context};
|
||||
|
||||
pub use model::*;
|
||||
|
||||
use ratatui::{
|
||||
crossterm::event::{self, KeyCode, KeyEventKind},
|
||||
DefaultTerminal,
|
||||
};
|
||||
use tui_scrollview::ScrollViewState;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Tui {
|
||||
terminal: Option<DefaultTerminal>,
|
||||
signal_shutdown: Sender<String>,
|
||||
pub state: State,
|
||||
scroll_view_state: ScrollViewState,
|
||||
}
|
||||
impl Actor for Tui {
|
||||
type Context = Context<Self>;
|
||||
fn started(&mut self, _ctx: &mut Self::Context) {
|
||||
self.terminal.replace(ratatui::init());
|
||||
}
|
||||
fn stopped(&mut self, _ctx: &mut Self::Context) {
|
||||
self.terminal.take();
|
||||
ratatui::restore();
|
||||
}
|
||||
}
|
||||
impl Tui {
|
||||
pub fn new(signal_shutdown: Sender<String>) -> Self {
|
||||
Self {
|
||||
terminal: None,
|
||||
signal_shutdown,
|
||||
state: State::initial(),
|
||||
scroll_view_state: ScrollViewState::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn draw(&mut self) -> std::io::Result<()> {
|
||||
let t = self.terminal.take();
|
||||
let scroll_view_state = &mut self.scroll_view_state;
|
||||
let state = &self.state;
|
||||
if let Some(mut terminal) = t {
|
||||
terminal.draw(|frame| {
|
||||
frame.render_stateful_widget(state, frame.area(), scroll_view_state);
|
||||
})?;
|
||||
self.terminal = Some(terminal);
|
||||
} else {
|
||||
eprintln!("No terminal setup");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_input(&mut self, ctx: &mut <Self as actix::Actor>::Context) -> std::io::Result<()> {
|
||||
if event::poll(std::time::Duration::from_millis(16))? {
|
||||
let event::Event::Key(key) = event::read()? else {
|
||||
return Ok(());
|
||||
};
|
||||
if key.kind != KeyEventKind::Press {
|
||||
return Ok(());
|
||||
}
|
||||
match key.code {
|
||||
KeyCode::Char('q') => {
|
||||
ctx.stop();
|
||||
if let Err(err) = self.signal_shutdown.send(String::new()) {
|
||||
tracing::error!(?err, "Failed to signal shutdown");
|
||||
}
|
||||
}
|
||||
KeyCode::Char('j') | KeyCode::Down => self.scroll_view_state.scroll_down(),
|
||||
KeyCode::Char('k') | KeyCode::Up => self.scroll_view_state.scroll_up(),
|
||||
KeyCode::Char('f') | KeyCode::PageDown => {
|
||||
self.scroll_view_state.scroll_page_down();
|
||||
}
|
||||
KeyCode::Char('b') | KeyCode::PageUp => {
|
||||
self.scroll_view_state.scroll_page_up();
|
||||
}
|
||||
KeyCode::Char('g') | KeyCode::Home => {
|
||||
self.scroll_view_state.scroll_to_top();
|
||||
}
|
||||
KeyCode::Char('G') | KeyCode::End => {
|
||||
self.scroll_view_state.scroll_to_bottom();
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
388
crates/cli/src/tui/actor/model.rs
Normal file
388
crates/cli/src/tui/actor/model.rs
Normal file
|
@ -0,0 +1,388 @@
|
|||
//
|
||||
use ratatui::{
|
||||
layout::Alignment,
|
||||
prelude::{Buffer, Rect},
|
||||
style::{Color, Style, Stylize as _},
|
||||
symbols::border,
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Paragraph, StatefulWidget, Widget},
|
||||
};
|
||||
|
||||
use git_next_core::{
|
||||
git::{self, graph::Log, Commit},
|
||||
ForgeAlias, RepoAlias, RepoBranches,
|
||||
};
|
||||
use tracing::info;
|
||||
use tui_scrollview::ScrollViewState;
|
||||
|
||||
use std::{collections::BTreeMap, fmt::Display, time::Instant};
|
||||
|
||||
use crate::{server::actor::messages::ValidAppConfig, tui::components::ConfiguredAppWidget};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct State {
|
||||
last_update: Instant,
|
||||
started: Instant,
|
||||
pub mode: ServerState,
|
||||
}
|
||||
impl State {
|
||||
pub fn initial() -> Self {
|
||||
Self {
|
||||
last_update: Instant::now(),
|
||||
started: Instant::now(),
|
||||
mode: ServerState::Initial { tick: 0 },
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tap(&mut self) {
|
||||
self.last_update = Instant::now();
|
||||
if let ServerState::Initial { tick } = &mut self.mode {
|
||||
*tick += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn beating_heart(&self) -> String {
|
||||
if self.last_update.duration_since(self.started).as_secs() % 2 == 0 {
|
||||
"💚 "
|
||||
} else {
|
||||
" 💚"
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn time() -> String {
|
||||
chrono::Local::now().format("%H:%M").to_string()
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ServerState {
|
||||
/// UI has started but has no information on the state of the server
|
||||
Initial { tick: usize }, // NOTE: for use with throbber-widgets-tui ?
|
||||
|
||||
/// The application configuration has been loaded, individual forges and repos have their own
|
||||
/// states
|
||||
Configured {
|
||||
forges: BTreeMap<ForgeAlias, ForgeState>,
|
||||
},
|
||||
}
|
||||
impl ServerState {
|
||||
pub fn update_branches(
|
||||
&mut self,
|
||||
forge_alias: &ForgeAlias,
|
||||
repo_alias: &RepoAlias,
|
||||
branches: RepoBranches,
|
||||
) {
|
||||
if let Self::Configured { forges } = self {
|
||||
let Some(forge_state) = forges.get_mut(forge_alias) else {
|
||||
return;
|
||||
};
|
||||
let Some(repo_state) = forge_state.repos.get_mut(repo_alias) else {
|
||||
return;
|
||||
};
|
||||
match repo_state {
|
||||
RepoState::Configured {
|
||||
branches: state_branches,
|
||||
..
|
||||
}
|
||||
| RepoState::Ready {
|
||||
branches: state_branches,
|
||||
..
|
||||
} => *state_branches = branches,
|
||||
|
||||
RepoState::Identified { .. } => (),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_log(&mut self, forge_alias: &ForgeAlias, repo_alias: &RepoAlias, log: Log) {
|
||||
if let Self::Configured { forges } = self {
|
||||
let Some(forge_state) = forges.get_mut(forge_alias) else {
|
||||
return;
|
||||
};
|
||||
let Some(repo_state) = forge_state.repos.get_mut(repo_alias) else {
|
||||
return;
|
||||
};
|
||||
match repo_state {
|
||||
RepoState::Ready { log: state_log, .. } => *state_log = log,
|
||||
|
||||
RepoState::Identified { .. } | RepoState::Configured { .. } => (),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
impl From<ValidAppConfig> for ServerState {
|
||||
fn from(app_config: ValidAppConfig) -> Self {
|
||||
Self::Configured {
|
||||
forges: app_config
|
||||
.app_config
|
||||
.forges()
|
||||
.map(|(forge_alias, config)| {
|
||||
(
|
||||
forge_alias,
|
||||
config
|
||||
.repos()
|
||||
.map(|(repo_alias, server_repo_config)| {
|
||||
(repo_alias, server_repo_config.repo_config())
|
||||
})
|
||||
.map(
|
||||
|(repo_alias, option_repo_config)| match option_repo_config {
|
||||
Some(rc) => (
|
||||
repo_alias.clone(),
|
||||
RepoState::Configured {
|
||||
repo_alias,
|
||||
message: RepoMessage::builder()
|
||||
.text("configured".into())
|
||||
.style(Style::default().fg(Color::LightGreen))
|
||||
.build(),
|
||||
alert: None,
|
||||
branches: rc.branches().clone(),
|
||||
log: git::graph::Log::default(),
|
||||
},
|
||||
),
|
||||
None => (
|
||||
repo_alias.clone(),
|
||||
RepoState::Identified {
|
||||
repo_alias,
|
||||
message: RepoMessage::builder()
|
||||
.text("identified".into())
|
||||
.style(Style::default().fg(Color::Gray))
|
||||
.build(),
|
||||
alert: None,
|
||||
},
|
||||
),
|
||||
},
|
||||
)
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
})
|
||||
.map(|(forge_alias, vec_repo_alias_state)| {
|
||||
let forge_state: ForgeState = ForgeState {
|
||||
alias: forge_alias.clone(),
|
||||
view_state: ViewState::default(),
|
||||
repos: vec_repo_alias_state.into_iter().collect::<BTreeMap<_, _>>(),
|
||||
};
|
||||
(forge_alias, forge_state)
|
||||
})
|
||||
.collect::<BTreeMap<_, _>>(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub enum ViewState {
|
||||
Collapsed,
|
||||
#[default]
|
||||
Expanded,
|
||||
}
|
||||
impl Display for ViewState {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let view_state = match self {
|
||||
Self::Collapsed => "+",
|
||||
Self::Expanded => "-",
|
||||
};
|
||||
write!(f, "{view_state}")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct ForgeState {
|
||||
pub alias: ForgeAlias,
|
||||
pub view_state: ViewState,
|
||||
pub repos: BTreeMap<RepoAlias, RepoState>,
|
||||
}
|
||||
|
||||
#[bon::builder]
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RepoMessage {
|
||||
text: String,
|
||||
style: Style,
|
||||
}
|
||||
impl From<&RepoMessage> for Span<'_> {
|
||||
fn from(value: &RepoMessage) -> Self {
|
||||
Self::default()
|
||||
.content(value.text.clone())
|
||||
.style(value.style)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum RepoState {
|
||||
Identified {
|
||||
repo_alias: RepoAlias,
|
||||
message: RepoMessage,
|
||||
alert: Option<String>,
|
||||
},
|
||||
Configured {
|
||||
repo_alias: RepoAlias,
|
||||
message: RepoMessage,
|
||||
alert: Option<String>,
|
||||
branches: RepoBranches,
|
||||
log: Log,
|
||||
},
|
||||
Ready {
|
||||
repo_alias: RepoAlias,
|
||||
message: RepoMessage,
|
||||
alert: Option<String>,
|
||||
branches: RepoBranches,
|
||||
view_state: ViewState,
|
||||
main: Commit,
|
||||
next: Commit,
|
||||
dev: Commit,
|
||||
log: Log,
|
||||
},
|
||||
}
|
||||
impl RepoState {
|
||||
#[tracing::instrument]
|
||||
pub fn update_branches(&mut self, branches: RepoBranches) {
|
||||
match self {
|
||||
Self::Configured {
|
||||
branches: state_branches,
|
||||
..
|
||||
}
|
||||
| Self::Ready {
|
||||
branches: state_branches,
|
||||
..
|
||||
} => {
|
||||
*state_branches = branches;
|
||||
}
|
||||
|
||||
Self::Identified { .. } => (),
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub fn update_log(&mut self, log: Log) {
|
||||
match self {
|
||||
Self::Configured { log: state_log, .. } | Self::Ready { log: state_log, .. } => {
|
||||
*state_log = log;
|
||||
}
|
||||
|
||||
Self::Identified { .. } => {
|
||||
info!("git graph log ignored by ui");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub fn update_message(&mut self, msg: impl Into<String> + std::fmt::Debug, colour: Color) {
|
||||
match self {
|
||||
Self::Identified { message, .. }
|
||||
| Self::Configured { message, .. }
|
||||
| Self::Ready { message, .. } => {
|
||||
info!(?msg, "updating ui");
|
||||
*message = RepoMessage::builder()
|
||||
.text(msg.into())
|
||||
.style(Style::default().fg(colour))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub fn clear_alert(&mut self) {
|
||||
match self {
|
||||
Self::Identified { alert, .. }
|
||||
| Self::Configured { alert, .. }
|
||||
| Self::Ready { alert, .. } => {
|
||||
*alert = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub fn alert(&mut self, msg: impl Into<String> + std::fmt::Debug) {
|
||||
let msg: String = msg.into();
|
||||
tracing::info!(%msg, "new tui alert");
|
||||
self.update_message("ALERT", Color::Red);
|
||||
match self {
|
||||
Self::Identified { alert, .. }
|
||||
| Self::Configured { alert, .. }
|
||||
| Self::Ready { alert, .. } => *alert = Some(msg),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ready(self, main: Commit, next: Commit, dev: Commit) -> Self {
|
||||
match self {
|
||||
Self::Identified {
|
||||
repo_alias,
|
||||
message,
|
||||
alert,
|
||||
} => Self::Identified {
|
||||
repo_alias,
|
||||
message,
|
||||
alert,
|
||||
},
|
||||
Self::Configured {
|
||||
repo_alias,
|
||||
message,
|
||||
alert,
|
||||
branches,
|
||||
log,
|
||||
} => Self::Ready {
|
||||
repo_alias,
|
||||
message,
|
||||
alert,
|
||||
branches,
|
||||
view_state: ViewState::Expanded,
|
||||
main,
|
||||
next,
|
||||
dev,
|
||||
log,
|
||||
},
|
||||
Self::Ready {
|
||||
repo_alias,
|
||||
message,
|
||||
alert,
|
||||
branches,
|
||||
view_state,
|
||||
log,
|
||||
.. // drop existing main, next and dev to use parameters
|
||||
} => Self::Ready {
|
||||
repo_alias,
|
||||
message,
|
||||
alert,
|
||||
branches,
|
||||
view_state,
|
||||
main,
|
||||
next,
|
||||
dev,
|
||||
log,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl StatefulWidget for &State {
|
||||
type State = ScrollViewState;
|
||||
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State)
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
let block = Block::bordered()
|
||||
.title_top(
|
||||
Line::from(format!(" Git-Next v{} ", clap::crate_version!()).bold())
|
||||
.alignment(Alignment::Center),
|
||||
)
|
||||
.title_bottom(
|
||||
Line::from(vec![
|
||||
" [q]uit ".into(),
|
||||
self.beating_heart().into(),
|
||||
" ".into(),
|
||||
])
|
||||
.alignment(Alignment::Center),
|
||||
)
|
||||
.title_bottom(Line::from(format!(" {} ", time())).alignment(Alignment::Right))
|
||||
.border_set(border::THICK);
|
||||
let interior = block.inner(area);
|
||||
block.render(area, buf);
|
||||
match &self.mode {
|
||||
ServerState::Initial { tick } => Paragraph::new(format!("Loading...{tick}"))
|
||||
.centered()
|
||||
.render(interior, buf),
|
||||
ServerState::Configured { forges } => {
|
||||
ConfiguredAppWidget { forges }.render(interior, buf, state);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
99
crates/cli/src/tui/actor/tests.rs
Normal file
99
crates/cli/src/tui/actor/tests.rs
Normal file
|
@ -0,0 +1,99 @@
|
|||
//
|
||||
mod model {
|
||||
mod repo_state {
|
||||
use git_next_core::{git::graph::Log, RepoBranches};
|
||||
use ratatui::style::Style;
|
||||
|
||||
use crate::{
|
||||
repo::tests::given,
|
||||
tui::actor::{RepoMessage, RepoState, ViewState},
|
||||
};
|
||||
type Alert = Option<String>;
|
||||
|
||||
fn identified_with_alert(alert: Alert) -> RepoState {
|
||||
RepoState::Identified {
|
||||
repo_alias: given::a_repo_alias(),
|
||||
message: RepoMessage::builder()
|
||||
.text(given::a_name())
|
||||
.style(Style::default())
|
||||
.build(),
|
||||
alert,
|
||||
}
|
||||
}
|
||||
|
||||
fn configured_with_alert(alert: Alert) -> RepoState {
|
||||
RepoState::Configured {
|
||||
repo_alias: given::a_repo_alias(),
|
||||
message: RepoMessage::builder()
|
||||
.text(given::a_name())
|
||||
.style(Style::default())
|
||||
.build(),
|
||||
alert,
|
||||
branches: RepoBranches::new(String::new(), String::new(), String::new()),
|
||||
log: Log::default(),
|
||||
}
|
||||
}
|
||||
fn ready_with_alert(alert: Alert) -> RepoState {
|
||||
RepoState::Ready {
|
||||
repo_alias: given::a_repo_alias(),
|
||||
message: RepoMessage::builder()
|
||||
.text(given::a_name())
|
||||
.style(Style::default())
|
||||
.build(),
|
||||
alert,
|
||||
branches: RepoBranches::new(String::new(), String::new(), String::new()),
|
||||
log: Log::default(),
|
||||
view_state: ViewState::default(),
|
||||
main: given::a_commit(),
|
||||
next: given::a_commit(),
|
||||
dev: given::a_commit(),
|
||||
}
|
||||
}
|
||||
|
||||
#[rstest::rstest]
|
||||
#[case(identified_with_alert(None))]
|
||||
#[case(configured_with_alert(None))]
|
||||
#[case(ready_with_alert(None))]
|
||||
fn none_alert_remains_none(#[case] mut repo_state: RepoState) {
|
||||
// given
|
||||
match &repo_state {
|
||||
RepoState::Identified { alert, .. }
|
||||
| RepoState::Configured { alert, .. }
|
||||
| RepoState::Ready { alert, .. } => {
|
||||
assert!(alert.is_none(), "should be none at start");
|
||||
}
|
||||
}
|
||||
// when
|
||||
repo_state.clear_alert();
|
||||
// then
|
||||
match &repo_state {
|
||||
RepoState::Identified { alert, .. }
|
||||
| RepoState::Configured { alert, .. }
|
||||
| RepoState::Ready { alert, .. } => assert!(alert.is_none(), "should remain none"),
|
||||
}
|
||||
}
|
||||
|
||||
#[rstest::rstest]
|
||||
#[case(identified_with_alert(Some(String::new())))]
|
||||
#[case(configured_with_alert(Some(String::new())))]
|
||||
#[case(ready_with_alert(Some(String::new())))]
|
||||
fn some_alert_becomes_none(#[case] mut repo_state: RepoState) {
|
||||
// given
|
||||
match &repo_state {
|
||||
RepoState::Identified { alert, .. }
|
||||
| RepoState::Configured { alert, .. }
|
||||
| RepoState::Ready { alert, .. } => {
|
||||
assert!(alert.is_some(), "should be some at start");
|
||||
}
|
||||
}
|
||||
// when
|
||||
repo_state.clear_alert();
|
||||
// then
|
||||
match &repo_state {
|
||||
RepoState::Identified { alert, .. }
|
||||
| RepoState::Configured { alert, .. }
|
||||
| RepoState::Ready { alert, .. } => assert!(alert.is_none(), "should become none"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue