forked from kemitix/git-next
Compare commits
318 commits
Author | SHA1 | Date | |
---|---|---|---|
bf6b4fcd21 | |||
614e721b91 | |||
|
0c8566a4a0 | ||
|
d3dfedc95b | ||
|
ea264aaf12 | ||
|
d2a93bc004 | ||
|
f908011503 | ||
|
eabf97dff8 | ||
c9d853797e | |||
|
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 |
273 changed files with 21178 additions and 6043 deletions
|
@ -1,17 +1,7 @@
|
|||
# ./cargo/config
|
||||
[target.x86_64-unknown-linux-gnu]
|
||||
linker = "/usr/bin/clang-16"
|
||||
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.4.1
|
||||
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.4.1
|
||||
with:
|
||||
args: release-plz release --backend gitea --git-token ${{ secrets.FORGEJO_TOKEN }}
|
||||
env:
|
||||
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
|
|
@ -13,26 +13,40 @@ jobs:
|
|||
build:
|
||||
runs-on: docker
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
toolchain:
|
||||
- name: stable
|
||||
- name: nightly
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Format
|
||||
uses: https://git.kemitix.net/kemitix/rust@v0.2.7
|
||||
- name: Check TODOs
|
||||
uses: kemitix/todo-checker@v1.1.0
|
||||
|
||||
- name: Machete
|
||||
uses: https://git.kemitix.net/kemitix/rust@v2.4.1
|
||||
with:
|
||||
args: fmt --all -- --check
|
||||
args: ${{ matrix.toolchain.name }} cargo machete
|
||||
|
||||
- name: Format
|
||||
uses: https://git.kemitix.net/kemitix/rust@v2.4.1
|
||||
with:
|
||||
args: ${{ matrix.toolchain.name }} cargo fmt --all -- --check
|
||||
|
||||
- name: Clippy
|
||||
uses: https://git.kemitix.net/kemitix/rust@v0.2.7
|
||||
uses: https://git.kemitix.net/kemitix/rust@v2.4.1
|
||||
with:
|
||||
args: clippy -- -D warnings
|
||||
args: ${{ matrix.toolchain.name }} cargo hack --feature-powerset clippy
|
||||
|
||||
- name: Build
|
||||
uses: https://git.kemitix.net/kemitix/rust@v0.2.7
|
||||
uses: https://git.kemitix.net/kemitix/rust@v2.4.1
|
||||
with:
|
||||
args: build
|
||||
args: ${{ matrix.toolchain.name }} cargo hack --feature-powerset build
|
||||
|
||||
- name: Test
|
||||
uses: https://git.kemitix.net/kemitix/rust@v0.2.7
|
||||
uses: https://git.kemitix.net/kemitix/rust@v2.4.1
|
||||
with:
|
||||
args: test
|
||||
args: ${{ matrix.toolchain.name }} cargo hack --feature-powerset test
|
||||
|
|
|
@ -1,6 +0,0 @@
|
|||
[branches]
|
||||
main = "main"
|
||||
next = "next"
|
||||
dev = "dev"
|
||||
|
||||
[options]
|
8
.gitignore
vendored
8
.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
|
||||
|
@ -23,3 +26,6 @@ data/
|
|||
.git-next.toml
|
||||
.envrc
|
||||
*.profraw
|
||||
|
||||
mutants.out/
|
||||
mutants.out.old/
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
steps:
|
||||
todo_check:
|
||||
# INFO: This doesn't have an equivalent yet for Forgejo Actions
|
||||
# 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
|
||||
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.2
|
||||
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
|
||||
|
|
615
CHANGELOG.md
615
CHANGELOG.md
|
@ -2,8 +2,622 @@
|
|||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## `git-next-core` - [0.13.11](https://git.kemitix.net/kemitix/git-next/compare/git-next-core-v0.13.10...git-next-core-v0.13.11) - 2024-09-14
|
||||
|
||||
### Added
|
||||
- should fetch repo on startup when not cloning
|
||||
- Remove branches when fetching from remote
|
||||
|
||||
### Other
|
||||
- reimplement git fetch using git
|
||||
|
||||
## `git-next` - [0.13.11](https://git.kemitix.net/kemitix/git-next/compare/v0.13.10...v0.13.11) - 2024-09-14
|
||||
|
||||
### Added
|
||||
- *(tui)* add time and version in border
|
||||
- should fetch repo on startup when not cloning
|
||||
- Remove branches when fetching from remote
|
||||
|
||||
### Other
|
||||
- Update TUI sooner when receiving CI status
|
||||
- reimplement git fetch using git
|
||||
- mark tui as complete on roadmap
|
||||
- Add missing port mapping parameter for running in docker
|
||||
|
||||
## `git-next-forge-github` - [0.13.10](https://git.kemitix.net/kemitix/git-next/compare/git-next-forge-github-v0.13.9...git-next-forge-github-v0.13.10) - 2024-09-12
|
||||
|
||||
### Added
|
||||
- optionally specify max commits between dev and main
|
||||
|
||||
## `git-next-forge-forgejo` - [0.13.10](https://git.kemitix.net/kemitix/git-next/compare/git-next-forge-forgejo-v0.13.9...git-next-forge-forgejo-v0.13.10) - 2024-09-12
|
||||
|
||||
### Added
|
||||
- optionally specify max commits between dev and main
|
||||
|
||||
## `git-next-core` - [0.13.10](https://git.kemitix.net/kemitix/git-next/compare/git-next-core-v0.13.9...git-next-core-v0.13.10) - 2024-09-12
|
||||
|
||||
### Added
|
||||
- optionally specify max commits between dev and main
|
||||
|
||||
## `git-next` - [0.13.10](https://git.kemitix.net/kemitix/git-next/compare/v0.13.9...v0.13.10) - 2024-09-12
|
||||
|
||||
### Added
|
||||
- optionally specify max commits between dev and main
|
||||
|
||||
### Fixed
|
||||
- *(tui)* make tui work from docker image
|
||||
- *(tui)* alerts, such as WIP aren't being reset
|
||||
- *(test)* tests requiring .git pass when not present
|
||||
- *(tui)* update ui when push next or main finishes
|
||||
- *(tui)* don't set background for normal repo alias
|
||||
|
||||
## `git-next` - [0.13.9](https://git.kemitix.net/kemitix/git-next/compare/v0.13.8...v0.13.9) - 2024-09-04
|
||||
|
||||
### Fixed
|
||||
- *(tui)* alerts are cleared on next repo update
|
||||
- shutdown properly on error
|
||||
- shutdown properly on file parse error
|
||||
|
||||
### Other
|
||||
- Expand docker docmentation
|
||||
|
||||
## `git-next-forge-forgejo` - [0.13.8](https://git.kemitix.net/kemitix/git-next/compare/git-next-forge-forgejo-v0.13.7...git-next-forge-forgejo-v0.13.8) - 2024-09-01
|
||||
|
||||
### Other
|
||||
- flatten nested blocks with early returns
|
||||
- rename method as peel
|
||||
|
||||
## `git-next-core` - [0.13.8](https://git.kemitix.net/kemitix/git-next/compare/git-next-core-v0.13.7...git-next-core-v0.13.8) - 2024-09-01
|
||||
|
||||
### Fixed
|
||||
- use configured branch names in user notification
|
||||
- create git graph log to after doing a fetch
|
||||
|
||||
### Other
|
||||
- flatten nested blocks with early returns
|
||||
- rename method as peel
|
||||
|
||||
## `git-next` - [0.13.8](https://git.kemitix.net/kemitix/git-next/compare/v0.13.7...v0.13.8) - 2024-09-01
|
||||
|
||||
### Added
|
||||
- improved error display when startup fails
|
||||
- *(tui)* clean up alert display
|
||||
- *(tui)* remove some borders to clean up appearance
|
||||
- *(tui)* make progression of branches clearer
|
||||
- *(tui)* remove label from repo identity widget
|
||||
- *(tui)* hightlight repo alias in red when in alert
|
||||
- *(tui)* branch names look more like 'pills'
|
||||
- *(tui)* highlight branchs in log
|
||||
- *(tui)* hightlight status message in colour
|
||||
- *(tui)* use moving heart emoji as liveness indicator
|
||||
- *(tui)* add scrolling when overflow screen
|
||||
- *(tui)* forge widgets only use required lines
|
||||
- *(tui)* repo widgets only use required lines
|
||||
- *(tui)* move forge alias to left and add prefix
|
||||
- *(tui)* remove count of forges
|
||||
- *(tui)* remove duplicate messages from repo body
|
||||
- *(tui)* highlight user interventions in red
|
||||
|
||||
### Fixed
|
||||
- use configured branch names in user notification
|
||||
- remove unused imports
|
||||
- *(tui)* remove logging from inside ui loop
|
||||
- *(tui)* don't show HEAD in log
|
||||
- *(tui)* improve colour contrast on light background
|
||||
- *(tui)* remove unused import
|
||||
- *(alert)* typo in email message
|
||||
- *(repo)* avoid blocking threads when pausing
|
||||
- *(test)* give actix more time to process message
|
||||
- *(test)* give actix more time to process message
|
||||
- *(test)* give actix more time to process message
|
||||
- *(tui)* improve reliability of status updates
|
||||
- create git graph log to after doing a fetch
|
||||
- *(tui)* remove logging of tui updates
|
||||
|
||||
### Other
|
||||
- flatten nested blocks with early returns
|
||||
- merge identical match branches
|
||||
- *(tui)* add regex dependency
|
||||
- *(tui)* introduce LogLine to wrap log formatting
|
||||
- *(tui)* simplify repo identity widget
|
||||
- rename method as peel
|
||||
- *(tui)* child widget can provide constraint to container
|
||||
- *(tui)* merge repo widgets into one
|
||||
|
||||
## `git-next-core` - [0.13.7](https://git.kemitix.net/kemitix/git-next/compare/git-next-core-v0.13.6...git-next-core-v0.13.7) - 2024-08-25
|
||||
|
||||
### Added
|
||||
- *(tui)* (experimental) show repo state, messages and git log
|
||||
|
||||
## `git-next` - [0.13.7](https://git.kemitix.net/kemitix/git-next/compare/v0.13.6...v0.13.7) - 2024-08-25
|
||||
|
||||
### Added
|
||||
- *(tui)* (experimental) show repo state, messages and git log
|
||||
|
||||
## `git-next-forge-github` - [0.13.6](https://git.kemitix.net/kemitix/git-next/compare/git-next-forge-github-v0.13.5...git-next-forge-github-v0.13.6) - 2024-08-23
|
||||
|
||||
### Fixed
|
||||
- *(github)* register webhook with valid callback url
|
||||
|
||||
## `git-next-core` - [0.13.6](https://git.kemitix.net/kemitix/git-next/compare/git-next-core-v0.13.5...git-next-core-v0.13.6) - 2024-08-23
|
||||
|
||||
### Added
|
||||
- *(tui)* (experimental) tui option
|
||||
|
||||
## `git-next` - [0.13.6](https://git.kemitix.net/kemitix/git-next/compare/v0.13.5...v0.13.6) - 2024-08-23
|
||||
|
||||
### Added
|
||||
- *(tui)* (experimental) tui option
|
||||
|
||||
### Fixed
|
||||
- file_watcher runs on own thread
|
||||
|
||||
### Other
|
||||
- test all feature combinations
|
||||
|
||||
## `git-next` - [0.13.5](https://git.kemitix.net/kemitix/git-next/compare/git-next-v0.13.4...git-next-v0.13.5) - 2024-08-10
|
||||
|
||||
### Added
|
||||
- make forge and repo alias more prominent in email
|
||||
|
||||
### Fixed
|
||||
- invalid config section typo in README
|
||||
|
||||
## `git-next-forge-github` - [0.13.4](https://git.kemitix.net/kemitix/git-next/compare/git-next-forge-github-v0.13.3...git-next-forge-github-v0.13.4) - 2024-08-08
|
||||
|
||||
### Other
|
||||
- cleanup pedantic clippy in forge-github crate
|
||||
|
||||
## `git-next-forge-forgejo` - [0.13.4](https://git.kemitix.net/kemitix/git-next/compare/git-next-forge-forgejo-v0.13.3...git-next-forge-forgejo-v0.13.4) - 2024-08-08
|
||||
|
||||
### Other
|
||||
- cleanup pedantic clippy in forge-forgejo crate
|
||||
|
||||
## `git-next-core` - [0.13.4](https://git.kemitix.net/kemitix/git-next/compare/git-next-core-v0.13.3...git-next-core-v0.13.4) - 2024-08-08
|
||||
|
||||
### Added
|
||||
- add short git log graph to notifications
|
||||
|
||||
### Other
|
||||
- macros use a more common syntax
|
||||
- cleanup pedantic clippy in core crate
|
||||
|
||||
## `git-next` - [0.13.4](https://git.kemitix.net/kemitix/git-next/compare/v0.13.3...v0.13.4) - 2024-08-08
|
||||
|
||||
### Added
|
||||
- add short git log graph to notifications
|
||||
|
||||
### Fixed
|
||||
- remove dependcy on clang & mold
|
||||
|
||||
### Other
|
||||
- macros use a more common syntax
|
||||
- cleanup pedantic clippy in core crate
|
||||
- cleanup pedantic clippy in cli crate
|
||||
|
||||
## `git-next-core` - [0.13.3](https://git.kemitix.net/kemitix/git-next/compare/git-next-core-v0.13.2...git-next-core-v0.13.3) - 2024-08-04
|
||||
|
||||
### Fixed
|
||||
- shout.desktop should be optional
|
||||
|
||||
## `git-next` - [0.13.3](https://git.kemitix.net/kemitix/git-next/compare/v0.13.2...v0.13.3) - 2024-08-04
|
||||
|
||||
### Fixed
|
||||
- shout.desktop should be optional
|
||||
|
||||
## `git-next` - [0.13.2](https://git.kemitix.net/kemitix/git-next/compare/v0.13.1...v0.13.2) - 2024-08-04
|
||||
|
||||
### Other
|
||||
- timing test waits longer than expiry
|
||||
|
||||
## `git-next-forge-github` - [0.13.1](https://git.kemitix.net/kemitix/git-next/compare/git-next-forge-github-v0.13.0...git-next-forge-github-v0.13.1) - 2024-08-04
|
||||
|
||||
### Other
|
||||
|
||||
- remove unused dependencies
|
||||
|
||||
## `git-next-forge-forgejo` - [0.13.1](https://git.kemitix.net/kemitix/git-next/compare/git-next-forge-forgejo-v0.13.0...git-next-forge-forgejo-v0.13.1) - 2024-08-04
|
||||
|
||||
### Other
|
||||
|
||||
- remove unused dependencies
|
||||
|
||||
## `git-next-core` - [0.13.1](https://git.kemitix.net/kemitix/git-next/compare/git-next-core-v0.13.0...git-next-core-v0.13.1) - 2024-08-04
|
||||
|
||||
### Added
|
||||
|
||||
- prevent duplicate alerts
|
||||
- add support for desktop notifications
|
||||
|
||||
### Other
|
||||
|
||||
- remove unused dependencies
|
||||
- update tests to check for email config parsing
|
||||
|
||||
## `git-next` - [0.13.1](https://git.kemitix.net/kemitix/git-next/compare/v0.13.0...v0.13.1) - 2024-08-04
|
||||
|
||||
### Added
|
||||
|
||||
- prevent duplicate alerts
|
||||
- add support for desktop notifications
|
||||
|
||||
### Fixed
|
||||
|
||||
- add example email config to server default template
|
||||
|
||||
### Other
|
||||
|
||||
- remove unused dependencies
|
||||
- extract alerts into own actor
|
||||
- add example to readme for listen, shout & storage
|
||||
- add config details for sending emails
|
||||
|
||||
## `git-next-forge-github` [0.13.0](https://git.kemitix.net/kemitix/git-next/compare/git-next-forge-github-v0.12.1...git-next-forge-github-v0.13.0) - 2024-08-02
|
||||
|
||||
### Added
|
||||
|
||||
- [**breaking**] restructured server config into listen & shout sections
|
||||
|
||||
## `git-next-forge-forgejo` [0.13.0](https://git.kemitix.net/kemitix/git-next/compare/git-next-forge-forgejo-v0.12.1...git-next-forge-forgejo-v0.13.0) - 2024-08-02
|
||||
|
||||
### Added
|
||||
|
||||
- [**breaking**] restructured server config into listen & shout sections
|
||||
|
||||
## `git-next-core` [0.13.0](https://git.kemitix.net/kemitix/git-next/compare/git-next-core-v0.12.1...git-next-core-v0.13.0) - 2024-08-02
|
||||
|
||||
### Added
|
||||
|
||||
- send email notifications (sendmail/smtp)
|
||||
- [**breaking**] restructured server config into listen & shout sections
|
||||
- remove notification.type
|
||||
- [**breaking**] reduce the max commit dev can be ahead of main
|
||||
|
||||
## `git-next` [0.13.0](https://git.kemitix.net/kemitix/git-next/compare/git-next-v0.12.1...git-next-v0.13.0) - 2024-08-02
|
||||
|
||||
### Added
|
||||
|
||||
- send email notifications (sendmail/smtp)
|
||||
- [**breaking**] restructured server config into listen & shout sections
|
||||
- remove notification.type
|
||||
- terminate process if config file is invalid
|
||||
- return better errors to user on server failure
|
||||
- return better errors to the user on init
|
||||
|
||||
## [0.12.1] - 2024-07-29
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Webhook secret doesn't need to be base64 encoded ([691a733](https://git.kemitix.net/kemitix/git-next/commit/691a733fc37cfba5d9be72b57e24c5b9d3c1218a))
|
||||
- Remove requirement for RUSTFLAGS to be set ([e56d6a3](https://git.kemitix.net/kemitix/git-next/commit/e56d6a3ebbb4b4bfcaacc986269ba898ffbd1bc6))
|
||||
- Make default server config example valid ([b7abe94](https://git.kemitix.net/kemitix/git-next/commit/b7abe949e2067e1c3663d45a520385d967f19af8))
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- Update create publishing command ([bf12712](https://git.kemitix.net/kemitix/git-next/commit/bf12712bcaaefe6ae7da113e03b739b42d860fcf))
|
||||
- Remove deprecated crates ([5dc0de8](https://git.kemitix.net/kemitix/git-next/commit/5dc0de8a05d610c3a5b7be00aac1033763a76949))
|
||||
|
||||
## [0.12.0] - 2024-07-28
|
||||
|
||||
[656ec4a](https://git.kemitix.net/kemitix/git-next/commit/656ec4a534b5b55ddceb05eee6ed610207ac33d4)...[b89431b](https://git.kemitix.net/kemitix/git-next/commit/b89431b7798dec0ab80010d76327bef89b94eeb0)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Don't log content of internal messages ([3ae1132](https://git.kemitix.net/kemitix/git-next/commit/3ae113212af3ee43f36383a22984e03e3f44f3f2))
|
||||
|
||||
### Documentation
|
||||
|
||||
- Add missing readme for git-next-core ([11de4ef](https://git.kemitix.net/kemitix/git-next/commit/11de4efae6e8e460f93ba05e91278d9239d98c9c))
|
||||
- Add missing notification config details ([991d0d1](https://git.kemitix.net/kemitix/git-next/commit/991d0d1a08c9730942d53313f9015f8f610dc8bb))
|
||||
|
||||
### Features
|
||||
|
||||
- Support macOS ([a56c6df](https://git.kemitix.net/kemitix/git-next/commit/a56c6df3f1ad8943185941ca733a4d91069994c1))
|
||||
- Avoid resetting next to main when dev is ahead of main ([d2ea93f](https://git.kemitix.net/kemitix/git-next/commit/d2ea93f05ec81f7b9af4e2a347fc0b324eb3770f))
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- Remove deprecated crates ([5a595ec](https://git.kemitix.net/kemitix/git-next/commit/5a595ec9eed77cf961f01c671c69ca2bc7988092))
|
||||
- Bump gix from 0.63 to 0.64 ([b24675d](https://git.kemitix.net/kemitix/git-next/commit/b24675d48a3e35a9d780a7f7f8cbfb1477765a7b))
|
||||
- Bump mockall from 0.12 to 0.13 ([22faa85](https://git.kemitix.net/kemitix/git-next/commit/22faa851dcdd99451c736290bc17b17cbe6aa55c))
|
||||
- Release 0.12.0 ([b89431b](https://git.kemitix.net/kemitix/git-next/commit/b89431b7798dec0ab80010d76327bef89b94eeb0))
|
||||
|
||||
### Refactor
|
||||
|
||||
- Merge server crate into cli crate ([1427284](https://git.kemitix.net/kemitix/git-next/commit/1427284c2a378d29246a7b92d4a5c5d9601793d0))
|
||||
- Merge server-actor crate into cli crate ([a679abe](https://git.kemitix.net/kemitix/git-next/commit/a679abeafcb624f400c33721b5828c5137d96fc6))
|
||||
- Merge file-watcher-crate into cli crate ([9ca532a](https://git.kemitix.net/kemitix/git-next/commit/9ca532a2b466b3a23e957a282e54c8985e0794d6))
|
||||
- Merge file-watcher-crate into cli crate ([366930b](https://git.kemitix.net/kemitix/git-next/commit/366930bcfcdb424e853bb8f81fdad0d719a50a69))
|
||||
- Merge webhook-actor crate into cli crate ([12ecc30](https://git.kemitix.net/kemitix/git-next/commit/12ecc308d559ed509da9db8016332c877efda3d0))
|
||||
- Merge repo-actor crate into cli crate ([c1981d8](https://git.kemitix.net/kemitix/git-next/commit/c1981d862c2da6a992475effe70061f56a67ff10))
|
||||
- Merge forge crate into cli crate ([5745817](https://git.kemitix.net/kemitix/git-next/commit/57458173d033936206d2225ec3b3b6fc8291229e))
|
||||
|
||||
## [0.11.0] - 2024-07-26
|
||||
|
||||
[f8fefcd](https://git.kemitix.net/kemitix/git-next/commit/f8fefcdeddf556b28dc611b85db8e2b5ffbb570d)...[656ec4a](https://git.kemitix.net/kemitix/git-next/commit/656ec4a534b5b55ddceb05eee6ed610207ac33d4)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Remove unused dependecy from file-watcher-actor ([b8f4ade](https://git.kemitix.net/kemitix/git-next/commit/b8f4adeb50a98e64efe2a1a9009c4d6a6b458e3b))
|
||||
|
||||
### Documentation
|
||||
|
||||
- Document Notifications to user ([1690e1b](https://git.kemitix.net/kemitix/git-next/commit/1690e1bff6a3b54ff59b0763ecc2e50c25f9b896))
|
||||
- Update message graph for repo-actor ([758ca5c](https://git.kemitix.net/kemitix/git-next/commit/758ca5c2dc9273be15cdfb383bdc35095bc7834e))
|
||||
- Update package graph ([768ec6a](https://git.kemitix.net/kemitix/git-next/commit/768ec6ae02fe7d850ff976d51aa3278c01ce1013))
|
||||
|
||||
### Features
|
||||
|
||||
- Enable configuration of a webhook for receiving notifications ([c86d890](https://git.kemitix.net/kemitix/git-next/commit/c86d890c2cbbbe87fde58664c68c91b698862044))
|
||||
- Support sending messages to the user ([e9877ca](https://git.kemitix.net/kemitix/git-next/commit/e9877ca9fa0addf3f018527712355ca0c3d9eb77))
|
||||
- Dispatch NotifyUser messages to server for user (1/2) ([bcf57bc](https://git.kemitix.net/kemitix/git-next/commit/bcf57bc728fd53f0abb9c4e94d9768fcce5e9dbe))
|
||||
- Dispatch NotifyUser messages to server for user (2/2) ([288c20c](https://git.kemitix.net/kemitix/git-next/commit/288c20c24b59b2fa5054c81c22d42af2af06afc7))
|
||||
- Post webhook notifications to user ([9e12f5e](https://git.kemitix.net/kemitix/git-next/commit/9e12f5eb5db5f3b150886b444af4c0ce3dbf2ed9))
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- Release 0.11.0 ([656ec4a](https://git.kemitix.net/kemitix/git-next/commit/656ec4a534b5b55ddceb05eee6ed610207ac33d4))
|
||||
|
||||
### Refactor
|
||||
|
||||
- Reduce cognitive complexity of `WebhookNotification` handler. 1/2 ([06292c2](https://git.kemitix.net/kemitix/git-next/commit/06292c2711f3aca6bc369b78f67e1936fdba7eb8))
|
||||
- Reduce cognitive complexity of `WebhookNotification` handler. 2/2 ([c104dfe](https://git.kemitix.net/kemitix/git-next/commit/c104dfedc1f41020b3468d73a52ae49e0050ebb2))
|
||||
- Reduce cognitive complexity of 'validate_position' ([92ebd45](https://git.kemitix.net/kemitix/git-next/commit/92ebd453076015993d25102d262a4821fe416e06))
|
||||
- Flag internally that dev not based on main will require used intervention ([ba67b1e](https://git.kemitix.net/kemitix/git-next/commit/ba67b1ebcba46308a44d3f6dccc16ed8b0acefe3))
|
||||
- Extract messages and handlers modules from webhook-actor ([8f95ae0](https://git.kemitix.net/kemitix/git-next/commit/8f95ae0058a9f426c5d3f8f96990f6b0eb358b9e))
|
||||
- Use Option<&T> over &Option<T> ([4978400](https://git.kemitix.net/kemitix/git-next/commit/4978400ece7c37ed51328da0667b2abb1b528fc7))
|
||||
- Merge actor-macros into core ([48c968d](https://git.kemitix.net/kemitix/git-next/commit/48c968db2d166942ba1be0f09f729d5611cedf18))
|
||||
- Merge config crate into core crate ([ab728c7](https://git.kemitix.net/kemitix/git-next/commit/ab728c7364caa0c8481cd2a10c3fa57bdc7f2d16))
|
||||
- Merge git create into core crate ([fa5fa80](https://git.kemitix.net/kemitix/git-next/commit/fa5fa809d99b70970d8f0f2f910afb99837e3913))
|
||||
|
||||
### Testing
|
||||
|
||||
- Restore unlinked test file ([2ec5ae1](https://git.kemitix.net/kemitix/git-next/commit/2ec5ae1d51b48198d0bb96ed5477e6e77f095f76))
|
||||
|
||||
## [0.10.0] - 2024-07-16
|
||||
|
||||
[41c8a31](https://git.kemitix.net/kemitix/git-next/commit/41c8a319b1344d2ce04bfa8f45eb9a267d8e9a3c)...[f8fefcd](https://git.kemitix.net/kemitix/git-next/commit/f8fefcdeddf556b28dc611b85db8e2b5ffbb570d)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Move server-default.toml inside crate that uses it ([639e561](https://git.kemitix.net/kemitix/git-next/commit/639e561be60a6e22eda14e2b44764eee6afb6ae7))
|
||||
- Move default.toml inside crate that uses it ([e2b545a](https://git.kemitix.net/kemitix/git-next/commit/e2b545ae396354cd009c12dc44daadac923f140b))
|
||||
|
||||
### Documentation
|
||||
|
||||
- Update installation instructions ([050e117](https://git.kemitix.net/kemitix/git-next/commit/050e1171b3b047bc5b5dfd22c1e8d8f4f76efaab))
|
||||
- Move main README into cli crate ([6981a7b](https://git.kemitix.net/kemitix/git-next/commit/6981a7b5e30c854ede6303958db9ab05600bca79))
|
||||
- Add readmes to each crate to direct users to main crate ([6c92f64](https://git.kemitix.net/kemitix/git-next/commit/6c92f64f8bcec3306ef13a22e91939f555a9c77d))
|
||||
- Add UnRegisterWebhook from RepoActor ([f44865f](https://git.kemitix.net/kemitix/git-next/commit/f44865fa92857c9c53c124e520a13cd10ce17a22))
|
||||
- Update link from root README to cli README ([619e1d5](https://git.kemitix.net/kemitix/git-next/commit/619e1d517d07297fc1e9e0d89fafb93e9136cc07))
|
||||
|
||||
### Features
|
||||
|
||||
- Unregister webhooks form forge during shutdown ([b715755](https://git.kemitix.net/kemitix/git-next/commit/b715755b91cecd8fa6b67a58ac3e6fd322c9c005))
|
||||
- Reload server config when file is touched ([33907a1](https://git.kemitix.net/kemitix/git-next/commit/33907a1d3284a2df27994f7da1ef65d3047f165f))
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- Restore clean check and tag checkout to publish script ([95129dd](https://git.kemitix.net/kemitix/git-next/commit/95129ddeefa26db7cb538f2be2ab5b3609e9a175))
|
||||
- Release 0.10.0 ([f8fefcd](https://git.kemitix.net/kemitix/git-next/commit/f8fefcdeddf556b28dc611b85db8e2b5ffbb570d))
|
||||
|
||||
### Build
|
||||
|
||||
- Add more metadata for crates.io ([69211a8](https://git.kemitix.net/kemitix/git-next/commit/69211a87a3aaba2c8e4037d5f1a8adbca185f13d))
|
||||
|
||||
## [0.9.4] - 2024-07-14
|
||||
|
||||
[d24bcd9](https://git.kemitix.net/kemitix/git-next/commit/d24bcd9ab1a31afe20501c6b6e0f08436683c1c2)...[41c8a31](https://git.kemitix.net/kemitix/git-next/commit/41c8a319b1344d2ce04bfa8f45eb9a267d8e9a3c)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Add missing version for workspace dependencies ([fa7f78c](https://git.kemitix.net/kemitix/git-next/commit/fa7f78c7347ea2cd7a1a854e8aa07acb881911b2))
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- Release 0.9.4 ([41c8a31](https://git.kemitix.net/kemitix/git-next/commit/41c8a319b1344d2ce04bfa8f45eb9a267d8e9a3c))
|
||||
|
||||
### Revert
|
||||
|
||||
- Fix: explicitly specify version in each crate ([adf56c1](https://git.kemitix.net/kemitix/git-next/commit/adf56c1b38f7ae397a1187302cead4864b3bddab))
|
||||
|
||||
## [0.9.3] - 2024-07-14
|
||||
|
||||
[59e8fc0](https://git.kemitix.net/kemitix/git-next/commit/59e8fc050d70db2779855f7d1d73e4cf00edd461)...[d24bcd9](https://git.kemitix.net/kemitix/git-next/commit/d24bcd9ab1a31afe20501c6b6e0f08436683c1c2)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Explicitly specify version in each crate ([cd93d04](https://git.kemitix.net/kemitix/git-next/commit/cd93d047cb948118f32ae0b8b0880a42a74226fb))
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- Release 0.9.3 ([d24bcd9](https://git.kemitix.net/kemitix/git-next/commit/d24bcd9ab1a31afe20501c6b6e0f08436683c1c2))
|
||||
|
||||
## [0.9.2] - 2024-07-14
|
||||
|
||||
[4c2e122](https://git.kemitix.net/kemitix/git-next/commit/4c2e1223467a3799506d9f44931aeec1d51cd26c)...[59e8fc0](https://git.kemitix.net/kemitix/git-next/commit/59e8fc050d70db2779855f7d1d73e4cf00edd461)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Typo and missing repository entry in Cargo.toml files ([c289617](https://git.kemitix.net/kemitix/git-next/commit/c289617ba9d530fc04bb197745b75e0c852a7711))
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- Release 0.9.2 ([59e8fc0](https://git.kemitix.net/kemitix/git-next/commit/59e8fc050d70db2779855f7d1d73e4cf00edd461))
|
||||
|
||||
## [0.9.1] - 2024-07-14
|
||||
|
||||
[43c6e81](https://git.kemitix.net/kemitix/git-next/commit/43c6e812dc611a2538b45b48e2014e42ef492904)...[4c2e122](https://git.kemitix.net/kemitix/git-next/commit/4c2e1223467a3799506d9f44931aeec1d51cd26c)
|
||||
|
||||
### Documentation
|
||||
|
||||
- Server-actor: add readme showing message paths ([10e6389](https://git.kemitix.net/kemitix/git-next/commit/10e63894c215e90610e79a2950d3bd0b20f1a04b))
|
||||
- Update changelog ([4c2e122](https://git.kemitix.net/kemitix/git-next/commit/4c2e1223467a3799506d9f44931aeec1d51cd26c))
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- Simplify workspace.members specification ([19d1f77](https://git.kemitix.net/kemitix/git-next/commit/19d1f770659e12bb6dc9733ebb1d134b96320898))
|
||||
- Add license and descriptions for each crate ([e410cfc](https://git.kemitix.net/kemitix/git-next/commit/e410cfc4f187e77dbd323bd45c6fff1344aa5d0f))
|
||||
- Release 0.9.1 ([fe23d3f](https://git.kemitix.net/kemitix/git-next/commit/fe23d3fe0aa2d6486024de15ebc6efe3f98faff9))
|
||||
|
||||
### Build
|
||||
|
||||
- Add publish-to-crates-io workflow ([9d11bb0](https://git.kemitix.net/kemitix/git-next/commit/9d11bb0e1fb97d67c5c734ffcfb6d1c48eb5d291))
|
||||
- Add script to publish to crates.io ([0c7a060](https://git.kemitix.net/kemitix/git-next/commit/0c7a0602118f4873a185396f2da4d6e596143ad9))
|
||||
- Disable broke publish workflow ([0981355](https://git.kemitix.net/kemitix/git-next/commit/0981355f28b0970f442f74386508e915e81a624e))
|
||||
|
||||
## [0.9.0] - 2024-07-12
|
||||
|
||||
[b0be0f6](https://git.kemitix.net/kemitix/git-next/commit/b0be0f636c2021d23448e4859f4ef8c3c58d2500)...[43c6e81](https://git.kemitix.net/kemitix/git-next/commit/43c6e812dc611a2538b45b48e2014e42ef492904)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Don't modify config of external repos ([57a614b](https://git.kemitix.net/kemitix/git-next/commit/57a614bad351c13788b6209635578b082abddb4d))
|
||||
|
||||
### Documentation
|
||||
|
||||
- Add roadmap to readme ([6a8d1bf](https://git.kemitix.net/kemitix/git-next/commit/6a8d1bf817b69766e15380e9f21679c5ea5d3c39))
|
||||
|
||||
### Features
|
||||
|
||||
- GitDir tracks when repo is cloned by git-next ([df35244](https://git.kemitix.net/kemitix/git-next/commit/df352443b7e990aecf15ca91b08fef510c391f22))
|
||||
- Update auth of interal repos when changed in config ([9c20e78](https://git.kemitix.net/kemitix/git-next/commit/9c20e780d02dea6ede51ace2ebcba033d5fbd8e3))
|
||||
- Log as an error when webhook url ends with a slash ([7578ab3](https://git.kemitix.net/kemitix/git-next/commit/7578ab31443a752c8f3ba792e782294e9518698c))
|
||||
- Perform controlled shutdown on ctrl-c ([fd762e2](https://git.kemitix.net/kemitix/git-next/commit/fd762e2bd2fa054988f7ff31a37fb9a1cf603fd0))
|
||||
- Recheck failed status ([5f36282](https://git.kemitix.net/kemitix/git-next/commit/5f36282667c8c2034f7259db0053d5561788047a))
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- Lint fix for Dockerfile ([cbf6c3b](https://git.kemitix.net/kemitix/git-next/commit/cbf6c3b73c04f844c30a26ade7b2ebd30d4c1e12))
|
||||
- Bump docker runtime os image ([f61c556](https://git.kemitix.net/kemitix/git-next/commit/f61c556f5bd5d7206657a1958df16398271fdccd))
|
||||
- Remove unused FakeOpenRepository ([d9feaea](https://git.kemitix.net/kemitix/git-next/commit/d9feaeaa7b06f7bdbf5988199a283eb6a7b4a6d9))
|
||||
- Bacon treats clippy warnings as errors ([56756ca](https://git.kemitix.net/kemitix/git-next/commit/56756cab707c261f5bc7bcbfaa8f4b75f043eb96))
|
||||
- Local dev used debug logging ([4252411](https://git.kemitix.net/kemitix/git-next/commit/425241196db84543be99dbd32acdbcaa6762a8fa))
|
||||
- Release 0.9.0 ([43c6e81](https://git.kemitix.net/kemitix/git-next/commit/43c6e812dc611a2538b45b48e2014e42ef492904))
|
||||
|
||||
### Refactor
|
||||
|
||||
- Split git::repository::open::tests module ([2e374d3](https://git.kemitix.net/kemitix/git-next/commit/2e374d317a1870ee6331484f0429f5faa6b3511b))
|
||||
- Split git::repository::tests module ([5ab075c](https://git.kemitix.net/kemitix/git-next/commit/5ab075c181557acad8e271ac08ddd0e729412ef8))
|
||||
- Extract git::repository::factory module ([4e60be6](https://git.kemitix.net/kemitix/git-next/commit/4e60be61f752a1a2a4171d4266e0e21368f5c47c))
|
||||
- Split server storage creation out from startup ([4276964](https://git.kemitix.net/kemitix/git-next/commit/4276964f4d0417b9deb953ae25ed54d02c80bab1))
|
||||
- Split ReceiveServerConfig handler ([7212154](https://git.kemitix.net/kemitix/git-next/commit/721215403790283447b101652e80c1ef766f4611))
|
||||
- Split messages and handlers for server-actor ([681b2c4](https://git.kemitix.net/kemitix/git-next/commit/681b2c4c10bd291c1a6772a2694c6abbb62c26da))
|
||||
|
||||
### Build
|
||||
|
||||
- Pin versions for docker base images ([6bbc894](https://git.kemitix.net/kemitix/git-next/commit/6bbc89490ae443871aa2a3a10ac4b503cee3157c))
|
||||
|
||||
## [0.8.1] - 2024-07-05
|
||||
|
||||
[8beef49](https://git.kemitix.net/kemitix/git-next/commit/8beef49b3e823444fb364cc1dcc4520edbe044d2)...[b0be0f6](https://git.kemitix.net/kemitix/git-next/commit/b0be0f636c2021d23448e4859f4ef8c3c58d2500)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Mermaid diagram syntax ([99d8672](https://git.kemitix.net/kemitix/git-next/commit/99d8672f553b97145feb756ac20ec57f90582474))
|
||||
- Typos in mermaid diagram ([209b29d](https://git.kemitix.net/kemitix/git-next/commit/209b29d2172065d7529b395d256cf673cd9fd223))
|
||||
- Default log level is info ([694135a](https://git.kemitix.net/kemitix/git-next/commit/694135a10b7262a3ad999443d91d42856b32d91f))
|
||||
|
||||
### Documentation
|
||||
|
||||
- Update crate interdependence graph ([9042005](https://git.kemitix.net/kemitix/git-next/commit/90420052cfca4100165e7af1b9cd7a15c0b269a7))
|
||||
- Minor updates to README ([6c24a36](https://git.kemitix.net/kemitix/git-next/commit/6c24a364764c7cccc87dd5cc41b4671fb8afad47))
|
||||
- Update installation instructions ([2483e85](https://git.kemitix.net/kemitix/git-next/commit/2483e851967a71efbeed02220197abd1b553bbe5))
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- Clean up footer of readme ([007a5bd](https://git.kemitix.net/kemitix/git-next/commit/007a5bd13c2255f4f407d2f122a5649f195e84f8))
|
||||
- Directly re-export function and type ([7b19f3b](https://git.kemitix.net/kemitix/git-next/commit/7b19f3b66f0c8318613193f587a1e3401b97d33d))
|
||||
- Remove unused token from github tests ([c2953ad](https://git.kemitix.net/kemitix/git-next/commit/c2953adba58f2dffca2160a410725a7c0a3cfd0d))
|
||||
- Release 0.8.1 ([b0be0f6](https://git.kemitix.net/kemitix/git-next/commit/b0be0f636c2021d23448e4859f4ef8c3c58d2500))
|
||||
|
||||
### Refactor
|
||||
|
||||
- Cli don't depend directly on git crate ([3dec12d](https://git.kemitix.net/kemitix/git-next/commit/3dec12de2024ccbde94bd8b581c0397743f76bae))
|
||||
- Server no longer depends directly on git crate ([12849d5](https://git.kemitix.net/kemitix/git-next/commit/12849d5a6956372b6fd0ee300570e078c3bd9346))
|
||||
|
||||
## [0.8.0] - 2024-07-02
|
||||
|
||||
[ea9a858](https://git.kemitix.net/kemitix/git-next/commit/ea9a858f4856600f955f6de45f0358414920d621)...[8beef49](https://git.kemitix.net/kemitix/git-next/commit/8beef49b3e823444fb364cc1dcc4520edbe044d2)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Remove unused GitDir::into_string() function ([65e9ddf](https://git.kemitix.net/kemitix/git-next/commit/65e9ddf5db05cf0ff2024ae70eb886475acf769a))
|
||||
- Where repo config is in server should register webhook ([975c9e3](https://git.kemitix.net/kemitix/git-next/commit/975c9e315ce2a59ebb6742a0b1e42c1716dcec8c))
|
||||
- Github: restarting server creates duplicate webhook for repo ([db90280](https://git.kemitix.net/kemitix/git-next/commit/db9028064188d766fc1ff872b81f63d1f6758fdd))
|
||||
- Start validating repo after registering webhook ([68005d7](https://git.kemitix.net/kemitix/git-next/commit/68005d757d919a48bca3ac9d76583b9d98e3f89a))
|
||||
- ReceiveRepoConfig tries to send two messages ([c9efbb9](https://git.kemitix.net/kemitix/git-next/commit/c9efbb993692a4a106d96eafb149e04f3aca0458))
|
||||
- Don't retry validation when non-retryable error ([ae7933c](https://git.kemitix.net/kemitix/git-next/commit/ae7933c79ee6dc3190255282705ca030fd3d00a0))
|
||||
- Github commit should use common headers ([73ab149](https://git.kemitix.net/kemitix/git-next/commit/73ab149aba4f6aac124b6a127514a443be91b914))
|
||||
- Messages should always get delivered ([83ce957](https://git.kemitix.net/kemitix/git-next/commit/83ce95776e96639bfce09f5a6342f5d27eb0e8c6))
|
||||
|
||||
### Documentation
|
||||
|
||||
- Fix typo ([0796df0](https://git.kemitix.net/kemitix/git-next/commit/0796df00d49120004186ace7681815d0c4771fdb))
|
||||
|
||||
### Features
|
||||
|
||||
- Ignore github ping webhook messages ([55d8ccb](https://git.kemitix.net/kemitix/git-next/commit/55d8ccb0bd107bd9454c92569654aaf578074e0c))
|
||||
- Load log levels from env RUST_LOG ([77d35e8](https://git.kemitix.net/kemitix/git-next/commit/77d35e8a0963f2223c20ff8032d3fb13f7cbedc3))
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- Remove dead code ([2acc43d](https://git.kemitix.net/kemitix/git-next/commit/2acc43d3d694c83e2ef9c1326a3c35c76b527de3))
|
||||
- Remove unused Fake repo facade ([8ce4528](https://git.kemitix.net/kemitix/git-next/commit/8ce4528c88ae4fb1ad2f4eeb2fbe5ade8f3a7bb2))
|
||||
- Fix name in config file ([f038ab5](https://git.kemitix.net/kemitix/git-next/commit/f038ab508b7dd24833ef3bd91248e8ed53f1325b))
|
||||
- Bacon run job runs server ([880fa0c](https://git.kemitix.net/kemitix/git-next/commit/880fa0cc0e3a5492cad2932cf390159f5c893faf))
|
||||
- Don't treat clippy warnings as errors ([7fdea29](https://git.kemitix.net/kemitix/git-next/commit/7fdea2913aabab23d0ad03897fea55b7f45d10ae))
|
||||
- Set default logging lever back to info ([d0c731f](https://git.kemitix.net/kemitix/git-next/commit/d0c731fc013499e15b6874574b6fe070a4b44ad0))
|
||||
- Release 0.8.0 ([8beef49](https://git.kemitix.net/kemitix/git-next/commit/8beef49b3e823444fb364cc1dcc4520edbe044d2))
|
||||
|
||||
### Refactor
|
||||
|
||||
- Tests: expand test given modules ([aa817a8](https://git.kemitix.net/kemitix/git-next/commit/aa817a8e95389b8f6767fd15cbe773743a4046a2))
|
||||
- Use given::a_name in config tests ([b5c0f5b](https://git.kemitix.net/kemitix/git-next/commit/b5c0f5bd36d828879a761d8642ed3c33f9fa4093))
|
||||
- Merge git::branch module into git::push ([c6a1d2c](https://git.kemitix.net/kemitix/git-next/commit/c6a1d2c21b3c4d48459678fc12bee505078a8885))
|
||||
- Git::push::reset takes all params as refs ([dcd9473](https://git.kemitix.net/kemitix/git-next/commit/dcd94736a995a1b9401b350bba2e7487f91bc385))
|
||||
- Rewrite git crate's mock repository ([926851d](https://git.kemitix.net/kemitix/git-next/commit/926851db1924e881a6d91e30c3d47c1229c06666))
|
||||
- Clean up eprintln use ([9b97083](https://git.kemitix.net/kemitix/git-next/commit/9b970835c8f5576401784b0e80b0cf62837450d5))
|
||||
- Add newtype macro ([2e71e40](https://git.kemitix.net/kemitix/git-next/commit/2e71e403789217afb05d40a4b7284865113a5f50))
|
||||
- Start to use newtype macro ([5e9f9eb](https://git.kemitix.net/kemitix/git-next/commit/5e9f9eb80ff9e645576a73854a63b437d97731cf))
|
||||
- Config: use newtype ([ea20afe](https://git.kemitix.net/kemitix/git-next/commit/ea20afee12f8f7e760e5641125dbf12cc073d74c))
|
||||
- Create a RepositoryFactory trait ([94ad2c4](https://git.kemitix.net/kemitix/git-next/commit/94ad2c441c88563b501b5be570a3a1301a265349))
|
||||
- Git: use newtype ([2cdaf39](https://git.kemitix.net/kemitix/git-next/commit/2cdaf39c0f0bd2ba1997faa141bbe24489591d0e))
|
||||
- Forgejo: explain todo warnings ([601e400](https://git.kemitix.net/kemitix/git-next/commit/601e4003005df8fc678fd0015d45320aefc1531c))
|
||||
- Repo-actor: rewrite tests using mockall ([ffab198](https://git.kemitix.net/kemitix/git-next/commit/ffab1986a77ab6c1fcc45788156b3168c85b8f56))
|
||||
- Remove unused Forge Deref implementation ([f460cd4](https://git.kemitix.net/kemitix/git-next/commit/f460cd4b493210f81be625dd0276aa6efb61ae8c))
|
||||
- Remove dead code ([6d9eb0a](https://git.kemitix.net/kemitix/git-next/commit/6d9eb0ab86b9fb5612b81cb365561a20c8b7e30c))
|
||||
- Extract webhook actor ([eba00a1](https://git.kemitix.net/kemitix/git-next/commit/eba00a112f25ba0b2d8e8b71ae654803920efa32))
|
||||
- Extract actor-macros crate ([2008afa](https://git.kemitix.net/kemitix/git-next/commit/2008afa4dd256d6796bf60203185f0fb66694c16))
|
||||
- Extract file-watcher-actor crate ([52d442f](https://git.kemitix.net/kemitix/git-next/commit/52d442f2b05a743bdabe97c2ff2d44dbd44a9b51))
|
||||
- Server: collapse tests to base of crate ([0fd3373](https://git.kemitix.net/kemitix/git-next/commit/0fd33739c108c22f7f8a36857dd04295a713fff7))
|
||||
- Update macro signatures and add documentation support ([717cc8b](https://git.kemitix.net/kemitix/git-next/commit/717cc8b0bc19a5c02b6180521969a4fd7789644a))
|
||||
- Remove dead code ([32fb92f](https://git.kemitix.net/kemitix/git-next/commit/32fb92fb8d14917c6ae82d42994b18770afeb025))
|
||||
- CloneRepo use actor::do_send to send LoadConfigFromRepo ([c571e9e](https://git.kemitix.net/kemitix/git-next/commit/c571e9ee8ddad8333889846b59b612461248136f))
|
||||
- Repo-actor: RepoActorLog: replace Mutex with RwLock ([3e137c6](https://git.kemitix.net/kemitix/git-next/commit/3e137c648099687a5faf52945650ec7325f8bc63))
|
||||
- Tests: repo-actor: use methods on RepoActorLog ([52df211](https://git.kemitix.net/kemitix/git-next/commit/52df2114e5d6df2b150b64cd30e3c5a3c229fe28))
|
||||
- Git: replace Mutex with RwLock in Repository ([73b416e](https://git.kemitix.net/kemitix/git-next/commit/73b416e3a010f9cd9522c01bca5e7b10dde1cb86))
|
||||
- Repo-actor: replace Mutex with RwLock ([8fceafc](https://git.kemitix.net/kemitix/git-next/commit/8fceafc3e1f2d84299e4f2102881ec15c9688395))
|
||||
- File-watcher doesn't debug log on each loop ([c85eee8](https://git.kemitix.net/kemitix/git-next/commit/c85eee85e94a6059efda1ac1ee3a0b3e59be17d1))
|
||||
- Only start actor system when server starts ([dfc0c1d](https://git.kemitix.net/kemitix/git-next/commit/dfc0c1dc8097234daf5a9e44f40dc834778e4d5f))
|
||||
|
||||
### Testing
|
||||
|
||||
- Tidy up config, forgejo and git tests ([271f4ec](https://git.kemitix.net/kemitix/git-next/commit/271f4ec1dcb4fd0020221cbd600d3cb1dfdbf04c))
|
||||
- Add more tests to git crate ([588666f](https://git.kemitix.net/kemitix/git-next/commit/588666ffe19d13c820c4f19dd162b9aea0a7f1b0))
|
||||
- Make TestRepository from git crate available to other crates ([be78597](https://git.kemitix.net/kemitix/git-next/commit/be78597331380aded1f750bc11c5267ec492943f))
|
||||
- Use println rather then eprintln in tests ([b9940cd](https://git.kemitix.net/kemitix/git-next/commit/b9940cd205678d8533f057e58e8d5ba1263e593f))
|
||||
- Repo-actor: add more tests ([e585b07](https://git.kemitix.net/kemitix/git-next/commit/e585b07f6b987294a85107aa268b9083fa1495cc))
|
||||
- Add more debug tracing ([40c61fa](https://git.kemitix.net/kemitix/git-next/commit/40c61fa9ff41c552aee7e08bc359113f47cc0515))
|
||||
|
||||
### Refactos
|
||||
|
||||
- Extract server-actor crate ([1131920](https://git.kemitix.net/kemitix/git-next/commit/113192042b8a2e43ccf37440ee85e4d1c280cc9d))
|
||||
|
||||
## [0.7.1] - 2024-06-06
|
||||
|
||||
[c1c62e7](https://git.kemitix.net/kemitix/git-next/commit/c1c62e7659f9c94a51da72a85a96ebf920457572)...[ea9a858](https://git.kemitix.net/kemitix/git-next/commit/ea9a858f4856600f955f6de45f0358414920d621)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Github: use correct url to check CI status ([46e2871](https://git.kemitix.net/kemitix/git-next/commit/46e2871e17677745ef6d11e7e3d50014d6da1e1d))
|
||||
|
@ -20,6 +634,7 @@ All notable changes to this project will be documented in this file.
|
|||
- Remove unused dependencies ([235aee8](https://git.kemitix.net/kemitix/git-next/commit/235aee8b11e07926d8b507d4d4b5444a0b0c354a))
|
||||
- Add grcov-coverage as an alternate report generation recipe ([d67b821](https://git.kemitix.net/kemitix/git-next/commit/d67b821130d1b73765ffcd60952a35141a4b8d3d))
|
||||
- Ignore coverage metadata (profraw files) ([8609652](https://git.kemitix.net/kemitix/git-next/commit/86096529284ab1eea72b864cd33b68845eae7c7d))
|
||||
- Release 0.7.1 ([ea9a858](https://git.kemitix.net/kemitix/git-next/commit/ea9a858f4856600f955f6de45f0358414920d621))
|
||||
|
||||
### Refactor
|
||||
|
||||
|
|
5246
Cargo.lock
generated
Normal file
5246
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
89
Cargo.toml
89
Cargo.toml
|
@ -1,42 +1,47 @@
|
|||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"crates/cli",
|
||||
"crates/server",
|
||||
"crates/config",
|
||||
"crates/git",
|
||||
"crates/forge",
|
||||
"crates/forge-forgejo",
|
||||
"crates/forge-github",
|
||||
"crates/repo-actor",
|
||||
]
|
||||
members = ["crates/*"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.7.1"
|
||||
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-forge = { path = "crates/forge" }
|
||||
git-next-forge-forgejo = { path = "crates/forge-forgejo" }
|
||||
git-next-forge-github = { path = "crates/forge-github" }
|
||||
git-next-repo-actor = { path = "crates/repo-actor" }
|
||||
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"
|
||||
|
@ -47,15 +52,15 @@ sha2 = "0.10"
|
|||
hex = "0.4"
|
||||
|
||||
# git
|
||||
# gix = "0.62"
|
||||
gix = { version = "0.63", features = [
|
||||
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.2" }
|
||||
kxio = "3.0"
|
||||
|
||||
# TOML parsing
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
|
@ -63,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"
|
||||
|
@ -72,9 +77,12 @@ git-conventional = "0.12"
|
|||
bytes = "1.6"
|
||||
ulid = "1.1"
|
||||
warp = "0.3"
|
||||
time = "0.3"
|
||||
standardwebhooks = "1.0"
|
||||
|
||||
# boilerplate
|
||||
derive_more = { version = "1.0.0-beta.6", features = [
|
||||
bon = "3.0"
|
||||
derive_more = { version = "1.0.0-beta", features = [
|
||||
"as_ref",
|
||||
"constructor",
|
||||
"display",
|
||||
|
@ -82,17 +90,32 @@ derive_more = { version = "1.0.0-beta.6", features = [
|
|||
"from",
|
||||
] }
|
||||
derive-with = "0.5"
|
||||
thiserror = "1.0"
|
||||
anyhow = "1.0"
|
||||
thiserror = "2.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 = ["rt", "macros"] }
|
||||
|
||||
# email
|
||||
lettre = "0.11"
|
||||
sendmail = "2.0"
|
||||
|
||||
# desktop notifications
|
||||
notifica = "3.0"
|
||||
|
||||
# Testing
|
||||
assert2 = "0.3"
|
||||
pretty_assertions = "1.4"
|
||||
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-16 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-16 --version
|
||||
RUN cargo --version
|
||||
RUN rustc --version
|
||||
RUN rustup --version
|
||||
|
|
409
README.md
409
README.md
|
@ -1,415 +1,12 @@
|
|||
# git-next
|
||||
|
||||
- Status: Alpha - dog-fooding
|
||||
## Trunk-based developement manager.
|
||||
|
||||
`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
|
||||
|
||||
## Prerequisits
|
||||
|
||||
- Rust 1.76.0 or later - https://www.rust-lang.org
|
||||
- pgk-config
|
||||
- libssl-dev
|
||||
|
||||
### x86_64-unknown-linux-gnu
|
||||
|
||||
Additionally for this platform, to improved compilation times:
|
||||
|
||||
- clang-16
|
||||
- mold
|
||||
|
||||
See `.cargo/config.toml` for how they are configured.
|
||||
|
||||
## Installation
|
||||
|
||||
You can install `git-next` using Cargo:
|
||||
|
||||
```shell
|
||||
cargo install --path .
|
||||
```
|
||||
|
||||
Not yet available to install from `crates.io`.
|
||||
|
||||
## 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.
|
||||
|
||||
#### 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
|
||||
|
||||
#### webhook
|
||||
|
||||
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.
|
||||
|
||||
- **url** - the HTTPS URL for forges to send webhook to
|
||||
|
||||
#### storage
|
||||
|
||||
`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"
|
||||
```
|
||||
|
||||
- **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 below for the permissions
|
||||
required for on each forge.
|
||||
|
||||
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.
|
||||
|
||||
## 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. 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](./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](./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.
|
||||
|
||||
## 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 --> server
|
||||
cli --> git
|
||||
|
||||
server --> config
|
||||
server --> git
|
||||
server --> forge
|
||||
server --> repo_actor
|
||||
|
||||
git --> config
|
||||
|
||||
forge --> config
|
||||
forge --> git
|
||||
forge --> forgejo
|
||||
forge --> github
|
||||
|
||||
forgejo --> config
|
||||
forgejo --> git
|
||||
|
||||
github --> config
|
||||
github --> git
|
||||
|
||||
repo_actor --> config
|
||||
repo_actor --> git
|
||||
repo_actor --> forge
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
|
|
|
@ -2,10 +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-git = { 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 }
|
||||
|
@ -13,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::net::Net,
|
||||
}
|
||||
|
||||
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;
|
50
crates/cli/src/alerts/webhook.rs
Normal file
50
crates/cli/src/alerts/webhook.rs
Normal file
|
@ -0,0 +1,50 @@
|
|||
//
|
||||
use git_next_core::{git::UserNotification, server::OutboundWebhook};
|
||||
use secrecy::ExposeSecret as _;
|
||||
use standardwebhooks::Webhook;
|
||||
|
||||
pub(super) async fn send_webhook(
|
||||
user_notification: &UserNotification,
|
||||
webhook_config: &OutboundWebhook,
|
||||
net: &kxio::net::Net,
|
||||
) {
|
||||
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::net::Net,
|
||||
) {
|
||||
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, "");
|
||||
net.post(webhook_config.url())
|
||||
.body(payload.to_string())
|
||||
.header("webhook-id", message_id)
|
||||
.header("webhook-timestamp", timestamp.to_string())
|
||||
.header("webhook-signature", signature)
|
||||
.send()
|
||||
.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::net::Net;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Forge;
|
||||
|
||||
impl Forge {
|
||||
pub fn create(repo_details: RepoDetails, net: Net) -> 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 = kxio::net::mock();
|
||||
let repo_details = given_repo_details(git_next_core::ForgeType::ForgeJo);
|
||||
let forge = Forge::create(repo_details, net.into());
|
||||
assert_eq!(forge.name(), "forgejo");
|
||||
}
|
||||
|
||||
#[cfg(feature = "github")]
|
||||
#[test]
|
||||
fn test_github_name() {
|
||||
let net = kxio::net::mock();
|
||||
let repo_details = given_repo_details(git_next_core::ForgeType::GitHub);
|
||||
let forge = Forge::create(repo_details, net.into());
|
||||
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,28 +1,20 @@
|
|||
//
|
||||
use color_eyre::{eyre::Context, Result};
|
||||
use kxio::fs::FileSystem;
|
||||
|
||||
pub fn run(fs: FileSystem) {
|
||||
let file_name = ".git-next.toml";
|
||||
let pathbuf = fs.base().join(file_name);
|
||||
match fs.path_exists(&pathbuf) {
|
||||
Ok(exists) => {
|
||||
if exists {
|
||||
eprintln!(
|
||||
"The configuration file already exists at {} - not overwritting it.",
|
||||
file_name
|
||||
);
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("Could not check if file exist: {} - {err:?}", file_name);
|
||||
}
|
||||
pub fn run(fs: &FileSystem) -> Result<()> {
|
||||
let pathbuf = fs.base().join(".git-next.toml");
|
||||
if fs
|
||||
.path(&pathbuf)
|
||||
.exists()
|
||||
.with_context(|| format!("Checking for existing file: {pathbuf:?}"))?
|
||||
{
|
||||
eprintln!("The configuration file already exists at {pathbuf:?} - not overwritting it.",);
|
||||
} else {
|
||||
fs.file(&pathbuf)
|
||||
.write(include_str!("../default.toml"))
|
||||
.with_context(|| format!("Writing file: {pathbuf:?}"))?;
|
||||
println!("Created a default configuration file at {pathbuf:?}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
@ -1,12 +1,27 @@
|
|||
//
|
||||
#![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 kxio::{fs, network::Network};
|
||||
use color_eyre::Result;
|
||||
use kxio::{fs, net};
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[clap(version = clap::crate_version!(), author = clap::crate_authors!(), about = clap::crate_description!())]
|
||||
|
@ -23,27 +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 repo = git_next_git::repository::new();
|
||||
let net = net::new();
|
||||
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, repo).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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
41
crates/cli/src/repo/handlers/check_ci_status.rs
Normal file
41
crates/cli/src/repo/handlers/check_ci_status.rs
Normal file
|
@ -0,0 +1,41 @@
|
|||
//
|
||||
use actix::prelude::*;
|
||||
|
||||
use tracing::{debug, warn, 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 {
|
||||
match forge.commit_status(&next).await {
|
||||
Ok(status) => {
|
||||
debug!("got status: {status:?}");
|
||||
do_send(&addr, ReceiveCIStatus::new((next, status)), log.as_ref());
|
||||
}
|
||||
Err(err) => warn!(?err, "fetching commit status"),
|
||||
}
|
||||
}
|
||||
.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;
|
76
crates/cli/src/repo/handlers/receive_ci_status.rs
Normal file
76
crates/cli/src/repo/handlers/receive_ci_status.rs
Normal file
|
@ -0,0 +1,76 @@
|
|||
//
|
||||
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);
|
||||
}
|
||||
Status::Error(_) => todo!(),
|
||||
}
|
||||
}
|
||||
}
|
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::net::Net;
|
||||
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: Net,
|
||||
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: Net,
|
||||
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
|
||||
}
|
237
crates/cli/src/repo/tests/given.rs
Normal file
237
crates/cli/src/repo/tests/given.rs
Normal file
|
@ -0,0 +1,237 @@
|
|||
//
|
||||
use super::*;
|
||||
|
||||
use git_next_core::server::ListenUrl;
|
||||
|
||||
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::net::MockNet {
|
||||
kxio::net::mock()
|
||||
}
|
||||
|
||||
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::TempFileSystem {
|
||||
#[allow(clippy::expect_used)]
|
||||
kxio::fs::temp().expect("temp fs")
|
||||
}
|
||||
|
||||
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::net::Net,
|
||||
) -> (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(&repo_details.gitdir).create()?;
|
||||
|
||||
//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(&repo_details.gitdir).create()?;
|
||||
|
||||
//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(&repo_details.gitdir).create()?;
|
||||
|
||||
//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(&repo_details.gitdir).create()?;
|
||||
|
||||
//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(&repo_details.gitdir).create()?;
|
||||
|
||||
//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(|_| Ok(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
|
||||
}
|
||||
}
|
261
crates/cli/src/server/actor/mod.rs
Normal file
261
crates/cli/src/server/actor/mod.rs
Normal file
|
@ -0,0 +1,261 @@
|
|||
//
|
||||
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, net::Net};
|
||||
|
||||
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: Net,
|
||||
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: Net,
|
||||
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);
|
||||
let path_handle = self.fs.path(&path);
|
||||
if path_handle.exists()? {
|
||||
if !path_handle.is_dir()? {
|
||||
return Err(Error::ForgeDirIsNotDirectory { path });
|
||||
}
|
||||
} else {
|
||||
tracing::info!(%forge_name, ?path_handle, "creating storage");
|
||||
self.fs.dir(&path).create_all()?;
|
||||
}
|
||||
}
|
||||
|
||||
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(dir).create() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
19
crates/cli/src/server/actor/tests/given.rs
Normal file
19
crates/cli/src/server/actor/tests/given.rs
Normal file
|
@ -0,0 +1,19 @@
|
|||
use std::time::Duration;
|
||||
|
||||
use actix::prelude::*;
|
||||
|
||||
use crate::alerts::{AlertsActor, History};
|
||||
|
||||
//
|
||||
pub fn a_filesystem() -> kxio::fs::TempFileSystem {
|
||||
#[allow(clippy::expect_used)]
|
||||
kxio::fs::temp().expect("temp fs")
|
||||
}
|
||||
|
||||
pub fn a_network() -> kxio::net::MockNet {
|
||||
kxio::net::mock()
|
||||
}
|
||||
|
||||
pub fn an_alerts_actor(net: kxio::net::Net) -> 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.as_real(), 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")));
|
||||
}
|
192
crates/cli/src/server/mod.rs
Normal file
192
crates/cli/src/server/mod.rs
Normal file
|
@ -0,0 +1,192 @@
|
|||
//
|
||||
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, net::Net};
|
||||
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(&pathbuf)
|
||||
.exists()
|
||||
.with_context(|| format!("Checking for existing file: {pathbuf:?}"))?
|
||||
{
|
||||
eprintln!("The configuration file already exists at {pathbuf:?} - not overwritting it.",);
|
||||
} else {
|
||||
fs.file(&pathbuf)
|
||||
.write(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: Net,
|
||||
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");
|
||||
}
|
|
@ -6,12 +6,12 @@ mod init {
|
|||
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")?;
|
||||
fs.file(&file).write("contents")?;
|
||||
|
||||
crate::init::run(fs.clone());
|
||||
crate::init::run(&fs)?;
|
||||
|
||||
assert_eq!(
|
||||
fs.file_read_to_string(&file)?,
|
||||
fs.file(&file).reader()?.to_string(),
|
||||
"contents",
|
||||
"The file has been changed"
|
||||
);
|
||||
|
@ -23,18 +23,59 @@ mod init {
|
|||
fn should_create_default_file_if_not_exists() -> TestResult {
|
||||
let fs = kxio::fs::temp()?;
|
||||
|
||||
crate::init::run(fs.clone());
|
||||
crate::init::run(&fs)?;
|
||||
|
||||
let file = fs.base().join(".git-next.toml");
|
||||
|
||||
assert!(fs.path_exists(&file)?, "The file has not been created");
|
||||
assert!(fs.path(&file).exists()?, "The file has not been created");
|
||||
|
||||
assert_eq!(
|
||||
fs.file_read_to_string(&file)?,
|
||||
include_str!("../../../default.toml"),
|
||||
fs.file(&file).reader()?.to_string(),
|
||||
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(&path).write("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(())
|
||||
}
|
||||
}
|
387
crates/cli/src/tui/actor/model.rs
Normal file
387
crates/cli/src/tui/actor/model.rs
Normal file
|
@ -0,0 +1,387 @@
|
|||
//
|
||||
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>,
|
||||
}
|
||||
|
||||
#[derive(bon::Builder, 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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
68
crates/cli/src/tui/components/configured_app.rs
Normal file
68
crates/cli/src/tui/components/configured_app.rs
Normal file
|
@ -0,0 +1,68 @@
|
|||
use std::collections::BTreeMap;
|
||||
|
||||
use git_next_core::ForgeAlias;
|
||||
use ratatui::{
|
||||
buffer::Buffer,
|
||||
layout::{Direction, Layout, Rect, Size},
|
||||
widgets::StatefulWidget,
|
||||
};
|
||||
use tui_scrollview::{ScrollView, ScrollViewState};
|
||||
|
||||
use crate::tui::actor::ForgeState;
|
||||
|
||||
use super::{forge::ForgeWidget, HeightContraintLength};
|
||||
|
||||
pub struct ConfiguredAppWidget<'a> {
|
||||
pub forges: &'a BTreeMap<ForgeAlias, ForgeState>,
|
||||
}
|
||||
impl HeightContraintLength for ConfiguredAppWidget<'_> {
|
||||
fn height_constraint_length(&self) -> u16 {
|
||||
self.children()
|
||||
.iter()
|
||||
.map(HeightContraintLength::height_constraint_length)
|
||||
.sum::<u16>()
|
||||
+ 2 // top + bottom borders
|
||||
}
|
||||
}
|
||||
impl StatefulWidget for ConfiguredAppWidget<'_> {
|
||||
type State = ScrollViewState;
|
||||
|
||||
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State)
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
let height = self
|
||||
.children()
|
||||
.iter()
|
||||
.map(HeightContraintLength::height_constraint_length)
|
||||
.sum::<u16>();
|
||||
let mut scroll = ScrollView::new(Size::new(area.width - 1, height));
|
||||
let layout_forge_list = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints(
|
||||
self.children()
|
||||
.iter()
|
||||
.map(HeightContraintLength::height_constraint_length),
|
||||
)
|
||||
.split(scroll.area());
|
||||
|
||||
self.children()
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.for_each(|(i, w)| scroll.render_widget(w, layout_forge_list[i]));
|
||||
|
||||
scroll.render(area, buf, state);
|
||||
}
|
||||
}
|
||||
impl<'a> ConfiguredAppWidget<'a> {
|
||||
fn children(&self) -> Vec<ForgeWidget<'a>> {
|
||||
self.forges
|
||||
.iter()
|
||||
.map(|(forge_alias, state)| ForgeWidget {
|
||||
forge_alias,
|
||||
repos: &state.repos,
|
||||
view_state: state.view_state,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue