This commit is contained in:
1 parent
d74db2096c
commit
8c763786c1
25 files changed
+3614
-407
No files matched your search
@@ -15,8 +15,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
cmake \
|
cmake \
|
||||||
pkg-config \
|
pkg-config \
|
||||||
clang \
|
clang \
|
||||||
valkey-server \
|
postgresql \
|
||||||
valkey-tools \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN groupadd --gid ${USER_GID:-1000} $USERNAME \
|
RUN groupadd --gid ${USER_GID:-1000} $USERNAME \
|
||||||
|
|||||||
@@ -22,3 +22,5 @@ METRICS_BIND_ADDR=
|
|||||||
# Sandboxed tool execution
|
# Sandboxed tool execution
|
||||||
# DOCKER_HOST=
|
# DOCKER_HOST=
|
||||||
SANDBOX_MAX_ITERATIONS=8
|
SANDBOX_MAX_ITERATIONS=8
|
||||||
|
|
||||||
|
DATABASE_URL=
|
||||||
+16
-1
@@ -2,6 +2,14 @@ when:
|
|||||||
event:
|
event:
|
||||||
- push
|
- push
|
||||||
|
|
||||||
|
services:
|
||||||
|
- name: postgres
|
||||||
|
image: postgres:18
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
POSTGRES_DB: steep
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: fmt
|
- name: fmt
|
||||||
image: rust:1.99
|
image: rust:1.99
|
||||||
@@ -15,10 +23,17 @@ steps:
|
|||||||
- rustup component add clippy
|
- rustup component add clippy
|
||||||
- cargo clippy --workspace --all-targets --all-features -- -D warnings
|
- cargo clippy --workspace --all-targets --all-features -- -D warnings
|
||||||
|
|
||||||
|
- name: wait-for-postgres
|
||||||
|
image: postgres:18
|
||||||
|
commands:
|
||||||
|
- until pg_isready -h postgres -U postgres; do sleep 1; done
|
||||||
|
|
||||||
- name: test
|
- name: test
|
||||||
image: rust:1.99
|
image: rust:1.99
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/steep
|
||||||
commands:
|
commands:
|
||||||
- cargo test --workspace --all-targets
|
- cargo test --workspace --all-targets -- --include-ignored
|
||||||
|
|
||||||
- name: container-build
|
- name: container-build
|
||||||
image: quay.io/buildah/stable
|
image: quay.io/buildah/stable
|
||||||
|
|||||||
Generated
+2761
-184
File diff suppressed because it is too large.
Load diff
+3
-3
@@ -28,9 +28,9 @@ hex = "0.4"
|
|||||||
bytes = "1.1"
|
bytes = "1.1"
|
||||||
metrics = "0.24"
|
metrics = "0.24"
|
||||||
metrics-exporter-prometheus = { version = "0.18", default-features = false, features = ["http-listener"] }
|
metrics-exporter-prometheus = { version = "0.18", default-features = false, features = ["http-listener"] }
|
||||||
apalis = { version = "0.7", features = ["catch-panic", "limit"] }
|
toasty = { version = "0.11", features = ["postgresql", "serde", "jiff"] }
|
||||||
apalis-redis = "0.7"
|
toasty-cli = "0.11"
|
||||||
redis = { version = "0.32", features = ["tokio-comp", "connection-manager"] }
|
jiff = "0.2"
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
debug = 1
|
debug = 1
|
||||||
+5
-7
@@ -10,10 +10,7 @@ RUN cargo build --release --package steep-server
|
|||||||
|
|
||||||
FROM debian:trixie-slim
|
FROM debian:trixie-slim
|
||||||
|
|
||||||
# git clones the pull request. ca-certificates is what every HTTPS call needs
|
|
||||||
# (Gitea, OpenRouter, git). The shared libraries are the non-base ones the binary
|
|
||||||
# links against, as reported by `ldd target/release/steep-server`; libc, libm
|
|
||||||
# and libgcc_s come from the base image.
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
git \
|
git \
|
||||||
@@ -26,6 +23,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=builder /app/target/release/steep-server ./steep-server
|
COPY --from=builder /app/target/release/steep-server ./steep-server
|
||||||
|
|
||||||
# Exec form, so the binary is PID 1 and receives the SIGTERM it handles to shut
|
COPY crates/steep-server/toasty ./toasty
|
||||||
# down gracefully.
|
ENV MIGRATIONS_DIR=/app/toasty
|
||||||
CMD ["./steep-server"]
|
|
||||||
|
CMD ["sh", "-c", "./steep-server migration apply && exec ./steep-server"]
|
||||||
@@ -38,7 +38,7 @@ Steep reads its configuration from environment variables (a `.env` file is suppo
|
|||||||
| `GITEA_URL` | Base URL of your Gitea instance |
|
| `GITEA_URL` | Base URL of your Gitea instance |
|
||||||
| `GITEA_TOKEN` | Gitea API token |
|
| `GITEA_TOKEN` | Gitea API token |
|
||||||
| `GITEA_TIMEOUT` | Gitea API request timeout in seconds |
|
| `GITEA_TIMEOUT` | Gitea API request timeout in seconds |
|
||||||
| `REDIS_URL` | Redis connection URL used for the durable review queue (e.g. `redis://127.0.0.1:6379`) |
|
| `DATABASE_URL` | PostgreSQL connection URL used for the durable review queue (e.g. `postgresql://user:pass@localhost:5432/steep`) |
|
||||||
| `METRICS_BIND_ADDR` | *(optional)* Bind address for the Prometheus metrics endpoint (e.g. `0.0.0.0:9100`). If unset, the metrics exporter is disabled. |
|
| `METRICS_BIND_ADDR` | *(optional)* Bind address for the Prometheus metrics endpoint (e.g. `0.0.0.0:9100`). If unset, the metrics exporter is disabled. |
|
||||||
| `SENTRY_DSN` | *(optional)* Sentry DSN for error tracking |
|
| `SENTRY_DSN` | *(optional)* Sentry DSN for error tracking |
|
||||||
| `RUST_LOG` | *(optional)* Log level, defaults to `info` |
|
| `RUST_LOG` | *(optional)* Log level, defaults to `info` |
|
||||||
@@ -102,6 +102,26 @@ hooks have run, the container is disconnected from the network for the rest of
|
|||||||
the review. Every container command is bounded by a timeout, and the container,
|
the review. Every container command is bounded by a timeout, and the container,
|
||||||
network and image are removed when the review ends (including on failure).
|
network and image are removed when the review ends (including on failure).
|
||||||
|
|
||||||
|
## Migrations
|
||||||
|
|
||||||
|
The database schema is versioned with [Toasty](https://github.com/tokio-rs/toasty) migrations, stored in `crates/steep-server/toasty/` (commit them).
|
||||||
|
|
||||||
|
After changing a model in `crates/steep-server/src/schemas/`, generate a migration:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run -p steep-server -- migration generate --name describe_change
|
||||||
|
```
|
||||||
|
|
||||||
|
Apply the pending migrations **before starting the service** — the server does not migrate at boot:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run -p steep-server -- migration apply
|
||||||
|
```
|
||||||
|
|
||||||
|
The `Containerfile` does this at startup: its entrypoint runs `steep-server migration apply` before exec'ing the server. The CLI reads the migration files from `MIGRATIONS_DIR` (defaults to the crate's `toasty/` directory; the image sets it to `/app/toasty`).
|
||||||
|
|
||||||
|
Other subcommands: `migration snapshot`, `migration drop`, `migration reset`. They read `DATABASE_URL`.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
The easiest way to get started is with the provided [Dev Container](https://containers.dev/) (VS Code or Zed with the dev container extension).
|
The easiest way to get started is with the provided [Dev Container](https://containers.dev/) (VS Code or Zed with the dev container extension).
|
||||||
@@ -129,7 +149,7 @@ Set `METRICS_BIND_ADDR` (e.g. `0.0.0.0:9100`) to enable it. The metrics are then
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `steep_webhooks_received_total` | counter | Total webhooks received (label: `event_type`) |
|
| `steep_webhooks_received_total` | counter | Total webhooks received (label: `event_type`) |
|
||||||
| `steep_webhooks_duplicate_total` | counter | Webhooks rejected as duplicates (label: `event_type`) |
|
| `steep_webhooks_duplicate_total` | counter | Webhooks rejected as duplicates (label: `event_type`) |
|
||||||
| `steep_webhooks_enqueue_failed_total` | counter | Webhooks dropped because enqueueing to Redis failed (label: `event_type`) |
|
| `steep_webhooks_enqueue_failed_total` | counter | Webhooks dropped because enqueueing to PostgreSQL failed (label: `event_type`) |
|
||||||
| `steep_bot_tasks_active` | gauge | Bot tasks currently in progress |
|
| `steep_bot_tasks_active` | gauge | Bot tasks currently in progress |
|
||||||
| `steep_bot_tasks_skipped_total` | counter | Reviews skipped because they were already done (label: `event_type`) |
|
| `steep_bot_tasks_skipped_total` | counter | Reviews skipped because they were already done (label: `event_type`) |
|
||||||
| `steep_bot_tasks_completed_total` | counter | Bot tasks completed successfully (label: `event_type`) |
|
| `steep_bot_tasks_completed_total` | counter | Bot tasks completed successfully (label: `event_type`) |
|
||||||
|
|||||||
@@ -26,6 +26,6 @@ metrics = { workspace = true }
|
|||||||
metrics-exporter-prometheus = { workspace = true }
|
metrics-exporter-prometheus = { workspace = true }
|
||||||
devcontainer-rs = { path = "../devcontainer-rs" }
|
devcontainer-rs = { path = "../devcontainer-rs" }
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
apalis = { workspace = true }
|
toasty = { workspace = true }
|
||||||
apalis-redis = { workspace = true }
|
toasty-cli = { workspace = true }
|
||||||
redis = { workspace = true }
|
jiff = { workspace = true }
|
||||||
@@ -14,10 +14,7 @@ use tracing::{info, instrument};
|
|||||||
|
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
use apalis::prelude::Storage;
|
|
||||||
|
|
||||||
use crate::consts::{GITEA_EVENT_TYPE_HEADER_NAME, GITEA_SIG_HEADER_NAME, MAX_WEBHOOK_BODY_SIZE};
|
use crate::consts::{GITEA_EVENT_TYPE_HEADER_NAME, GITEA_SIG_HEADER_NAME, MAX_WEBHOOK_BODY_SIZE};
|
||||||
use crate::dedup::{Dedup, WEBHOOK_TTL};
|
|
||||||
use crate::errors::AppError;
|
use crate::errors::AppError;
|
||||||
use crate::gitea::WebhookType;
|
use crate::gitea::WebhookType;
|
||||||
use crate::metrics;
|
use crate::metrics;
|
||||||
@@ -64,20 +61,19 @@ async fn webhook(
|
|||||||
metrics::webhook_received(event_type);
|
metrics::webhook_received(event_type);
|
||||||
|
|
||||||
let event_id = wb.event_id();
|
let event_id = wb.event_id();
|
||||||
let webhook_key = Dedup::webhook_key(event_id);
|
match app_state.dedup.is_known(event_id).await {
|
||||||
if !app_state
|
Ok(true) => {
|
||||||
.dedup
|
metrics::webhook_duplicate(event_type);
|
||||||
.claim(&webhook_key, WEBHOOK_TTL)
|
return Err(AppError::AlreadyProcessedErr);
|
||||||
.await
|
}
|
||||||
.map_err(anyhow::Error::from)?
|
Ok(false) => {}
|
||||||
{
|
Err(err) => {
|
||||||
metrics::webhook_duplicate(event_type);
|
metrics::webhook_enqueue_failed(event_type);
|
||||||
return Err(AppError::AlreadyProcessedErr);
|
return Err(AppError::EnqueueErr(anyhow::Error::from(err)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut storage = app_state.storage.clone();
|
if let Err(err) = app_state.queue.push(wb).await {
|
||||||
if let Err(err) = storage.push(wb).await {
|
|
||||||
app_state.dedup.release(&webhook_key).await.ok();
|
|
||||||
metrics::webhook_enqueue_failed(event_type);
|
metrics::webhook_enqueue_failed(event_type);
|
||||||
return Err(AppError::EnqueueErr(anyhow::Error::from(err)));
|
return Err(AppError::EnqueueErr(anyhow::Error::from(err)));
|
||||||
}
|
}
|
||||||
|
|||||||
+114
-64
@@ -1,15 +1,21 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
use serde::{Deserialize, Deserializer};
|
||||||
|
use tokio::sync::Semaphore;
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
use tracing::{error, info, instrument};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
dedup::{Dedup, REVIEW_TTL},
|
bot_actions::review::ReviewOutcome,
|
||||||
|
dedup::{Dedup, ReviewRecord},
|
||||||
gitea::{GiteaAPI, WebhookType},
|
gitea::{GiteaAPI, WebhookType},
|
||||||
|
job_queue::JobQueue,
|
||||||
metrics,
|
metrics,
|
||||||
open_router::OpenRouterClient,
|
open_router::OpenRouterClient,
|
||||||
sandbox::SandboxConfig,
|
sandbox::SandboxConfig,
|
||||||
|
schemas::Job,
|
||||||
};
|
};
|
||||||
use apalis::prelude::{Data, Error, Monitor, WorkerBuilder, WorkerBuilderExt, WorkerFactoryFn};
|
|
||||||
use apalis_redis::RedisStorage;
|
|
||||||
use serde::{Deserialize, Deserializer};
|
|
||||||
use tokio_util::sync::CancellationToken;
|
|
||||||
use tracing::{error, info, instrument};
|
|
||||||
|
|
||||||
#[derive(Deserialize, Debug)]
|
#[derive(Deserialize, Debug)]
|
||||||
pub struct ReviewResult {
|
pub struct ReviewResult {
|
||||||
@@ -127,6 +133,7 @@ pub struct Bot {
|
|||||||
open_router_model: String,
|
open_router_model: String,
|
||||||
sandbox: SandboxConfig,
|
sandbox: SandboxConfig,
|
||||||
dedup: Dedup,
|
dedup: Dedup,
|
||||||
|
queue: JobQueue,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Bot {
|
impl Bot {
|
||||||
@@ -137,6 +144,7 @@ impl Bot {
|
|||||||
open_router_model: String,
|
open_router_model: String,
|
||||||
sandbox: SandboxConfig,
|
sandbox: SandboxConfig,
|
||||||
dedup: Dedup,
|
dedup: Dedup,
|
||||||
|
queue: JobQueue,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
gitea_api,
|
gitea_api,
|
||||||
@@ -145,40 +153,119 @@ impl Bot {
|
|||||||
open_router_model,
|
open_router_model,
|
||||||
sandbox,
|
sandbox,
|
||||||
dedup,
|
dedup,
|
||||||
|
queue,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Consomme la file Redis jusqu'à l'annulation de `shutdown`.
|
/// Consomme la file jusqu'à l'annulation de `shutdown`.
|
||||||
///
|
///
|
||||||
/// Les jobs en cours sont laissés se terminer, et ceux qui n'ont pas encore
|
/// Un sémaphore borne la concurrence ; les jobs en cours sont laissés se
|
||||||
/// été acquittés restent dans Redis : ils seront repris au prochain démarrage.
|
/// terminer. Les jobs non terminés restent en base et seront repris au
|
||||||
pub async fn start(
|
/// prochain démarrage.
|
||||||
&self,
|
pub async fn start(&self, shutdown: CancellationToken) -> anyhow::Result<()> {
|
||||||
storage: RedisStorage<WebhookType>,
|
|
||||||
shutdown: CancellationToken,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
info!("Bot started");
|
info!("Bot started");
|
||||||
|
|
||||||
let worker = WorkerBuilder::new("steep-review")
|
let semaphore = Arc::new(Semaphore::new(self.max_concurrent));
|
||||||
.concurrency(self.max_concurrent)
|
let mut tasks = tokio::task::JoinSet::new();
|
||||||
.data(self.clone())
|
let mut pending = std::pin::pin!(self.queue.pending());
|
||||||
.backend(storage)
|
|
||||||
.build_fn(handle_review);
|
|
||||||
|
|
||||||
Monitor::new()
|
loop {
|
||||||
.register(worker)
|
let job = tokio::select! {
|
||||||
.run_with_signal(async move {
|
biased;
|
||||||
shutdown.cancelled().await;
|
_ = shutdown.cancelled() => break,
|
||||||
Ok(())
|
job = pending.next() => match job {
|
||||||
})
|
Some(job) => job,
|
||||||
.await?;
|
None => break,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
while let Some(res) = tasks.try_join_next() {
|
||||||
|
if let Err(err) = res {
|
||||||
|
error!("Task panicked: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let permit = semaphore.clone().acquire_owned().await?;
|
||||||
|
|
||||||
|
if let Err(err) = self.queue.mark_processing(job.id).await {
|
||||||
|
error!(%err, "failed to mark job as processing");
|
||||||
|
}
|
||||||
|
|
||||||
|
let bot = self.clone();
|
||||||
|
metrics::increment_task_active();
|
||||||
|
|
||||||
|
tasks.spawn(async move {
|
||||||
|
bot.process(job).await;
|
||||||
|
drop(permit);
|
||||||
|
metrics::decrement_task_active();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.join_all().await;
|
||||||
|
|
||||||
info!("Bot shutting down complete");
|
info!("Bot shutting down complete");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn process(&self, job: Job) {
|
||||||
|
let event_type = job.payload.event_type_str();
|
||||||
|
|
||||||
|
match self.dedup.is_reviewed(job.event_id).await {
|
||||||
|
Ok(true) => {
|
||||||
|
metrics::task_skipped(event_type);
|
||||||
|
info!("Review already done, skipping");
|
||||||
|
|
||||||
|
if let Err(err) = self.queue.remove(job.id).await {
|
||||||
|
error!(%err, "failed to remove job");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Ok(false) => {}
|
||||||
|
Err(err) => error!(%err, "Dedup lookup failed, reviewing anyway"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload = job.payload.0.clone();
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
let result = self.exec(payload).await;
|
||||||
|
let duration_ms = started.elapsed().as_millis() as u64;
|
||||||
|
|
||||||
|
let (ok, outcome) = match result {
|
||||||
|
Ok(outcome) => {
|
||||||
|
metrics::task_completed(event_type);
|
||||||
|
info!("Task completed");
|
||||||
|
(true, outcome)
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
metrics::task_failed(event_type);
|
||||||
|
error!(%err, "Task error");
|
||||||
|
sentry_anyhow::capture_anyhow(&err);
|
||||||
|
(false, ReviewOutcome::empty())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let record = ReviewRecord {
|
||||||
|
event_id: job.event_id,
|
||||||
|
repo: job.repo.clone(),
|
||||||
|
pr: job.pr,
|
||||||
|
ok,
|
||||||
|
model: self.open_router_model.clone(),
|
||||||
|
markdown: outcome.markdown,
|
||||||
|
cost: outcome.cost,
|
||||||
|
comments: outcome.comments as u64,
|
||||||
|
duration_ms,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(err) = self.dedup.mark_reviewed(record).await {
|
||||||
|
error!(%err, "failed to record review");
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(err) = self.queue.remove(job.id).await {
|
||||||
|
error!(%err, "failed to remove job");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[instrument(skip(self, webhook), fields(repo, pr))]
|
#[instrument(skip(self, webhook), fields(repo, pr))]
|
||||||
pub async fn exec(&self, webhook: WebhookType) -> anyhow::Result<()> {
|
pub async fn exec(&self, webhook: WebhookType) -> anyhow::Result<ReviewOutcome> {
|
||||||
match &webhook {
|
match &webhook {
|
||||||
WebhookType::Review(p) => {
|
WebhookType::Review(p) => {
|
||||||
tracing::Span::current().record("repo", &p.repository.full_name);
|
tracing::Span::current().record("repo", &p.repository.full_name);
|
||||||
@@ -203,40 +290,3 @@ impl Bot {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_review(webhook: WebhookType, bot: Data<Bot>) -> Result<(), Error> {
|
|
||||||
let event_type = webhook.event_type_str();
|
|
||||||
let review_key = Dedup::review_key(webhook.event_id());
|
|
||||||
|
|
||||||
match bot.dedup.is_marked(&review_key).await {
|
|
||||||
Ok(true) => {
|
|
||||||
metrics::task_skipped(event_type);
|
|
||||||
info!("Review already done, skipping");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
Ok(false) => {}
|
|
||||||
Err(err) => error!(%err, "Dedup lookup failed, reviewing anyway"),
|
|
||||||
}
|
|
||||||
|
|
||||||
metrics::increment_task_active();
|
|
||||||
let result = bot.exec(webhook).await;
|
|
||||||
metrics::decrement_task_active();
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(()) => {
|
|
||||||
metrics::task_completed(event_type);
|
|
||||||
info!("Task completed");
|
|
||||||
|
|
||||||
if let Err(err) = bot.dedup.mark(&review_key, REVIEW_TTL).await {
|
|
||||||
error!(%err, "Failed to mark review as done");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
metrics::task_failed(event_type);
|
|
||||||
error!(%err, "Task error");
|
|
||||||
sentry_anyhow::capture_anyhow(&err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -12,6 +12,22 @@ use crate::{
|
|||||||
text::excerpt,
|
text::excerpt,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pub struct ReviewOutcome {
|
||||||
|
pub markdown: String,
|
||||||
|
pub cost: Option<f64>,
|
||||||
|
pub comments: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReviewOutcome {
|
||||||
|
pub fn empty() -> Self {
|
||||||
|
Self {
|
||||||
|
markdown: String::new(),
|
||||||
|
cost: None,
|
||||||
|
comments: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[instrument(skip(gitea_api, open_router_client, sandbox_config, tools, review_payload))]
|
#[instrument(skip(gitea_api, open_router_client, sandbox_config, tools, review_payload))]
|
||||||
pub async fn exec_review(
|
pub async fn exec_review(
|
||||||
gitea_api: &GiteaAPI,
|
gitea_api: &GiteaAPI,
|
||||||
@@ -20,7 +36,7 @@ pub async fn exec_review(
|
|||||||
sandbox_config: &SandboxConfig,
|
sandbox_config: &SandboxConfig,
|
||||||
tools: Vec<Tool>,
|
tools: Vec<Tool>,
|
||||||
review_payload: ReviewPayload,
|
review_payload: ReviewPayload,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<ReviewOutcome> {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
repo = %review_payload.repository.full_name,
|
repo = %review_payload.repository.full_name,
|
||||||
pr = review_payload.pull_request.number,
|
pr = review_payload.pull_request.number,
|
||||||
@@ -36,7 +52,7 @@ pub async fn exec_review(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let bot_result: Result<ReviewResult, anyhow::Error> = async {
|
let bot_result: Result<(ReviewResult, String), anyhow::Error> = async {
|
||||||
let full_name = &review_payload.repository.full_name;
|
let full_name = &review_payload.repository.full_name;
|
||||||
let index = review_payload.pull_request.number;
|
let index = review_payload.pull_request.number;
|
||||||
|
|
||||||
@@ -91,15 +107,21 @@ pub async fn exec_review(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(review_result)
|
Ok((review_result, final_review_markdown))
|
||||||
}
|
}
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
match bot_result {
|
match bot_result {
|
||||||
Ok(_) => {
|
Ok((review_result, markdown)) => {
|
||||||
gitea_api
|
gitea_api
|
||||||
.delete_comment(&review_payload.repository.full_name, new_comment.id)
|
.delete_comment(&review_payload.repository.full_name, new_comment.id)
|
||||||
.await
|
.await?;
|
||||||
|
|
||||||
|
Ok(ReviewOutcome {
|
||||||
|
markdown,
|
||||||
|
cost: review_result.cost,
|
||||||
|
comments: review_result.reviews.len(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
gitea_api
|
gitea_api
|
||||||
@@ -108,17 +130,13 @@ pub async fn exec_review(
|
|||||||
&review_payload.repository.full_name,
|
&review_payload.repository.full_name,
|
||||||
new_comment.id,
|
new_comment.id,
|
||||||
)
|
)
|
||||||
.await
|
.await?;
|
||||||
|
|
||||||
|
Err(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runs the review inside a sandbox container, letting the model explore the
|
|
||||||
/// repository with tools before answering.
|
|
||||||
///
|
|
||||||
/// The answer of the model is parsed by [`ReviewResult::from_str`], which the
|
|
||||||
/// agent loop enforces: an answer that is not a review is sent back to the model
|
|
||||||
/// for correction.
|
|
||||||
async fn run_sandboxed_review(
|
async fn run_sandboxed_review(
|
||||||
gitea_api: &GiteaAPI,
|
gitea_api: &GiteaAPI,
|
||||||
open_router_client: &OpenRouterClient,
|
open_router_client: &OpenRouterClient,
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
use toasty::Db;
|
||||||
|
|
||||||
|
use crate::schemas::{Job, JobStatus};
|
||||||
|
|
||||||
|
pub async fn open(url: &str) -> toasty::Result<Db> {
|
||||||
|
Db::builder()
|
||||||
|
.models(toasty::models!(crate::*))
|
||||||
|
.connect(url)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn connect(url: &str) -> toasty::Result<Db> {
|
||||||
|
let mut db = open(url).await?;
|
||||||
|
|
||||||
|
Job::update_by_status(JobStatus::Processing)
|
||||||
|
.status(JobStatus::Pending)
|
||||||
|
.exec(&mut db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) async fn test_connect() -> Db {
|
||||||
|
static DB: tokio::sync::OnceCell<Db> = tokio::sync::OnceCell::const_new();
|
||||||
|
|
||||||
|
DB.get_or_init(|| async {
|
||||||
|
let url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
||||||
|
|
||||||
|
let migrations = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("toasty");
|
||||||
|
let config = toasty_cli::Config::new()
|
||||||
|
.migration(toasty_cli::MigrationConfig::new().path(migrations));
|
||||||
|
|
||||||
|
toasty_cli::ToastyCli::with_config(open(&url).await.expect("open pool"), config)
|
||||||
|
.parse_from(["toasty", "migration", "apply"])
|
||||||
|
.await
|
||||||
|
.expect("apply migrations");
|
||||||
|
|
||||||
|
connect(&url).await.expect("connect to postgres")
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
@@ -1,66 +1,71 @@
|
|||||||
use std::time::Duration;
|
use toasty::Db;
|
||||||
|
|
||||||
use redis::aio::ConnectionManager;
|
use crate::schemas::{Job, Review};
|
||||||
|
|
||||||
pub const WEBHOOK_TTL: Duration = Duration::from_secs(24 * 60 * 60);
|
pub struct ReviewRecord {
|
||||||
pub const REVIEW_TTL: Duration = Duration::from_secs(7 * 24 * 60 * 60);
|
pub event_id: u64,
|
||||||
|
pub repo: String,
|
||||||
|
pub pr: u64,
|
||||||
|
pub ok: bool,
|
||||||
|
pub model: String,
|
||||||
|
pub markdown: String,
|
||||||
|
pub cost: Option<f64>,
|
||||||
|
pub comments: u64,
|
||||||
|
pub duration_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct Dedup {
|
pub struct Dedup {
|
||||||
conn: ConnectionManager,
|
db: Db,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Dedup {
|
impl Dedup {
|
||||||
pub fn new(conn: ConnectionManager) -> Self {
|
pub fn new(db: Db) -> Self {
|
||||||
Self { conn }
|
Self { db }
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn webhook_key(event_id: u64) -> String {
|
pub async fn is_known(&self, event_id: u64) -> toasty::Result<bool> {
|
||||||
format!("steep:webhook:{event_id}")
|
let mut db = self.db.clone();
|
||||||
|
|
||||||
|
if Job::filter_by_event_id(event_id)
|
||||||
|
.first()
|
||||||
|
.exec(&mut db)
|
||||||
|
.await?
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Review::filter_by_event_id(event_id)
|
||||||
|
.first()
|
||||||
|
.exec(&mut db)
|
||||||
|
.await?
|
||||||
|
.is_some())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn review_key(event_id: u64) -> String {
|
pub async fn is_reviewed(&self, event_id: u64) -> toasty::Result<bool> {
|
||||||
format!("steep:review:{event_id}")
|
let mut db = self.db.clone();
|
||||||
|
|
||||||
|
Ok(Review::filter_by_event_id(event_id)
|
||||||
|
.first()
|
||||||
|
.exec(&mut db)
|
||||||
|
.await?
|
||||||
|
.is_some())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn claim(&self, key: &str, ttl: Duration) -> redis::RedisResult<bool> {
|
pub async fn mark_reviewed(&self, record: ReviewRecord) -> toasty::Result<()> {
|
||||||
let set: Option<String> = redis::cmd("SET")
|
let mut db = self.db.clone();
|
||||||
.arg(key)
|
|
||||||
.arg(1)
|
|
||||||
.arg("NX")
|
|
||||||
.arg("EX")
|
|
||||||
.arg(ttl.as_secs())
|
|
||||||
.query_async(&mut self.conn.clone())
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(set.is_some())
|
Review::upsert_by_event_id(record.event_id)
|
||||||
}
|
.repo(record.repo)
|
||||||
|
.pr(record.pr)
|
||||||
pub async fn mark(&self, key: &str, ttl: Duration) -> redis::RedisResult<()> {
|
.ok(record.ok)
|
||||||
let _: String = redis::cmd("SET")
|
.model(record.model)
|
||||||
.arg(key)
|
.markdown(record.markdown)
|
||||||
.arg(1)
|
.cost(record.cost)
|
||||||
.arg("EX")
|
.comments(record.comments)
|
||||||
.arg(ttl.as_secs())
|
.duration_ms(record.duration_ms)
|
||||||
.query_async(&mut self.conn.clone())
|
.exec(&mut db)
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn is_marked(&self, key: &str) -> redis::RedisResult<bool> {
|
|
||||||
let exists: i64 = redis::cmd("EXISTS")
|
|
||||||
.arg(key)
|
|
||||||
.query_async(&mut self.conn.clone())
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(exists > 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn release(&self, key: &str) -> redis::RedisResult<()> {
|
|
||||||
let _: i64 = redis::cmd("DEL")
|
|
||||||
.arg(key)
|
|
||||||
.query_async(&mut self.conn.clone())
|
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -70,51 +75,42 @@ impl Dedup {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::schemas::Review;
|
||||||
#[test]
|
|
||||||
fn webhook_key_is_namespaced() {
|
|
||||||
assert_eq!(Dedup::webhook_key(42), "steep:webhook:42");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn review_key_is_namespaced() {
|
|
||||||
assert_eq!(Dedup::review_key(42), "steep:review:42");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn the_two_keys_for_the_same_event_differ() {
|
|
||||||
assert_ne!(Dedup::webhook_key(7), Dedup::review_key(7));
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn redis_dedup() -> Dedup {
|
|
||||||
let url = std::env::var("REDIS_URL").expect("REDIS_URL must be set");
|
|
||||||
Dedup::new(apalis_redis::connect(url).await.expect("connect to redis"))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[ignore = "requires a running Redis (REDIS_URL)"]
|
#[ignore = "requires DATABASE_URL"]
|
||||||
async fn claim_then_claim_is_a_duplicate() {
|
async fn a_recorded_review_is_known_and_reviewed() {
|
||||||
let dedup = redis_dedup().await;
|
let db = crate::db::test_connect().await;
|
||||||
let key = "steep:test:claim";
|
let dedup = Dedup::new(db.clone());
|
||||||
dedup.release(key).await.unwrap();
|
|
||||||
|
|
||||||
assert!(dedup.claim(key, Duration::from_secs(30)).await.unwrap());
|
let event_id = 999_000_101;
|
||||||
assert!(!dedup.claim(key, Duration::from_secs(30)).await.unwrap());
|
Review::delete_by_event_id(&mut db.clone(), event_id)
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
|
|
||||||
dedup.release(key).await.unwrap();
|
assert!(!dedup.is_known(event_id).await.unwrap());
|
||||||
}
|
assert!(!dedup.is_reviewed(event_id).await.unwrap());
|
||||||
|
|
||||||
#[tokio::test]
|
dedup
|
||||||
#[ignore = "requires a running Redis (REDIS_URL)"]
|
.mark_reviewed(ReviewRecord {
|
||||||
async fn mark_then_is_marked() {
|
event_id,
|
||||||
let dedup = redis_dedup().await;
|
repo: "owner/repo".to_string(),
|
||||||
let key = "steep:test:mark";
|
pr: 1,
|
||||||
dedup.release(key).await.unwrap();
|
ok: true,
|
||||||
|
model: "test/model".to_string(),
|
||||||
|
markdown: "looks good".to_string(),
|
||||||
|
cost: Some(0.01),
|
||||||
|
comments: 2,
|
||||||
|
duration_ms: 5,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
assert!(!dedup.is_marked(key).await.unwrap());
|
assert!(dedup.is_reviewed(event_id).await.unwrap());
|
||||||
dedup.mark(key, Duration::from_secs(30)).await.unwrap();
|
assert!(dedup.is_known(event_id).await.unwrap());
|
||||||
assert!(dedup.is_marked(key).await.unwrap());
|
|
||||||
|
|
||||||
dedup.release(key).await.unwrap();
|
Review::delete_by_event_id(&mut db.clone(), event_id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -11,7 +11,7 @@ pub struct EnvConfig {
|
|||||||
pub gitea_url: String,
|
pub gitea_url: String,
|
||||||
pub gitea_token: String,
|
pub gitea_token: String,
|
||||||
pub gitea_timeout: u64,
|
pub gitea_timeout: u64,
|
||||||
pub redis_url: String,
|
pub database_url: String,
|
||||||
pub metrics_bind_addr: Option<String>,
|
pub metrics_bind_addr: Option<String>,
|
||||||
pub sandbox_max_iterations: usize,
|
pub sandbox_max_iterations: usize,
|
||||||
}
|
}
|
||||||
@@ -26,7 +26,7 @@ pub fn load_config() -> anyhow::Result<EnvConfig> {
|
|||||||
let gitea_url = try_get_env("GITEA_URL")?;
|
let gitea_url = try_get_env("GITEA_URL")?;
|
||||||
let gitea_token = try_get_env("GITEA_TOKEN")?;
|
let gitea_token = try_get_env("GITEA_TOKEN")?;
|
||||||
let gitea_timeout = try_get_env("GITEA_TIMEOUT")?.parse()?;
|
let gitea_timeout = try_get_env("GITEA_TIMEOUT")?.parse()?;
|
||||||
let redis_url = try_get_env("REDIS_URL")?;
|
let database_url = try_get_env("DATABASE_URL")?;
|
||||||
let metrics_bind_addr = std::env::var("METRICS_BIND_ADDR").ok();
|
let metrics_bind_addr = std::env::var("METRICS_BIND_ADDR").ok();
|
||||||
let sandbox_max_iterations = std::env::var("SANDBOX_MAX_ITERATIONS")
|
let sandbox_max_iterations = std::env::var("SANDBOX_MAX_ITERATIONS")
|
||||||
.ok()
|
.ok()
|
||||||
@@ -43,7 +43,7 @@ pub fn load_config() -> anyhow::Result<EnvConfig> {
|
|||||||
gitea_url,
|
gitea_url,
|
||||||
gitea_token,
|
gitea_token,
|
||||||
gitea_timeout,
|
gitea_timeout,
|
||||||
redis_url,
|
database_url,
|
||||||
metrics_bind_addr,
|
metrics_bind_addr,
|
||||||
sandbox_max_iterations,
|
sandbox_max_iterations,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -281,7 +281,7 @@ async fn read_capped(response: reqwest::Response) -> anyhow::Result<String> {
|
|||||||
Ok(String::from_utf8_lossy(&buf).into_owned())
|
Ok(String::from_utf8_lossy(&buf).into_owned())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
pub enum WebhookType {
|
pub enum WebhookType {
|
||||||
Review(ReviewPayload),
|
Review(ReviewPayload),
|
||||||
}
|
}
|
||||||
@@ -298,9 +298,21 @@ impl WebhookType {
|
|||||||
WebhookType::Review(_) => "review",
|
WebhookType::Review(_) => "review",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn repository_full_name(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
WebhookType::Review(payload) => &payload.repository.full_name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pull_request_number(&self) -> u64 {
|
||||||
|
match self {
|
||||||
|
WebhookType::Review(payload) => payload.pull_request.number,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize, Debug)]
|
#[derive(Deserialize, Serialize, Debug, Clone)]
|
||||||
pub struct ReviewPayload {
|
pub struct ReviewPayload {
|
||||||
pub action: String,
|
pub action: String,
|
||||||
pub pull_request: PullRequest,
|
pub pull_request: PullRequest,
|
||||||
@@ -308,13 +320,13 @@ pub struct ReviewPayload {
|
|||||||
pub comment: Comment,
|
pub comment: Comment,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize, Debug)]
|
#[derive(Deserialize, Serialize, Debug, Clone)]
|
||||||
pub struct PullRequest {
|
pub struct PullRequest {
|
||||||
pub number: u64,
|
pub number: u64,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize, Debug)]
|
#[derive(Deserialize, Serialize, Debug, Clone)]
|
||||||
pub struct Comment {
|
pub struct Comment {
|
||||||
pub id: u64,
|
pub id: u64,
|
||||||
pub body: String,
|
pub body: String,
|
||||||
@@ -325,7 +337,7 @@ pub struct User {
|
|||||||
pub login: String,
|
pub login: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize, Debug)]
|
#[derive(Deserialize, Serialize, Debug, Clone)]
|
||||||
pub struct Repository {
|
pub struct Repository {
|
||||||
pub full_name: String,
|
pub full_name: String,
|
||||||
pub clone_url: String,
|
pub clone_url: String,
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
use std::{collections::VecDeque, time::Duration};
|
||||||
|
|
||||||
|
use futures_util::stream::{self, Stream};
|
||||||
|
use toasty::Db;
|
||||||
|
use tracing::error;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
gitea::WebhookType,
|
||||||
|
schemas::{Job, JobStatus},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Délai entre deux interrogations de la table quand la file est vide.
|
||||||
|
const POLL_INTERVAL: Duration = Duration::from_secs(1);
|
||||||
|
|
||||||
|
/// File des reviews à faire.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct JobQueue {
|
||||||
|
db: Db,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JobQueue {
|
||||||
|
pub fn new(db: Db) -> Self {
|
||||||
|
Self { db }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enfile une nouvelle review.
|
||||||
|
///
|
||||||
|
/// L'unicité de `event_id` fait échouer l'insertion d'un doublon.
|
||||||
|
pub async fn push(&self, webhook: WebhookType) -> toasty::Result<()> {
|
||||||
|
let event_id = webhook.event_id();
|
||||||
|
let repo = webhook.repository_full_name().to_string();
|
||||||
|
let pr = webhook.pull_request_number();
|
||||||
|
|
||||||
|
let mut db = self.db.clone();
|
||||||
|
|
||||||
|
toasty::create!(Job {
|
||||||
|
event_id,
|
||||||
|
repo,
|
||||||
|
pr,
|
||||||
|
payload: webhook,
|
||||||
|
status: JobStatus::Pending,
|
||||||
|
})
|
||||||
|
.exec(&mut db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flux des jobs en attente.
|
||||||
|
///
|
||||||
|
/// C'est un `Stream` (et non un `Iterator`) : l'accès à la base est
|
||||||
|
/// asynchrone. Le flux interroge la table en boucle et met en tampon les
|
||||||
|
/// jobs trouvés.
|
||||||
|
pub fn pending(&self) -> impl Stream<Item = Job> {
|
||||||
|
let db = self.db.clone();
|
||||||
|
|
||||||
|
stream::unfold(VecDeque::new(), move |mut buffer| {
|
||||||
|
let db = db.clone();
|
||||||
|
|
||||||
|
async move {
|
||||||
|
let mut db = db;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if let Some(job) = buffer.pop_front() {
|
||||||
|
return Some((job, buffer));
|
||||||
|
}
|
||||||
|
|
||||||
|
match Job::filter_by_status(JobStatus::Pending)
|
||||||
|
.exec(&mut db)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(jobs) if jobs.is_empty() => {
|
||||||
|
tokio::time::sleep(POLL_INTERVAL).await;
|
||||||
|
}
|
||||||
|
Ok(jobs) => buffer.extend(jobs),
|
||||||
|
Err(err) => {
|
||||||
|
error!(%err, "failed to poll pending jobs");
|
||||||
|
tokio::time::sleep(POLL_INTERVAL).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marque un job comme en cours de traitement.
|
||||||
|
pub async fn mark_processing(&self, id: u64) -> toasty::Result<()> {
|
||||||
|
let mut db = self.db.clone();
|
||||||
|
|
||||||
|
Job::update_by_id(id)
|
||||||
|
.status(JobStatus::Processing)
|
||||||
|
.exec(&mut db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retire un job de la file, une fois la review enregistrée.
|
||||||
|
pub async fn remove(&self, id: u64) -> toasty::Result<()> {
|
||||||
|
let mut db = self.db.clone();
|
||||||
|
|
||||||
|
Job::delete_by_id(&mut db, id).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::gitea::{Comment, PullRequest, Repository, ReviewPayload};
|
||||||
|
|
||||||
|
fn webhook(event_id: u64) -> WebhookType {
|
||||||
|
WebhookType::Review(ReviewPayload {
|
||||||
|
action: "created".to_string(),
|
||||||
|
pull_request: PullRequest {
|
||||||
|
number: 1,
|
||||||
|
title: "Test".to_string(),
|
||||||
|
},
|
||||||
|
repository: Repository {
|
||||||
|
full_name: "owner/repo".to_string(),
|
||||||
|
clone_url: "https://example.com/owner/repo.git".to_string(),
|
||||||
|
},
|
||||||
|
comment: Comment {
|
||||||
|
id: event_id,
|
||||||
|
body: "@bot review".to_string(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn next_job(queue: &JobQueue, event_id: u64) -> Job {
|
||||||
|
let mut pending = std::pin::pin!(queue.pending());
|
||||||
|
|
||||||
|
tokio::time::timeout(Duration::from_secs(10), async {
|
||||||
|
loop {
|
||||||
|
if let Some(job) = pending.next().await
|
||||||
|
&& job.event_id == event_id
|
||||||
|
{
|
||||||
|
break job;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("timed out waiting for the job")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "requires DATABASE_URL"]
|
||||||
|
async fn a_pushed_job_is_pending_then_removed() {
|
||||||
|
let db = crate::db::test_connect().await;
|
||||||
|
let queue = JobQueue::new(db.clone());
|
||||||
|
|
||||||
|
let event_id = 999_000_201;
|
||||||
|
Job::delete_by_event_id(&mut db.clone(), event_id)
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
queue.push(webhook(event_id)).await.unwrap();
|
||||||
|
|
||||||
|
let job = next_job(&queue, event_id).await;
|
||||||
|
queue.mark_processing(job.id).await.unwrap();
|
||||||
|
queue.remove(job.id).await.unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
Job::filter_by_event_id(event_id)
|
||||||
|
.first()
|
||||||
|
.exec(&mut db.clone())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "requires DATABASE_URL"]
|
||||||
|
async fn a_duplicate_event_id_is_rejected() {
|
||||||
|
let db = crate::db::test_connect().await;
|
||||||
|
let queue = JobQueue::new(db.clone());
|
||||||
|
|
||||||
|
let event_id = 999_000_202;
|
||||||
|
Job::delete_by_event_id(&mut db.clone(), event_id)
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
queue.push(webhook(event_id)).await.unwrap();
|
||||||
|
assert!(queue.push(webhook(event_id)).await.is_err());
|
||||||
|
|
||||||
|
Job::delete_by_event_id(&mut db.clone(), event_id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,8 @@
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
bot::Bot,
|
bot::Bot, dedup::Dedup, gitea::GiteaAPI, job_queue::JobQueue, open_router::OpenRouterClient,
|
||||||
dedup::Dedup,
|
sandbox::SandboxConfig, state::AppState,
|
||||||
gitea::{GiteaAPI, WebhookType},
|
|
||||||
open_router::OpenRouterClient,
|
|
||||||
sandbox::SandboxConfig,
|
|
||||||
state::AppState,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use dotenvy::dotenv;
|
use dotenvy::dotenv;
|
||||||
@@ -19,13 +15,16 @@ mod api;
|
|||||||
mod bot;
|
mod bot;
|
||||||
mod bot_actions;
|
mod bot_actions;
|
||||||
mod consts;
|
mod consts;
|
||||||
|
mod db;
|
||||||
mod dedup;
|
mod dedup;
|
||||||
mod env;
|
mod env;
|
||||||
mod errors;
|
mod errors;
|
||||||
mod gitea;
|
mod gitea;
|
||||||
|
mod job_queue;
|
||||||
mod metrics;
|
mod metrics;
|
||||||
mod open_router;
|
mod open_router;
|
||||||
mod sandbox;
|
mod sandbox;
|
||||||
|
mod schemas;
|
||||||
mod state;
|
mod state;
|
||||||
mod text;
|
mod text;
|
||||||
|
|
||||||
@@ -38,6 +37,11 @@ const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(60);
|
|||||||
fn main() -> anyhow::Result<()> {
|
fn main() -> anyhow::Result<()> {
|
||||||
dotenv().ok();
|
dotenv().ok();
|
||||||
|
|
||||||
|
// Sous-commande de migration : `steep-server migration <generate|apply|...>`.
|
||||||
|
if std::env::args().nth(1).as_deref() == Some("migration") {
|
||||||
|
return tokio::runtime::Runtime::new()?.block_on(run_migrations());
|
||||||
|
}
|
||||||
|
|
||||||
tracing_subscriber::registry()
|
tracing_subscriber::registry()
|
||||||
.with(fmt::layer())
|
.with(fmt::layer())
|
||||||
.with(
|
.with(
|
||||||
@@ -63,6 +67,24 @@ fn main() -> anyhow::Result<()> {
|
|||||||
tokio::runtime::Runtime::new()?.block_on(run())
|
tokio::runtime::Runtime::new()?.block_on(run())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn run_migrations() -> anyhow::Result<()> {
|
||||||
|
let database_url = env::try_get_env("DATABASE_URL")?;
|
||||||
|
let db = db::open(&database_url).await?;
|
||||||
|
|
||||||
|
let migrations = match std::env::var("MIGRATIONS_DIR") {
|
||||||
|
Ok(dir) => std::path::PathBuf::from(dir),
|
||||||
|
Err(_) => std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("toasty"),
|
||||||
|
};
|
||||||
|
let config =
|
||||||
|
toasty_cli::Config::new().migration(toasty_cli::MigrationConfig::new().path(migrations));
|
||||||
|
|
||||||
|
toasty_cli::ToastyCli::with_config(db, config)
|
||||||
|
.parse_and_run()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn run() -> anyhow::Result<()> {
|
async fn run() -> anyhow::Result<()> {
|
||||||
let config = env::load_config()?;
|
let config = env::load_config()?;
|
||||||
|
|
||||||
@@ -101,10 +123,9 @@ async fn run() -> anyhow::Result<()> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let redis_conn = apalis_redis::connect(config.redis_url.clone()).await?;
|
let db = db::connect(&config.database_url).await?;
|
||||||
let storage: apalis_redis::RedisStorage<WebhookType> =
|
let dedup = Dedup::new(db.clone());
|
||||||
apalis_redis::RedisStorage::new(redis_conn.clone());
|
let queue = JobQueue::new(db);
|
||||||
let dedup = Dedup::new(redis_conn);
|
|
||||||
|
|
||||||
let bot = Bot::new(
|
let bot = Bot::new(
|
||||||
gitea_api,
|
gitea_api,
|
||||||
@@ -113,12 +134,13 @@ async fn run() -> anyhow::Result<()> {
|
|||||||
config.open_router_model.clone(),
|
config.open_router_model.clone(),
|
||||||
sandbox,
|
sandbox,
|
||||||
dedup.clone(),
|
dedup.clone(),
|
||||||
|
queue.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
let app_state = AppState {
|
let app_state = AppState {
|
||||||
storage: storage.clone(),
|
queue,
|
||||||
bot_name: gitea_user.login.clone(),
|
|
||||||
dedup,
|
dedup,
|
||||||
|
bot_name: gitea_user.login.clone(),
|
||||||
config,
|
config,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -143,7 +165,7 @@ async fn run() -> anyhow::Result<()> {
|
|||||||
tokio::select! {
|
tokio::select! {
|
||||||
result = async {
|
result = async {
|
||||||
tokio::try_join!(
|
tokio::try_join!(
|
||||||
bot.start(storage, shutdown.clone()),
|
bot.start(shutdown.clone()),
|
||||||
api::start(app_state, shutdown.clone()),
|
api::start(app_state, shutdown.clone()),
|
||||||
signal
|
signal
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ pub fn describe() {
|
|||||||
describe_counter!(
|
describe_counter!(
|
||||||
"steep_webhooks_enqueue_failed_total",
|
"steep_webhooks_enqueue_failed_total",
|
||||||
Unit::Count,
|
Unit::Count,
|
||||||
"Webhooks dropped because enqueueing to Redis failed"
|
"Webhooks dropped because enqueueing to PostgreSQL failed"
|
||||||
);
|
);
|
||||||
describe_gauge!(
|
describe_gauge!(
|
||||||
"steep_bot_tasks_active",
|
"steep_bot_tasks_active",
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
use jiff::Timestamp;
|
||||||
|
|
||||||
|
use crate::gitea::WebhookType;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, toasty::Embed)]
|
||||||
|
pub enum JobStatus {
|
||||||
|
Pending,
|
||||||
|
Processing,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, toasty::Model)]
|
||||||
|
pub struct Job {
|
||||||
|
#[key]
|
||||||
|
#[auto]
|
||||||
|
pub id: u64,
|
||||||
|
|
||||||
|
#[unique]
|
||||||
|
pub event_id: u64,
|
||||||
|
|
||||||
|
pub repo: String,
|
||||||
|
pub pr: u64,
|
||||||
|
|
||||||
|
#[column(type = jsonb)]
|
||||||
|
pub payload: toasty::Json<WebhookType>,
|
||||||
|
|
||||||
|
#[index]
|
||||||
|
pub status: JobStatus,
|
||||||
|
|
||||||
|
#[auto]
|
||||||
|
pub created_at: Timestamp,
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
pub mod job;
|
||||||
|
pub mod review;
|
||||||
|
|
||||||
|
pub use job::{Job, JobStatus};
|
||||||
|
pub use review::Review;
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
use jiff::Timestamp;
|
||||||
|
|
||||||
|
#[derive(Debug, toasty::Model)]
|
||||||
|
pub struct Review {
|
||||||
|
#[key]
|
||||||
|
#[auto]
|
||||||
|
pub id: u64,
|
||||||
|
|
||||||
|
#[unique]
|
||||||
|
pub event_id: u64,
|
||||||
|
|
||||||
|
pub repo: String,
|
||||||
|
pub pr: u64,
|
||||||
|
|
||||||
|
/// Vrai si la review a abouti.
|
||||||
|
pub ok: bool,
|
||||||
|
|
||||||
|
/// Modèle utilisé.
|
||||||
|
pub model: String,
|
||||||
|
|
||||||
|
/// Markdown posté sur la pull request (vide en cas d'échec).
|
||||||
|
pub markdown: String,
|
||||||
|
|
||||||
|
/// Coût OpenRouter en USD, si connu.
|
||||||
|
pub cost: Option<f64>,
|
||||||
|
|
||||||
|
/// Nombre de commentaires produits.
|
||||||
|
pub comments: u64,
|
||||||
|
|
||||||
|
/// Durée du traitement en millisecondes.
|
||||||
|
pub duration_ms: u64,
|
||||||
|
|
||||||
|
#[default(jiff::Timestamp::now())]
|
||||||
|
pub completed_at: Timestamp,
|
||||||
|
}
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
use apalis_redis::RedisStorage;
|
use crate::{dedup::Dedup, env::EnvConfig, job_queue::JobQueue};
|
||||||
|
|
||||||
use crate::{dedup::Dedup, env::EnvConfig, gitea::WebhookType};
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub storage: RedisStorage<WebhookType>,
|
pub queue: JobQueue,
|
||||||
pub bot_name: String,
|
|
||||||
pub dedup: Dedup,
|
pub dedup: Dedup,
|
||||||
|
pub bot_name: String,
|
||||||
pub config: EnvConfig,
|
pub config: EnvConfig,
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
version = 1
|
||||||
|
|
||||||
|
[[migrations]]
|
||||||
|
id = 3569579561409902287
|
||||||
|
name = "0000_init.sql"
|
||||||
|
snapshot_name = "0000_snapshot.toml"
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
CREATE TYPE "job_status" AS ENUM ('pending', 'processing');
|
||||||
|
CREATE TABLE "jobs" (
|
||||||
|
"id" BIGINT NOT NULL GENERATED BY DEFAULT AS IDENTITY,
|
||||||
|
"event_id" BIGINT NOT NULL,
|
||||||
|
"repo" TEXT NOT NULL,
|
||||||
|
"pr" BIGINT NOT NULL,
|
||||||
|
"payload" JSONB NOT NULL,
|
||||||
|
"status" "job_status" NOT NULL,
|
||||||
|
"created_at" TIMESTAMPTZ(6) NOT NULL,
|
||||||
|
PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX "index_jobs_by_event_id" ON "jobs" ("event_id");
|
||||||
|
CREATE INDEX "index_jobs_by_status" ON "jobs" ("status");
|
||||||
|
CREATE TABLE "reviews" (
|
||||||
|
"id" BIGINT NOT NULL GENERATED BY DEFAULT AS IDENTITY,
|
||||||
|
"event_id" BIGINT NOT NULL,
|
||||||
|
"repo" TEXT NOT NULL,
|
||||||
|
"pr" BIGINT NOT NULL,
|
||||||
|
"ok" BOOLEAN NOT NULL,
|
||||||
|
"model" TEXT NOT NULL,
|
||||||
|
"markdown" TEXT NOT NULL,
|
||||||
|
"cost" DOUBLE PRECISION,
|
||||||
|
"comments" BIGINT NOT NULL,
|
||||||
|
"duration_ms" BIGINT NOT NULL,
|
||||||
|
"completed_at" TIMESTAMPTZ(6) NOT NULL,
|
||||||
|
PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX "index_reviews_by_event_id" ON "reviews" ("event_id");
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
version = 1
|
||||||
|
|
||||||
|
[schema]
|
||||||
|
|
||||||
|
[[schema.tables]]
|
||||||
|
id = 0
|
||||||
|
name = "jobs"
|
||||||
|
primary_key = { columns = [{ table = 0, index = 0 }], index = { table = 0, index = 0 } }
|
||||||
|
|
||||||
|
[[schema.tables.columns]]
|
||||||
|
id = { table = 0, index = 0 }
|
||||||
|
name = "id"
|
||||||
|
ty = "U64"
|
||||||
|
storage_ty = { UnsignedInteger = 8 }
|
||||||
|
auto_increment = true
|
||||||
|
|
||||||
|
[[schema.tables.columns]]
|
||||||
|
id = { table = 0, index = 1 }
|
||||||
|
name = "event_id"
|
||||||
|
ty = "U64"
|
||||||
|
storage_ty = { UnsignedInteger = 8 }
|
||||||
|
|
||||||
|
[[schema.tables.columns]]
|
||||||
|
id = { table = 0, index = 2 }
|
||||||
|
name = "repo"
|
||||||
|
ty = "String"
|
||||||
|
storage_ty = "Text"
|
||||||
|
|
||||||
|
[[schema.tables.columns]]
|
||||||
|
id = { table = 0, index = 3 }
|
||||||
|
name = "pr"
|
||||||
|
ty = "U64"
|
||||||
|
storage_ty = { UnsignedInteger = 8 }
|
||||||
|
|
||||||
|
[[schema.tables.columns]]
|
||||||
|
id = { table = 0, index = 4 }
|
||||||
|
name = "payload"
|
||||||
|
ty = "String"
|
||||||
|
storage_ty = "Jsonb"
|
||||||
|
|
||||||
|
[[schema.tables.columns]]
|
||||||
|
id = { table = 0, index = 5 }
|
||||||
|
name = "status"
|
||||||
|
ty = "String"
|
||||||
|
storage_ty = { Enum = { name = "job_status", variants = [{ name = "pending" }, { name = "processing" }] } }
|
||||||
|
|
||||||
|
[[schema.tables.columns]]
|
||||||
|
id = { table = 0, index = 6 }
|
||||||
|
name = "created_at"
|
||||||
|
ty = "Timestamp"
|
||||||
|
storage_ty = { Timestamp = 6 }
|
||||||
|
|
||||||
|
[[schema.tables.indices]]
|
||||||
|
id = { table = 0, index = 0 }
|
||||||
|
name = "index_jobs_by_id"
|
||||||
|
on = 0
|
||||||
|
columns = [{ column = { table = 0, index = 0 }, op = "Eq", scope = "Partition" }]
|
||||||
|
unique = true
|
||||||
|
primary_key = true
|
||||||
|
|
||||||
|
[[schema.tables.indices]]
|
||||||
|
id = { table = 0, index = 1 }
|
||||||
|
name = "index_jobs_by_event_id"
|
||||||
|
on = 0
|
||||||
|
columns = [{ column = { table = 0, index = 1 }, op = "Eq", scope = "Partition" }]
|
||||||
|
unique = true
|
||||||
|
|
||||||
|
[[schema.tables.indices]]
|
||||||
|
id = { table = 0, index = 2 }
|
||||||
|
name = "index_jobs_by_status"
|
||||||
|
on = 0
|
||||||
|
columns = [{ column = { table = 0, index = 5 }, op = "Eq", scope = "Partition" }]
|
||||||
|
|
||||||
|
[[schema.tables]]
|
||||||
|
id = 1
|
||||||
|
name = "reviews"
|
||||||
|
primary_key = { columns = [{ table = 1, index = 0 }], index = { table = 1, index = 0 } }
|
||||||
|
|
||||||
|
[[schema.tables.columns]]
|
||||||
|
id = { table = 1, index = 0 }
|
||||||
|
name = "id"
|
||||||
|
ty = "U64"
|
||||||
|
storage_ty = { UnsignedInteger = 8 }
|
||||||
|
auto_increment = true
|
||||||
|
|
||||||
|
[[schema.tables.columns]]
|
||||||
|
id = { table = 1, index = 1 }
|
||||||
|
name = "event_id"
|
||||||
|
ty = "U64"
|
||||||
|
storage_ty = { UnsignedInteger = 8 }
|
||||||
|
|
||||||
|
[[schema.tables.columns]]
|
||||||
|
id = { table = 1, index = 2 }
|
||||||
|
name = "repo"
|
||||||
|
ty = "String"
|
||||||
|
storage_ty = "Text"
|
||||||
|
|
||||||
|
[[schema.tables.columns]]
|
||||||
|
id = { table = 1, index = 3 }
|
||||||
|
name = "pr"
|
||||||
|
ty = "U64"
|
||||||
|
storage_ty = { UnsignedInteger = 8 }
|
||||||
|
|
||||||
|
[[schema.tables.columns]]
|
||||||
|
id = { table = 1, index = 4 }
|
||||||
|
name = "ok"
|
||||||
|
ty = "Bool"
|
||||||
|
storage_ty = "Boolean"
|
||||||
|
|
||||||
|
[[schema.tables.columns]]
|
||||||
|
id = { table = 1, index = 5 }
|
||||||
|
name = "model"
|
||||||
|
ty = "String"
|
||||||
|
storage_ty = "Text"
|
||||||
|
|
||||||
|
[[schema.tables.columns]]
|
||||||
|
id = { table = 1, index = 6 }
|
||||||
|
name = "markdown"
|
||||||
|
ty = "String"
|
||||||
|
storage_ty = "Text"
|
||||||
|
|
||||||
|
[[schema.tables.columns]]
|
||||||
|
id = { table = 1, index = 7 }
|
||||||
|
name = "cost"
|
||||||
|
ty = "F64"
|
||||||
|
storage_ty = { Float = 8 }
|
||||||
|
nullable = true
|
||||||
|
|
||||||
|
[[schema.tables.columns]]
|
||||||
|
id = { table = 1, index = 8 }
|
||||||
|
name = "comments"
|
||||||
|
ty = "U64"
|
||||||
|
storage_ty = { UnsignedInteger = 8 }
|
||||||
|
|
||||||
|
[[schema.tables.columns]]
|
||||||
|
id = { table = 1, index = 9 }
|
||||||
|
name = "duration_ms"
|
||||||
|
ty = "U64"
|
||||||
|
storage_ty = { UnsignedInteger = 8 }
|
||||||
|
|
||||||
|
[[schema.tables.columns]]
|
||||||
|
id = { table = 1, index = 10 }
|
||||||
|
name = "completed_at"
|
||||||
|
ty = "Timestamp"
|
||||||
|
storage_ty = { Timestamp = 6 }
|
||||||
|
|
||||||
|
[[schema.tables.indices]]
|
||||||
|
id = { table = 1, index = 0 }
|
||||||
|
name = "index_reviews_by_id"
|
||||||
|
on = 1
|
||||||
|
columns = [{ column = { table = 1, index = 0 }, op = "Eq", scope = "Partition" }]
|
||||||
|
unique = true
|
||||||
|
primary_key = true
|
||||||
|
|
||||||
|
[[schema.tables.indices]]
|
||||||
|
id = { table = 1, index = 1 }
|
||||||
|
name = "index_reviews_by_event_id"
|
||||||
|
on = 1
|
||||||
|
columns = [{ column = { table = 1, index = 1 }, op = "Eq", scope = "Partition" }]
|
||||||
|
unique = true
|
||||||
Reference in new issue
Block a user