Compare commits

..

No commits in common. "main" and "v0.12.0" have entirely different histories.

198 changed files with 1703 additions and 10790 deletions

View file

@ -1,7 +1,17 @@
# ./cargo/config.toml # ./cargo/config.toml
[target.x86_64-unknown-linux-gnu]
linker = "/usr/bin/clang-16"
rustflags = [
"-C",
"link-arg=--ld-path=/usr/bin/mold",
"--cfg",
"tokio_unstable",
]
[profile.dev] [profile.dev]
debug = 0 debug = 0
strip = "debuginfo" strip = "debuginfo"
[env] [env]
RUST_LOG = "hyper=warn,git_url_parse=warn,debug" RUST_LOG = "hyper=warn,git_url_parse=warn,debug"
RUSTFLAGS = "--cfg tokio_unstable"

View file

@ -0,0 +1,25 @@
name: Publish to crates.io
on:
release:
env:
CARGO_TERM_COLOR: always
jobs:
build:
runs-on: docker
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login
uses: https://git.kemitix.net/kemitix/rust@v0.3.1
with:
args: login "$CARGO_REGISTRY_TOKEN"
- name: Publish
uses: https://git.kemitix.net/kemitix/rust@v0.3.1
with:
args: publish --registry crates-io --no-verify

View file

@ -1,35 +0,0 @@
name: Release Please
permissions:
pull-requests: write
contents: write
on:
push:
branches:
- main
env:
CARGO_TERM_COLOR: always
jobs:
release-plz:
name: Release-plz
runs-on: docker
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Run release-plz release-pr
uses: https://git.kemitix.net/kemitix/rust@v2.3.0
with:
args: release-plz release-pr --backend gitea --git-token ${{ secrets.FORGEJO_TOKEN }}
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
- name: Run release-plz release
uses: https://git.kemitix.net/kemitix/rust@v2.3.0
with:
args: release-plz release --backend gitea --git-token ${{ secrets.FORGEJO_TOKEN }}
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}

View file

@ -13,40 +13,26 @@ jobs:
build: build:
runs-on: docker runs-on: docker
strategy:
matrix:
toolchain:
- name: stable
- name: nightly
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Check TODOs
uses: kemitix/todo-checker@v1.1.0
- name: Machete
uses: https://git.kemitix.net/kemitix/rust@v2.3.0
with:
args: ${{ matrix.toolchain.name }} cargo machete
- name: Format - name: Format
uses: https://git.kemitix.net/kemitix/rust@v2.3.0 uses: https://git.kemitix.net/kemitix/rust@v0.3.1
with: with:
args: ${{ matrix.toolchain.name }} cargo fmt --all -- --check args: fmt --all -- --check
- name: Clippy - name: Clippy
uses: https://git.kemitix.net/kemitix/rust@v2.3.0 uses: https://git.kemitix.net/kemitix/rust@v0.3.1
with: with:
args: ${{ matrix.toolchain.name }} cargo hack --feature-powerset clippy args: clippy
- name: Build - name: Build
uses: https://git.kemitix.net/kemitix/rust@v2.3.0 uses: https://git.kemitix.net/kemitix/rust@v0.3.1
with: with:
args: ${{ matrix.toolchain.name }} cargo hack --feature-powerset build args: build
- name: Test - name: Test
uses: https://git.kemitix.net/kemitix/rust@v2.3.0 uses: https://git.kemitix.net/kemitix/rust@v0.3.1
with: with:
args: ${{ matrix.toolchain.name }} cargo hack --feature-powerset test args: test

6
.git-next.toml Normal file
View file

@ -0,0 +1,6 @@
[branches]
main = "main"
next = "next"
dev = "dev"
[options]

5
.gitignore vendored
View file

@ -1,6 +1,3 @@
# git-next ui logs
.local/
# ---> Rust # ---> Rust
# Generated by Cargo # Generated by Cargo
# will have compiled files and executables # will have compiled files and executables
@ -12,7 +9,7 @@ target/
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries # 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 # 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 # These are backup files generated by rustfmt
**/*.rs.bk **/*.rs.bk

View file

@ -1,13 +1,13 @@
steps: steps:
docker-build: 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
when: when:
- event: push - event: push
branch: next branch: next
# INFO: https://woodpecker-ci.org/plugins/Docker%20Buildx
image: docker.io/woodpeckerci/plugin-docker-buildx:5.0.0
settings: settings:
username: kemitix # git-next-woodpecker-todo-checker - read:issue
repo: git.kemitix.net/kemitix/git-next repository_token: "776a3b928b852472c2af727a360c85c00af64b9f"
dockerfile: Dockerfile prefix_regex: "(#|//) (TODO|FIXME): "
auto_tag: false debug: false
dry-run: true # don't push to remote repo

View file

@ -1,10 +1,23 @@
steps: 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: docker-build:
when: when:
- event: tag - event: tag
ref: refs/tags/v* ref: refs/tags/v*
# INFO: https://woodpecker-ci.org/plugins/Docker%20Buildx # INFO: https://woodpecker-ci.org/plugins/Docker%20Buildx
image: docker.io/woodpeckerci/plugin-docker-buildx:5.0.0 image: docker.io/woodpeckerci/plugin-docker-buildx:4.0.0
settings: settings:
username: kemitix username: kemitix
repo: git.kemitix.net/kemitix/git-next repo: git.kemitix.net/kemitix/git-next

View file

