Compare commits

...

319 commits
next ... main

Author SHA1 Message Date
Renovate Bot
6872483841 chore(deps): update rust crate gix to 0.68 2024-11-24 10:01:31 +00:00
bf6b4fcd21 fix: apply clippy suggestions from rust nightly 2024-11-21 21:57:49 +00:00
614e721b91 test: ignore use of expect in tests creating temp fs 2024-11-21 21:40:43 +00:00
Renovate Bot
0c8566a4a0 chore(deps): update rust crate bon to v3 2024-11-21 21:40:38 +00:00
Renovate Bot
d3dfedc95b chore(deps): update rust crate kxio to v3 2024-11-21 10:30:38 +00:00
Renovate Bot
ea264aaf12 chore(deps): update rust crate thiserror to v2 2024-11-15 18:07:56 +00:00
Renovate Bot
d2a93bc004 chore(deps): update kemitix/rust action to v2.4.1 2024-11-15 18:07:56 +00:00
Renovate Bot
f908011503 fix: rustdoc typo 2024-11-15 18:07:56 +00:00
Renovate Bot
eabf97dff8 build: add build recipe to justfile 2024-11-15 18:07:22 +00:00
c9d853797e build: ignore mutation output 2024-11-15 18:07:22 +00:00
Renovate Bot
0f78fc731a chore(deps): update rust crate notify to v7 2024-10-25 18:12:13 +01:00
Renovate Bot
1784f3f28b chore(deps): update rust crate tui-scrollview to 0.5
revert: reneable ScrollView
2024-10-25 18:10:44 +01:00
Renovate Bot
b794a21dd9 chore(deps): update rust crate gix to 0.67 2024-10-22 19:16:59 +00:00
Renovate Bot
d989da659c chore(deps): update rust crate ratatui to 0.29 2024-10-22 07:32:53 +01:00
23de987444 fix: disable ScrollView
Current version is incompatible with latest Ratatui. Backout this change
when compatibility is restore.
2024-10-22 07:31:08 +01:00
Renovate Bot
9d6271a176 chore(deps): update docker.io/rust docker tag to v1.82.0 2024-10-21 19:37:22 +01:00
Renovate Bot
ddc22867b3 chore(deps): update docker.io/woodpeckerci/plugin-docker-buildx docker tag to v5 2024-10-21 19:37:13 +01:00
Renovate Bot
5e4e287562 chore(deps): update rust crate rstest to 0.23 2024-10-21 19:18:04 +01:00
Renovate Bot
6a0e0580dc chore(deps): update rust crate secrecy to 0.10 2024-10-21 19:14:10 +01:00
Renovate Bot
7bd6347dd8 chore(deps): update kemitix/rust action to v2.3.0 2024-09-30 21:46:31 +00:00
Renovate Bot
360b7f2cf7 chore(deps): update kemitix/rust action to v2.2.0 2024-09-25 08:31:43 +00:00
f3a5b9cb4c build: switch to forgejo-todo-checker
Remove woodpecker's TODO checker
2024-09-22 15:22:53 +01:00
18a537b18e build: add cargo machette to push-next workflow 2024-09-17 15:20:34 +01:00
ef6474ef9f test: also run CI tests against Rust nightly 2024-09-17 11:44:52 +01:00
dbf1a0db27 docs: add demo gif of tui 2024-09-16 13:54:33 +01:00
ForgeJo Action. See: https://git.kemitix.net/kemitix/rust
91c5973e31 chore: release
Signed-off-by: ForgeJo Action. See: https://git.kemitix.net/kemitix/rust <action@git.kemitix.net>
2024-09-14 14:24:04 +00:00
978205b823 feat(tui): add time and version in border 2024-09-14 15:13:45 +01:00
8359d0d7ca refactor: Update TUI sooner when receiving CI status
Looking to avoid getting stuck on 'Checking CI status', but this doesn't
appear to be where the problem is coming from.
2024-09-14 12:40:30 +01:00
93cf6f83df chore: add run and run-ui recipes to justfile 2024-09-14 12:26:06 +01:00
681d85aac1 chore: remove manual crates.io publish recipe from justfile 2024-09-14 12:22:29 +01:00
d4f16e6f5e feat: should fetch repo on startup when not cloning
We already have a copy of the repo, so we don't clone, but we should
perform a `git fetch` to make sure it is up-to-date.
2024-09-14 12:19:24 +01:00
048111202a feat: Remove branches when fetching from remote 2024-09-14 07:42:24 +01:00
3ea7f36c98 build(docker): Don't break when debian drops old packge versions
Debian routinly drop older versions of packages from the repositories as
new versions replace them. Pinning the version causes the build to break
at seamingly random times when the pinned version gets dropped.
2024-09-14 07:38:13 +01:00
6c60e3fb7a refactor: reimplement git fetch using git 2024-09-13 18:55:21 +01:00
313d6d79c5 docs: mark tui as complete on roadmap 2024-09-13 09:48:38 +01:00
189d579d33 docs: Add missing port mapping parameter for running in docker 2024-09-13 08:59:38 +01:00
a77c6335a6 chore: ignore .local/ directory
This directory is created when running git-next via docker with the --ui
option.
2024-09-13 08:53:04 +01:00
ForgeJo Action. See: https://git.kemitix.net/kemitix/rust
82241de0dd chore: release
Signed-off-by: ForgeJo Action. See: https://git.kemitix.net/kemitix/rust <action@git.kemitix.net>
2024-09-12 19:46:24 +00:00
664e424d1a fix(tui): make tui work from docker image
Add missing environment variable in Dockerfile and gave example command
to run via docker.

Closes kemitix/git-next#154
2024-09-12 19:50:29 +01:00
df6b96fbfd fix(tui): alerts, such as WIP aren't being reset 2024-09-12 10:37:53 +01:00
566125f5c0 fix(test): tests requiring .git pass when not present
These are tests that assume they are running in a locally checked out
git repository. If that isn't the case, e.g. when using jujutsu, then
the tests should not fail. They will continue to run as normal under
CI conditions as those do use a locally checked out git repository.
2024-09-12 10:37:46 +01:00
80af909ab0 build(push-next): use rust image v1.81.0 2024-09-08 12:07:18 +01:00
ecd460cdfb fix(tui): update ui when push next or main finishes
Removes the artificial pause while we wait for any CI to start before
checking the CI status.

Closes kemitix/git-next#160
2024-09-06 18:28:02 +01:00
Renovate Bot
35c2057f05 chore(deps): update docker.io/rust docker tag to v1.81.0 2024-09-06 07:31:55 +00:00
d2e2d00fe1 fix(tui): don't set background for normal repo alias
This didn't look good when using a light coloured terminal.
2024-09-06 08:19:43 +01:00
e759e495fd feat: optionally specify max commits between dev and main
The default is 25.

Closes kemitix/git-next#121
2024-09-06 08:10:10 +01:00
ForgeJo Action. See: https://git.kemitix.net/kemitix/rust
3672fd5d45 chore: release
Signed-off-by: ForgeJo Action. See: https://git.kemitix.net/kemitix/rust <action@git.kemitix.net>
2024-09-04 05:45:15 +00:00
1f0b5e867c fix(tui): alerts are cleared on next repo update
Closes kemitix/git-next#151
2024-09-04 06:35:41 +01:00
8ca7aad3c3 docs: Expand docker docmentation 2024-09-03 20:17:59 +01:00
d923e831f0 build(docker): enable passing arguments when running via docker 2024-09-03 20:08:40 +01:00
5e0cf270dd fix: shutdown properly on error 2024-09-03 20:08:12 +01:00
b4a4631a1d fix: shutdown properly on file parse error
Closes kemitix/git-next#152
2024-09-03 06:53:12 +01:00
181ec8eb0f build(woodpecker): build docker image on push to next 2024-09-01 13:53:03 +01:00
47cbbad8e7 build(docker): update debian libssl3 dependency 2024-09-01 13:52:47 +01:00
e793c18215 docs(release): add links 2024-09-01 13:26:49 +01:00
ForgeJo Action. See: https://git.kemitix.net/kemitix/rust
224b63deb1 chore: release
Signed-off-by: ForgeJo Action. See: https://git.kemitix.net/kemitix/rust <action@git.kemitix.net>
2024-09-01 12:20:59 +00:00
3c01a822fd feat: improved error display when startup fails 2024-09-01 13:10:14 +01:00
4160b6d6ee fix: use configured branch names in user notification
Remove near-duplicate to string implementations.
2024-09-01 08:38:08 +01:00
853b862f10 feat(tui): clean up alert display 2024-09-01 07:35:58 +01:00
ca70c03e8b refactor: flatten nested blocks with early returns 2024-09-01 07:18:05 +01:00
f475095f4a feat(tui): remove some borders to clean up appearance 2024-09-01 06:57:16 +01:00
eae351d8a4 build(mac): make it clean when mac tunnel has closed 2024-09-01 06:35:55 +01:00
c1564807f8 refactor: merge identical match branches 2024-08-31 22:32:09 +01:00
b24005c3fe fix: remove unused imports 2024-08-31 22:31:49 +01:00
22ce2d431a feat(tui): make progression of branches clearer
Using the branch names configured for the repo, indicate that the
branches move towards dev.
2024-08-31 19:59:16 +01:00
be41842dae feat(tui): remove label from repo identity widget 2024-08-31 19:55:33 +01:00
9720fd01fc feat(tui): hightlight repo alias in red when in alert 2024-08-31 19:32:56 +01:00
8550adf79e fix(tui): remove logging from inside ui loop 2024-08-31 19:26:20 +01:00
2b09872131 feat(tui): branch names look more like 'pills'
Use round brackets
2024-08-31 19:20:50 +01:00
d2048d8a34 fix(tui): don't show HEAD in log 2024-08-31 18:20:24 +01:00
02609fdc11 fix(tui): improve colour contrast on light background 2024-08-31 18:19:05 +01:00
01f54d79ae feat(tui): highlight branchs in log 2024-08-31 18:18:57 +01:00
1df982005e chore(tui): add regex dependency 2024-08-31 14:17:28 +01:00
2abb36ad6c fix(tui): remove unused import 2024-08-31 14:17:05 +01:00
576eaaf990 refactor(tui): introduce LogLine to wrap log formatting 2024-08-31 13:33:45 +01:00
97b685363a refactor(tui): simplify repo identity widget
Adds blue to repo alias
2024-08-31 13:23:18 +01:00
a2940ec753 refactor: rename method as peel
Method on newtypes `unwrap` could be confused with the risky method of
the same name for Option and Result.
2024-08-31 11:18:09 +01:00
d5d313064a fix(alert): typo in email message 2024-08-31 09:53:53 +01:00
f9e305afa4 feat(tui): hightlight status message in colour 2024-08-31 09:53:53 +01:00
4555b3ae09 fix(repo): avoid blocking threads when pausing 2024-08-31 09:53:53 +01:00
64da1d8a34 fix(test): give actix more time to process message 2024-08-31 09:53:53 +01:00
a650996ecd fix(test): give actix more time to process message 2024-08-31 09:31:27 +01:00
eca556f976 feat(tui): use moving heart emoji as liveness indicator
The heart moves between two positions every second as long as the ui is
being updated.
2024-08-31 08:56:43 +01:00
a3dd82705f fix(test): give actix more time to process message 2024-08-31 08:56:43 +01:00
7504ab5a2d fix(tui): improve reliability of status updates 2024-08-31 07:03:58 +01:00
eb42745383 build: add start-mac-tunnel 2024-08-30 09:12:57 +01:00
126d5d3ef5 fix: create git graph log to after doing a fetch 2024-08-30 09:12:57 +01:00
4f6669548c feat(tui): add scrolling when overflow screen 2024-08-29 09:40:16 +01:00
52bd9cc30b feat(tui): forge widgets only use required lines
Rather than filling all the space available, the ForgeWidget now only
uses as many lines as it needs to show its contents.
2024-08-28 22:25:31 +01:00
2959bdfad4 feat(tui): repo widgets only use required lines
Rather than filling all the space available, the RepoWidget now only
uses as many lines as it needs to show its contents.
2024-08-28 09:14:02 +01:00
f85cbce4c6 refactor(tui): child widget can provide constraint to container 2024-08-28 07:53:56 +01:00
4517fe62e4 feat(tui): move forge alias to left and add prefix 2024-08-27 19:15:36 +01:00
c6bf287ed1 feat(tui): remove count of forges 2024-08-27 19:15:21 +01:00
35e3676930 fix(tui): remove logging of tui updates 2024-08-27 07:20:05 +01:00
95e9209e17 feat(tui): remove duplicate messages from repo body
The latest message is still displayed in the repo header
2024-08-26 08:39:33 +01:00
d1a685ae34 feat(tui): highlight user interventions in red 2024-08-26 08:21:31 +01:00
e489fb36e9 refactor(tui): merge repo widgets into one 2024-08-26 08:03:52 +01:00
09ff4c3a54 build(docker): enable all features in docker images
Add support for the experimental TUI when using the docker image.
2024-08-26 06:51:37 +01:00
48a5ed7a3b docs: add notes on how to do a release 2024-08-25 19:28:47 +01:00
ForgeJo Action. See: https://git.kemitix.net/kemitix/rust
76ae37a9a5 chore: release
Signed-off-by: ForgeJo Action. See: https://git.kemitix.net/kemitix/rust <action@git.kemitix.net>
2024-08-25 15:14:41 +00:00
5d9915bdbd feat(tui): (experimental) show repo state, messages and git log 2024-08-25 15:59:42 +01:00
ForgeJo Action. See: https://git.kemitix.net/kemitix/rust
f504b62ff6 chore: release
Signed-off-by: ForgeJo Action. See: https://git.kemitix.net/kemitix/rust <action@git.kemitix.net>
2024-08-23 07:39:32 +00:00
47dbc1a8a4 chore(deps): update rust crate gix to 0.66
This required manually bumping a locked transitive dependency:

> cargo update -p winnow@0.6.7

Ref: https://github.com/Byron/gitoxide/issues/1538
2024-08-23 08:14:20 +01:00
7a4f9a45a6 fix(github): register webhook with valid callback url 2024-08-22 19:55:32 +01:00
622e144986 feat(tui): (experimental) tui option
When the 'tui' feature is enabled, then server start accepts an optional
--ui parameter. When specified a ratatui ui will display, showing
liveness and a ping update when a valid config is loaded.
2024-08-12 10:01:35 +01:00
0632225752 build: test all feature combinations 2024-08-12 10:01:32 +01:00
08d2377404 fix: file_watcher runs on own thread
Closes kemitix/git-next#142
2024-08-11 13:55:38 +01:00
e34c6e0ef6 fix: Revert "fix: release-plz generated PR changelog"
This reverts commit f5a3524cb9.
2024-08-10 18:38:14 +01:00
ForgeJo Action. See: https://git.kemitix.net/kemitix/rust
6c5e1c1a80 chore: release
Signed-off-by: ForgeJo Action. See: https://git.kemitix.net/kemitix/rust <action@git.kemitix.net>
2024-08-10 17:23:24 +00:00
ac069551d8 chore: simplify just validate-dev-branch task 2024-08-10 18:21:33 +01:00
f5a3524cb9 fix: release-plz generated PR changelog 2024-08-10 18:13:47 +01:00
f0daac76b4 feat: make forge and repo alias more prominent in email
Closes kemitix/git-next#141
2024-08-10 18:12:00 +01:00
Renovate Bot
bbbb010762 chore(deps): update docker.io/rust docker tag to v1.80.1 2024-08-08 20:46:07 +00:00
60d05c8b3b fix: invalid config section typo in README 2024-08-08 09:44:54 +01:00
ForgeJo Action. See: https://git.kemitix.net/kemitix/rust
ad916cb845 chore: release
Signed-off-by: ForgeJo Action. See: https://git.kemitix.net/kemitix/rust <action@git.kemitix.net>
2024-08-08 07:41:52 +00:00
ef24cb583c feat: add short git log graph to notifications
Closes kemitix/git-next#133
2024-08-08 08:39:01 +01:00
8c19680056 refactor: macros use a more common syntax
Parameters were separated by ':', but are now separated by ','.
2024-08-06 20:06:39 +01:00
ad358ad7c2 refactor: cleanup pedantic clippy in forge-github crate 2024-08-06 16:21:25 +01:00
067296ffab refactor: cleanup pedantic clippy in forge-forgejo crate 2024-08-06 16:15:56 +01:00
6acefda5d3 refactor: cleanup pedantic clippy in core crate 2024-08-06 16:07:25 +01:00
24251f0c9c refactor: cleanup pedantic clippy in cli crate 2024-08-06 07:10:14 +01:00
281c07c849 fix: remove dependcy on clang & mold
This was only added to try and improve compile times.

Re-measuring the difference after months of work and refactoring, the
gain from the additional requirements was marginal (39.8s -> 37.5s).

So, to simplify the requirement, clang and mold have been removed.

Closes: kemitix/git-next#131
2024-08-04 20:41:38 +01:00
9a1756bf6c build(forgejo): remove publish-to-crates-io step
This is now handled by release-plz
2024-08-04 19:24:27 +01:00
34019b5c4a build(woodpecker): remove publish-to-forgejo step
This is now handled by release-plz
2024-08-04 19:24:03 +01:00
ForgeJo Action. See: https://git.kemitix.net/kemitix/rust
180e8ed0e0 chore: release
Signed-off-by: ForgeJo Action. See: https://git.kemitix.net/kemitix/rust <action@git.kemitix.net>
2024-08-04 15:25:54 +00:00
d63b712007 build(push-main): use forgejo secret token directly 2024-08-04 16:22:54 +01:00
3895246b72 fix: shout.desktop should be optional
If the value isn't present, then it is treated as false
2024-08-04 16:02:06 +01:00
a9783807b3 build(build-*): bump rust action to v1.80.0-2 2024-08-04 15:43:35 +01:00
ee135eb5fe build(push-main): use sha to specify rust action (bust cache) 2024-08-04 15:15:33 +01:00
f363f9eb17 build(push-main): remove redundent steps 2024-08-04 15:06:05 +01:00
74437e90f2 build: use release-plz from dev kemitix/rust action 2024-08-04 14:55:49 +01:00
2156780a3d build: correct path to rust toolchain action 2024-08-04 13:47:25 +01:00
5534160aaf build: add release-plz ci 2024-08-04 13:37:10 +01:00
f4a399e24b chore: release
Signed-off-by: Paul Campbell <pcampbell@kemitix.net>
2024-08-04 10:14:50 +01:00
c6e3d714a7 build: upgrade docker image to use debian:stable-20240722-slim 2024-08-04 10:03:52 +01:00
c27d891b65 build: upgrade git-next-builder to 2024-08-04 2024-08-04 10:03:52 +01:00
347b9cb4dc build: add missing dependency libdbus-1-dev to correct Dockerfile 2024-08-04 10:03:52 +01:00
5d64692f31 test: timing test waits longer than expiry 2024-08-04 10:03:52 +01:00
b1d5344cfa build: add missing dependency libdbus-1-dev 2024-08-04 08:23:47 +01:00
58d9a993e9 chore: release 0.13.1 2024-08-04 08:09:15 +01:00
6a31b4687e build: release-plz single changelog and tag 2024-08-04 08:02:40 +01:00
6de8e4f988 feat: prevent duplicate alerts
Closes kemitix/git-next#128
2024-08-03 23:07:56 +01:00
850e990ab4 refactor: remove unused dependencies 2024-08-03 22:50:18 +01:00
421e85cb0b refactor: extract alerts into own actor 2024-08-03 12:59:40 +01:00
9a2fa2e8a5 feat: add support for desktop notifications
Closes: kemitix/git-next#119
2024-08-03 12:59:40 +01:00
2b77eae508 build: update to rust with libdbus-1-dev 2024-08-03 12:59:40 +01:00
Renovate Bot
bcc64c7205 chore(deps): update kemitix/rust action to v1 2024-08-02 19:16:02 +00:00
dc3c55f570 docs: add example to readme for listen, shout & storage 2024-08-02 19:06:39 +01:00
637abb50cd fix: add example email config to server default template 2024-08-02 19:06:39 +01:00
6bc4b7b143 docs: add config details for sending emails 2024-08-02 18:47:05 +01:00
9fb70f98d6 test: update tests to check for email config parsing 2024-08-02 18:47:05 +01:00
Renovate Bot
7b056cb879 chore(deps): update rust crate derive_more to 1.0.0-beta 2024-08-02 11:07:03 +01:00
Renovate Bot
6f1e80daf5 chore(deps): update docker.io/rust docker tag to v1.80.0 2024-08-02 10:01:11 +00:00
cd2e918247 chore: renovate PRs should target dev branch 2024-08-02 10:45:30 +01:00
Renovate Bot
e5eafc42f0 chore(deps): update docker.io/woodpeckerci/plugin-docker-buildx docker tag to v4.2.0 2024-08-02 09:16:02 +00:00
474a9b5aaa chore: release
Signed-off-by: Paul Campbell <pcampbell@kemitix.net>
2024-08-02 08:58:19 +01:00
355176ce69 chore: remove .git-next.toml 2024-08-02 08:53:48 +01:00
6ac44fa5c0 chore: for binary we track Cargo.lock 2024-08-02 08:52:23 +01:00
12a2981ab5 feat: send email notifications (sendmail/smtp)
Closes kemitix/git-next#114
2024-08-02 07:36:11 +01:00
538728c491 feat!: restructured server config into listen & shout sections
Groups 'http' and 'webhook' sections under 'listen'.

Renames 'notification' section as 'shout'.
2024-08-01 07:56:31 +01:00
8df7600053 feat: remove notification.type
This makes it easier to specify multiple types of notifications,
rather than a single type.
2024-07-31 06:56:04 +01:00
7b64e300b6 feat!: reduce the max commit dev can be ahead of main
From 50 to 25.

Aim to make this a configuration option from git-next-server.toml
2024-07-30 16:40:39 +01:00
f6bc2e1283 feat: terminate process if config file is invalid 2024-07-30 16:27:24 +01:00
1650e93920 feat: return better errors to user on server failure 2024-07-30 11:18:29 +01:00
9a9c73d929 feat: return better errors to the user on init 2024-07-30 11:18:29 +01:00
03ae9153b4 chore: justfile publish revert to dev branch when complete 2024-07-29 10:33:03 +01:00
dd0a1ca41f chore: Release 0.12.1 2024-07-29 08:59:37 +01:00
e58ba94d97 chore: remove deprecated crates 2024-07-29 08:59:32 +01:00
bf12712bca chore: update create publishing command 2024-07-29 08:17:48 +01:00
b7abe949e2 fix: make default server config example valid
Included some comments to help configure the file.

Closes kemitix/git-next#115
2024-07-29 08:16:16 +01:00
e56d6a3ebb fix: remove requirement for RUSTFLAGS to be set
Closes kemitix/git-next#116
2024-07-29 08:07:39 +01:00
691a733fc3 fix: webhook secret doesn't need to be base64 encoded
Closes kemitix/git-next#118
2024-07-29 07:51:09 +01:00
b89431b779 chore: Release 0.12.0 2024-07-28 20:36:04 +01:00
d2ea93f05e feat: avoid resetting next to main when dev is ahead of main
When dev is not based on next, next is reset to main, however, it should
reset to the next commit towards dev when when is ahead of main.

Closes kemitix/git-next#111
2024-07-28 20:32:08 +01:00
991d0d1a08 docs: add missing notification config details 2024-07-28 18:29:20 +01:00
a56c6df3f1 feat: support macOS
Closes kemitix/git-next#108
2024-07-28 16:26:39 +01:00
22faa851dc chore: bump mockall from 0.12 to 0.13 2024-07-28 13:58:32 +01:00
b24675d48a chore: bump gix from 0.63 to 0.64 2024-07-28 13:58:15 +01:00
11de4efae6 docs: add missing readme for git-next-core
Closes kemitix/git-next#112
2024-07-28 13:54:25 +01:00
57458173d0 refactor: merge forge crate into cli crate 2024-07-28 13:35:26 +01:00
c1981d862c refactor: merge repo-actor crate into cli crate 2024-07-28 12:18:15 +01:00
12ecc308d5 refactor: merge webhook-actor crate into cli crate 2024-07-27 19:06:20 +01:00
366930bcfc refactor: merge file-watcher-crate into cli crate 2024-07-27 19:06:20 +01:00
9ca532a2b4 refactor: merge file-watcher-crate into cli crate 2024-07-27 18:51:05 +01:00
a679abeafc refactor: merge server-actor crate into cli crate 2024-07-27 08:27:04 +01:00
1427284c2a refactor: merge server crate into cli crate 2024-07-27 08:11:52 +01:00
5a595ec9ee chore: remove deprecated crates
These crates have been merged into git-next-core, and tombstones
published to crates.io.
2024-07-27 08:00:06 +01:00
3ae113212a fix: don't log content of internal messages 2024-07-27 07:03:52 +01:00
656ec4a534 chore: Release 0.11.0 2024-07-26 19:18:16 +01:00
2ec5ae1d51 tests: restore unlinked test file 2024-07-26 19:18:12 +01:00
fa5fa809d9 refactor: merge git create into core crate 2024-07-26 07:59:37 +01:00
b8f4adeb50 fix: remove unused dependecy from file-watcher-actor 2024-07-25 22:46:19 +01:00
768ec6ae02 docs: update package graph 2024-07-25 22:44:11 +01:00
ab728c7364 refactor: merge config crate into core crate 2024-07-25 21:08:16 +01:00
48c968db2d refactor: merge actor-macros into core
Starting to flatten the crates.
2024-07-25 07:37:29 +01:00
758ca5c2dc docs: update message graph for repo-actor 2024-07-24 08:35:29 +01:00
9e12f5eb5d feat: post webhook notifications to user
Closes kemitix/git-next#91
2024-07-23 20:40:01 +01:00
288c20c24b feat: dispatch NotifyUser messages to server for user (2/2) 2024-07-23 20:39:02 +01:00
4978400ece refactor: use Option<&T> over &Option<T> 2024-07-23 20:38:58 +01:00
bcf57bc728 feat: dispatch NotifyUser messages to server for user (1/2) 2024-07-23 20:38:54 +01:00
e9877ca9fa feat: support sending messages to the user 2024-07-23 20:38:51 +01:00
c86d890c2c feat: enable configuration of a webhook for receiving notifications 2024-07-23 20:38:29 +01:00
1690e1bff6 docs: document Notifications to user 2024-07-23 20:37:08 +01:00
8f95ae0058 refactor: extract messages and handlers modules from webhook-actor 2024-07-19 07:48:55 +01:00
ba67b1ebcb refactor: flag internally that dev not based on main will require used intervention
Preparation for when we will be sending user notifications
2024-07-16 20:00:29 +01:00
92ebd45307 refactor: Reduce cognitive complexity of 'validate_position'
Closes kemitix/git-next#83
2024-07-16 19:59:11 +01:00
c104dfedc1 refactor: Reduce cognitive complexity of WebhookNotification handler. 2/2
Closes kemitix/git-next#49
2024-07-16 18:33:45 +01:00
06292c2711 refactor: Reduce cognitive complexity of WebhookNotification handler. 1/2
Closes kemitix/git-next#49
2024-07-16 18:14:32 +01:00
f8fefcdedd chore: Release 0.10.0 2024-07-16 08:41:53 +01:00
95129ddeef chore: restore clean check and tag checkout to publish script 2024-07-16 08:41:53 +01:00
33907a1d32 feat: reload server config when file is touched
Closes kemitix/git-next#84
2024-07-16 07:14:57 +01:00
619e1d517d docs: update link from root README to cli README 2024-07-15 16:08:48 +01:00
f44865fa92 docs: add UnRegisterWebhook from RepoActor 2024-07-15 07:53:14 +01:00
b715755b91 feat: unregister webhooks form forge during shutdown
Closes kemitix/git-next#46
2024-07-15 07:39:06 +01:00
6c92f64f8b docs: add readmes to each crate to direct users to main crate
Closes kemitix/git-next#106
2024-07-14 20:58:58 +01:00
6981a7b5e3 docs: move main README into cli crate 2024-07-14 20:54:17 +01:00
69211a87a3 build: add more metadata for crates.io 2024-07-14 20:47:19 +01:00
050e1171b3 docs: update installation instructions 2024-07-14 20:44:18 +01:00
e2b545ae39 fix: move default.toml inside crate that uses it 2024-07-14 20:22:32 +01:00
639e561be6 fix: move server-default.toml inside crate that uses it 2024-07-14 20:22:32 +01:00
41c8a319b1 chore: Release 0.9.4 2024-07-14 16:39:55 +01:00
adf56c1b38 revert: fix: explicitly specify version in each crate
This reverts commit cd93d047cb.
2024-07-14 16:39:17 +01:00
fa7f78c734 fix: add missing version for workspace dependencies 2024-07-14 16:37:12 +01:00
d24bcd9ab1 chore: Release 0.9.3 2024-07-14 14:25:10 +01:00
cd93d047cb fix: explicitly specify version in each crate
crates.io doesn't appear to like taking the version from the workspace
crate
2024-07-14 14:24:41 +01:00
59e8fc050d chore: Release 0.9.2 2024-07-14 13:34:27 +01:00
c289617ba9 fix: typo and missing repository entry in Cargo.toml files 2024-07-14 13:32:07 +01:00
4c2e122346 docs: update changelog 2024-07-14 13:20:55 +01:00
fe23d3fe0a chore: Release 0.9.1 2024-07-14 10:45:24 +01:00
0981355f28 build: disable broke publish workflow
needs to be updated to support multiple crates in a workspace
2024-07-14 10:45:07 +01:00
0c7a060211 build: add script to publish to crates.io 2024-07-14 10:40:47 +01:00
e410cfc4f1 chore: add license and descriptions for each crate 2024-07-14 10:40:34 +01:00
19d1f77065 chore: simplify workspace.members specification 2024-07-14 10:31:23 +01:00
10e63894c2 docs: server-actor: add readme showing message paths 2024-07-13 08:16:24 +01:00
9d11bb0e1f build: add publish-to-crates-io workflow 2024-07-13 07:46:41 +01:00
43c6e812dc chore: Release 0.9.0 2024-07-12 19:04:39 +01:00
57a614bad3 fix: don't modify config of external repos
The git config files of external repos are read-only.

This is the only place where we make reference to a remote named
'origin', so this also closes kemitix/git-next#85.

Closes kemitix/git-next#85
2024-07-12 18:52:57 +01:00
5f36282667 feat: recheck failed status
Should a status check for a transient reason and is re-run, this will
allow that to be detected without the need to restart the git-next
server or force a spurious rebase.

Closes kemitix/git-next#88
2024-07-12 08:05:41 +01:00
fd762e2bd2 feat: perform controlled shutdown on ctrl-c
Closes kemitix/git-next#94

Controlled shutdown includes attempting to unregister webhooks.
2024-07-11 19:19:04 +01:00
681b2c4c10 refactor: split messages and handlers for server-actor 2024-07-11 19:19:01 +01:00
7578ab3144 feat: log as an error when webhook url ends with a slash
Closes kemitix/git-next#87
2024-07-11 19:18:58 +01:00
7212154037 refactor: split ReceiveServerConfig handler
First handler, with original name, validates the server config.

The new second handler, ReceiveValidServerConfig, can then (re)start the
server without needing to validate the settings.
2024-07-11 19:18:55 +01:00
4276964f4d refactor: split server storage creation out from startup
Closes kemitix/git-next#75
2024-07-11 19:18:50 +01:00
9c20e780d0 feat: update auth of interal repos when changed in config
Closes kemitix/git-next#100
2024-07-10 09:05:36 +01:00
df352443b7 feat: GitDir tracks when repo is cloned by git-next 2024-07-06 15:08:13 +01:00
425241196d chore: local dev used debug logging 2024-07-06 14:23:04 +01:00
4e60be61f7 refactor: extract git::repository::factory module 2024-07-05 20:31:16 +01:00
5ab075c181 refactor: split git::repository::tests module 2024-07-05 20:12:17 +01:00
56756cab70 chore: bacon treats clippy warnings as errors 2024-07-05 20:12:17 +01:00
d9feaeaa7b chore: remove unused FakeOpenRepository 2024-07-05 20:12:17 +01:00
2e374d317a refactor: split git::repository::open::tests module 2024-07-05 20:12:17 +01:00
6a8d1bf817 docs: add roadmap to readme 2024-07-05 18:55:36 +01:00
f61c556f5b chore: bump docker runtime os image 2024-07-05 07:58:53 +01:00
6bbc89490a build: pin versions for docker base images 2024-07-05 07:54:43 +01:00
cbf6c3b73c chore: lint fix for Dockerfile
>  2 warnings found (use --debug to expand):
> - FromAsCasing: 'as' and 'FROM' keywords' casing do not match (line 6)
> - FromAsCasing: 'as' and 'FROM' keywords' casing do not match (line
11)
2024-07-05 07:46:45 +01:00
b0be0f636c chore: Release 0.8.1 2024-07-05 07:26:42 +01:00
694135a10b fix: default log level is info
When RUST_LOG isn't set, the default log level is INFO rather than
ERROR.

Closes kemitix/git-next#104
2024-07-05 07:23:40 +01:00
2483e85196 docs: update installation instructions 2024-07-05 07:10:07 +01:00
c2953adba5 chore: remove unused token from github tests 2024-07-05 07:04:27 +01:00
7b19f3b66f chore: directly re-export function and type 2024-07-05 06:59:54 +01:00
6c24a36476 docs: minor updates to README 2024-07-04 19:04:02 +01:00
12849d5a69 refactor: server no longer depends directly on git crate 2024-07-03 07:42:11 +01:00
3dec12de20 refactor: cli don't depend directly on git crate 2024-07-03 07:35:01 +01:00
007a5bd13c chore: clean up footer of readme 2024-07-03 07:32:12 +01:00
209b29d217 fix: typos in mermaid diagram 2024-07-03 07:30:17 +01:00
99d8672f55 fix: mermaid diagram syntax 2024-07-03 07:29:02 +01:00
90420052cf docs: update crate interdependence graph 2024-07-03 07:16:08 +01:00
8beef49b3e chore: Release 0.8.0 2024-07-02 19:00:51 +01:00
d0c731fc01 chore: set default logging lever back to info 2024-07-02 18:53:05 +01:00
83ce95776e fix: messages should always get delivered
Remove the async wrapper for sending messages as they were never being
delivered.
2024-07-02 18:51:40 +01:00
7fdea2913a chore: don't treat clippy warnings as errors 2024-07-02 18:29:52 +01:00
dfc0c1dc80 refactor: only start actor system when server starts 2024-07-01 06:54:07 +01:00
77d35e8a09 feat: load log levels from env RUST_LOG 2024-06-30 20:12:47 +01:00
c85eee85e9 refactor: file-watcher doesn't debug log on each loop 2024-06-30 20:12:35 +01:00
40c61fa9ff test: add more debug tracing 2024-06-30 19:42:09 +01:00
73ab149aba fix: github commit should use common headers 2024-06-30 19:30:22 +01:00
ae7933c79e fix: don't retry validation when non-retryable error
Closes kemitix/git-next#90
2024-06-30 18:48:49 +01:00
c9efbb9936 fix: ReceiveRepoConfig tries to send two messages
Similar to CloneRepo the handler tries to send two messages one after
the other. Leave it to WebhookRegistered handler to kick off the
ValidateRepo. Also update the README with the correct message sequence.
2024-06-30 16:59:24 +01:00
68005d757d fix: start validating repo after registering webhook
Clone Repo wasn't sending the second message, so workaround: have it be
sent after registering the webhook.
2024-06-30 16:54:26 +01:00
55d8ccb0bd feat: ignore github ping webhook messages
Closes kemitix/git-next#101
2024-06-30 15:20:00 +01:00
8fceafc3e1 refactor: repo-actor: replace Mutex with RwLock 2024-06-30 13:17:33 +01:00
73b416e3a0 refactor: git: replace Mutex with RwLock in Repository 2024-06-30 13:14:50 +01:00
52df2114e5 refactor: tests: repo-actor: use methods on RepoActorLog 2024-06-30 13:12:12 +01:00
3e137c6480 refactor: repo-actor: RepoActorLog: replace Mutex with RwLock 2024-06-30 12:40:17 +01:00
db90280641 fix: github: restarting server creates duplicate webhook for repo
The Github routine for registering a new webhook, wasn't removing any
existing matching webhooks. There is a test for this, but it doesn't
assert that the delete requests are made. (This is a limitation of
kxio).