@ -2,305 +2,8 @@
All notable changes to this project will be documented in this file. 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 ## [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 ### Bug Fixes
- Don't log content of internal messages ([3ae1132](https://git.kemitix.net/kemitix/git-next/commit/3ae113212af3ee43f36383a22984e03e3f44f3f2)) - Don't log content of internal messages ([3ae1132](https://git.kemitix.net/kemitix/git-next/commit/3ae113212af3ee43f36383a22984e03e3f44f3f2))
@ -320,7 +23,6 @@ All notable changes to this project will be documented in this file.
- Remove deprecated crates ([5a595ec](https://git.kemitix.net/kemitix/git-next/commit/5a595ec9eed77cf961f01c671c69ca2bc7988092)) - 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 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)) - 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 ### Refactor

5060
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -3,7 +3,7 @@ resolver = "2"
members = ["crates/*"] members = ["crates/*"]
[workspace.package] [workspace.package]
version = "0.13.11" version = "0.12.0" # Update git-next-* under workspace.dependencies
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"
@ -15,33 +15,32 @@ documentation = "https://git.kemitix.net/kemitix/git-next/src/branch/main/README
keywords = ["git", "cli", "server", "tool"] keywords = ["git", "cli", "server", "tool"]
categories = ["development-tools"] categories = ["development-tools"]
# [workspace.lints.clippy] [workspace.lints.clippy]
# pedantic = { level = "warn", priority = -1 } nursery = { level = "warn", priority = -1 }
# nursery = { level = "warn", priority = -1 } # pedantic = "warn"
# unwrap_used = "warn" unwrap_used = "warn"
# expect_used = "warn" expect_used = "warn"
[workspace.dependencies] [workspace.dependencies]
git-next-core = { path = "crates/core", version = "0.13" } git-next-core = { path = "crates/core", version = "0.12" }
git-next-forge-forgejo = { path = "crates/forge-forgejo", version = "0.13" } git-next-forge-forgejo = { path = "crates/forge-forgejo", version = "0.12" }
git-next-forge-github = { path = "crates/forge-github", version = "0.13" } git-next-forge-github = { path = "crates/forge-github", version = "0.12" }
# TUI # remove after 0.12.0
ratatui = "0.29" git-next-server = { path = "crates/server", version = "0.12" }
directories = "5.0" git-next-server-actor = { path = "crates/server-actor", version = "0.12" }
lazy_static = "1.5" git-next-forge = { path = "crates/forge", version = "0.12" }
color-eyre = "0.6" git-next-repo-actor = { path = "crates/repo-actor", version = "0.12" }
tui-scrollview = "0.5" git-next-webhook-actor = { path = "crates/webhook-actor", version = "0.12" }
regex = "1.10" git-next-file-watcher-actor = { path = "crates/file-watcher-actor", version = "0.12" }
chrono = "0.4"
# CLI parsing # CLI parsing
clap = { version = "4.5", features = ["cargo", "derive"] } clap = { version = "4.5", features = ["cargo", "derive"] }
# logging # logging
console-subscriber = "0.3"
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = "0.3"
tracing-error = "0.2.0"
# base64 decoding # base64 decoding
base64 = "0.22" base64 = "0.22"
@ -52,7 +51,8 @@ sha2 = "0.10"
hex = "0.4" hex = "0.4"
# git # git
gix = { version = "0.67", features = [ # gix = "0.62"
gix = { version = "0.64", features = [
"dirwalk", "dirwalk",
"blocking-http-transport-reqwest-rust-tls", "blocking-http-transport-reqwest-rust-tls",
] } ] }
@ -68,7 +68,7 @@ serde_json = "1.0"
toml = "0.8" toml = "0.8"
# Secrets and Password # Secrets and Password
secrecy = "0.10" secrecy = "0.8"
# Conventional Commit check # Conventional Commit check
git-conventional = "0.12" git-conventional = "0.12"
@ -81,8 +81,7 @@ time = "0.3"
standardwebhooks = "1.0" standardwebhooks = "1.0"
# boilerplate # boilerplate
bon = "2.0" derive_more = { version = "1.0.0-beta.6", features = [
derive_more = { version = "1.0.0-beta", features = [
"as_ref", "as_ref",
"constructor", "constructor",
"display", "display",
@ -90,32 +89,20 @@ derive_more = { version = "1.0.0-beta", features = [
"from", "from",
] } ] }
derive-with = "0.5" derive-with = "0.5"
anyhow = "1.0"
thiserror = "1.0" thiserror = "1.0"
pike = "0.1" pike = "0.1"
# iters
take-until = "0.2"
# file watcher # file watcher
notify = "7.0" notify = "6.1"
# Actors # Actors
actix = "0.13" actix = "0.13"
actix-rt = "2.9" actix-rt = "2.9"
tokio = { version = "1.37", features = ["rt", "macros"] } tokio = { version = "1.37", features = ["rt", "macros"] }
# email
lettre = "0.11"
sendmail = "2.0"
# desktop notifications
notifica = "3.0"
# Testing # Testing
assert2 = "0.3" assert2 = "0.3"
pretty_assertions = "1.4" pretty_assertions = "1.4"
rand = "0.8" rand = "0.8"
mockall = "0.13" mockall = "0.13"
test-log = "0.2" test-log = "0.2"
rstest = { version = "0.23", features = ["async-timeout"] }

View file

@ -1,6 +1,6 @@
# Leveraging the pre-built Docker images with # Leveraging the pre-built Docker images with
# cargo-chef and the Rust toolchain # cargo-chef and the Rust toolchain
FROM git.kemitix.net/kemitix/git-next-builder:2024.08.04 AS chef FROM git.kemitix.net/kemitix/git-next-builder:2024.07.05 AS chef
WORKDIR /app WORKDIR /app
FROM chef AS planner FROM chef AS planner
@ -12,19 +12,19 @@ FROM chef AS builder
COPY --from=planner /app/recipe.json recipe.json COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --profile release --recipe-path recipe.json RUN cargo chef cook --profile release --recipe-path recipe.json
COPY . . COPY . .
RUN cargo build --release --bin git-next --all-features && \ RUN cargo build --release --bin git-next && \
strip target/release/git-next strip target/release/git-next
FROM docker.io/debian:stable-20240904-slim AS runtime FROM docker.io/debian:stable-20240701-slim AS runtime
WORKDIR /app WORKDIR /app
RUN apt-get update && \ RUN apt-get update && \
apt-get satisfy -y "git (>=2.39), libssl3 (>=3.0.14), libdbus-1-dev (>=1.14.10), ca-certificates (>=20230311)" \ apt-get install --no-install-recommends -y \
git=1:2.39.2-1.1 \
libssl3=3.0.13-1~deb12u1 \
ca-certificates=20230311 \
&& \ && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
USER 1000 USER 1000
COPY --from=builder /app/target/release/git-next /usr/local/bin COPY --from=builder /app/target/release/git-next /usr/local/bin
ENV HOME=/app ENTRYPOINT [ "/usr/local/bin/git-next", "server", "start" ]
ENTRYPOINT [ "/usr/local/bin/git-next" ]
CMD [ "server", "start" ]

View file

@ -1,7 +1,7 @@
FROM docker.io/rust:1.82.0-bookworm FROM docker.io/rust:1.79.0-bookworm
RUN apt-get update && \ RUN apt-get update && \
apt-get install -y libdbus-1-dev && \ apt-get install -y clang-16 mold && \
curl -L https://github.com/cargo-bins/cargo-binstall/releases/latest/download/cargo-binstall-x86_64-unknown-linux-musl.tgz -o cargo-binstall.tgz && \ 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 && \ tar -xzf cargo-binstall.tgz && \
rm cargo-binstall.tgz && \ rm cargo-binstall.tgz && \
@ -15,6 +15,8 @@ RUN cargo chef --version
RUN rustfmt --version RUN rustfmt --version
RUN cargo fmt --version RUN cargo fmt --version
RUN cargo clippy --version RUN cargo clippy --version
RUN mold --version
RUN clang-16 --version
RUN cargo --version RUN cargo --version
RUN rustc --version RUN rustc --version
RUN rustup --version RUN rustup --version

View file

@ -6,7 +6,4 @@
development workflows where each commit must pass CI before being included in development workflows where each commit must pass CI before being included in
the main branch. the main branch.
![Demo](./demo.gif)
See [README.md](https://git.kemitix.net/kemitix/git-next/src/branch/main/crates/cli/README.md) for more information. See [README.md](https://git.kemitix.net/kemitix/git-next/src/branch/main/crates/cli/README.md) for more information.

View file

@ -1,18 +0,0 @@
# 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.

View file

@ -12,26 +12,16 @@ keywords = { workspace = true }
categories = { workspace = true } categories = { workspace = true }
[features] [features]
# default = ["forgejo", "github"] default = ["forgejo", "github"]
default = ["forgejo", "github", "tui"]
forgejo = ["git-next-forge-forgejo"] forgejo = ["git-next-forge-forgejo"]
github = ["git-next-forge-github"] github = ["git-next-forge-github"]
tui = ["ratatui", "directories", "lazy_static", "tui-scrollview", "regex", "chrono"]
[dependencies] [dependencies]
git-next-core = { workspace = true } git-next-core = { workspace = true }
git-next-server-actor = { workspace = true }
git-next-forge-forgejo = { workspace = true, optional = true } git-next-forge-forgejo = { workspace = true, optional = true }
git-next-forge-github = { 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 # CLI parsing
clap = { workspace = true } clap = { workspace = true }
@ -39,9 +29,12 @@ clap = { workspace = true }
kxio = { workspace = true } kxio = { workspace = true }
# logging # logging
console-subscriber = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
tracing-subscriber = { workspace = true } tracing-subscriber = { workspace = true }
tracing-error.workspace = true
# git
async-trait = { workspace = true }
# Conventional Commit check # Conventional Commit check
git-conventional = { workspace = true } git-conventional = { workspace = true }
@ -49,19 +42,20 @@ git-conventional = { workspace = true }
# TOML parsing # TOML parsing
toml = { workspace = true } toml = { workspace = true }
# base64 decoding
base64 = { workspace = true }
# Actors # Actors
actix = { workspace = true } actix = { workspace = true }
actix-rt = { workspace = true } actix-rt = { workspace = true }
tokio = { workspace = true }
# boilerplate # boilerplate
bon = { workspace = true }
derive_more = { workspace = true } derive_more = { workspace = true }
derive-with = { workspace = true } derive-with = { workspace = true }
anyhow = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
# Webhooks # Webhooks
serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
ulid = { workspace = true } ulid = { workspace = true }
time = { workspace = true } time = { workspace = true }
@ -73,13 +67,6 @@ warp = { workspace = true }
# file watcher (linux) # file watcher (linux)
notify = { workspace = true } notify = { workspace = true }
# email
lettre = { workspace = true }
sendmail = { workspace = true }
# desktop notifications
notifica = { workspace = true }
[dev-dependencies] [dev-dependencies]
# Testing # Testing
assert2 = { workspace = true } assert2 = { workspace = true }
@ -87,13 +74,9 @@ test-log = { workspace = true }
rand = { workspace = true } rand = { workspace = true }
pretty_assertions = { workspace = true } pretty_assertions = { workspace = true }
mockall = { workspace = true } mockall = { workspace = true }
rstest = { workspace = true }
[lints.clippy] [lints.clippy]
nursery = { level = "warn", priority = -1 } nursery = { level = "warn", priority = -1 }
pedantic = { level = "warn", priority = -1 } # pedantic = "warn"
unwrap_used = "warn" unwrap_used = "warn"
expect_used = "warn" expect_used = "warn"
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(tarpaulin_include)'] }

View file

@ -29,8 +29,13 @@ See [Behaviour](#behaviour) to learn how we do this.
- Rust 1.76.0 or later - https://www.rust-lang.org - Rust 1.76.0 or later - https://www.rust-lang.org
- pgk-config - pgk-config
- libssl-dev - libssl-dev
- libdbus-1-dev (ubuntu/debian)
- dbus-devel (fedora) ### 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. See `.cargo/config.toml` for how they are configured.
@ -59,7 +64,7 @@ cargo install --path crates/cli
- [x] cli - [x] cli
- [x] server - [x] server
- [x] notifications - notify user when intervention required (e.g. to rebase) - [x] notifications - notify user when intervention required (e.g. to rebase)
- [x] tui overview - [ ] tui overview
- [ ] webui overview - [ ] webui overview
## Branch Names ## Branch Names
@ -82,17 +87,7 @@ happen to have those same names.
The server is configured by the `git-next-server.toml` file. The server is configured by the `git-next-server.toml` file.
#### listen #### http
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, 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. github.com). You can do this via any method that suits your environment,
@ -105,9 +100,17 @@ This is the address and port that your reverse proxy should route traffic to.
- **addr** - the IP address the server should bind to - **addr** - the IP address the server should bind to
- **port** - the IP port the server should bind to - **port** - the IP port the server should bind to
##### url #### notification
The HTTPS URL for forges to send webhooks to. The server should be able to notify the user when manual intervention is required.
Currently this is only available via sending a Webhook message.
- **type** - one of `None` or `Webhook`
- **webhook** - the URL to POST the notification to
See [Notifications](#notifications) for more details.
#### webhook
Your forges need to know where they should route webhooks to. This should be 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 an address this is accessible to the forge. So, for github.com, it would need
@ -115,68 +118,10 @@ 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 on your own network, then it only needs to be accessible from the server your
forge is running on. forge is running on.
#### shout - **url** - the HTTPS URL for forges to send webhook to
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 #### storage
```toml
[storage]
path = "./data"
```
`git-next` will create a bare clone of each repo that you configure it to `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 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 does not need to be backed up, as any missing information will be cloned when
@ -198,14 +143,13 @@ forge_type = "GitHub"
hostname = "github.com" hostname = "github.com"
user = "username" user = "username"
token = "api-key" token = "api-key"
max_dev_commits = 25
``` ```
- **forge_type** - one of: `ForgeJo` or `GitHub` - **forge_type** - one of: `ForgeJo` or `GitHub`
- **hostname** - the hostname for the forge. - **hostname** - the hostname for the forge.
- **user** - the user to authenticate as - **user** - the user to authenticate as
- **token** - application token for the user. See [Forges](#forges) below for the permissions required for each forge. - **token** - application token for the user. See below for the permissions
- **max_dev_commits** - [optional] the maximum number of commits allowed between `dev` and `main`. Defaults to 25. required for on each forge.
Generally, the `user` will need to be able to push to `main` and to _force-push_ Generally, the `user` will need to be able to push to `main` and to _force-push_
to `next`. to `next`.
@ -270,11 +214,13 @@ 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 same one specified as the `origin` remote. Otherwise the behaviour is
untested and undefined. untested and undefined.
## Webhook Notifications ## Notifications
When sending a Webhook Notification to a user they are sent using the `git-next` can send a number of notification to the user when intervention is required.
Standard Webhooks format. That means all POST messages have the Currently, only WebHooks are supported.
following headers:
Webhooks are sent using the Standard Webhooks format. That means all POST messages have
the following headers:
- `Webhook-Id` - `Webhook-Id`
- `Webhook-Signature` - `Webhook-Signature`
@ -298,13 +244,7 @@ Sample payload:
"main": "main" "main": "main"
}, },
"forge_alias": "jo", "forge_alias": "jo",
"repo_alias": "kxio", "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", "timestamp": "1721760933",
"type": "branch.dev.not-on-main" "type": "branch.dev.not-on-main"
@ -325,16 +265,11 @@ Sample payload:
{ {
"data": { "data": {
"commit": { "commit": {
"sha": "c37bd2caf6825f9770d725a681e5cfc09d7fd4f2", "sha": "98abef1af6825f9770d725a681e5cfc09d7fd4f2",
"message": "feat: add log graph to notifications (1 of 2)" "message": "feat: add foo to bar template"
}, },
"forge_alias": "jo", "forge_alias": "jo",
"repo_alias": "kxio", "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", "timestamp": "1721760933",
"type": "cicheck.failed" "type": "cicheck.failed"
@ -577,58 +512,6 @@ world = { repo = "user/world", branch = "master", main = "master", next = "upcom
The token is created [here](https://github.com/settings/tokens/new) and requires the `repo` and `admin:repo_hook` permissions. 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 ## Contributing
Contributions to `git-next` are welcome! If you find a bug or have a feature Contributions to `git-next` are welcome! If you find a bug or have a feature

View file

@ -1,10 +0,0 @@
//
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");
}
}

View file

@ -1,71 +0,0 @@
//
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");
})?)
}

View file

@ -1,2 +0,0 @@
mod notify_user;
mod update_shout;

View file

@ -1,41 +0,0 @@
//
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);
}
}

View file

@ -1,12 +0,0 @@
//
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());
}
}

View file

@ -1,50 +0,0 @@
//
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);
};
}
}

View file

@ -1,10 +0,0 @@
//
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"
);

View file

@ -1,93 +0,0 @@
//
use actix::prelude::*;
use derive_more::derive::Constructor;
use git_next_core::{git::UserNotification, server::Shout};
pub use history::History;
mod desktop;
mod email;
mod handlers;
mod history;
pub mod messages;
mod webhook;
#[cfg(test)]
mod tests;
#[allow(clippy::module_name_repetitions)]
#[derive(Debug, Constructor)]
pub struct AlertsActor {
shout: Option<Shout>, // config for sending alerts to users
history: History, // record of alerts sent recently (e.g. 24 hours)
net: kxio::network::Network,
}
impl Actor for AlertsActor {
type Context = Context<Self>;
}
fn short_message(user_notification: &UserNotification) -> String {
let (forge_alias, repo_alias) = user_notification.aliases();
format!("[git-next] {forge_alias}/{repo_alias}: {user_notification}")
}
fn full_message(user_notification: &UserNotification) -> String {
match user_notification {
UserNotification::CICheckFailed {
forge_alias,
repo_alias,
commit,
log,
} => {
let sha = commit.sha();
let message = commit.message();
[
"CI Checks have Failed".to_string(),
format!("Forge: {forge_alias}\nRepo : {repo_alias}"),
format!("Commit:\n - {sha}\n - {message}"),
"Log:".to_string(),
log.join("\n"),
]
.join("\n\n")
}
UserNotification::RepoConfigLoadFailure {
forge_alias,
repo_alias,
reason,
} => [
"Failed to read or parse the .git-next.toml file from repo".to_string(),
format!(" - {reason}"),
format!("Forge: {forge_alias}\nRepo : {repo_alias}"),
]
.join("\n\n"),
UserNotification::WebhookRegistration {
forge_alias,
repo_alias,
reason,
} => [
"Failed to register webhook with the forge".to_string(),
format!(" - {reason}"),
format!("Forge: {forge_alias}\nRepo : {repo_alias}"),
]
.join("\n\n"),
UserNotification::DevNotBasedOnMain {
forge_alias,
repo_alias,
dev_branch,
main_branch,
dev_commit: _,
main_commit: _,
log,
} => [
format!("The branch '{dev_branch}' is not based on the branch '{main_branch}'."),
format!("TODO: Rebase '{dev_branch}' onto '{main_branch}'."),
format!("Forge: {forge_alias}\nRepo : {repo_alias}"),
"Log:".to_string(),
log.join("\n"),
]
.join("\n\n"),
}
}

View file

@ -1,68 +0,0 @@
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());
}

View file

@ -1 +0,0 @@
mod history;

View file

@ -1,54 +0,0 @@
//
use git_next_core::{git::UserNotification, server::OutboundWebhook};
use kxio::network::{NetRequest, NetUrl, RequestBody, ResponseType};
use secrecy::ExposeSecret as _;
use standardwebhooks::Webhook;
pub(super) async fn send_webhook(
user_notification: &UserNotification,
webhook_config: &OutboundWebhook,
net: &kxio::network::Network,
) {
let Ok(webhook) =
Webhook::from_bytes(webhook_config.secret().expose_secret().as_bytes().into())
else {
tracing::warn!("Invalid notification configuration (signer) - can't sent notification");
return;
};
do_send_webhook(user_notification, webhook, webhook_config, net).await;
}
async fn do_send_webhook(
user_notification: &UserNotification,
webhook: Webhook,
webhook_config: &OutboundWebhook,
net: &kxio::network::Network,
) {
let message_id = format!("msg_{}", ulid::Ulid::new());
let timestamp = time::OffsetDateTime::now_utc();
let payload = user_notification.as_json(timestamp);
let timestamp = timestamp.unix_timestamp();
let to_sign = format!("{message_id}.{timestamp}.{payload}");
tracing::info!(?to_sign, "");
#[allow(clippy::expect_used)]
let signature = webhook
.sign(&message_id, timestamp, payload.to_string().as_ref())
.expect("signature");
tracing::info!(?signature, "");
let url = webhook_config.url();
let net_url = NetUrl::new(url.to_string());
let request = NetRequest::post(net_url)
.body(RequestBody::Json(payload))
.header("webhook-id", &message_id)
.header("webhook-timestamp", &timestamp.to_string())
.header("webhook-signature", &signature)
.response_type(ResponseType::None)
.build();
net.post_json::<()>(request).await.map_or_else(
|err| {
tracing::warn!(?err, "sending webhook");
},
|_| (),
);
}

View file

@ -2,18 +2,13 @@
use actix::prelude::*; use actix::prelude::*;
use actix::Recipient; use actix::Recipient;
use anyhow::{Context, Result}; use notify::event::ModifyKind;
use notify::{event::ModifyKind, Watcher}; use notify::Watcher;
use tracing::{error, info}; use tracing::error;
use tracing::info;
use std::{ use std::path::PathBuf;
path::PathBuf, use std::time::Duration;
sync::{
atomic::{AtomicBool, Ordering},
Arc,
},
time::Duration,
};
#[derive(Debug, Message)] #[derive(Debug, Message)]
#[rtype(result = "()")] #[rtype(result = "()")]
@ -24,26 +19,23 @@ pub enum Error {
#[error("io")] #[error("io")]
Io(#[from] std::io::Error), Io(#[from] std::io::Error),
} }
pub fn watch_file(path: PathBuf, recipient: Recipient<FileUpdated>) -> Result<Arc<AtomicBool>> { pub async fn watch_file(path: PathBuf, recipient: Recipient<FileUpdated>) {
let (tx, rx) = std::sync::mpsc::channel(); let (tx, rx) = std::sync::mpsc::channel();
let shutdown = Arc::new(AtomicBool::default());
let mut handler = notify::recommended_watcher(tx).context("file watcher")?; #[allow(clippy::expect_used)]
let mut handler = notify::recommended_watcher(tx).expect("file watcher");
#[allow(clippy::expect_used)]
handler handler
.watch(&path, notify::RecursiveMode::NonRecursive) .watch(&path, notify::RecursiveMode::NonRecursive)
.with_context(|| format!("Watching: {path:?}"))?; .expect("watch file");
let thread_shutdown = shutdown.clone(); info!("Watching: {:?}", path);
actix_rt::task::spawn_blocking(move || { async move {
loop { loop {
if thread_shutdown.load(Ordering::Relaxed) {
drop(handler);
break;
}
for result in rx.try_iter() { for result in rx.try_iter() {
match result { match result {
Ok(event) => match event.kind { Ok(event) => match event.kind {
notify::EventKind::Modify(ModifyKind::Data(_)) => { notify::EventKind::Modify(ModifyKind::Data(_)) => {
info!("File modified"); tracing::info!("File modified");
recipient.do_send(FileUpdated); recipient.do_send(FileUpdated);
break; break;
} }
@ -59,8 +51,8 @@ pub fn watch_file(path: PathBuf, recipient: Recipient<FileUpdated>) -> Result<Ar
} }
} }
} }
std::thread::sleep(Duration::from_millis(1000)); actix_rt::time::sleep(Duration::from_millis(1000)).await;
} }
}); }
Ok(shutdown) .await;
} }

View file

@ -1,5 +1,8 @@
// //
use git_next_core::git::{ForgeLike, RepoDetails}; use git_next_core::{
git::{ForgeLike, RepoDetails},
ForgeType,
};
#[cfg(feature = "forgejo")] #[cfg(feature = "forgejo")]
use git_next_forge_forgejo::ForgeJo; use git_next_forge_forgejo::ForgeJo;
@ -16,14 +19,10 @@ impl Forge {
pub fn create(repo_details: RepoDetails, net: Network) -> Box<dyn ForgeLike> { pub fn create(repo_details: RepoDetails, net: Network) -> Box<dyn ForgeLike> {
match repo_details.forge.forge_type() { match repo_details.forge.forge_type() {
#[cfg(feature = "forgejo")] #[cfg(feature = "forgejo")]
git_next_core::ForgeType::ForgeJo => Box::new(ForgeJo::new(repo_details, net)), ForgeType::ForgeJo => Box::new(ForgeJo::new(repo_details, net)),
#[cfg(feature = "github")] #[cfg(feature = "github")]
git_next_core::ForgeType::GitHub => Box::new(Github::new(repo_details, net)), ForgeType::GitHub => Box::new(Github::new(repo_details, net)),
_ => { ForgeType::MockForge => unreachable!(),
drop(repo_details);
drop(net);
unreachable!();
}
} }
} }
} }

View file

@ -1,5 +1,4 @@
// //
#[cfg(any(feature = "forgejo", feature = "github"))]
use super::*; use super::*;
use git_next_core::{ use git_next_core::{
@ -8,30 +7,31 @@ use git_next_core::{
GitDir, RepoConfigSource, StoragePathType, GitDir, RepoConfigSource, StoragePathType,
}; };
#[cfg(feature = "forgejo")]
#[test] #[test]
fn test_forgejo_name() { fn test_forgejo_name() {
let net = Network::new_mock(); let net = Network::new_mock();
let repo_details = given_repo_details(git_next_core::ForgeType::ForgeJo); let repo_details = given_repo_details(ForgeType::ForgeJo);
let forge = Forge::create(repo_details, net); let forge = Forge::create(repo_details, net);
assert_eq!(forge.name(), "forgejo"); assert_eq!(forge.name(), "forgejo");
} }
#[cfg(feature = "github")]
#[test] #[test]
fn test_github_name() { fn test_github_name() {
let net = Network::new_mock(); let net = Network::new_mock();
let repo_details = given_repo_details(git_next_core::ForgeType::GitHub); let repo_details = given_repo_details(ForgeType::GitHub);
let forge = Forge::create(repo_details, net); let forge = Forge::create(repo_details, net);
assert_eq!(forge.name(), "github"); assert_eq!(forge.name(), "github");
} }
#[allow(dead_code)] fn given_fs() -> kxio::fs::FileSystem {
fn given_repo_details(forge_type: git_next_core::ForgeType) -> RepoDetails { kxio::fs::temp().unwrap_or_else(|e| {
let fs = kxio::fs::temp().unwrap_or_else(|e| {
println!("{e}"); println!("{e}");
panic!("fs") panic!("fs")
}); })
}
fn given_repo_details(forge_type: ForgeType) -> RepoDetails {
let fs = given_fs();
git::repo_details( git::repo_details(
1, 1,
git::Generation::default(), git::Generation::default(),

View file

@ -1,18 +1,29 @@
// //
use color_eyre::{eyre::Context, Result};
use kxio::fs::FileSystem; use kxio::fs::FileSystem;
pub fn run(fs: &FileSystem) -> Result<()> { pub fn run(fs: FileSystem) {
let pathbuf = fs.base().join(".git-next.toml"); let file_name = ".git-next.toml";
if fs let pathbuf = fs.base().join(file_name);
.path_exists(&pathbuf) match fs.path_exists(&pathbuf) {
.with_context(|| format!("Checking for existing file: {pathbuf:?}"))? Ok(exists) => {
{ if exists {
eprintln!("The configuration file already exists at {pathbuf:?} - not overwritting it.",); eprintln!(
"The configuration file already exists at {} - not overwritting it.",
file_name
);
} else { } else {
fs.file_write(&pathbuf, include_str!("../default.toml")) match fs.file_write(&pathbuf, include_str!("../default.toml")) {
.with_context(|| format!("Writing file: {pathbuf:?}"))?; Ok(_) => {
println!("Created a default configuration file at {pathbuf:?}"); 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);
}
} }
Ok(())
} }

View file

@ -1,26 +1,19 @@
// //
#![allow(clippy::module_name_repetitions)]
mod alerts;
mod file_watcher; mod file_watcher;
mod forge; mod forge;
mod init; mod init;
mod repo; mod repo;
mod server; mod server;
mod webhook;
#[cfg(feature = "tui")]
mod tui;
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;
mod webhook;
use git_next_core::git; use git_next_core::git;
use std::path::PathBuf; use std::path::PathBuf;
use clap::Parser; use clap::Parser;
use color_eyre::Result;
use kxio::{fs, network::Network}; use kxio::{fs, network::Network};
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
@ -38,40 +31,27 @@ enum Command {
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
enum Server { enum Server {
Init, Init,
Start { Start,
/// Display a UI (experimental)
#[cfg(feature = "tui")]
#[arg(long, required = false)]
ui: bool,
},
} }
fn main() -> Result<()> { fn main() {
let fs = fs::new(PathBuf::default()); let fs = fs::new(PathBuf::default());
let net = Network::new_real(); let net = Network::new_real();
let repository_factory = git::repository::factory::real(); let repository_factory = git::repository::factory::real();
let commands = Commands::parse(); let commands = Commands::parse();
match commands.command { match commands.command {
Command::Init => init::run(&fs), Command::Init => {
init::run(fs);
}
Command::Server(server) => match server { Command::Server(server) => match server {
Server::Init => server::init(&fs), Server::Init => {
#[cfg(not(feature = "tui"))] server::init(fs);
Server::Start {} => server::start( }
false, Server::Start => {
fs, let sleep_duration = std::time::Duration::from_secs(10);
net, server::start(fs, net, repository_factory, sleep_duration);
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),
),
}, },
} }
} }

View file

@ -17,19 +17,21 @@ use tracing::{info, instrument, warn};
// advance next to the next commit towards the head of the dev branch // advance next to the next commit towards the head of the dev branch
#[instrument(fields(next), skip_all)] #[instrument(fields(next), skip_all)]
pub fn advance_next( pub fn advance_next(
commit: Option<Commit>, next: &Commit,
force: git_next_core::git::push::Force, main: &Commit,
repo_details: &RepoDetails, dev_commit_history: &[Commit],
repo_details: RepoDetails,
repo_config: RepoConfig, repo_config: RepoConfig,
open_repository: &dyn OpenRepositoryLike, open_repository: &dyn OpenRepositoryLike,
message_token: MessageToken, message_token: MessageToken,
) -> Result<MessageToken> { ) -> Result<MessageToken> {
let (commit, force) = find_next_commit_on_dev(next, main, dev_commit_history);
let commit = commit.ok_or_else(|| Error::NextAtDev)?; let commit = commit.ok_or_else(|| Error::NextAtDev)?;
validate_commit_message(commit.message())?; validate_commit_message(commit.message())?;
info!("Advancing next to commit '{}'", commit); info!("Advancing next to commit '{}'", commit);
reset( reset(
open_repository, open_repository,
repo_details, &repo_details,
&repo_config.branches().next(), &repo_config.branches().next(),
&commit.into(), &commit.into(),
&force, &force,
@ -64,7 +66,7 @@ pub fn find_next_commit_on_dev(
) -> (Option<Commit>, Force) { ) -> (Option<Commit>, Force) {
let mut next_commit: Option<&Commit> = None; let mut next_commit: Option<&Commit> = None;
let mut force = Force::No; let mut force = Force::No;
for commit in dev_commit_history { for commit in dev_commit_history.iter() {
if commit == next { if commit == next {
break; break;
}; };

View file

@ -1,18 +1,15 @@
// //
use actix::prelude::*; use actix::prelude::*;
use git_next_core::{git, RepoConfigSource}; use git_next_core::RepoConfigSource;
use tracing::warn; use tracing::warn;
use crate::{ use crate::repo::{
repo::{
branch::advance_main, branch::advance_main,
do_send, do_send,
messages::{AdvanceMain, LoadConfigFromRepo, ValidateRepo}, messages::{AdvanceMain, LoadConfigFromRepo, ValidateRepo},
RepoActor, RepoActor,
},
server::actor::messages::RepoUpdate,
}; };
impl Handler<AdvanceMain> for RepoActor { impl Handler<AdvanceMain> for RepoActor {
@ -29,31 +26,24 @@ impl Handler<AdvanceMain> for RepoActor {
let repo_details = self.repo_details.clone(); let repo_details = self.repo_details.clone();
let addr = ctx.address(); let addr = ctx.address();
let message_token = self.message_token; let message_token = self.message_token;
let commit = msg.peel();
self.update_tui(RepoUpdate::AdvancingMain { match advance_main(
commit: commit.clone(), msg.unwrap(),
}); &repo_details,
&repo_config,
if let Err(err) = advance_main(commit, &repo_details, &repo_config, &**open_repository) { &**open_repository,
) {
Err(err) => {
warn!("advance main: {err}"); 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}")),
} }
} Ok(_) => match repo_config.source() {
match repo_config.source() {
RepoConfigSource::Repo => { RepoConfigSource::Repo => {
do_send(&addr, LoadConfigFromRepo, self.log.as_ref()); do_send(addr, LoadConfigFromRepo, self.log.as_ref());
} }
RepoConfigSource::Server => { RepoConfigSource::Server => {
do_send(&addr, ValidateRepo::new(message_token), self.log.as_ref()); do_send(addr, ValidateRepo::new(message_token), self.log.as_ref());
} }
} },
} }
} }
} }

View file

@ -1,17 +1,13 @@
// //
use actix::prelude::*; use actix::prelude::*;
use git_next_core::git; use tracing::warn;
use tracing::{warn, Instrument};
use crate::{ use crate::repo::{
repo::{ branch::advance_next,
branch::{advance_next, find_next_commit_on_dev},
do_send, do_send,
messages::{AdvanceNext, AdvanceNextPayload, ValidateRepo}, messages::{AdvanceNext, AdvanceNextPayload, ValidateRepo},
RepoActor, RepoActor,
},
server::actor::messages::RepoUpdate,
}; };
impl Handler<AdvanceNext> for RepoActor { impl Handler<AdvanceNext> for RepoActor {
@ -24,52 +20,30 @@ impl Handler<AdvanceNext> for RepoActor {
let Some(open_repository) = &self.open_repository else { let Some(open_repository) = &self.open_repository else {
return; return;
}; };
let AdvanceNextPayload { let AdvanceNextPayload {
next, next,
main, main,
dev_commit_history, dev_commit_history,
} = msg.peel(); } = msg.unwrap();
let repo_details = self.repo_details.clone(); let repo_details = self.repo_details.clone();
let repo_config = repo_config.clone(); let repo_config = repo_config.clone();
let addr = ctx.address(); 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( match advance_next(
commit, &next,
force, &main,
&repo_details, &dev_commit_history,
repo_details,
repo_config, repo_config,
&**open_repository, &**open_repository,
self.message_token, self.message_token,
) { ) {
Ok(message_token) => { Ok(message_token) => {
self.update_tui(RepoUpdate::NextUpdated); // pause to allow any CI checks to be started
match open_repository.fetch() { std::thread::sleep(self.sleep_duration);
Ok(()) => self.update_tui_log(git::graph::log(&self.repo_details)), do_send(addr, ValidateRepo::new(message_token), self.log.as_ref());
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());
} }
Err(err) => warn!("advance next: {err}"),
} }
} }
} }

View file

@ -3,13 +3,10 @@ use actix::prelude::*;
use tracing::{debug, Instrument as _}; use tracing::{debug, Instrument as _};
use crate::{ use crate::repo::{
repo::{
do_send, do_send,
messages::{CheckCIStatus, ReceiveCIStatus}, messages::{CheckCIStatus, ReceiveCIStatus},
RepoActor, RepoActor,
},
server::actor::messages::RepoUpdate,
}; };
impl Handler<CheckCIStatus> for RepoActor { impl Handler<CheckCIStatus> for RepoActor {
@ -17,18 +14,16 @@ impl Handler<CheckCIStatus> for RepoActor {
fn handle(&mut self, msg: CheckCIStatus, ctx: &mut Self::Context) -> Self::Result { fn handle(&mut self, msg: CheckCIStatus, ctx: &mut Self::Context) -> Self::Result {
crate::repo::logger(self.log.as_ref(), "start: CheckCIStatus"); crate::repo::logger(self.log.as_ref(), "start: CheckCIStatus");
let addr = ctx.address(); let addr = ctx.address();
let forge = self.forge.duplicate(); let forge = self.forge.duplicate();
let next = msg.peel(); let next = msg.unwrap();
let log = self.log.clone(); let log = self.log.clone();
self.update_tui(RepoUpdate::CheckingCI);
// get the status - pass, fail, pending (all others map to fail, e.g. error) // get the status - pass, fail, pending (all others map to fail, e.g. error)
async move { async move {
let status = forge.commit_status(&next).await; let status = forge.commit_status(&next).await;
debug!("got status: {status:?}"); debug!("got status: {status:?}");
do_send(&addr, ReceiveCIStatus::new((next, status)), log.as_ref()); do_send(addr, ReceiveCIStatus::new((next, status)), log.as_ref());
} }
.in_current_span() .in_current_span()
.into_actor(self) .into_actor(self)

View file

@ -4,13 +4,10 @@ use actix::prelude::*;
use git_next_core::git; use git_next_core::git;
use tracing::{debug, instrument, warn}; use tracing::{debug, instrument, warn};
use crate::{ use crate::repo::{
repo::{
do_send, logger, do_send, logger,
messages::{CloneRepo, LoadConfigFromRepo, RegisterWebhook}, messages::{CloneRepo, LoadConfigFromRepo, RegisterWebhook},
RepoActor, RepoActor,
},
server::actor::messages::RepoUpdate,
}; };
impl Handler<CloneRepo> for RepoActor { impl Handler<CloneRepo> for RepoActor {
@ -18,24 +15,21 @@ impl Handler<CloneRepo> for RepoActor {
#[instrument(name = "RepoActor::CloneRepo", skip_all, fields(repo = %self.repo_details))] #[instrument(name = "RepoActor::CloneRepo", skip_all, fields(repo = %self.repo_details))]
fn handle(&mut self, _msg: CloneRepo, ctx: &mut Self::Context) -> Self::Result { fn handle(&mut self, _msg: CloneRepo, ctx: &mut Self::Context) -> Self::Result {
logger(self.log.as_ref(), "Handler: CloneRepo: start"); logger(self.log.as_ref(), "Handler: CloneRepo: start");
self.update_tui(RepoUpdate::Opening);
debug!("Handler: CloneRepo: start"); debug!("Handler: CloneRepo: start");
match git::repository::open(&*self.repository_factory, &self.repo_details) { match git::repository::open(&*self.repository_factory, &self.repo_details) {
Ok(repository) => { Ok(repository) => {
logger(self.log.as_ref(), "open okay"); logger(self.log.as_ref(), "open okay");
debug!("open okay"); debug!("open okay");
self.update_tui(RepoUpdate::Opened);
self.open_repository.replace(repository); self.open_repository.replace(repository);
if self.repo_details.repo_config.is_none() { if self.repo_details.repo_config.is_none() {
do_send(&ctx.address(), LoadConfigFromRepo, self.log.as_ref()); do_send(ctx.address(), LoadConfigFromRepo, self.log.as_ref());
} else { } else {
do_send(&ctx.address(), RegisterWebhook::new(), self.log.as_ref()); do_send(ctx.address(), RegisterWebhook::new(), self.log.as_ref());
} }
} }
Err(err) => { Err(err) => {
logger(self.log.as_ref(), "open failed"); logger(self.log.as_ref(), "open failed");
warn!("Could not open repo: {err:?}"); warn!("Could not open repo: {err:?}")
self.alert_tui(err.to_string());
} }
} }
debug!("Handler: CloneRepo: finish"); debug!("Handler: CloneRepo: finish");

View file

@ -5,13 +5,10 @@ use git_next_core::git::UserNotification;
use tracing::{debug, instrument, Instrument as _}; use tracing::{debug, instrument, Instrument as _};
use crate::{ use crate::repo::{
repo::{
do_send, load, do_send, load,
messages::{LoadConfigFromRepo, ReceiveRepoConfig}, messages::{LoadConfigFromRepo, ReceiveRepoConfig},
notify_user, RepoActor, notify_user, RepoActor,
},
server::actor::messages::RepoUpdate,
}; };
impl Handler<LoadConfigFromRepo> for RepoActor { impl Handler<LoadConfigFromRepo> for RepoActor {
@ -19,7 +16,6 @@ impl Handler<LoadConfigFromRepo> for RepoActor {
#[instrument(name = "Repocrate::repo::LoadConfigFromRepo", skip_all, fields(repo = %self.repo_details))] #[instrument(name = "Repocrate::repo::LoadConfigFromRepo", skip_all, fields(repo = %self.repo_details))]
fn handle(&mut self, _msg: LoadConfigFromRepo, ctx: &mut Self::Context) -> Self::Result { fn handle(&mut self, _msg: LoadConfigFromRepo, ctx: &mut Self::Context) -> Self::Result {
debug!("Handler: LoadConfigFromRepo: start"); debug!("Handler: LoadConfigFromRepo: start");
self.update_tui(RepoUpdate::LoadingConfigFromRepo);
let Some(open_repository) = &self.open_repository else { let Some(open_repository) = &self.open_repository else {
return; return;
}; };
@ -32,9 +28,7 @@ impl Handler<LoadConfigFromRepo> for RepoActor {
let log = self.log.clone(); let log = self.log.clone();
async move { async move {
match load::config_from_repository(repo_details, &*open_repository).await { match load::config_from_repository(repo_details, &*open_repository).await {
Ok(repo_config) => { Ok(repo_config) => do_send(addr, ReceiveRepoConfig::new(repo_config), log.as_ref()),
do_send(&addr, ReceiveRepoConfig::new(repo_config), log.as_ref());
}
Err(err) => notify_user( Err(err) => notify_user(
notify_user_recipient.as_ref(), notify_user_recipient.as_ref(),
UserNotification::RepoConfigLoadFailure { UserNotification::RepoConfigLoadFailure {

View file

@ -1,74 +1,54 @@
// //
use actix::prelude::*; use actix::prelude::*;
use git_next_core::git::{forge::commit::Status, graph, UserNotification}; use git_next_core::git::{forge::commit::Status, UserNotification};
use tracing::{debug, Instrument}; use tracing::debug;
use crate::{ use crate::repo::{
repo::{ delay_send, do_send, logger,
do_send, logger,
messages::{AdvanceMain, ReceiveCIStatus, ValidateRepo}, messages::{AdvanceMain, ReceiveCIStatus, ValidateRepo},
notify_user, RepoActor, notify_user, RepoActor,
},
server::actor::messages::RepoUpdate,
}; };
impl Handler<ReceiveCIStatus> for RepoActor { impl Handler<ReceiveCIStatus> for RepoActor {
type Result = (); type Result = ();
fn handle(&mut self, msg: ReceiveCIStatus, ctx: &mut Self::Context) -> Self::Result { fn handle(&mut self, msg: ReceiveCIStatus, ctx: &mut Self::Context) -> Self::Result {
logger(self.log.as_ref(), "start: ReceiveCIStatus"); let log = self.log.clone();
let (next, status) = msg.peel(); logger(log.as_ref(), "start: ReceiveCIStatus");
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 addr = ctx.address();
let (next, status) = msg.unwrap();
let forge_alias = self.repo_details.forge.forge_alias().clone(); let forge_alias = self.repo_details.forge.forge_alias().clone();
let repo_alias = self.repo_details.repo_alias.clone(); let repo_alias = self.repo_details.repo_alias.clone();
let message_token = self.message_token; let message_token = self.message_token;
let sleep_duration = self.sleep_duration; let sleep_duration = self.sleep_duration;
debug!(?status, "");
match status { match status {
Status::Pass => { Status::Pass => {
do_send(&addr, AdvanceMain::new(next), self.log.as_ref()); do_send(addr, AdvanceMain::new(next), self.log.as_ref());
} }
Status::Pending => { Status::Pending => {
let log = self.log.clone(); std::thread::sleep(sleep_duration);
async move { do_send(addr, ValidateRepo::new(message_token), self.log.as_ref());
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 => { Status::Fail => {
tracing::warn!("Checks have failed"); tracing::warn!("Checks have failed");
notify_user( notify_user(
self.notify_user_recipient.as_ref(), self.notify_user_recipient.as_ref(),
UserNotification::CICheckFailed { UserNotification::CICheckFailed {
forge_alias, forge_alias,
repo_alias, repo_alias,
commit: next, commit: next,
log: graph_log,
}, },
log.as_ref(),
);
delay_send(
addr,
sleep_duration,
ValidateRepo::new(message_token),
self.log.as_ref(), 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);
} }
} }
} }

View file

@ -2,25 +2,19 @@
use actix::prelude::*; use actix::prelude::*;
use tracing::instrument; use tracing::instrument;
use crate::{ use crate::repo::{
repo::{
do_send, do_send,
messages::{ReceiveRepoConfig, RegisterWebhook}, messages::{ReceiveRepoConfig, RegisterWebhook},
RepoActor, RepoActor,
},
server::actor::messages::RepoUpdate,
}; };
impl Handler<ReceiveRepoConfig> for RepoActor { impl Handler<ReceiveRepoConfig> for RepoActor {
type Result = (); type Result = ();
#[instrument(name = "RepoActor::ReceiveRepoConfig", skip_all, fields(repo = %self.repo_details, branches = ?msg))] #[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 { fn handle(&mut self, msg: ReceiveRepoConfig, ctx: &mut Self::Context) -> Self::Result {
let repo_config = msg.peel(); let repo_config = msg.unwrap();
self.update_tui(RepoUpdate::ReceiveRepoConfig {
repo_config: repo_config.clone(),
});
self.repo_details.repo_config.replace(repo_config); self.repo_details.repo_config.replace(repo_config);
self.update_tui_branches();
do_send(&ctx.address(), RegisterWebhook::new(), self.log.as_ref()); do_send(ctx.address(), RegisterWebhook::new(), self.log.as_ref());
} }
} }

View file

@ -1,15 +1,12 @@
// //
use actix::prelude::*; use actix::prelude::*;
use tracing::{debug, error, Instrument as _}; use tracing::{debug, Instrument as _};
use crate::{ use crate::repo::{
repo::{
do_send, do_send,
messages::{RegisterWebhook, WebhookRegistered}, messages::{RegisterWebhook, WebhookRegistered},
notify_user, RepoActor, notify_user, RepoActor,
},
server::actor::messages::RepoUpdate,
}; };
use git_next_core::git::UserNotification; use git_next_core::git::UserNotification;
@ -21,27 +18,23 @@ impl Handler<RegisterWebhook> for RepoActor {
if self.webhook_id.is_none() { if self.webhook_id.is_none() {
let forge_alias = self.repo_details.forge.forge_alias().clone(); let forge_alias = self.repo_details.forge.forge_alias().clone();
let repo_alias = self.repo_details.repo_alias.clone(); let repo_alias = self.repo_details.repo_alias.clone();
let repo_listen_url = self let webhook_url = self.webhook.url(&forge_alias, &repo_alias);
.listen_url
.repo_url(forge_alias.clone(), repo_alias.clone());
let forge = self.forge.duplicate(); let forge = self.forge.duplicate();
let addr = ctx.address(); let addr = ctx.address();
let notify_user_recipient = self.notify_user_recipient.clone(); let notify_user_recipient = self.notify_user_recipient.clone();
let log = self.log.clone(); let log = self.log.clone();
self.update_tui(RepoUpdate::RegisteringWebhook);
debug!("registering webhook"); debug!("registering webhook");
async move { async move {
match forge.register_webhook(&repo_listen_url).await { match forge.register_webhook(&webhook_url).await {
Ok(registered_webhook) => { Ok(registered_webhook) => {
debug!(?registered_webhook, "webhook registered"); debug!(?registered_webhook, "");
do_send( do_send(
&addr, addr,
WebhookRegistered::from(registered_webhook), WebhookRegistered::from(registered_webhook),
log.as_ref(), log.as_ref(),
); );
} }
Err(err) => { Err(err) => {
error!(?err, "failed to register webhook");
notify_user( notify_user(
notify_user_recipient.as_ref(), notify_user_recipient.as_ref(),
UserNotification::WebhookRegistration { UserNotification::WebhookRegistration {
@ -57,8 +50,6 @@ impl Handler<RegisterWebhook> for RepoActor {
.in_current_span() .in_current_span()
.into_actor(self) .into_actor(self)
.wait(ctx); .wait(ctx);
} else {
self.alert_tui("already have a webhook id - cant register webhook");
} }
} }
} }

View file

@ -3,24 +3,18 @@ use actix::prelude::*;
use tracing::{debug, warn, Instrument as _}; use tracing::{debug, warn, Instrument as _};
use crate::{ use crate::repo::{messages::UnRegisterWebhook, RepoActor};
repo::{messages::UnRegisterWebhook, RepoActor},
server::actor::messages::RepoUpdate,
};
impl Handler<UnRegisterWebhook> for RepoActor { impl Handler<UnRegisterWebhook> for RepoActor {
type Result = (); type Result = ();
fn handle(&mut self, _msg: UnRegisterWebhook, ctx: &mut Self::Context) -> Self::Result { fn handle(&mut self, _msg: UnRegisterWebhook, ctx: &mut Self::Context) -> Self::Result {
let Some(webhook_id) = self.webhook_id.take() else { if let Some(webhook_id) = self.webhook_id.take() {
return;
};
self.update_tui(RepoUpdate::UnregisteringWebhook);
let forge = self.forge.duplicate(); let forge = self.forge.duplicate();
debug!("unregistering webhook"); debug!("unregistering webhook");
async move { async move {
match forge.unregister_webhook(&webhook_id).await { match forge.unregister_webhook(&webhook_id).await {
Ok(()) => debug!("unregistered webhook"), Ok(_) => debug!("unregistered webhook"),
Err(err) => warn!(?err, "unregistering webhook"), Err(err) => warn!(?err, "unregistering webhook"),
} }
} }
@ -30,3 +24,4 @@ impl Handler<UnRegisterWebhook> for RepoActor {
debug!("unregistering webhook done"); debug!("unregistering webhook done");
} }
} }
}

View file

@ -1,32 +1,26 @@
// //
use actix::prelude::*; use actix::prelude::*;
use tracing::{info, instrument, Instrument as _}; use derive_more::Deref as _;
use tracing::{debug, instrument, Instrument as _};
use crate::{ use crate::repo::{
repo::{
do_send, logger, do_send, logger,
messages::{AdvanceNext, AdvanceNextPayload, CheckCIStatus, MessageToken, ValidateRepo}, messages::{AdvanceNext, AdvanceNextPayload, CheckCIStatus, MessageToken, ValidateRepo},
notify_user, RepoActor, notify_user, RepoActor,
},
server::actor::messages::RepoUpdate,
}; };
use git_next_core::git::{ use git_next_core::git::validation::positions::{validate_positions, Error, Positions};
push::Force,
validation::positions::{validate, Error, Positions},
UserNotification,
};
impl Handler<ValidateRepo> for RepoActor { impl Handler<ValidateRepo> for RepoActor {
type Result = (); type Result = ();
#[instrument(name = "RepoActor::ValidateRepo", skip_all, fields(repo = %self.repo_details, token = %&*msg))] #[instrument(name = "RepoActor::ValidateRepo", skip_all, fields(repo = %self.repo_details, token = %msg.deref()))]
fn handle(&mut self, msg: ValidateRepo, ctx: &mut Self::Context) -> Self::Result { fn handle(&mut self, msg: ValidateRepo, ctx: &mut Self::Context) -> Self::Result {
logger(self.log.as_ref(), "start: ValidateRepo"); logger(self.log.as_ref(), "start: ValidateRepo");
// Message Token - make sure we are only triggered for the latest/current token // Message Token - make sure we are only triggered for the latest/current token
match self.token_status(msg.peel()) { match self.token_status(msg.unwrap()) {
TokenStatus::Current => {} // do nothing TokenStatus::Current => {} // do nothing
TokenStatus::Expired => { TokenStatus::Expired => {
logger( logger(
@ -48,94 +42,66 @@ impl Handler<ValidateRepo> for RepoActor {
format!("accepted token: {}", self.message_token), format!("accepted token: {}", self.message_token),
); );
self.update_tui(RepoUpdate::ValidateRepo);
// Repository positions // Repository positions
let Some(ref open_repository) = self.open_repository else { let Some(ref open_repository) = self.open_repository else {
logger(self.log.as_ref(), "no open repository"); logger(self.log.as_ref(), "no open repository");
self.alert_tui("repo not open");
return; return;
}; };
logger(self.log.as_ref(), "have open repository"); logger(self.log.as_ref(), "have open repository");
let Some(repo_config) = self.repo_details.repo_config.clone() else { let Some(repo_config) = self.repo_details.repo_config.clone() else {
logger(self.log.as_ref(), "no repo config"); logger(self.log.as_ref(), "no repo config");
self.alert_tui("no repo config");
return; return;
}; };
logger(self.log.as_ref(), "have repo config"); logger(self.log.as_ref(), "have repo config");
match validate(&**open_repository, &self.repo_details, &repo_config) { match validate_positions(&**open_repository, &self.repo_details, repo_config) {
Ok(( Ok(Positions {
Positions {
main, main,
next, next,
dev, dev,
dev_commit_history, dev_commit_history,
next_is_valid, next_is_valid,
}, }) => {
git_log, debug!(%main, %next, %dev, "positions");
)) => {
info!(%main, %next, %dev, "positions");
self.update_tui_log(git_log);
if next_is_valid && next != main { if next_is_valid && next != main {
info!("Checking CI"); do_send(ctx.address(), CheckCIStatus::new(next), self.log.as_ref());
do_send(&ctx.address(), CheckCIStatus::new(next), self.log.as_ref());
} else if next != dev { } else if next != dev {
info!("Advance next");
self.update_tui(RepoUpdate::AdvancingNext {
commit: next.clone(),
force: Force::No,
});
do_send( do_send(
&ctx.address(), ctx.address(),
AdvanceNext::new(AdvanceNextPayload { AdvanceNext::new(AdvanceNextPayload {
next, next,
main, main,
dev_commit_history, dev_commit_history,
}), }),
self.log.as_ref(), self.log.as_ref(),
); )
} else { } else {
info!("do nothing"); // do nothing
self.update_tui(RepoUpdate::Okay { main, next, dev });
} }
} }
Err(Error::Retryable(message)) => { Err(Error::Retryable(message)) => {
info!(?message, "Retryable");
self.alert_tui(format!("retryable: {message}"));
logger(self.log.as_ref(), message); logger(self.log.as_ref(), message);
let addr = ctx.address(); let addr = ctx.address();
let message_token = self.message_token; let message_token = self.message_token;
let sleep_duration = self.sleep_duration; let sleep_duration = self.sleep_duration;
let log = self.log.clone(); let log = self.log.clone();
async move { async move {
info!("sleeping before retrying..."); debug!("sleeping before retrying...");
logger(log.as_ref(), "before sleep"); logger(log.as_ref(), "before sleep");
actix_rt::time::sleep(sleep_duration).await; actix_rt::time::sleep(sleep_duration).await;
logger(log.as_ref(), "after sleep"); logger(log.as_ref(), "after sleep");
do_send(&addr, ValidateRepo::new(message_token), log.as_ref()); do_send(addr, ValidateRepo::new(message_token), log.as_ref());
} }
.in_current_span() .in_current_span()
.into_actor(self) .into_actor(self)
.wait(ctx); .wait(ctx);
} }
Err(Error::UserIntervention(user_notification)) => { Err(Error::UserIntervention(user_notification)) => notify_user(
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(), self.notify_user_recipient.as_ref(),
user_notification, user_notification,
self.log.as_ref(), self.log.as_ref(),
); ),
}
Err(Error::NonRetryable(message)) => { Err(Error::NonRetryable(message)) => {
info!(?message, "NonRetryable");
self.alert_tui(format!("Error: {message}"));
logger(self.log.as_ref(), message); logger(self.log.as_ref(), message);
} }
} }

View file

@ -3,13 +3,10 @@ use actix::prelude::*;
use tracing::{info, instrument, warn}; use tracing::{info, instrument, warn};
use crate::{ use crate::repo::{
repo::{
do_send, logger, do_send, logger,
messages::{ValidateRepo, WebhookNotification}, messages::{ValidateRepo, WebhookNotification},
ActorLog, RepoActor, RepoActor, RepoActorLog,
},
server::actor::messages::RepoUpdate,
}; };
use git_next_core::{ use git_next_core::{
@ -55,13 +52,9 @@ impl Handler<WebhookNotification> for RepoActor {
return; return;
} }
Some(Branch::Main) => { Some(Branch::Main) => {
self.update_tui(RepoUpdate::WebhookReceived {
branch: Branch::Main,
push: push.clone(),
});
if handle_push( if handle_push(
push, push,
&config.branches().main(), config.branches().main(),
&mut self.last_main_commit, &mut self.last_main_commit,
self.log.as_ref(), self.log.as_ref(),
) )
@ -71,13 +64,9 @@ impl Handler<WebhookNotification> for RepoActor {
}; };
} }
Some(Branch::Next) => { Some(Branch::Next) => {
self.update_tui(RepoUpdate::WebhookReceived {
branch: Branch::Next,
push: push.clone(),
});
if handle_push( if handle_push(
push, push,
&config.branches().next(), config.branches().next(),
&mut self.last_next_commit, &mut self.last_next_commit,
self.log.as_ref(), self.log.as_ref(),
) )
@ -87,13 +76,9 @@ impl Handler<WebhookNotification> for RepoActor {
}; };
} }
Some(Branch::Dev) => { Some(Branch::Dev) => {
self.update_tui(RepoUpdate::WebhookReceived {
branch: Branch::Dev,
push: push.clone(),
});
if handle_push( if handle_push(
push, push,
&config.branches().dev(), config.branches().dev(),
&mut self.last_dev_commit, &mut self.last_dev_commit,
self.log.as_ref(), self.log.as_ref(),
) )
@ -110,7 +95,7 @@ impl Handler<WebhookNotification> for RepoActor {
"New commit" "New commit"
); );
do_send( do_send(
&ctx.address(), ctx.address(),
ValidateRepo::new(message_token), ValidateRepo::new(message_token),
self.log.as_ref(), self.log.as_ref(),
); );
@ -121,7 +106,7 @@ fn validate_notification(
msg: &WebhookNotification, msg: &WebhookNotification,
webhook_auth: Option<&WebhookAuth>, webhook_auth: Option<&WebhookAuth>,
forge: &dyn ForgeLike, forge: &dyn ForgeLike,
log: Option<&ActorLog>, log: Option<&RepoActorLog>,
) -> Result<(), ()> { ) -> Result<(), ()> {
let Some(expected_authorization) = webhook_auth else { let Some(expected_authorization) = webhook_auth else {
logger(log, "server has no auth token"); logger(log, "server has no auth token");
@ -146,11 +131,11 @@ fn validate_notification(
fn handle_push( fn handle_push(
push: Push, push: Push,
branch: &BranchName, branch: BranchName,
last_commit: &mut Option<Commit>, last_commit: &mut Option<Commit>,
log: Option<&ActorLog>, log: Option<&RepoActorLog>,
) -> Result<(), ()> { ) -> Result<(), ()> {
logger(log, format!("message is for {branch} branch")); logger(log, "message is for dev branch");
let commit = Commit::from(push); let commit = Commit::from(push);
if last_commit.as_ref() == Some(&commit) { if last_commit.as_ref() == Some(&commit) {
logger(log, format!("not a new commit on {branch}")); logger(log, format!("not a new commit on {branch}"));

View file

@ -2,24 +2,20 @@
use actix::prelude::*; use actix::prelude::*;
use tracing::instrument; use tracing::instrument;
use crate::{ use crate::repo::{
repo::{
do_send, do_send,
messages::{ValidateRepo, WebhookRegistered}, messages::{ValidateRepo, WebhookRegistered},
RepoActor, RepoActor,
},
server::actor::messages::RepoUpdate,
}; };
impl Handler<WebhookRegistered> for RepoActor { impl Handler<WebhookRegistered> for RepoActor {
type Result = (); type Result = ();
#[instrument(name = "RepoActor::WebhookRegistered", skip_all, fields(repo = %self.repo_details, webhook_id = %msg.webhook_id()))] #[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 { 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_id.replace(msg.webhook_id().clone());
self.webhook_auth.replace(msg.webhook_auth().clone()); self.webhook_auth.replace(msg.webhook_auth().clone());
do_send( do_send(
&ctx.address(), ctx.address(),
ValidateRepo::new(self.message_token), ValidateRepo::new(self.message_token),
self.log.as_ref(), self.log.as_ref(),
); );

View file

@ -6,37 +6,22 @@ use git_next_core::{
message, newtype, ForgeNotification, RegisteredWebhook, RepoConfig, WebhookAuth, WebhookId, message, newtype, ForgeNotification, RegisteredWebhook, RepoConfig, WebhookAuth, WebhookId,
}; };
message!( message!(LoadConfigFromRepo: "Request to load the `git-next.toml` from the git repo.");
LoadConfigFromRepo, message!(CloneRepo: "Request to clone (or open) the git repo.");
"Request to load the `git-next.toml` from the git repo." message!(ReceiveRepoConfig: RepoConfig: r#"Notification that the `git-next.toml` file has been loaded from the repo and parsed.
);
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."# 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.
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. 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, Contains a [MessageToken] to reduce duplicate messages being sent. Only messages with the latest [MessageToken] are handled,
all others are dropped."# all others are dropped."#);
);
message!( message!(WebhookRegistered: (WebhookId, WebhookAuth): r#"Notification that a webhook has been registered with a forge.
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 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."# incoming messages from the forge must provide."#);
);
impl WebhookRegistered { impl WebhookRegistered {
pub const fn webhook_id(&self) -> &WebhookId { pub const fn webhook_id(&self) -> &WebhookId {
&self.0 .0 &self.0 .0
@ -53,51 +38,28 @@ impl From<RegisteredWebhook> for WebhookRegistered {
} }
} }
message!( message!(UnRegisterWebhook: "Request that the webhook be removed from the forge, so they will stop notifying us.");
UnRegisterWebhook,
"Request that the webhook be removed from the forge, so they will stop notifying us."
);
newtype!( newtype!(MessageToken: u32, Copy, Default, Display, PartialOrd, Ord: r#"An incremental token used to identify the current set of messages.
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 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 received, marking that message the latest, and causing any previous messages still being processed to be dropped when
they next send a [ValidateRepo] message."# they next send a [ValidateRepo] message."#);
);
impl MessageToken { impl MessageToken {
pub const fn next(self) -> Self { pub const fn next(&self) -> Self {
Self(self.0 + 1) Self(self.0 + 1)
} }
} }
message!( message!(RegisterWebhook: "Requests that a Webhook be registered with the forge.");
RegisterWebhook, message!(CheckCIStatus: Commit: r#"Requests that the CI status for the commit be checked.
"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. 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."# Contains the commit from the tip of the `next` branch."#); // next commit
); // next commit message!(ReceiveCIStatus: (Commit, Status): r#"Notification of the status of the CI checks for the 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."# Contains a tuple of the commit that was checked (the tip of the `next` branch) and the status."#); // commit and it's status
); // commit and it's status
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct AdvanceNextPayload { pub struct AdvanceNextPayload {
@ -105,23 +67,7 @@ pub struct AdvanceNextPayload {
pub main: Commit, pub main: Commit,
pub dev_commit_history: Vec<Commit>, pub dev_commit_history: Vec<Commit>,
} }
message!( 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
AdvanceNext, message!(AdvanceMain: Commit: "Request to advance the `main` branch on to same commit as the `next` branch."); // next commit
AdvanceNextPayload, message!(WebhookNotification: ForgeNotification: "Notification of a webhook message from the forge.");
"Request to advance the `next` branch on to the next commit on the `dev branch." message!(NotifyUser: UserNotification: "Request to send the message payload to the notification webhook");
); // 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"
);

View file

@ -1,13 +1,11 @@
// //
use actix::prelude::*; use actix::prelude::*;
use crate::{
alerts::messages::NotifyUser,
server::{actor::messages::RepoUpdate, ServerActor},
};
use derive_more::Deref; use derive_more::Deref;
use kxio::network::Network; use kxio::network::Network;
use tracing::{info, instrument, warn, Instrument}; use messages::NotifyUser;
use std::time::Duration;
use tracing::{info, warn, Instrument};
use git_next_core::{ use git_next_core::{
git::{ git::{
@ -15,8 +13,7 @@ use git_next_core::{
repository::{factory::RepositoryFactory, open::OpenRepositoryLike}, repository::{factory::RepositoryFactory, open::OpenRepositoryLike},
UserNotification, UserNotification,
}, },
server::ListenUrl, server, WebhookAuth, WebhookId,
WebhookAuth, WebhookId,
}; };
mod branch; mod branch;
@ -26,11 +23,11 @@ pub mod messages;
mod notifications; mod notifications;
#[cfg(test)] #[cfg(test)]
pub mod tests; mod tests;
#[derive(Clone, Debug, Default)] #[derive(Clone, Debug, Default)]
pub struct ActorLog(std::sync::Arc<std::sync::RwLock<Vec<String>>>); pub struct RepoActorLog(std::sync::Arc<std::sync::RwLock<Vec<String>>>);
impl Deref for ActorLog { impl Deref for RepoActorLog {
type Target = std::sync::Arc<std::sync::RwLock<Vec<String>>>; type Target = std::sync::Arc<std::sync::RwLock<Vec<String>>>;
fn deref(&self) -> &Self::Target { fn deref(&self) -> &Self::Target {
@ -40,8 +37,7 @@ impl Deref for ActorLog {
/// An actor that represents a Git Repository. /// An actor that represents a Git Repository.
/// ///
/// When this actor is started it is sent the `CloneRepo` message. /// When this actor is started it is sent the [CloneRepo] message.
#[allow(clippy::module_name_repetitions)]
#[derive(Debug, derive_more::Display, derive_with::With)] #[derive(Debug, derive_more::Display, derive_with::With)]
#[display("{}:{}:{}", generation, repo_details.forge.forge_alias(), repo_details.repo_alias)] #[display("{}:{}:{}", generation, repo_details.forge.forge_alias(), repo_details.repo_alias)]
pub struct RepoActor { pub struct RepoActor {
@ -49,7 +45,7 @@ pub struct RepoActor {
generation: git::Generation, generation: git::Generation,
message_token: messages::MessageToken, message_token: messages::MessageToken,
repo_details: git::RepoDetails, repo_details: git::RepoDetails,
listen_url: ListenUrl, webhook: server::InboundWebhook,
webhook_id: Option<WebhookId>, // INFO: if [None] then no webhook is configured webhook_id: Option<WebhookId>, // INFO: if [None] then no webhook is configured
webhook_auth: Option<WebhookAuth>, // 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_main_commit: Option<git::Commit>,
@ -59,29 +55,27 @@ pub struct RepoActor {
open_repository: Option<Box<dyn OpenRepositoryLike>>, open_repository: Option<Box<dyn OpenRepositoryLike>>,
net: Network, net: Network,
forge: Box<dyn git::ForgeLike>, forge: Box<dyn git::ForgeLike>,
log: Option<ActorLog>, log: Option<RepoActorLog>,
notify_user_recipient: Option<Recipient<NotifyUser>>, notify_user_recipient: Option<Recipient<NotifyUser>>,
server_addr: Option<Addr<ServerActor>>,
} }
impl RepoActor { impl RepoActor {
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub fn new( pub fn new(
repo_details: git::RepoDetails, repo_details: git::RepoDetails,
forge: Box<dyn git::ForgeLike>, forge: Box<dyn git::ForgeLike>,
listen_url: ListenUrl, webhook: server::InboundWebhook,
generation: git::Generation, generation: git::Generation,
net: Network, net: Network,
repository_factory: Box<dyn RepositoryFactory>, repository_factory: Box<dyn RepositoryFactory>,
sleep_duration: std::time::Duration, sleep_duration: std::time::Duration,
notify_user_recipient: Option<Recipient<NotifyUser>>, notify_user_recipient: Option<Recipient<NotifyUser>>,
server_addr: Option<Addr<ServerActor>>,
) -> Self { ) -> Self {
let message_token = messages::MessageToken::default(); let message_token = messages::MessageToken::default();
Self { Self {
generation, generation,
message_token, message_token,
repo_details, repo_details,
listen_url, webhook,
webhook_id: None, webhook_id: None,
webhook_auth: None, webhook_auth: None,
last_main_commit: None, last_main_commit: None,
@ -94,56 +88,12 @@ impl RepoActor {
sleep_duration, sleep_duration,
log: None, log: None,
notify_user_recipient, 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 { impl Actor for RepoActor {
type Context = Context<Self>; type Context = Context<Self>;
#[instrument(name = "RepoActor::stopping", skip_all, fields(repo = %self.repo_details))] #[tracing::instrument(name = "RepoActor::stopping", skip_all, fields(repo = %self.repo_details))]
fn stopping(&mut self, ctx: &mut Self::Context) -> Running { fn stopping(&mut self, ctx: &mut Self::Context) -> Running {
tracing::debug!("stopping"); tracing::debug!("stopping");
info!("Checking webhook"); info!("Checking webhook");
@ -167,22 +117,33 @@ impl Actor for RepoActor {
} }
} }
pub fn do_send<M>(addr: &Addr<RepoActor>, msg: M, log: Option<&ActorLog>) pub fn delay_send<M>(addr: Addr<RepoActor>, delay: Duration, msg: M, log: Option<&RepoActorLog>)
where where
M: actix::Message + Send + 'static + std::fmt::Debug, M: actix::Message + Send + 'static + std::fmt::Debug,
RepoActor: actix::Handler<M>, RepoActor: actix::Handler<M>,
<M as actix::Message>::Result: Send, <M as actix::Message>::Result: Send,
{ {
let log_message = format!("send: {msg:?}"); let log_message = format!("send-after-delay: {:?}", msg);
info!(log_message); tracing::debug!(log_message);
logger(log, log_message); logger(log, log_message);
if cfg!(not(test)) { std::thread::sleep(delay);
// #[cfg(not(test))] do_send(addr, msg, log)
addr.do_send(msg);
}
} }
pub fn logger(log: Option<&ActorLog>, message: impl Into<String>) { pub fn do_send<M>(_addr: Addr<RepoActor>, msg: M, log: Option<&RepoActorLog>)
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);
tracing::debug!(log_message);
logger(log, log_message);
#[cfg(not(test))]
_addr.do_send(msg)
}
pub fn logger(log: Option<&RepoActorLog>, message: impl Into<String>) {
if let Some(log) = log { if let Some(log) = log {
let message: String = message.into(); let message: String = message.into();
tracing::debug!(message); tracing::debug!(message);
@ -192,10 +153,10 @@ pub fn logger(log: Option<&ActorLog>, message: impl Into<String>) {
pub fn notify_user( pub fn notify_user(
recipient: Option<&Recipient<NotifyUser>>, recipient: Option<&Recipient<NotifyUser>>,
user_notification: UserNotification, user_notification: UserNotification,
log: Option<&ActorLog>, log: Option<&RepoActorLog>,
) { ) {
let msg = NotifyUser::from(user_notification); let msg = NotifyUser::from(user_notification);
let log_message = format!("send: {msg:?}"); let log_message = format!("send: {:?}", msg);
tracing::debug!(log_message); tracing::debug!(log_message);
logger(log, log_message); logger(log, log_message);
if let Some(recipient) = &recipient { if let Some(recipient) = &recipient {

View file

@ -1,4 +1,5 @@
// //
use derive_more::Deref as _;
use crate::repo::messages::NotifyUser; use crate::repo::messages::NotifyUser;
@ -9,12 +10,11 @@ use serde_json::json;
impl NotifyUser { impl NotifyUser {
pub fn as_json(&self, timestamp: time::OffsetDateTime) -> serde_json::Value { pub fn as_json(&self, timestamp: time::OffsetDateTime) -> serde_json::Value {
let timestamp = timestamp.unix_timestamp().to_string(); let timestamp = timestamp.unix_timestamp().to_string();
match &**self { match self.deref() {
UserNotification::CICheckFailed { UserNotification::CICheckFailed {
forge_alias, forge_alias,
repo_alias, repo_alias,
commit, commit,
log,
} => json!({ } => json!({
"type": "cicheck.failed", "type": "cicheck.failed",
"timestamp": timestamp, "timestamp": timestamp,
@ -24,8 +24,7 @@ impl NotifyUser {
"commit": { "commit": {
"sha": commit.sha(), "sha": commit.sha(),
"message": commit.message() "message": commit.message()
}, }
"log": **log
} }
}), }),
UserNotification::RepoConfigLoadFailure { UserNotification::RepoConfigLoadFailure {
@ -59,9 +58,6 @@ impl NotifyUser {
repo_alias, repo_alias,
dev_branch, dev_branch,
main_branch, main_branch,
dev_commit,
main_commit,
log,
} => json!({ } => json!({
"type": "branch.dev.not-on-main", "type": "branch.dev.not-on-main",
"timestamp": timestamp, "timestamp": timestamp,
@ -71,18 +67,7 @@ impl NotifyUser {
"branches": { "branches": {
"dev": dev_branch, "dev": dev_branch,
"main": main_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
} }
}), }),
} }

View file

@ -1,34 +1,11 @@
// //
use crate::repo::branch::find_next_commit_on_dev;
use super::*; 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 { mod when_at_dev {
// next and dev branches are the same // next and dev branches are the same
use super::*; use super::*;
#[test] #[test]
fn should_not_push() { fn should_not_push() -> TestResult {
let next = given::a_commit(); let next = given::a_commit();
let main = &next; let main = &next;
let dev_commit_history = &[next.clone()]; let dev_commit_history = &[next.clone()];
@ -38,11 +15,11 @@ mod when_at_dev {
// no on_push defined - so any call to push will cause an error // no on_push defined - so any call to push will cause an error
let message_token = given::a_message_token(); let message_token = given::a_message_token();
let_assert!( let_assert!(
Err(err) = advance_next_sut( Err(err) = branch::advance_next(
&next, &next,
main, main,
dev_commit_history, dev_commit_history,
&repo_details, repo_details,
repo_config, repo_config,
&open_repository, &open_repository,
message_token, message_token,
@ -50,6 +27,7 @@ mod when_at_dev {
); );
tracing::debug!("Got: {err}"); tracing::debug!("Got: {err}");
assert!(matches!(err, branch::Error::NextAtDev)); assert!(matches!(err, branch::Error::NextAtDev));
Ok(())
} }
} }
@ -62,7 +40,7 @@ mod can_advance {
use super::*; use super::*;
#[test] #[test]
fn should_not_push() { fn should_not_push() -> TestResult {
let next = given::a_commit(); let next = given::a_commit();
let main = &next; let main = &next;
let dev = given::a_commit_with_message("wip: test: message".to_string()); let dev = given::a_commit_with_message("wip: test: message".to_string());
@ -73,11 +51,11 @@ mod can_advance {
// no on_push defined - so any call to push will cause an error // no on_push defined - so any call to push will cause an error
let message_token = given::a_message_token(); let message_token = given::a_message_token();
let_assert!( let_assert!(
Err(err) = advance_next_sut( Err(err) = branch::advance_next(
&next, &next,
main, main,
dev_commit_history, dev_commit_history,
&repo_details, repo_details,
repo_config, repo_config,
&open_repository, &open_repository,
message_token, message_token,
@ -85,6 +63,7 @@ mod can_advance {
); );
tracing::debug!("Got: {err}"); tracing::debug!("Got: {err}");
assert!(matches!(err, branch::Error::IsWorkInProgress)); assert!(matches!(err, branch::Error::IsWorkInProgress));
Ok(())
} }
} }
@ -93,7 +72,7 @@ mod can_advance {
use super::*; use super::*;
#[test] #[test]
fn should_not_push_and_error() { fn should_not_push_and_error() -> TestResult {
let next = given::a_commit(); let next = given::a_commit();
let main = &next; let main = &next;
let dev = given::a_commit(); let dev = given::a_commit();
@ -104,11 +83,11 @@ mod can_advance {
// no on_push defined - so any call to push will cause an error // no on_push defined - so any call to push will cause an error
let message_token = given::a_message_token(); let message_token = given::a_message_token();
let_assert!( let_assert!(
Err(err) = advance_next_sut( Err(err) = branch::advance_next(
&next, &next,
main, main,
dev_commit_history, dev_commit_history,
&repo_details, repo_details,
repo_config, repo_config,
&open_repository, &open_repository,
message_token, message_token,
@ -120,6 +99,7 @@ mod can_advance {
branch::Error::InvalidCommitMessage{reason} branch::Error::InvalidCommitMessage{reason}
if reason == "Missing type in the commit summary, expected `type: description`" if reason == "Missing type in the commit summary, expected `type: description`"
)); ));
Ok(())
} }
} }
@ -132,7 +112,7 @@ mod can_advance {
use super::*; use super::*;
#[test] #[test]
fn should_error() { fn should_error() -> TestResult {
let next = given::a_commit(); let next = given::a_commit();
let main = &next; let main = &next;
let dev = given::a_commit_with_message("test: message".to_string()); let dev = given::a_commit_with_message("test: message".to_string());
@ -144,11 +124,11 @@ mod can_advance {
expect::push(&mut open_repository, Err(git::push::Error::Lock)); expect::push(&mut open_repository, Err(git::push::Error::Lock));
let message_token = given::a_message_token(); let message_token = given::a_message_token();
let_assert!( let_assert!(
Err(err) = advance_next_sut( Err(err) = branch::advance_next(
&next, &next,
main, main,
dev_commit_history, dev_commit_history,
&repo_details, repo_details,
repo_config, repo_config,
&open_repository, &open_repository,
message_token, message_token,
@ -156,6 +136,7 @@ mod can_advance {
); );
tracing::debug!("Got: {err:?}"); tracing::debug!("Got: {err:?}");
assert!(matches!(err, branch::Error::Push(git::push::Error::Lock))); assert!(matches!(err, branch::Error::Push(git::push::Error::Lock)));
Ok(())
} }
} }
@ -164,7 +145,7 @@ mod can_advance {
use super::*; use super::*;
#[test] #[test]
fn should_ok() { fn should_ok() -> TestResult {
let next = given::a_commit(); let next = given::a_commit();
let main = &next; let main = &next;
let dev = given::a_commit_with_message("test: message".to_string()); let dev = given::a_commit_with_message("test: message".to_string());
@ -176,11 +157,11 @@ mod can_advance {
expect::push_ok(&mut open_repository); expect::push_ok(&mut open_repository);
let message_token = given::a_message_token(); let message_token = given::a_message_token();
let_assert!( let_assert!(
Ok(mt) = advance_next_sut( Ok(mt) = branch::advance_next(
&next, &next,
main, main,
dev_commit_history, dev_commit_history,
&repo_details, repo_details,
repo_config, repo_config,
&open_repository, &open_repository,
message_token, message_token,
@ -188,6 +169,7 @@ mod can_advance {
); );
tracing::debug!("Got: {mt:?}"); tracing::debug!("Got: {mt:?}");
assert_eq!(mt, message_token); assert_eq!(mt, message_token);
Ok(())
} }
} }
} }

View file

@ -15,7 +15,7 @@ pub fn fetch(open_repository: &mut MockOpenRepositoryLike, result: Result<(), fe
} }
pub fn push_ok(open_repository: &mut MockOpenRepositoryLike) { pub fn push_ok(open_repository: &mut MockOpenRepositoryLike) {
expect::push(open_repository, Ok(())); expect::push(open_repository, Ok(()))
} }
pub fn push( pub fn push(

View file

@ -1,5 +1,3 @@
use git_next_core::server::ListenUrl;
// //
use super::*; use super::*;
@ -20,12 +18,12 @@ pub fn has_remote_defaults(
open_repository: &mut MockOpenRepositoryLike, open_repository: &mut MockOpenRepositoryLike,
remotes: HashMap<Direction, Option<RemoteUrl>>, remotes: HashMap<Direction, Option<RemoteUrl>>,
) { ) {
for (direction, remote) in remotes { remotes.into_iter().for_each(|(direction, remote)| {
open_repository open_repository
.expect_find_default_remote() .expect_find_default_remote()
.with(eq(direction)) .with(eq(direction))
.return_once(|_| remote); .return_once(|_| remote);
} });
} }
pub fn a_webhook_auth() -> WebhookAuth { pub fn a_webhook_auth() -> WebhookAuth {
@ -52,8 +50,8 @@ pub fn a_network() -> kxio::network::MockNetwork {
kxio::network::MockNetwork::new() kxio::network::MockNetwork::new()
} }
pub fn a_listen_url() -> ListenUrl { pub fn a_webhook_url(forge_alias: &ForgeAlias, repo_alias: &RepoAlias) -> WebhookUrl {
ListenUrl::new(a_name()) InboundWebhook::new(a_name()).url(forge_alias, repo_alias)
} }
pub fn a_name() -> String { pub fn a_name() -> String {
@ -69,22 +67,6 @@ pub fn a_name() -> String {
generate(5) 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 { pub fn a_webhook_id() -> WebhookId {
WebhookId::new(a_name()) WebhookId::new(a_name())
} }
@ -105,8 +87,7 @@ pub fn a_forge_config() -> ForgeConfig {
a_name(), a_name(),
a_name(), a_name(),
a_name(), a_name(),
maybe_a_number(), Default::default(), // no repos
BTreeMap::default(), // no repos
) )
} }
@ -187,7 +168,10 @@ pub fn a_message_token() -> MessageToken {
MessageToken::default() MessageToken::default()
} }
#[allow(clippy::unnecessary_box_returns)] pub fn a_webhook(url: &WebhookUrl) -> InboundWebhook {
InboundWebhook::new(url.clone().into())
}
pub fn a_forge() -> Box<MockForgeLike> { pub fn a_forge() -> Box<MockForgeLike> {
Box::new(MockForgeLike::new()) Box::new(MockForgeLike::new())
} }
@ -197,22 +181,24 @@ pub fn a_repo_actor(
repository_factory: Box<dyn RepositoryFactory>, repository_factory: Box<dyn RepositoryFactory>,
forge: Box<dyn ForgeLike>, forge: Box<dyn ForgeLike>,
net: kxio::network::Network, net: kxio::network::Network,
) -> (RepoActor, ActorLog) { ) -> (RepoActor, RepoActorLog) {
let listen_url = given::a_listen_url(); let forge_alias = repo_details.forge.forge_alias();
let repo_alias = &repo_details.repo_alias;
let webhook_url = given::a_webhook_url(forge_alias, repo_alias);
let webhook = given::a_webhook(&webhook_url);
let generation = Generation::default(); let generation = Generation::default();
let log = ActorLog::default(); let log = RepoActorLog::default();
let actors_log = log.clone(); let actors_log = log.clone();
( (
RepoActor::new( RepoActor::new(
repo_details, repo_details,
forge, forge,
listen_url, webhook,
generation, generation,
net, net,
repository_factory, repository_factory,
std::time::Duration::from_nanos(1), std::time::Duration::from_nanos(1),
None, None,
None,
) )
.with_log(actors_log), .with_log(actors_log),
log, log,

View file

@ -25,11 +25,6 @@ async fn when_repo_config_should_fetch_then_push_then_revalidate() -> TestResult
.times(1) .times(1)
.in_sequence(&mut seq) .in_sequence(&mut seq)
.return_once(|_, _, _, _| Ok(())); .return_once(|_, _, _, _| Ok(()));
open_repository
.expect_fetch()
.times(1)
.in_sequence(&mut seq)
.return_once(|| Ok(()));
//when //when
let (addr, log) = when::start_actor_with_open_repository( let (addr, log) = when::start_actor_with_open_repository(
@ -46,13 +41,13 @@ async fn when_repo_config_should_fetch_then_push_then_revalidate() -> TestResult
log.read().map_err(|e| e.to_string()).map(|l| { log.read().map_err(|e| e.to_string()).map(|l| {
assert!(l assert!(l
.iter() .iter()
.any(|message| message.contains("send: LoadConfigFromRepo"))); .any(|message| message.contains("send: LoadConfigFromRepo")))
})?; })?;
Ok(()) Ok(())
} }
#[actix::test] #[actix::test]
async fn when_app_config_should_fetch_then_push_then_revalidate() -> TestResult { async fn when_server_config_should_fetch_then_push_then_revalidate() -> TestResult {
//given //given
let fs = given::a_filesystem(); let fs = given::a_filesystem();
let (mut open_repository, mut repo_details) = given::an_open_repository(&fs); let (mut open_repository, mut repo_details) = given::an_open_repository(&fs);
@ -75,11 +70,6 @@ async fn when_app_config_should_fetch_then_push_then_revalidate() -> TestResult
.times(1) .times(1)
.in_sequence(&mut seq) .in_sequence(&mut seq)
.return_once(|_, _, _, _| Ok(())); .return_once(|_, _, _, _| Ok(()));
open_repository
.expect_fetch()
.times(1)
.in_sequence(&mut seq)
.return_once(|| Ok(()));
//when //when
let (addr, log) = when::start_actor_with_open_repository( let (addr, log) = when::start_actor_with_open_repository(
@ -93,6 +83,10 @@ async fn when_app_config_should_fetch_then_push_then_revalidate() -> TestResult
//then //then
tracing::debug!(?log, ""); tracing::debug!(?log, "");
log.require_message_containing("send: ValidateRepo")?; log.read().map_err(|e| e.to_string()).map(|l| {
assert!(l
.iter()
.any(|message| message.contains("send: ValidateRepo")))
})?;
Ok(()) Ok(())
} }

View file

@ -1,11 +1,9 @@
use std::time::Duration;
use crate::repo::messages::AdvanceNextPayload; use crate::repo::messages::AdvanceNextPayload;
// //
use super::*; use super::*;
#[test_log::test(actix::test)] #[actix::test]
async fn should_fetch_then_push_then_revalidate() -> TestResult { async fn should_fetch_then_push_then_revalidate() -> TestResult {
//given //given
let fs = given::a_filesystem(); let fs = given::a_filesystem();
@ -28,11 +26,6 @@ async fn should_fetch_then_push_then_revalidate() -> TestResult {
.times(1) .times(1)
.in_sequence(&mut seq) .in_sequence(&mut seq)
.return_once(|_, _, _, _| Ok(())); .return_once(|_, _, _, _| Ok(()));
open_repository
.expect_fetch()
.times(1)
.in_sequence(&mut seq)
.return_once(|| Ok(()));
//when //when
let (addr, log) = when::start_actor_with_open_repository( let (addr, log) = when::start_actor_with_open_repository(
@ -48,11 +41,14 @@ async fn should_fetch_then_push_then_revalidate() -> TestResult {
}, },
)) ))
.await?; .await?;
actix_rt::time::sleep(Duration::from_millis(9)).await;
System::current().stop(); System::current().stop();
//then //then
tracing::debug!(?log, ""); tracing::debug!(?log, "");
log.require_message_containing("send: ValidateRepo")?; log.read().map_err(|e| e.to_string()).map(|l| {
assert!(l
.iter()
.any(|message| message.contains("send: ValidateRepo")))
})?;
Ok(()) Ok(())
} }

View file

@ -31,7 +31,7 @@ async fn should_passthrough_to_receive_ci_status() -> TestResult {
log.read().map_err(|e| e.to_string()).map(|l| { log.read().map_err(|e| e.to_string()).map(|l| {
assert!(l assert!(l
.iter() .iter()
.any(|message| message.contains("send: ReceiveCIStatus"))); .any(|message| message.contains("send: ReceiveCIStatus")))
})?; })?;
Ok(()) Ok(())
} }

View file

@ -43,10 +43,6 @@ async fn should_open() -> TestResult {
//given //given
let fs = given::a_filesystem(); let fs = given::a_filesystem();
let (mut open_repository, repo_details) = given::an_open_repository(&fs); 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); given::has_all_valid_remote_defaults(&mut open_repository, &repo_details);
// factory opens a repository // factory opens a repository
let mut repository_factory = MockRepositoryFactory::new(); let mut repository_factory = MockRepositoryFactory::new();
@ -83,10 +79,6 @@ async fn when_server_has_no_repo_config_should_send_load_from_repo() -> TestResu
//given //given
let fs = given::a_filesystem(); let fs = given::a_filesystem();
let (mut open_repository, mut repo_details) = given::an_open_repository(&fs); 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)] #[allow(clippy::unwrap_used)]
let _repo_config = repo_details.repo_config.take().unwrap(); let _repo_config = repo_details.repo_config.take().unwrap();
@ -114,10 +106,6 @@ async fn when_server_has_repo_config_should_send_register_webhook() -> TestResul
//given //given
let fs = given::a_filesystem(); let fs = given::a_filesystem();
let (mut open_repository, repo_details) = given::an_open_repository(&fs); let (mut open_repository, repo_details) = given::an_open_repository(&fs);
open_repository
.expect_fetch()
.times(1)
.return_once(|| Ok(()));
#[allow(clippy::unwrap_used)] #[allow(clippy::unwrap_used)]
given::has_all_valid_remote_defaults(&mut open_repository, &repo_details); given::has_all_valid_remote_defaults(&mut open_repository, &repo_details);
@ -141,10 +129,6 @@ async fn opened_repo_with_no_default_push_should_not_proceed() -> TestResult {
//given //given
let fs = given::a_filesystem(); let fs = given::a_filesystem();
let (mut open_repository, repo_details) = given::an_open_repository(&fs); let (mut open_repository, repo_details) = given::an_open_repository(&fs);
open_repository
.expect_fetch()
.times(1)
.return_once(|| Ok(()));
given::has_remote_defaults( given::has_remote_defaults(
&mut open_repository, &mut open_repository,
@ -174,10 +158,15 @@ async fn opened_repo_with_no_default_fetch_should_not_proceed() -> TestResult {
//given //given
let fs = given::a_filesystem(); let fs = given::a_filesystem();
let (mut open_repository, repo_details) = given::an_open_repository(&fs); let (mut open_repository, repo_details) = given::an_open_repository(&fs);
open_repository
.expect_fetch() given::has_remote_defaults(
.times(1) &mut open_repository,
.return_once(|| Err(git::fetch::Error::NoFetchRemoteFound)); HashMap::from([
(Direction::Push, repo_details.remote_url()),
(Direction::Fetch, None),
]),
);
let mut repository_factory = MockRepositoryFactory::new(); let mut repository_factory = MockRepositoryFactory::new();
expect::open_repository(&mut repository_factory, open_repository); expect::open_repository(&mut repository_factory, open_repository);
fs.dir_create(&repo_details.gitdir)?; fs.dir_create(&repo_details.gitdir)?;

View file

@ -32,7 +32,7 @@ async fn should_store_repo_config_in_actor() -> TestResult {
Ok(()) Ok(())
} }
#[test_log::test(actix::test)] #[actix::test]
async fn should_register_webhook() -> TestResult { async fn should_register_webhook() -> TestResult {
//given //given
let fs = given::a_filesystem(); let fs = given::a_filesystem();
@ -54,6 +54,10 @@ async fn should_register_webhook() -> TestResult {
//then //then
tracing::debug!(?log, ""); tracing::debug!(?log, "");
log.require_message_containing("send: RegisterWebhook")?; log.read().map_err(|e| e.to_string()).map(|l| {
assert!(l
.iter()
.any(|message| message.contains("send: RegisterWebhook")))
})?;
Ok(()) Ok(())
} }

View file

@ -1,5 +1,3 @@
use std::time::Duration;
// //
use super::*; use super::*;
@ -26,9 +24,9 @@ async fn when_pass_should_advance_main_to_next() -> TestResult {
//then //then
tracing::debug!(?log, ""); tracing::debug!(?log, "");
log.read().map_err(|e| e.to_string()).map(|l| { log.read().map_err(|e| e.to_string()).map(|l| {
let expected = format!("send: AdvanceMain({next_commit:?})"); let expected = format!("send: AdvanceMain({:?})", next_commit);
tracing::debug!(%expected,""); tracing::debug!(%expected,"");
assert!(l.iter().any(|message| message.contains(&expected))); assert!(l.iter().any(|message| message.contains(&expected)))
})?; })?;
Ok(()) Ok(())
} }
@ -51,12 +49,15 @@ async fn when_pending_should_recheck_ci_status() -> TestResult {
git::forge::commit::Status::Pending, git::forge::commit::Status::Pending,
))) )))
.await?; .await?;
actix_rt::time::sleep(Duration::from_millis(9)).await;
System::current().stop(); System::current().stop();
//then //then
tracing::debug!(?log, ""); tracing::debug!(?log, "");
log.require_message_containing("send: ValidateRepo")?; log.read().map_err(|e| e.to_string()).map(|l| {
assert!(l
.iter()
.any(|message| message.contains("send: ValidateRepo")))
})?;
Ok(()) Ok(())
} }
@ -78,11 +79,11 @@ async fn when_fail_should_recheck_after_delay() -> TestResult {
git::forge::commit::Status::Fail, git::forge::commit::Status::Fail,
))) )))
.await?; .await?;
actix_rt::time::sleep(Duration::from_millis(9)).await; actix_rt::time::sleep(std::time::Duration::from_millis(2)).await;
System::current().stop(); System::current().stop();
//then //then
log.require_message_containing("send: ValidateRepo")?; log.require_message_containing("send-after-delay: ValidateRepo")?;
Ok(()) Ok(())
} }

View file

@ -31,7 +31,7 @@ async fn when_registered_ok_should_send_webhook_registered() -> TestResult {
log.read().map_err(|e| e.to_string()).map(|l| { log.read().map_err(|e| e.to_string()).map(|l| {
assert!(l assert!(l
.iter() .iter()
.any(|message| message.contains("send: WebhookRegistered"))); .any(|message| message.contains("send: WebhookRegistered")))
})?; })?;
Ok(()) Ok(())
} }

View file

@ -9,7 +9,7 @@ async fn when_no_expected_auth_token_drop_notification() -> TestResult {
let forge_alias = given::a_forge_alias(); let forge_alias = given::a_forge_alias();
let repo_alias = given::a_repo_alias(); let repo_alias = given::a_repo_alias();
let headers = BTreeMap::new(); let headers = BTreeMap::new();
let body = Body::new(String::new()); let body = Body::new("".to_string());
let forge_notification = ForgeNotification::new(forge_alias, repo_alias, headers, body); let forge_notification = ForgeNotification::new(forge_alias, repo_alias, headers, body);
let repository_factory = MockRepositoryFactory::new(); let repository_factory = MockRepositoryFactory::new();
let (actor, log) = given::a_repo_actor( let (actor, log) = given::a_repo_actor(
@ -43,7 +43,7 @@ async fn when_no_repo_config_drop_notification() -> TestResult {
let forge_alias = given::a_forge_alias(); let forge_alias = given::a_forge_alias();
let repo_alias = given::a_repo_alias(); let repo_alias = given::a_repo_alias();
let headers = BTreeMap::new(); let headers = BTreeMap::new();
let body = Body::new(String::new()); let body = Body::new("".to_string());
let forge_notification = ForgeNotification::new(forge_alias, repo_alias, headers, body); let forge_notification = ForgeNotification::new(forge_alias, repo_alias, headers, body);
let repository_factory = MockRepositoryFactory::new(); let repository_factory = MockRepositoryFactory::new();
let (actor, log) = given::a_repo_actor( let (actor, log) = given::a_repo_actor(
@ -77,7 +77,7 @@ async fn when_message_auth_is_invalid_drop_notification() -> TestResult {
let forge_alias = given::a_forge_alias(); let forge_alias = given::a_forge_alias();
let repo_alias = given::a_repo_alias(); let repo_alias = given::a_repo_alias();
let headers = BTreeMap::new(); let headers = BTreeMap::new();
let body = Body::new(String::new()); let body = Body::new("".to_string());
let forge_notification = ForgeNotification::new(forge_alias, repo_alias, headers, body); let forge_notification = ForgeNotification::new(forge_alias, repo_alias, headers, body);
let repository_factory = MockRepositoryFactory::new(); let repository_factory = MockRepositoryFactory::new();
let mut forge = given::a_forge(); let mut forge = given::a_forge();
@ -115,7 +115,7 @@ async fn when_message_is_ignorable_drop_notification() -> TestResult {
let forge_alias = given::a_forge_alias(); let forge_alias = given::a_forge_alias();
let repo_alias = given::a_repo_alias(); let repo_alias = given::a_repo_alias();
let headers = BTreeMap::new(); let headers = BTreeMap::new();
let body = Body::new(String::new()); let body = Body::new("".to_string());
let forge_notification = ForgeNotification::new(forge_alias, repo_alias, headers, body); let forge_notification = ForgeNotification::new(forge_alias, repo_alias, headers, body);
let repository_factory = MockRepositoryFactory::new(); let repository_factory = MockRepositoryFactory::new();
let mut forge = given::a_forge(); let mut forge = given::a_forge();
@ -157,7 +157,7 @@ async fn when_message_is_not_a_push_drop_notification() -> TestResult {
let forge_alias = given::a_forge_alias(); let forge_alias = given::a_forge_alias();
let repo_alias = given::a_repo_alias(); let repo_alias = given::a_repo_alias();
let headers = BTreeMap::new(); let headers = BTreeMap::new();
let body = Body::new(String::new()); let body = Body::new("".to_string());
let forge_notification = ForgeNotification::new(forge_alias, repo_alias, headers, body); let forge_notification = ForgeNotification::new(forge_alias, repo_alias, headers, body);
let repository_factory = MockRepositoryFactory::new(); let repository_factory = MockRepositoryFactory::new();
let mut forge = given::a_forge(); let mut forge = given::a_forge();
@ -200,7 +200,7 @@ async fn when_message_is_push_on_unknown_branch_drop_notification() -> TestResul
let forge_alias = given::a_forge_alias(); let forge_alias = given::a_forge_alias();
let repo_alias = given::a_repo_alias(); let repo_alias = given::a_repo_alias();
let headers = BTreeMap::new(); let headers = BTreeMap::new();
let body = Body::new(String::new()); let body = Body::new("".to_string());
let forge_notification = ForgeNotification::new(forge_alias, repo_alias, headers, body); let forge_notification = ForgeNotification::new(forge_alias, repo_alias, headers, body);
let repository_factory = MockRepositoryFactory::new(); let repository_factory = MockRepositoryFactory::new();
let commit = given::a_commit(); let commit = given::a_commit();
@ -248,7 +248,7 @@ async fn when_message_is_push_already_seen_commit_to_main() -> TestResult {
let forge_alias = given::a_forge_alias(); let forge_alias = given::a_forge_alias();
let repo_alias = given::a_repo_alias(); let repo_alias = given::a_repo_alias();
let headers = BTreeMap::new(); let headers = BTreeMap::new();
let body = Body::new(String::new()); let body = Body::new("".to_string());
let forge_notification = ForgeNotification::new(forge_alias, repo_alias, headers, body); let forge_notification = ForgeNotification::new(forge_alias, repo_alias, headers, body);
let repository_factory = MockRepositoryFactory::new(); let repository_factory = MockRepositoryFactory::new();
let commit = given::a_commit(); let commit = given::a_commit();
@ -297,7 +297,7 @@ async fn when_message_is_push_already_seen_commit_to_next() -> TestResult {
let forge_alias = given::a_forge_alias(); let forge_alias = given::a_forge_alias();
let repo_alias = given::a_repo_alias(); let repo_alias = given::a_repo_alias();
let headers = BTreeMap::new(); let headers = BTreeMap::new();
let body = Body::new(String::new()); let body = Body::new("".to_string());
let forge_notification = ForgeNotification::new(forge_alias, repo_alias, headers, body); let forge_notification = ForgeNotification::new(forge_alias, repo_alias, headers, body);
let repository_factory = MockRepositoryFactory::new(); let repository_factory = MockRepositoryFactory::new();
let commit = given::a_commit(); let commit = given::a_commit();
@ -346,7 +346,7 @@ async fn when_message_is_push_already_seen_commit_to_dev() -> TestResult {
let forge_alias = given::a_forge_alias(); let forge_alias = given::a_forge_alias();
let repo_alias = given::a_repo_alias(); let repo_alias = given::a_repo_alias();
let headers = BTreeMap::new(); let headers = BTreeMap::new();
let body = Body::new(String::new()); let body = Body::new("".to_string());
let forge_notification = ForgeNotification::new(forge_alias, repo_alias, headers, body); let forge_notification = ForgeNotification::new(forge_alias, repo_alias, headers, body);
let repository_factory = MockRepositoryFactory::new(); let repository_factory = MockRepositoryFactory::new();
let commit = given::a_commit(); let commit = given::a_commit();
@ -395,7 +395,7 @@ async fn when_message_is_push_new_commit_to_main_should_stash_and_validate_repo(
let forge_alias = given::a_forge_alias(); let forge_alias = given::a_forge_alias();
let repo_alias = given::a_repo_alias(); let repo_alias = given::a_repo_alias();
let headers = BTreeMap::new(); let headers = BTreeMap::new();
let body = Body::new(String::new()); let body = Body::new("".to_string());
let forge_notification = ForgeNotification::new(forge_alias, repo_alias, headers, body); let forge_notification = ForgeNotification::new(forge_alias, repo_alias, headers, body);
let repository_factory = MockRepositoryFactory::new(); let repository_factory = MockRepositoryFactory::new();
let push_commit = given::a_commit(); let push_commit = given::a_commit();
@ -443,7 +443,7 @@ async fn when_message_is_push_new_commit_to_next_should_stash_and_validate_repo(
let forge_alias = given::a_forge_alias(); let forge_alias = given::a_forge_alias();
let repo_alias = given::a_repo_alias(); let repo_alias = given::a_repo_alias();
let headers = BTreeMap::new(); let headers = BTreeMap::new();
let body = Body::new(String::new()); let body = Body::new("".to_string());
let forge_notification = ForgeNotification::new(forge_alias, repo_alias, headers, body); let forge_notification = ForgeNotification::new(forge_alias, repo_alias, headers, body);
let repository_factory = MockRepositoryFactory::new(); let repository_factory = MockRepositoryFactory::new();
let push_commit = given::a_commit(); let push_commit = given::a_commit();
@ -491,7 +491,7 @@ async fn when_message_is_push_new_commit_to_dev_should_stash_and_validate_repo()
let forge_alias = given::a_forge_alias(); let forge_alias = given::a_forge_alias();
let repo_alias = given::a_repo_alias(); let repo_alias = given::a_repo_alias();
let headers = BTreeMap::new(); let headers = BTreeMap::new();
let body = Body::new(String::new()); let body = Body::new("".to_string());
let forge_notification = ForgeNotification::new(forge_alias, repo_alias, headers, body); let forge_notification = ForgeNotification::new(forge_alias, repo_alias, headers, body);
let repository_factory = MockRepositoryFactory::new(); let repository_factory = MockRepositoryFactory::new();
let push_commit = given::a_commit(); let push_commit = given::a_commit();

View file

@ -5,7 +5,7 @@ use crate::{
git, git,
repo::{ repo::{
messages::{CloneRepo, MessageToken}, messages::{CloneRepo, MessageToken},
ActorLog, RepoActor, RepoActor, RepoActorLog,
}, },
}; };
@ -21,6 +21,7 @@ use git_next_core::{
Commit, ForgeLike, Generation, MockForgeLike, RepoDetails, Commit, ForgeLike, Generation, MockForgeLike, RepoDetails,
}, },
message, message,
server::{InboundWebhook, WebhookUrl},
webhook::{forge_notification::Body, Push}, webhook::{forge_notification::Body, Push},
BranchName, ForgeAlias, ForgeConfig, ForgeNotification, ForgeType, GitDir, Hostname, BranchName, ForgeAlias, ForgeConfig, ForgeNotification, ForgeType, GitDir, Hostname,
RegisteredWebhook, RemoteUrl, RepoAlias, RepoBranches, RepoConfig, RepoConfigSource, RegisteredWebhook, RemoteUrl, RepoAlias, RepoBranches, RepoConfig, RepoConfigSource,
@ -45,7 +46,7 @@ mod handlers;
mod load; mod load;
mod when; mod when;
impl ActorLog { impl RepoActorLog {
pub fn no_message_contains(&self, needle: impl AsRef<str> + std::fmt::Display) -> TestResult { pub fn no_message_contains(&self, needle: impl AsRef<str> + std::fmt::Display) -> TestResult {
if self.find_in_messages(needle.as_ref())? { if self.find_in_messages(needle.as_ref())? {
error!(?self, ""); error!(?self, "");
@ -78,7 +79,7 @@ impl ActorLog {
} }
} }
message!(ExamineActor => RepoActorView, "Request a view of the current state of the [RepoActor]."); message!(ExamineActor => RepoActorView: "Request a view of the current state of the [RepoActor].");
impl Handler<ExamineActor> for RepoActor { impl Handler<ExamineActor> for RepoActor {
type Result = RepoActorView; type Result = RepoActorView;

View file

@ -5,7 +5,7 @@ pub fn start_actor(
repository_factory: MockRepositoryFactory, repository_factory: MockRepositoryFactory,
repo_details: RepoDetails, repo_details: RepoDetails,
forge: Box<dyn ForgeLike>, forge: Box<dyn ForgeLike>,
) -> (actix::Addr<RepoActor>, ActorLog) { ) -> (actix::Addr<RepoActor>, RepoActorLog) {
let (actor, log) = given::a_repo_actor( let (actor, log) = given::a_repo_actor(
repo_details, repo_details,
Box::new(repository_factory), Box::new(repository_factory),
@ -19,7 +19,7 @@ pub fn start_actor_with_open_repository(
open_repository: Box<dyn OpenRepositoryLike>, open_repository: Box<dyn OpenRepositoryLike>,
repo_details: RepoDetails, repo_details: RepoDetails,
forge: Box<dyn ForgeLike>, forge: Box<dyn ForgeLike>,
) -> (actix::Addr<RepoActor>, ActorLog) { ) -> (actix::Addr<RepoActor>, RepoActorLog) {
let (actor, log) = given::a_repo_actor(repo_details, mock(), forge, given::a_network().into()); let (actor, log) = given::a_repo_actor(repo_details, mock(), forge, given::a_network().into());
let actor = actor.with_open_repository(Some(open_repository)); let actor = actor.with_open_repository(Some(open_repository));
(actor.start(), log) (actor.start(), log)

View file

@ -1,20 +1,24 @@
// //
use actix::prelude::*; use actix::prelude::*;
use git_next_core::server::AppConfig; use git_next_core::server::ServerConfig;
use crate::{ use crate::{
file_watcher::FileUpdated, file_watcher::FileUpdated,
server::actor::{messages::ReceiveAppConfig, ServerActor}, server::actor::{messages::ReceiveServerConfig, ServerActor},
}; };
impl Handler<FileUpdated> for ServerActor { impl Handler<FileUpdated> for ServerActor {
type Result = (); type Result = ();
fn handle(&mut self, _msg: FileUpdated, ctx: &mut Self::Context) -> Self::Result { fn handle(&mut self, _msg: FileUpdated, ctx: &mut Self::Context) -> Self::Result {
match AppConfig::load(&self.fs) { let server_config = match ServerConfig::load(&self.fs) {
Ok(app_config) => self.do_send(ReceiveAppConfig::new(app_config), ctx), Ok(server_config) => server_config,
Err(err) => self.abort(ctx, format!("Failed to load config file. Error: {err}")), Err(err) => {
tracing::error!("Failed to load config file. Error: {}", err);
return;
}
}; };
self.do_send(ReceiveServerConfig::new(server_config), ctx);
} }
} }

View file

@ -1,7 +1,5 @@
mod file_updated; mod file_updated;
mod receive_app_config; mod notify_user;
mod receive_valid_app_config; mod receive_server_config;
mod server_update; mod receive_valid_server_config;
mod shutdown; mod shutdown;
mod shutdown_trigger;
mod subscribe_updates;

View file

@ -0,0 +1,82 @@
//
use actix::prelude::*;
use secrecy::ExposeSecret;
use standardwebhooks::Webhook;
use tracing::Instrument;
use crate::{repo::messages::NotifyUser, server::actor::ServerActor};
use git_next_core::server::{self, Notification, NotificationType};
impl Handler<NotifyUser> for ServerActor {
type Result = ();
fn handle(&mut self, msg: NotifyUser, ctx: &mut Self::Context) -> Self::Result {
let Some(server_config) = &self.server_config else {
return;
};
let notification_config = server_config.notification().clone();
let net = self.net.clone();
async move {
match notification_config.r#type() {
NotificationType::None => { /* do nothing */ }
NotificationType::Webhook => send_webhook(msg, notification_config, net).await,
}
}
.in_current_span()
.into_actor(self)
.wait(ctx);
}
}
async fn send_webhook(
msg: NotifyUser,
notification_config: Notification,
net: kxio::network::Network,
) {
let Some(webhook_config) = notification_config.webhook() else {
tracing::warn!("Invalid notification configuration (config) - can't sent notification");
return;
};
let Ok(webhook) = Webhook::new(webhook_config.secret().expose_secret()) else {
tracing::warn!("Invalid notification configuration (signer) - can't sent notification");
return;
};
do_send_webhook(msg, webhook, webhook_config, net).await;
}
async fn do_send_webhook(
msg: NotifyUser,
webhook: Webhook,
webhook_config: &server::OutboundWebhook,
net: kxio::network::Network,
) {
let message_id = format!("msg_{}", ulid::Ulid::new());
let timestamp = time::OffsetDateTime::now_utc();
let payload = msg.as_json(timestamp);
let timestamp = timestamp.unix_timestamp();
let to_sign = format!("{message_id}.{timestamp}.{payload}");
tracing::info!(?to_sign, "");
#[allow(clippy::expect_used)]
let signature = webhook
.sign(&message_id, timestamp, payload.to_string().as_ref())
.expect("signature");
tracing::info!(?signature, "");
let url = webhook_config.url();
use kxio::network::{NetRequest, NetUrl, RequestBody, ResponseType};
let net_url = NetUrl::new(url.to_string());
let request = NetRequest::post(net_url)
.body(RequestBody::Json(payload))
.header("webhook-id", &message_id)
.header("webhook-timestamp", &timestamp.to_string())
.header("webhook-signature", &signature)
.response_type(ResponseType::None)
.build();
net.post_json::<()>(request).await.map_or_else(
|err| {
tracing::warn!(?err, "sending webhook");
},
|_| (),
);
}

View file

@ -1,35 +0,0 @@
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,
);
}
}

View file

@ -0,0 +1,38 @@
use actix::prelude::*;
use crate::server::actor::{
messages::{ReceiveServerConfig, ReceiveValidServerConfig, ValidServerConfig},
ServerActor,
};
impl Handler<ReceiveServerConfig> for ServerActor {
type Result = ();
#[allow(clippy::cognitive_complexity)]
fn handle(&mut self, msg: ReceiveServerConfig, ctx: &mut Self::Context) -> Self::Result {
tracing::info!("recieved server config");
let Ok(socket_addr) = msg.http() else {
tracing::error!("Unable to parse http.addr");
return;
};
let Some(server_storage) = self.server_storage(&msg) else {
tracing::error!("Server storage not available");
return;
};
if msg.inbound_webhook().base_url().ends_with('/') {
tracing::error!("webhook.url must not end with a '/'");
return;
}
self.do_send(
ReceiveValidServerConfig::new(ValidServerConfig::new(
msg.unwrap(),
socket_addr,
server_storage,
)),
ctx,
);
}
}

View file

@ -1,97 +0,0 @@
//
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,
&notify_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)
}

View file

@ -0,0 +1,69 @@
//
use actix::prelude::*;
use tracing::info;
use crate::{
server::actor::{
messages::{ReceiveValidServerConfig, ValidServerConfig},
ServerActor,
},
webhook::{
messages::ShutdownWebhook,
router::{AddWebhookRecipient, WebhookRouter},
WebhookActor,
},
};
impl Handler<ReceiveValidServerConfig> for ServerActor {
type Result = ();
fn handle(&mut self, msg: ReceiveValidServerConfig, ctx: &mut Self::Context) -> Self::Result {
let ValidServerConfig {
server_config,
socket_address,
server_storage,
} = msg.unwrap();
// 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 = WebhookRouter::default().start();
let inbound_webhook = server_config.inbound_webhook();
// Forge Actors
for (forge_alias, forge_config) in server_config.forges() {
let repo_actors = self
.create_forge_repos(
forge_config,
forge_alias.clone(),
&server_storage,
inbound_webhook,
ctx.address().recipient(),
)
.into_iter()
.map(|a| self.start_actor(a))
.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));
repo_actors.into_iter().for_each(|(repo_alias, addr)| {
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);
self.server_config.replace(server_config);
}
}

View file

@ -1,14 +0,0 @@
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());
});
}
}

View file

@ -1,12 +0,0 @@
//
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());
}
}

View file

@ -1,10 +0,0 @@
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());
}
}

View file

@ -1,113 +1,27 @@
// //-
use actix::{Message, Recipient};
use derive_more::Constructor; use derive_more::Constructor;
use git_next_core::{ use git_next_core::{
git::{self, forge::commit::Status, graph::Log, Commit},
message, message,
server::{AppConfig, Storage}, server::{ServerConfig, ServerStorage},
webhook::{push::Branch, Push},
ForgeAlias, RepoAlias, RepoBranches, RepoConfig,
}; };
use std::net::SocketAddr; use std::net::SocketAddr;
// receive server config // receive server config
message!( message!(ReceiveServerConfig: ServerConfig: "Notification of newly loaded server configuration.
ReceiveAppConfig,
AppConfig,
"Notification of newly loaded server configuration.
This message will prompt the `git-next` server to stop and restart all repo-actors. This message will prompt the `git-next` server to stop and restart all repo-actors.
Contains the new server configuration." Contains the new server configuration.");
);
// receive valid server config // receive valid server config
#[derive(Clone, Debug, PartialEq, Eq, Constructor)] #[derive(Clone, Debug, PartialEq, Eq, Constructor)]
pub struct ValidAppConfig { pub struct ValidServerConfig {
pub app_config: AppConfig, pub server_config: ServerConfig,
pub socket_address: SocketAddr, pub socket_address: SocketAddr,
pub storage: Storage, pub server_storage: ServerStorage,
} }
message!( message!(ReceiveValidServerConfig: ValidServerConfig: "Notification of validated server configuration.");
ReceiveValidAppConfig,
ValidAppConfig,
"Notification of validated server configuration."
);
message!(Shutdown, "Notification to shutdown the server actor"); 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
}
}

View file

@ -1,6 +1,6 @@
// //
use actix::prelude::*; use actix::prelude::*;
use messages::{ReceiveAppConfig, ServerUpdate, Shutdown}; use messages::ReceiveServerConfig;
use tracing::error; use tracing::error;
#[cfg(test)] #[cfg(test)]
@ -10,13 +10,17 @@ mod handlers;
pub mod messages; pub mod messages;
use crate::{ use crate::{
alerts::messages::NotifyUser, alerts::AlertsActor, forge::Forge, repo::RepoActor, forge::Forge,
repo::{
messages::{CloneRepo, NotifyUser},
RepoActor,
},
webhook::WebhookActor, webhook::WebhookActor,
}; };
use git_next_core::{ use git_next_core::{
git::{repository::factory::RepositoryFactory, Generation, RepoDetails}, git::{repository::factory::RepositoryFactory, Generation, RepoDetails},
server::{self, AppConfig, ListenUrl, Storage}, server::{self, InboundWebhook, ServerConfig, ServerStorage},
ForgeAlias, ForgeConfig, GitDir, RepoAlias, ServerRepoConfig, StoragePathType, ForgeAlias, ForgeConfig, GitDir, RepoAlias, ServerRepoConfig, StoragePathType,
}; };
@ -44,23 +48,18 @@ pub enum Error {
} }
type Result<T> = core::result::Result<T, Error>; type Result<T> = core::result::Result<T, Error>;
#[allow(clippy::module_name_repetitions)]
#[derive(derive_with::With)] #[derive(derive_with::With)]
#[with(message_log)] #[with(message_log)]
pub struct ServerActor { pub struct ServerActor {
app_config: Option<AppConfig>, server_config: Option<ServerConfig>,
generation: Generation, generation: Generation,
webhook_actor_addr: Option<Addr<WebhookActor>>, webhook_actor_addr: Option<Addr<WebhookActor>>,
fs: FileSystem, fs: FileSystem,
net: Network, net: Network,
alerts: Addr<AlertsActor>,
repository_factory: Box<dyn RepositoryFactory>, repository_factory: Box<dyn RepositoryFactory>,
sleep_duration: std::time::Duration, sleep_duration: std::time::Duration,
repo_actors: BTreeMap<(ForgeAlias, RepoAlias), Addr<RepoActor>>, repo_actors: BTreeMap<(ForgeAlias, RepoAlias), Addr<RepoActor>>,
shutdown_trigger: Option<std::sync::mpsc::Sender<String>>,
subscribers: Vec<Recipient<ServerUpdate>>,
// testing // testing
message_log: Option<Arc<RwLock<Vec<String>>>>, message_log: Option<Arc<RwLock<Vec<String>>>>,
} }
@ -72,21 +71,17 @@ impl ServerActor {
pub fn new( pub fn new(
fs: FileSystem, fs: FileSystem,
net: Network, net: Network,
alerts: Addr<AlertsActor>,
repo: Box<dyn RepositoryFactory>, repo: Box<dyn RepositoryFactory>,
sleep_duration: std::time::Duration, sleep_duration: std::time::Duration,
) -> Self { ) -> Self {
let generation = Generation::default(); let generation = Generation::default();
Self { Self {
app_config: None, server_config: None,
generation, generation,
webhook_actor_addr: None, webhook_actor_addr: None,
fs, fs,
net, net,
alerts,
repository_factory: repo, repository_factory: repo,
shutdown_trigger: None,
subscribers: Vec::default(),
sleep_duration, sleep_duration,
repo_actors: BTreeMap::new(), repo_actors: BTreeMap::new(),
message_log: None, message_log: None,
@ -94,10 +89,10 @@ impl ServerActor {
} }
fn create_forge_data_directories( fn create_forge_data_directories(
&self, &self,
app_config: &AppConfig, server_config: &ServerConfig,
server_dir: &std::path::Path, server_dir: &std::path::Path,
) -> Result<()> { ) -> Result<()> {
for (forge_name, _forge_config) in app_config.forges() { for (forge_name, _forge_config) in server_config.forges() {
let forge_dir: PathBuf = (&forge_name).into(); let forge_dir: PathBuf = (&forge_name).into();
let path = server_dir.join(&forge_dir); let path = server_dir.join(&forge_dir);
if self.fs.path_exists(&path)? { if self.fs.path_exists(&path)? {
@ -117,10 +112,9 @@ impl ServerActor {
&self, &self,
forge_config: &ForgeConfig, forge_config: &ForgeConfig,
forge_name: ForgeAlias, forge_name: ForgeAlias,
server_storage: &Storage, server_storage: &ServerStorage,
listen_url: &ListenUrl, webhook: &InboundWebhook,
notify_user_recipient: &Recipient<NotifyUser>, notify_user_recipient: Recipient<NotifyUser>,
server_addr: Option<Addr<Self>>,
) -> Vec<(ForgeAlias, RepoAlias, RepoActor)> { ) -> Vec<(ForgeAlias, RepoAlias, RepoActor)> {
let span = let span =
tracing::info_span!("create_forge_repos", name = %forge_name, config = %forge_config); tracing::info_span!("create_forge_repos", name = %forge_name, config = %forge_config);
@ -128,13 +122,7 @@ impl ServerActor {
let _guard = span.enter(); let _guard = span.enter();
tracing::info!("Creating Forge"); tracing::info!("Creating Forge");
let mut repos = vec![]; let mut repos = vec![];
let creator = self.create_actor( let creator = self.create_actor(forge_name, forge_config.clone(), server_storage, webhook);
forge_name,
forge_config.clone(),
server_storage,
listen_url,
server_addr,
);
for (repo_alias, server_repo_config) in forge_config.repos() { for (repo_alias, server_repo_config) in forge_config.repos() {
let forge_repo = creator(( let forge_repo = creator((
repo_alias, repo_alias,
@ -154,18 +142,18 @@ impl ServerActor {
&self, &self,
forge_name: ForgeAlias, forge_name: ForgeAlias,
forge_config: ForgeConfig, forge_config: ForgeConfig,
server_storage: &Storage, server_storage: &ServerStorage,
listen_url: &ListenUrl, webhook: &InboundWebhook,
server_addr: Option<Addr<Self>>,
) -> impl Fn( ) -> impl Fn(
(RepoAlias, &ServerRepoConfig, Recipient<NotifyUser>), (RepoAlias, &ServerRepoConfig, Recipient<NotifyUser>),
) -> (ForgeAlias, RepoAlias, RepoActor) { ) -> (ForgeAlias, RepoAlias, RepoActor) {
let server_storage = server_storage.clone(); let server_storage = server_storage.clone();
let listen_url = listen_url.clone(); let webhook = webhook.clone();
let net = self.net.clone(); let net = self.net.clone();
let repository_factory = self.repository_factory.duplicate(); let repository_factory = self.repository_factory.duplicate();
let generation = self.generation; let generation = self.generation;
let sleep_duration = self.sleep_duration; let sleep_duration = self.sleep_duration;
// let notify_user_recipient = server_addr.recipient();
move |(repo_alias, server_repo_config, notify_user_recipient)| { move |(repo_alias, server_repo_config, notify_user_recipient)| {
let span = tracing::info_span!("create_actor", alias = %repo_alias, config = %server_repo_config); let span = tracing::info_span!("create_actor", alias = %repo_alias, config = %server_repo_config);
let _guard = span.enter(); let _guard = span.enter();
@ -197,20 +185,32 @@ impl ServerActor {
let actor = RepoActor::new( let actor = RepoActor::new(
repo_details, repo_details,
forge, forge,
listen_url.clone(), webhook.clone(),
generation, generation,
net.clone(), net.clone(),
repository_factory.duplicate(), repository_factory.duplicate(),
sleep_duration, sleep_duration,
Some(notify_user_recipient), Some(notify_user_recipient),
server_addr.clone(),
); );
(forge_name.clone(), repo_alias, actor) (forge_name.clone(), repo_alias, actor)
} }
} }
fn server_storage(&self, app_config: &ReceiveAppConfig) -> Option<Storage> { fn start_actor(
let server_storage = app_config.storage().clone(); &self,
actor: (ForgeAlias, RepoAlias, RepoActor),
) -> (RepoAlias, Addr<RepoActor>) {
let (forge_name, repo_alias, actor) = actor;
let span = tracing::info_span!("start_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)
}
fn server_storage(&self, server_config: &ReceiveServerConfig) -> Option<ServerStorage> {
let server_storage = server_config.storage().clone();
let dir = server_storage.path(); let dir = server_storage.path();
if !dir.exists() { if !dir.exists() {
if let Err(err) = self.fs.dir_create(dir) { if let Err(err) = self.fs.dir_create(dir) {
@ -222,39 +222,26 @@ impl ServerActor {
error!(?dir, "Failed to confirm server storage"); error!(?dir, "Failed to confirm server storage");
return None; return None;
}; };
if let Err(err) = self.create_forge_data_directories(app_config, &canon) { if let Err(err) = self.create_forge_data_directories(server_config, &canon) {
error!(?err, "Failure creating forge storage"); error!(?err, "Failure creating forge storage");
return None; return None;
} }
Some(server_storage) Some(server_storage)
} }
/// Attempts to gracefully shutdown the server before stopping the system. fn do_send<M>(&mut self, msg: M, _ctx: &mut <Self as actix::Actor>::Context)
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 where
M: actix::Message + Send + 'static + std::fmt::Debug, M: actix::Message + Send + 'static + std::fmt::Debug,
Self: actix::Handler<M>, Self: actix::Handler<M>,
<M as actix::Message>::Result: Send, <M as actix::Message>::Result: Send,
{ {
if let Some(message_log) = &self.message_log { if let Some(message_log) = &self.message_log {
let log_message = format!("send: {msg:?}"); let log_message = format!("send: {:?}", msg);
if let Ok(mut log) = message_log.write() { if let Ok(mut log) = message_log.write() {
log.push(log_message); log.push(log_message);
} }
} }
if cfg!(not(test)) { #[cfg(not(test))]
ctx.address().do_send(msg); _ctx.address().do_send(msg);
}
} }
} }

View file

@ -1,10 +1,3 @@
use std::time::Duration;
use actix::prelude::*;
use crate::alerts::{AlertsActor, History};
//
pub fn a_filesystem() -> kxio::fs::FileSystem { pub fn a_filesystem() -> kxio::fs::FileSystem {
kxio::fs::temp().unwrap_or_else(|e| panic!("{}", e)) kxio::fs::temp().unwrap_or_else(|e| panic!("{}", e))
} }
@ -12,7 +5,3 @@ pub fn a_filesystem() -> kxio::fs::FileSystem {
pub fn a_network() -> kxio::network::MockNetwork { pub fn a_network() -> kxio::network::MockNetwork {
kxio::network::MockNetwork::new() kxio::network::MockNetwork::new()
} }
pub fn an_alerts_actor(net: kxio::network::Network) -> Addr<AlertsActor> {
AlertsActor::new(None, History::new(Duration::from_millis(1)), net).start()
}

View file

@ -1,3 +1,3 @@
mod receive_app_config; mod receive_server_config;
mod given; mod given;

View file

@ -1,10 +1,10 @@
// //
use actix::prelude::*; use actix::prelude::*;
use crate::server::actor::{tests::given, ReceiveAppConfig, ServerActor}; use crate::server::actor::{tests::given, ReceiveServerConfig, ServerActor};
use git_next_core::{ use git_next_core::{
git, git,
server::{AppConfig, Http, Listen, ListenUrl, Shout, Storage}, server::{Http, InboundWebhook, Notification, ServerConfig, ServerStorage},
}; };
use std::{ use std::{
@ -18,20 +18,17 @@ async fn when_webhook_url_has_trailing_slash_should_not_send() {
// parameters // parameters
let fs = given::a_filesystem(); let fs = given::a_filesystem();
let net = given::a_network(); let net = given::a_network();
let alerts = given::an_alerts_actor(net.clone().into());
let repo = git::repository::factory::mock(); let repo = git::repository::factory::mock();
let duration = std::time::Duration::from_millis(1); let duration = std::time::Duration::from_millis(1);
// sut // sut
let server = ServerActor::new(fs.clone(), net.into(), alerts, repo, duration); let server = ServerActor::new(fs.clone(), net.into(), repo, duration);
// collaborators // collaborators
let listen = Listen::new( let http = Http::new("0.0.0.0".to_string(), 80);
Http::new("0.0.0.0".to_string(), 80), let webhook = InboundWebhook::new("http://localhost/".to_string()); // With trailing slash
ListenUrl::new("http://localhost/".to_string()), // with trailing slash let notifications = Notification::none();
); let server_storage = ServerStorage::new((fs.base()).to_path_buf());
let shout = Shout::default();
let server_storage = Storage::new((fs.base()).to_path_buf());
let repos = BTreeMap::default(); let repos = BTreeMap::default();
// debugging // debugging
@ -39,9 +36,12 @@ async fn when_webhook_url_has_trailing_slash_should_not_send() {
let server = server.with_message_log(Some(message_log.clone())); let server = server.with_message_log(Some(message_log.clone()));
//when //when
server.start().do_send(ReceiveAppConfig::new(AppConfig::new( server
listen, .start()
shout, .do_send(ReceiveServerConfig::new(ServerConfig::new(
http,
webhook,
notifications,
server_storage, server_storage,
repos, repos,
))); )));
@ -52,5 +52,5 @@ async fn when_webhook_url_has_trailing_slash_should_not_send() {
tracing::debug!(?message_log, ""); tracing::debug!(?message_log, "");
assert!(message_log.read().iter().any(|log| !log assert!(message_log.read().iter().any(|log| !log
.iter() .iter()
.any(|line| line == "send: ReceiveValidServerConfig"))); .any(|line| line != "send: ReceiveValidServerConfig")));
} }

View file

@ -1,190 +1,87 @@
// //
pub mod actor; mod actor;
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;
use actix::prelude::*; 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 crate::file_watcher::{watch_file, FileUpdated};
use actor::ServerActor;
use git_next_core::git::RepositoryFactory; use git_next_core::git::RepositoryFactory;
use color_eyre::{eyre::Context, Result};
use kxio::{fs::FileSystem, network::Network}; use kxio::{fs::FileSystem, network::Network};
use tracing::info; use tracing::{info, level_filters::LevelFilter};
use std::{ use std::path::PathBuf;
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) {
pub fn init(fs: &FileSystem) -> Result<()> {
let file_name = "git-next-server.toml"; let file_name = "git-next-server.toml";
let pathbuf = PathBuf::from(file_name); let pathbuf = PathBuf::from(file_name);
if fs let Ok(exists) = fs.path_exists(&pathbuf) else {
.path_exists(&pathbuf) eprintln!("Could not check if file exist: {}", file_name);
.with_context(|| format!("Checking for existing file: {pathbuf:?}"))? return;
{ };
eprintln!("The configuration file already exists at {pathbuf:?} - not overwritting it.",); if exists {
eprintln!(
"The configuration file already exists at {} - not overwritting it.",
file_name
);
} else { } else {
fs.file_write(&pathbuf, include_str!("server-default.toml")) match fs.file_write(&pathbuf, include_str!("server-default.toml")) {
.with_context(|| format!("Writing file: {pathbuf:?}"))?; Ok(_) => println!("Created a default configuration file at {}", file_name),
println!("Created a default configuration file at {pathbuf:?}",); Err(e) => {
eprintln!("Failed to write to the configuration file: {}", e)
}
}
} }
Ok(())
} }
#[allow(clippy::too_many_lines)]
pub fn start( pub fn start(
ui: bool,
fs: FileSystem, fs: FileSystem,
net: Network, net: Network,
repo: Box<dyn RepositoryFactory>, repo: Box<dyn RepositoryFactory>,
sleep_duration: std::time::Duration, sleep_duration: std::time::Duration,
) -> Result<()> { ) {
if ui {
#[cfg(feature = "tui")]
{
crate::tui::logging::initialize_logging()?;
}
} else {
init_logging(); 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 { let execution = async move {
info!("Starting Alert Dispatcher...");
let alerts_addr = AlertsActor::new(None, History::new(A_DAY), net.clone()).start();
info!("Starting Server..."); info!("Starting Server...");
let server = let server = ServerActor::new(fs.clone(), net.clone(), repo, sleep_duration).start();
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); server.do_send(FileUpdated);
info!("Server running - Press Ctrl-C to stop..."); info!("Starting File Watcher...");
tokio::select! { watch_file("git-next-server.toml".into(), server.clone().recipient()).await;
_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 info!("Server running - Press Ctrl-C to stop...");
fw_shutdown.store(true, Ordering::Relaxed); let _ = actix_rt::signal::ctrl_c().await;
info!("Ctrl-C received, shutting down...");
server.do_send(crate::server::actor::messages::Shutdown); server.do_send(crate::server::actor::messages::Shutdown);
actix_rt::time::sleep(std::time::Duration::from_millis(10)).await; actix_rt::time::sleep(std::time::Duration::from_millis(200)).await;
System::current().stop(); System::current().stop();
}; };
let system = System::new(); let system = System::new();
Arbiter::current().spawn(execution); Arbiter::current().spawn(execution);
system.run()?; if let Err(err) = system.run() {
tracing::error!(?err, "")
// 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() { pub fn init_logging() {
use tracing::Level; use tracing_subscriber::prelude::*;
use tracing_subscriber::FmtSubscriber; use tracing_subscriber::EnvFilter;
let subscriber = FmtSubscriber::builder() let subscriber = tracing_subscriber::fmt::layer()
.with_target(false) .with_target(false)
.with_file(true) .with_file(true)
.with_line_number(true) .with_line_number(true)
.with_max_level(Level::INFO) .with_filter(
.finish(); EnvFilter::builder()
.with_default_directive(LevelFilter::INFO.into())
#[allow(clippy::expect_used)] .from_env_lossy(),
tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber failed"); );
tracing_subscriber::registry()
.with(console_subscriber::ConsoleLayer::builder().spawn())
.with(subscriber)
.init();
} }

View file

@ -1,35 +1,25 @@
[listen] [http]
# The address and port to listen to for incoming webhooks from forges. addr = "0.0.0.0"
http = { addr = "0.0.0.0", port = 8080 } port = 8080
# The URL where forge should send updates to. [webhook]
# 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 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 [storage]
# 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" path = "./data"
[forge] # the forges to connect to [notifications]
type = "WebHook"
webhook = { url = "https://localhost:9090" }
# [forge.default] [forge]
# forge_type = "ForgeJo"
# hostname = "git.example.net" [forge.default]
# user = "bob" # the user to perform actions as forge_type = "ForgeJo"
# token = "API-Token" hostname = "git.example.net"
# user = "git-next" # the user to perform actions as
# [forge.default.repos] # the repos at the forge to manage token = "API-Token"
# 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" } [forge.default.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

View file

@ -6,7 +6,7 @@ use git_next_core::{
ApiToken, ForgeType, GitDir, Hostname, RepoBranches, RepoConfig, RepoConfigSource, RepoPath, ApiToken, ForgeType, GitDir, Hostname, RepoBranches, RepoConfig, RepoConfigSource, RepoPath,
StoragePathType, User, StoragePathType, User,
}; };
use secrecy::SecretString; use secrecy::Secret;
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>; type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
@ -37,7 +37,7 @@ fn gitdir_should_display_as_pathbuf() {
//given //given
let gitdir = GitDir::new("foo/dir".into(), StoragePathType::External); let gitdir = GitDir::new("foo/dir".into(), StoragePathType::External);
//when //when
let result = format!("{gitdir}"); let result = format!("{}", gitdir);
//then //then
assert_eq!(result, "foo/dir"); assert_eq!(result, "foo/dir");
} }
@ -59,13 +59,10 @@ fn repo_details_find_default_push_remote_finds_correct_remote() -> Result<()> {
repo_details.forge = repo_details repo_details.forge = repo_details
.forge .forge
.with_user(User::new("git".to_string())) .with_user(User::new("git".to_string()))
.with_token(ApiToken::new(SecretString::from(String::new()))) .with_token(ApiToken::new(Secret::new("".to_string())))
.with_hostname(Hostname::new("git.kemitix.net")); .with_hostname(Hostname::new("git.kemitix.net"));
repo_details.repo_path = RepoPath::new("kemitix/git-next".to_string()); repo_details.repo_path = RepoPath::new("kemitix/git-next".to_string());
let Ok(open_repository) = git::repository::factory::real().open(&repo_details) else { let open_repository = git::repository::factory::real().open(&repo_details)?;
// .git directory may not be present on dev environment
return Ok(());
};
let_assert!( let_assert!(
Some(found_git_remote) = open_repository.find_default_remote(Direction::Push), Some(found_git_remote) = open_repository.find_default_remote(Direction::Push),
"Default Push Remote not found" "Default Push Remote not found"
@ -95,13 +92,13 @@ fn gitdir_validate_should_pass_a_valid_git_repo() -> Result<()> {
repo_details.forge = repo_details repo_details.forge = repo_details
.forge .forge
.with_user(User::new("git".to_string())) .with_user(User::new("git".to_string()))
.with_token(ApiToken::new(SecretString::from(String::new()))) .with_token(ApiToken::new(Secret::new("".to_string())))
.with_hostname(Hostname::new("git.kemitix.net")); .with_hostname(Hostname::new("git.kemitix.net"));
tracing::debug!("opening..."); tracing::debug!("opening...");
let Ok(repository) = git::repository::factory::real().open(&repo_details) else { let_assert!(
// .git directory may not be present on dev environment Ok(repository) = git::repository::factory::real().open(&repo_details),
return Ok(()); "open repository"
}; );
tracing::debug!("open okay"); tracing::debug!("open okay");
tracing::info!(?repository, "FOO"); tracing::info!(?repository, "FOO");
tracing::info!(?repo_details, "BAR"); tracing::info!(?repo_details, "BAR");
@ -111,13 +108,11 @@ fn gitdir_validate_should_pass_a_valid_git_repo() -> Result<()> {
} }
#[test] #[test]
fn gitdir_validate_should_fail_a_git_repo_with_wrong_remote() { fn gitdir_validate_should_fail_a_git_repo_with_wrong_remote() -> Result<()> {
let_assert!( let_assert!(
Ok(cli_crate_dir) = std::env::current_dir().map_err(git::validation::remotes::Error::Io) 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())); let_assert!(Some(Some(root)) = cli_crate_dir.parent().map(|p| p.parent()));
eprintln!("root: {root:?}");
let mut repo_details = git::repo_details( let mut repo_details = git::repo_details(
1, 1,
git::Generation::default(), git::Generation::default(),
@ -129,17 +124,16 @@ fn gitdir_validate_should_fail_a_git_repo_with_wrong_remote() {
repo_details.forge = repo_details repo_details.forge = repo_details
.forge .forge
.with_user(User::new("git".to_string())) .with_user(User::new("git".to_string()))
.with_token(ApiToken::new(SecretString::from(String::new()))) .with_token(ApiToken::new(Secret::new("".to_string())))
.with_hostname(Hostname::new("git.kemitix.net")); .with_hostname(Hostname::new("git.kemitix.net"));
let Ok(repository) = git::repository::factory::real().open(&repo_details) else { let repository = git::repository::factory::real().open(&repo_details)?;
// .git directory may not be present on dev environment
return;
};
let mut repo_details = repo_details.clone(); let mut repo_details = repo_details.clone();
repo_details.forge = repo_details repo_details.forge = repo_details
.forge .forge
.with_hostname(Hostname::new("code.kemitix.net")); .with_hostname(Hostname::new("code.kemitix.net"));
let_assert!(Err(_) = validate_default_remotes(&*repository, &repo_details)); let_assert!(Err(_) = validate_default_remotes(&*repository, &repo_details));
Ok(())
} }
#[test] #[test]

View file

@ -8,7 +8,7 @@ mod init {
let file = fs.base().join(".git-next.toml"); let file = fs.base().join(".git-next.toml");
fs.file_write(&file, "contents")?; fs.file_write(&file, "contents")?;
crate::init::run(&fs)?; crate::init::run(fs.clone());
assert_eq!( assert_eq!(
fs.file_read_to_string(&file)?, fs.file_read_to_string(&file)?,
@ -23,7 +23,7 @@ mod init {
fn should_create_default_file_if_not_exists() -> TestResult { fn should_create_default_file_if_not_exists() -> TestResult {
let fs = kxio::fs::temp()?; let fs = kxio::fs::temp()?;
crate::init::run(&fs)?; crate::init::run(fs.clone());
let file = fs.base().join(".git-next.toml"); let file = fs.base().join(".git-next.toml");
@ -38,44 +38,3 @@ mod init {
Ok(()) Ok(())
} }
} }
mod file_watcher {
use std::{sync::atomic::Ordering, time::Duration};
use actix::{Actor, Context, Handler};
use rstest::*;
use crate::file_watcher::{self, FileUpdated};
use super::TestResult;
#[rstest]
#[actix::test]
#[timeout(Duration::from_millis(80))]
async fn should_not_block_calling_thread() -> TestResult {
let fs = kxio::fs::temp()?;
let path = fs.base().join("file");
fs.file_write(&path, "foo")?;
let listener = Listener;
let l_addr = listener.start();
let recipient = l_addr.recipient();
let fw_shutdown = file_watcher::watch_file(path, recipient)?;
std::thread::sleep(Duration::from_millis(10));
fw_shutdown.store(true, Ordering::Relaxed);
Ok(()) // was not blocked
}
struct Listener;
impl Actor for Listener {
type Context = Context<Self>;
}
impl Handler<FileUpdated> for Listener {
type Result = ();
fn handle(&mut self, _msg: FileUpdated, _ctx: &mut Self::Context) -> Self::Result {
// todo!()
}
}
}

View file

@ -1,40 +0,0 @@
# 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

View file

@ -1,3 +0,0 @@
//
mod server_update;
mod tick;

View file

@ -1,104 +0,0 @@
//
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);
}
}
}
}
}
}
}

View file

@ -1,15 +0,0 @@
//
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(())
}
}

View file

@ -1,4 +0,0 @@
//
use git_next_core::message;
message!(Tick => std::io::Result<()>, "Update the TUI");

View file

@ -1,97 +0,0 @@
//
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(())
}
}

View file

@ -1,388 +0,0 @@
//
use ratatui::{
layout::Alignment,
prelude::{Buffer, Rect},
style::{Color, Style, Stylize as _},
symbols::border,
text::{Line, Span},
widgets::{Block, Paragraph, StatefulWidget, Widget},
};
use git_next_core::{
git::{self, graph::Log, Commit},
ForgeAlias, RepoAlias, RepoBranches,
};
use tracing::info;
use tui_scrollview::ScrollViewState;
use std::{collections::BTreeMap, fmt::Display, time::Instant};
use crate::{server::actor::messages::ValidAppConfig, tui::components::ConfiguredAppWidget};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct State {
last_update: Instant,
started: Instant,
pub mode: ServerState,
}
impl State {
pub fn initial() -> Self {
Self {
last_update: Instant::now(),
started: Instant::now(),
mode: ServerState::Initial { tick: 0 },
}
}
pub fn tap(&mut self) {
self.last_update = Instant::now();
if let ServerState::Initial { tick } = &mut self.mode {
*tick += 1;
}
}
fn beating_heart(&self) -> String {
if self.last_update.duration_since(self.started).as_secs() % 2 == 0 {
"💚 "
} else {
" 💚"
}
.to_string()
}
}
fn time() -> String {
chrono::Local::now().format("%H:%M").to_string()
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ServerState {
/// UI has started but has no information on the state of the server
Initial { tick: usize }, // NOTE: for use with throbber-widgets-tui ?
/// The application configuration has been loaded, individual forges and repos have their own
/// states
Configured {
forges: BTreeMap<ForgeAlias, ForgeState>,
},
}
impl ServerState {
pub fn update_branches(
&mut self,
forge_alias: &ForgeAlias,
repo_alias: &RepoAlias,
branches: RepoBranches,
) {
if let Self::Configured { forges } = self {
let Some(forge_state) = forges.get_mut(forge_alias) else {
return;
};
let Some(repo_state) = forge_state.repos.get_mut(repo_alias) else {
return;
};
match repo_state {
RepoState::Configured {
branches: state_branches,
..
}
| RepoState::Ready {
branches: state_branches,
..
} => *state_branches = branches,
RepoState::Identified { .. } => (),
}
}
}
pub fn update_log(&mut self, forge_alias: &ForgeAlias, repo_alias: &RepoAlias, log: Log) {
if let Self::Configured { forges } = self {
let Some(forge_state) = forges.get_mut(forge_alias) else {
return;
};
let Some(repo_state) = forge_state.repos.get_mut(repo_alias) else {
return;
};
match repo_state {
RepoState::Ready { log: state_log, .. } => *state_log = log,
RepoState::Identified { .. } | RepoState::Configured { .. } => (),
}
}
}
}
impl From<ValidAppConfig> for ServerState {
fn from(app_config: ValidAppConfig) -> Self {
Self::Configured {
forges: app_config
.app_config
.forges()
.map(|(forge_alias, config)| {
(
forge_alias,
config
.repos()
.map(|(repo_alias, server_repo_config)| {
(repo_alias, server_repo_config.repo_config())
})
.map(
|(repo_alias, option_repo_config)| match option_repo_config {
Some(rc) => (
repo_alias.clone(),
RepoState::Configured {
repo_alias,
message: RepoMessage::builder()
.text("configured".into())
.style(Style::default().fg(Color::LightGreen))
.build(),
alert: None,
branches: rc.branches().clone(),
log: git::graph::Log::default(),
},
),
None => (
repo_alias.clone(),
RepoState::Identified {
repo_alias,
message: RepoMessage::builder()
.text("identified".into())
.style(Style::default().fg(Color::Gray))
.build(),
alert: None,
},
),
},
)
.collect::<Vec<_>>(),
)
})
.map(|(forge_alias, vec_repo_alias_state)| {
let forge_state: ForgeState = ForgeState {
alias: forge_alias.clone(),
view_state: ViewState::default(),
repos: vec_repo_alias_state.into_iter().collect::<BTreeMap<_, _>>(),
};
(forge_alias, forge_state)
})
.collect::<BTreeMap<_, _>>(),
}
}
}
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
pub enum ViewState {
Collapsed,
#[default]
Expanded,
}
impl Display for ViewState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let view_state = match self {
Self::Collapsed => "+",
Self::Expanded => "-",
};
write!(f, "{view_state}")
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ForgeState {
pub alias: ForgeAlias,
pub view_state: ViewState,
pub repos: BTreeMap<RepoAlias, RepoState>,
}
#[bon::builder]
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RepoMessage {
text: String,
style: Style,
}
impl From<&RepoMessage> for Span<'_> {
fn from(value: &RepoMessage) -> Self {
Self::default()
.content(value.text.clone())
.style(value.style)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RepoState {
Identified {
repo_alias: RepoAlias,
message: RepoMessage,
alert: Option<String>,
},
Configured {
repo_alias: RepoAlias,
message: RepoMessage,
alert: Option<String>,
branches: RepoBranches,
log: Log,
},
Ready {
repo_alias: RepoAlias,
message: RepoMessage,
alert: Option<String>,
branches: RepoBranches,
view_state: ViewState,
main: Commit,
next: Commit,
dev: Commit,
log: Log,
},
}
impl RepoState {
#[tracing::instrument]
pub fn update_branches(&mut self, branches: RepoBranches) {
match self {
Self::Configured {
branches: state_branches,
..
}
| Self::Ready {
branches: state_branches,
..
} => {
*state_branches = branches;
}
Self::Identified { .. } => (),
}
}
#[tracing::instrument]
pub fn update_log(&mut self, log: Log) {
match self {
Self::Configured { log: state_log, .. } | Self::Ready { log: state_log, .. } => {
*state_log = log;
}
Self::Identified { .. } => {
info!("git graph log ignored by ui");
}
}
}
#[tracing::instrument]
pub fn update_message(&mut self, msg: impl Into<String> + std::fmt::Debug, colour: Color) {
match self {
Self::Identified { message, .. }
| Self::Configured { message, .. }
| Self::Ready { message, .. } => {
info!(?msg, "updating ui");
*message = RepoMessage::builder()
.text(msg.into())
.style(Style::default().fg(colour))
.build();
}
}
}
#[tracing::instrument]
pub fn clear_alert(&mut self) {
match self {
Self::Identified { alert, .. }
| Self::Configured { alert, .. }
| Self::Ready { alert, .. } => {
*alert = None;
}
}
}
#[tracing::instrument]
pub fn alert(&mut self, msg: impl Into<String> + std::fmt::Debug) {
let msg: String = msg.into();
tracing::info!(%msg, "new tui alert");
self.update_message("ALERT", Color::Red);
match self {
Self::Identified { alert, .. }
| Self::Configured { alert, .. }
| Self::Ready { alert, .. } => *alert = Some(msg),
}
}
pub fn ready(self, main: Commit, next: Commit, dev: Commit) -> Self {
match self {
Self::Identified {
repo_alias,
message,
alert,
} => Self::Identified {
repo_alias,
message,
alert,
},
Self::Configured {
repo_alias,
message,
alert,
branches,
log,
} => Self::Ready {
repo_alias,
message,
alert,
branches,
view_state: ViewState::Expanded,
main,
next,
dev,
log,
},
Self::Ready {
repo_alias,
message,
alert,
branches,
view_state,
log,
.. // drop existing main, next and dev to use parameters
} => Self::Ready {
repo_alias,
message,
alert,
branches,
view_state,
main,
next,
dev,
log,
},
}
}
}
impl StatefulWidget for &State {
type State = ScrollViewState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State)
where
Self: Sized,
{
let block = Block::bordered()
.title_top(
Line::from(format!(" Git-Next v{} ", clap::crate_version!()).bold())
.alignment(Alignment::Center),
)
.title_bottom(
Line::from(vec![
" [q]uit ".into(),
self.beating_heart().into(),
" ".into(),
])
.alignment(Alignment::Center),
)
.title_bottom(Line::from(format!(" {} ", time())).alignment(Alignment::Right))
.border_set(border::THICK);
let interior = block.inner(area);
block.render(area, buf);
match &self.mode {
ServerState::Initial { tick } => Paragraph::new(format!("Loading...{tick}"))
.centered()
.render(interior, buf),
ServerState::Configured { forges } => {
ConfiguredAppWidget { forges }.render(interior, buf, state);
}
}
}
}

View file

@ -1,99 +0,0 @@
//
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"),
}
}
}
}

View file

@ -1,68 +0,0 @@
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<'a> HeightContraintLength for ConfiguredAppWidget<'a> {
fn height_constraint_length(&self) -> u16 {
self.children()
.iter()
.map(HeightContraintLength::height_constraint_length)
.sum::<u16>()
+ 2 // top + bottom borders
}
}
impl<'a> StatefulWidget for ConfiguredAppWidget<'a> {
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<_>>()
}
}

View file

@ -1,22 +0,0 @@
//
use git_next_core::ForgeAlias;
use ratatui::{buffer::Buffer, layout::Rect, text::Text, widgets::Widget};
use crate::tui::components::HeightContraintLength;
pub struct CollapsedForgeWidget<'a> {
pub forge_alias: &'a ForgeAlias,
}
impl<'a> HeightContraintLength for CollapsedForgeWidget<'a> {
fn height_constraint_length(&self) -> u16 {
1
}
}
impl<'a> Widget for CollapsedForgeWidget<'a> {
fn render(self, area: Rect, buf: &mut Buffer)
where
Self: Sized,
{
Text::from(format!("- {}", self.forge_alias)).render(area, buf);
}
}

View file

@ -1,61 +0,0 @@
//
use std::collections::BTreeMap;
use git_next_core::{ForgeAlias, RepoAlias};
use ratatui::{
buffer::Buffer,
layout::{Alignment, Direction, Layout, Rect},
text::Line,
widgets::{Block, Widget},
};
use crate::tui::{
actor::RepoState,
components::{repo::RepoWidget, HeightContraintLength},
};
pub struct ExpandedForgeWidget<'a> {
pub forge_alias: &'a ForgeAlias,
pub repos: &'a BTreeMap<RepoAlias, RepoState>,
}
impl<'a> HeightContraintLength for ExpandedForgeWidget<'a> {
fn height_constraint_length(&self) -> u16 {
self.children()
.iter()
.map(HeightContraintLength::height_constraint_length)
.sum::<u16>()
+ 2 // top title + bottom padding
}
}
impl<'a> Widget for ExpandedForgeWidget<'a> {
fn render(self, area: Rect, buf: &mut Buffer)
where
Self: Sized,
{
let block = Block::default().title_top(
Line::from(format!(" forge: {} ", self.forge_alias)).alignment(Alignment::Left),
);
let children = self.children();
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints(
children
.iter()
.map(HeightContraintLength::height_constraint_length),
)
.split(block.inner(area));
block.render(area, buf);
children
.into_iter()
.enumerate()
.for_each(|(i, w)| w.render(layout[i], buf));
}
}
impl<'a> ExpandedForgeWidget<'a> {
fn children(&self) -> Vec<RepoWidget<'a>> {
self.repos
.values()
.map(|repo_state| RepoWidget { repo_state })
.collect::<Vec<_>>()
}
}

View file

@ -1,53 +0,0 @@
//
mod collapsed;
mod expanded;
use std::collections::BTreeMap;
use collapsed::CollapsedForgeWidget;
use expanded::ExpandedForgeWidget;
use git_next_core::{ForgeAlias, RepoAlias};
use ratatui::{buffer::Buffer, layout::Rect, widgets::Widget};
use crate::tui::actor::{RepoState, ViewState};
use super::HeightContraintLength;
pub struct ForgeWidget<'a> {
pub forge_alias: &'a ForgeAlias,
pub repos: &'a BTreeMap<RepoAlias, RepoState>,
pub view_state: ViewState,
}
impl<'a> HeightContraintLength for ForgeWidget<'a> {
fn height_constraint_length(&self) -> u16 {
match self.view_state {
ViewState::Collapsed => CollapsedForgeWidget {
forge_alias: self.forge_alias,
}
.height_constraint_length(),
ViewState::Expanded => ExpandedForgeWidget {
forge_alias: self.forge_alias,
repos: self.repos,
}
.height_constraint_length(),
}
}
}
impl<'a> Widget for ForgeWidget<'a> {
fn render(self, area: Rect, buf: &mut Buffer)
where
Self: Sized,
{
match self.view_state {
ViewState::Collapsed => CollapsedForgeWidget {
forge_alias: self.forge_alias,
}
.render(area, buf),
ViewState::Expanded => ExpandedForgeWidget {
forge_alias: self.forge_alias,
repos: self.repos,
}
.render(area, buf),
}
}
}

View file

@ -1,128 +0,0 @@
//
use git_next_core::git::graph::Log;
use ratatui::{
style::{Color, Style},
text::{Line, Span, Text},
widgets::{Paragraph, Widget},
};
use regex::Regex;
use super::HeightContraintLength;
pub struct CommitLog<'a> {
pub log: &'a Log,
}
impl<'a> HeightContraintLength for CommitLog<'a> {
fn height_constraint_length(&self) -> u16 {
u16::try_from(self.log.len()).unwrap_or(u16::MAX)
}
}
impl<'a> Widget for CommitLog<'a> {
fn render(self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer)
where
Self: Sized,
{
Paragraph::new(Text::from(
self.log
.iter()
.map(LogLine::new)
.map(Line::from)
.collect::<Vec<_>>(),
))
.render(area, buf);
}
}
struct LogLine {
raw: String,
}
impl LogLine {
fn new(raw: impl Into<String>) -> Self {
Self { raw: raw.into() }
}
}
lazy_static::lazy_static! {
static ref RE: Regex =
#[allow(clippy::unwrap_used)]
Regex::new(
r"^(?<pre>.*)\s(?<hash>[0-9a-f]{7})\s\((?<branches>.*?)\)\s(?<message>.*)",
).unwrap();
static ref BRANCHES: Regex =
#[allow(clippy::unwrap_used)]
Regex::new(
r"origin\/(?<branch>[^,]+)",
).unwrap();
}
impl From<LogLine> for Line<'_> {
fn from(value: LogLine) -> Self {
match RE.captures(&value.raw) {
Some(caps) => {
let pre = caps["pre"].to_owned();
let hash = caps["hash"].to_owned();
let message = caps["message"].to_owned();
let mut branches = BRANCHES
.captures_iter(&caps["branches"])
.map(|captures| captures["branch"].to_owned())
.filter(|branch| branch != "HEAD")
.collect::<Vec<_>>();
if branches.is_empty() {
// line without branches
Line::from(vec![
pre.into(),
" ".into(),
hash.into(),
" ".into(),
message.into(),
])
} else {
// line withbranches
let mut spans = vec![pre.into(), " ".into(), hash.into(), " ".into()];
branches.sort();
branches
.into_iter()
.map(|branch| format!("({branch})"))
.map(Span::from)
.map(|span| span.style(Style::default().fg(Color::White).bg(Color::Blue)))
.for_each(|span| spans.push(span));
spans.push(" ".into());
spans.push(message.into());
Line::from(spans)
}
}
None => {
// non-commit line
Line::from(value.raw.clone())
}
}
}
}
#[cfg(test)]
mod tests {
use tracing::info;
use super::RE;
#[test_log::test]
fn parse_log_line() -> Result<(), Box<dyn std::error::Error>> {
let line = "* 97b6853 (origin/next, origin/main, origin/dev, origin/HEAD) refactor(tui): simplify repo identity widget";
RE.captures(line).map_or_else(
|| Err("Failed to capture".into()),
|caps| {
info!(?caps, "");
assert_eq!(&caps["pre"], "*");
assert_eq!(&caps["hash"], "97b6853");
assert_eq!(
&caps["branches"],
"origin/next, origin/main, origin/dev, origin/HEAD"
);
assert_eq!(
&caps["message"],
"refactor(tui): simplify repo identity widget"
);
Ok(())
},
)
}
}

View file

@ -1,12 +0,0 @@
//
mod configured_app;
mod forge;
mod history;
mod repo;
pub use configured_app::ConfiguredAppWidget;
pub use history::CommitLog;
pub trait HeightContraintLength {
fn height_constraint_length(&self) -> u16;
}

View file

@ -1,87 +0,0 @@
use std::string::ToString;
//
use git_next_core::{RepoAlias, RepoBranches};
use ratatui::{
layout::Alignment,
style::{Color, Style, Stylize as _},
text::{Line, Span},
widgets::block::Title,
};
use crate::tui::actor::RepoMessage;
pub struct Identity<'a> {
pub repo_alias: &'a RepoAlias,
pub alert: Option<&'a str>,
pub message: &'a RepoMessage,
pub repo_branches: Option<&'a RepoBranches>,
}
impl<'a> Identity<'a> {
pub const fn new(
repo_alias: &'a RepoAlias,
alert: Option<&'a str>,
message: &'a RepoMessage,
repo_branches: Option<&'a RepoBranches>,
) -> Self {
Self {
repo_alias,
alert,
message,
repo_branches,
}
}
}
impl<'a> Identity<'a> {
fn spans(self) -> Vec<Span<'a>> {
let alert = self
.alert
.map(ToString::to_string)
.map(|alert| alert.fg(Color::White).bg(Color::Red));
let message = self.message;
let main = self
.repo_branches
.map(RepoBranches::main)
.map_or_else(|| "_".to_string(), |b| b.to_string());
let next = self
.repo_branches
.map(RepoBranches::next)
.map_or_else(|| "_".to_string(), |b| b.to_string());
let dev = self
.repo_branches
.map(RepoBranches::dev)
.map_or_else(|| "_".to_string(), |b| b.to_string());
let mut spans = vec![" ".into()];
match alert {
None => spans.push(
Span::from(self.repo_alias.to_string()).style(Style::default().fg(Color::Cyan)),
),
Some(alert) => {
spans.push(
Span::from(self.repo_alias.to_string())
.style(Style::default().fg(Color::White).bg(Color::Red)),
);
spans.push(" ".into());
spans.push(alert);
}
}
spans.push(" ".into());
spans.push(format!("({main} -> {next} -> {dev}) ").into());
spans.push(message.into());
spans.push(" ".into());
spans
}
}
impl<'a> From<Identity<'a>> for Title<'a> {
fn from(identity: Identity<'a>) -> Self {
Self {
content: Line {
spans: identity.spans(),
style: Style::reset(),
alignment: None,
},
alignment: Some(Alignment::Left),
position: None,
}
}
}

View file

@ -1,114 +0,0 @@
//
mod identity;
use std::string::String;
use git_next_core::{RepoAlias, RepoBranches};
use crate::{
git,
tui::{
actor::{RepoMessage, RepoState},
components::CommitLog,
},
};
use identity::Identity;
use ratatui::{
buffer::Buffer,
layout::Rect,
widgets::{Block, Borders, Widget},
};
use super::HeightContraintLength;
pub struct RepoWidget<'a> {
pub repo_state: &'a RepoState,
}
impl<'a> HeightContraintLength for RepoWidget<'a> {
fn height_constraint_length(&self) -> u16 {
self.inner().height_constraint_length() + 2 // top + bottom borders
}
}
impl<'a> RepoWidget<'a> {
fn inner(&self) -> InnerRepoWidget {
match self.repo_state {
RepoState::Identified {
repo_alias,
message,
alert,
} => InnerRepoWidget {
repo_alias,
message,
alert: alert.as_ref().map(String::as_str),
branches: None,
log: None,
},
RepoState::Configured {
repo_alias,
message,
alert,
branches,
log,
}
| RepoState::Ready {
repo_alias,
message,
alert,
branches,
log,
..
} => InnerRepoWidget {
repo_alias,
message,
alert: alert.as_ref().map(String::as_str),
branches: Some(branches),
log: Some(log),
},
}
}
}
impl<'a> Widget for RepoWidget<'a> {
fn render(self, area: Rect, buf: &mut Buffer)
where
Self: Sized,
{
self.inner().render(area, buf);
}
}
struct InnerRepoWidget<'a> {
pub repo_alias: &'a RepoAlias,
pub message: &'a RepoMessage,
pub alert: Option<&'a str>,
pub branches: Option<&'a RepoBranches>,
pub log: Option<&'a git::graph::Log>,
}
impl<'a> HeightContraintLength for InnerRepoWidget<'a> {
fn height_constraint_length(&self) -> u16 {
self.log
.map(|log| CommitLog { log })
.map_or(0, |w| w.height_constraint_length())
}
}
impl<'a> Widget for InnerRepoWidget<'a> {
fn render(self, area: Rect, buf: &mut Buffer)
where
Self: Sized,
{
let block = Block::default()
.title(Identity::new(
self.repo_alias,
self.alert,
self.message,
self.branches,
))
.borders(Borders::TOP);
if let Some(log) = self.log {
CommitLog { log }.render(block.inner(area), buf);
}
block.render(area, buf);
}
}

View file

@ -1,86 +0,0 @@
//
use std::path::PathBuf;
use color_eyre::eyre::Result;
use directories::ProjectDirs;
use lazy_static::lazy_static;
use tracing_error::ErrorLayer;
use tracing_subscriber::{self, layer::SubscriberExt, util::SubscriberInitExt, Layer};
lazy_static! {
pub static ref PROJECT_NAME: String = env!("CARGO_CRATE_NAME").to_uppercase();
pub static ref DATA_FOLDER: Option<PathBuf> =
std::env::var(format!("{}_DATA", PROJECT_NAME.clone()))
.ok()
.map(PathBuf::from);
pub static ref LOG_ENV: String = format!("{}_LOGLEVEL", PROJECT_NAME.clone());
pub static ref LOG_FILE: String = format!("{}.log", env!("CARGO_PKG_NAME"));
}
fn project_directory() -> Option<ProjectDirs> {
ProjectDirs::from("net", "kemitix", env!("CARGO_PKG_NAME"))
}
pub fn get_data_dir() -> PathBuf {
let directory = DATA_FOLDER.clone().map_or_else(
|| {
project_directory().map_or_else(
|| PathBuf::from(".").join(".data"),
|proj_dirs| proj_dirs.data_local_dir().to_path_buf(),
)
},
|data_folder| data_folder,
);
directory
}
pub fn initialize_logging() -> Result<()> {
let directory = get_data_dir();
std::fs::create_dir_all(directory.clone())?;
let log_path = directory.join(LOG_FILE.clone());
let log_file = std::fs::File::create(log_path)?;
std::env::set_var(
"RUST_LOG",
std::env::var("RUST_LOG")
.or_else(|_| std::env::var(LOG_ENV.clone()))
.unwrap_or_else(|_| format!("{}=info", env!("CARGO_CRATE_NAME"))),
);
let file_subscriber = tracing_subscriber::fmt::layer()
.with_file(true)
.with_line_number(true)
.with_writer(log_file)
.with_target(false)
.with_ansi(false)
.with_filter(tracing_subscriber::filter::EnvFilter::from_default_env());
tracing_subscriber::registry()
.with(file_subscriber)
.with(ErrorLayer::default())
.init();
Ok(())
}
/// Similar to the `std::dbg!` macro, but generates `tracing` events rather
/// than printing to stdout.
///
/// By default, the verbosity level for the generated events is `DEBUG`, but
/// this can be customized.
#[macro_export]
macro_rules! trace_dbg {
(target: $target:expr, level: $level:expr, $ex:expr) => {{
match $ex {
value => {
tracing::event!(target: $target, $level, ?value, stringify!($ex));
value
}
}
}};
(level: $level:expr, $ex:expr) => {
trace_dbg!(target: module_path!(), level: $level, $ex)
};
(target: $target:expr, $ex:expr) => {
trace_dbg!(target: $target, level: tracing::Level::DEBUG, $ex)
};
($ex:expr) => {
trace_dbg!(level: tracing::Level::DEBUG, $ex)
};
}

View file

@ -1,7 +0,0 @@
//
mod actor;
pub mod components;
pub mod logging;
pub use actor::messages::Tick;
pub use actor::Tui;

View file

@ -1,4 +1,4 @@
// //
use git_next_core::message; use git_next_core::message;
message!(ShutdownWebhook, "Request to shutdown the Webhook actor"); message!(ShutdownWebhook: "Request to shutdown the Webhook actor");

Some files were not shown because too many files have changed in this diff Show more