Closes kemitix/git-next#102
2024-06-30 12:23:42 +01:00
975c9e315c fix: where repo config is in server should register webhook 2024-06-30 08:09:10 +01:00
880fa0cc0e chore: bacon run job runs server 2024-06-30 08:08:44 +01:00
0796df00d4 docs: fix typo 2024-06-30 08:00:51 +01:00
c571e9ee8d refactor: CloneRepo use actor::do_send to send LoadConfigFromRepo 2024-06-30 07:55:55 +01:00
f038ab508b chore: fix name in config file 2024-06-29 20:03:25 +01:00
32fb92fb8d refactor: remove dead code 2024-06-29 19:24:18 +01:00
717cc8b0bc refactor: update macro signatures and add documentation support 2024-06-29 18:26:19 +01:00
0fd33739c1 refactor: server: collapse tests to base of crate 2024-06-29 11:16:46 +01:00
113192042b refactos: extract server-actor crate 2024-06-29 11:14:09 +01:00
52d442f2b0 refactor: extract file-watcher-actor crate 2024-06-29 10:57:18 +01:00
2008afa4dd refactor: extract actor-macros crate 2024-06-29 10:49:12 +01:00
eba00a112f refactor: extract webhook actor 2024-06-29 08:25:16 +01:00
6d9eb0ab86 refactor: remove dead code 2024-06-29 07:01:31 +01:00
f460cd4b49 refactor: remove unused Forge Deref implementation 2024-06-29 07:01:31 +01:00
e585b07f6b tests: repo-actor: add more tests 2024-06-29 07:01:26 +01:00
ffab1986a7 refactor: repo-actor: rewrite tests using mockall 2024-06-27 18:58:47 +01:00
601e400300 refactor: forgejo: explain todo warnings 2024-06-20 19:09:50 +01:00
2cdaf39c0f refactor: git: use newtype 2024-06-20 19:06:24 +01:00
b9940cd205 tests: use println rather then eprintln in tests
This should reduce the noise in output when a test is running and passing.
2024-06-20 18:54:01 +01:00
8ce4528c88 chore: remove unused Fake repo facade 2024-06-20 18:28:05 +01:00
94ad2c441c refactor: create a RepositoryFactory trait 2024-06-20 18:28:01 +01:00
ea20afee12 refactor: config: use newtype 2024-06-19 08:16:54 +01:00
5e9f9eb80f refactor: start to use newtype macro 2024-06-19 06:45:45 +01:00
2e71e40378 refactor: add newtype macro 2024-06-16 08:00:00 +01:00
be78597331 tests: make TestRepository from git crate available to other crates 2024-06-14 09:05:11 +01:00
2acc43d3d6 chore: remove dead code 2024-06-14 08:19:55 +01:00
Renovate Bot
cb1ba07148 chore(deps): update rust crate console-subscriber to 0.3 2024-06-13 20:03:27 +01:00
9b970835c8 refactor: clean up eprintln use 2024-06-13 20:00:04 +01:00
588666ffe1 tests: add more tests to git crate 2024-06-13 19:50:19 +01:00
926851db19 refactor: rewrite git crate's mock repository 2024-06-09 10:02:57 +01:00
dcd94736a9 refactor: git::push::reset takes all params as refs 2024-06-09 09:49:54 +01:00
c6a1d2c21b refactor: merge git::branch module into git::push 2024-06-09 09:49:54 +01:00
65e9ddf5db fix: remove unused GitDir::into_string() function 2024-06-09 09:49:54 +01:00
b5c0f5bd36 refactor: use given::a_name in config tests 2024-06-08 20:16:15 +01:00
273 changed files with 21156 additions and 6043 deletions

View file

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

View file

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

View file

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

View file

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

8
.gitignore vendored
View file

@ -1,3 +1,6 @@
# git-next ui logs
.local/
# ---> Rust
# Generated by Cargo
# will have compiled files and executables
@ -9,7 +12,7 @@ target/
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
Cargo.lock
# Cargo.lock
# These are backup files generated by rustfmt
**/*.rs.bk
@ -23,3 +26,6 @@ data/
.git-next.toml
.envrc
*.profraw
mutants.out/
mutants.out.old/

View file

@ -1,13 +1,13 @@
steps:
todo_check:
# INFO: This doesn't have an equivalent yet for Forgejo Actions
# INFO: https://woodpecker-ci.org/plugins/TODO-Checker
image: codeberg.org/epsilon_02/todo-checker:1.1
docker-build:
when:
- event: push
branch: next
# INFO: https://woodpecker-ci.org/plugins/Docker%20Buildx
image: docker.io/woodpeckerci/plugin-docker-buildx:5.0.0
settings:
# git-next-woodpecker-todo-checker - read:issue
repository_token: "776a3b928b852472c2af727a360c85c00af64b9f"
prefix_regex: "(#|//) (TODO|FIXME): "
debug: false
username: kemitix
repo: git.kemitix.net/kemitix/git-next
dockerfile: Dockerfile
auto_tag: false
dry-run: true # don't push to remote repo

View file

@ -1,23 +1,10 @@
steps:
publish-to-forgejo:
when:
- event: tag
ref: refs/tags/v*
# INFO: https://woodpecker-ci.org/plugins/Gitea%20Release
image: docker.io/woodpeckerci/plugin-gitea-release:0.3.2
settings:
base_url: https://git.kemitix.net
api_key:
from_secret: FORGEJO_RELEASE_PLUGIN
target: main
prerelease: true
docker-build:
when:
- event: tag
ref: refs/tags/v*
# INFO: https://woodpecker-ci.org/plugins/Docker%20Buildx
image: docker.io/woodpeckerci/plugin-docker-buildx:4.0.0
image: docker.io/woodpeckerci/plugin-docker-buildx:5.0.0
settings:
username: kemitix
repo: git.kemitix.net/kemitix/git-next

View file

@ -2,8 +2,622 @@
All notable changes to this project will be documented in this file.
## `git-next-core` - [0.13.11](https://git.kemitix.net/kemitix/git-next/compare/git-next-core-v0.13.10...git-next-core-v0.13.11) - 2024-09-14
### Added
- should fetch repo on startup when not cloning
- Remove branches when fetching from remote
### Other
- reimplement git fetch using git
## `git-next` - [0.13.11](https://git.kemitix.net/kemitix/git-next/compare/v0.13.10...v0.13.11) - 2024-09-14
### Added
- *(tui)* add time and version in border
- should fetch repo on startup when not cloning
- Remove branches when fetching from remote
### Other
- Update TUI sooner when receiving CI status
- reimplement git fetch using git
- mark tui as complete on roadmap
- Add missing port mapping parameter for running in docker
## `git-next-forge-github` - [0.13.10](https://git.kemitix.net/kemitix/git-next/compare/git-next-forge-github-v0.13.9...git-next-forge-github-v0.13.10) - 2024-09-12
### Added
- optionally specify max commits between dev and main
## `git-next-forge-forgejo` - [0.13.10](https://git.kemitix.net/kemitix/git-next/compare/git-next-forge-forgejo-v0.13.9...git-next-forge-forgejo-v0.13.10) - 2024-09-12
### Added
- optionally specify max commits between dev and main
## `git-next-core` - [0.13.10](https://git.kemitix.net/kemitix/git-next/compare/git-next-core-v0.13.9...git-next-core-v0.13.10) - 2024-09-12
### Added
- optionally specify max commits between dev and main
## `git-next` - [0.13.10](https://git.kemitix.net/kemitix/git-next/compare/v0.13.9...v0.13.10) - 2024-09-12
### Added
- optionally specify max commits between dev and main
### Fixed
- *(tui)* make tui work from docker image
- *(tui)* alerts, such as WIP aren't being reset
- *(test)* tests requiring .git pass when not present
- *(tui)* update ui when push next or main finishes
- *(tui)* don't set background for normal repo alias
## `git-next` - [0.13.9](https://git.kemitix.net/kemitix/git-next/compare/v0.13.8...v0.13.9) - 2024-09-04
### Fixed
- *(tui)* alerts are cleared on next repo update
- shutdown properly on error
- shutdown properly on file parse error
### Other
- Expand docker docmentation
## `git-next-forge-forgejo` - [0.13.8](https://git.kemitix.net/kemitix/git-next/compare/git-next-forge-forgejo-v0.13.7...git-next-forge-forgejo-v0.13.8) - 2024-09-01
### Other
- flatten nested blocks with early returns
- rename method as peel
## `git-next-core` - [0.13.8](https://git.kemitix.net/kemitix/git-next/compare/git-next-core-v0.13.7...git-next-core-v0.13.8) - 2024-09-01
### Fixed
- use configured branch names in user notification
- create git graph log to after doing a fetch
### Other
- flatten nested blocks with early returns
- rename method as peel
## `git-next` - [0.13.8](https://git.kemitix.net/kemitix/git-next/compare/v0.13.7...v0.13.8) - 2024-09-01
### Added
- improved error display when startup fails
- *(tui)* clean up alert display
- *(tui)* remove some borders to clean up appearance
- *(tui)* make progression of branches clearer
- *(tui)* remove label from repo identity widget
- *(tui)* hightlight repo alias in red when in alert
- *(tui)* branch names look more like 'pills'
- *(tui)* highlight branchs in log
- *(tui)* hightlight status message in colour
- *(tui)* use moving heart emoji as liveness indicator
- *(tui)* add scrolling when overflow screen
- *(tui)* forge widgets only use required lines
- *(tui)* repo widgets only use required lines
- *(tui)* move forge alias to left and add prefix
- *(tui)* remove count of forges
- *(tui)* remove duplicate messages from repo body
- *(tui)* highlight user interventions in red
### Fixed
- use configured branch names in user notification
- remove unused imports
- *(tui)* remove logging from inside ui loop
- *(tui)* don't show HEAD in log
- *(tui)* improve colour contrast on light background
- *(tui)* remove unused import
- *(alert)* typo in email message
- *(repo)* avoid blocking threads when pausing
- *(test)* give actix more time to process message
- *(test)* give actix more time to process message
- *(test)* give actix more time to process message
- *(tui)* improve reliability of status updates
- create git graph log to after doing a fetch
- *(tui)* remove logging of tui updates
### Other
- flatten nested blocks with early returns
- merge identical match branches
- *(tui)* add regex dependency
- *(tui)* introduce LogLine to wrap log formatting
- *(tui)* simplify repo identity widget
- rename method as peel
- *(tui)* child widget can provide constraint to container
- *(tui)* merge repo widgets into one
## `git-next-core` - [0.13.7](https://git.kemitix.net/kemitix/git-next/compare/git-next-core-v0.13.6...git-next-core-v0.13.7) - 2024-08-25
### Added
- *(tui)* (experimental) show repo state, messages and git log
## `git-next` - [0.13.7](https://git.kemitix.net/kemitix/git-next/compare/v0.13.6...v0.13.7) - 2024-08-25
### Added
- *(tui)* (experimental) show repo state, messages and git log
## `git-next-forge-github` - [0.13.6](https://git.kemitix.net/kemitix/git-next/compare/git-next-forge-github-v0.13.5...git-next-forge-github-v0.13.6) - 2024-08-23
### Fixed
- *(github)* register webhook with valid callback url
## `git-next-core` - [0.13.6](https://git.kemitix.net/kemitix/git-next/compare/git-next-core-v0.13.5...git-next-core-v0.13.6) - 2024-08-23
### Added
- *(tui)* (experimental) tui option
## `git-next` - [0.13.6](https://git.kemitix.net/kemitix/git-next/compare/v0.13.5...v0.13.6) - 2024-08-23
### Added
- *(tui)* (experimental) tui option
### Fixed
- file_watcher runs on own thread
### Other
- test all feature combinations
## `git-next` - [0.13.5](https://git.kemitix.net/kemitix/git-next/compare/git-next-v0.13.4...git-next-v0.13.5) - 2024-08-10
### Added
- make forge and repo alias more prominent in email
### Fixed
- invalid config section typo in README
## `git-next-forge-github` - [0.13.4](https://git.kemitix.net/kemitix/git-next/compare/git-next-forge-github-v0.13.3...git-next-forge-github-v0.13.4) - 2024-08-08
### Other
- cleanup pedantic clippy in forge-github crate
## `git-next-forge-forgejo` - [0.13.4](https://git.kemitix.net/kemitix/git-next/compare/git-next-forge-forgejo-v0.13.3...git-next-forge-forgejo-v0.13.4) - 2024-08-08
### Other
- cleanup pedantic clippy in forge-forgejo crate
## `git-next-core` - [0.13.4](https://git.kemitix.net/kemitix/git-next/compare/git-next-core-v0.13.3...git-next-core-v0.13.4) - 2024-08-08
### Added
- add short git log graph to notifications
### Other
- macros use a more common syntax
- cleanup pedantic clippy in core crate
## `git-next` - [0.13.4](https://git.kemitix.net/kemitix/git-next/compare/v0.13.3...v0.13.4) - 2024-08-08
### Added
- add short git log graph to notifications
### Fixed
- remove dependcy on clang & mold
### Other
- macros use a more common syntax
- cleanup pedantic clippy in core crate
- cleanup pedantic clippy in cli crate
## `git-next-core` - [0.13.3](https://git.kemitix.net/kemitix/git-next/compare/git-next-core-v0.13.2...git-next-core-v0.13.3) - 2024-08-04
### Fixed
- shout.desktop should be optional
## `git-next` - [0.13.3](https://git.kemitix.net/kemitix/git-next/compare/v0.13.2...v0.13.3) - 2024-08-04
### Fixed
- shout.desktop should be optional
## `git-next` - [0.13.2](https://git.kemitix.net/kemitix/git-next/compare/v0.13.1...v0.13.2) - 2024-08-04
### Other
- timing test waits longer than expiry
## `git-next-forge-github` - [0.13.1](https://git.kemitix.net/kemitix/git-next/compare/git-next-forge-github-v0.13.0...git-next-forge-github-v0.13.1) - 2024-08-04
### Other
- remove unused dependencies
## `git-next-forge-forgejo` - [0.13.1](https://git.kemitix.net/kemitix/git-next/compare/git-next-forge-forgejo-v0.13.0...git-next-forge-forgejo-v0.13.1) - 2024-08-04
### Other
- remove unused dependencies
## `git-next-core` - [0.13.1](https://git.kemitix.net/kemitix/git-next/compare/git-next-core-v0.13.0...git-next-core-v0.13.1) - 2024-08-04
### Added
- prevent duplicate alerts
- add support for desktop notifications
### Other
- remove unused dependencies
- update tests to check for email config parsing
## `git-next` - [0.13.1](https://git.kemitix.net/kemitix/git-next/compare/v0.13.0...v0.13.1) - 2024-08-04
### Added
- prevent duplicate alerts
- add support for desktop notifications
### Fixed
- add example email config to server default template
### Other
- remove unused dependencies
- extract alerts into own actor
- add example to readme for listen, shout & storage
- add config details for sending emails
## `git-next-forge-github` [0.13.0](https://git.kemitix.net/kemitix/git-next/compare/git-next-forge-github-v0.12.1...git-next-forge-github-v0.13.0) - 2024-08-02
### Added
- [**breaking**] restructured server config into listen & shout sections
## `git-next-forge-forgejo` [0.13.0](https://git.kemitix.net/kemitix/git-next/compare/git-next-forge-forgejo-v0.12.1...git-next-forge-forgejo-v0.13.0) - 2024-08-02
### Added
- [**breaking**] restructured server config into listen & shout sections
## `git-next-core` [0.13.0](https://git.kemitix.net/kemitix/git-next/compare/git-next-core-v0.12.1...git-next-core-v0.13.0) - 2024-08-02
### Added
- send email notifications (sendmail/smtp)
- [**breaking**] restructured server config into listen & shout sections
- remove notification.type
- [**breaking**] reduce the max commit dev can be ahead of main
## `git-next` [0.13.0](https://git.kemitix.net/kemitix/git-next/compare/git-next-v0.12.1...git-next-v0.13.0) - 2024-08-02
### Added
- send email notifications (sendmail/smtp)
- [**breaking**] restructured server config into listen & shout sections
- remove notification.type
- terminate process if config file is invalid
- return better errors to user on server failure
- return better errors to the user on init
## [0.12.1] - 2024-07-29
### Bug Fixes
- Webhook secret doesn't need to be base64 encoded ([691a733](https://git.kemitix.net/kemitix/git-next/commit/691a733fc37cfba5d9be72b57e24c5b9d3c1218a))
- Remove requirement for RUSTFLAGS to be set ([e56d6a3](https://git.kemitix.net/kemitix/git-next/commit/e56d6a3ebbb4b4bfcaacc986269ba898ffbd1bc6))
- Make default server config example valid ([b7abe94](https://git.kemitix.net/kemitix/git-next/commit/b7abe949e2067e1c3663d45a520385d967f19af8))
### Miscellaneous Tasks
- Update create publishing command ([bf12712](https://git.kemitix.net/kemitix/git-next/commit/bf12712bcaaefe6ae7da113e03b739b42d860fcf))
- Remove deprecated crates ([5dc0de8](https://git.kemitix.net/kemitix/git-next/commit/5dc0de8a05d610c3a5b7be00aac1033763a76949))
## [0.12.0] - 2024-07-28
[656ec4a](https://git.kemitix.net/kemitix/git-next/commit/656ec4a534b5b55ddceb05eee6ed610207ac33d4)...[b89431b](https://git.kemitix.net/kemitix/git-next/commit/b89431b7798dec0ab80010d76327bef89b94eeb0)
### Bug Fixes
- Don't log content of internal messages ([3ae1132](https://git.kemitix.net/kemitix/git-next/commit/3ae113212af3ee43f36383a22984e03e3f44f3f2))
### Documentation
- Add missing readme for git-next-core ([11de4ef](https://git.kemitix.net/kemitix/git-next/commit/11de4efae6e8e460f93ba05e91278d9239d98c9c))
- Add missing notification config details ([991d0d1](https://git.kemitix.net/kemitix/git-next/commit/991d0d1a08c9730942d53313f9015f8f610dc8bb))
### Features
- Support macOS ([a56c6df](https://git.kemitix.net/kemitix/git-next/commit/a56c6df3f1ad8943185941ca733a4d91069994c1))
- Avoid resetting next to main when dev is ahead of main ([d2ea93f](https://git.kemitix.net/kemitix/git-next/commit/d2ea93f05ec81f7b9af4e2a347fc0b324eb3770f))
### Miscellaneous Tasks
- Remove deprecated crates ([5a595ec](https://git.kemitix.net/kemitix/git-next/commit/5a595ec9eed77cf961f01c671c69ca2bc7988092))
- Bump gix from 0.63 to 0.64 ([b24675d](https://git.kemitix.net/kemitix/git-next/commit/b24675d48a3e35a9d780a7f7f8cbfb1477765a7b))
- Bump mockall from 0.12 to 0.13 ([22faa85](https://git.kemitix.net/kemitix/git-next/commit/22faa851dcdd99451c736290bc17b17cbe6aa55c))
- Release 0.12.0 ([b89431b](https://git.kemitix.net/kemitix/git-next/commit/b89431b7798dec0ab80010d76327bef89b94eeb0))
### Refactor
- Merge server crate into cli crate ([1427284](https://git.kemitix.net/kemitix/git-next/commit/1427284c2a378d29246a7b92d4a5c5d9601793d0))
- Merge server-actor crate into cli crate ([a679abe](https://git.kemitix.net/kemitix/git-next/commit/a679abeafcb624f400c33721b5828c5137d96fc6))
- Merge file-watcher-crate into cli crate ([9ca532a](https://git.kemitix.net/kemitix/git-next/commit/9ca532a2b466b3a23e957a282e54c8985e0794d6))
- Merge file-watcher-crate into cli crate ([366930b](https://git.kemitix.net/kemitix/git-next/commit/366930bcfcdb424e853bb8f81fdad0d719a50a69))
- Merge webhook-actor crate into cli crate ([12ecc30](https://git.kemitix.net/kemitix/git-next/commit/12ecc308d559ed509da9db8016332c877efda3d0))
- Merge repo-actor crate into cli crate ([c1981d8](https://git.kemitix.net/kemitix/git-next/commit/c1981d862c2da6a992475effe70061f56a67ff10))
- Merge forge crate into cli crate ([5745817](https://git.kemitix.net/kemitix/git-next/commit/57458173d033936206d2225ec3b3b6fc8291229e))
## [0.11.0] - 2024-07-26
[f8fefcd](https://git.kemitix.net/kemitix/git-next/commit/f8fefcdeddf556b28dc611b85db8e2b5ffbb570d)...[656ec4a](https://git.kemitix.net/kemitix/git-next/commit/656ec4a534b5b55ddceb05eee6ed610207ac33d4)
### Bug Fixes
- Remove unused dependecy from file-watcher-actor ([b8f4ade](https://git.kemitix.net/kemitix/git-next/commit/b8f4adeb50a98e64efe2a1a9009c4d6a6b458e3b))
### Documentation
- Document Notifications to user ([1690e1b](https://git.kemitix.net/kemitix/git-next/commit/1690e1bff6a3b54ff59b0763ecc2e50c25f9b896))
- Update message graph for repo-actor ([758ca5c](https://git.kemitix.net/kemitix/git-next/commit/758ca5c2dc9273be15cdfb383bdc35095bc7834e))
- Update package graph ([768ec6a](https://git.kemitix.net/kemitix/git-next/commit/768ec6ae02fe7d850ff976d51aa3278c01ce1013))
### Features
- Enable configuration of a webhook for receiving notifications ([c86d890](https://git.kemitix.net/kemitix/git-next/commit/c86d890c2cbbbe87fde58664c68c91b698862044))
- Support sending messages to the user ([e9877ca](https://git.kemitix.net/kemitix/git-next/commit/e9877ca9fa0addf3f018527712355ca0c3d9eb77))
- Dispatch NotifyUser messages to server for user (1/2) ([bcf57bc](https://git.kemitix.net/kemitix/git-next/commit/bcf57bc728fd53f0abb9c4e94d9768fcce5e9dbe))
- Dispatch NotifyUser messages to server for user (2/2) ([288c20c](https://git.kemitix.net/kemitix/git-next/commit/288c20c24b59b2fa5054c81c22d42af2af06afc7))
- Post webhook notifications to user ([9e12f5e](https://git.kemitix.net/kemitix/git-next/commit/9e12f5eb5db5f3b150886b444af4c0ce3dbf2ed9))
### Miscellaneous Tasks
- Release 0.11.0 ([656ec4a](https://git.kemitix.net/kemitix/git-next/commit/656ec4a534b5b55ddceb05eee6ed610207ac33d4))
### Refactor
- Reduce cognitive complexity of `WebhookNotification` handler. 1/2 ([06292c2](https://git.kemitix.net/kemitix/git-next/commit/06292c2711f3aca6bc369b78f67e1936fdba7eb8))
- Reduce cognitive complexity of `WebhookNotification` handler. 2/2 ([c104dfe](https://git.kemitix.net/kemitix/git-next/commit/c104dfedc1f41020b3468d73a52ae49e0050ebb2))
- Reduce cognitive complexity of 'validate_position' ([92ebd45](https://git.kemitix.net/kemitix/git-next/commit/92ebd453076015993d25102d262a4821fe416e06))
- Flag internally that dev not based on main will require used intervention ([ba67b1e](https://git.kemitix.net/kemitix/git-next/commit/ba67b1ebcba46308a44d3f6dccc16ed8b0acefe3))
- Extract messages and handlers modules from webhook-actor ([8f95ae0](https://git.kemitix.net/kemitix/git-next/commit/8f95ae0058a9f426c5d3f8f96990f6b0eb358b9e))
- Use Option<&T> over &Option<T> ([4978400](https://git.kemitix.net/kemitix/git-next/commit/4978400ece7c37ed51328da0667b2abb1b528fc7))
- Merge actor-macros into core ([48c968d](https://git.kemitix.net/kemitix/git-next/commit/48c968db2d166942ba1be0f09f729d5611cedf18))
- Merge config crate into core crate ([ab728c7](https://git.kemitix.net/kemitix/git-next/commit/ab728c7364caa0c8481cd2a10c3fa57bdc7f2d16))
- Merge git create into core crate ([fa5fa80](https://git.kemitix.net/kemitix/git-next/commit/fa5fa809d99b70970d8f0f2f910afb99837e3913))
### Testing
- Restore unlinked test file ([2ec5ae1](https://git.kemitix.net/kemitix/git-next/commit/2ec5ae1d51b48198d0bb96ed5477e6e77f095f76))
## [0.10.0] - 2024-07-16
[41c8a31](https://git.kemitix.net/kemitix/git-next/commit/41c8a319b1344d2ce04bfa8f45eb9a267d8e9a3c)...[f8fefcd](https://git.kemitix.net/kemitix/git-next/commit/f8fefcdeddf556b28dc611b85db8e2b5ffbb570d)
### Bug Fixes
- Move server-default.toml inside crate that uses it ([639e561](https://git.kemitix.net/kemitix/git-next/commit/639e561be60a6e22eda14e2b44764eee6afb6ae7))
- Move default.toml inside crate that uses it ([e2b545a](https://git.kemitix.net/kemitix/git-next/commit/e2b545ae396354cd009c12dc44daadac923f140b))
### Documentation
- Update installation instructions ([050e117](https://git.kemitix.net/kemitix/git-next/commit/050e1171b3b047bc5b5dfd22c1e8d8f4f76efaab))
- Move main README into cli crate ([6981a7b](https://git.kemitix.net/kemitix/git-next/commit/6981a7b5e30c854ede6303958db9ab05600bca79))
- Add readmes to each crate to direct users to main crate ([6c92f64](https://git.kemitix.net/kemitix/git-next/commit/6c92f64f8bcec3306ef13a22e91939f555a9c77d))
- Add UnRegisterWebhook from RepoActor ([f44865f](https://git.kemitix.net/kemitix/git-next/commit/f44865fa92857c9c53c124e520a13cd10ce17a22))
- Update link from root README to cli README ([619e1d5](https://git.kemitix.net/kemitix/git-next/commit/619e1d517d07297fc1e9e0d89fafb93e9136cc07))
### Features
- Unregister webhooks form forge during shutdown ([b715755](https://git.kemitix.net/kemitix/git-next/commit/b715755b91cecd8fa6b67a58ac3e6fd322c9c005))
- Reload server config when file is touched ([33907a1](https://git.kemitix.net/kemitix/git-next/commit/33907a1d3284a2df27994f7da1ef65d3047f165f))
### Miscellaneous Tasks
- Restore clean check and tag checkout to publish script ([95129dd](https://git.kemitix.net/kemitix/git-next/commit/95129ddeefa26db7cb538f2be2ab5b3609e9a175))
- Release 0.10.0 ([f8fefcd](https://git.kemitix.net/kemitix/git-next/commit/f8fefcdeddf556b28dc611b85db8e2b5ffbb570d))
### Build
- Add more metadata for crates.io ([69211a8](https://git.kemitix.net/kemitix/git-next/commit/69211a87a3aaba2c8e4037d5f1a8adbca185f13d))
## [0.9.4] - 2024-07-14
[d24bcd9](https://git.kemitix.net/kemitix/git-next/commit/d24bcd9ab1a31afe20501c6b6e0f08436683c1c2)...[41c8a31](https://git.kemitix.net/kemitix/git-next/commit/41c8a319b1344d2ce04bfa8f45eb9a267d8e9a3c)
### Bug Fixes
- Add missing version for workspace dependencies ([fa7f78c](https://git.kemitix.net/kemitix/git-next/commit/fa7f78c7347ea2cd7a1a854e8aa07acb881911b2))
### Miscellaneous Tasks
- Release 0.9.4 ([41c8a31](https://git.kemitix.net/kemitix/git-next/commit/41c8a319b1344d2ce04bfa8f45eb9a267d8e9a3c))
### Revert
- Fix: explicitly specify version in each crate ([adf56c1](https://git.kemitix.net/kemitix/git-next/commit/adf56c1b38f7ae397a1187302cead4864b3bddab))
## [0.9.3] - 2024-07-14
[59e8fc0](https://git.kemitix.net/kemitix/git-next/commit/59e8fc050d70db2779855f7d1d73e4cf00edd461)...[d24bcd9](https://git.kemitix.net/kemitix/git-next/commit/d24bcd9ab1a31afe20501c6b6e0f08436683c1c2)
### Bug Fixes
- Explicitly specify version in each crate ([cd93d04](https://git.kemitix.net/kemitix/git-next/commit/cd93d047cb948118f32ae0b8b0880a42a74226fb))
### Miscellaneous Tasks
- Release 0.9.3 ([d24bcd9](https://git.kemitix.net/kemitix/git-next/commit/d24bcd9ab1a31afe20501c6b6e0f08436683c1c2))
## [0.9.2] - 2024-07-14
[4c2e122](https://git.kemitix.net/kemitix/git-next/commit/4c2e1223467a3799506d9f44931aeec1d51cd26c)...[59e8fc0](https://git.kemitix.net/kemitix/git-next/commit/59e8fc050d70db2779855f7d1d73e4cf00edd461)
### Bug Fixes
- Typo and missing repository entry in Cargo.toml files ([c289617](https://git.kemitix.net/kemitix/git-next/commit/c289617ba9d530fc04bb197745b75e0c852a7711))
### Miscellaneous Tasks
- Release 0.9.2 ([59e8fc0](https://git.kemitix.net/kemitix/git-next/commit/59e8fc050d70db2779855f7d1d73e4cf00edd461))
## [0.9.1] - 2024-07-14
[43c6e81](https://git.kemitix.net/kemitix/git-next/commit/43c6e812dc611a2538b45b48e2014e42ef492904)...[4c2e122](https://git.kemitix.net/kemitix/git-next/commit/4c2e1223467a3799506d9f44931aeec1d51cd26c)
### Documentation
- Server-actor: add readme showing message paths ([10e6389](https://git.kemitix.net/kemitix/git-next/commit/10e63894c215e90610e79a2950d3bd0b20f1a04b))
- Update changelog ([4c2e122](https://git.kemitix.net/kemitix/git-next/commit/4c2e1223467a3799506d9f44931aeec1d51cd26c))
### Miscellaneous Tasks
- Simplify workspace.members specification ([19d1f77](https://git.kemitix.net/kemitix/git-next/commit/19d1f770659e12bb6dc9733ebb1d134b96320898))
- Add license and descriptions for each crate ([e410cfc](https://git.kemitix.net/kemitix/git-next/commit/e410cfc4f187e77dbd323bd45c6fff1344aa5d0f))
- Release 0.9.1 ([fe23d3f](https://git.kemitix.net/kemitix/git-next/commit/fe23d3fe0aa2d6486024de15ebc6efe3f98faff9))
### Build
- Add publish-to-crates-io workflow ([9d11bb0](https://git.kemitix.net/kemitix/git-next/commit/9d11bb0e1fb97d67c5c734ffcfb6d1c48eb5d291))
- Add script to publish to crates.io ([0c7a060](https://git.kemitix.net/kemitix/git-next/commit/0c7a0602118f4873a185396f2da4d6e596143ad9))
- Disable broke publish workflow ([0981355](https://git.kemitix.net/kemitix/git-next/commit/0981355f28b0970f442f74386508e915e81a624e))
## [0.9.0] - 2024-07-12
[b0be0f6](https://git.kemitix.net/kemitix/git-next/commit/b0be0f636c2021d23448e4859f4ef8c3c58d2500)...[43c6e81](https://git.kemitix.net/kemitix/git-next/commit/43c6e812dc611a2538b45b48e2014e42ef492904)
### Bug Fixes
- Don't modify config of external repos ([57a614b](https://git.kemitix.net/kemitix/git-next/commit/57a614bad351c13788b6209635578b082abddb4d))
### Documentation
- Add roadmap to readme ([6a8d1bf](https://git.kemitix.net/kemitix/git-next/commit/6a8d1bf817b69766e15380e9f21679c5ea5d3c39))
### Features
- GitDir tracks when repo is cloned by git-next ([df35244](https://git.kemitix.net/kemitix/git-next/commit/df352443b7e990aecf15ca91b08fef510c391f22))
- Update auth of interal repos when changed in config ([9c20e78](https://git.kemitix.net/kemitix/git-next/commit/9c20e780d02dea6ede51ace2ebcba033d5fbd8e3))
- Log as an error when webhook url ends with a slash ([7578ab3](https://git.kemitix.net/kemitix/git-next/commit/7578ab31443a752c8f3ba792e782294e9518698c))
- Perform controlled shutdown on ctrl-c ([fd762e2](https://git.kemitix.net/kemitix/git-next/commit/fd762e2bd2fa054988f7ff31a37fb9a1cf603fd0))
- Recheck failed status ([5f36282](https://git.kemitix.net/kemitix/git-next/commit/5f36282667c8c2034f7259db0053d5561788047a))
### Miscellaneous Tasks
- Lint fix for Dockerfile ([cbf6c3b](https://git.kemitix.net/kemitix/git-next/commit/cbf6c3b73c04f844c30a26ade7b2ebd30d4c1e12))
- Bump docker runtime os image ([f61c556](https://git.kemitix.net/kemitix/git-next/commit/f61c556f5bd5d7206657a1958df16398271fdccd))
- Remove unused FakeOpenRepository ([d9feaea](https://git.kemitix.net/kemitix/git-next/commit/d9feaeaa7b06f7bdbf5988199a283eb6a7b4a6d9))
- Bacon treats clippy warnings as errors ([56756ca](https://git.kemitix.net/kemitix/git-next/commit/56756cab707c261f5bc7bcbfaa8f4b75f043eb96))
- Local dev used debug logging ([4252411](https://git.kemitix.net/kemitix/git-next/commit/425241196db84543be99dbd32acdbcaa6762a8fa))
- Release 0.9.0 ([43c6e81](https://git.kemitix.net/kemitix/git-next/commit/43c6e812dc611a2538b45b48e2014e42ef492904))
### Refactor
- Split git::repository::open::tests module ([2e374d3](https://git.kemitix.net/kemitix/git-next/commit/2e374d317a1870ee6331484f0429f5faa6b3511b))
- Split git::repository::tests module ([5ab075c](https://git.kemitix.net/kemitix/git-next/commit/5ab075c181557acad8e271ac08ddd0e729412ef8))
- Extract git::repository::factory module ([4e60be6](https://git.kemitix.net/kemitix/git-next/commit/4e60be61f752a1a2a4171d4266e0e21368f5c47c))
- Split server storage creation out from startup ([4276964](https://git.kemitix.net/kemitix/git-next/commit/4276964f4d0417b9deb953ae25ed54d02c80bab1))
- Split ReceiveServerConfig handler ([7212154](https://git.kemitix.net/kemitix/git-next/commit/721215403790283447b101652e80c1ef766f4611))
- Split messages and handlers for server-actor ([681b2c4](https://git.kemitix.net/kemitix/git-next/commit/681b2c4c10bd291c1a6772a2694c6abbb62c26da))
### Build
- Pin versions for docker base images ([6bbc894](https://git.kemitix.net/kemitix/git-next/commit/6bbc89490ae443871aa2a3a10ac4b503cee3157c))
## [0.8.1] - 2024-07-05
[8beef49](https://git.kemitix.net/kemitix/git-next/commit/8beef49b3e823444fb364cc1dcc4520edbe044d2)...[b0be0f6](https://git.kemitix.net/kemitix/git-next/commit/b0be0f636c2021d23448e4859f4ef8c3c58d2500)
### Bug Fixes
- Mermaid diagram syntax ([99d8672](https://git.kemitix.net/kemitix/git-next/commit/99d8672f553b97145feb756ac20ec57f90582474))
- Typos in mermaid diagram ([209b29d](https://git.kemitix.net/kemitix/git-next/commit/209b29d2172065d7529b395d256cf673cd9fd223))
- Default log level is info ([694135a](https://git.kemitix.net/kemitix/git-next/commit/694135a10b7262a3ad999443d91d42856b32d91f))
### Documentation
- Update crate interdependence graph ([9042005](https://git.kemitix.net/kemitix/git-next/commit/90420052cfca4100165e7af1b9cd7a15c0b269a7))
- Minor updates to README ([6c24a36](https://git.kemitix.net/kemitix/git-next/commit/6c24a364764c7cccc87dd5cc41b4671fb8afad47))
- Update installation instructions ([2483e85](https://git.kemitix.net/kemitix/git-next/commit/2483e851967a71efbeed02220197abd1b553bbe5))
### Miscellaneous Tasks
- Clean up footer of readme ([007a5bd](https://git.kemitix.net/kemitix/git-next/commit/007a5bd13c2255f4f407d2f122a5649f195e84f8))
- Directly re-export function and type ([7b19f3b](https://git.kemitix.net/kemitix/git-next/commit/7b19f3b66f0c8318613193f587a1e3401b97d33d))
- Remove unused token from github tests ([c2953ad](https://git.kemitix.net/kemitix/git-next/commit/c2953adba58f2dffca2160a410725a7c0a3cfd0d))
- Release 0.8.1 ([b0be0f6](https://git.kemitix.net/kemitix/git-next/commit/b0be0f636c2021d23448e4859f4ef8c3c58d2500))
### Refactor
- Cli don't depend directly on git crate ([3dec12d](https://git.kemitix.net/kemitix/git-next/commit/3dec12de2024ccbde94bd8b581c0397743f76bae))
- Server no longer depends directly on git crate ([12849d5](https://git.kemitix.net/kemitix/git-next/commit/12849d5a6956372b6fd0ee300570e078c3bd9346))
## [0.8.0] - 2024-07-02
[ea9a858](https://git.kemitix.net/kemitix/git-next/commit/ea9a858f4856600f955f6de45f0358414920d621)...[8beef49](https://git.kemitix.net/kemitix/git-next/commit/8beef49b3e823444fb364cc1dcc4520edbe044d2)
### Bug Fixes
- Remove unused GitDir::into_string() function ([65e9ddf](https://git.kemitix.net/kemitix/git-next/commit/65e9ddf5db05cf0ff2024ae70eb886475acf769a))
- Where repo config is in server should register webhook ([975c9e3](https://git.kemitix.net/kemitix/git-next/commit/975c9e315ce2a59ebb6742a0b1e42c1716dcec8c))
- Github: restarting server creates duplicate webhook for repo ([db90280](https://git.kemitix.net/kemitix/git-next/commit/db9028064188d766fc1ff872b81f63d1f6758fdd))
- Start validating repo after registering webhook ([68005d7](https://git.kemitix.net/kemitix/git-next/commit/68005d757d919a48bca3ac9d76583b9d98e3f89a))
- ReceiveRepoConfig tries to send two messages ([c9efbb9](https://git.kemitix.net/kemitix/git-next/commit/c9efbb993692a4a106d96eafb149e04f3aca0458))
- Don't retry validation when non-retryable error ([ae7933c](https://git.kemitix.net/kemitix/git-next/commit/ae7933c79ee6dc3190255282705ca030fd3d00a0))
- Github commit should use common headers ([73ab149](https://git.kemitix.net/kemitix/git-next/commit/73ab149aba4f6aac124b6a127514a443be91b914))
- Messages should always get delivered ([83ce957](https://git.kemitix.net/kemitix/git-next/commit/83ce95776e96639bfce09f5a6342f5d27eb0e8c6))
### Documentation
- Fix typo ([0796df0](https://git.kemitix.net/kemitix/git-next/commit/0796df00d49120004186ace7681815d0c4771fdb))
### Features
- Ignore github ping webhook messages ([55d8ccb](https://git.kemitix.net/kemitix/git-next/commit/55d8ccb0bd107bd9454c92569654aaf578074e0c))
- Load log levels from env RUST_LOG ([77d35e8](https://git.kemitix.net/kemitix/git-next/commit/77d35e8a0963f2223c20ff8032d3fb13f7cbedc3))
### Miscellaneous Tasks
- Remove dead code ([2acc43d](https://git.kemitix.net/kemitix/git-next/commit/2acc43d3d694c83e2ef9c1326a3c35c76b527de3))
- Remove unused Fake repo facade ([8ce4528](https://git.kemitix.net/kemitix/git-next/commit/8ce4528c88ae4fb1ad2f4eeb2fbe5ade8f3a7bb2))
- Fix name in config file ([f038ab5](https://git.kemitix.net/kemitix/git-next/commit/f038ab508b7dd24833ef3bd91248e8ed53f1325b))
- Bacon run job runs server ([880fa0c](https://git.kemitix.net/kemitix/git-next/commit/880fa0cc0e3a5492cad2932cf390159f5c893faf))
- Don't treat clippy warnings as errors ([7fdea29](https://git.kemitix.net/kemitix/git-next/commit/7fdea2913aabab23d0ad03897fea55b7f45d10ae))
- Set default logging lever back to info ([d0c731f](https://git.kemitix.net/kemitix/git-next/commit/d0c731fc013499e15b6874574b6fe070a4b44ad0))
- Release 0.8.0 ([8beef49](https://git.kemitix.net/kemitix/git-next/commit/8beef49b3e823444fb364cc1dcc4520edbe044d2))
### Refactor
- Tests: expand test given modules ([aa817a8](https://git.kemitix.net/kemitix/git-next/commit/aa817a8e95389b8f6767fd15cbe773743a4046a2))
- Use given::a_name in config tests ([b5c0f5b](https://git.kemitix.net/kemitix/git-next/commit/b5c0f5bd36d828879a761d8642ed3c33f9fa4093))
- Merge git::branch module into git::push ([c6a1d2c](https://git.kemitix.net/kemitix/git-next/commit/c6a1d2c21b3c4d48459678fc12bee505078a8885))
- Git::push::reset takes all params as refs ([dcd9473](https://git.kemitix.net/kemitix/git-next/commit/dcd94736a995a1b9401b350bba2e7487f91bc385))
- Rewrite git crate's mock repository ([926851d](https://git.kemitix.net/kemitix/git-next/commit/926851db1924e881a6d91e30c3d47c1229c06666))
- Clean up eprintln use ([9b97083](https://git.kemitix.net/kemitix/git-next/commit/9b970835c8f5576401784b0e80b0cf62837450d5))
- Add newtype macro ([2e71e40](https://git.kemitix.net/kemitix/git-next/commit/2e71e403789217afb05d40a4b7284865113a5f50))
- Start to use newtype macro ([5e9f9eb](https://git.kemitix.net/kemitix/git-next/commit/5e9f9eb80ff9e645576a73854a63b437d97731cf))
- Config: use newtype ([ea20afe](https://git.kemitix.net/kemitix/git-next/commit/ea20afee12f8f7e760e5641125dbf12cc073d74c))
- Create a RepositoryFactory trait ([94ad2c4](https://git.kemitix.net/kemitix/git-next/commit/94ad2c441c88563b501b5be570a3a1301a265349))
- Git: use newtype ([2cdaf39](https://git.kemitix.net/kemitix/git-next/commit/2cdaf39c0f0bd2ba1997faa141bbe24489591d0e))
- Forgejo: explain todo warnings ([601e400](https://git.kemitix.net/kemitix/git-next/commit/601e4003005df8fc678fd0015d45320aefc1531c))
- Repo-actor: rewrite tests using mockall ([ffab198](https://git.kemitix.net/kemitix/git-next/commit/ffab1986a77ab6c1fcc45788156b3168c85b8f56))
- Remove unused Forge Deref implementation ([f460cd4](https://git.kemitix.net/kemitix/git-next/commit/f460cd4b493210f81be625dd0276aa6efb61ae8c))
- Remove dead code ([6d9eb0a](https://git.kemitix.net/kemitix/git-next/commit/6d9eb0ab86b9fb5612b81cb365561a20c8b7e30c))
- Extract webhook actor ([eba00a1](https://git.kemitix.net/kemitix/git-next/commit/eba00a112f25ba0b2d8e8b71ae654803920efa32))
- Extract actor-macros crate ([2008afa](https://git.kemitix.net/kemitix/git-next/commit/2008afa4dd256d6796bf60203185f0fb66694c16))
- Extract file-watcher-actor crate ([52d442f](https://git.kemitix.net/kemitix/git-next/commit/52d442f2b05a743bdabe97c2ff2d44dbd44a9b51))
- Server: collapse tests to base of crate ([0fd3373](https://git.kemitix.net/kemitix/git-next/commit/0fd33739c108c22f7f8a36857dd04295a713fff7))
- Update macro signatures and add documentation support ([717cc8b](https://git.kemitix.net/kemitix/git-next/commit/717cc8b0bc19a5c02b6180521969a4fd7789644a))
- Remove dead code ([32fb92f](https://git.kemitix.net/kemitix/git-next/commit/32fb92fb8d14917c6ae82d42994b18770afeb025))
- CloneRepo use actor::do_send to send LoadConfigFromRepo ([c571e9e](https://git.kemitix.net/kemitix/git-next/commit/c571e9ee8ddad8333889846b59b612461248136f))
- Repo-actor: RepoActorLog: replace Mutex with RwLock ([3e137c6](https://git.kemitix.net/kemitix/git-next/commit/3e137c648099687a5faf52945650ec7325f8bc63))
- Tests: repo-actor: use methods on RepoActorLog ([52df211](https://git.kemitix.net/kemitix/git-next/commit/52df2114e5d6df2b150b64cd30e3c5a3c229fe28))
- Git: replace Mutex with RwLock in Repository ([73b416e](https://git.kemitix.net/kemitix/git-next/commit/73b416e3a010f9cd9522c01bca5e7b10dde1cb86))
- Repo-actor: replace Mutex with RwLock ([8fceafc](https://git.kemitix.net/kemitix/git-next/commit/8fceafc3e1f2d84299e4f2102881ec15c9688395))
- File-watcher doesn't debug log on each loop ([c85eee8](https://git.kemitix.net/kemitix/git-next/commit/c85eee85e94a6059efda1ac1ee3a0b3e59be17d1))
- Only start actor system when server starts ([dfc0c1d](https://git.kemitix.net/kemitix/git-next/commit/dfc0c1dc8097234daf5a9e44f40dc834778e4d5f))
### Testing
- Tidy up config, forgejo and git tests ([271f4ec](https://git.kemitix.net/kemitix/git-next/commit/271f4ec1dcb4fd0020221cbd600d3cb1dfdbf04c))
- Add more tests to git crate ([588666f](https://git.kemitix.net/kemitix/git-next/commit/588666ffe19d13c820c4f19dd162b9aea0a7f1b0))
- Make TestRepository from git crate available to other crates ([be78597](https://git.kemitix.net/kemitix/git-next/commit/be78597331380aded1f750bc11c5267ec492943f))
- Use println rather then eprintln in tests ([b9940cd](https://git.kemitix.net/kemitix/git-next/commit/b9940cd205678d8533f057e58e8d5ba1263e593f))
- Repo-actor: add more tests ([e585b07](https://git.kemitix.net/kemitix/git-next/commit/e585b07f6b987294a85107aa268b9083fa1495cc))
- Add more debug tracing ([40c61fa](https://git.kemitix.net/kemitix/git-next/commit/40c61fa9ff41c552aee7e08bc359113f47cc0515))
### Refactos
- Extract server-actor crate ([1131920](https://git.kemitix.net/kemitix/git-next/commit/113192042b8a2e43ccf37440ee85e4d1c280cc9d))
## [0.7.1] - 2024-06-06
[c1c62e7](https://git.kemitix.net/kemitix/git-next/commit/c1c62e7659f9c94a51da72a85a96ebf920457572)...[ea9a858](https://git.kemitix.net/kemitix/git-next/commit/ea9a858f4856600f955f6de45f0358414920d621)
### Bug Fixes
- Github: use correct url to check CI status ([46e2871](https://git.kemitix.net/kemitix/git-next/commit/46e2871e17677745ef6d11e7e3d50014d6da1e1d))
@ -20,6 +634,7 @@ All notable changes to this project will be documented in this file.
- Remove unused dependencies ([235aee8](https://git.kemitix.net/kemitix/git-next/commit/235aee8b11e07926d8b507d4d4b5444a0b0c354a))
- Add grcov-coverage as an alternate report generation recipe ([d67b821](https://git.kemitix.net/kemitix/git-next/commit/d67b821130d1b73765ffcd60952a35141a4b8d3d))
- Ignore coverage metadata (profraw files) ([8609652](https://git.kemitix.net/kemitix/git-next/commit/86096529284ab1eea72b864cd33b68845eae7c7d))
- Release 0.7.1 ([ea9a858](https://git.kemitix.net/kemitix/git-next/commit/ea9a858f4856600f955f6de45f0358414920d621))
### Refactor

5224
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,42 +1,47 @@
[workspace]
resolver = "2"
members = [
"crates/cli",
"crates/server",
"crates/config",
"crates/git",
"crates/forge",
"crates/forge-forgejo",
"crates/forge-github",
"crates/repo-actor",
]
members = ["crates/*"]
[workspace.package]
version = "0.7.1"
edition = "2021"
version = "0.13.11"
[workspace.lints.clippy]
nursery = { level = "warn", priority = -1 }
# pedantic = "warn"
unwrap_used = "warn"
expect_used = "warn"
edition = "2021"
license = "MIT"
repository = "https://git.kemitix.net/kemitix/git-next"
authors = ["Paul Campbell <pcampbell@kemitix.net>"]
rust-version = "1.76"
description = "trunk-based development manager"
documentation = "https://git.kemitix.net/kemitix/git-next/src/branch/main/README.md"
keywords = ["git", "cli", "server", "tool"]
categories = ["development-tools"]
# [workspace.lints.clippy]
# pedantic = { level = "warn", priority = -1 }
# nursery = { level = "warn", priority = -1 }
# unwrap_used = "warn"
# expect_used = "warn"
[workspace.dependencies]
git-next-server = { path = "crates/server" }
git-next-config = { path = "crates/config" }
git-next-git = { path = "crates/git" }
git-next-forge = { path = "crates/forge" }
git-next-forge-forgejo = { path = "crates/forge-forgejo" }
git-next-forge-github = { path = "crates/forge-github" }
git-next-repo-actor = { path = "crates/repo-actor" }
git-next-core = { path = "crates/core", version = "0.13" }
git-next-forge-forgejo = { path = "crates/forge-forgejo", version = "0.13" }
git-next-forge-github = { path = "crates/forge-github", version = "0.13" }
# TUI
ratatui = "0.29"
directories = "5.0"
lazy_static = "1.5"
color-eyre = "0.6"
tui-scrollview = "0.5"
regex = "1.10"
chrono = "0.4"
# CLI parsing
clap = { version = "4.5", features = ["cargo", "derive"] }
# logging
console-subscriber = "0.2"
tracing = "0.1"
tracing-subscriber = "0.3"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tracing-error = "0.2.0"
# base64 decoding
base64 = "0.22"
@ -47,15 +52,15 @@ sha2 = "0.10"
hex = "0.4"
# git
# gix = "0.62"
gix = { version = "0.63", features = [
gix = { version = "0.68", features = [
"dirwalk",
"blocking-http-transport-reqwest-rust-tls",
] }
async-trait = "0.1"
git-url-parse = "0.4"
# fs/network
kxio = { version = "1.2" }
kxio = "3.0"
# TOML parsing
serde = { version = "1.0", features = ["derive"] }
@ -63,7 +68,7 @@ serde_json = "1.0"
toml = "0.8"
# Secrets and Password
secrecy = "0.8"
secrecy = "0.10"
# Conventional Commit check
git-conventional = "0.12"
@ -72,9 +77,12 @@ git-conventional = "0.12"
bytes = "1.6"
ulid = "1.1"
warp = "0.3"
time = "0.3"
standardwebhooks = "1.0"
# boilerplate
derive_more = { version = "1.0.0-beta.6", features = [
bon = "3.0"
derive_more = { version = "1.0.0-beta", features = [
"as_ref",
"constructor",
"display",
@ -82,17 +90,32 @@ derive_more = { version = "1.0.0-beta.6", features = [
"from",
] }
derive-with = "0.5"
thiserror = "1.0"
anyhow = "1.0"
thiserror = "2.0"
pike = "0.1"
# iters
take-until = "0.2"
# file watcher
inotify = "0.10"
notify = "7.0"
# Actors
actix = "0.13"
actix-rt = "2.9"
tokio = { version = "1.37", features = ["rt", "macros"] }
# email
lettre = "0.11"
sendmail = "2.0"
# desktop notifications
notifica = "3.0"
# Testing
assert2 = "0.3"
pretty_assertions = "1.4"
rand = "0.8"
mockall = "0.13"
test-log = "0.2"
rstest = { version = "0.23", features = ["async-timeout"] }

View file

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

View file

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

409
README.md
View file

@ -1,415 +1,12 @@
# git-next
- Status: Alpha - dog-fooding
## Trunk-based developement manager.
`git-next` is a combined server and command-line tool that enables trunk-based
development workflows where each commit must pass CI before being included in
the main branch.
## Features
![Demo](./demo.gif)
- Enforce the requirement for each commit to pass the CI pipeline before being
included in the main branch
- Provide a server component that manages the trunk-based development process
- Ensure a consistent, high-quality codebase by preventing untested changes
from being merged
## Prerequisits
- Rust 1.76.0 or later - https://www.rust-lang.org
- pgk-config
- libssl-dev
### x86_64-unknown-linux-gnu
Additionally for this platform, to improved compilation times:
- clang-16
- mold
See `.cargo/config.toml` for how they are configured.
## Installation
You can install `git-next` using Cargo:
```shell
cargo install --path .
```
Not yet available to install from `crates.io`.
## Branch Names
`git-next` uses three branches, `main`, `next` and `dev`, although they do not
need to have those names. In the documentation we will use those names, but
each repo must specify the names of the branches to use for each, even if they
happen to have those same names.
## Configuration
- The branches to use for `main`, `next` and `dev` must be specified in either
the `.git-next.toml` in the repo itself, or in the server configuration file,
`git-next-server.toml`. See below for details.
- CI checks should be configured to run when the `next` branch is `pushed`.
- The `dev` branch _must_ have the `main` branch as an ancestor.
- The `next` branch _must_ have the `main` branch as an ancestor.
### Server
The server is configured by the `git-next-server.toml` file.
#### http
The server needs to be able to receive webhook notifications from your forge,
(e.g. github.com). You can do this via any method that suits your environment,
e.g. ngrok or a reverse proxy from a web server that itself can route traffic
to the machine you are running the git-next server on.
Specify the address and port the server should listen to for incoming webhooks.
This is the address and port that your reverse proxy should route traffic to.
- **addr** - the IP address the server should bind to
- **port** - the IP port the server should bind to
#### webhook
Your forges need to know where they should route webhooks to. This should be
an address this is accessible to the forge. So, for github.com, it would need
to be a publicly accessible HTTPS URL. For a self-hosted forge, e.g. ForgeJo,
on your own network, then it only needs to be accessible from the server your
forge is running on.
- **url** - the HTTPS URL for forges to send webhook to
#### storage
`git-next` will create a bare clone of each repo that you configure it to
monitor. They will all be created in the directory specified here. This data
does not need to be backed up, as any missing information will be cloned when
the server starts up.
- **path** - directory to store local copies of monitored repos
#### forge
Within the forge tree, specify each forge you want to monitor repos on.
Give your forge an alias, e.g. `default`, `gh`, `github`.
e.g.
```toml
[forge.github]
forge_type = "GitHub"
hostname = "github.com"
user = "username"
token = "api-key"
```
- **forge_type** - one of: `ForgeJo` or `GitHub`
- **hostname** - the hostname for the forge.
- **user** - the user to authenticate as
- **token** - application token for the user. See below for the permissions
required for on each forge.
Generally, the `user` will need to be able to push to `main` and to _force-push_
to `next`.
#### repos
For each forge, you need to specify which repos on the forge you want to
monitor. They do not need to be owned by the `user`, but they `user` must have
the `push` and `force-push` permissions as mentioned above for each of the
repositories.
e.g.
```toml
[forge.github.repos]
my-repo = { repo = "owner/repo", branch = "main", gitdir = "/home/pcampbell/project/my-repo" }
[forge.github.repos.other-repo]
repo = "user/other"
branch = "master"
main = "master"
next = "ci-testing"
dev = "trunk"
```
Note that toml allows specifying the values on one line, or across multiple
lines. Both are equivalent. What is not equivalent between `my-repo` and
`other-repo`, is that one will require a configuration file within the repo
itself. `other-repo` specifies the `main`, `next` and `dev` branches to be
used, but `my-repo` doesn't.
A sample `.git-next-toml` file that would need to exist in `my-repo`'s `owner/repo`
repo, on the `main` branch:
```toml
[branches]
main = "main"
next = "next"
dev = "dev"
```
- **repo** - the owner and name of the repo to be monitored
- **branch** - the branch to look for a `.git-next.toml` file if needed
- **gitdir** - (optional) you can use a local copy of the repo
- **main** - the branch to use as `main`
- **next** - the branch to use as `next`
- **dev** - the branch to use as `dev`
##### gitdir
Additional notes on using `gitdir`:
When you specify the `gitdir` value, the repo cloned in that directory will
be used for perform the equivalent of `git fetch`, `git push` and `git push
--force-with-lease`.
These commands will not affect the contents of your working tree, nor will
it change any local branches. Only the details about branches on the remote
forge will be updated.
Currently `git-next` can only use a `gitdir` if the forge and repo is the
same one specified as the `origin` remote. Otherwise the behaviour is
untested and undefined.
## Behaviour
Development happens on the `dev` branch, where each commit is expected to
be able to pass the CI checks.
(Note: in the diagrams, mermaid isn't capable of showing `main` and `next` on
the same commit, so we show `next` as empty)
```mermaid
gitGraph
commit
commit
branch next
branch dev
commit
commit
commit
```
When the `git-next` server sees that the `dev` branch is ahead of the `next`
branch, it will push the `next` branch fast-forward one commit along the `dev`
branch.
```mermaid
gitGraph
commit
commit
branch next
commit
branch dev
commit
commit
```
It will then wait for the CI checks to pass for the newly updated `next` branch.
When the CI checks for the `next` branch pass, it will push the `main` branch
fast-forward to the `next` branch. We return to the top and start again.
```mermaid
gitGraph
commit
commit
commit
branch next
branch dev
commit
commit
```
If the CI checks should fail for the `next` branch, the developer should
**amend** that commit **in the history of their `dev` branch**.
They should then force-push their rebased `dev` branch.
```mermaid
gitGraph
commit
commit
branch next
commit
checkout main
branch dev
commit
commit
commit
```
`git-next` will then detect that the `next` branch is no longer part of the
`dev` branch ancestory, and will reset `next` back to `main`.
We then return to the top, where `git-next` sees that `dev` is ahead of `next`.
When the `dev` branch is on the same commit as the `main` branch, then there
are no pending commits and `git-next` will wait until it receives a webhook
indicating that there has been a push to one of the branches. At which point
it will start at the top again.
### Important
The `dev` branch _should_ have the `next` branch as an ancestor.
However, when the commit on tip of the `next` branch has failed CI and is
amended, this will not be the case. When this happens `git-next` will
**force-push** the `next` branch back to the same commit as the `main` branch.
This is the only time a force-push will happen in `git-next`.
In short, the `next` branch **belongs** to `git-next`. Don't try to update it
yourself. `git-next` will update the `next` it as it sees fit.
## Getting Started
To use `git-next` for trunk-based development, follow these steps:
### Initialise the repo (optional)
You need to specify which branches you are using. You can do this in the repo,
or in the server configuration.
To create a default config file for the repo, run this command in the root of
your repo:
```shell
git next init
```
This will create a `.git-next.toml` file. [Default](./default.toml)
By default the expected branches are `main`, `next` and `dev`. Each of these
three branches _must_ exist in your repo.
### Initialise the server
The server uses the file `git-next-server.toml` for configuration. It expects
to find this file the the current directory when executed.
The create the default config file, run this command:
```shell
git next server init
```
This will create a `git-next-server.toml` file. [Default](./server-default.toml)
Edit this file to your needs. See the [Configuration](#configuration) section above.
### Run the server
In the directory with your `git-next-server.toml` file, run the command:
```shell
git next server start
```
### Forges
The following forges are supported:
- [ForgeJo](https://forgejo.org) (probably compatible with Gitea, but not tested)
- [GitHub](https://github.com/)
Note: ForgeJo is a hard fork of Gitea, but currently they are largely compatible.
For now using a `forge_type` of `ForgeJo` with a Gitea instance will probably work
okay. The only API calls we make are around registering and unregistering webhooks.
So, as long as those APIs remain the same, they should be compatible.
#### ForgeJo
Configure the forge in `git-next-server.toml` like:
```toml
[forge.jo]
forge_type = "ForgeJo"
hostname = "git.myforgejo.com"
user = "bob"
token = "..."
[forge.jo.repos]
hello = { repo = "user/hello", branch = "main", gitdir = "/opt/git/projects/user/hello.git" } # maps to https://git.example.net/user/hello on the branch 'main'
world = { repo = "user/world", branch = "master", main = "master", next = "upcoming", "dev" = "develop" } # maps to the 'master' branch
```
The token is created on your ForgeJo instance at (for example)
`https://git.myforgejo.com/user/settings/applications`
and requires the `write:repository` permission.
#### GitHub
Configure the forge in `git-next-server.toml` like:
```toml
[forge.gh]
forge_type = "GitHub"
hostname = "github.com" # required even for GitHub
user = "bob"
token = "..."
[forge.gh.repos]
hello = { repo = "user/hello", branch = "main", gitdir = "/opt/git/projects/user/hello.git" } # maps to https://github.com/user/hello on the branch 'main'
world = { repo = "user/world", branch = "master", main = "master", next = "upcoming", "dev" = "develop" } # maps to the 'master' branch
```
The token is created [here](https://github.com/settings/tokens/new) and requires the `repo` and `admin:repo_hook` permissions.
## Contributing
Contributions to `git-next` are welcome! If you find a bug or have a feature
request, please
[create an issue](https://git.kemitix.net/kemitix/git-next/issues/new).
If you'd like to contribute code, feel free to submit changes.
Before you start committing, run the `just install-hooks` command to setup the
Git Hooks. ([Get Just](https://just.systems/man/en/chapter_3.html))
## Crate Dependency
The following diagram shows the dependency between the crates that make up `git-next`:
```mermaid
stateDiagram-v2
cli --> server
cli --> git
server --> config
server --> git
server --> forge
server --> repo_actor
git --> config
forge --> config
forge --> git
forge --> forgejo
forge --> github
forgejo --> config
forgejo --> git
github --> config
github --> git
repo_actor --> config
repo_actor --> git
repo_actor --> forge
```
## License
`git-next` is released under the [MIT License](./LICENSE).
See [README.md](https://git.kemitix.net/kemitix/git-next/src/branch/main/crates/cli/README.md) for more information.

18
RELEASE.md Normal file
View file

@ -0,0 +1,18 @@
# How to create a new Release
## TLDR
1. Merge PR `chore: release`
2. Wait for [`push-main` workflow](https://git.kemitix.net/kemitix/git-next/actions?workflow=push-main.yml) to complete
3. Replace [Release Notes](https://git.kemitix.net/kemitix/git-next/releases) body with details from [CHANGELOG](https://git.kemitix.net/kemitix/git-next/src/branch/main/CHANGELOG.md) (remove crates and duplicates)
4. Update thread: <https://mitra.kemitix.net/post/01907ef5-5bd9-b0b6-2b8a-e29762541d78>
## Detail
The workflow in `.forgejo/workflows/push-main.yaml` will create or update a PR whenever `main` branch is updated.
The create the new release, merge this PR. It will automatically create the Release on the `git-next` repo, and publish crates to <https://crates/io>.
The Release notes that are included with the create Release are currently incorrect, and will need to be manually updated from the `CHANGELOG.md`. Remove crate headers and any resulting duplicates.
Post an update to the Fediverse. See link above.

View file

@ -4,7 +4,7 @@
# Complete help on configuration: https://dystroy.org/bacon/config/
default_job = "check"
reverse = true
# reverse = true
[jobs.check]
command = ["cargo", "check", "--color", "always"]
@ -33,7 +33,6 @@ command = [
"--",
"-Dwarnings",
]
# "-Wclippy::pedantic",
need_stdout = false
[jobs.test]
@ -48,7 +47,7 @@ need_stdout = false
[jobs.doc-open]
command = ["cargo", "doc", "--color", "always", "--no-deps", "--open"]
need_stdout = false
on_success = "back" # so that we don't open the browser at each change
on_success = "back" # so that we don't open the browser at each change
# You can run your application and have the result displayed in bacon,
# *if* it makes sense for this crate. You can run an example the same
@ -57,7 +56,7 @@ on_success = "back" # so that we don't open the browser at each change
# If you want to pass options to your program, a `--` separator
# will be needed.
[jobs.run]
command = [ "cargo", "run", "--color", "always" ]
command = ["cargo", "run", "--color", "always", "--", "server", "start"]
need_stdout = true
allow_warnings = true

View file

@ -2,10 +2,35 @@
name = "git-next"
version = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
repository = { workspace = true }
description = "git-next, the trunk-based development manager"
authors = { workspace = true }
rust-version = { workspace = true }
documentation = { workspace = true }
keywords = { workspace = true }
categories = { workspace = true }
[features]
# default = ["forgejo", "github"]
default = ["forgejo", "github", "tui"]
forgejo = ["git-next-forge-forgejo"]
github = ["git-next-forge-github"]
tui = ["ratatui", "directories", "lazy_static", "tui-scrollview", "regex", "chrono"]
[dependencies]
git-next-server = { workspace = true }
git-next-git = { workspace = true }
git-next-core = { workspace = true }
git-next-forge-forgejo = { workspace = true, optional = true }
git-next-forge-github = { workspace = true, optional = true }
# TUI
ratatui = { workspace = true, optional = true }
directories = { workspace = true, optional = true }
lazy_static = { workspace = true, optional = true }
color-eyre = { workspace = true }
tui-scrollview = { workspace = true, optional = true }
regex = { workspace = true, optional = true }
chrono = { workspace = true, optional = true }
# CLI parsing
clap = { workspace = true }
@ -13,11 +38,62 @@ clap = { workspace = true }
# fs/network
kxio = { workspace = true }
# logging
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
tracing-error.workspace = true
# Conventional Commit check
git-conventional = { workspace = true }
# TOML parsing
toml = { workspace = true }
# Actors
actix = { workspace = true }
actix-rt = { workspace = true }
tokio = { workspace = true }
# boilerplate
bon = { workspace = true }
derive_more = { workspace = true }
derive-with = { workspace = true }
anyhow = { workspace = true }
thiserror = { workspace = true }
# Webhooks
serde_json = { workspace = true }
ulid = { workspace = true }
time = { workspace = true }
secrecy = { workspace = true }
standardwebhooks = { workspace = true }
bytes = { workspace = true }
warp = { workspace = true }
# file watcher (linux)
notify = { workspace = true }
# email
lettre = { workspace = true }
sendmail = { workspace = true }
# desktop notifications
notifica = { workspace = true }
[dev-dependencies]
# Testing
assert2 = { workspace = true }
test-log = { workspace = true }
rand = { workspace = true }
pretty_assertions = { workspace = true }
mockall = { workspace = true }
rstest = { workspace = true }
[lints.clippy]
nursery = { level = "warn", priority = -1 }
# pedantic = "warn"
pedantic = { level = "warn", priority = -1 }
unwrap_used = "warn"
expect_used = "warn"
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(tarpaulin_include)'] }

660
crates/cli/README.md Normal file
View file

@ -0,0 +1,660 @@
# git-next
## Trunk-based developement manager.
> A source-control branching model, where developers collaborate on code in a single branch
> called trunk, resist any pressure to create other long-lived development branches by
> employing documented techniques. They therefore avoid merge hell, do not break the build,
> and live happily ever after. - [source](https://trunkbaseddevelopment.com)
- Status: **BETA** - dog-fooding
`git-next` is a combined server and command-line tool that enables trunk-based
development workflows where each commit must pass CI before being included in
the main branch.
## Features
- Allows enforcing the requirement for each commit to pass the CI pipeline before being
included in the main branch
- Provides a server component that manages the trunk-based development process
- Ensure a consistent, high-quality codebase by preventing untested changes
from being added to main
- Requires each commit uses conventional commit format.
See [Behaviour](#behaviour) to learn how we do this.
## Prerequisits
- Rust 1.76.0 or later - https://www.rust-lang.org
- pgk-config
- libssl-dev
- libdbus-1-dev (ubuntu/debian)
- dbus-devel (fedora)
See `.cargo/config.toml` for how they are configured.
## Installation
You can install `git-next` from <https://crates.io/>:
```shell
cargo install git-next
```
If you use [mise](https://mise.jdx.dev):
```shell
mise use -g cargo:git-next
```
Or you can install `git-next` from source after cloning:
```shell
cargo install --path crates/cli
```
## Roadmap
- [x] cli
- [x] server
- [x] notifications - notify user when intervention required (e.g. to rebase)
- [x] tui overview
- [ ] webui overview
## Branch Names
`git-next` uses three branches, `main`, `next` and `dev`, although they do not
need to have those names. In the documentation we will use those names, but
each repo must specify the names of the branches to use for each, even if they
happen to have those same names.
## Configuration
- The branches to use for `main`, `next` and `dev` must be specified in either
the `.git-next.toml` in the repo itself, or in the server configuration file,
`git-next-server.toml`. See below for details.
- CI checks should be configured to run when the `next` branch is `pushed`.
- The `dev` branch _must_ have the `main` branch as an ancestor.
- The `next` branch _must_ have the `main` branch as an ancestor.
### Server
The server is configured by the `git-next-server.toml` file.
#### listen
The server should listen for webhook notifications from each forge.
```toml
[listen]
http = { addr = "0.0.0.0", port = 8080 }
url = "https://localhost:8080"
```
##### http
The server needs to be able to receive webhook notifications from your forge,
(e.g. github.com). You can do this via any method that suits your environment,
e.g. ngrok or a reverse proxy from a web server that itself can route traffic
to the machine you are running the git-next server on.
Specify the address and port the server should listen to for incoming webhooks.
This is the address and port that your reverse proxy should route traffic to.
- **addr** - the IP address the server should bind to
- **port** - the IP port the server should bind to
##### url
The HTTPS URL for forges to send webhooks to.
Your forges need to know where they should route webhooks to. This should be
an address this is accessible to the forge. So, for github.com, it would need
to be a publicly accessible HTTPS URL. For a self-hosted forge, e.g. ForgeJo,
on your own network, then it only needs to be accessible from the server your
forge is running on.
#### shout
The server should be able to notify the user when manual intervention is required.
```toml
[shout]
desktop = true
[shout.webhook]
url = "https//localhost:9090"
secret = "secret-password"
[shout.email]
from = "git-next@example.com"
to = "developer@example.com"
[shout.email.smtp]
hostname = "smtp.example.com"
username = "git-next@example.com"
password = "MySecretEmailPassword42"
```
##### desktop
When specified as `true`, desktop notifications will be sent for some events.
##### webhook
Will send a POST request for some events.
- **url** - the URL to POST the notification to and the
- **secret** - the sync key used to sign the webhook payload
See [Notifications](#notifications) for more details.
##### email
Will send an email for some events.
- **from** - the email address to send the email from
- **to** - the email address to send the email to
With just `from` and `to` specified, `git-next` will attempt to send emails
with `sendmail` if it is configured.
Alternativly, you can use an SMTP relay.
###### smtp
Will send emails using an SMTP relay.
- **hostname** - the SMTP relay server
- **username** - the account to authenticate as
- **password** - the password to authenticate with
#### storage
```toml
[storage]
path = "./data"
```
`git-next` will create a bare clone of each repo that you configure it to
monitor. They will all be created in the directory specified here. This data
does not need to be backed up, as any missing information will be cloned when
the server starts up.
- **path** - directory to store local copies of monitored repos
#### forge
Within the forge tree, specify each forge you want to monitor repos on.
Give your forge an alias, e.g. `default`, `gh`, `github`.
e.g.
```toml
[forge.github]
forge_type = "GitHub"
hostname = "github.com"
user = "username"
token = "api-key"
max_dev_commits = 25
```
- **forge_type** - one of: `ForgeJo` or `GitHub`
- **hostname** - the hostname for the forge.
- **user** - the user to authenticate as
- **token** - application token for the user. See [Forges](#forges) below for the permissions required for each forge.
- **max_dev_commits** - [optional] the maximum number of commits allowed between `dev` and `main`. Defaults to 25.
Generally, the `user` will need to be able to push to `main` and to _force-push_
to `next`.
#### repos
For each forge, you need to specify which repos on the forge you want to
monitor. They do not need to be owned by the `user`, but they `user` must have
the `push` and `force-push` permissions as mentioned above for each of the
repositories.
e.g.
```toml
[forge.github.repos]
my-repo = { repo = "owner/repo", branch = "main", gitdir = "/home/pcampbell/project/my-repo" }
[forge.github.repos.other-repo]
repo = "user/other"
branch = "master"
main = "master"
next = "ci-testing"
dev = "trunk"
```
Note that toml allows specifying the values on one line, or across multiple
lines. Both are equivalent. What is not equivalent between `my-repo` and
`other-repo`, is that one will require a configuration file within the repo
itself. `other-repo` specifies the `main`, `next` and `dev` branches to be
used, but `my-repo` doesn't.
A sample `.git-next-toml` file that would need to exist in `my-repo`'s `owner/repo`
repo, on the `main` branch:
```toml
[branches]
main = "main"
next = "next"
dev = "dev"
```
- **repo** - the owner and name of the repo to be monitored
- **branch** - the branch to look for a `.git-next.toml` file if needed
- **gitdir** - (optional) you can use a local copy of the repo
- **main** - the branch to use as `main`
- **next** - the branch to use as `next`
- **dev** - the branch to use as `dev`
##### gitdir
Additional notes on using `gitdir`:
When you specify the `gitdir` value, the repo cloned in that directory will
be used for perform the equivalent of `git fetch`, `git push` and `git push
--force-with-lease`.
These commands will not affect the contents of your working tree, nor will
it change any local branches. Only the details about branches on the remote
forge will be updated.
Currently `git-next` can only use a `gitdir` if the forge and repo is the
same one specified as the `origin` remote. Otherwise the behaviour is
untested and undefined.
## Webhook Notifications
When sending a Webhook Notification to a user they are sent using the
Standard Webhooks format. That means all POST messages have the
following headers:
- `Webhook-Id`
- `Webhook-Signature`
- `Webhook-Timestamp`
### Events
#### Dev Not Based on Main
This message `type` indicates that the `dev` branch is not based on `main`.
**Action Required**: Rebase the `dev` branch onto the `main` branch.
Sample payload:
```json
{
"data": {
"branches": {
"dev": "dev",
"main": "main"
},
"forge_alias": "jo",
"repo_alias": "kxio",
"log": [
"* 9bfce91 (origin/dev) fix: add log graph to notifications",
"| * c37bd2c (origin/next, origin/main) feat: add log graph to notifications",
"|/",
"* 8c19680 refactor: macros use a more common syntax"
]
},
"timestamp": "1721760933",
"type": "branch.dev.not-on-main"
}
```
#### CI Check Failed
This message `type` indicates that the commit on the tip of the `next` branch has failed the
configured CI checks.
**Action Required**: Either update the commit to correct the issue CI raised, or, if the issue
is transient (e.g. a network issue), re-run/re-start the job in your CI.
Sample payload:
```json
{
"data": {
"commit": {
"sha": "c37bd2caf6825f9770d725a681e5cfc09d7fd4f2",
"message": "feat: add log graph to notifications (1 of 2)"
},
"forge_alias": "jo",
"repo_alias": "kxio",
"log": [
"* 9bfce91 (origin/dev) feat: add log graph to notifications (2 of 2)",
"* c37bd2c (origin/next) feat: add log graph to notifications (1 of 2)",
"* 8c19680 (origin/main) refactor: macros use a more common syntax"
]
},
"timestamp": "1721760933",
"type": "cicheck.failed"
}
```
#### Repo Config Load Failed
This message `type` indicates that `git-next` wasn't able to load the configuration for the
repo from the `git-next.toml` file in the repository.
**Action Required**: Review the `reason` provided.
Sample payload:
```json
{
"data": {
"reason": "File not found: .git-next.toml",
"forge_alias": "jo",
"repo_alias": "kxio"
},
"timestamp": "1721760933",
"type": "config.load.failed"
}
```
#### Webhook Registration Failed
This message `type` indicates that `git-next` wasn't able to register it's webhook with the
forge repository, so will not receive updates when the branches in the repo are updated.
**Action Required**: Review the `reason` provided.
Sample payload:
```json
{
"data": {
"reason": "repo config not loaded",
"forge_alias": "jo",
"repo_alias": "kxio"
},
"timestamp": "1721760933",
"type": "webhook.registration.failed"
}
```
## Behaviour
The branch names are configurable, but we will talk about `main`, `next` and `dev`.
Development happens on the `dev` branch, where each commit is expected to
be able to pass the CI checks.
(Note: in the diagrams, mermaid isn't capable of showing `main` and `next` on
the same commit, so we show `next` as empty)
```mermaid
gitGraph
commit
commit
branch next
branch dev
commit
commit
commit
```
When the `git-next` server sees that the `dev` branch is ahead of the `next`
branch, it will push the `next` branch fast-forward one commit along the `dev`
branch.
```mermaid
gitGraph
commit
commit
branch next
commit
branch dev
commit
commit
```
It will then wait for the CI checks to pass for the newly updated `next` branch.
When the CI checks for the `next` branch pass, it will push the `main` branch
fast-forward to the `next` branch. We return to the top and start again.
```mermaid
gitGraph
commit
commit
commit
branch next
branch dev
commit
commit
```
If the CI checks should fail for the `next` branch, the developer should
**amend** that commit **in the history of their `dev` branch**.
They should then force-push their rebased `dev` branch.
```mermaid
gitGraph
commit
commit
branch next
commit
checkout main
branch dev
commit
commit
commit
```
`git-next` will then detect that the `next` branch is no longer part of the
`dev` branch ancestory, and will reset `next` back to `main`.
We then return to the top, where `git-next` sees that `dev` is ahead of `next`.
When the `dev` branch is on the same commit as the `main` branch, then there
are no pending commits and `git-next` will wait until it receives a webhook
indicating that there has been a push to one of the branches. At which point
it will start at the top again.
### Important
The `dev` branch _should_ have the `next` branch as an ancestor.
However, when the commit on tip of the `next` branch has failed CI and is
amended, this will not be the case. When this happens `git-next` will
**force-push** the `next` branch back to the same commit as the `main` branch.
This is the only time a force-push will happen in `git-next`.
In short, the `next` branch **belongs** to `git-next`. Don't try to update it
yourself. `git-next` will update the `next` it as it sees fit.
## Getting Started
To use `git-next` for trunk-based development, follow these steps:
### Initialise the repo (optional)
You need to specify which branches you are using. You can do this in the repo,
or in the server configuration.
To create a default config file for the repo, run this command in the root of
your repo:
```shell
git next init
```
This will create a `.git-next.toml` file. [Default](./crates/cli/default.toml)
By default the expected branches are `main`, `next` and `dev`. Each of these
three branches _must_ exist in your repo.
### Initialise the server
The server uses the file `git-next-server.toml` for configuration. It expects
to find this file the the current directory when executed.
The create the default config file, run this command:
```shell
git next server init
```
This will create a `git-next-server.toml` file. [Default](./crates/server/server-default.toml)
Edit this file to your needs. See the [Configuration](#configuration) section above.
### Run the server
In the directory with your `git-next-server.toml` file, run the command:
```shell
git next server start
```
### Forges
The following forges are supported:
- [ForgeJo](https://forgejo.org) (probably compatible with Gitea, but not tested)
- [GitHub](https://github.com/)
Note: ForgeJo is a hard fork of Gitea, but currently they are largely compatible.
For now using a `forge_type` of `ForgeJo` with a Gitea instance will probably work
okay. The only API calls we make are around registering and unregistering webhooks.
So, as long as those APIs remain the same, they should be compatible.
#### ForgeJo
Configure the forge in `git-next-server.toml` like:
```toml
[forge.jo]
forge_type = "ForgeJo"
hostname = "git.myforgejo.com"
user = "bob"
token = "..."
[forge.jo.repos]
hello = { repo = "user/hello", branch = "main", gitdir = "/opt/git/projects/user/hello.git" } # maps to https://git.example.net/user/hello on the branch 'main'
world = { repo = "user/world", branch = "master", main = "master", next = "upcoming", "dev" = "develop" } # maps to the 'master' branch
```
The token is created on your ForgeJo instance at (for example)
`https://git.myforgejo.com/user/settings/applications`
and requires the `write:repository` permission.
#### GitHub
Configure the forge in `git-next-server.toml` like:
```toml
[forge.gh]
forge_type = "GitHub"
hostname = "github.com" # required even for GitHub
user = "bob"
token = "..."
[forge.gh.repos]
hello = { repo = "user/hello", branch = "main", gitdir = "/opt/git/projects/user/hello.git" } # maps to https://github.com/user/hello on the branch 'main'
world = { repo = "user/world", branch = "master", main = "master", next = "upcoming", "dev" = "develop" } # maps to the 'master' branch
```
The token is created [here](https://github.com/settings/tokens/new) and requires the `repo` and `admin:repo_hook` permissions.
## Docker
`git-next` is available as a [Docker image](https://git.kemitix.net/kemitix/-/packages/container/git-next/).
```shell
docker pull docker pull git.kemitix.net/kemitix/git-next:latest
```
### Docker Compose
Here is an example `docker-compose.yml`:
```yaml
services:
server:
image: git.kemitix.net/kemitix/git-next:latest
container_name: git-next-server
restart: unless-stopped
environment:
RUST_LOG: "hyper=warn,info"
ports:
- 8080:8092
volumes:
- ./:/app/
```
Note: this assumes the `git-next-server.toml` has a `listen.http.port` of
`8092` and that you are using a reverse proxy to route traffic arriving at
`listen.url` to port `8080`.
### Docker Run
This will run with the `server start` options:
```shell
docker run -it -p "8080:8092" -v .:/app/ git.kemitix.net/kemitix/git-next:latest
```
To perform `server init`:
```shell
docker run -it -v .:/app/ git.kemitix.net/kemitix/git-next:latest server init
```
To perform repo `init`:
```shell
docker run -it -v .:/app/ git.kemitix.net/kemitix/git-next:latest init
```
TUI support is not available in the docker container. See [kemitix/git-next#154](https://git.kemitix.net/kemitix/git-next/issues/154).
## Contributing
Contributions to `git-next` are welcome! If you find a bug or have a feature
request, please
[create an issue](https://git.kemitix.net/kemitix/git-next/issues/new).
If you'd like to contribute code, feel free to submit changes.
Before you start committing, run the `just install-hooks` command to setup the
Git Hooks. ([Get Just](https://just.systems/man/en/chapter_3.html))
## Crate Dependency
The following diagram shows the dependency between the crates that make up `git-next`:
```mermaid
stateDiagram-v2
cli --> core
cli --> forge_forgejo
cli --> forge_github
forge_forgejo --> core
forge_github --> core
```
## License
`git-next` is released under the [MIT License](./LICENSE).

View file

@ -0,0 +1,10 @@
//
use crate::alerts::short_message;
use git_next_core::git::UserNotification;
pub(super) fn send_desktop_notification(user_notification: &UserNotification) {
let message = short_message(user_notification);
if let Err(err) = notifica::notify("git-next", &message) {
tracing::warn!(?err, "failed to send desktop notification");
}
}

View file

@ -0,0 +1,71 @@
//
use git_next_core::{
git::UserNotification,
server::{EmailConfig, SmtpConfig},
};
use crate::alerts::{full_message, short_message};
#[derive(Debug)]
struct EmailMessage {
from: String,
to: String,
subject: String,
body: String,
}
pub(super) fn send_email(user_notification: &UserNotification, email_config: &EmailConfig) {
let email_message = EmailMessage {
from: email_config.from().to_string(),
to: email_config.to().to_string(),
subject: short_message(user_notification),
body: full_message(user_notification),
};
match email_config.smtp() {
Some(smtp) => send_email_smtp(email_message, smtp),
None => send_email_sendmail(&email_message),
}
}
fn send_email_sendmail(email_message: &EmailMessage) {
use sendmail::email;
match email::send(
&email_message.from,
[email_message.to.as_ref()],
&email_message.subject,
&email_message.body,
) {
Ok(()) => tracing::info!("Email sent successfully!"),
Err(e) => tracing::warn!("Could not send email: {:?}", e),
}
}
fn send_email_smtp(email_message: EmailMessage, smtp: &SmtpConfig) {
if let Err(err) = do_send_email_smtp(email_message, smtp) {
tracing::warn!(?err, "sending email");
}
}
fn do_send_email_smtp(email_message: EmailMessage, smtp: &SmtpConfig) -> Result<(), anyhow::Error> {
use lettre::{transport::smtp::authentication::Credentials, Message, SmtpTransport, Transport};
let email = Message::builder()
.from(email_message.from.parse()?)
.to(email_message.to.parse()?)
.subject(email_message.subject)
.body(email_message.body)?;
let creds = Credentials::new(smtp.username().to_string(), smtp.password().to_string());
let mailer = SmtpTransport::relay(smtp.hostname())?
.credentials(creds)
.build();
Ok(mailer
.send(&email)
.map(|response| {
response
.message()
.map(ToString::to_string)
.collect::<Vec<_>>()
})
.map(|response| {
tracing::info!(?response, "email sent via smtp");
})?)
}

View file

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

View file

@ -0,0 +1,41 @@
//
use actix::prelude::*;
use tracing::{info, Instrument as _};
use crate::alerts::{
desktop::send_desktop_notification, email::send_email, messages::NotifyUser,
webhook::send_webhook, AlertsActor,
};
impl Handler<NotifyUser> for AlertsActor {
type Result = ();
fn handle(&mut self, msg: NotifyUser, ctx: &mut Self::Context) -> Self::Result {
let Some(shout) = &self.shout else {
info!("No shout config available");
return;
};
let net = self.net.clone();
let shout = shout.clone();
let Some(user_notification) = self.history.sendable(msg.peel()) else {
return;
};
async move {
if let Some(webhook_config) = shout.webhook() {
send_webhook(&user_notification, webhook_config, &net).await;
}
if let Some(email_config) = shout.email() {
send_email(&user_notification, email_config);
}
if let Some(desktop) = shout.desktop() {
if desktop {
send_desktop_notification(&user_notification);
}
}
}
.in_current_span()
.into_actor(self)
.wait(ctx);
}
}

View file

@ -0,0 +1,12 @@
//
use actix::prelude::*;
use crate::alerts::{messages::UpdateShout, AlertsActor};
impl Handler<UpdateShout> for AlertsActor {
type Result = ();
fn handle(&mut self, msg: UpdateShout, _ctx: &mut Self::Context) -> Self::Result {
self.shout.replace(msg.peel());
}
}

View file

@ -0,0 +1,50 @@
//
use git_next_core::git::UserNotification;
use tracing::info;
use std::{
collections::HashMap,
time::{Duration, Instant},
};
#[derive(Debug)]
pub struct History {
/// The maximum age of an item in the history.
///
/// Items older than this will be dropped.
max_age_seconds: Duration,
/// Maps a user notification to when it was last seen.
///
/// The user notification will not be sent until after `max_age_seconds` from last seen.
///
/// Each time we see a given user notification, the last seen time will be updated.
items: HashMap<UserNotification, Instant>,
}
impl History {
pub fn new(max_age_seconds: Duration) -> Self {
Self {
max_age_seconds,
items: HashMap::default(),
}
}
pub fn sendable(&mut self, user_notification: UserNotification) -> Option<UserNotification> {
let now = Instant::now();
self.prune(&now); // remove expired items first
let contains_key = self.items.contains_key(&user_notification);
self.items.insert(user_notification.clone(), now);
if contains_key {
info!("previously sent");
return None;
}
info!("not sent before");
Some(user_notification)
}
pub fn prune(&mut self, now: &Instant) {
if let Some(threshold) = now.checked_sub(self.max_age_seconds) {
self.items.retain(|_, last_seen| *last_seen > threshold);
};
}
}

View file

@ -0,0 +1,10 @@
//
use git_next_core::{git::UserNotification, message, server::Shout};
message!(UpdateShout, Shout, "Updated Shout configuration");
message!(
NotifyUser,
UserNotification,
"Request to send the message payload to the notification webhook"
);

View file

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

View file

@ -0,0 +1,68 @@
use std::time::Duration;
use assert2::let_assert;
use git_next_core::git::UserNotification;
use crate::{alerts::History, repo::tests::given};
#[test]
fn when_history_is_empty_then_message_is_passed() {
let mut history = History::new(Duration::from_millis(1));
let user_notification = UserNotification::RepoConfigLoadFailure {
forge_alias: given::a_forge_alias(),
repo_alias: given::a_repo_alias(),
reason: given::a_name(),
};
let result = history.sendable(user_notification);
assert!(result.is_some());
}
#[test]
fn when_history_has_expired_then_message_is_passed() {
let dur = Duration::from_millis(1);
let mut history = History::new(dur);
let user_notification = UserNotification::RepoConfigLoadFailure {
forge_alias: given::a_forge_alias(),
repo_alias: given::a_repo_alias(),
reason: given::a_name(),
};
// add message to history
let result = history.sendable(user_notification);
let_assert!(Some(user_notification) = result);
std::thread::sleep(dur);
// twice the expiry duration to avoid things happening in the wrong order
std::thread::sleep(dur);
// after dur, message has expired, so is still valid
let result = history.sendable(user_notification);
assert!(result.is_some());
}
#[test]
fn when_history_has_unexpired_then_message_is_blocked() {
let dur = Duration::from_secs(1);
let mut history = History::new(dur);
let user_notification = UserNotification::RepoConfigLoadFailure {
forge_alias: given::a_forge_alias(),
repo_alias: given::a_repo_alias(),
reason: given::a_name(),
};
// add message to history
let result = history.sendable(user_notification);
let_assert!(Some(user_notification) = result);
// no time passed
// std::thread::sleep(dur);
// after dur, message has expired, so is still valid
let result = history.sendable(user_notification);
assert!(result.is_none());
}

View file

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

View file

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

View file

@ -0,0 +1,66 @@
//
use actix::prelude::*;
use actix::Recipient;
use anyhow::{Context, Result};
use notify::{event::ModifyKind, Watcher};
use tracing::{error, info};
use std::{
path::PathBuf,
sync::{
atomic::{AtomicBool, Ordering},
Arc,
},
time::Duration,
};
#[derive(Debug, Message)]
#[rtype(result = "()")]
pub struct FileUpdated;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("io")]
Io(#[from] std::io::Error),
}
pub fn watch_file(path: PathBuf, recipient: Recipient<FileUpdated>) -> Result<Arc<AtomicBool>> {
let (tx, rx) = std::sync::mpsc::channel();
let shutdown = Arc::new(AtomicBool::default());
let mut handler = notify::recommended_watcher(tx).context("file watcher")?;
handler
.watch(&path, notify::RecursiveMode::NonRecursive)
.with_context(|| format!("Watching: {path:?}"))?;
let thread_shutdown = shutdown.clone();
actix_rt::task::spawn_blocking(move || {
loop {
if thread_shutdown.load(Ordering::Relaxed) {
drop(handler);
break;
}
for result in rx.try_iter() {
match result {
Ok(event) => match event.kind {
notify::EventKind::Modify(ModifyKind::Data(_)) => {
info!("File modified");
recipient.do_send(FileUpdated);
break;
}
notify::EventKind::Modify(_)
| notify::EventKind::Create(_)
| notify::EventKind::Remove(_)
| notify::EventKind::Any
| notify::EventKind::Access(_)
| notify::EventKind::Other => { /* do nothing */ }
},
Err(err) => {
error!(?err, "Watching file: {path:?}");
}
}
}
std::thread::sleep(Duration::from_millis(1000));
}
});
Ok(shutdown)
}

View file

@ -0,0 +1,32 @@
//
use git_next_core::git::{ForgeLike, RepoDetails};
#[cfg(feature = "forgejo")]
use git_next_forge_forgejo::ForgeJo;
#[cfg(feature = "github")]
use git_next_forge_github::Github;
use kxio::net::Net;
#[derive(Clone, Debug)]
pub struct Forge;
impl Forge {
pub fn create(repo_details: RepoDetails, net: Net) -> Box<dyn ForgeLike> {
match repo_details.forge.forge_type() {
#[cfg(feature = "forgejo")]
git_next_core::ForgeType::ForgeJo => Box::new(ForgeJo::new(repo_details, net)),
#[cfg(feature = "github")]
git_next_core::ForgeType::GitHub => Box::new(Github::new(repo_details, net)),
_ => {
drop(repo_details);
drop(net);
unreachable!();
}
}
}
}
#[cfg(test)]
pub mod tests;

View file

@ -0,0 +1,42 @@
//
#[cfg(any(feature = "forgejo", feature = "github"))]
use super::*;
use git_next_core::{
self as core,
git::{self, RepoDetails},
GitDir, RepoConfigSource, StoragePathType,
};
#[cfg(feature = "forgejo")]
#[test]
fn test_forgejo_name() {
let net = kxio::net::mock();
let repo_details = given_repo_details(git_next_core::ForgeType::ForgeJo);
let forge = Forge::create(repo_details, net.into());
assert_eq!(forge.name(), "forgejo");
}
#[cfg(feature = "github")]
#[test]
fn test_github_name() {
let net = kxio::net::mock();
let repo_details = given_repo_details(git_next_core::ForgeType::GitHub);
let forge = Forge::create(repo_details, net.into());
assert_eq!(forge.name(), "github");
}
#[allow(dead_code)]
fn given_repo_details(forge_type: git_next_core::ForgeType) -> RepoDetails {
let fs = kxio::fs::temp().unwrap_or_else(|e| {
println!("{e}");
panic!("fs")
});
git::repo_details(
1,
git::Generation::default(),
core::common::forge_details(1, forge_type),
Some(core::common::repo_config(1, RepoConfigSource::Repo)),
GitDir::new(fs.base().to_path_buf(), StoragePathType::Internal),
)
}

View file

@ -1,28 +1,20 @@
//
use color_eyre::{eyre::Context, Result};
use kxio::fs::FileSystem;
pub fn run(fs: FileSystem) {
let file_name = ".git-next.toml";
let pathbuf = fs.base().join(file_name);
match fs.path_exists(&pathbuf) {
Ok(exists) => {
if exists {
eprintln!(
"The configuration file already exists at {} - not overwritting it.",
file_name
);
} else {
match fs.file_write(&pathbuf, include_str!("../../../default.toml")) {
Ok(_) => {
println!("Created a default configuration file at {}", file_name);
}
Err(e) => {
eprintln!("Failed to write to the configuration file: {}", e)
}
}
}
}
Err(err) => {
eprintln!("Could not check if file exist: {} - {err:?}", file_name);
}
pub fn run(fs: &FileSystem) -> Result<()> {
let pathbuf = fs.base().join(".git-next.toml");
if fs
.path(&pathbuf)
.exists()
.with_context(|| format!("Checking for existing file: {pathbuf:?}"))?
{
eprintln!("The configuration file already exists at {pathbuf:?} - not overwritting it.",);
} else {
fs.file(&pathbuf)
.write(include_str!("../default.toml"))
.with_context(|| format!("Writing file: {pathbuf:?}"))?;
println!("Created a default configuration file at {pathbuf:?}");
}
Ok(())
}

View file

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

View file

@ -0,0 +1,37 @@
```mermaid
stateDiagram-v2
SERVER --> CloneRepo :on start
SERVER --> UnRegisterWebhook :on shutdown
CloneRepo --> LoadConfigFromRepo :on repo config
CloneRepo --> RegisterWebhook :on server config
LoadConfigFromRepo --> ReceiveRepoConfig
ValidateRepo --> CheckCIStatus :on next ahead of main
ValidateRepo --> AdvanceNext :on dev ahead of next
ValidateRepo --> [*] :on dev == next == main
ValidateRepo --> USER :on non-retryable error
ValidateRepo --> ValidateRepo :on retryable error
CheckCIStatus --> ReceiveCIStatus
ReceiveCIStatus --> AdvanceMain :on Pass
ReceiveCIStatus --> ValidateRepo :on Pending
ReceiveCIStatus --> USER :on Fail
AdvanceNext --> ValidateRepo
ReceiveRepoConfig --> RegisterWebhook
RegisterWebhook --> WebhookRegistered
WebhookRegistered --> ValidateRepo
AdvanceMain --> LoadConfigFromRepo :on repo config
AdvanceMain --> ValidateRepo :on server config
FORGE --> WebhookNotification :on push
WebhookNotification --> ValidateRepo
```

View file

@ -0,0 +1,114 @@
//
use crate::repo::messages::MessageToken;
use git_next_core::{
git::{
commit::Message,
push::{reset, Force},
repository::open::OpenRepositoryLike,
Commit, GitRef, RepoDetails,
},
RepoConfig,
};
use derive_more::Display;
use tracing::{info, instrument, warn};
// advance next to the next commit towards the head of the dev branch
#[instrument(fields(next), skip_all)]
pub fn advance_next(
commit: Option<Commit>,
force: git_next_core::git::push::Force,
repo_details: &RepoDetails,
repo_config: RepoConfig,
open_repository: &dyn OpenRepositoryLike,
message_token: MessageToken,
) -> Result<MessageToken> {
let commit = commit.ok_or_else(|| Error::NextAtDev)?;
validate_commit_message(commit.message())?;
info!("Advancing next to commit '{}'", commit);
reset(
open_repository,
repo_details,
&repo_config.branches().next(),
&commit.into(),
&force,
)?;
Ok(message_token)
}
#[instrument]
fn validate_commit_message(message: &Message) -> Result<()> {
let message = &message.to_string();
if message.to_ascii_lowercase().starts_with("wip") {
return Err(Error::IsWorkInProgress);
}
match ::git_conventional::Commit::parse(message) {
Ok(commit) => {
info!(?commit, "Pass");
Ok(())
}
Err(err) => {
warn!(?err, "Fail");
Err(Error::InvalidCommitMessage {
reason: err.kind().to_string(),
})
}
}
}
pub fn find_next_commit_on_dev(
next: &Commit,
main: &Commit,
dev_commit_history: &[Commit],
) -> (Option<Commit>, Force) {
let mut next_commit: Option<&Commit> = None;
let mut force = Force::No;
for commit in dev_commit_history {
if commit == next {
break;
};
if commit == main {
force = Force::From(GitRef::from(next.sha().clone()));
break;
};
next_commit.replace(commit);
}
(next_commit.cloned(), force)
}
// advance main branch to the commit 'next'
#[instrument(fields(next), skip_all)]
pub fn advance_main(
next: Commit,
repo_details: &RepoDetails,
repo_config: &RepoConfig,
open_repository: &dyn OpenRepositoryLike,
) -> Result<()> {
info!("Advancing main to next");
reset(
open_repository,
repo_details,
&repo_config.branches().main(),
&next.into(),
&Force::No,
)?;
Ok(())
}
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, thiserror::Error, Display)]
pub enum Error {
#[display("push: {}", 0)]
Push(#[from] crate::git::push::Error),
#[display("no commits to advance next to")]
NextAtDev,
#[display("commit is a Work-in-progress")]
IsWorkInProgress,
#[display("commit message is not in conventional commit format: {reason}")]
InvalidCommitMessage { reason: String },
}

View file

@ -0,0 +1,59 @@
//
use actix::prelude::*;
use git_next_core::{git, RepoConfigSource};
use tracing::warn;
use crate::{
repo::{
branch::advance_main,
do_send,
messages::{AdvanceMain, LoadConfigFromRepo, ValidateRepo},
RepoActor,
},
server::actor::messages::RepoUpdate,
};
impl Handler<AdvanceMain> for RepoActor {
type Result = ();
#[tracing::instrument(name = "RepoActor::AdvanceMainTo", skip_all, fields(repo = %self.repo_details, commit = ?msg))]
fn handle(&mut self, msg: AdvanceMain, ctx: &mut Self::Context) -> Self::Result {
let Some(repo_config) = self.repo_details.repo_config.clone() else {
warn!("No config loaded");
return;
};
let Some(open_repository) = &self.open_repository else {
return;
};
let repo_details = self.repo_details.clone();
let addr = ctx.address();
let message_token = self.message_token;
let commit = msg.peel();
self.update_tui(RepoUpdate::AdvancingMain {
commit: commit.clone(),
});
if let Err(err) = advance_main(commit, &repo_details, &repo_config, &**open_repository) {
warn!("advance main: {err}");
self.alert_tui(format!("advance main: {err}"));
} else {
self.update_tui(RepoUpdate::MainUpdated);
if let Some(open_repository) = &self.open_repository {
match open_repository.fetch() {
Ok(()) => self.update_tui_log(git::graph::log(&self.repo_details)),
Err(err) => self.alert_tui(format!("fetching: {err}")),
}
}
match repo_config.source() {
RepoConfigSource::Repo => {
do_send(&addr, LoadConfigFromRepo, self.log.as_ref());
}
RepoConfigSource::Server => {
do_send(&addr, ValidateRepo::new(message_token), self.log.as_ref());
}
}
}
}
}

View file

@ -0,0 +1,75 @@
//
use actix::prelude::*;
use git_next_core::git;
use tracing::{warn, Instrument};
use crate::{
repo::{
branch::{advance_next, find_next_commit_on_dev},
do_send,
messages::{AdvanceNext, AdvanceNextPayload, ValidateRepo},
RepoActor,
},
server::actor::messages::RepoUpdate,
};
impl Handler<AdvanceNext> for RepoActor {
type Result = ();
fn handle(&mut self, msg: AdvanceNext, ctx: &mut Self::Context) -> Self::Result {
let Some(repo_config) = &self.repo_details.repo_config else {
return;
};
let Some(open_repository) = &self.open_repository else {
return;
};
let AdvanceNextPayload {
next,
main,
dev_commit_history,
} = msg.peel();
let repo_details = self.repo_details.clone();
let repo_config = repo_config.clone();
let addr = ctx.address();
let (commit, force) = find_next_commit_on_dev(&next, &main, &dev_commit_history);
if let Some(commit) = &commit {
self.update_tui(RepoUpdate::AdvancingNext {
commit: commit.clone(),
force: force.clone(),
});
};
match advance_next(
commit,
force,
&repo_details,
repo_config,
&**open_repository,
self.message_token,
) {
Ok(message_token) => {
self.update_tui(RepoUpdate::NextUpdated);
match open_repository.fetch() {
Ok(()) => self.update_tui_log(git::graph::log(&self.repo_details)),
Err(err) => self.alert_tui(format!("fetching: {err}")),
}
// INFO: pause to allow any CI checks to be started
let sleep_duration = self.sleep_duration;
let log = self.log.clone();
async move {
actix_rt::time::sleep(sleep_duration).await;
do_send(&addr, ValidateRepo::new(message_token), log.as_ref());
}
.in_current_span()
.into_actor(self)
.wait(ctx);
}
Err(err) => {
warn!("advance next: {err}");
self.alert_tui(err.to_string());
}
}
}
}

View file

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

View file

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

View file

@ -0,0 +1,54 @@
//
use actix::prelude::*;
use git_next_core::git::UserNotification;
use tracing::{debug, instrument, Instrument as _};
use crate::{
repo::{
do_send, load,
messages::{LoadConfigFromRepo, ReceiveRepoConfig},
notify_user, RepoActor,
},
server::actor::messages::RepoUpdate,
};
impl Handler<LoadConfigFromRepo> for RepoActor {
type Result = ();
#[instrument(name = "Repocrate::repo::LoadConfigFromRepo", skip_all, fields(repo = %self.repo_details))]
fn handle(&mut self, _msg: LoadConfigFromRepo, ctx: &mut Self::Context) -> Self::Result {
debug!("Handler: LoadConfigFromRepo: start");
self.update_tui(RepoUpdate::LoadingConfigFromRepo);
let Some(open_repository) = &self.open_repository else {
return;
};
let open_repository = open_repository.duplicate();
let repo_details = self.repo_details.clone();
let forge_alias = repo_details.forge.forge_alias().clone();
let repo_alias = repo_details.repo_alias.clone();
let addr = ctx.address();
let notify_user_recipient = self.notify_user_recipient.clone();
let log = self.log.clone();
async move {
match load::config_from_repository(repo_details, &*open_repository).await {
Ok(repo_config) => {
do_send(&addr, ReceiveRepoConfig::new(repo_config), log.as_ref());
}
Err(err) => notify_user(
notify_user_recipient.as_ref(),
UserNotification::RepoConfigLoadFailure {
forge_alias,
repo_alias,
reason: err.to_string(),
},
log.as_ref(),
),
}
}
.in_current_span()
.into_actor(self)
.wait(ctx);
debug!("Handler: LoadConfigFromRepo: finish");
}
}

View file

@ -0,0 +1,12 @@
pub mod advance_main;
pub mod advance_next;
pub mod check_ci_status;
pub mod clone_repo;
pub mod load_config_from_repo;
pub mod receive_ci_status;
pub mod receive_repo_config;
pub mod register_webhook;
pub mod unregister_webhook;
pub mod validate_repo;
pub mod webhook_notification;
pub mod webhook_registered;

View file

@ -0,0 +1,76 @@
//
use actix::prelude::*;
use git_next_core::git::{forge::commit::Status, graph, UserNotification};
use tracing::{debug, Instrument};
use crate::{
repo::{
do_send, logger,
messages::{AdvanceMain, ReceiveCIStatus, ValidateRepo},
notify_user, RepoActor,
},
server::actor::messages::RepoUpdate,
};
impl Handler<ReceiveCIStatus> for RepoActor {
type Result = ();
fn handle(&mut self, msg: ReceiveCIStatus, ctx: &mut Self::Context) -> Self::Result {
logger(self.log.as_ref(), "start: ReceiveCIStatus");
let (next, status) = msg.peel();
self.update_tui(RepoUpdate::ReceiveCIStatus {
status: status.clone(),
});
debug!(?status, "");
let graph_log = graph::log(&self.repo_details);
self.update_tui_log(graph_log.clone());
let addr = ctx.address();
let forge_alias = self.repo_details.forge.forge_alias().clone();
let repo_alias = self.repo_details.repo_alias.clone();
let message_token = self.message_token;
let sleep_duration = self.sleep_duration;
match status {
Status::Pass => {
do_send(&addr, AdvanceMain::new(next), self.log.as_ref());
}
Status::Pending => {
let log = self.log.clone();
async move {
actix_rt::time::sleep(sleep_duration).await;
do_send(&addr, ValidateRepo::new(message_token), log.as_ref());
}
.in_current_span()
.into_actor(self)
.wait(ctx);
}
Status::Fail => {
tracing::warn!("Checks have failed");
notify_user(
self.notify_user_recipient.as_ref(),
UserNotification::CICheckFailed {
forge_alias,
repo_alias,
commit: next,
log: graph_log,
},
self.log.as_ref(),
);
let log = self.log.clone();
async move {
debug!("sleeping before retrying...");
logger(log.as_ref(), "before sleep");
actix_rt::time::sleep(sleep_duration).await;
logger(log.as_ref(), "after sleep");
do_send(&addr, ValidateRepo::new(message_token), log.as_ref());
}
.in_current_span()
.into_actor(self)
.wait(ctx);
}
Status::Error(_) => todo!(),
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,166 @@
//
use actix::prelude::*;
use tracing::{info, instrument, warn};
use crate::{
repo::{
do_send, logger,
messages::{ValidateRepo, WebhookNotification},
ActorLog, RepoActor,
},
server::actor::messages::RepoUpdate,
};
use git_next_core::{
git::{Commit, ForgeLike},
webhook::{push::Branch, Push},
BranchName, WebhookAuth,
};
impl Handler<WebhookNotification> for RepoActor {
type Result = ();
#[instrument(name = "RepoActor::WebhookMessage", skip_all, fields(token = %self.message_token, repo = %self.repo_details))]
fn handle(&mut self, msg: WebhookNotification, ctx: &mut Self::Context) -> Self::Result {
let Some(config) = &self.repo_details.repo_config else {
logger(self.log.as_ref(), "server has no repo config");
warn!("No repo config");
return;
};
if validate_notification(
&msg,
self.webhook_auth.as_ref(),
&*self.forge,
self.log.as_ref(),
)
.is_err()
{
return;
}
let body = msg.body();
match self.forge.parse_webhook_body(body) {
Err(err) => {
logger(self.log.as_ref(), "message parse error - not a push");
warn!(?err, "Not a 'push'");
return;
}
Ok(push) => match push.branch(config.branches()) {
None => {
logger(self.log.as_ref(), "unknown branch");
warn!(
?push,
"Unrecognised branch, we should be filtering to only the ones we want"
);
return;
}
Some(Branch::Main) => {
self.update_tui(RepoUpdate::WebhookReceived {
branch: Branch::Main,
push: push.clone(),
});
if handle_push(
push,
&config.branches().main(),
&mut self.last_main_commit,
self.log.as_ref(),
)
.is_err()
{
return;
};
}
Some(Branch::Next) => {
self.update_tui(RepoUpdate::WebhookReceived {
branch: Branch::Next,
push: push.clone(),
});
if handle_push(
push,
&config.branches().next(),
&mut self.last_next_commit,
self.log.as_ref(),
)
.is_err()
{
return;
};
}
Some(Branch::Dev) => {
self.update_tui(RepoUpdate::WebhookReceived {
branch: Branch::Dev,
push: push.clone(),
});
if handle_push(
push,
&config.branches().dev(),
&mut self.last_dev_commit,
self.log.as_ref(),
)
.is_err()
{
return;
};
}
},
}
let message_token = self.message_token.next();
info!(
token = %message_token,
"New commit"
);
do_send(
&ctx.address(),
ValidateRepo::new(message_token),
self.log.as_ref(),
);
}
}
fn validate_notification(
msg: &WebhookNotification,
webhook_auth: Option<&WebhookAuth>,
forge: &dyn ForgeLike,
log: Option<&ActorLog>,
) -> Result<(), ()> {
let Some(expected_authorization) = webhook_auth else {
logger(log, "server has no auth token");
warn!("Don't know what authorization to expect");
return Err(());
};
if !forge.is_message_authorised(msg, expected_authorization) {
logger(log, "message authorisation is invalid");
warn!(
"Invalid authorization - expected {}",
expected_authorization
);
return Err(());
}
if forge.should_ignore_message(msg) {
logger(log, "forge sent ignorable message");
return Err(());
}
Ok(())
}
fn handle_push(
push: Push,
branch: &BranchName,
last_commit: &mut Option<Commit>,
log: Option<&ActorLog>,
) -> Result<(), ()> {
logger(log, format!("message is for {branch} branch"));
let commit = Commit::from(push);
if last_commit.as_ref() == Some(&commit) {
logger(log, format!("not a new commit on {branch}"));
info!(
%branch ,
%commit,
"Ignoring - already aware of branch at commit",
);
return Err(());
}
last_commit.replace(commit);
Ok(())
}

View file

@ -0,0 +1,27 @@
//
use actix::prelude::*;
use tracing::instrument;
use crate::{
repo::{
do_send,
messages::{ValidateRepo, WebhookRegistered},
RepoActor,
},
server::actor::messages::RepoUpdate,
};
impl Handler<WebhookRegistered> for RepoActor {
type Result = ();
#[instrument(name = "RepoActor::WebhookRegistered", skip_all, fields(repo = %self.repo_details, webhook_id = %msg.webhook_id()))]
fn handle(&mut self, msg: WebhookRegistered, ctx: &mut Self::Context) -> Self::Result {
self.update_tui(RepoUpdate::RegisteredWebhook);
self.webhook_id.replace(msg.webhook_id().clone());
self.webhook_auth.replace(msg.webhook_auth().clone());
do_send(
&ctx.address(),
ValidateRepo::new(self.message_token),
self.log.as_ref(),
);
}
}

View file

@ -0,0 +1,55 @@
//
use git_next_core::{
git::{repository::open::OpenRepositoryLike, RepoDetails},
BranchName, RepoConfig,
};
use std::path::PathBuf;
use derive_more::Display;
use tracing::{info, instrument};
/// Loads the [RepoConfig] from the `.git-next.toml` file in the repository
#[instrument(skip_all, fields(branch = %repo_details.branch))]
pub async fn config_from_repository(
repo_details: RepoDetails,
open_repository: &dyn OpenRepositoryLike,
) -> Result<RepoConfig> {
info!("Loading .git-next.toml from repo");
let contents =
open_repository.read_file(&repo_details.branch, &PathBuf::from(".git-next.toml"))?;
let config = RepoConfig::parse(&contents)?;
let branches = open_repository.remote_branches()?;
required_branch(&config.branches().main(), &branches)?;
required_branch(&config.branches().next(), &branches)?;
required_branch(&config.branches().dev(), &branches)?;
Ok(config)
}
fn required_branch(branch_name: &BranchName, branches: &[BranchName]) -> Result<()> {
branches
.iter()
.find(|branch| *branch == branch_name)
.ok_or_else(|| Error::BranchNotFound(branch_name.clone()))?;
Ok(())
}
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, thiserror::Error, Display)]
pub enum Error {
#[display("file")]
File(#[from] crate::git::file::Error),
#[display("config")]
Config(#[from] git_next_core::server::Error),
#[display("toml")]
Toml(#[from] toml::de::Error),
#[display("push")]
Push(#[from] crate::git::push::Error),
#[display("branch not found: {}", 0)]
BranchNotFound(BranchName),
}

View file

@ -0,0 +1,127 @@
//
use derive_more::Display;
use git_next_core::{
git::{forge::commit::Status, Commit, UserNotification},
message, newtype, ForgeNotification, RegisteredWebhook, RepoConfig, WebhookAuth, WebhookId,
};
message!(
LoadConfigFromRepo,
"Request to load the `git-next.toml` from the git repo."
);
message!(CloneRepo, "Request to clone (or open) the git repo.");
message!(
ReceiveRepoConfig,
RepoConfig,
r#"Notification that the `git-next.toml` file has been loaded from the repo and parsed.
Contains the parsed contents of the `git-next.toml` file."#
);
message!(
ValidateRepo,
MessageToken,
r#"Request that the state of the branches in the git repo be assessed and generate any followup actions.
This is the main function of `git-next` where decisions are made on what branches need to be updated and when.
Contains a [MessageToken] to reduce duplicate messages being sent. Only messages with the latest [MessageToken] are handled,
all others are dropped."#
);
message!(
WebhookRegistered,
(WebhookId, WebhookAuth),
r#"Notification that a webhook has been registered with a forge.
Contains a tuple of the ID for the webhook returned from the forge, and the unique authorisation token that
incoming messages from the forge must provide."#
);
impl WebhookRegistered {
pub const fn webhook_id(&self) -> &WebhookId {
&self.0 .0
}
pub const fn webhook_auth(&self) -> &WebhookAuth {
&self.0 .1
}
}
impl From<RegisteredWebhook> for WebhookRegistered {
fn from(value: RegisteredWebhook) -> Self {
let webhook_id = value.id().clone();
let webhook_auth = value.auth().clone();
Self::from((webhook_id, webhook_auth))
}
}
message!(
UnRegisterWebhook,
"Request that the webhook be removed from the forge, so they will stop notifying us."
);
newtype!(
MessageToken,
u32,
Copy,
Default,
Display,
PartialOrd,
Ord,
r#"An incremental token used to identify the current set of messages.
Primarily used by [ValidateRepo] to reduce duplicate messages. The token is incremented when a new Webhook message is
received, marking that message the latest, and causing any previous messages still being processed to be dropped when
they next send a [ValidateRepo] message."#
);
impl MessageToken {
pub const fn next(self) -> Self {
Self(self.0 + 1)
}
}
message!(
RegisterWebhook,
"Requests that a Webhook be registered with the forge."
);
message!(
CheckCIStatus,
Commit,
r#"Requests that the CI status for the commit be checked.
Once the CI Status has been received it will be sent via a [ReceiveCIStatus] message.
Contains the commit from the tip of the `next` branch."#
); // next commit
message!(
ReceiveCIStatus,
(Commit, Status),
r#"Notification of the status of the CI checks for the commit.
Contains a tuple of the commit that was checked (the tip of the `next` branch) and the status."#
); // commit and it's status
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AdvanceNextPayload {
pub next: Commit,
pub main: Commit,
pub dev_commit_history: Vec<Commit>,
}
message!(
AdvanceNext,
AdvanceNextPayload,
"Request to advance the `next` branch on to the next commit on the `dev branch."
); // next commit and the dev commit history
message!(
AdvanceMain,
Commit,
"Request to advance the `main` branch on to same commit as the `next` branch."
); // next commit
message!(
WebhookNotification,
ForgeNotification,
"Notification of a webhook message from the forge."
);
message!(
NotifyUser,
UserNotification,
"Request to send the message payload to the notification webhook"
);

204
crates/cli/src/repo/mod.rs Normal file
View file

@ -0,0 +1,204 @@
//
use actix::prelude::*;
use crate::{
alerts::messages::NotifyUser,
server::{actor::messages::RepoUpdate, ServerActor},
};
use derive_more::Deref;
use kxio::net::Net;
use tracing::{info, instrument, warn, Instrument};
use git_next_core::{
git::{
self,
repository::{factory::RepositoryFactory, open::OpenRepositoryLike},
UserNotification,
},
server::ListenUrl,
WebhookAuth, WebhookId,
};
mod branch;
pub mod handlers;
mod load;
pub mod messages;
mod notifications;
#[cfg(test)]
pub mod tests;
#[derive(Clone, Debug, Default)]
pub struct ActorLog(std::sync::Arc<std::sync::RwLock<Vec<String>>>);
impl Deref for ActorLog {
type Target = std::sync::Arc<std::sync::RwLock<Vec<String>>>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
/// An actor that represents a Git Repository.
///
/// When this actor is started it is sent the `CloneRepo` message.
#[allow(clippy::module_name_repetitions)]
#[derive(Debug, derive_more::Display, derive_with::With)]
#[display("{}:{}:{}", generation, repo_details.forge.forge_alias(), repo_details.repo_alias)]
pub struct RepoActor {
sleep_duration: std::time::Duration,
generation: git::Generation,
message_token: messages::MessageToken,
repo_details: git::RepoDetails,
listen_url: ListenUrl,
webhook_id: Option<WebhookId>, // INFO: if [None] then no webhook is configured
webhook_auth: Option<WebhookAuth>, // INFO: if [None] then no webhook is configured
last_main_commit: Option<git::Commit>,
last_next_commit: Option<git::Commit>,
last_dev_commit: Option<git::Commit>,
repository_factory: Box<dyn RepositoryFactory>,
open_repository: Option<Box<dyn OpenRepositoryLike>>,
net: Net,
forge: Box<dyn git::ForgeLike>,
log: Option<ActorLog>,
notify_user_recipient: Option<Recipient<NotifyUser>>,
server_addr: Option<Addr<ServerActor>>,
}
impl RepoActor {
#[allow(clippy::too_many_arguments)]
pub fn new(
repo_details: git::RepoDetails,
forge: Box<dyn git::ForgeLike>,
listen_url: ListenUrl,
generation: git::Generation,
net: Net,
repository_factory: Box<dyn RepositoryFactory>,
sleep_duration: std::time::Duration,
notify_user_recipient: Option<Recipient<NotifyUser>>,
server_addr: Option<Addr<ServerActor>>,
) -> Self {
let message_token = messages::MessageToken::default();
Self {
generation,
message_token,
repo_details,
listen_url,
webhook_id: None,
webhook_auth: None,
last_main_commit: None,
last_next_commit: None,
last_dev_commit: None,
repository_factory,
open_repository: None,
forge,
net,
sleep_duration,
log: None,
notify_user_recipient,
server_addr,
}
}
fn update_tui_branches(&self) {
if cfg!(feature = "tui") {
use crate::server::actor::messages::RepoUpdate;
let Some(repo_config) = &self.repo_details.repo_config else {
return;
};
let branches = repo_config.branches().clone();
self.update_tui(RepoUpdate::Branches { branches });
}
}
#[allow(unused_variables)]
fn update_tui_log(&self, log: git::graph::Log) {
if cfg!(feature = "tui") {
self.update_tui(RepoUpdate::Log { log });
}
}
#[allow(unused_variables)]
fn alert_tui(&self, alert: impl Into<String>) {
if cfg!(feature = "tui") {
self.update_tui(RepoUpdate::Alert {
alert: alert.into(),
});
}
}
#[allow(unused_variables)]
fn update_tui(&self, repo_update: RepoUpdate) {
if cfg!(feature = "tui") {
let Some(server_addr) = &self.server_addr else {
return;
};
let update = crate::server::actor::messages::ServerUpdate::RepoUpdate {
forge_alias: self.repo_details.forge.forge_alias().clone(),
repo_alias: self.repo_details.repo_alias.clone(),
repo_update,
};
server_addr.do_send(update);
}
}
}
impl Actor for RepoActor {
type Context = Context<Self>;
#[instrument(name = "RepoActor::stopping", skip_all, fields(repo = %self.repo_details))]
fn stopping(&mut self, ctx: &mut Self::Context) -> Running {
tracing::debug!("stopping");
info!("Checking webhook");
match self.webhook_id.take() {
Some(webhook_id) => {
tracing::warn!("stopping - unregistering webhook");
info!(%webhook_id, "Unregistring webhook");
let forge = self.forge.duplicate();
async move {
if let Err(err) = forge.unregister_webhook(&webhook_id).await {
warn!("unregistering webhook: {err}");
}
}
.in_current_span()
.into_actor(self)
.wait(ctx);
Running::Continue
}
None => Running::Stop,
}
}
}
pub fn do_send<M>(addr: &Addr<RepoActor>, msg: M, log: Option<&ActorLog>)
where
M: actix::Message + Send + 'static + std::fmt::Debug,
RepoActor: actix::Handler<M>,
<M as actix::Message>::Result: Send,
{
let log_message = format!("send: {msg:?}");
info!(log_message);
logger(log, log_message);
if cfg!(not(test)) {
// #[cfg(not(test))]
addr.do_send(msg);
}
}
pub fn logger(log: Option<&ActorLog>, message: impl Into<String>) {
if let Some(log) = log {
let message: String = message.into();
tracing::debug!(message);
let _ = log.write().map(|mut l| l.push(message));
}
}
pub fn notify_user(
recipient: Option<&Recipient<NotifyUser>>,
user_notification: UserNotification,
log: Option<&ActorLog>,
) {
let msg = NotifyUser::from(user_notification);
let log_message = format!("send: {msg:?}");
tracing::debug!(log_message);
logger(log, log_message);
if let Some(recipient) = &recipient {
recipient.do_send(msg);
}
}

View file

@ -0,0 +1,90 @@
//
use crate::repo::messages::NotifyUser;
use git_next_core::git::UserNotification;
use serde_json::json;
impl NotifyUser {
pub fn as_json(&self, timestamp: time::OffsetDateTime) -> serde_json::Value {
let timestamp = timestamp.unix_timestamp().to_string();
match &**self {
UserNotification::CICheckFailed {
forge_alias,
repo_alias,
commit,
log,
} => json!({
"type": "cicheck.failed",
"timestamp": timestamp,
"data": {
"forge_alias": forge_alias,
"repo_alias": repo_alias,
"commit": {
"sha": commit.sha(),
"message": commit.message()
},
"log": **log
}
}),
UserNotification::RepoConfigLoadFailure {
forge_alias,
repo_alias,
reason,
} => json!({
"type": "config.load.failed",
"timestamp": timestamp,
"data": {
"forge_alias": forge_alias,
"repo_alias": repo_alias,
"reason": reason
}
}),
UserNotification::WebhookRegistration {
forge_alias,
repo_alias,
reason,
} => json!({
"type": "webhook.registration.failed",
"timestamp": timestamp,
"data": {
"forge_alias": forge_alias,
"repo_alias": repo_alias,
"reason": reason
}
}),
UserNotification::DevNotBasedOnMain {
forge_alias,
repo_alias,
dev_branch,
main_branch,
dev_commit,
main_commit,
log,
} => json!({
"type": "branch.dev.not-on-main",
"timestamp": timestamp,
"data": {
"forge_alias": forge_alias,
"repo_alias": repo_alias,
"branches": {
"dev": dev_branch,
"main": main_branch
},
"commits": {
"dev": {
"sha": dev_commit.sha(),
"message": dev_commit.message()
},
"main": {
"sha": main_commit.sha(),
"message": main_commit.message()
}
},
"log": **log
}
}),
}
}
}

View file

@ -0,0 +1,32 @@
use crate::git;
//
use super::*;
#[test]
fn push_is_error_should_error() {
let commit = given::a_commit();
let fs = given::a_filesystem();
let repo_config = given::a_repo_config();
let (mut open_repository, repo_details) = given::an_open_repository(&fs);
expect::fetch_ok(&mut open_repository);
expect::push(&mut open_repository, Err(git::push::Error::Lock));
let_assert!(
Err(err) = branch::advance_main(commit, &repo_details, &repo_config, &open_repository)
);
assert!(matches!(
err,
branch::Error::Push(crate::git::push::Error::Lock)
));
}
#[test]
fn push_is_ok_should_ok() {
let commit = given::a_commit();
let fs = given::a_filesystem();
let repo_config = given::a_repo_config();
let (mut open_repository, repo_details) = given::an_open_repository(&fs);
expect::fetch_ok(&mut open_repository);
expect::push_ok(&mut open_repository);
assert!(branch::advance_main(commit, &repo_details, &repo_config, &open_repository).is_ok());
}

View file

@ -0,0 +1,194 @@
//
use crate::repo::branch::find_next_commit_on_dev;
use super::*;
fn advance_next_sut(
next: &Commit,
main: &Commit,
dev_commit_history: &[Commit],
repo_details: &RepoDetails,
repo_config: RepoConfig,
open_repository: &dyn OpenRepositoryLike,
message_token: MessageToken,
) -> branch::Result<MessageToken> {
let (commit, force) = find_next_commit_on_dev(next, main, dev_commit_history);
branch::advance_next(
commit,
force,
repo_details,
repo_config,
open_repository,
message_token,
)
}
mod when_at_dev {
// next and dev branches are the same
use super::*;
#[test]
fn should_not_push() {
let next = given::a_commit();
let main = &next;
let dev_commit_history = &[next.clone()];
let fs = given::a_filesystem();
let repo_config = given::a_repo_config();
let (open_repository, repo_details) = given::an_open_repository(&fs);
// no on_push defined - so any call to push will cause an error
let message_token = given::a_message_token();
let_assert!(
Err(err) = advance_next_sut(
&next,
main,
dev_commit_history,
&repo_details,
repo_config,
&open_repository,
message_token,
)
);
tracing::debug!("Got: {err}");
assert!(matches!(err, branch::Error::NextAtDev));
}
}
mod can_advance {
// dev has at least one commit ahead of next
use super::*;
mod to_wip_commit {
// commit on dev is either invalid message or a WIP
use super::*;
#[test]
fn should_not_push() {
let next = given::a_commit();
let main = &next;
let dev = given::a_commit_with_message("wip: test: message".to_string());
let dev_commit_history = &[dev, next.clone()];
let fs = given::a_filesystem();
let repo_config = given::a_repo_config();
let (open_repository, repo_details) = given::an_open_repository(&fs);
// no on_push defined - so any call to push will cause an error
let message_token = given::a_message_token();
let_assert!(
Err(err) = advance_next_sut(
&next,
main,
dev_commit_history,
&repo_details,
repo_config,
&open_repository,
message_token,
)
);
tracing::debug!("Got: {err}");
assert!(matches!(err, branch::Error::IsWorkInProgress));
}
}
mod to_invalid_commit {
// commit on dev is either invalid message or a WIP
use super::*;
#[test]
fn should_not_push_and_error() {
let next = given::a_commit();
let main = &next;
let dev = given::a_commit();
let dev_commit_history = &[dev, next.clone()];
let fs = given::a_filesystem();
let repo_config = given::a_repo_config();
let (open_repository, repo_details) = given::an_open_repository(&fs);
// no on_push defined - so any call to push will cause an error
let message_token = given::a_message_token();
let_assert!(
Err(err) = advance_next_sut(
&next,
main,
dev_commit_history,
&repo_details,
repo_config,
&open_repository,
message_token,
)
);
tracing::debug!("Got: {err}");
assert!(matches!(
err,
branch::Error::InvalidCommitMessage{reason}
if reason == "Missing type in the commit summary, expected `type: description`"
));
}
}
mod to_valid_commit {
// commit on dev is valid conventional commit message
use super::*;
mod push_is_err {
// the git push command fails
use super::*;
#[test]
fn should_error() {
let next = given::a_commit();
let main = &next;
let dev = given::a_commit_with_message("test: message".to_string());
let dev_commit_history = &[dev, next.clone()];
let fs = given::a_filesystem();
let repo_config = given::a_repo_config();
let (mut open_repository, repo_details) = given::an_open_repository(&fs);
expect::fetch_ok(&mut open_repository);
expect::push(&mut open_repository, Err(git::push::Error::Lock));
let message_token = given::a_message_token();
let_assert!(
Err(err) = advance_next_sut(
&next,
main,
dev_commit_history,
&repo_details,
repo_config,
&open_repository,
message_token,
)
);
tracing::debug!("Got: {err:?}");
assert!(matches!(err, branch::Error::Push(git::push::Error::Lock)));
}
}
mod push_is_ok {
// the git push command succeeds
use super::*;
#[test]
fn should_ok() {
let next = given::a_commit();
let main = &next;
let dev = given::a_commit_with_message("test: message".to_string());
let dev_commit_history = &[dev, next.clone()];
let fs = given::a_filesystem();
let repo_config = given::a_repo_config();
let (mut open_repository, repo_details) = given::an_open_repository(&fs);
expect::fetch_ok(&mut open_repository);
expect::push_ok(&mut open_repository);
let message_token = given::a_message_token();
let_assert!(
Ok(mt) = advance_next_sut(
&next,
main,
dev_commit_history,
&repo_details,
repo_config,
&open_repository,
message_token,
)
);
tracing::debug!("Got: {mt:?}");
assert_eq!(mt, message_token);
}
}
}
}

View file

@ -0,0 +1,50 @@
use git_next_core::git::push::Force;
use git_next_core::git::GitRef;
//
use super::*;
mod advance_main;
mod advance_next;
use crate::git;
use crate::repo::branch;
#[actix_rt::test]
async fn test_find_next_commit_on_dev_when_next_is_at_main() {
let next = given::a_commit(); // and main
let expected = given::a_commit();
let dev_commit_history = vec![
given::a_commit(), // dev HEAD
expected.clone(),
next.clone(), // next - advancing towards dev HEAD
given::a_commit(), // parent of next
];
let (next_commit, force) = branch::find_next_commit_on_dev(&next, &next, &dev_commit_history);
assert_eq!(next_commit, Some(expected), "Found the wrong commit");
assert_eq!(force, Force::No, "should not try to force");
}
#[actix_rt::test]
async fn test_find_next_commit_on_dev_when_next_is_not_on_dev() {
let next = given::a_commit();
let main = given::a_commit();
let expected = given::a_commit();
let dev_commit_history = vec![
given::a_commit(), // dev HEAD
expected.clone(),
main.clone(), // main - advancing towards dev HEAD
given::a_commit(), // parent of next
];
let (next_commit, force) = branch::find_next_commit_on_dev(&next, &main, &dev_commit_history);
assert_eq!(next_commit, Some(expected), "Found the wrong commit");
assert_eq!(
force,
Force::From(GitRef::from(next.sha().clone())),
"should force back onto dev branch"
);
}

View file

@ -0,0 +1,53 @@
use git_next_core::git::fetch;
//
use super::*;
pub fn fetch_ok(open_repository: &mut MockOpenRepositoryLike) {
expect::fetch(open_repository, Ok(()));
}
pub fn fetch(open_repository: &mut MockOpenRepositoryLike, result: Result<(), fetch::Error>) {
open_repository
.expect_fetch()
.times(1)
.return_once(|| result);
}
pub fn push_ok(open_repository: &mut MockOpenRepositoryLike) {
expect::push(open_repository, Ok(()));
}
pub fn push(
open_repository: &mut MockOpenRepositoryLike,
result: Result<(), crate::git::push::Error>,
) {
open_repository
.expect_push()
.times(1)
.return_once(move |_, _, _, _| result);
}
pub fn open_repository(
repository_factory: &mut MockRepositoryFactory,
open_repository: MockOpenRepositoryLike,
) {
repository_factory
.expect_open()
.times(1)
.return_once(move |_| Ok(Box::new(open_repository)));
}
pub fn main_commit_log(
validation_repo: &mut MockOpenRepositoryLike,
main_branch: BranchName,
) -> Commit {
let main_commit = given::a_commit();
let main_branch_log = vec![main_commit.clone()];
validation_repo
.expect_commit_log()
.times(1)
.with(eq(main_branch), eq([]))
.return_once(move |_, _| Ok(main_branch_log));
main_commit
}

View file

@ -0,0 +1,237 @@
//
use super::*;
use git_next_core::server::ListenUrl;
pub fn has_all_valid_remote_defaults(
open_repository: &mut MockOpenRepositoryLike,
repo_details: &RepoDetails,
) {
has_remote_defaults(
open_repository,
HashMap::from([
(Direction::Push, repo_details.remote_url()),
(Direction::Fetch, repo_details.remote_url()),
]),
);
}
pub fn has_remote_defaults(
open_repository: &mut MockOpenRepositoryLike,
remotes: HashMap<Direction, Option<RemoteUrl>>,
) {
for (direction, remote) in remotes {
open_repository
.expect_find_default_remote()
.with(eq(direction))
.return_once(|_| remote);
}
}
pub fn a_webhook_auth() -> WebhookAuth {
WebhookAuth::generate()
}
pub fn repo_branches() -> RepoBranches {
RepoBranches::new(
format!("main-{}", a_name()),
format!("next-{}", a_name()),
format!("dev-{}", a_name()),
)
}
pub fn a_forge_alias() -> ForgeAlias {
ForgeAlias::new(a_name())
}
pub fn a_repo_alias() -> RepoAlias {
RepoAlias::new(a_name())
}
pub fn a_network() -> kxio::net::MockNet {
kxio::net::mock()
}
pub fn a_listen_url() -> ListenUrl {
ListenUrl::new(a_name())
}
pub fn a_name() -> String {
use rand::Rng;
use std::iter;
fn generate(len: usize) -> String {
const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
let mut rng = rand::thread_rng();
let one_char = || CHARSET[rng.gen_range(0..CHARSET.len())] as char;
iter::repeat_with(one_char).take(len).collect()
}
generate(5)
}
pub fn maybe_a_number() -> Option<u32> {
use rand::Rng;
let mut rng = rand::thread_rng();
if Rng::gen_ratio(&mut rng, 1, 2) {
Some(a_number())
} else {
None
}
}
pub fn a_number() -> u32 {
use rand::Rng;
let mut rng = rand::thread_rng();
rng.gen_range(0..100)
}
pub fn a_webhook_id() -> WebhookId {
WebhookId::new(a_name())
}
pub fn a_branch_name(prefix: impl Into<String>) -> BranchName {
BranchName::new(format!("{}-{}", prefix.into(), a_name()))
}
pub fn a_git_dir(fs: &kxio::fs::FileSystem) -> GitDir {
let dir_name = a_name();
let dir = fs.base().join(dir_name);
GitDir::new(dir, StoragePathType::Internal)
}
pub fn a_forge_config() -> ForgeConfig {
ForgeConfig::new(
ForgeType::MockForge,
a_name(),
a_name(),
a_name(),
maybe_a_number(),
BTreeMap::default(), // no repos
)
}
pub fn a_server_repo_config() -> ServerRepoConfig {
let main = a_branch_name("main").to_string();
let next = a_branch_name("next").to_string();
let dev = a_branch_name("dev").to_string();
ServerRepoConfig::new(
format!("{}/{}", a_name(), a_name()),
main.clone(),
None,
Some(main),
Some(next),
Some(dev),
)
}
pub fn a_repo_config() -> RepoConfig {
RepoConfig::new(given::repo_branches(), RepoConfigSource::Repo)
}
pub fn a_named_commit(name: impl Into<String>) -> Commit {
Commit::new(a_named_commit_sha(name), a_commit_message())
}
pub fn a_commit() -> Commit {
Commit::new(a_commit_sha(), a_commit_message())
}
pub fn a_commit_with_message(message: impl Into<crate::git::commit::Message>) -> Commit {
Commit::new(a_commit_sha(), message.into())
}
pub fn a_commit_message() -> crate::git::commit::Message {
crate::git::commit::Message::new(a_name())
}
pub fn a_named_commit_sha(name: impl Into<String>) -> Sha {
Sha::new(format!("{}-{}", name.into(), a_name()))
}
pub fn a_commit_sha() -> Sha {
Sha::new(a_name())
}
pub fn a_filesystem() -> kxio::fs::TempFileSystem {
#[allow(clippy::expect_used)]
kxio::fs::temp().expect("temp fs")
}
pub fn repo_details(fs: &kxio::fs::FileSystem) -> RepoDetails {
let generation = Generation::default();
let repo_alias = a_repo_alias();
let server_repo_config = a_server_repo_config();
let forge_alias = a_forge_alias();
let forge_config = a_forge_config();
let gitdir = a_git_dir(fs);
RepoDetails::new(
generation,
&repo_alias,
&server_repo_config,
&forge_alias,
&forge_config,
gitdir,
)
}
pub fn an_open_repository(fs: &kxio::fs::FileSystem) -> (MockOpenRepositoryLike, RepoDetails) {
let open_repository = MockOpenRepositoryLike::new();
let gitdir = given::a_git_dir(fs);
let hostname = given::a_hostname();
let repo_details = given::repo_details(fs)
.with_gitdir(gitdir)
.with_hostname(hostname);
(open_repository, repo_details)
}
pub fn a_message_token() -> MessageToken {
MessageToken::default()
}
#[allow(clippy::unnecessary_box_returns)]
pub fn a_forge() -> Box<MockForgeLike> {
Box::new(MockForgeLike::new())
}
pub fn a_repo_actor(
repo_details: RepoDetails,
repository_factory: Box<dyn RepositoryFactory>,
forge: Box<dyn ForgeLike>,
net: kxio::net::Net,
) -> (RepoActor, ActorLog) {
let listen_url = given::a_listen_url();
let generation = Generation::default();
let log = ActorLog::default();
let actors_log = log.clone();
(
RepoActor::new(
repo_details,
forge,
listen_url,
generation,
net,
repository_factory,
std::time::Duration::from_nanos(1),
None,
None,
)
.with_log(actors_log),
log,
)
}
pub fn a_hostname() -> Hostname {
Hostname::new(given::a_name())
}
pub fn a_registered_webhook() -> RegisteredWebhook {
RegisteredWebhook::new(given::a_webhook_id(), given::a_webhook_auth())
}
pub fn a_push() -> Push {
Push::new(
given::a_branch_name("push"),
given::a_name(),
given::a_name(),
)
}

View file

@ -0,0 +1,98 @@
//
use super::*;
#[actix::test]
async fn when_repo_config_should_fetch_then_push_then_revalidate() -> TestResult {
//given
let fs = given::a_filesystem();
let (mut open_repository, mut repo_details) = given::an_open_repository(&fs);
// config from repo
#[allow(clippy::unwrap_used)]
let repo_config = repo_details.repo_config.take().unwrap();
repo_details.repo_config = Some(repo_config.with_source(RepoConfigSource::Repo));
let next_commit = given::a_commit_with_message("feat: next".to_string());
let mut seq = mockall::Sequence::new();
open_repository
.expect_fetch()
.times(1)
.in_sequence(&mut seq)
.return_once(|| Ok(()));
open_repository
.expect_push()
.times(1)
.in_sequence(&mut seq)
.return_once(|_, _, _, _| Ok(()));
open_repository
.expect_fetch()
.times(1)
.in_sequence(&mut seq)
.return_once(|| Ok(()));
//when
let (addr, log) = when::start_actor_with_open_repository(
Box::new(open_repository),
repo_details,
given::a_forge(),
);
addr.send(crate::repo::messages::AdvanceMain::new(next_commit.clone()))
.await?;
System::current().stop();
//then
tracing::debug!(?log, "");
log.read().map_err(|e| e.to_string()).map(|l| {
assert!(l
.iter()
.any(|message| message.contains("send: LoadConfigFromRepo")));
})?;
Ok(())
}
#[actix::test]
async fn when_app_config_should_fetch_then_push_then_revalidate() -> TestResult {
//given
let fs = given::a_filesystem();
let (mut open_repository, mut repo_details) = given::an_open_repository(&fs);
// config from server
#[allow(clippy::unwrap_used)]
let repo_config = repo_details.repo_config.take().unwrap();
repo_details.repo_config = Some(repo_config.with_source(RepoConfigSource::Server));
let next_commit = given::a_commit_with_message("feat: next".to_string());
let mut seq = mockall::Sequence::new();
open_repository
.expect_fetch()
.times(1)
.in_sequence(&mut seq)
.return_once(|| Ok(()));
open_repository
.expect_push()
.times(1)
.in_sequence(&mut seq)
.return_once(|_, _, _, _| Ok(()));
open_repository
.expect_fetch()
.times(1)
.in_sequence(&mut seq)
.return_once(|| Ok(()));
//when
let (addr, log) = when::start_actor_with_open_repository(
Box::new(open_repository),
repo_details,
given::a_forge(),
);
addr.send(crate::repo::messages::AdvanceMain::new(next_commit.clone()))
.await?;
System::current().stop();
//then
tracing::debug!(?log, "");
log.require_message_containing("send: ValidateRepo")?;
Ok(())
}

View file

@ -0,0 +1,58 @@
use std::time::Duration;
use crate::repo::messages::AdvanceNextPayload;
//
use super::*;
#[test_log::test(actix::test)]
async fn should_fetch_then_push_then_revalidate() -> TestResult {
//given
let fs = given::a_filesystem();
let (mut open_repository, repo_details) = given::an_open_repository(&fs);
let next_commit = given::a_commit_with_message("feat: next".to_string());
let dev_commit_history = vec![
given::a_commit_with_message("feat: dev".to_string()),
given::a_commit_with_message("feat: target".to_string()),
next_commit.clone(),
];
let mut seq = mockall::Sequence::new();
open_repository
.expect_fetch()
.times(1)
.in_sequence(&mut seq)
.return_once(|| Ok(()));
open_repository
.expect_push()
.times(1)
.in_sequence(&mut seq)
.return_once(|_, _, _, _| Ok(()));
open_repository
.expect_fetch()
.times(1)
.in_sequence(&mut seq)
.return_once(|| Ok(()));
//when
let (addr, log) = when::start_actor_with_open_repository(
Box::new(open_repository),
repo_details,
given::a_forge(),
);
addr.send(crate::repo::messages::AdvanceNext::new(
AdvanceNextPayload {
next: next_commit.clone(),
main: next_commit.clone(),
dev_commit_history,
},
))
.await?;
actix_rt::time::sleep(Duration::from_millis(9)).await;
System::current().stop();
//then
tracing::debug!(?log, "");
log.require_message_containing("send: ValidateRepo")?;
Ok(())
}

View file

@ -0,0 +1,37 @@
//
use super::*;
#[actix::test]
async fn should_passthrough_to_receive_ci_status() -> TestResult {
//given
let fs = given::a_filesystem();
let (open_repository, repo_details) = given::an_open_repository(&fs);
let next_commit = given::a_named_commit("next");
let mut forge = git::MockForgeLike::new();
when::commit_status(
&mut forge,
next_commit.clone(),
git::forge::commit::Status::Pass,
);
//when
let (addr, log) = when::start_actor_with_open_repository(
Box::new(open_repository),
repo_details,
Box::new(forge),
);
addr.send(crate::repo::messages::CheckCIStatus::new(
next_commit.clone(),
))
.await?;
System::current().stop();
//then
tracing::debug!(?log, "");
log.read().map_err(|e| e.to_string()).map(|l| {
assert!(l
.iter()
.any(|message| message.contains("send: ReceiveCIStatus")));
})?;
Ok(())
}

View file

@ -0,0 +1,194 @@
//
use super::*;
#[actix::test]
async fn should_clone() -> TestResult {
//given
let fs = given::a_filesystem();
let (mut open_repository, /* mut */ repo_details) = given::an_open_repository(&fs);
// #[allow(clippy::unwrap_used)]
// let repo_config = repo_details.repo_config.take().unwrap();
given::has_all_valid_remote_defaults(&mut open_repository, &repo_details);
// handles_validate_repo_message(&mut open_repository, repo_config.branches());
// factory clones an open repository
let mut repository_factory = MockRepositoryFactory::new();
let cloned = Arc::new(RwLock::new(vec![]));
let cloned_ref = cloned.clone();
repository_factory
.expect_git_clone()
.times(2)
.return_once(move |_| {
let _ = cloned_ref.write().map(|mut l| l.push(()));
Ok(Box::new(open_repository))
});
//when
let (addr, _log) = when::start_actor(repository_factory, repo_details, given::a_forge());
addr.send(CloneRepo::new()).await?;
System::current().stop();
//then
cloned
.read()
.map_err(|e| e.to_string())
.map(|o| assert_eq!(o.len(), 1))?;
Ok(())
}
#[actix::test]
async fn should_open() -> TestResult {
//given
let fs = given::a_filesystem();
let (mut open_repository, repo_details) = given::an_open_repository(&fs);
open_repository
.expect_fetch()
.times(1)
.return_once(|| Ok(()));
given::has_all_valid_remote_defaults(&mut open_repository, &repo_details);
// factory opens a repository
let mut repository_factory = MockRepositoryFactory::new();
let opened = Arc::new(RwLock::new(vec![]));
let opened_ref = opened.clone();
repository_factory
.expect_open()
.times(1)
.return_once(move |_| {
let _ = opened_ref.write().map(|mut l| l.push(()));
Ok(Box::new(open_repository))
});
fs.dir(&repo_details.gitdir).create()?;
//when
let (addr, _log) = when::start_actor(repository_factory, repo_details, given::a_forge());
addr.send(CloneRepo::new()).await?;
System::current().stop();
//then
opened
.read()
.map_err(|e| e.to_string())
.map(|o| assert_eq!(o.len(), 1))?;
Ok(())
}
/// The server config can optionally include the names of the main, next and dev
/// branches. When it doesn't we should load the `.git-next.yaml` from from the
/// repo and get the branch names from there by sending a [LoadConfigFromRepo] message.
#[actix::test]
async fn when_server_has_no_repo_config_should_send_load_from_repo() -> TestResult {
//given
let fs = given::a_filesystem();
let (mut open_repository, mut repo_details) = given::an_open_repository(&fs);
open_repository
.expect_fetch()
.times(1)
.return_once(|| Ok(()));
#[allow(clippy::unwrap_used)]
let _repo_config = repo_details.repo_config.take().unwrap();
given::has_all_valid_remote_defaults(&mut open_repository, &repo_details);
let mut repository_factory = MockRepositoryFactory::new();
expect::open_repository(&mut repository_factory, open_repository);
fs.dir(&repo_details.gitdir).create()?;
//when
let (addr, log) = when::start_actor(repository_factory, repo_details, given::a_forge());
addr.send(CloneRepo::new()).await?;
System::current().stop();
//then
log.require_message_containing("send: LoadConfigFromRepo")?;
Ok(())
}
/// The server config can optionally include the names of the main, next and dev
/// branches. When it does we should register the webhook by sending [RegisterWebhook] message.
#[actix::test]
async fn when_server_has_repo_config_should_send_register_webhook() -> TestResult {
//given
let fs = given::a_filesystem();
let (mut open_repository, repo_details) = given::an_open_repository(&fs);
open_repository
.expect_fetch()
.times(1)
.return_once(|| Ok(()));
#[allow(clippy::unwrap_used)]
given::has_all_valid_remote_defaults(&mut open_repository, &repo_details);
let mut repository_factory = MockRepositoryFactory::new();
expect::open_repository(&mut repository_factory, open_repository);
fs.dir(&repo_details.gitdir).create()?;
//when
let (addr, log) = when::start_actor(repository_factory, repo_details, given::a_forge());
addr.send(CloneRepo::new()).await?;
System::current().stop();
//then
log.require_message_containing("send: RegisterWebhook")?;
Ok(())
}
#[test_log::test(actix::test)]
async fn opened_repo_with_no_default_push_should_not_proceed() -> TestResult {
//given
let fs = given::a_filesystem();
let (mut open_repository, repo_details) = given::an_open_repository(&fs);
open_repository
.expect_fetch()
.times(1)
.return_once(|| Ok(()));
given::has_remote_defaults(
&mut open_repository,
HashMap::from([
(Direction::Push, None),
(Direction::Fetch, repo_details.remote_url()),
]),
);
let mut repository_factory = MockRepositoryFactory::new();
expect::open_repository(&mut repository_factory, open_repository);
fs.dir(&repo_details.gitdir).create()?;
//when
let (addr, log) = when::start_actor(repository_factory, repo_details, given::a_forge());
addr.send(CloneRepo::new()).await?;
System::current().stop();
//then
log.require_message_containing("open failed")?;
Ok(())
}
#[test_log::test(actix::test)]
async fn opened_repo_with_no_default_fetch_should_not_proceed() -> TestResult {
//given
let fs = given::a_filesystem();
let (mut open_repository, repo_details) = given::an_open_repository(&fs);
open_repository
.expect_fetch()
.times(1)
.return_once(|| Err(git::fetch::Error::NoFetchRemoteFound));
let mut repository_factory = MockRepositoryFactory::new();
expect::open_repository(&mut repository_factory, open_repository);
fs.dir(&repo_details.gitdir).create()?;
//when
let (addr, log) = when::start_actor(repository_factory, repo_details, given::a_forge());
addr.send(CloneRepo::new()).await?;
System::current().stop();
//then
log.require_message_containing("open failed")?;
Ok(())
}

View file

@ -0,0 +1,82 @@
//
use super::*;
#[actix::test]
async fn when_read_file_ok_should_send_config_loaded() -> TestResult {
//given
let fs = given::a_filesystem();
let (mut open_repository, repo_details) = given::an_open_repository(&fs);
let mut load_config_open_repo = MockOpenRepositoryLike::new();
let branches = given::repo_branches();
let remote_branches = vec![branches.main(), branches.next(), branches.dev()];
load_config_open_repo
.expect_read_file()
.return_once(move |_, _| {
Ok(format!(
r#"
[branches]
main = "{}"
next = "{}"
dev = "{}"
"#,
branches.main(),
branches.next(),
branches.dev()
))
});
load_config_open_repo
.expect_remote_branches()
.return_once(|| Ok(remote_branches));
open_repository
.expect_duplicate()
.return_once(|| Box::new(load_config_open_repo));
//when
let (addr, log) = when::start_actor_with_open_repository(
Box::new(open_repository),
repo_details,
given::a_forge(),
);
addr.send(crate::repo::messages::LoadConfigFromRepo::new())
.await?;
System::current().stop();
//then
tracing::debug!(?log, "");
log.require_message_containing("send: ReceiveRepoConfig")?;
log.no_message_contains("send: NotifyUsers")?;
Ok(())
}
#[actix::test]
async fn when_read_file_err_should_notify_user() -> TestResult {
//given
let fs = given::a_filesystem();
let (mut open_repository, repo_details) = given::an_open_repository(&fs);
let mut load_config_open_repo = MockOpenRepositoryLike::new();
load_config_open_repo
.expect_read_file()
.return_once(move |_, _| Err(git::file::Error::FileNotFound));
open_repository
.expect_duplicate()
.return_once(|| Box::new(load_config_open_repo));
//when
let (addr, log) = when::start_actor_with_open_repository(
Box::new(open_repository),
repo_details,
given::a_forge(),
);
addr.send(crate::repo::messages::LoadConfigFromRepo::new())
.await?;
System::current().stop();
//then
tracing::debug!(?log, "");
log.require_message_containing("send: NotifyUser")?;
log.no_message_contains("send: ReceiveRepoConfig")?;
Ok(())
}

View file

@ -0,0 +1,59 @@
//
use super::*;
#[actix::test]
async fn should_store_repo_config_in_actor() -> TestResult {
//given
let fs = given::a_filesystem();
let (open_repository, repo_details) = given::an_open_repository(&fs);
let new_repo_config = given::a_repo_config();
//when
let (addr, log) = when::start_actor_with_open_repository(
Box::new(open_repository),
repo_details,
given::a_forge(),
);
addr.send(crate::repo::messages::ReceiveRepoConfig::new(
new_repo_config.clone(),
))
.await?;
System::current().stop();
//then
tracing::debug!(?log, "");
let reo_actor_view = addr.send(ExamineActor).await?;
assert_eq!(
reo_actor_view.repo_details.repo_config,
Some(new_repo_config)
);
Ok(())
}
#[test_log::test(actix::test)]
async fn should_register_webhook() -> TestResult {
//given
let fs = given::a_filesystem();
let (open_repository, repo_details) = given::an_open_repository(&fs);
let new_repo_config = given::a_repo_config();
//when
let (addr, log) = when::start_actor_with_open_repository(
Box::new(open_repository),
repo_details,
given::a_forge(),
);
addr.send(crate::repo::messages::ReceiveRepoConfig::new(
new_repo_config.clone(),
))
.await?;
System::current().stop();
//then
tracing::debug!(?log, "");
log.require_message_containing("send: RegisterWebhook")?;
Ok(())
}

View file

@ -0,0 +1,14 @@
//
use super::*;
mod advance_main;
mod advance_next;
mod check_ci_status;
mod clone_repo;
mod load_config_from_repo;
mod loaded_config;
mod receive_ci_status;
mod register_webhook;
mod validate_repo;
mod webhook_notification;
mod webhook_registered;

View file

@ -0,0 +1,112 @@
use std::time::Duration;
//
use super::*;
#[test_log::test(actix::test)]
async fn when_pass_should_advance_main_to_next() -> TestResult {
//given
let fs = given::a_filesystem();
let (open_repository, repo_details) = given::an_open_repository(&fs);
let next_commit = given::a_named_commit("next");
//when
let (addr, log) = when::start_actor_with_open_repository(
Box::new(open_repository),
repo_details,
given::a_forge(),
);
addr.send(crate::repo::messages::ReceiveCIStatus::new((
next_commit.clone(),
git::forge::commit::Status::Pass,
)))
.await?;
System::current().stop();
//then
tracing::debug!(?log, "");
log.read().map_err(|e| e.to_string()).map(|l| {
let expected = format!("send: AdvanceMain({next_commit:?})");
tracing::debug!(%expected,"");
assert!(l.iter().any(|message| message.contains(&expected)));
})?;
Ok(())
}
#[test_log::test(actix::test)]
async fn when_pending_should_recheck_ci_status() -> TestResult {
//given
let fs = given::a_filesystem();
let (open_repository, repo_details) = given::an_open_repository(&fs);
let next_commit = given::a_named_commit("next");
//when
let (addr, log) = when::start_actor_with_open_repository(
Box::new(open_repository),
repo_details,
given::a_forge(),
);
addr.send(crate::repo::messages::ReceiveCIStatus::new((
next_commit.clone(),
git::forge::commit::Status::Pending,
)))
.await?;
actix_rt::time::sleep(Duration::from_millis(9)).await;
System::current().stop();
//then
tracing::debug!(?log, "");
log.require_message_containing("send: ValidateRepo")?;
Ok(())
}
#[test_log::test(actix::test)]
async fn when_fail_should_recheck_after_delay() -> TestResult {
//given
let fs = given::a_filesystem();
let (open_repository, repo_details) = given::an_open_repository(&fs);
let next_commit = given::a_named_commit("next");
//when
let (addr, log) = when::start_actor_with_open_repository(
Box::new(open_repository),
repo_details,
given::a_forge(),
);
addr.send(crate::repo::messages::ReceiveCIStatus::new((
next_commit.clone(),
git::forge::commit::Status::Fail,
)))
.await?;
actix_rt::time::sleep(Duration::from_millis(9)).await;
System::current().stop();
//then
log.require_message_containing("send: ValidateRepo")?;
Ok(())
}
#[test_log::test(actix::test)]
async fn when_fail_should_notify_user() -> TestResult {
//given
let fs = given::a_filesystem();
let (open_repository, repo_details) = given::an_open_repository(&fs);
let next_commit = given::a_named_commit("next");
//when
let (addr, log) = when::start_actor_with_open_repository(
Box::new(open_repository),
repo_details,
given::a_forge(),
);
addr.send(crate::repo::messages::ReceiveCIStatus::new((
next_commit.clone(),
git::forge::commit::Status::Fail,
)))
.await?;
System::current().stop();
//then
log.require_message_containing("send: NotifyUser")?;
Ok(())
}

View file

@ -0,0 +1,71 @@
//
use super::*;
#[actix::test]
async fn when_registered_ok_should_send_webhook_registered() -> TestResult {
//given
let fs = given::a_filesystem();
let (open_repository, repo_details) = given::an_open_repository(&fs);
let registered_webhook = given::a_registered_webhook();
let mut my_forge = git::MockForgeLike::new();
my_forge
.expect_register_webhook()
.return_once(move |_| Ok(registered_webhook));
let mut forge = git::MockForgeLike::new();
forge.expect_duplicate().return_once(|| Box::new(my_forge));
//when
let (addr, log) = when::start_actor_with_open_repository(
Box::new(open_repository),
repo_details,
Box::new(forge),
);
addr.send(crate::repo::messages::RegisterWebhook::new())
.await?;
System::current().stop();
//then
tracing::debug!(?log, "");
log.read().map_err(|e| e.to_string()).map(|l| {
assert!(l
.iter()
.any(|message| message.contains("send: WebhookRegistered")));
})?;
Ok(())
}
#[actix::test]
async fn when_registered_error_should_send_notify_user() -> TestResult {
//given
let fs = given::a_filesystem();
let (open_repository, repo_details) = given::an_open_repository(&fs);
let mut my_forge = git::MockForgeLike::new();
my_forge.expect_register_webhook().return_once(move |_| {
Err(git::forge::webhook::Error::FailedToRegister(
"foo".to_string(),
))
});
let mut forge = git::MockForgeLike::new();
forge.expect_duplicate().return_once(|| Box::new(my_forge));
//when
let (addr, log) = when::start_actor_with_open_repository(
Box::new(open_repository),
repo_details,
Box::new(forge),
);
addr.send(crate::repo::messages::RegisterWebhook::new())
.await?;
System::current().stop();
//then
tracing::debug!(?log, "");
log.read()
.map_err(|e| e.to_string())
.map(|l| assert!(l.iter().any(|message| message.contains("send: NotifyUser"))))?;
Ok(())
}

View file

@ -0,0 +1,560 @@
use crate::repo::messages::{AdvanceNext, AdvanceNextPayload};
//
use super::*;
#[test_log::test(actix::test)]
async fn repo_with_next_not_an_ancestor_of_dev_and_dev_on_main_should_be_reset_to_main(
) -> TestResult {
//given
let fs = given::a_filesystem();
let (mut open_repository, repo_details) = given::an_open_repository(&fs);
#[allow(clippy::unwrap_used)]
let repo_config = repo_details.repo_config.clone().unwrap();
expect::fetch_ok(&mut open_repository);
let branches = repo_config.branches();
// commit_log main
let main_commit = expect::main_commit_log(&mut open_repository, branches.main());
// next - based on main
let next_branch_log = vec![given::a_commit(), main_commit.clone()];
// dev - based on main, but not on next
let dev_branch_log = vec![main_commit.clone()];
// commit_log next - based on main, but not a parent of dev
open_repository
.expect_commit_log()
.times(1)
.with(eq(branches.next()), eq([main_commit.clone()]))
.return_once(move |_, _| Ok(next_branch_log));
// commit_log dev
open_repository
.expect_commit_log()
.times(1)
.with(eq(branches.dev()), eq([main_commit]))
.return_once(|_, _| Ok(dev_branch_log));
// expect to reset the branch
expect::fetch_ok(&mut open_repository);
expect::push_ok(&mut open_repository);
//when
let (addr, log) = when::start_actor_with_open_repository(
Box::new(open_repository),
repo_details,
given::a_forge(),
);
addr.send(crate::repo::messages::ValidateRepo::new(
MessageToken::default(),
))
.await?;
System::current().stop();
//then
log.require_message_containing(format!("Branch {} has been reset", branches.next()))?;
Ok(())
}
#[test_log::test(actix::test)]
async fn repo_with_next_not_an_ancestor_of_dev_and_dev_ahead_of_main_should_be_reset_to_dev(
) -> TestResult {
//given
let fs = given::a_filesystem();
let (mut open_repository, repo_details) = given::an_open_repository(&fs);
#[allow(clippy::unwrap_used)]
let repo_config = repo_details.repo_config.clone().unwrap();
expect::fetch_ok(&mut open_repository);
let branches = repo_config.branches();
// commit_log main
let main_commit = expect::main_commit_log(&mut open_repository, branches.main());
// next - based on main
let next_commit = given::a_commit();
let next_branch_log = vec![next_commit.clone(), main_commit.clone()];
// dev - based on main, but not on next
let dev_branch_log = vec![given::a_commit(), main_commit.clone()];
// commit_log next - based on main, but not a parent of dev
open_repository
.expect_commit_log()
.times(1)
.with(eq(branches.next()), eq([main_commit.clone()]))
.return_once(move |_, _| Ok(next_branch_log));
// commit_log dev
let dev_branch_log_clone = dev_branch_log.clone();
open_repository
.expect_commit_log()
.times(1)
.with(eq(branches.dev()), eq([main_commit.clone()]))
.return_once(|_, _| Ok(dev_branch_log_clone));
// expect to reset the branch
expect::fetch_ok(&mut open_repository);
expect::push_ok(&mut open_repository);
//when
let (addr, log) = when::start_actor_with_open_repository(
Box::new(open_repository),
repo_details,
given::a_forge(),
);
addr.send(crate::repo::messages::ValidateRepo::new(
MessageToken::default(),
))
.await?;
System::current().stop();
//then
let expected = AdvanceNext::new(AdvanceNextPayload {
next: next_commit,
main: main_commit,
dev_commit_history: dev_branch_log,
});
log.require_message_containing(format!("send: {expected:?}",))?;
Ok(())
}
#[test_log::test(actix::test)]
async fn repo_with_next_not_on_or_near_main_should_be_reset() -> TestResult {
//given
let fs = given::a_filesystem();
let (mut open_repository, repo_details) = given::an_open_repository(&fs);
#[allow(clippy::unwrap_used)]
let repo_config = repo_details.repo_config.clone().unwrap();
expect::fetch_ok(&mut open_repository);
let branches = repo_config.branches();
// commit_log main
let main_commit = expect::main_commit_log(&mut open_repository, branches.main());
// next - based on main, but too far in advance
let next_branch_log = vec![given::a_commit(), given::a_commit(), main_commit.clone()];
// dev - based on next
let mut dev_branch_log = vec![given::a_commit()];
dev_branch_log.extend(next_branch_log.clone());
// commit_log next
open_repository
.expect_commit_log()
.times(1)
.with(eq(branches.next()), eq([main_commit.clone()]))
.return_once(move |_, _| Ok(next_branch_log));
// commit_log dev
open_repository
.expect_commit_log()
.times(1)
.with(eq(branches.dev()), eq([main_commit]))
.return_once(|_, _| Ok(dev_branch_log));
expect::fetch_ok(&mut open_repository);
expect::push_ok(&mut open_repository);
//when
let (addr, log) = when::start_actor_with_open_repository(
Box::new(open_repository),
repo_details,
given::a_forge(),
);
addr.send(crate::repo::messages::ValidateRepo::new(
MessageToken::default(),
))
.await?;
System::current().stop();
//then
log.require_message_containing(format!("Branch {} has been reset", branches.next()))?;
Ok(())
}
#[test_log::test(actix::test)]
async fn repo_with_next_not_based_on_main_should_be_reset() -> TestResult {
//given
let fs = given::a_filesystem();
let (mut open_repository, repo_details) = given::an_open_repository(&fs);
#[allow(clippy::unwrap_used)]
let repo_config = repo_details.repo_config.clone().unwrap();
// given::has_all_valid_remote_defaults(&mut open_repository, &repo_details);
expect::fetch_ok(&mut open_repository);
let branches = repo_config.branches();
// commit_log main
let main_commit = expect::main_commit_log(&mut open_repository, branches.main());
// next - not based on main
let next_branch_log = vec![given::a_commit(), given::a_commit(), given::a_commit()];
// dev - based on main
let dev_branch_log = vec![given::a_commit(), main_commit.clone()];
// commit_log next
open_repository
.expect_commit_log()
.times(1)
.with(eq(branches.next()), eq([main_commit.clone()]))
.return_once(move |_, _| Ok(next_branch_log));
// commit_log dev
open_repository
.expect_commit_log()
.times(1)
.with(eq(branches.dev()), eq([main_commit]))
.return_once(|_, _| Ok(dev_branch_log));
expect::fetch_ok(&mut open_repository);
expect::push_ok(&mut open_repository);
//when
let (addr, log) = when::start_actor_with_open_repository(
Box::new(open_repository),
repo_details,
given::a_forge(),
);
addr.send(crate::repo::messages::ValidateRepo::new(
MessageToken::default(),
))
.await?;
System::current().stop();
//then
log.require_message_containing(format!("Branch {} has been reset", branches.next()))?;
Ok(())
}
#[test_log::test(actix::test)]
async fn repo_with_next_ahead_of_main_should_check_ci_status() -> TestResult {
//given
let fs = given::a_filesystem();
let (mut open_repository, repo_details) = given::an_open_repository(&fs);
#[allow(clippy::unwrap_used)]
let repo_config = repo_details.repo_config.clone().unwrap();
// Validate repo branches
expect::fetch_ok(&mut open_repository);
let branches = repo_config.branches();
// commit_log main
let main_commit = expect::main_commit_log(&mut open_repository, branches.main());
// next - ahead of main
let next_commit = given::a_named_commit("next");
let next_branch_log = vec![next_commit.clone(), main_commit.clone()];
// dev - on next
let dev_branch_log = next_branch_log.clone();
// commit_log next
open_repository
.expect_commit_log()
.times(1)
.with(eq(branches.next()), eq([main_commit.clone()]))
.return_once(move |_, _| Ok(next_branch_log));
// commit_log dev
open_repository
.expect_commit_log()
.times(1)
.with(eq(branches.dev()), eq([main_commit]))
.return_once(|_, _| Ok(dev_branch_log));
//when
let (addr, log) = when::start_actor_with_open_repository(
Box::new(open_repository),
repo_details,
given::a_forge(),
);
addr.send(crate::repo::messages::ValidateRepo::new(
MessageToken::default(),
))
.await?;
System::current().stop();
//then
log.require_message_containing(format!("send: CheckCIStatus({next_commit:?})"))?;
Ok(())
}
#[test_log::test(actix::test)]
async fn repo_with_dev_and_next_on_main_should_do_nothing() -> TestResult {
// Do nothing, when the situation changes we will hear about it via a webhook
//given
let fs = given::a_filesystem();
let (mut open_repository, repo_details) = given::an_open_repository(&fs);
#[allow(clippy::unwrap_used)]
let repo_config = repo_details.repo_config.clone().unwrap();
// Validate repo branches
expect::fetch_ok(&mut open_repository);
let branches = repo_config.branches();
// commit_log main
let main_commit = expect::main_commit_log(&mut open_repository, branches.main());
// next - on main
let next_branch_log = vec![main_commit.clone(), given::a_commit()];
// dev - on next
let dev_branch_log = next_branch_log.clone();
// commit_log next
open_repository
.expect_commit_log()
.times(1)
.with(eq(branches.next()), eq([main_commit.clone()]))
.return_once(move |_, _| Ok(next_branch_log));
// commit_log dev
let dev_commit_log = dev_branch_log.clone();
open_repository
.expect_commit_log()
.times(1)
.with(eq(branches.dev()), eq([main_commit]))
.return_once(move |_, _| Ok(dev_commit_log));
//when
let (addr, log) = when::start_actor_with_open_repository(
Box::new(open_repository),
repo_details,
given::a_forge(),
);
addr.send(crate::repo::messages::ValidateRepo::new(
MessageToken::default(),
))
.await?;
System::current().stop();
//then
log.no_message_contains("send:")?;
Ok(())
}
#[test_log::test(actix::test)]
async fn repo_with_dev_ahead_of_next_should_advance_next() -> TestResult {
//given
let fs = given::a_filesystem();
let (mut open_repository, repo_details) = given::an_open_repository(&fs);
#[allow(clippy::unwrap_used)]
let repo_config = repo_details.repo_config.clone().unwrap();
// Validate repo branches
expect::fetch_ok(&mut open_repository);
let branches = repo_config.branches();
// commit_log main
let main_commit = expect::main_commit_log(&mut open_repository, branches.main());
// next - on main
let next_commit = main_commit.clone();
let next_branch_log = vec![main_commit.clone(), given::a_commit()];
// dev - ahead of next
let dev_commit = given::a_named_commit("dev");
let dev_branch_log = vec![dev_commit, main_commit.clone()];
// commit_log next
open_repository
.expect_commit_log()
.times(1)
.with(eq(branches.next()), eq([main_commit.clone()]))
.return_once(move |_, _| Ok(next_branch_log));
// commit_log dev
let dev_commit_log = dev_branch_log.clone();
open_repository
.expect_commit_log()
.times(1)
.with(eq(branches.dev()), eq([main_commit.clone()]))
.return_once(move |_, _| Ok(dev_commit_log));
//when
let (addr, log) = when::start_actor_with_open_repository(
Box::new(open_repository),
repo_details,
given::a_forge(),
);
addr.send(crate::repo::messages::ValidateRepo::new(
MessageToken::default(),
))
.await?;
System::current().stop();
//then
let expected = AdvanceNext::new(AdvanceNextPayload {
next: next_commit,
main: main_commit,
dev_commit_history: dev_branch_log,
});
log.require_message_containing(format!("send: {expected:?}"))?;
Ok(())
}
#[test_log::test(actix::test)]
async fn repo_with_dev_not_ahead_of_main_should_notify_user() -> TestResult {
//given
let fs = given::a_filesystem();
let (mut open_repository, repo_details) = given::an_open_repository(&fs);
#[allow(clippy::unwrap_used)]
let repo_config = repo_details.repo_config.clone().unwrap();
// Validate repo branches
expect::fetch_ok(&mut open_repository);
let branches = repo_config.branches();
// commit_log main
let main_commit = expect::main_commit_log(&mut open_repository, branches.main());
// next - on main
let next_commit = main_commit.clone();
let next_branch_log = vec![next_commit.clone(), given::a_commit()];
// dev - not ahead of next
let dev_commit = given::a_named_commit("dev");
let dev_branch_log = vec![dev_commit];
// commit_log next
open_repository
.expect_commit_log()
.times(1)
.with(eq(branches.next()), eq([main_commit.clone()]))
.return_once(move |_, _| Ok(next_branch_log));
// commit_log dev
let dev_commit_log = dev_branch_log.clone();
open_repository
.expect_commit_log()
.times(1)
.with(eq(branches.dev()), eq([main_commit]))
.return_once(move |_, _| Ok(dev_commit_log));
//when
let (addr, log) = when::start_actor_with_open_repository(
Box::new(open_repository),
repo_details,
given::a_forge(),
);
addr.send(crate::repo::messages::ValidateRepo::new(
MessageToken::default(),
))
.await?;
System::current().stop();
//then
log.require_message_containing("send: NotifyUser")?;
Ok(())
}
#[test_log::test(actix::test)]
async fn should_accept_message_with_current_token() -> TestResult {
//given
let fs = given::a_filesystem();
let repo_details = given::repo_details(&fs);
//when
let (actor, log) = given::a_repo_actor(
repo_details,
git::repository::factory::mock(),
given::a_forge(),
given::a_network().into(),
);
let actor = actor.with_message_token(MessageToken::new(2_u32));
let addr = actor.start();
addr.send(crate::repo::messages::ValidateRepo::new(MessageToken::new(
2_u32,
)))
.await?;
System::current().stop();
//then
log.require_message_containing("accepted token: 2")?;
Ok(())
}
#[test_log::test(actix::test)]
async fn should_accept_message_with_new_token() -> TestResult {
//given
let fs = given::a_filesystem();
let repo_details = given::repo_details(&fs);
//when
let (actor, log) = given::a_repo_actor(
repo_details,
git::repository::factory::mock(),
given::a_forge(),
given::a_network().into(),
);
let actor = actor.with_message_token(MessageToken::new(2_u32));
let addr = actor.start();
addr.send(crate::repo::messages::ValidateRepo::new(MessageToken::new(
3_u32,
)))
.await?;
System::current().stop();
//then
log.require_message_containing("accepted token: 3")?;
Ok(())
}
#[test_log::test(actix::test)]
async fn should_reject_message_with_expired_token() -> TestResult {
//given
let fs = given::a_filesystem();
let repo_details = given::repo_details(&fs);
//when
let (actor, log) = given::a_repo_actor(
repo_details,
git::repository::factory::mock(),
given::a_forge(),
given::a_network().into(),
);
let actor = actor.with_message_token(MessageToken::new(4_u32));
let addr = actor.start();
addr.send(crate::repo::messages::ValidateRepo::new(MessageToken::new(
3_u32,
)))
.await?;
System::current().stop();
//then
log.no_message_contains("accepted token")?;
Ok(())
}
#[test_log::test(actix::test)]
// NOTE: failed then passed on retry: count = 6
async fn should_send_validate_repo_when_retryable_error() -> TestResult {
//given
let fs = given::a_filesystem();
let (mut open_repository, repo_details) = given::an_open_repository(&fs);
open_repository.expect_fetch().return_once(|| Ok(()));
open_repository
.expect_commit_log()
.return_once(|_, _| Err(git::commit::log::Error::Lock));
//when
let (addr, log) = when::start_actor_with_open_repository(
Box::new(open_repository),
repo_details,
given::a_forge(),
);
addr.send(crate::repo::messages::ValidateRepo::new(
MessageToken::default(),
))
.await?;
actix_rt::time::sleep(std::time::Duration::from_millis(2)).await;
System::current().stop();
//then
log.require_message_containing("accepted token: 0")?;
log.require_message_containing("send: ValidateRepo")?;
Ok(())
}
#[test_log::test(actix::test)]
async fn should_send_notify_user_when_non_retryable_error() -> TestResult {
//given
let fs = given::a_filesystem();
let (mut open_repository, repo_details) = given::an_open_repository(&fs);
#[allow(clippy::unwrap_used)]
let repo_config = repo_details.repo_config.clone().unwrap();
open_repository.expect_fetch().return_once(|| Ok(()));
// branches are all unrelated - non-retryable until each branch is updated
let branches = repo_config.branches();
// commit_log main
let main_commit = expect::main_commit_log(&mut open_repository, branches.main());
// next
let next_branch_log = vec![given::a_commit()];
// dev
let dev_branch_log = vec![given::a_commit()];
// commit_log next
open_repository
.expect_commit_log()
.times(1)
.with(eq(branches.next()), eq([main_commit.clone()]))
.return_once(move |_, _| Ok(next_branch_log));
// commit_log dev
open_repository
.expect_commit_log()
.times(1)
.with(eq(branches.dev()), eq([main_commit]))
.return_once(|_, _| Ok(dev_branch_log));
//when
let (addr, log) = when::start_actor_with_open_repository(
Box::new(open_repository),
repo_details,
given::a_forge(),
);
addr.send(crate::repo::messages::ValidateRepo::new(
MessageToken::default(),
))
.await?;
actix_rt::time::sleep(std::time::Duration::from_millis(1)).await;
System::current().stop();
//then
log.require_message_containing("accepted token")?;
log.require_message_containing("send: NotifyUser")?;
Ok(())
}

View file

@ -0,0 +1,531 @@
//
use super::*;
#[actix::test]
async fn when_no_expected_auth_token_drop_notification() -> TestResult {
//given
let fs = given::a_filesystem();
let repo_details = given::repo_details(&fs);
let forge_alias = given::a_forge_alias();
let repo_alias = given::a_repo_alias();
let headers = BTreeMap::new();
let body = Body::new(String::new());
let forge_notification = ForgeNotification::new(forge_alias, repo_alias, headers, body);
let repository_factory = MockRepositoryFactory::new();
let (actor, log) = given::a_repo_actor(
repo_details,
Box::new(repository_factory),
given::a_forge(),
given::a_network().into(),
);
let actor = actor.with_webhook_auth(None);
//when
actor
.start()
.send(crate::repo::messages::WebhookNotification::new(
forge_notification,
))
.await?;
System::current().stop();
//then
log.no_message_contains("send")?;
log.require_message_containing("server has no auth token")?;
Ok(())
}
#[actix::test]
async fn when_no_repo_config_drop_notification() -> TestResult {
//given
let fs = given::a_filesystem();
let repo_details = given::repo_details(&fs).with_repo_config(None);
let forge_alias = given::a_forge_alias();
let repo_alias = given::a_repo_alias();
let headers = BTreeMap::new();
let body = Body::new(String::new());
let forge_notification = ForgeNotification::new(forge_alias, repo_alias, headers, body);
let repository_factory = MockRepositoryFactory::new();
let (actor, log) = given::a_repo_actor(
repo_details,
Box::new(repository_factory),
given::a_forge(),
given::a_network().into(),
);
let actor = actor.with_webhook_auth(Some(given::a_webhook_auth()));
//when
actor
.start()
.send(crate::repo::messages::WebhookNotification::new(
forge_notification,
))
.await?;
System::current().stop();
//then
log.no_message_contains("send")?;
log.require_message_containing("server has no repo config")?;
Ok(())
}
#[actix::test]
async fn when_message_auth_is_invalid_drop_notification() -> TestResult {
//given
let fs = given::a_filesystem();
let repo_details = given::repo_details(&fs);
let forge_alias = given::a_forge_alias();
let repo_alias = given::a_repo_alias();
let headers = BTreeMap::new();
let body = Body::new(String::new());
let forge_notification = ForgeNotification::new(forge_alias, repo_alias, headers, body);
let repository_factory = MockRepositoryFactory::new();
let mut forge = given::a_forge();
forge
.expect_is_message_authorised()
.return_once(|_, _| false); // is not valid
let (actor, log) = given::a_repo_actor(
repo_details,
Box::new(repository_factory),
forge,
given::a_network().into(),
);
let actor = actor.with_webhook_auth(Some(given::a_webhook_auth()));
//when
actor
.start()
.send(crate::repo::messages::WebhookNotification::new(
forge_notification,
))
.await?;
System::current().stop();
//then
log.no_message_contains("send")?;
log.require_message_containing("message authorisation is invalid")?;
Ok(())
}
#[actix::test]
async fn when_message_is_ignorable_drop_notification() -> TestResult {
//given
let fs = given::a_filesystem();
let repo_details = given::repo_details(&fs);
let forge_alias = given::a_forge_alias();
let repo_alias = given::a_repo_alias();
let headers = BTreeMap::new();
let body = Body::new(String::new());
let forge_notification = ForgeNotification::new(forge_alias, repo_alias, headers, body);
let repository_factory = MockRepositoryFactory::new();
let mut forge = given::a_forge();
forge
.expect_is_message_authorised()
.return_once(|_, _| true); // is valid
forge.expect_should_ignore_message().returning(|_| true);
forge
.expect_parse_webhook_body()
.return_once(|_| Err(git::forge::webhook::Error::NetworkResponseEmpty));
let (actor, log) = given::a_repo_actor(
repo_details,
Box::new(repository_factory),
forge,
given::a_network().into(),
);
let actor = actor.with_webhook_auth(Some(given::a_webhook_auth()));
//when
actor
.start()
.send(crate::repo::messages::WebhookNotification::new(
forge_notification,
))
.await?;
System::current().stop();
//then
log.no_message_contains("send")?;
log.require_message_containing("forge sent ignorable message")?;
Ok(())
}
#[actix::test]
async fn when_message_is_not_a_push_drop_notification() -> TestResult {
//given
let fs = given::a_filesystem();
let repo_details = given::repo_details(&fs);
let forge_alias = given::a_forge_alias();
let repo_alias = given::a_repo_alias();
let headers = BTreeMap::new();
let body = Body::new(String::new());
let forge_notification = ForgeNotification::new(forge_alias, repo_alias, headers, body);
let repository_factory = MockRepositoryFactory::new();
let mut forge = given::a_forge();
forge
.expect_is_message_authorised()
.return_once(|_, _| true); // is valid
forge.expect_should_ignore_message().returning(|_| false);
forge
.expect_parse_webhook_body()
.return_once(|_| Err(git::forge::webhook::Error::NetworkResponseEmpty));
let (actor, log) = given::a_repo_actor(
repo_details,
Box::new(repository_factory),
forge,
given::a_network().into(),
);
let actor = actor.with_webhook_auth(Some(given::a_webhook_auth()));
//when
actor
.start()
.send(crate::repo::messages::WebhookNotification::new(
forge_notification,
))
.await?;
System::current().stop();
//then
log.no_message_contains("send")?;
log.require_message_containing("message parse error - not a push")?;
Ok(())
}
#[actix::test]
async fn when_message_is_push_on_unknown_branch_drop_notification() -> TestResult {
//given
let fs = given::a_filesystem();
let repo_config = given::a_repo_config();
let repo_details = given::repo_details(&fs).with_repo_config(Some(repo_config.clone()));
let forge_alias = given::a_forge_alias();
let repo_alias = given::a_repo_alias();
let headers = BTreeMap::new();
let body = Body::new(String::new());
let forge_notification = ForgeNotification::new(forge_alias, repo_alias, headers, body);
let repository_factory = MockRepositoryFactory::new();
let commit = given::a_commit();
let push = given::a_push()
.with_branch(given::a_branch_name("unknown"))
.with_sha(commit.sha().to_string())
.with_message(commit.message().to_string());
let mut forge = given::a_forge();
forge
.expect_is_message_authorised()
.return_once(|_, _| true); // is valid
forge.expect_should_ignore_message().returning(|_| false);
forge.expect_parse_webhook_body().return_once(|_| Ok(push));
let (actor, log) = given::a_repo_actor(
repo_details,
Box::new(repository_factory),
forge,
given::a_network().into(),
);
let actor = actor
.with_webhook_auth(Some(given::a_webhook_auth()))
.with_last_main_commit(commit);
//when
actor
.start()
.send(crate::repo::messages::WebhookNotification::new(
forge_notification,
))
.await?;
System::current().stop();
//then
log.no_message_contains("send")?;
log.require_message_containing("unknown branch")?;
Ok(())
}
#[actix::test]
async fn when_message_is_push_already_seen_commit_to_main() -> TestResult {
//given
let fs = given::a_filesystem();
let repo_config = given::a_repo_config();
let repo_details = given::repo_details(&fs).with_repo_config(Some(repo_config.clone()));
let forge_alias = given::a_forge_alias();
let repo_alias = given::a_repo_alias();
let headers = BTreeMap::new();
let body = Body::new(String::new());
let forge_notification = ForgeNotification::new(forge_alias, repo_alias, headers, body);
let repository_factory = MockRepositoryFactory::new();
let commit = given::a_commit();
let main = repo_config.branches().main();
let push = given::a_push()
.with_branch(main.clone())
.with_sha(commit.sha().to_string())
.with_message(commit.message().to_string());
let mut forge = given::a_forge();
forge
.expect_is_message_authorised()
.return_once(|_, _| true); // is valid
forge.expect_should_ignore_message().returning(|_| false);
forge.expect_parse_webhook_body().return_once(|_| Ok(push));
let (actor, log) = given::a_repo_actor(
repo_details,
Box::new(repository_factory),
forge,
given::a_network().into(),
);
let actor = actor
.with_webhook_auth(Some(given::a_webhook_auth()))
.with_last_main_commit(commit);
//when
actor
.start()
.send(crate::repo::messages::WebhookNotification::new(
forge_notification,
))
.await?;
System::current().stop();
//then
log.no_message_contains("send")?;
log.require_message_containing(format!("not a new commit on {main}"))?;
Ok(())
}
#[actix::test]
async fn when_message_is_push_already_seen_commit_to_next() -> TestResult {
//given
let fs = given::a_filesystem();
let repo_config = given::a_repo_config();
let repo_details = given::repo_details(&fs).with_repo_config(Some(repo_config.clone()));
let forge_alias = given::a_forge_alias();
let repo_alias = given::a_repo_alias();
let headers = BTreeMap::new();
let body = Body::new(String::new());
let forge_notification = ForgeNotification::new(forge_alias, repo_alias, headers, body);
let repository_factory = MockRepositoryFactory::new();
let commit = given::a_commit();
let next = repo_config.branches().next();
let push = given::a_push()
.with_branch(next.clone())
.with_sha(commit.sha().to_string())
.with_message(commit.message().to_string());
let mut forge = given::a_forge();
forge
.expect_is_message_authorised()
.return_once(|_, _| true); // is valid
forge.expect_should_ignore_message().returning(|_| false);
forge.expect_parse_webhook_body().return_once(|_| Ok(push));
let (actor, log) = given::a_repo_actor(
repo_details,
Box::new(repository_factory),
forge,
given::a_network().into(),
);
let actor = actor
.with_webhook_auth(Some(given::a_webhook_auth()))
.with_last_next_commit(commit);
//when
actor
.start()
.send(crate::repo::messages::WebhookNotification::new(
forge_notification,
))
.await?;
System::current().stop();
//then
log.no_message_contains("send")?;
log.require_message_containing(format!("not a new commit on {next}"))?;
Ok(())
}
#[actix::test]
async fn when_message_is_push_already_seen_commit_to_dev() -> TestResult {
//given
let fs = given::a_filesystem();
let repo_config = given::a_repo_config();
let repo_details = given::repo_details(&fs).with_repo_config(Some(repo_config.clone()));
let forge_alias = given::a_forge_alias();
let repo_alias = given::a_repo_alias();
let headers = BTreeMap::new();
let body = Body::new(String::new());
let forge_notification = ForgeNotification::new(forge_alias, repo_alias, headers, body);
let repository_factory = MockRepositoryFactory::new();
let commit = given::a_commit();
let dev = repo_config.branches().dev();
let push = given::a_push()
.with_branch(dev.clone())
.with_sha(commit.sha().to_string())
.with_message(commit.message().to_string());
let mut forge = given::a_forge();
forge
.expect_is_message_authorised()
.return_once(|_, _| true); // is valid
forge.expect_should_ignore_message().returning(|_| false);
forge.expect_parse_webhook_body().return_once(|_| Ok(push));
let (actor, log) = given::a_repo_actor(
repo_details,
Box::new(repository_factory),
forge,
given::a_network().into(),
);
let actor = actor
.with_webhook_auth(Some(given::a_webhook_auth()))
.with_last_dev_commit(commit);
//when
actor
.start()
.send(crate::repo::messages::WebhookNotification::new(
forge_notification,
))
.await?;
System::current().stop();
//then
log.no_message_contains("send")?;
log.require_message_containing(format!("not a new commit on {dev}"))?;
Ok(())
}
#[actix::test]
async fn when_message_is_push_new_commit_to_main_should_stash_and_validate_repo() -> TestResult {
//given
let fs = given::a_filesystem();
let repo_config = given::a_repo_config();
let repo_details = given::repo_details(&fs).with_repo_config(Some(repo_config.clone()));
let forge_alias = given::a_forge_alias();
let repo_alias = given::a_repo_alias();
let headers = BTreeMap::new();
let body = Body::new(String::new());
let forge_notification = ForgeNotification::new(forge_alias, repo_alias, headers, body);
let repository_factory = MockRepositoryFactory::new();
let push_commit = given::a_commit();
let push = given::a_push()
.with_branch(repo_config.branches().main())
.with_sha(push_commit.sha().to_string())
.with_message(push_commit.message().to_string());
let mut forge = given::a_forge();
forge
.expect_is_message_authorised()
.return_once(|_, _| true); // is valid
forge.expect_should_ignore_message().returning(|_| false);
forge.expect_parse_webhook_body().return_once(|_| Ok(push));
let (actor, log) = given::a_repo_actor(
repo_details,
Box::new(repository_factory),
forge,
given::a_network().into(),
);
let actor = actor
.with_webhook_auth(Some(given::a_webhook_auth()))
.with_last_main_commit(given::a_commit());
//when
let addr = actor.start();
addr.send(crate::repo::messages::WebhookNotification::new(
forge_notification,
))
.await?;
System::current().stop();
//then
let view = addr.send(ExamineActor).await?;
assert_eq!(view.last_main_commit, Some(push_commit));
log.require_message_containing("send: ValidateRepo")?;
Ok(())
}
#[actix::test]
async fn when_message_is_push_new_commit_to_next_should_stash_and_validate_repo() -> TestResult {
//given
let fs = given::a_filesystem();
let repo_config = given::a_repo_config();
let repo_details = given::repo_details(&fs).with_repo_config(Some(repo_config.clone()));
let forge_alias = given::a_forge_alias();
let repo_alias = given::a_repo_alias();
let headers = BTreeMap::new();
let body = Body::new(String::new());
let forge_notification = ForgeNotification::new(forge_alias, repo_alias, headers, body);
let repository_factory = MockRepositoryFactory::new();
let push_commit = given::a_commit();
let push = given::a_push()
.with_branch(repo_config.branches().next())
.with_sha(push_commit.sha().to_string())
.with_message(push_commit.message().to_string());
let mut forge = given::a_forge();
forge
.expect_is_message_authorised()
.return_once(|_, _| true); // is valid
forge.expect_should_ignore_message().returning(|_| false);
forge.expect_parse_webhook_body().return_once(|_| Ok(push));
let (actor, log) = given::a_repo_actor(
repo_details,
Box::new(repository_factory),
forge,
given::a_network().into(),
);
let actor = actor
.with_webhook_auth(Some(given::a_webhook_auth()))
.with_last_next_commit(given::a_commit());
//when
let addr = actor.start();
addr.send(crate::repo::messages::WebhookNotification::new(
forge_notification,
))
.await?;
System::current().stop();
//then
let view = addr.send(ExamineActor).await?;
assert_eq!(view.last_next_commit, Some(push_commit));
log.require_message_containing("send: ValidateRepo")?;
Ok(())
}
#[actix::test]
async fn when_message_is_push_new_commit_to_dev_should_stash_and_validate_repo() -> TestResult {
//given
let fs = given::a_filesystem();
let repo_config = given::a_repo_config();
let repo_details = given::repo_details(&fs).with_repo_config(Some(repo_config.clone()));
let forge_alias = given::a_forge_alias();
let repo_alias = given::a_repo_alias();
let headers = BTreeMap::new();
let body = Body::new(String::new());
let forge_notification = ForgeNotification::new(forge_alias, repo_alias, headers, body);
let repository_factory = MockRepositoryFactory::new();
let push_commit = given::a_commit();
let push = given::a_push()
.with_branch(repo_config.branches().dev())
.with_sha(push_commit.sha().to_string())
.with_message(push_commit.message().to_string());
let mut forge = given::a_forge();
forge
.expect_is_message_authorised()
.return_once(|_, _| true); // is valid
forge.expect_should_ignore_message().returning(|_| false);
forge.expect_parse_webhook_body().return_once(|_| Ok(push));
let (actor, log) = given::a_repo_actor(
repo_details,
Box::new(repository_factory),
forge,
given::a_network().into(),
);
let actor = actor
.with_webhook_auth(Some(given::a_webhook_auth()))
.with_last_dev_commit(given::a_commit());
//when
let addr = actor.start();
addr.send(crate::repo::messages::WebhookNotification::new(
forge_notification,
))
.await?;
System::current().stop();
//then
let view = addr.send(ExamineActor).await?;
assert_eq!(view.last_dev_commit, Some(push_commit));
log.require_message_containing("send: ValidateRepo")?;
Ok(())
}

View file

@ -0,0 +1,56 @@
//
use super::*;
#[actix::test]
async fn should_store_webhook_details() -> TestResult {
//given
let fs = given::a_filesystem();
let (open_repository, repo_details) = given::an_open_repository(&fs);
let webhook_id = given::a_webhook_id();
let webhook_auth = given::a_webhook_auth();
//when
let (addr, _log) = when::start_actor_with_open_repository(
Box::new(open_repository),
repo_details,
given::a_forge(),
);
addr.send(crate::repo::messages::WebhookRegistered::new((
webhook_id.clone(),
webhook_auth.clone(),
)))
.await?;
System::current().stop();
//then
let view = addr.send(ExamineActor).await?;
assert_eq!(view.webhook_id, Some(webhook_id));
assert_eq!(view.webhook_auth, Some(webhook_auth));
Ok(())
}
#[actix::test]
async fn should_send_validate_repo_message() -> TestResult {
//given
let fs = given::a_filesystem();
let (open_repository, repo_details) = given::an_open_repository(&fs);
let webhook_id = given::a_webhook_id();
let webhook_auth = given::a_webhook_auth();
//when
let (addr, log) = when::start_actor_with_open_repository(
Box::new(open_repository),
repo_details,
given::a_forge(),
);
addr.send(crate::repo::messages::WebhookRegistered::new((
webhook_id.clone(),
webhook_auth.clone(),
)))
.await?;
System::current().stop();
//then
log.require_message_containing("send: ValidateRepo")?;
Ok(())
}

View file

@ -0,0 +1,169 @@
//
use super::*;
use crate::git::file;
use crate::repo::load;
#[actix::test]
async fn when_file_not_found_should_error() -> TestResult {
//given
let fs = given::a_filesystem();
let (mut open_repository, repo_details) = given::an_open_repository(&fs);
open_repository
.expect_read_file()
.returning(|_, _| Err(file::Error::FileNotFound));
//when
let_assert!(Err(err) = load::config_from_repository(repo_details, &open_repository).await);
//then
debug!("Got: {err:?}");
assert!(matches!(
err,
load::Error::File(crate::git::file::Error::FileNotFound)
));
Ok(())
}
#[actix::test]
async fn when_file_format_invalid_should_error() -> TestResult {
//given
let fs = given::a_filesystem();
let (mut open_repository, repo_details) = given::an_open_repository(&fs);
let contents = given::a_name(); // not a valid file content
open_repository
.expect_read_file()
.return_once(move |_, _| Ok(contents));
//when
let_assert!(Err(err) = load::config_from_repository(repo_details, &open_repository).await);
//then
debug!("Got: {err:?}");
assert!(matches!(err, load::Error::Toml(_)));
Ok(())
}
#[actix::test]
async fn when_main_branch_is_missing_should_error() -> TestResult {
//given
let fs = given::a_filesystem();
let (mut open_repository, repo_details) = given::an_open_repository(&fs);
let branches = given::repo_branches();
let main = branches.main();
let next = branches.next();
let dev = branches.dev();
let contents = format!(
r#"
[branches]
main = "{main}"
next = "{next}"
dev = "{dev}"
"#
);
open_repository
.expect_read_file()
.return_once(|_, _| Ok(contents));
let branches = vec![next, dev];
open_repository
.expect_remote_branches()
.return_once(move || Ok(branches));
//when
let_assert!(Err(err) = load::config_from_repository(repo_details, &open_repository).await);
//then
debug!("Got: {err:?}");
assert!(matches!(err, load::Error::BranchNotFound(branch) if branch == main));
Ok(())
}
#[actix::test]
async fn when_next_branch_is_missing_should_error() -> TestResult {
//given
let fs = given::a_filesystem();
let (mut open_repository, repo_details) = given::an_open_repository(&fs);
let branches = given::repo_branches();
let main = branches.main();
let next = branches.next();
let dev = branches.dev();
let contents = format!(
r#"
[branches]
main = "{main}"
next = "{next}"
dev = "{dev}"
"#
);
open_repository
.expect_read_file()
.return_once(|_, _| Ok(contents));
let branches = vec![main, dev];
open_repository
.expect_remote_branches()
.return_once(move || Ok(branches));
//when
let_assert!(Err(err) = load::config_from_repository(repo_details, &open_repository).await);
//then
debug!("Got: {err:?}");
assert!(matches!(err, load::Error::BranchNotFound(branch) if branch == next));
Ok(())
}
#[actix::test]
async fn when_dev_branch_is_missing_should_error() -> TestResult {
//given
let fs = given::a_filesystem();
let (mut open_repository, repo_details) = given::an_open_repository(&fs);
let branches = given::repo_branches();
let main = branches.main();
let next = branches.next();
let dev = branches.dev();
let contents = format!(
r#"
[branches]
main = "{main}"
next = "{next}"
dev = "{dev}"
"#
);
open_repository
.expect_read_file()
.return_once(move |_, _| Ok(contents));
let branches = vec![main, next];
open_repository
.expect_remote_branches()
.return_once(move || Ok(branches));
//when
let_assert!(Err(err) = load::config_from_repository(repo_details, &open_repository).await);
//then
debug!("Got: {err:?}");
assert!(matches!(err, load::Error::BranchNotFound(branch) if branch == dev));
Ok(())
}
#[actix::test]
async fn when_valid_file_should_return_repo_config() -> TestResult {
//given
let fs = given::a_filesystem();
let (mut open_repository, repo_details) = given::an_open_repository(&fs);
let repo_config = given::a_repo_config();
let branches = repo_config.branches();
let main = branches.main();
let next = branches.next();
let dev = branches.dev();
let contents = format!(
r#"
[branches]
main = "{main}"
next = "{next}"
dev = "{dev}"
"#
);
open_repository
.expect_read_file()
.return_once(move |_, _| Ok(contents));
let branches = vec![main, next, dev];
open_repository
.expect_remote_branches()
.return_once(move || Ok(branches));
//when
let_assert!(Ok(result) = load::config_from_repository(repo_details, &open_repository).await);
//then
debug!("Got: {result:?}");
assert_eq!(result, repo_config);
Ok(())
}

View file

@ -0,0 +1,110 @@
//
use actix::prelude::*;
use crate::{
git,
repo::{
messages::{CloneRepo, MessageToken},
ActorLog, RepoActor,
},
};
use git_next_core::{
git::{
commit::Sha,
forge::commit::Status,
repository::{
factory::{mock, MockRepositoryFactory, RepositoryFactory},
open::{MockOpenRepositoryLike, OpenRepositoryLike},
Direction,
},
Commit, ForgeLike, Generation, MockForgeLike, RepoDetails,
},
message,
webhook::{forge_notification::Body, Push},
BranchName, ForgeAlias, ForgeConfig, ForgeNotification, ForgeType, GitDir, Hostname,
RegisteredWebhook, RemoteUrl, RepoAlias, RepoBranches, RepoConfig, RepoConfigSource,
ServerRepoConfig, StoragePathType, WebhookAuth, WebhookId,
};
use assert2::let_assert;
use mockall::predicate::eq;
use tracing::{debug, error};
use std::{
collections::{BTreeMap, HashMap},
sync::{Arc, RwLock},
};
type TestResult = Result<(), Box<dyn std::error::Error>>;
mod branch;
mod expect;
pub mod given;
mod handlers;
mod load;
mod when;
impl ActorLog {
pub fn no_message_contains(&self, needle: impl AsRef<str> + std::fmt::Display) -> TestResult {
if self.find_in_messages(needle.as_ref())? {
error!(?self, "");
panic!("found unexpected message: {needle}");
}
Ok(())
}
pub fn require_message_containing(
&self,
needle: impl AsRef<str> + std::fmt::Display,
) -> TestResult {
if !self.find_in_messages(needle.as_ref())? {
error!(?self, "");
panic!("expected message not found: {needle}");
}
Ok(())
}
fn find_in_messages(
&self,
needle: impl AsRef<str>,
) -> Result<bool, Box<dyn std::error::Error>> {
let found = self
.read()
.map_err(|e| e.to_string())?
.iter()
.any(|message| message.contains(needle.as_ref()));
Ok(found)
}
}
message!(ExamineActor => RepoActorView, "Request a view of the current state of the [RepoActor].");
impl Handler<ExamineActor> for RepoActor {
type Result = RepoActorView;
fn handle(&mut self, _msg: ExamineActor, _ctx: &mut Self::Context) -> Self::Result {
let repo_actor: &Self = self;
Self::Result::from(repo_actor)
}
}
#[derive(Debug, MessageResponse)]
pub struct RepoActorView {
pub repo_details: RepoDetails,
pub webhook_id: Option<WebhookId>, // INFO: if [None] then no webhook is configured
pub webhook_auth: Option<WebhookAuth>, // INFO: if [None] then no webhook is configured
pub last_main_commit: Option<Commit>,
pub last_next_commit: Option<Commit>,
pub last_dev_commit: Option<Commit>,
}
impl From<&RepoActor> for RepoActorView {
fn from(repo_actor: &RepoActor) -> Self {
Self {
repo_details: repo_actor.repo_details.clone(),
webhook_id: repo_actor.webhook_id.clone(),
webhook_auth: repo_actor.webhook_auth.clone(),
last_main_commit: repo_actor.last_main_commit.clone(),
last_next_commit: repo_actor.last_next_commit.clone(),
last_dev_commit: repo_actor.last_dev_commit.clone(),
}
}
}

View file

@ -0,0 +1,37 @@
//
use super::*;
pub fn start_actor(
repository_factory: MockRepositoryFactory,
repo_details: RepoDetails,
forge: Box<dyn ForgeLike>,
) -> (actix::Addr<RepoActor>, ActorLog) {
let (actor, log) = given::a_repo_actor(
repo_details,
Box::new(repository_factory),
forge,
given::a_network().into(),
);
(actor.start(), log)
}
pub fn start_actor_with_open_repository(
open_repository: Box<dyn OpenRepositoryLike>,
repo_details: RepoDetails,
forge: Box<dyn ForgeLike>,
) -> (actix::Addr<RepoActor>, ActorLog) {
let (actor, log) = given::a_repo_actor(repo_details, mock(), forge, given::a_network().into());
let actor = actor.with_open_repository(Some(open_repository));
(actor.start(), log)
}
pub fn commit_status(forge: &mut MockForgeLike, commit: Commit, status: Status) {
let mut commit_status_forge = MockForgeLike::new();
commit_status_forge
.expect_commit_status()
.with(mockall::predicate::eq(commit))
.return_once(|_| Ok(status));
forge
.expect_duplicate()
.return_once(move || Box::new(commit_status_forge));
}

View file

@ -0,0 +1,17 @@
```mermaid
stateDiagram-v2
SERVER --> FileUpdated :on start
FILE_WATCHER_ACTOR --> FileUpdated : WatchFile
FileUpdated --> ReceiveServerConfig
ReceiveServerConfig --> ReceiveValidServerConfig
ReceiveValidServerConfig --> WEBHOOK_ACTOR:ShutdownWebhook
ReceiveValidServerConfig --> REPO_ACTOR:START
ReceiveValidServerConfig --> REPO_ACTOR:CloneRepo
ReceiveValidServerConfig --> WEBHOOK_ROUTER:START
ReceiveValidServerConfig --> WEBHOOK_ROUTER:AddWebhookRecipient
ReceiveValidServerConfig --> WEBHOOK_ACTOR:START
```

View file

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

View file

@ -0,0 +1,7 @@
mod file_updated;
mod receive_app_config;
mod receive_valid_app_config;
mod server_update;
mod shutdown;
mod shutdown_trigger;
mod subscribe_updates;

View file

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

View file

@ -0,0 +1,97 @@
//
use actix::prelude::*;
use git_next_core::{ForgeAlias, RepoAlias};
use tracing::info;
use crate::{
alerts::messages::UpdateShout,
repo::{messages::CloneRepo, RepoActor},
server::actor::{
messages::{ReceiveValidAppConfig, ServerUpdate, ValidAppConfig},
ServerActor,
},
webhook::{
messages::ShutdownWebhook,
router::{AddWebhookRecipient, WebhookRouterActor},
WebhookActor,
},
};
impl Handler<ReceiveValidAppConfig> for ServerActor {
type Result = ();
fn handle(&mut self, msg: ReceiveValidAppConfig, ctx: &mut Self::Context) -> Self::Result {
let ValidAppConfig {
app_config,
socket_address,
storage: server_storage,
} = msg.peel();
// shutdown any existing webhook actor
if let Some(webhook_actor_addr) = self.webhook_actor_addr.take() {
webhook_actor_addr.do_send(ShutdownWebhook);
}
self.generation.inc();
// Webhook Server
info!("Starting Webhook Server...");
let webhook_router = WebhookRouterActor::default().start();
let listen_url = app_config.listen().url();
let notify_user_recipient = self.alerts.clone().recipient();
let server_addr = Some(ctx.address());
// Forge Actors
for (forge_alias, forge_config) in app_config.forges() {
let repo_actors = self
.create_forge_repos(
forge_config,
forge_alias.clone(),
&server_storage,
listen_url,
&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,14 @@
use actix::Handler;
//
use crate::server::{actor::messages::ServerUpdate, ServerActor};
impl Handler<ServerUpdate> for ServerActor {
type Result = ();
fn handle(&mut self, msg: ServerUpdate, _ctx: &mut Self::Context) -> Self::Result {
self.subscribers.iter().for_each(move |subscriber| {
subscriber.do_send(msg.clone());
});
}
}

View file

@ -0,0 +1,30 @@
//-
use actix::prelude::*;
use tracing::debug;
use crate::{
repo::messages::UnRegisterWebhook,
server::actor::{messages::Shutdown, ServerActor},
webhook::messages::ShutdownWebhook,
};
impl Handler<Shutdown> for ServerActor {
type Result = ();
fn handle(&mut self, _msg: Shutdown, _ctx: &mut Self::Context) -> Self::Result {
self.repo_actors
.iter()
.for_each(|((forge_alias, repo_alias), addr)| {
debug!(%forge_alias, %repo_alias, "removing webhook");
addr.do_send(UnRegisterWebhook::new());
debug!(%forge_alias, %repo_alias, "removed webhook");
});
debug!("server shutdown");
if let Some(webhook) = self.webhook_actor_addr.take() {
debug!("shutting down webhook");
webhook.do_send(ShutdownWebhook);
debug!("webhook shutdown");
}
}
}

View file

@ -0,0 +1,12 @@
//
use actix::Handler;
use crate::server::{actor::messages::ShutdownTrigger, ServerActor};
impl Handler<ShutdownTrigger> for ServerActor {
type Result = ();
fn handle(&mut self, msg: ShutdownTrigger, _ctx: &mut Self::Context) -> Self::Result {
self.shutdown_trigger.replace(msg.peel());
}
}

View file

@ -0,0 +1,10 @@
use crate::server::actor::{messages::SubscribeToUpdates, ServerActor};
//
impl actix::Handler<SubscribeToUpdates> for ServerActor {
type Result = ();
fn handle(&mut self, msg: SubscribeToUpdates, _ctx: &mut Self::Context) -> Self::Result {
self.subscribers.push(msg.peel());
}
}

View file

@ -0,0 +1,113 @@
//
use actix::{Message, Recipient};
use derive_more::Constructor;
use git_next_core::{
git::{self, forge::commit::Status, graph::Log, Commit},
message,
server::{AppConfig, Storage},
webhook::{push::Branch, Push},
ForgeAlias, RepoAlias, RepoBranches, RepoConfig,
};
use std::net::SocketAddr;
// receive server config
message!(
ReceiveAppConfig,
AppConfig,
"Notification of newly loaded server configuration.
This message will prompt the `git-next` server to stop and restart all repo-actors.
Contains the new server configuration."
);
// receive valid server config
#[derive(Clone, Debug, PartialEq, Eq, Constructor)]
pub struct ValidAppConfig {
pub app_config: AppConfig,
pub socket_address: SocketAddr,
pub storage: Storage,
}
message!(
ReceiveValidAppConfig,
ValidAppConfig,
"Notification of validated server configuration."
);
message!(Shutdown, "Notification to shutdown the server actor");
#[derive(Clone, Debug, PartialEq, Eq, Message)]
#[rtype(result = "()")]
pub enum ServerUpdate {
/// List of all configured forges and aliases
AppConfigLoaded { app_config: ValidAppConfig },
RepoUpdate {
forge_alias: ForgeAlias,
repo_alias: RepoAlias,
repo_update: RepoUpdate,
},
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RepoUpdate {
Branches {
branches: RepoBranches,
},
Log {
log: Log,
},
ValidateRepo,
Okay {
main: Commit,
next: Commit,
dev: Commit,
},
Alert {
alert: String,
},
CheckingCI,
AdvancingNext {
commit: git::Commit,
force: git::push::Force,
},
AdvancingMain {
commit: git::Commit,
},
Opening,
LoadingConfigFromRepo,
ReceiveCIStatus {
status: Status,
},
ReceiveRepoConfig {
repo_config: RepoConfig,
},
RegisteringWebhook,
UnregisteringWebhook,
WebhookReceived {
branch: Branch,
push: Push,
},
RegisteredWebhook,
Opened,
NextUpdated,
MainUpdated,
}
message!(
SubscribeToUpdates,
Recipient<ServerUpdate>,
"Subscribe to receive updates from the server"
);
/// Sends a channel to be used to shutdown the server
#[derive(Message, Constructor)]
#[rtype(result = "()")]
pub struct ShutdownTrigger(std::sync::mpsc::Sender<String>);
impl ShutdownTrigger {
pub fn peel(self) -> std::sync::mpsc::Sender<String> {
self.0
}
}

View file

@ -0,0 +1,261 @@
//
use actix::prelude::*;
use messages::{ReceiveAppConfig, ServerUpdate, Shutdown};
use tracing::error;
#[cfg(test)]
mod tests;
mod handlers;
pub mod messages;
use crate::{
alerts::messages::NotifyUser, alerts::AlertsActor, forge::Forge, repo::RepoActor,
webhook::WebhookActor,
};
use git_next_core::{
git::{repository::factory::RepositoryFactory, Generation, RepoDetails},
server::{self, AppConfig, ListenUrl, Storage},
ForgeAlias, ForgeConfig, GitDir, RepoAlias, ServerRepoConfig, StoragePathType,
};
use kxio::{fs::FileSystem, net::Net};
use std::{
collections::BTreeMap,
path::PathBuf,
sync::{Arc, RwLock},
};
#[derive(Debug, derive_more::Display, derive_more::From)]
pub enum Error {
#[display("Failed to create data directories")]
FailedToCreateDataDirectory(kxio::fs::Error),
#[display("The forge data path is not a directory: {path:?}")]
ForgeDirIsNotDirectory {
path: PathBuf,
},
Config(server::Error),
Io(std::io::Error),
}
type Result<T> = core::result::Result<T, Error>;
#[allow(clippy::module_name_repetitions)]
#[derive(derive_with::With)]
#[with(message_log)]
pub struct ServerActor {
app_config: Option<AppConfig>,
generation: Generation,
webhook_actor_addr: Option<Addr<WebhookActor>>,
fs: FileSystem,
net: Net,
alerts: Addr<AlertsActor>,
repository_factory: Box<dyn RepositoryFactory>,
sleep_duration: std::time::Duration,
repo_actors: BTreeMap<(ForgeAlias, RepoAlias), Addr<RepoActor>>,
shutdown_trigger: Option<std::sync::mpsc::Sender<String>>,
subscribers: Vec<Recipient<ServerUpdate>>,
// testing
message_log: Option<Arc<RwLock<Vec<String>>>>,
}
impl Actor for ServerActor {
type Context = Context<Self>;
}
impl ServerActor {
pub fn new(
fs: FileSystem,
net: Net,
alerts: Addr<AlertsActor>,
repo: Box<dyn RepositoryFactory>,
sleep_duration: std::time::Duration,
) -> Self {
let generation = Generation::default();
Self {
app_config: None,
generation,
webhook_actor_addr: None,
fs,
net,
alerts,
repository_factory: repo,
shutdown_trigger: None,
subscribers: Vec::default(),
sleep_duration,
repo_actors: BTreeMap::new(),
message_log: None,
}
}
fn create_forge_data_directories(
&self,
app_config: &AppConfig,
server_dir: &std::path::Path,
) -> Result<()> {
for (forge_name, _forge_config) in app_config.forges() {
let forge_dir: PathBuf = (&forge_name).into();
let path = server_dir.join(&forge_dir);
let path_handle = self.fs.path(&path);
if path_handle.exists()? {
if !path_handle.is_dir()? {
return Err(Error::ForgeDirIsNotDirectory { path });
}
} else {
tracing::info!(%forge_name, ?path_handle, "creating storage");
self.fs.dir(&path).create_all()?;
}
}
Ok(())
}
fn create_forge_repos(
&self,
forge_config: &ForgeConfig,
forge_name: ForgeAlias,
server_storage: &Storage,
listen_url: &ListenUrl,
notify_user_recipient: &Recipient<NotifyUser>,
server_addr: Option<Addr<Self>>,
) -> Vec<(ForgeAlias, RepoAlias, RepoActor)> {
let span =
tracing::info_span!("create_forge_repos", name = %forge_name, config = %forge_config);
let _guard = span.enter();
tracing::info!("Creating Forge");
let mut repos = vec![];
let creator = self.create_actor(
forge_name,
forge_config.clone(),
server_storage,
listen_url,
server_addr,
);
for (repo_alias, server_repo_config) in forge_config.repos() {
let forge_repo = creator((
repo_alias,
server_repo_config,
notify_user_recipient.clone(),
));
tracing::info!(
alias = %forge_repo.1,
"Created Repo"
);
repos.push(forge_repo);
}
repos
}
fn create_actor(
&self,
forge_name: ForgeAlias,
forge_config: ForgeConfig,
server_storage: &Storage,
listen_url: &ListenUrl,
server_addr: Option<Addr<Self>>,
) -> impl Fn(
(RepoAlias, &ServerRepoConfig, Recipient<NotifyUser>),
) -> (ForgeAlias, RepoAlias, RepoActor) {
let server_storage = server_storage.clone();
let listen_url = listen_url.clone();
let net = self.net.clone();
let repository_factory = self.repository_factory.duplicate();
let generation = self.generation;
let sleep_duration = self.sleep_duration;
move |(repo_alias, server_repo_config, notify_user_recipient)| {
let span = tracing::info_span!("create_actor", alias = %repo_alias, config = %server_repo_config);
let _guard = span.enter();
tracing::info!("Creating Repo");
let gitdir = server_repo_config.gitdir().map_or_else(
|| {
GitDir::new(
server_storage
.path()
.join(forge_name.to_string())
.join(repo_alias.to_string()),
StoragePathType::Internal,
)
},
|gitdir| gitdir,
);
// INFO: can't canonicalise gitdir as the path needs to exist to do that and we may not
// have cloned the repo yet
let repo_details = RepoDetails::new(
generation,
&repo_alias,
server_repo_config,
&forge_name,
&forge_config,
gitdir,
);
let forge = Forge::create(repo_details.clone(), net.clone());
tracing::info!("Starting Repo Actor");
let actor = RepoActor::new(
repo_details,
forge,
listen_url.clone(),
generation,
net.clone(),
repository_factory.duplicate(),
sleep_duration,
Some(notify_user_recipient),
server_addr.clone(),
);
(forge_name.clone(), repo_alias, actor)
}
}
fn server_storage(&self, app_config: &ReceiveAppConfig) -> Option<Storage> {
let server_storage = app_config.storage().clone();
let dir = server_storage.path();
if !dir.exists() {
if let Err(err) = self.fs.dir(dir).create() {
error!(?err, ?dir, "Failed to create server storage");
return None;
}
}
let Ok(canon) = dir.canonicalize() else {
error!(?dir, "Failed to confirm server storage");
return None;
};
if let Err(err) = self.create_forge_data_directories(app_config, &canon) {
error!(?err, "Failure creating forge storage");
return None;
}
Some(server_storage)
}
/// Attempts to gracefully shutdown the server before stopping the system.
fn abort(&mut self, ctx: &<Self as actix::Actor>::Context, message: impl Into<String>) {
self.do_send(crate::server::actor::messages::Shutdown, ctx);
if let Some(t) = self.shutdown_trigger.take() {
let _ = t.send(message.into());
} else {
error!("{}", message.into());
self.do_send(Shutdown, ctx);
// System::current().stop_with_code(1);
}
}
fn do_send<M>(&self, msg: M, ctx: &<Self as actix::Actor>::Context)
where
M: actix::Message + Send + 'static + std::fmt::Debug,
Self: actix::Handler<M>,
<M as actix::Message>::Result: Send,
{
if let Some(message_log) = &self.message_log {
let log_message = format!("send: {msg:?}");
if let Ok(mut log) = message_log.write() {
log.push(log_message);
}
}
if cfg!(not(test)) {
ctx.address().do_send(msg);
}
}
}

View file

@ -0,0 +1,19 @@
use std::time::Duration;
use actix::prelude::*;
use crate::alerts::{AlertsActor, History};
//
pub fn a_filesystem() -> kxio::fs::TempFileSystem {
#[allow(clippy::expect_used)]
kxio::fs::temp().expect("temp fs")
}
pub fn a_network() -> kxio::net::MockNet {
kxio::net::mock()
}
pub fn an_alerts_actor(net: kxio::net::Net) -> Addr<AlertsActor> {
AlertsActor::new(None, History::new(Duration::from_millis(1)), net).start()
}

View file

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

View file

@ -0,0 +1,56 @@
//
use actix::prelude::*;
use crate::server::actor::{tests::given, ReceiveAppConfig, ServerActor};
use git_next_core::{
git,
server::{AppConfig, Http, Listen, ListenUrl, Shout, Storage},
};
use std::{
collections::BTreeMap,
sync::{Arc, RwLock},
};
#[test_log::test(actix::test)]
async fn when_webhook_url_has_trailing_slash_should_not_send() {
//given
// parameters
let fs = given::a_filesystem();
let net = given::a_network();
let alerts = given::an_alerts_actor(net.clone().into());
let repo = git::repository::factory::mock();
let duration = std::time::Duration::from_millis(1);
// sut
let server = ServerActor::new(fs.as_real(), net.into(), alerts, repo, duration);
// collaborators
let listen = Listen::new(
Http::new("0.0.0.0".to_string(), 80),
ListenUrl::new("http://localhost/".to_string()), // with trailing slash
);
let shout = Shout::default();
let server_storage = Storage::new((fs.base()).to_path_buf());
let repos = BTreeMap::default();
// debugging
let message_log: Arc<RwLock<Vec<String>>> = Arc::new(RwLock::new(vec![]));
let server = server.with_message_log(Some(message_log.clone()));
//when
server.start().do_send(ReceiveAppConfig::new(AppConfig::new(
listen,
shout,
server_storage,
repos,
)));
actix_rt::time::sleep(std::time::Duration::from_millis(1)).await;
//then
// INFO: assert that ReceiveValidServerConfig is NOT sent
tracing::debug!(?message_log, "");
assert!(message_log.read().iter().any(|log| !log
.iter()
.any(|line| line == "send: ReceiveValidServerConfig")));
}

View file

@ -0,0 +1,192 @@
//
pub mod actor;
#[cfg(test)]
mod tests;
use actix::prelude::*;
use actix_rt::signal;
use actor::messages::ShutdownTrigger;
use crate::{
alerts::{AlertsActor, History},
file_watcher::{watch_file, FileUpdated},
};
#[allow(clippy::module_name_repetitions)]
pub use actor::ServerActor;
use git_next_core::git::RepositoryFactory;
use color_eyre::{eyre::Context, Result};
use kxio::{fs::FileSystem, net::Net};
use tracing::info;
use std::{
path::PathBuf,
sync::{atomic::Ordering, mpsc::channel, Arc, RwLock},
time::Duration,
};
const A_DAY: Duration = Duration::from_secs(24 * 60 * 60);
pub fn init(fs: &FileSystem) -> Result<()> {
let file_name = "git-next-server.toml";
let pathbuf = PathBuf::from(file_name);
if fs
.path(&pathbuf)
.exists()
.with_context(|| format!("Checking for existing file: {pathbuf:?}"))?
{
eprintln!("The configuration file already exists at {pathbuf:?} - not overwritting it.",);
} else {
fs.file(&pathbuf)
.write(include_str!("server-default.toml"))
.with_context(|| format!("Writing file: {pathbuf:?}"))?;
println!("Created a default configuration file at {pathbuf:?}",);
}
Ok(())
}
#[allow(clippy::too_many_lines)]
pub fn start(
ui: bool,
fs: FileSystem,
net: Net,
repo: Box<dyn RepositoryFactory>,
sleep_duration: std::time::Duration,
) -> Result<()> {
if ui {
#[cfg(feature = "tui")]
{
crate::tui::logging::initialize_logging()?;
}
} else {
init_logging();
}
let shutdown_message_holder: Arc<RwLock<Option<String>>> = Arc::new(RwLock::new(None));
let shutdown_message_holder_exec = shutdown_message_holder.clone();
let file_watcher_err_holder: Arc<RwLock<Option<anyhow::Error>>> = Arc::new(RwLock::new(None));
let file_watcher_err_holder_exec = file_watcher_err_holder.clone();
let execution = async move {
info!("Starting Alert Dispatcher...");
let alerts_addr = AlertsActor::new(None, History::new(A_DAY), net.clone()).start();
info!("Starting Server...");
let server =
ServerActor::new(fs.clone(), net.clone(), alerts_addr, repo, sleep_duration).start();
info!("Starting File Watcher...");
let watch_file = watch_file("git-next-server.toml".into(), server.clone().recipient());
let fw_shutdown = match watch_file {
Ok(fw_shutdown) => fw_shutdown,
Err(err) => {
// shutdown now
server.do_send(crate::server::actor::messages::Shutdown);
actix_rt::time::sleep(std::time::Duration::from_millis(10)).await;
System::current().stop();
let _ = file_watcher_err_holder_exec
.write()
.map(|mut o| o.replace(err));
return;
}
};
let (tx_shutdown, rx_shutdown) = channel::<String>();
if ui {
#[cfg(feature = "tui")]
{
use crate::server::actor::messages::SubscribeToUpdates;
use crate::tui;
let tui_addr = tui::Tui::new(tx_shutdown.clone()).start();
server.do_send(SubscribeToUpdates::new(tui_addr.clone().recipient()));
server.do_send(ShutdownTrigger::new(tx_shutdown));
server.do_send(FileUpdated); // update file after ui subscription in place
loop {
let _ = tui_addr.send(tui::Tick).await;
if let Ok(message) = rx_shutdown.try_recv() {
let _ = shutdown_message_holder_exec
.write()
.map(|mut o| o.replace(message));
break;
}
actix_rt::time::sleep(Duration::from_millis(16)).await;
}
}
} else {
server.do_send(ShutdownTrigger::new(tx_shutdown.clone()));
server.do_send(FileUpdated);
info!("Server running - Press Ctrl-C to stop...");
tokio::select! {
_r = signal::ctrl_c() => {
info!("Ctrl-C received, shutting down...");
}
_x = async move {
loop{
if let Ok(message) = rx_shutdown.try_recv() {
let _ = shutdown_message_holder_exec
.write()
.map(|mut o| o.replace(message));
break;
}
actix_rt::task::yield_now().await;
}
} => {
info!("signaled shutdown");
}
};
}
// shutdown
fw_shutdown.store(true, Ordering::Relaxed);
server.do_send(crate::server::actor::messages::Shutdown);
actix_rt::time::sleep(std::time::Duration::from_millis(10)).await;
System::current().stop();
};
let system = System::new();
Arbiter::current().spawn(execution);
system.run()?;
// check for error from server thread
#[allow(clippy::unwrap_used)]
if let Some(err) = &*shutdown_message_holder.read().unwrap() {
#[cfg(feature = "tui")]
if ui {
ratatui::restore();
}
if !err.is_empty() {
return Err(color_eyre::eyre::eyre!(format!("{err}")));
}
}
// check for error from file watcher thread
#[allow(clippy::unwrap_used)]
if let Some(err) = &*file_watcher_err_holder.read().unwrap() {
#[cfg(feature = "tui")]
if ui {
ratatui::restore();
}
return Err(color_eyre::eyre::eyre!(format!("{err}")));
}
Ok(())
}
pub fn init_logging() {
use tracing::Level;
use tracing_subscriber::FmtSubscriber;
let subscriber = FmtSubscriber::builder()
.with_target(false)
.with_file(true)
.with_line_number(true)
.with_max_level(Level::INFO)
.finish();
#[allow(clippy::expect_used)]
tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber failed");
}

View file

@ -0,0 +1,35 @@
[listen]
# The address and port to listen to for incoming webhooks from forges.
http = { addr = "0.0.0.0", port = 8080 }
# The URL where forge should send updates to.
# This should be route to 'http.addr:http.port' above (e.g. using a reverse proxy)
url = "https://localhost:8080" # don't include any query path or a trailing slash
[shout] # where updates from git-next should be sent to alert the user
# webhook = { url = "https//localhost:9090", secret = "secret-password" }
# desktop = true # enable desktop notifications
# [shout.email]
# from = "git-next@example.com"
# to = "developer@example.com"
#
# [shout.email.smtp]
# hostname = "smtp.example.com"
# username = "git-next@example.com"
# password = "MySecretEmailPassword42"
[storage] # where local copies of repositories will be cloned (bare) into
path = "./data"
[forge] # the forges to connect to
# [forge.default]
# forge_type = "ForgeJo"
# hostname = "git.example.net"
# user = "bob" # the user to perform actions as
# token = "API-Token"
#
# [forge.default.repos] # the repos at the forge to manage
# hello = { repo = "bob/hello", branch = "main", gitdir = "/opt/git/projects/bob/hello.git" }
# world = { repo = "bob/world", branch = "master", main = "master", next = "upcoming", "dev" = "develop" }

View file

@ -0,0 +1,151 @@
//
use assert2::let_assert;
use git_next_core::{
self as core,
git::{self, repository::Direction, validation::remotes::validate_default_remotes},
ApiToken, ForgeType, GitDir, Hostname, RepoBranches, RepoConfig, RepoConfigSource, RepoPath,
StoragePathType, User,
};
use secrecy::SecretString;
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
#[test]
fn test_repo_config_load() -> Result<()> {
let toml = r#"[branches]
main = "main"
next = "next"
dev = "dev"
[options]
"#;
let config = RepoConfig::parse(toml)?;
assert_eq!(
config,
RepoConfig::new(
RepoBranches::new("main".to_string(), "next".to_string(), "dev".to_string(),),
RepoConfigSource::Repo
)
);
Ok(())
}
#[test]
fn gitdir_should_display_as_pathbuf() {
//given
let gitdir = GitDir::new("foo/dir".into(), StoragePathType::External);
//when
let result = format!("{gitdir}");
//then
assert_eq!(result, "foo/dir");
}
#[test]
// NOTE: this test assumes it is being run in a cloned worktree from the project's home repo:
// git.kemitix.net:kemitix/git-next
// If the default push remote is something else, then this test will fail
fn repo_details_find_default_push_remote_finds_correct_remote() -> Result<()> {
let cli_crate_dir = std::env::current_dir().map_err(git::validation::remotes::Error::Io)?;
let_assert!(Some(Some(root)) = cli_crate_dir.parent().map(|p| p.parent()));
let mut repo_details = git::repo_details(
1,
git::Generation::default(),
core::common::forge_details(1, ForgeType::MockForge),
None,
GitDir::new(root.to_path_buf(), StoragePathType::External), // Server GitDir - should be ignored
);
repo_details.forge = repo_details
.forge
.with_user(User::new("git".to_string()))
.with_token(ApiToken::new(SecretString::from(String::new())))
.with_hostname(Hostname::new("git.kemitix.net"));
repo_details.repo_path = RepoPath::new("kemitix/git-next".to_string());
let Ok(open_repository) = git::repository::factory::real().open(&repo_details) else {
// .git directory may not be present on dev environment
return Ok(());
};
let_assert!(
Some(found_git_remote) = open_repository.find_default_remote(Direction::Push),
"Default Push Remote not found"
);
let_assert!(Some(config_git_remote) = repo_details.remote_url());
assert!(
found_git_remote.matches(&config_git_remote),
"Default Push Remote must match config"
);
Ok(())
}
#[test_log::test]
fn gitdir_validate_should_pass_a_valid_git_repo() -> Result<()> {
let cli_crate_dir = std::env::current_dir().map_err(git::validation::remotes::Error::Io)?;
let_assert!(Some(Some(root)) = cli_crate_dir.parent().map(|p| p.parent()));
let mut repo_details = git::repo_details(
1,
git::Generation::default(),
core::common::forge_details(1, ForgeType::MockForge),
None,
GitDir::new(root.to_path_buf(), StoragePathType::External), // Server GitDir - should be ignored
)
.with_repo_path(RepoPath::new("kemitix/git-next".to_string()));
repo_details.forge = repo_details
.forge
.with_user(User::new("git".to_string()))
.with_token(ApiToken::new(SecretString::from(String::new())))
.with_hostname(Hostname::new("git.kemitix.net"));
tracing::debug!("opening...");
let Ok(repository) = git::repository::factory::real().open(&repo_details) else {
// .git directory may not be present on dev environment
return Ok(());
};
tracing::debug!("open okay");
tracing::info!(?repository, "FOO");
tracing::info!(?repo_details, "BAR");
validate_default_remotes(&*repository, &repo_details)?;
Ok(())
}
#[test]
fn gitdir_validate_should_fail_a_git_repo_with_wrong_remote() {
let_assert!(
Ok(cli_crate_dir) = std::env::current_dir().map_err(git::validation::remotes::Error::Io)
);
eprintln!("cli_crate_dir: {cli_crate_dir:?}");
let_assert!(Some(Some(root)) = cli_crate_dir.parent().map(|p| p.parent()));
eprintln!("root: {root:?}");
let mut repo_details = git::repo_details(
1,
git::Generation::default(),
core::common::forge_details(1, ForgeType::MockForge),
None,
GitDir::new(root.to_path_buf(), StoragePathType::External), // Server GitDir - should be ignored
)
.with_repo_path(RepoPath::new("kemitix/git-next".to_string()));
repo_details.forge = repo_details
.forge
.with_user(User::new("git".to_string()))
.with_token(ApiToken::new(SecretString::from(String::new())))
.with_hostname(Hostname::new("git.kemitix.net"));
let Ok(repository) = git::repository::factory::real().open(&repo_details) else {
// .git directory may not be present on dev environment
return;
};
let mut repo_details = repo_details.clone();
repo_details.forge = repo_details
.forge
.with_hostname(Hostname::new("code.kemitix.net"));
let_assert!(Err(_) = validate_default_remotes(&*repository, &repo_details));
}
#[test]
fn git_remote_to_string_is_as_expected() {
let git_remote = git::GitRemote::new(Hostname::new("foo"), RepoPath::new("bar".to_string()));
let as_string = git_remote.to_string();
assert_eq!(as_string, "foo:bar");
}

View file

@ -6,12 +6,12 @@ mod init {
fn should_not_update_file_if_it_exists() -> TestResult {
let fs = kxio::fs::temp()?;
let file = fs.base().join(".git-next.toml");
fs.file_write(&file, "contents")?;
fs.file(&file).write("contents")?;
crate::init::run(fs.clone());
crate::init::run(&fs)?;
assert_eq!(
fs.file_read_to_string(&file)?,
fs.file(&file).reader()?.to_string(),
"contents",
"The file has been changed"
);
@ -23,18 +23,59 @@ mod init {
fn should_create_default_file_if_not_exists() -> TestResult {
let fs = kxio::fs::temp()?;
crate::init::run(fs.clone());
crate::init::run(&fs)?;
let file = fs.base().join(".git-next.toml");
assert!(fs.path_exists(&file)?, "The file has not been created");
assert!(fs.path(&file).exists()?, "The file has not been created");
assert_eq!(
fs.file_read_to_string(&file)?,
include_str!("../../../default.toml"),
fs.file(&file).reader()?.to_string(),
include_str!("../default.toml"),
"The file does not match the default template"
);
Ok(())
}
}
mod file_watcher {
use std::{sync::atomic::Ordering, time::Duration};
use actix::{Actor, Context, Handler};
use rstest::*;
use crate::file_watcher::{self, FileUpdated};
use super::TestResult;
#[rstest]
#[actix::test]
#[timeout(Duration::from_millis(80))]
async fn should_not_block_calling_thread() -> TestResult {
let fs = kxio::fs::temp()?;
let path = fs.base().join("file");
fs.file(&path).write("foo")?;
let listener = Listener;
let l_addr = listener.start();
let recipient = l_addr.recipient();
let fw_shutdown = file_watcher::watch_file(path, recipient)?;
std::thread::sleep(Duration::from_millis(10));
fw_shutdown.store(true, Ordering::Relaxed);
Ok(()) // was not blocked
}
struct Listener;
impl Actor for Listener {
type Context = Context<Self>;
}
impl Handler<FileUpdated> for Listener {
type Result = ();
fn handle(&mut self, _msg: FileUpdated, _ctx: &mut Self::Context) -> Self::Result {
// todo!()
}
}
}

View file

@ -0,0 +1,40 @@
# Terminal UI
Currently the Terminal UI is an experimental feature, controlled by the feature flag `tui`.
## Build & Run
The build `git-next` with the Terminal UI use: `cargo install git-next --features tui`
To run `git-next` with the Terminal UI use: `git-next server start --ui`
### Docker
If using the docker image you will need to create a directory to mount that contains the
`git-next-server.toml` file. Mount this directory as `/app`. In the example below we use
the current directory for this.
If you want to persist the clones of your monitored repos then point `storage.path` in
`git-next-server.toml` to the the directory `/app`, (e.g. `path = "/app/data"`).
Map the port your webhook notifications are arriving on to the port specified in `listen.http.port`.
`docker run -it -p "8080:8092" -v .:/app/ git.kemitix.net/kemitix/git-next:latest server start --ui`
## logs
When the Terminal UI is enabled via the `--ui` parameter, logs are written to the file:
- `???` on Linux
- `~/Library/Application Support/net.kemitix.git-next/git-next.log` on MacOS
- `???` on Windows
## Keys
- `q` - Quit
- `j` - Down
- `k` - Up
- `f` - Page Down
- `b` - Page Up
- `g` - Top/Home
- `G` - Bottom/End

View file

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

View file

@ -0,0 +1,104 @@
//
use actix::Handler;
use ratatui::style::Color;
use crate::{
server::actor::messages::{RepoUpdate, ServerUpdate},
tui::{actor::ServerState, Tui},
};
static OKAY: Color = Color::Green;
static PREP: Color = Color::Gray;
static ACTING: Color = Color::LightBlue;
static WARN: Color = Color::Red;
impl Handler<ServerUpdate> for Tui {
type Result = ();
fn handle(&mut self, msg: ServerUpdate, _ctx: &mut Self::Context) -> Self::Result {
self.state.tap();
match msg {
ServerUpdate::AppConfigLoaded { app_config } => {
self.state.mode = ServerState::from(app_config);
}
ServerUpdate::RepoUpdate {
forge_alias,
repo_alias,
repo_update,
} => {
if let ServerState::Configured { forges } = &mut self.state.mode {
let Some(forge_state) = forges.get_mut(&forge_alias) else {
return;
};
let Some(repo_state) = forge_state.repos.get_mut(&repo_alias) else {
return;
};
repo_state.clear_alert();
match repo_update {
RepoUpdate::Branches { branches } => {
repo_state.update_branches(branches);
}
RepoUpdate::Log { log } => {
repo_state.update_log(log);
}
RepoUpdate::ValidateRepo => repo_state.update_message("polling...", ACTING),
RepoUpdate::Okay { main, next, dev } => {
repo_state.clear_alert();
repo_state.update_message("okay", OKAY);
*repo_state = repo_state.clone().ready(main, next, dev);
}
RepoUpdate::Alert { alert } => {
repo_state.alert(alert);
}
RepoUpdate::CheckingCI => {
repo_state.update_message("Checking CI status", ACTING);
}
RepoUpdate::AdvancingNext { commit, force: _ } => {
repo_state
.update_message(format!("advancing next to {commit}"), ACTING);
}
RepoUpdate::NextUpdated => {
repo_state.update_message("next updated - pause while CI starts", OKAY);
}
RepoUpdate::AdvancingMain { commit } => {
repo_state
.update_message(format!("advancing main to {commit}"), ACTING);
}
RepoUpdate::MainUpdated => {
repo_state.update_message("main updated", OKAY);
}
RepoUpdate::Opening => {
repo_state.update_message("opening...", PREP);
}
RepoUpdate::Opened => {
repo_state.update_message("opened", PREP);
}
RepoUpdate::LoadingConfigFromRepo => {
repo_state.update_message("loading config from repo...", PREP);
}
RepoUpdate::ReceiveCIStatus { status } => {
repo_state.update_message(format!("ci status: {status:?}"), WARN);
}
RepoUpdate::ReceiveRepoConfig { repo_config: _ } => {
repo_state.update_message("loaded config from repo", PREP);
}
RepoUpdate::RegisteringWebhook => {
repo_state.update_message("registering webhook...", PREP);
}
RepoUpdate::UnregisteringWebhook => {
repo_state.update_message("unregistering webhook...", PREP);
}
RepoUpdate::WebhookReceived { branch, push: _ } => {
repo_state
.update_message(format!("webhook update: {branch:?}"), ACTING);
}
RepoUpdate::RegisteredWebhook => {
repo_state.update_message("registered webhook", PREP);
}
}
}
}
}
}
}

View file

@ -0,0 +1,15 @@
//
use actix::Handler;
use crate::tui::actor::{messages::Tick, Tui};
impl Handler<Tick> for Tui {
type Result = std::io::Result<()>;
fn handle(&mut self, _msg: Tick, ctx: &mut Self::Context) -> Self::Result {
self.state.tap();
self.draw()?;
self.handle_input(ctx)?;
Ok(())
}
}

View file

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

View file

@ -0,0 +1,97 @@
//
mod handlers;
pub mod messages;
mod model;
#[cfg(test)]
mod tests;
use std::sync::mpsc::Sender;
use actix::{Actor, ActorContext as _, Context};
pub use model::*;
use ratatui::{
crossterm::event::{self, KeyCode, KeyEventKind},
DefaultTerminal,
};
use tui_scrollview::ScrollViewState;
#[derive(Debug)]
pub struct Tui {
terminal: Option<DefaultTerminal>,
signal_shutdown: Sender<String>,
pub state: State,
scroll_view_state: ScrollViewState,
}
impl Actor for Tui {
type Context = Context<Self>;
fn started(&mut self, _ctx: &mut Self::Context) {
self.terminal.replace(ratatui::init());
}
fn stopped(&mut self, _ctx: &mut Self::Context) {
self.terminal.take();
ratatui::restore();
}
}
impl Tui {
pub fn new(signal_shutdown: Sender<String>) -> Self {
Self {
terminal: None,
signal_shutdown,
state: State::initial(),
scroll_view_state: ScrollViewState::default(),
}
}
fn draw(&mut self) -> std::io::Result<()> {
let t = self.terminal.take();
let scroll_view_state = &mut self.scroll_view_state;
let state = &self.state;
if let Some(mut terminal) = t {
terminal.draw(|frame| {
frame.render_stateful_widget(state, frame.area(), scroll_view_state);
})?;
self.terminal = Some(terminal);
} else {
eprintln!("No terminal setup");
}
Ok(())
}
fn handle_input(&mut self, ctx: &mut <Self as actix::Actor>::Context) -> std::io::Result<()> {
if event::poll(std::time::Duration::from_millis(16))? {
let event::Event::Key(key) = event::read()? else {
return Ok(());
};
if key.kind != KeyEventKind::Press {
return Ok(());
}
match key.code {
KeyCode::Char('q') => {
ctx.stop();
if let Err(err) = self.signal_shutdown.send(String::new()) {
tracing::error!(?err, "Failed to signal shutdown");
}
}
KeyCode::Char('j') | KeyCode::Down => self.scroll_view_state.scroll_down(),
KeyCode::Char('k') | KeyCode::Up => self.scroll_view_state.scroll_up(),
KeyCode::Char('f') | KeyCode::PageDown => {
self.scroll_view_state.scroll_page_down();
}
KeyCode::Char('b') | KeyCode::PageUp => {
self.scroll_view_state.scroll_page_up();
}
KeyCode::Char('g') | KeyCode::Home => {
self.scroll_view_state.scroll_to_top();
}
KeyCode::Char('G') | KeyCode::End => {
self.scroll_view_state.scroll_to_bottom();
}
_ => (),
}
}
Ok(())
}
}

View file

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

View file

@ -0,0 +1,99 @@
//
mod model {
mod repo_state {
use git_next_core::{git::graph::Log, RepoBranches};
use ratatui::style::Style;
use crate::{
repo::tests::given,
tui::actor::{RepoMessage, RepoState, ViewState},
};
type Alert = Option<String>;
fn identified_with_alert(alert: Alert) -> RepoState {
RepoState::Identified {
repo_alias: given::a_repo_alias(),
message: RepoMessage::builder()
.text(given::a_name())
.style(Style::default())
.build(),
alert,
}
}
fn configured_with_alert(alert: Alert) -> RepoState {
RepoState::Configured {
repo_alias: given::a_repo_alias(),
message: RepoMessage::builder()
.text(given::a_name())
.style(Style::default())
.build(),
alert,
branches: RepoBranches::new(String::new(), String::new(), String::new()),
log: Log::default(),
}
}
fn ready_with_alert(alert: Alert) -> RepoState {
RepoState::Ready {
repo_alias: given::a_repo_alias(),
message: RepoMessage::builder()
.text(given::a_name())
.style(Style::default())
.build(),
alert,
branches: RepoBranches::new(String::new(), String::new(), String::new()),
log: Log::default(),
view_state: ViewState::default(),
main: given::a_commit(),
next: given::a_commit(),
dev: given::a_commit(),
}
}
#[rstest::rstest]
#[case(identified_with_alert(None))]
#[case(configured_with_alert(None))]
#[case(ready_with_alert(None))]
fn none_alert_remains_none(#[case] mut repo_state: RepoState) {
// given
match &repo_state {
RepoState::Identified { alert, .. }
| RepoState::Configured { alert, .. }
| RepoState::Ready { alert, .. } => {
assert!(alert.is_none(), "should be none at start");
}
}
// when
repo_state.clear_alert();
// then
match &repo_state {
RepoState::Identified { alert, .. }
| RepoState::Configured { alert, .. }
| RepoState::Ready { alert, .. } => assert!(alert.is_none(), "should remain none"),
}
}
#[rstest::rstest]
#[case(identified_with_alert(Some(String::new())))]
#[case(configured_with_alert(Some(String::new())))]
#[case(ready_with_alert(Some(String::new())))]
fn some_alert_becomes_none(#[case] mut repo_state: RepoState) {
// given
match &repo_state {
RepoState::Identified { alert, .. }
| RepoState::Configured { alert, .. }
| RepoState::Ready { alert, .. } => {
assert!(alert.is_some(), "should be some at start");
}
}
// when
repo_state.clear_alert();
// then
match &repo_state {
RepoState::Identified { alert, .. }
| RepoState::Configured { alert, .. }
| RepoState::Ready { alert, .. } => assert!(alert.is_none(), "should become none"),
}
}
}
}

View file

@ -0,0 +1,68 @@
use std::collections::BTreeMap;
use git_next_core::ForgeAlias;
use ratatui::{
buffer::Buffer,
layout::{Direction, Layout, Rect, Size},
widgets::StatefulWidget,
};
use tui_scrollview::{ScrollView, ScrollViewState};
use crate::tui::actor::ForgeState;
use super::{forge::ForgeWidget, HeightContraintLength};
pub struct ConfiguredAppWidget<'a> {
pub forges: &'a BTreeMap<ForgeAlias, ForgeState>,
}
impl HeightContraintLength for ConfiguredAppWidget<'_> {
fn height_constraint_length(&self) -> u16 {
self.children()
.iter()
.map(HeightContraintLength::height_constraint_length)
.sum::<u16>()
+ 2 // top + bottom borders
}
}
impl StatefulWidget for ConfiguredAppWidget<'_> {
type State = ScrollViewState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State)
where
Self: Sized,
{
let height = self
.children()
.iter()
.map(HeightContraintLength::height_constraint_length)
.sum::<u16>();
let mut scroll = ScrollView::new(Size::new(area.width - 1, height));
let layout_forge_list = Layout::default()
.direction(Direction::Vertical)
.constraints(
self.children()
.iter()
.map(HeightContraintLength::height_constraint_length),
)
.split(scroll.area());
self.children()
.into_iter()
.enumerate()
.for_each(|(i, w)| scroll.render_widget(w, layout_forge_list[i]));
scroll.render(area, buf, state);
}
}
impl<'a> ConfiguredAppWidget<'a> {
fn children(&self) -> Vec<ForgeWidget<'a>> {
self.forges
.iter()
.map(|(forge_alias, state)| ForgeWidget {
forge_alias,
repos: &state.repos,
view_state: state.view_state,
})
.collect::<Vec<_>>()
}
}

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