migrations/010_repo_mirrors.sql
+0
−14
diff --git a/migrations/010_repo_mirrors.sql b/migrations/010_repo_mirrors.sql
deleted file mode 100644
index 6d5bb5c..0000000
--- a/migrations/010_repo_mirrors.sql
+++ /dev/null
@@ -1,15 +0,0 @@
--- Pull mirrors: fetch from a remote into the local bare repository.
-CREATE TABLE IF NOT EXISTS repo_mirrors (
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
- repo_id UUID NOT NULL UNIQUE REFERENCES repositories(id) ON DELETE CASCADE,
- remote_url TEXT NOT NULL,
- enabled BOOLEAN NOT NULL DEFAULT TRUE,
- last_synced_at TIMESTAMPTZ,
- last_error TEXT,
- created_by UUID REFERENCES users(id) ON DELETE SET NULL,
- created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
- updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
- CONSTRAINT repo_mirrors_remote_url_nonempty CHECK (char_length(trim(remote_url)) > 0)
-);
-CREATE INDEX IF NOT EXISTS repo_mirrors_enabled_idx
- ON repo_mirrors(enabled) WHERE enabled = TRUE;
migrations/011_user_audit_log.sql
+0
−17
diff --git a/migrations/011_user_audit_log.sql b/migrations/011_user_audit_log.sql
deleted file mode 100644
index 13a5f1b..0000000
--- a/migrations/011_user_audit_log.sql
+++ /dev/null
@@ -1,18 +0,0 @@
--- Per-user security / account audit trail (separate from activity_events social feed).
-CREATE TABLE IF NOT EXISTS audit_log (
- id BIGSERIAL PRIMARY KEY,
- user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
- actor_id UUID REFERENCES users(id) ON DELETE SET NULL,
- action TEXT NOT NULL,
- ip TEXT,
- user_agent TEXT,
- metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
- created_at TIMESTAMPTZ NOT NULL DEFAULT now()
-);
-
-CREATE INDEX IF NOT EXISTS audit_log_user_id_created_at_idx
- ON audit_log(user_id, created_at DESC);
-CREATE INDEX IF NOT EXISTS audit_log_created_at_idx
- ON audit_log(created_at DESC);
-CREATE INDEX IF NOT EXISTS audit_log_action_idx
- ON audit_log(action);
migrations/012_user_theme.sql
+0
−9
diff --git a/migrations/012_user_theme.sql b/migrations/012_user_theme.sql
deleted file mode 100644
index 7b57126..0000000
--- a/migrations/012_user_theme.sql
+++ /dev/null
@@ -1,10 +0,0 @@
--- Per-user appearance preference: light | dark | system
-
-ALTER TABLE users
- ADD COLUMN IF NOT EXISTS theme TEXT NOT NULL DEFAULT 'system';
-
-ALTER TABLE users
- DROP CONSTRAINT IF EXISTS users_theme_check;
-
-ALTER TABLE users
- ADD CONSTRAINT users_theme_check CHECK (theme IN ('light', 'dark', 'system'));
migrations/013_deploy_keys.sql
+0
−13
diff --git a/migrations/013_deploy_keys.sql b/migrations/013_deploy_keys.sql
deleted file mode 100644
index dd351b3..0000000
--- a/migrations/013_deploy_keys.sql
+++ /dev/null
@@ -1,14 +0,0 @@
--- Repo-scoped SSH deploy keys (read-only or read-write).
-CREATE TABLE IF NOT EXISTS deploy_keys (
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
- repo_id UUID NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
- name TEXT NOT NULL DEFAULT 'deploy',
- public_key TEXT NOT NULL,
- fingerprint TEXT NOT NULL UNIQUE,
- read_only BOOLEAN NOT NULL DEFAULT TRUE,
- created_by UUID REFERENCES users(id) ON DELETE SET NULL,
- created_at TIMESTAMPTZ NOT NULL DEFAULT now()
-);
-
-CREATE INDEX IF NOT EXISTS deploy_keys_repo_id_idx ON deploy_keys(repo_id);
-CREATE INDEX IF NOT EXISTS deploy_keys_fingerprint_idx ON deploy_keys(fingerprint);
\ No newline at end of file
migrations/014_webhooks.sql
+0
−26
diff --git a/migrations/014_webhooks.sql b/migrations/014_webhooks.sql
deleted file mode 100644
index 880aed9..0000000
--- a/migrations/014_webhooks.sql
+++ /dev/null
@@ -1,27 +0,0 @@
--- Repository webhooks + delivery log
-
-CREATE TABLE webhooks (
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
- repo_id UUID NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
- url TEXT NOT NULL,
- secret TEXT NOT NULL DEFAULT '',
- events TEXT[] NOT NULL DEFAULT '{}',
- active BOOLEAN NOT NULL DEFAULT TRUE,
- created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
- CONSTRAINT webhooks_url_http CHECK (url ~* '^https?://')
-);
-CREATE INDEX webhooks_repo_id_idx ON webhooks(repo_id);
-
-CREATE TABLE webhook_deliveries (
- id BIGSERIAL PRIMARY KEY,
- webhook_id UUID NOT NULL REFERENCES webhooks(id) ON DELETE CASCADE,
- event TEXT NOT NULL,
- action TEXT NOT NULL DEFAULT '',
- success BOOLEAN NOT NULL DEFAULT FALSE,
- status_code INT,
- error TEXT,
- duration_ms INT,
- created_at TIMESTAMPTZ NOT NULL DEFAULT now()
-);
-CREATE INDEX webhook_deliveries_webhook_id_idx ON webhook_deliveries(webhook_id);
-CREATE INDEX webhook_deliveries_created_at_idx ON webhook_deliveries(created_at DESC);
\ No newline at end of file
migrations/016_notifications.sql
+0
−19
diff --git a/migrations/016_notifications.sql b/migrations/016_notifications.sql
deleted file mode 100644
index 0439544..0000000
--- a/migrations/016_notifications.sql
+++ /dev/null
@@ -1,20 +0,0 @@
--- In-app notifications for watched repos and thread participants
-
-CREATE TABLE IF NOT EXISTS notifications (
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
- user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
- kind TEXT NOT NULL,
- title TEXT NOT NULL,
- body TEXT NOT NULL DEFAULT '',
- href TEXT NOT NULL DEFAULT '',
- repo_id UUID REFERENCES repositories(id) ON DELETE SET NULL,
- read_at TIMESTAMPTZ,
- created_at TIMESTAMPTZ NOT NULL DEFAULT now()
-);
-
-CREATE INDEX IF NOT EXISTS notifications_user_id_created_at_idx
- ON notifications (user_id, created_at DESC);
-
-CREATE INDEX IF NOT EXISTS notifications_user_id_unread_idx
- ON notifications (user_id)
- WHERE read_at IS NULL;
migrations/017_labels_milestones.sql
+0
−53
diff --git a/migrations/017_labels_milestones.sql b/migrations/017_labels_milestones.sql
deleted file mode 100644
index f4ce6e1..0000000
--- a/migrations/017_labels_milestones.sql
+++ /dev/null
@@ -1,55 +0,0 @@
--- Labels and milestones for issues and pull requests.
-
--- Replace the legacy text-only issue_labels table with proper label entities.
-DROP TABLE IF EXISTS issue_labels;
-
-CREATE TABLE labels (
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
- repo_id UUID NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
- name TEXT NOT NULL,
- color TEXT NOT NULL DEFAULT '0969da',
- description TEXT NOT NULL DEFAULT '',
- created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
- UNIQUE (repo_id, name),
- CONSTRAINT labels_name_len CHECK (char_length(name) BETWEEN 1 AND 50),
- CONSTRAINT labels_color_format CHECK (color ~ '^[0-9a-fA-F]{6}$')
-);
-CREATE INDEX labels_repo_id_idx ON labels(repo_id);
-
-CREATE TABLE milestones (
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
- repo_id UUID NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
- title TEXT NOT NULL,
- description TEXT NOT NULL DEFAULT '',
- due_on DATE,
- state TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open', 'closed')),
- created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
- updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
- closed_at TIMESTAMPTZ,
- UNIQUE (repo_id, title),
- CONSTRAINT milestones_title_len CHECK (char_length(title) BETWEEN 1 AND 120)
-);
-CREATE INDEX milestones_repo_id_idx ON milestones(repo_id);
-CREATE INDEX milestones_repo_state_idx ON milestones(repo_id, state);
-
-ALTER TABLE issues
- ADD COLUMN IF NOT EXISTS milestone_id UUID REFERENCES milestones(id) ON DELETE SET NULL;
-CREATE INDEX IF NOT EXISTS issues_milestone_id_idx ON issues(milestone_id);
-
-ALTER TABLE pull_requests
- ADD COLUMN IF NOT EXISTS milestone_id UUID REFERENCES milestones(id) ON DELETE SET NULL;
-CREATE INDEX IF NOT EXISTS pull_requests_milestone_id_idx ON pull_requests(milestone_id);
-
-CREATE TABLE issue_labels (
- issue_id UUID NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
- label_id UUID NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
- PRIMARY KEY (issue_id, label_id)
-);
-CREATE INDEX issue_labels_label_id_idx ON issue_labels(label_id);
-
-CREATE TABLE pull_labels (
- pull_id UUID NOT NULL REFERENCES pull_requests(id) ON DELETE CASCADE,
- label_id UUID NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
- PRIMARY KEY (pull_id, label_id)
-);
-CREATE INDEX pull_labels_label_id_idx ON pull_labels(label_id);
migrations/018_pull_reviews.sql
+0
−9
diff --git a/migrations/018_pull_reviews.sql b/migrations/018_pull_reviews.sql
deleted file mode 100644
index bbc690f..0000000
--- a/migrations/018_pull_reviews.sql
+++ /dev/null
@@ -1,9 +0,0 @@
-CREATE TABLE IF NOT EXISTS pull_reviews (
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
- pull_id UUID NOT NULL REFERENCES pull_requests(id) ON DELETE CASCADE,
- reviewer_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
- state TEXT NOT NULL CHECK (state IN ('approved','changes_requested','commented')),
- body TEXT NOT NULL DEFAULT '',
- created_at TIMESTAMPTZ NOT NULL DEFAULT now()
-);
-CREATE INDEX IF NOT EXISTS pull_reviews_pull_id_idx ON pull_reviews(pull_id);
src/db/deploy_keys.rs
+0
−98
diff --git a/src/db/deploy_keys.rs b/src/db/deploy_keys.rs
deleted file mode 100644
index d538af3..0000000
--- a/src/db/deploy_keys.rs
+++ /dev/null
@@ -1,98 +0,0 @@
-//! Repo-scoped SSH deploy keys (read-only or read-write).
-
-use anyhow::Result;
-use chrono::{DateTime, Utc};
-use serde::Serialize;
-use sqlx::{FromRow, PgPool};
-use uuid::Uuid;
-
-#[derive(Debug, Clone, FromRow, Serialize)]
-pub struct DeployKey {
- pub id: Uuid,
- pub repo_id: Uuid,
- pub name: String,
- pub public_key: String,
- pub fingerprint: String,
- pub read_only: bool,
- pub created_by: Option<Uuid>,
- pub created_at: DateTime<Utc>,
-}
-
-impl DeployKey {
- pub fn permission_label(&self) -> &'static str {
- if self.read_only {
- "Read-only"
- } else {
- "Read-write"
- }
- }
-}
-
-pub async fn add_deploy_key(
- pool: &PgPool,
- repo_id: Uuid,
- name: &str,
- public_key: &str,
- fingerprint: &str,
- read_only: bool,
- created_by: Option<Uuid>,
-) -> Result<DeployKey> {
- let clash: Option<(Uuid,)> = sqlx::query_as(
- "SELECT id FROM ssh_keys WHERE rtrim(fingerprint, '=') = rtrim($1, '=') LIMIT 1",
- )
- .bind(fingerprint)
- .fetch_optional(pool)
- .await?;
- if clash.is_some() {
- anyhow::bail!("fingerprint already registered as a user SSH key");
- }
- Ok(sqlx::query_as::<_, DeployKey>(
- r#"
- INSERT INTO deploy_keys (repo_id, name, public_key, fingerprint, read_only, created_by)
- VALUES ($1, $2, $3, $4, $5, $6)
- RETURNING *
- "#,
- )
- .bind(repo_id)
- .bind(name)
- .bind(public_key)
- .bind(fingerprint)
- .bind(read_only)
- .bind(created_by)
- .fetch_one(pool)
- .await?)
-}
-
-pub async fn list_deploy_keys(pool: &PgPool, repo_id: Uuid) -> Result<Vec<DeployKey>> {
- Ok(sqlx::query_as::<_, DeployKey>(
- "SELECT * FROM deploy_keys WHERE repo_id = $1 ORDER BY created_at",
- )
- .bind(repo_id)
- .fetch_all(pool)
- .await?)
-}
-
-pub async fn delete_deploy_key(pool: &PgPool, repo_id: Uuid, id: Uuid) -> Result<()> {
- sqlx::query("DELETE FROM deploy_keys WHERE id = $1 AND repo_id = $2")
- .bind(id)
- .bind(repo_id)
- .execute(pool)
- .await?;
- Ok(())
-}
-
-pub async fn deploy_key_by_fingerprint(
- pool: &PgPool,
- fingerprint: &str,
-) -> Result<Option<DeployKey>> {
- let normalized = fingerprint.trim_end_matches('=');
- Ok(sqlx::query_as::<_, DeployKey>(
- r#"
- SELECT * FROM deploy_keys
- WHERE rtrim(fingerprint, '=') = $1
- "#,
- )
- .bind(normalized)
- .fetch_optional(pool)
- .await?)
-}
\ No newline at end of file
diff --git a/src/db/mod.rs b/src/db/mod.rs
index 2b7d249..8d84a74 100644
--- a/src/db/mod.rs
+++ b/src/db/mod.rs
@@ -1,9 +1,6 @@
-pub mod deploy_keys;
pub mod models;
pub mod queries;
-pub use deploy_keys::DeployKey;
-
use anyhow::{Context, Result};
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
diff --git a/src/db/models.rs b/src/db/models.rs
index 42d2653..874e00c 100644
--- a/src/db/models.rs
+++ b/src/db/models.rs
@@ -17,21 +17,10 @@ pub struct User {
pub is_suspended: bool,
pub show_email: bool,
pub vigilant_mode: bool,
- /// `light`, `dark`, or `system` (follows prefers-color-scheme).
- pub theme: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
-impl User {
- pub fn theme_pref(&self) -> &str {
- match self.theme.as_str() {
- "light" | "dark" => self.theme.as_str(),
- _ => "system",
- }
- }
-}
-
#[derive(Debug, Clone, FromRow)]
pub struct UserMfa {
pub user_id: Uuid,
@@ -112,19 +101,6 @@ pub struct BranchRule {
pub created_at: DateTime<Utc>,
}
-#[derive(Debug, Clone, FromRow, Serialize)]
-pub struct RepoMirror {
- pub id: Uuid,
- pub repo_id: Uuid,
- pub remote_url: String,
- pub enabled: bool,
- pub last_synced_at: Option<DateTime<Utc>>,
- pub last_error: Option<String>,
- pub created_by: Option<Uuid>,
- pub created_at: DateTime<Utc>,
- pub updated_at: DateTime<Utc>,
-}
-
#[derive(Debug, Clone, FromRow)]
pub struct CommentReaction {
pub comment_id: Uuid,
@@ -182,19 +158,6 @@ pub struct ActivityEvent {
pub created_at: DateTime<Utc>,
}
-/// Per-user security / account audit entry (not the social activity feed).
-#[derive(Debug, Clone, FromRow)]
-pub struct AuditLog {
- pub id: i64,
- pub user_id: Uuid,
- pub actor_id: Option<Uuid>,
- pub action: String,
- pub ip: Option<String>,
- pub user_agent: Option<String>,
- pub metadata: serde_json::Value,
- pub created_at: DateTime<Utc>,
-}
-
#[derive(Debug, Clone, FromRow)]
pub struct Issue {
pub id: Uuid,
@@ -204,7 +167,6 @@ pub struct Issue {
pub title: String,
pub body: String,
pub state: String,
- pub milestone_id: Option<Uuid>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub closed_at: Option<DateTime<Utc>>,
@@ -223,7 +185,6 @@ pub struct PullRequest {
pub target_branch: String,
pub merge_commit: Option<String>,
pub source_repo_id: Option<Uuid>,
- pub milestone_id: Option<Uuid>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub merged_at: Option<DateTime<Utc>>,
@@ -231,51 +192,6 @@ pub struct PullRequest {
}
#[derive(Debug, Clone, FromRow)]
-pub struct Label {
- pub id: Uuid,
- pub repo_id: Uuid,
- pub name: String,
- pub color: String,
- pub description: String,
- pub created_at: DateTime<Utc>,
-}
-
-impl Label {
- pub fn bg_color(&self) -> String {
- format!("#{}", self.color.trim_start_matches('#'))
- }
-
- pub fn text_color(&self) -> &'static str {
- let hex = self.color.trim_start_matches('#');
- if hex.len() != 6 {
- return "#ffffff";
- }
- let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(0) as f32;
- let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(0) as f32;
- let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(0) as f32;
- let lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255.0;
- if lum > 0.55 {
- "#111111"
- } else {
- "#ffffff"
- }
- }
-}
-
-#[derive(Debug, Clone, FromRow)]
-pub struct Milestone {
- pub id: Uuid,
- pub repo_id: Uuid,
- pub title: String,
- pub description: String,
- pub due_on: Option<NaiveDate>,
- pub state: String,
- pub created_at: DateTime<Utc>,
- pub updated_at: DateTime<Utc>,
- pub closed_at: Option<DateTime<Utc>>,
-}
-
-#[derive(Debug, Clone, FromRow)]
pub struct Comment {
pub id: Uuid,
pub repo_id: Uuid,
@@ -288,16 +204,6 @@ pub struct Comment {
}
#[derive(Debug, Clone, FromRow)]
-pub struct PullReview {
- pub id: Uuid,
- pub pull_id: Uuid,
- pub reviewer_id: Uuid,
- pub state: String,
- pub body: String,
- pub created_at: DateTime<Utc>,
-}
-
-#[derive(Debug, Clone, FromRow)]
pub struct Release {
pub id: Uuid,
pub repo_id: Uuid,
@@ -328,43 +234,6 @@ pub struct CommitDay {
pub count: i32,
}
-#[derive(Debug, Clone, FromRow, Serialize)]
-pub struct Webhook {
- pub id: Uuid,
- pub repo_id: Uuid,
- pub url: String,
- pub secret: String,
- pub events: Vec<String>,
- pub active: bool,
- pub created_at: DateTime<Utc>,
-}
-
-#[derive(Debug, Clone, FromRow)]
-pub struct WebhookDelivery {
- pub id: i64,
- pub webhook_id: Uuid,
- pub event: String,
- pub action: String,
- pub success: bool,
- pub status_code: Option<i32>,
- pub error: Option<String>,
- pub duration_ms: Option<i32>,
- pub created_at: DateTime<Utc>,
-}
-
-#[derive(Debug, Clone, FromRow, Serialize)]
-pub struct Notification {
- pub id: Uuid,
- pub user_id: Uuid,
- pub kind: String,
- pub title: String,
- pub body: String,
- pub href: String,
- pub repo_id: Option<Uuid>,
- pub read_at: Option<DateTime<Utc>>,
- pub created_at: DateTime<Utc>,
-}
-
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Access {
src/db/queries.rs
+33
−1027
diff --git a/src/db/queries.rs b/src/db/queries.rs
index 8df7cab..340cf0a 100644
--- a/src/db/queries.rs
+++ b/src/db/queries.rs
@@ -1,7 +1,7 @@
use super::models::*;
use anyhow::Result;
use chrono::{DateTime, NaiveDate, Utc};
-use sqlx::{FromRow, PgPool};
+use sqlx::PgPool;
use uuid::Uuid;
pub async fn upsert_user_from_oidc(
@@ -1326,29 +1326,6 @@ pub async fn user_owns_email(pool: &PgPool, user_id: Uuid, email: &str) -> Resul
Ok(row.is_some())
}
-/// Resolve a kitgit username from a commit author email (primary or secondary).
-pub async fn username_by_email(pool: &PgPool, email: &str) -> Result<Option<String>> {
- let email = email.trim().to_ascii_lowercase();
- if email.is_empty() {
- return Ok(None);
- }
- let row: Option<(String,)> = sqlx::query_as(
- r#"
- SELECT u.username FROM users u
- WHERE lower(u.email) = $1
- UNION
- SELECT u.username FROM users u
- JOIN user_emails e ON e.user_id = u.id
- WHERE lower(e.email) = $1
- LIMIT 1
- "#,
- )
- .bind(&email)
- .fetch_optional(pool)
- .await?;
- Ok(row.map(|(u,)| u))
-}
-
pub async fn list_all_gpg_keys(pool: &PgPool) -> Result<Vec<GpgKey>> {
Ok(sqlx::query_as::<_, GpgKey>("SELECT * FROM gpg_keys ORDER BY created_at DESC")
.fetch_all(pool)
@@ -1522,50 +1499,22 @@ pub async fn create_issue(
}
pub async fn list_issues(pool: &PgPool, repo_id: Uuid, state: Option<&str>) -> Result<Vec<Issue>> {
- list_issues_filtered(pool, repo_id, state, None, None).await
-}
-
-pub async fn list_issues_filtered(
- pool: &PgPool,
- repo_id: Uuid,
- state: Option<&str>,
- label_id: Option<Uuid>,
- milestone_id: Option<Uuid>,
-) -> Result<Vec<Issue>> {
- Ok(sqlx::query_as::<_, Issue>(
- r#"
- SELECT i.* FROM issues i
- WHERE i.repo_id = $1
- AND ($2::text IS NULL OR i.state = $2)
- AND ($3::uuid IS NULL OR i.milestone_id = $3)
- AND (
- $4::uuid IS NULL OR EXISTS (
- SELECT 1 FROM issue_labels il
- WHERE il.issue_id = i.id AND il.label_id = $4
- )
- )
- ORDER BY i.number DESC
- "#,
- )
- .bind(repo_id)
- .bind(state)
- .bind(milestone_id)
- .bind(label_id)
- .fetch_all(pool)
- .await?)
-}
-
-pub async fn set_issue_milestone(
- pool: &PgPool,
- issue_id: Uuid,
- milestone_id: Option<Uuid>,
-) -> Result<()> {
- sqlx::query("UPDATE issues SET milestone_id = $2, updated_at = now() WHERE id = $1")
- .bind(issue_id)
- .bind(milestone_id)
- .execute(pool)
- .await?;
- Ok(())
+ if let Some(s) = state {
+ Ok(sqlx::query_as::<_, Issue>(
+ "SELECT * FROM issues WHERE repo_id = $1 AND state = $2 ORDER BY number DESC",
+ )
+ .bind(repo_id)
+ .bind(s)
+ .fetch_all(pool)
+ .await?)
+ } else {
+ Ok(sqlx::query_as::<_, Issue>(
+ "SELECT * FROM issues WHERE repo_id = $1 ORDER BY number DESC",
+ )
+ .bind(repo_id)
+ .fetch_all(pool)
+ .await?)
+ }
}
pub async fn get_issue(pool: &PgPool, repo_id: Uuid, number: i32) -> Result<Option<Issue>> {
@@ -1638,50 +1587,22 @@ pub async fn create_pull_ex(
}
pub async fn list_pulls(pool: &PgPool, repo_id: Uuid, state: Option<&str>) -> Result<Vec<PullRequest>> {
- list_pulls_filtered(pool, repo_id, state, None, None).await
-}
-
-pub async fn list_pulls_filtered(
- pool: &PgPool,
- repo_id: Uuid,
- state: Option<&str>,
- label_id: Option<Uuid>,
- milestone_id: Option<Uuid>,
-) -> Result<Vec<PullRequest>> {
- Ok(sqlx::query_as::<_, PullRequest>(
- r#"
- SELECT p.* FROM pull_requests p
- WHERE p.repo_id = $1
- AND ($2::text IS NULL OR p.state = $2)
- AND ($3::uuid IS NULL OR p.milestone_id = $3)
- AND (
- $4::uuid IS NULL OR EXISTS (
- SELECT 1 FROM pull_labels pl
- WHERE pl.pull_id = p.id AND pl.label_id = $4
- )
- )
- ORDER BY p.number DESC
- "#,
- )
- .bind(repo_id)
- .bind(state)
- .bind(milestone_id)
- .bind(label_id)
- .fetch_all(pool)
- .await?)
-}
-
-pub async fn set_pull_milestone(
- pool: &PgPool,
- pull_id: Uuid,
- milestone_id: Option<Uuid>,
-) -> Result<()> {
- sqlx::query("UPDATE pull_requests SET milestone_id = $2, updated_at = now() WHERE id = $1")
- .bind(pull_id)
- .bind(milestone_id)
- .execute(pool)
- .await?;
- Ok(())
+ if let Some(s) = state {
+ Ok(sqlx::query_as::<_, PullRequest>(
+ "SELECT * FROM pull_requests WHERE repo_id = $1 AND state = $2 ORDER BY number DESC",
+ )
+ .bind(repo_id)
+ .bind(s)
+ .fetch_all(pool)
+ .await?)
+ } else {
+ Ok(sqlx::query_as::<_, PullRequest>(
+ "SELECT * FROM pull_requests WHERE repo_id = $1 ORDER BY number DESC",
+ )
+ .bind(repo_id)
+ .fetch_all(pool)
+ .await?)
+ }
}
pub async fn get_pull(pool: &PgPool, repo_id: Uuid, number: i32) -> Result<Option<PullRequest>> {
@@ -1762,36 +1683,6 @@ pub async fn list_pull_comments(pool: &PgPool, pull_id: Uuid) -> Result<Vec<Comm
.await?)
}
-pub async fn create_review(
- pool: &PgPool,
- pull_id: Uuid,
- reviewer_id: Uuid,
- state: &str,
- body: &str,
-) -> Result<PullReview> {
- Ok(sqlx::query_as::<_, PullReview>(
- r#"
- INSERT INTO pull_reviews (pull_id, reviewer_id, state, body)
- VALUES ($1, $2, $3, $4) RETURNING *
- "#,
- )
- .bind(pull_id)
- .bind(reviewer_id)
- .bind(state)
- .bind(body)
- .fetch_one(pool)
- .await?)
-}
-
-pub async fn list_reviews_for_pull(pool: &PgPool, pull_id: Uuid) -> Result<Vec<PullReview>> {
- Ok(sqlx::query_as::<_, PullReview>(
- "SELECT * FROM pull_reviews WHERE pull_id = $1 ORDER BY created_at",
- )
- .bind(pull_id)
- .fetch_all(pool)
- .await?)
-}
-
pub async fn create_release(
pool: &PgPool,
repo_id: Uuid,
@@ -2359,19 +2250,6 @@ pub async fn update_privacy(
.await?)
}
-pub async fn update_user_theme(pool: &PgPool, id: Uuid, theme: &str) -> Result<User> {
- Ok(sqlx::query_as::<_, User>(
- r#"
- UPDATE users SET theme = $2, updated_at = now()
- WHERE id = $1 RETURNING *
- "#,
- )
- .bind(id)
- .bind(theme)
- .fetch_one(pool)
- .await?)
-}
-
pub async fn list_user_emails(pool: &PgPool, user_id: Uuid) -> Result<Vec<UserEmail>> {
Ok(sqlx::query_as::<_, UserEmail>(
"SELECT * FROM user_emails WHERE user_id = $1 ORDER BY is_primary DESC, email",
@@ -2404,36 +2282,6 @@ pub async fn delete_user_email(pool: &PgPool, id: Uuid, user_id: Uuid) -> Result
Ok(())
}
-pub async fn set_primary_email(pool: &PgPool, user_id: Uuid, email_id: Uuid) -> Result<()> {
- let mut tx = pool.begin().await?;
- let row: Option<(String,)> = sqlx::query_as(
- "SELECT email FROM user_emails WHERE id = $1 AND user_id = $2",
- )
- .bind(email_id)
- .bind(user_id)
- .fetch_optional(&mut *tx)
- .await?;
- let Some((email,)) = row else {
- anyhow::bail!("email not found");
- };
- sqlx::query("UPDATE user_emails SET is_primary = false WHERE user_id = $1")
- .bind(user_id)
- .execute(&mut *tx)
- .await?;
- sqlx::query("UPDATE user_emails SET is_primary = true WHERE id = $1 AND user_id = $2")
- .bind(email_id)
- .bind(user_id)
- .execute(&mut *tx)
- .await?;
- sqlx::query("UPDATE users SET email = $2, updated_at = now() WHERE id = $1")
- .bind(user_id)
- .bind(&email)
- .execute(&mut *tx)
- .await?;
- tx.commit().await?;
- Ok(())
-}
-
pub async fn ensure_primary_email(pool: &PgPool, user_id: Uuid, email: &str) -> Result<()> {
if email.trim().is_empty() {
return Ok(());
@@ -2680,7 +2528,6 @@ pub async fn export_user_data(pool: &PgPool, user_id: Uuid) -> Result<serde_json
"bio": user.bio,
"show_email": user.show_email,
"vigilant_mode": user.vigilant_mode,
- "theme": user.theme,
"created_at": user.created_at,
},
"emails": emails,
@@ -2749,844 +2596,3 @@ pub async fn lfs_object_size(pool: &PgPool, oid: &str) -> Result<Option<i64>> {
.await?;
Ok(row.map(|r| r.0))
}
-
-// ── repo mirrors ─────────────────────────────────────────────────────────────
-
-pub async fn get_repo_mirror(pool: &PgPool, repo_id: Uuid) -> Result<Option<RepoMirror>> {
- Ok(sqlx::query_as::<_, RepoMirror>(
- "SELECT * FROM repo_mirrors WHERE repo_id = $1",
- )
- .bind(repo_id)
- .fetch_optional(pool)
- .await?)
-}
-
-pub async fn upsert_repo_mirror(
- pool: &PgPool,
- repo_id: Uuid,
- remote_url: &str,
- enabled: bool,
- created_by: Uuid,
-) -> Result<RepoMirror> {
- Ok(sqlx::query_as::<_, RepoMirror>(
- r#"
- INSERT INTO repo_mirrors (repo_id, remote_url, enabled, created_by)
- VALUES ($1, $2, $3, $4)
- ON CONFLICT (repo_id) DO UPDATE SET
- remote_url = EXCLUDED.remote_url,
- enabled = EXCLUDED.enabled,
- updated_at = now()
- RETURNING *
- "#,
- )
- .bind(repo_id)
- .bind(remote_url)
- .bind(enabled)
- .bind(created_by)
- .fetch_one(pool)
- .await?)
-}
-
-pub async fn set_mirror_sync_result(
- pool: &PgPool,
- repo_id: Uuid,
- ok: bool,
- error: Option<&str>,
-) -> Result<RepoMirror> {
- if ok {
- Ok(sqlx::query_as::<_, RepoMirror>(
- r#"
- UPDATE repo_mirrors
- SET last_synced_at = now(), last_error = NULL, updated_at = now()
- WHERE repo_id = $1
- RETURNING *
- "#,
- )
- .bind(repo_id)
- .fetch_one(pool)
- .await?)
- } else {
- Ok(sqlx::query_as::<_, RepoMirror>(
- r#"
- UPDATE repo_mirrors
- SET last_error = $2, updated_at = now()
- WHERE repo_id = $1
- RETURNING *
- "#,
- )
- .bind(repo_id)
- .bind(error)
- .fetch_one(pool)
- .await?)
- }
-}
-
-pub async fn delete_repo_mirror(pool: &PgPool, repo_id: Uuid) -> Result<()> {
- sqlx::query("DELETE FROM repo_mirrors WHERE repo_id = $1")
- .bind(repo_id)
- .execute(pool)
- .await?;
- Ok(())
-}
-
-// ── per-user audit log ───────────────────────────────────────────────────────
-
-pub async fn record_audit_log(
- pool: &PgPool,
- user_id: Uuid,
- actor_id: Option<Uuid>,
- action: &str,
- ip: Option<&str>,
- user_agent: Option<&str>,
- metadata: serde_json::Value,
-) -> Result<()> {
- sqlx::query(
- r#"
- INSERT INTO audit_log (user_id, actor_id, action, ip, user_agent, metadata)
- VALUES ($1, $2, $3, $4, $5, $6)
- "#,
- )
- .bind(user_id)
- .bind(actor_id)
- .bind(action)
- .bind(ip)
- .bind(user_agent)
- .bind(metadata)
- .execute(pool)
- .await?;
- Ok(())
-}
-
-pub async fn list_audit_log_for_user(
- pool: &PgPool,
- user_id: Uuid,
- limit: i64,
-) -> Result<Vec<AuditLog>> {
- Ok(sqlx::query_as::<_, AuditLog>(
- "SELECT * FROM audit_log WHERE user_id = $1 ORDER BY created_at DESC LIMIT $2",
- )
- .bind(user_id)
- .bind(limit)
- .fetch_all(pool)
- .await?)
-}
-
-// ── webhooks ─────────────────────────────────────────────────────────────────
-
-pub async fn list_webhooks(pool: &PgPool, repo_id: Uuid) -> Result<Vec<Webhook>> {
- Ok(sqlx::query_as::<_, Webhook>(
- "SELECT * FROM webhooks WHERE repo_id = $1 ORDER BY created_at DESC",
- )
- .bind(repo_id)
- .fetch_all(pool)
- .await?)
-}
-
-pub async fn list_active_webhooks_for_event(
- pool: &PgPool,
- repo_id: Uuid,
- event: &str,
-) -> Result<Vec<Webhook>> {
- Ok(sqlx::query_as::<_, Webhook>(
- r#"
- SELECT * FROM webhooks
- WHERE repo_id = $1 AND active = TRUE AND $2 = ANY(events)
- ORDER BY created_at
- "#,
- )
- .bind(repo_id)
- .bind(event)
- .fetch_all(pool)
- .await?)
-}
-
-pub async fn get_webhook(pool: &PgPool, id: Uuid, repo_id: Uuid) -> Result<Option<Webhook>> {
- Ok(sqlx::query_as::<_, Webhook>(
- "SELECT * FROM webhooks WHERE id = $1 AND repo_id = $2",
- )
- .bind(id)
- .bind(repo_id)
- .fetch_optional(pool)
- .await?)
-}
-
-pub async fn create_webhook(
- pool: &PgPool,
- repo_id: Uuid,
- url: &str,
- secret: &str,
- events: &[String],
-) -> Result<Webhook> {
- Ok(sqlx::query_as::<_, Webhook>(
- r#"
- INSERT INTO webhooks (repo_id, url, secret, events)
- VALUES ($1, $2, $3, $4)
- RETURNING *
- "#,
- )
- .bind(repo_id)
- .bind(url)
- .bind(secret)
- .bind(events)
- .fetch_one(pool)
- .await?)
-}
-
-pub async fn set_webhook_active(
- pool: &PgPool,
- id: Uuid,
- repo_id: Uuid,
- active: bool,
-) -> Result<()> {
- sqlx::query("UPDATE webhooks SET active = $3 WHERE id = $1 AND repo_id = $2")
- .bind(id)
- .bind(repo_id)
- .bind(active)
- .execute(pool)
- .await?;
- Ok(())
-}
-
-pub async fn delete_webhook(pool: &PgPool, id: Uuid, repo_id: Uuid) -> Result<()> {
- sqlx::query("DELETE FROM webhooks WHERE id = $1 AND repo_id = $2")
- .bind(id)
- .bind(repo_id)
- .execute(pool)
- .await?;
- Ok(())
-}
-
-pub async fn record_webhook_delivery(
- pool: &PgPool,
- webhook_id: Uuid,
- event: &str,
- action: &str,
- success: bool,
- status_code: Option<i32>,
- error: Option<&str>,
- duration_ms: i32,
-) -> Result<()> {
- sqlx::query(
- r#"
- INSERT INTO webhook_deliveries
- (webhook_id, event, action, success, status_code, error, duration_ms)
- VALUES ($1, $2, $3, $4, $5, $6, $7)
- "#,
- )
- .bind(webhook_id)
- .bind(event)
- .bind(action)
- .bind(success)
- .bind(status_code)
- .bind(error)
- .bind(duration_ms)
- .execute(pool)
- .await?;
- Ok(())
-}
-
-pub async fn list_recent_webhook_deliveries(
- pool: &PgPool,
- repo_id: Uuid,
- limit: i64,
-) -> Result<Vec<(WebhookDelivery, String)>> {
- #[derive(sqlx::FromRow)]
- struct Row {
- id: i64,
- webhook_id: Uuid,
- event: String,
- action: String,
- success: bool,
- status_code: Option<i32>,
- error: Option<String>,
- duration_ms: Option<i32>,
- created_at: DateTime<Utc>,
- webhook_url: String,
- }
- let rows = sqlx::query_as::<_, Row>(
- r#"
- SELECT d.id, d.webhook_id, d.event, d.action, d.success, d.status_code,
- d.error, d.duration_ms, d.created_at, w.url AS webhook_url
- FROM webhook_deliveries d
- JOIN webhooks w ON w.id = d.webhook_id
- WHERE w.repo_id = $1
- ORDER BY d.created_at DESC
- LIMIT $2
- "#,
- )
- .bind(repo_id)
- .bind(limit)
- .fetch_all(pool)
- .await?;
- Ok(rows
- .into_iter()
- .map(|r| {
- (
- WebhookDelivery {
- id: r.id,
- webhook_id: r.webhook_id,
- event: r.event,
- action: r.action,
- success: r.success,
- status_code: r.status_code,
- error: r.error,
- duration_ms: r.duration_ms,
- created_at: r.created_at,
- },
- r.webhook_url,
- )
- })
- .collect())
-}
-
-// ── notifications ────────────────────────────────────────────────────────────
-
-pub async fn create_notification(
- pool: &PgPool,
- user_id: Uuid,
- kind: &str,
- title: &str,
- body: &str,
- href: &str,
- repo_id: Option<Uuid>,
-) -> Result<Notification> {
- Ok(sqlx::query_as::<_, Notification>(
- r#"
- INSERT INTO notifications (user_id, kind, title, body, href, repo_id)
- VALUES ($1, $2, $3, $4, $5, $6)
- RETURNING *
- "#,
- )
- .bind(user_id)
- .bind(kind)
- .bind(title)
- .bind(body)
- .bind(href)
- .bind(repo_id)
- .fetch_one(pool)
- .await?)
-}
-
-/// Fan out the same notification to many users, skipping `exclude_user_id`.
-pub async fn notify_users(
- pool: &PgPool,
- user_ids: &[Uuid],
- exclude_user_id: Uuid,
- kind: &str,
- title: &str,
- body: &str,
- href: &str,
- repo_id: Option<Uuid>,
-) -> Result<u64> {
- let mut created = 0u64;
- for &uid in user_ids {
- if uid == exclude_user_id {
- continue;
- }
- create_notification(pool, uid, kind, title, body, href, repo_id).await?;
- created += 1;
- }
- Ok(created)
-}
-
-pub async fn list_repo_watcher_ids(pool: &PgPool, repo_id: Uuid) -> Result<Vec<Uuid>> {
- let rows: Vec<(Uuid,)> =
- sqlx::query_as("SELECT user_id FROM repo_watches WHERE repo_id = $1")
- .bind(repo_id)
- .fetch_all(pool)
- .await?;
- Ok(rows.into_iter().map(|r| r.0).collect())
-}
-
-pub async fn notify_repo_watchers(
- pool: &PgPool,
- repo_id: Uuid,
- exclude_user_id: Uuid,
- kind: &str,
- title: &str,
- body: &str,
- href: &str,
-) -> Result<u64> {
- let watchers = list_repo_watcher_ids(pool, repo_id).await?;
- notify_users(
- pool,
- &watchers,
- exclude_user_id,
- kind,
- title,
- body,
- href,
- Some(repo_id),
- )
- .await
-}
-
-/// Issue author plus prior comment authors.
-pub async fn list_issue_participant_ids(pool: &PgPool, issue_id: Uuid) -> Result<Vec<Uuid>> {
- let rows: Vec<(Uuid,)> = sqlx::query_as(
- r#"
- SELECT author_id FROM issues WHERE id = $1
- UNION
- SELECT DISTINCT author_id FROM comments WHERE issue_id = $1
- "#,
- )
- .bind(issue_id)
- .fetch_all(pool)
- .await?;
- Ok(rows.into_iter().map(|r| r.0).collect())
-}
-
-/// Pull author plus prior comment authors.
-pub async fn list_pull_participant_ids(pool: &PgPool, pull_id: Uuid) -> Result<Vec<Uuid>> {
- let rows: Vec<(Uuid,)> = sqlx::query_as(
- r#"
- SELECT author_id FROM pull_requests WHERE id = $1
- UNION
- SELECT DISTINCT author_id FROM comments WHERE pull_id = $1
- "#,
- )
- .bind(pull_id)
- .fetch_all(pool)
- .await?;
- Ok(rows.into_iter().map(|r| r.0).collect())
-}
-
-pub async fn list_notifications(
- pool: &PgPool,
- user_id: Uuid,
- limit: i64,
-) -> Result<Vec<Notification>> {
- Ok(sqlx::query_as::<_, Notification>(
- r#"
- SELECT * FROM notifications
- WHERE user_id = $1
- ORDER BY created_at DESC
- LIMIT $2
- "#,
- )
- .bind(user_id)
- .bind(limit)
- .fetch_all(pool)
- .await?)
-}
-
-pub async fn unread_notification_count(pool: &PgPool, user_id: Uuid) -> Result<i64> {
- let (n,): (i64,) = sqlx::query_as(
- "SELECT COUNT(*)::bigint FROM notifications WHERE user_id = $1 AND read_at IS NULL",
- )
- .bind(user_id)
- .fetch_one(pool)
- .await?;
- Ok(n)
-}
-
-pub async fn mark_notification_read(
- pool: &PgPool,
- id: Uuid,
- user_id: Uuid,
-) -> Result<Option<Notification>> {
- Ok(sqlx::query_as::<_, Notification>(
- r#"
- UPDATE notifications
- SET read_at = COALESCE(read_at, now())
- WHERE id = $1 AND user_id = $2
- RETURNING *
- "#,
- )
- .bind(id)
- .bind(user_id)
- .fetch_optional(pool)
- .await?)
-}
-
-pub async fn mark_all_notifications_read(pool: &PgPool, user_id: Uuid) -> Result<u64> {
- let res = sqlx::query(
- "UPDATE notifications SET read_at = now() WHERE user_id = $1 AND read_at IS NULL",
- )
- .bind(user_id)
- .execute(pool)
- .await?;
- Ok(res.rows_affected())
-}
-
-// ── labels & milestones ──────────────────────────────────────────────────────
-
-#[derive(Debug, Clone, FromRow)]
-struct IssueLabelRow {
- issue_id: Uuid,
- id: Uuid,
- repo_id: Uuid,
- name: String,
- color: String,
- description: String,
- created_at: DateTime<Utc>,
-}
-
-#[derive(Debug, Clone, FromRow)]
-struct PullLabelRow {
- pull_id: Uuid,
- id: Uuid,
- repo_id: Uuid,
- name: String,
- color: String,
- description: String,
- created_at: DateTime<Utc>,
-}
-
-impl From<IssueLabelRow> for (Uuid, Label) {
- fn from(r: IssueLabelRow) -> Self {
- (
- r.issue_id,
- Label {
- id: r.id,
- repo_id: r.repo_id,
- name: r.name,
- color: r.color,
- description: r.description,
- created_at: r.created_at,
- },
- )
- }
-}
-
-impl From<PullLabelRow> for (Uuid, Label) {
- fn from(r: PullLabelRow) -> Self {
- (
- r.pull_id,
- Label {
- id: r.id,
- repo_id: r.repo_id,
- name: r.name,
- color: r.color,
- description: r.description,
- created_at: r.created_at,
- },
- )
- }
-}
-
-pub async fn list_labels(pool: &PgPool, repo_id: Uuid) -> Result<Vec<Label>> {
- Ok(sqlx::query_as::<_, Label>(
- "SELECT * FROM labels WHERE repo_id = $1 ORDER BY lower(name)",
- )
- .bind(repo_id)
- .fetch_all(pool)
- .await?)
-}
-
-pub async fn get_label(pool: &PgPool, repo_id: Uuid, id: Uuid) -> Result<Option<Label>> {
- Ok(
- sqlx::query_as::<_, Label>("SELECT * FROM labels WHERE repo_id = $1 AND id = $2")
- .bind(repo_id)
- .bind(id)
- .fetch_optional(pool)
- .await?,
- )
-}
-
-pub async fn create_label(
- pool: &PgPool,
- repo_id: Uuid,
- name: &str,
- color: &str,
- description: &str,
-) -> Result<Label> {
- Ok(sqlx::query_as::<_, Label>(
- r#"
- INSERT INTO labels (repo_id, name, color, description)
- VALUES ($1, $2, $3, $4) RETURNING *
- "#,
- )
- .bind(repo_id)
- .bind(name)
- .bind(color)
- .bind(description)
- .fetch_one(pool)
- .await?)
-}
-
-pub async fn update_label(
- pool: &PgPool,
- id: Uuid,
- name: &str,
- color: &str,
- description: &str,
-) -> Result<Label> {
- Ok(sqlx::query_as::<_, Label>(
- r#"
- UPDATE labels SET name = $2, color = $3, description = $4
- WHERE id = $1 RETURNING *
- "#,
- )
- .bind(id)
- .bind(name)
- .bind(color)
- .bind(description)
- .fetch_one(pool)
- .await?)
-}
-
-pub async fn delete_label(pool: &PgPool, id: Uuid) -> Result<()> {
- sqlx::query("DELETE FROM labels WHERE id = $1")
- .bind(id)
- .execute(pool)
- .await?;
- Ok(())
-}
-
-pub async fn list_milestones(
- pool: &PgPool,
- repo_id: Uuid,
- state: Option<&str>,
-) -> Result<Vec<Milestone>> {
- if let Some(s) = state {
- Ok(sqlx::query_as::<_, Milestone>(
- "SELECT * FROM milestones WHERE repo_id = $1 AND state = $2 ORDER BY due_on NULLS LAST, lower(title)",
- )
- .bind(repo_id)
- .bind(s)
- .fetch_all(pool)
- .await?)
- } else {
- Ok(sqlx::query_as::<_, Milestone>(
- "SELECT * FROM milestones WHERE repo_id = $1 ORDER BY state ASC, due_on NULLS LAST, lower(title)",
- )
- .bind(repo_id)
- .fetch_all(pool)
- .await?)
- }
-}
-
-pub async fn list_open_milestones(pool: &PgPool, repo_id: Uuid) -> Result<Vec<Milestone>> {
- list_milestones(pool, repo_id, Some("open")).await
-}
-
-pub async fn get_milestone(pool: &PgPool, repo_id: Uuid, id: Uuid) -> Result<Option<Milestone>> {
- Ok(
- sqlx::query_as::<_, Milestone>("SELECT * FROM milestones WHERE repo_id = $1 AND id = $2")
- .bind(repo_id)
- .bind(id)
- .fetch_optional(pool)
- .await?,
- )
-}
-
-pub async fn create_milestone(
- pool: &PgPool,
- repo_id: Uuid,
- title: &str,
- description: &str,
- due_on: Option<NaiveDate>,
-) -> Result<Milestone> {
- Ok(sqlx::query_as::<_, Milestone>(
- r#"
- INSERT INTO milestones (repo_id, title, description, due_on)
- VALUES ($1, $2, $3, $4) RETURNING *
- "#,
- )
- .bind(repo_id)
- .bind(title)
- .bind(description)
- .bind(due_on)
- .fetch_one(pool)
- .await?)
-}
-
-pub async fn update_milestone(
- pool: &PgPool,
- id: Uuid,
- title: &str,
- description: &str,
- due_on: Option<NaiveDate>,
-) -> Result<Milestone> {
- Ok(sqlx::query_as::<_, Milestone>(
- r#"
- UPDATE milestones SET
- title = $2,
- description = $3,
- due_on = $4,
- updated_at = now()
- WHERE id = $1
- RETURNING *
- "#,
- )
- .bind(id)
- .bind(title)
- .bind(description)
- .bind(due_on)
- .fetch_one(pool)
- .await?)
-}
-
-pub async fn set_milestone_state(pool: &PgPool, id: Uuid, state: &str) -> Result<Milestone> {
- let closed = if state == "closed" {
- Some(Utc::now())
- } else {
- None
- };
- Ok(sqlx::query_as::<_, Milestone>(
- r#"
- UPDATE milestones SET
- state = $2,
- closed_at = $3,
- updated_at = now()
- WHERE id = $1
- RETURNING *
- "#,
- )
- .bind(id)
- .bind(state)
- .bind(closed)
- .fetch_one(pool)
- .await?)
-}
-
-pub async fn delete_milestone(pool: &PgPool, id: Uuid) -> Result<()> {
- sqlx::query("DELETE FROM milestones WHERE id = $1")
- .bind(id)
- .execute(pool)
- .await?;
- Ok(())
-}
-
-pub async fn list_issue_labels(pool: &PgPool, issue_id: Uuid) -> Result<Vec<Label>> {
- Ok(sqlx::query_as::<_, Label>(
- r#"
- SELECT l.* FROM labels l
- JOIN issue_labels il ON il.label_id = l.id
- WHERE il.issue_id = $1
- ORDER BY lower(l.name)
- "#,
- )
- .bind(issue_id)
- .fetch_all(pool)
- .await?)
-}
-
-pub async fn list_pull_labels(pool: &PgPool, pull_id: Uuid) -> Result<Vec<Label>> {
- Ok(sqlx::query_as::<_, Label>(
- r#"
- SELECT l.* FROM labels l
- JOIN pull_labels pl ON pl.label_id = l.id
- WHERE pl.pull_id = $1
- ORDER BY lower(l.name)
- "#,
- )
- .bind(pull_id)
- .fetch_all(pool)
- .await?)
-}
-
-pub async fn set_issue_labels(pool: &PgPool, issue_id: Uuid, label_ids: &[Uuid]) -> Result<()> {
- let mut tx = pool.begin().await?;
- sqlx::query("DELETE FROM issue_labels WHERE issue_id = $1")
- .bind(issue_id)
- .execute(&mut *tx)
- .await?;
- for id in label_ids {
- sqlx::query(
- "INSERT INTO issue_labels (issue_id, label_id) VALUES ($1, $2) ON CONFLICT DO NOTHING",
- )
- .bind(issue_id)
- .bind(id)
- .execute(&mut *tx)
- .await?;
- }
- sqlx::query("UPDATE issues SET updated_at = now() WHERE id = $1")
- .bind(issue_id)
- .execute(&mut *tx)
- .await?;
- tx.commit().await?;
- Ok(())
-}
-
-pub async fn set_pull_labels(pool: &PgPool, pull_id: Uuid, label_ids: &[Uuid]) -> Result<()> {
- let mut tx = pool.begin().await?;
- sqlx::query("DELETE FROM pull_labels WHERE pull_id = $1")
- .bind(pull_id)
- .execute(&mut *tx)
- .await?;
- for id in label_ids {
- sqlx::query(
- "INSERT INTO pull_labels (pull_id, label_id) VALUES ($1, $2) ON CONFLICT DO NOTHING",
- )
- .bind(pull_id)
- .bind(id)
- .execute(&mut *tx)
- .await?;
- }
- sqlx::query("UPDATE pull_requests SET updated_at = now() WHERE id = $1")
- .bind(pull_id)
- .execute(&mut *tx)
- .await?;
- tx.commit().await?;
- Ok(())
-}
-
-pub async fn labels_for_issues(pool: &PgPool, issue_ids: &[Uuid]) -> Result<Vec<(Uuid, Label)>> {
- if issue_ids.is_empty() {
- return Ok(Vec::new());
- }
- let rows = sqlx::query_as::<_, IssueLabelRow>(
- r#"
- SELECT il.issue_id, l.id, l.repo_id, l.name, l.color, l.description, l.created_at
- FROM issue_labels il
- JOIN labels l ON l.id = il.label_id
- WHERE il.issue_id = ANY($1)
- ORDER BY lower(l.name)
- "#,
- )
- .bind(issue_ids)
- .fetch_all(pool)
- .await?;
- Ok(rows.into_iter().map(Into::into).collect())
-}
-
-pub async fn labels_for_pulls(pool: &PgPool, pull_ids: &[Uuid]) -> Result<Vec<(Uuid, Label)>> {
- if pull_ids.is_empty() {
- return Ok(Vec::new());
- }
- let rows = sqlx::query_as::<_, PullLabelRow>(
- r#"
- SELECT pl.pull_id, l.id, l.repo_id, l.name, l.color, l.description, l.created_at
- FROM pull_labels pl
- JOIN labels l ON l.id = pl.label_id
- WHERE pl.pull_id = ANY($1)
- ORDER BY lower(l.name)
- "#,
- )
- .bind(pull_ids)
- .fetch_all(pool)
- .await?;
- Ok(rows.into_iter().map(Into::into).collect())
-}
-
-pub async fn get_milestones_by_ids(pool: &PgPool, ids: &[Uuid]) -> Result<Vec<Milestone>> {
- if ids.is_empty() {
- return Ok(Vec::new());
- }
- Ok(sqlx::query_as::<_, Milestone>(
- "SELECT * FROM milestones WHERE id = ANY($1)",
- )
- .bind(ids)
- .fetch_all(pool)
- .await?)
-}
-
-pub async fn filter_repo_label_ids(
- pool: &PgPool,
- repo_id: Uuid,
- label_ids: &[Uuid],
-) -> Result<Vec<Uuid>> {
- if label_ids.is_empty() {
- return Ok(Vec::new());
- }
- let rows: Vec<(Uuid,)> = sqlx::query_as(
- "SELECT id FROM labels WHERE repo_id = $1 AND id = ANY($2)",
- )
- .bind(repo_id)
- .bind(label_ids)
- .fetch_all(pool)
- .await?;
- Ok(rows.into_iter().map(|r| r.0).collect())
-}
diff --git a/src/git/blame.rs b/src/git/blame.rs
deleted file mode 100644
index 1b55286..0000000
--- a/src/git/blame.rs
+++ /dev/null
@@ -1,134 +0,0 @@
-//! Blame and path history helpers (git2).
-
-use anyhow::{anyhow, Result};
-use git2::{BlameOptions, Commit, Oid, Repository as G2Repo, Sort};
-use std::collections::HashMap;
-use std::path::Path;
-
-use super::repo::{read_blob, resolve_ref, CommitInfo};
-
-/// One displayed line in a blame view.
-#[derive(Debug, Clone)]
-pub struct BlameLine {
- pub line_no: usize,
- pub content: String,
- pub commit_id: String,
- pub short_id: String,
- pub author: String,
- pub time: i64,
- pub summary: String,
- /// First line of a contiguous blame hunk (same final commit).
- pub hunk_start: bool,
-}
-
-/// Commits that introduced a change to `path` (blob OID differs from parent), newest first.
-pub fn list_commits_for_path(
- repo: &G2Repo,
- reference: &str,
- path: &str,
- limit: usize,
-) -> Result<Vec<CommitInfo>> {
- let oid = resolve_ref(repo, reference)?;
- let mut revwalk = repo.revwalk()?;
- revwalk.push(oid)?;
- revwalk.set_sorting(Sort::TIME)?;
- let mut out = Vec::new();
- for id in revwalk {
- let id = id?;
- let c = repo.find_commit(id)?;
- if !path_changed_in_commit(&c, path)? {
- continue;
- }
- let author = c.author();
- out.push(CommitInfo {
- id: id.to_string(),
- short_id: id.to_string()[..7.min(id.to_string().len())].to_string(),
- message: c.summary().unwrap_or("").to_string(),
- author: author.name().unwrap_or("").to_string(),
- email: author.email().unwrap_or("").to_string(),
- time: author.when().seconds(),
- });
- if out.len() >= limit {
- break;
- }
- }
- Ok(out)
-}
-
-fn path_changed_in_commit(commit: &Commit, path: &str) -> Result<bool> {
- let tree = commit.tree()?;
- let new_id = tree.get_path(Path::new(path)).ok().map(|e| e.id());
- if commit.parent_count() == 0 {
- return Ok(new_id.is_some());
- }
- let parent_tree = commit.parent(0)?.tree()?;
- let old_id = parent_tree.get_path(Path::new(path)).ok().map(|e| e.id());
- Ok(old_id != new_id)
-}
-
-/// Per-line blame for a text file at `reference`:`path`.
-pub fn blame_file(repo: &G2Repo, reference: &str, path: &str) -> Result<Vec<BlameLine>> {
- let (data, binary) = read_blob(repo, reference, path)?;
- if binary {
- return Err(anyhow!("binary file"));
- }
- let oid = resolve_ref(repo, reference)?;
- let mut opts = BlameOptions::new();
- opts.newest_commit(oid);
- let blame = repo.blame_file(Path::new(path), Some(&mut opts))?;
-
- let text = String::from_utf8_lossy(&data);
- let mut lines: Vec<&str> = text.split('\n').collect();
- if text.ends_with('\n') {
- lines.pop();
- }
-
- let mut summaries: HashMap<Oid, String> = HashMap::new();
- let mut out = Vec::with_capacity(lines.len());
- let mut prev_commit: Option<Oid> = None;
-
- for (i, line) in lines.iter().enumerate() {
- let line_no = i + 1;
- let Some(hunk) = blame.get_line(line_no) else {
- out.push(BlameLine {
- line_no,
- content: (*line).to_string(),
- commit_id: String::new(),
- short_id: String::new(),
- author: String::new(),
- time: 0,
- summary: String::new(),
- hunk_start: true,
- });
- continue;
- };
- let commit_oid = hunk.final_commit_id();
- let commit_id = commit_oid.to_string();
- let short_id = commit_id[..7.min(commit_id.len())].to_string();
- let sig = hunk.final_signature();
- let author = sig.name().unwrap_or("").to_string();
- let time = sig.when().seconds();
- let summary = summaries
- .entry(commit_oid)
- .or_insert_with(|| {
- repo.find_commit(commit_oid)
- .ok()
- .and_then(|c| c.summary().map(|s| s.to_string()))
- .unwrap_or_default()
- })
- .clone();
- let hunk_start = prev_commit != Some(commit_oid);
- prev_commit = Some(commit_oid);
- out.push(BlameLine {
- line_no,
- content: (*line).to_string(),
- commit_id,
- short_id,
- author,
- time,
- summary,
- hunk_start,
- });
- }
- Ok(out)
-}
diff --git a/src/git/http.rs b/src/git/http.rs
index a816ab9..05a5e73 100644
--- a/src/git/http.rs
+++ b/src/git/http.rs
@@ -166,18 +166,6 @@ async fn post_push_hooks(
.bind(repo.id)
.execute(&state.pool)
.await;
- crate::webhooks::spawn_dispatch(
- state.pool.clone(),
- crate::webhooks::EVENT_PUSH,
- "push".into(),
- repo.clone(),
- owner.to_string(),
- Some(user.clone()),
- serde_json::json!({
- "ref": format!("refs/heads/{}", repo.default_branch),
- "default_branch": repo.default_branch,
- }),
- );
Ok(())
}
diff --git a/src/git/mod.rs b/src/git/mod.rs
index 9dc4ce8..b402e6d 100644
--- a/src/git/mod.rs
+++ b/src/git/mod.rs
@@ -1,4 +1,3 @@
-pub mod blame;
pub mod http;
pub mod languages;
pub mod lfs;
@@ -6,6 +5,5 @@ pub mod repo;
pub mod ssh;
pub mod verify;
-pub use blame::{blame_file, list_commits_for_path};
pub use repo::*;
pub use verify::{extract_commit_signature, verify_commit_signature};
diff --git a/src/git/repo.rs b/src/git/repo.rs
index 16cc994..06e53dd 100644
--- a/src/git/repo.rs
+++ b/src/git/repo.rs
@@ -79,83 +79,6 @@ pub fn rename_branch(repo: &G2Repo, old: &str, new: &str) -> Result<()> {
Ok(())
}
-/// Whether `url` is an allowed remote for pull-mirroring (no local/file paths).
-pub fn is_safe_mirror_url(url: &str) -> bool {
- let url = url.trim();
- if url.is_empty() || url.contains('\0') || url.contains('\n') || url.contains('\r') {
- return false;
- }
- let lower = url.to_ascii_lowercase();
- if lower.starts_with("file:")
- || lower.starts_with('/')
- || lower.starts_with("\\\\")
- || (url.len() >= 2 && url.as_bytes()[1] == b':')
- {
- return false;
- }
- lower.starts_with("https://")
- || lower.starts_with("http://")
- || lower.starts_with("git://")
- || lower.starts_with("ssh://")
- || (url.contains('@') && url.contains(':') && !lower.contains("://"))
-}
-
-/// Fetch refs from `remote_url` into the existing bare repo (pull mirror).
-pub fn mirror_fetch(repos_root: &Path, owner: &str, name: &str, remote_url: &str) -> Result<()> {
- if !is_safe_mirror_url(remote_url) {
- return Err(anyhow!("unsupported or unsafe mirror URL"));
- }
- let path = bare_path(repos_root, owner, name);
- if !path.exists() {
- return Err(anyhow!("local bare repository missing"));
- }
- let path_str = path
- .to_str()
- .ok_or_else(|| anyhow!("repository path is not valid UTF-8"))?;
- let url = remote_url.trim();
-
- // Dedicated remote so we do not clobber a user's `origin`.
- let _ = std::process::Command::new("git")
- .args(["-C", path_str, "remote", "remove", "kitgit-mirror"])
- .output();
- let add = std::process::Command::new("git")
- .args(["-C", path_str, "remote", "add", "kitgit-mirror", url])
- .output()
- .with_context(|| "git remote add")?;
- if !add.status.success() {
- return Err(anyhow!(
- "git remote add failed: {}",
- String::from_utf8_lossy(&add.stderr).trim()
- ));
- }
-
- let fetch = std::process::Command::new("git")
- .args([
- "-C",
- path_str,
- "fetch",
- "kitgit-mirror",
- "+refs/heads/*:refs/heads/*",
- "+refs/tags/*:refs/tags/*",
- "--prune",
- "--force",
- ])
- .output()
- .with_context(|| "git fetch mirror")?;
- if !fetch.status.success() {
- return Err(anyhow!(
- "git fetch failed: {}",
- String::from_utf8_lossy(&fetch.stderr).trim()
- ));
- }
-
- if let Ok(repo) = G2Repo::open_bare(&path) {
- let _ = repo.config()?.set_bool("http.receivepack", true);
- let _ = repo.config()?.set_bool("http.uploadpack", true);
- }
- Ok(())
-}
-
/// Copy a bare repository on disk (for forks).
pub fn clone_bare(src: &Path, dest: &Path) -> Result<()> {
if dest.exists() {
@@ -391,13 +314,6 @@ pub fn list_commits(repo: &G2Repo, reference: &str, limit: usize) -> Result<Vec<
Ok(out)
}
-pub fn count_commits(repo: &G2Repo, reference: &str) -> Result<usize> {
- let oid = resolve_ref(repo, reference)?;
- let mut revwalk = repo.revwalk()?;
- revwalk.push(oid)?;
- Ok(revwalk.count())
-}
-
pub fn get_commit(repo: &G2Repo, id: &str) -> Result<CommitInfo> {
let oid = Oid::from_str(id)?;
let c = repo.find_commit(oid)?;
diff --git a/src/git/ssh.rs b/src/git/ssh.rs
index 89f0474..95d2ba2 100644
--- a/src/git/ssh.rs
+++ b/src/git/ssh.rs
@@ -101,46 +101,28 @@ impl Server for SshServer {
state: self.state.clone(),
user_id: None,
username: None,
- deploy_key: None,
stdin: None,
receive_meta: None,
}
}
}
-/// Authenticated via a repo-scoped deploy key (not a user key).
-struct DeployKeyAuth {
- repo_id: Uuid,
- read_only: bool,
- created_by: Option<Uuid>,
- name: String,
-}
-
struct SshHandler {
state: AppState,
user_id: Option<Uuid>,
username: Option<String>,
- deploy_key: Option<DeployKeyAuth>,
stdin: Option<Arc<Mutex<tokio::process::ChildStdin>>>,
receive_meta: Option<(String, String)>,
}
impl SshHandler {
- fn is_authenticated(&self) -> bool {
- self.user_id.is_some() || self.deploy_key.is_some()
- }
-
/// Print `static/text.txt` (with `{user}` substituted) and close — no shell access.
async fn greet_and_quit(
&self,
channel: ChannelId,
session: &mut Session,
) -> Result<(), anyhow::Error> {
- let username = self
- .username
- .as_deref()
- .or_else(|| self.deploy_key.as_ref().map(|d| d.name.as_str()))
- .unwrap_or("git");
+ let username = self.username.as_deref().unwrap_or("git");
let path = self.state.config.static_dir.join("text.txt");
let mut msg = tokio::fs::read_to_string(&path).await.unwrap_or_else(|_| {
format!(
@@ -177,20 +159,6 @@ impl Handler for SshHandler {
}
self.user_id = Some(user.id);
self.username = Some(user.username);
- self.deploy_key = None;
- return Ok(Auth::Accept);
- }
- if let Some(key) =
- crate::db::deploy_keys::deploy_key_by_fingerprint(&self.state.pool, &fp).await?
- {
- self.user_id = None;
- self.username = Some(format!("deploy:{}", key.name));
- self.deploy_key = Some(DeployKeyAuth {
- repo_id: key.repo_id,
- read_only: key.read_only,
- created_by: key.created_by,
- name: key.name,
- });
return Ok(Auth::Accept);
}
tracing::debug!("ssh publickey rejected fp={fp}");
@@ -237,7 +205,7 @@ impl Handler for SshHandler {
channel: ChannelId,
session: &mut Session,
) -> Result<(), Self::Error> {
- if !self.is_authenticated() {
+ if self.user_id.is_none() {
session.channel_failure(channel)?;
return Ok(());
}
@@ -251,10 +219,10 @@ impl Handler for SshHandler {
session: &mut Session,
) -> Result<(), Self::Error> {
let cmd = String::from_utf8_lossy(data).to_string();
- if !self.is_authenticated() {
+ let Some(user_id) = self.user_id else {
session.channel_failure(channel)?;
return Ok(());
- }
+ };
let (service, repo_path) = match parse_git_command(&cmd) {
Ok(v) => v,
@@ -271,26 +239,11 @@ impl Handler for SshHandler {
session.close(channel)?;
return Ok(());
};
-
- let allowed = if let Some(dk) = &self.deploy_key {
- // Deploy keys only unlock the single repo they were added to.
- if dk.repo_id != repo.id {
- false
- } else {
- match service {
- "git-upload-pack" => true,
- "git-receive-pack" => !dk.read_only && !repo.archived,
- _ => false,
- }
- }
- } else {
- let user_id = self.user_id.expect("authenticated without deploy key");
- let access = queries::repo_access(&self.state.pool, &repo, Some(user_id)).await?;
- match service {
- "git-upload-pack" => access.can_read(),
- "git-receive-pack" => access.can_write() && !repo.archived,
- _ => false,
- }
+ let access = queries::repo_access(&self.state.pool, &repo, Some(user_id)).await?;
+ let allowed = match service {
+ "git-upload-pack" => access.can_read(),
+ "git-receive-pack" => access.can_write() && !repo.archived,
+ _ => false,
};
if !allowed {
session.data(channel, b"access denied\n".as_slice())?;
@@ -358,55 +311,35 @@ impl Handler for SshHandler {
let mut g = stdin.lock().await;
let _ = g.shutdown().await;
}
- let Some((owner, name)) = self.receive_meta.take() else {
- return Ok(());
- };
- let Ok(Some((repo, _))) = queries::get_repo(&self.state.pool, &owner, &name).await else {
- return Ok(());
- };
- let actor_id = self
- .user_id
- .or_else(|| self.deploy_key.as_ref().and_then(|d| d.created_by));
- let mut sender = None;
- if let Some(uid) = actor_id {
- if let Ok(Some(user)) = queries::get_user_by_id(&self.state.pool, uid).await {
- let _ = queries::record_activity(
- &self.state.pool,
- Some(user.id),
- Some(repo.id),
- "push",
- "pushed",
- serde_json::json!({}),
- )
- .await;
- let _ = queries::bump_commit_activity(
- &self.state.pool,
- user.id,
- chrono::Utc::now().date_naive(),
- 1,
- )
- .await;
- sender = Some(user);
- }
- }
- if let Ok(g) = crate::git::open_bare(&self.state.config.repos_dir(), &owner, &name) {
- if let Ok(files) = crate::git::walk_files(&g, &repo.default_branch) {
- let stats = crate::git::languages::detect_languages(&files);
- let _ = queries::set_language_stats(&self.state.pool, repo.id, stats).await;
+ if let (Some(uid), Some((owner, name))) = (self.user_id, self.receive_meta.take()) {
+ if let Ok(Some((repo, _))) = queries::get_repo(&self.state.pool, &owner, &name).await {
+ if let Ok(Some(user)) = queries::get_user_by_id(&self.state.pool, uid).await {
+ let _ = queries::record_activity(
+ &self.state.pool,
+ Some(user.id),
+ Some(repo.id),
+ "push",
+ "pushed",
+ serde_json::json!({}),
+ )
+ .await;
+ let _ = queries::bump_commit_activity(
+ &self.state.pool,
+ user.id,
+ chrono::Utc::now().date_naive(),
+ 1,
+ )
+ .await;
+ if let Ok(g) = crate::git::open_bare(&self.state.config.repos_dir(), &owner, &name)
+ {
+ if let Ok(files) = crate::git::walk_files(&g, &repo.default_branch) {
+ let stats = crate::git::languages::detect_languages(&files);
+ let _ = queries::set_language_stats(&self.state.pool, repo.id, stats).await;
+ }
+ }
+ }
}
}
- crate::webhooks::spawn_dispatch(
- self.state.pool.clone(),
- crate::webhooks::EVENT_PUSH,
- "push".into(),
- repo.clone(),
- owner,
- sender,
- serde_json::json!({
- "ref": format!("refs/heads/{}", repo.default_branch),
- "default_branch": repo.default_branch,
- }),
- );
Ok(())
}
}
@@ -444,4 +377,4 @@ fn split_repo_path(path: &str) -> Result<(String, String)> {
let owner = parts.next().context("owner")?.to_string();
let name = parts.next().context("name")?.to_string();
Ok((owner, name))
-}
\ No newline at end of file
+}
diff --git a/src/main.rs b/src/main.rs
index 9be6dec..2a913a5 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -8,7 +8,6 @@ mod mfa;
mod og;
mod state;
mod web;
-mod webhooks;
use crate::auth::AuthState;
use crate::config::Config;
diff --git a/src/web/mod.rs b/src/web/mod.rs
index 2c3401a..6c6969c 100644
--- a/src/web/mod.rs
+++ b/src/web/mod.rs
@@ -4,74 +4,16 @@ pub mod templates;
use crate::git;
use crate::state::AppState;
-use axum::body::{Body, to_bytes};
use axum::extract::{DefaultBodyLimit, Request};
-use axum::http::{header, HeaderValue, StatusCode};
+use axum::http::{header, StatusCode};
use axum::middleware::{from_fn, Next};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::Router;
-use std::time::{Duration, Instant};
use tower_http::limit::RequestBodyLimitLayer;
use tower_http::services::ServeDir;
-const RENDER_MS_MARKER: &[u8] = b"<!--kg-render-ms-->";
-
-fn format_render_duration(elapsed: Duration) -> String {
- let ms = elapsed.as_secs_f64() * 1000.0;
- if ms < 10.0 {
- format!("{ms:.1}ms")
- } else {
- format!("{:.0}ms", ms.round())
- }
-}
-
-fn inject_render_marker(html: &[u8], label: &str) -> Option<Vec<u8>> {
- let pos = html
- .windows(RENDER_MS_MARKER.len())
- .position(|window| window == RENDER_MS_MARKER)?;
- let mut out = Vec::with_capacity(html.len() - RENDER_MS_MARKER.len() + label.len());
- out.extend_from_slice(&html[..pos]);
- out.extend_from_slice(label.as_bytes());
- out.extend_from_slice(&html[pos + RENDER_MS_MARKER.len()..]);
- Some(out)
-}
-
-/// Measure HTML page handler time and fill the layout footer timing marker.
-async fn inject_render_timing(req: Request, next: Next) -> Response {
- let start = Instant::now();
- let res = next.run(req).await;
- let elapsed = start.elapsed();
-
- let is_html = res
- .headers()
- .get(header::CONTENT_TYPE)
- .and_then(|v| v.to_str().ok())
- .is_some_and(|ct| ct.starts_with("text/html"));
- if !is_html {
- return res;
- }
-
- let (mut parts, body) = res.into_parts();
- let Ok(bytes) = to_bytes(body, 16 * 1024 * 1024).await else {
- return Response::from_parts(parts, Body::empty());
- };
-
- let label = format_render_duration(elapsed);
- let body = match inject_render_marker(&bytes, &label) {
- Some(updated) => {
- parts.headers.remove(header::CONTENT_LENGTH);
- if let Ok(len) = HeaderValue::from_str(&updated.len().to_string()) {
- parts.headers.insert(header::CONTENT_LENGTH, len);
- }
- Body::from(updated)
- }
- None => Body::from(bytes),
- };
- Response::from_parts(parts, body)
-}
-
-/// Rewrite `/owner/repo.git` → `/owner/repo` and drop a trailing slash so
+/// Rewrite `/owner/repo.git` → `/owner/repo` and drop a trailing slash so
/// browser pages and git smart-HTTP both work with classic forge URLs.
fn normalize_repo_path(path: &str) -> String {
let mut path = path.to_string();
@@ -131,7 +73,6 @@ pub fn app_router(state: AppState) -> Router {
.route("/auth/callback", get(routes::auth_callback))
.route("/auth/logout", get(routes::auth_logout))
.route("/admin", get(routes::admin_panel))
- .route("/admin/users/{username}/audit", get(routes::admin_user_audit))
.route("/admin/users", post(routes::admin_set_user))
.route("/admin/users/suspend", post(routes::admin_set_suspended))
.route("/admin/motd", post(routes::admin_save_motd))
@@ -148,19 +89,6 @@ pub fn app_router(state: AppState) -> Router {
)
.route("/admin/repos/{id}/delete", post(routes::admin_repo_delete))
.route("/site-banner.json", get(routes::site_banner_json))
- .route("/notifications", get(routes::notifications_list))
- .route(
- "/notifications/unread.json",
- get(routes::notifications_unread_json),
- )
- .route(
- "/notifications/read-all",
- post(routes::notifications_mark_all_read),
- )
- .route(
- "/notifications/{id}/read",
- post(routes::notifications_mark_read),
- )
.route("/new", get(routes::new_repo_form).post(routes::new_repo))
.route(
"/settings/profile",
@@ -184,10 +112,6 @@ pub fn app_router(state: AppState) -> Router {
post(routes_extra::account_privacy),
)
.route(
- "/settings/account/theme",
- post(routes_extra::account_theme),
- )
- .route(
"/settings/account/emails",
post(routes_extra::account_add_email),
)
@@ -196,10 +120,6 @@ pub fn app_router(state: AppState) -> Router {
post(routes_extra::account_delete_email),
)
.route(
- "/settings/account/emails/{id}/primary",
- post(routes_extra::account_set_primary_email),
- )
- .route(
"/settings/account/sessions/{id}/revoke",
post(routes_extra::account_revoke_session),
)
@@ -242,8 +162,6 @@ pub fn app_router(state: AppState) -> Router {
.route("/{owner}/{repo}/og.png", get(routes::repo_og_image))
.route("/{owner}/{repo}/tree/{*rest}", get(routes::repo_tree))
.route("/{owner}/{repo}/blob/{*rest}", get(routes::repo_blob))
- .route("/{owner}/{repo}/blame/{*rest}", get(routes::repo_blame))
- .route("/{owner}/{repo}/history/{*rest}", get(routes::repo_history))
.route("/{owner}/{repo}/raw/{*rest}", get(routes_extra::repo_raw))
.route("/{owner}/{repo}/star", post(routes_extra::repo_star))
.route("/{owner}/{repo}/watch", post(routes_extra::repo_watch))
@@ -259,20 +177,20 @@ pub fn app_router(state: AppState) -> Router {
.route("/{owner}/{repo}/upload", post(routes::repo_upload))
.route("/{owner}/{repo}/branches", get(routes::repo_branches))
.route(
- "/{owner}/{repo}/branches/rename",
+ "/{owner}/{repo}/branches/{branch}/rename",
post(routes_extra::branch_rename),
)
.route(
- "/{owner}/{repo}/branches/delete",
+ "/{owner}/{repo}/branches/{branch}/delete",
post(routes_extra::branch_delete),
)
.route("/{owner}/{repo}/tags", post(routes_extra::tag_create))
.route(
- "/{owner}/{repo}/tags/rename",
+ "/{owner}/{repo}/tags/{tag}/rename",
post(routes_extra::tag_rename),
)
.route(
- "/{owner}/{repo}/tags/delete",
+ "/{owner}/{repo}/tags/{tag}/delete",
post(routes_extra::tag_delete),
)
.route(
@@ -293,14 +211,6 @@ pub fn app_router(state: AppState) -> Router {
post(routes::issue_reopen),
)
.route(
- "/{owner}/{repo}/issues/{number}/labels",
- post(routes::issue_labels_save),
- )
- .route(
- "/{owner}/{repo}/issues/{number}/milestone",
- post(routes::issue_milestone_save),
- )
- .route(
"/{owner}/{repo}/pulls",
get(routes::pulls_list).post(routes::pull_create),
)
@@ -318,50 +228,6 @@ pub fn app_router(state: AppState) -> Router {
post(routes::pull_close),
)
.route(
- "/{owner}/{repo}/pulls/{number}/labels",
- post(routes::pull_labels_save),
- )
- .route(
- "/{owner}/{repo}/pulls/{number}/milestone",
- post(routes::pull_milestone_save),
- )
- .route(
- "/{owner}/{repo}/pulls/{number}/review",
- post(routes::pull_review),
- )
- .route(
- "/{owner}/{repo}/labels",
- get(routes_extra::labels_list).post(routes_extra::label_create),
- )
- .route(
- "/{owner}/{repo}/labels/{id}/update",
- post(routes_extra::label_update),
- )
- .route(
- "/{owner}/{repo}/labels/{id}/delete",
- post(routes_extra::label_delete),
- )
- .route(
- "/{owner}/{repo}/milestones",
- get(routes_extra::milestones_list).post(routes_extra::milestone_create),
- )
- .route(
- "/{owner}/{repo}/milestones/{id}/update",
- post(routes_extra::milestone_update),
- )
- .route(
- "/{owner}/{repo}/milestones/{id}/close",
- post(routes_extra::milestone_close),
- )
- .route(
- "/{owner}/{repo}/milestones/{id}/reopen",
- post(routes_extra::milestone_reopen),
- )
- .route(
- "/{owner}/{repo}/milestones/{id}/delete",
- post(routes_extra::milestone_delete),
- )
- .route(
"/{owner}/{repo}/releases",
get(routes::releases_list).post(routes::release_create),
)
@@ -397,26 +263,6 @@ pub fn app_router(state: AppState) -> Router {
post(routes::collab_remove),
)
.route(
- "/{owner}/{repo}/settings/deploy-keys",
- post(routes::deploy_key_add),
- )
- .route(
- "/{owner}/{repo}/settings/deploy-keys/{id}/delete",
- post(routes::deploy_key_delete),
- )
- .route(
- "/{owner}/{repo}/settings/webhooks",
- post(routes::webhook_add),
- )
- .route(
- "/{owner}/{repo}/settings/webhooks/{id}/delete",
- post(routes::webhook_delete),
- )
- .route(
- "/{owner}/{repo}/settings/webhooks/{id}/toggle",
- post(routes::webhook_toggle),
- )
- .route(
"/{owner}/{repo}/settings/branch-rules",
post(routes_extra::branch_rule_add),
)
@@ -425,18 +271,6 @@ pub fn app_router(state: AppState) -> Router {
post(routes_extra::branch_rule_delete),
)
.route(
- "/{owner}/{repo}/settings/mirror",
- post(routes_extra::mirror_save),
- )
- .route(
- "/{owner}/{repo}/settings/mirror/sync",
- post(routes_extra::mirror_sync),
- )
- .route(
- "/{owner}/{repo}/settings/mirror/delete",
- post(routes_extra::mirror_delete),
- )
- .route(
"/{owner}/{repo}/settings/danger/archive",
post(routes::repo_archive),
)
@@ -450,7 +284,6 @@ pub fn app_router(state: AppState) -> Router {
)
.route("/{username}", get(routes::profile))
.nest_service("/static", ServeDir::new(static_dir))
- .layer(from_fn(inject_render_timing))
.layer(from_fn(normalize_git_url))
.layer(DefaultBodyLimit::max(100 * 1024 * 1024))
.layer(RequestBodyLimitLayer::new(100 * 1024 * 1024))
@@ -459,8 +292,7 @@ pub fn app_router(state: AppState) -> Router {
#[cfg(test)]
mod path_tests {
- use super::{format_render_duration, inject_render_marker, normalize_repo_path};
- use std::time::Duration;
+ use super::normalize_repo_path;
#[test]
fn strips_git_suffix_and_slash() {
@@ -470,19 +302,5 @@ mod path_tests {
assert_eq!(normalize_repo_path("/o/r/"), "/o/r");
assert_eq!(normalize_repo_path("/"), "/");
}
-
- #[test]
- fn injects_render_timing_marker() {
- let html = b"<footer>rendered in <!--kg-render-ms--></footer>";
- let out = inject_render_marker(html, "4.2ms").unwrap();
- assert_eq!(out, b"<footer>rendered in 4.2ms</footer>");
- assert!(inject_render_marker(b"<footer>kitgit</footer>", "1ms").is_none());
- }
-
- #[test]
- fn formats_render_duration() {
- assert_eq!(format_render_duration(Duration::from_micros(4200)), "4.2ms");
- assert_eq!(format_render_duration(Duration::from_millis(42)), "42ms");
- }
}
src/web/routes.rs
+115
−1313
diff --git a/src/web/routes.rs b/src/web/routes.rs
index b99d0d7..2db68d1 100644
--- a/src/web/routes.rs
+++ b/src/web/routes.rs
@@ -20,7 +20,7 @@ use serde::Deserialize;
use std::path::PathBuf;
use uuid::Uuid;
-// ── helpers ──────────────────────────────────────────────────────────────────
+// ── helpers ──────────────────────────────────────────────────────────────────
pub type AppResult<T> = Result<T, AppError>;
@@ -113,95 +113,6 @@ fn redirect_with_cookies(to: &str, cookies: Vec<HeaderValue>) -> Response {
res
}
-/// Best-effort client IP / User-Agent from reverse-proxy headers.
-pub fn request_client_meta(headers: &HeaderMap) -> (Option<String>, Option<String>) {
- let ua = headers
- .get(axum::http::header::USER_AGENT)
- .and_then(|v| v.to_str().ok())
- .map(|s| s.chars().take(512).collect::<String>());
- let ip = headers
- .get("x-forwarded-for")
- .and_then(|v| v.to_str().ok())
- .and_then(|s| s.split(',').next())
- .map(|s| s.trim().to_string())
- .filter(|s| !s.is_empty())
- .or_else(|| {
- headers
- .get("x-real-ip")
- .and_then(|v| v.to_str().ok())
- .map(|s| s.trim().to_string())
- .filter(|s| !s.is_empty())
- });
- (ip, ua)
-}
-
-/// Write an audit_log row; failures are logged and never fail the request.
-pub async fn record_audit(
- state: &AppState,
- headers: &HeaderMap,
- user_id: Uuid,
- actor_id: Option<Uuid>,
- action: &str,
- metadata: serde_json::Value,
-) {
- let (ip, ua) = request_client_meta(headers);
- if let Err(e) = queries::record_audit_log(
- &state.pool,
- user_id,
- actor_id,
- action,
- ip.as_deref(),
- ua.as_deref(),
- metadata,
- )
- .await
- {
- tracing::warn!("audit_log write failed ({action}): {e:#}");
- }
-}
-
-fn audit_action_label(action: &str) -> &'static str {
- match action {
- "login.success" => "Signed in",
- "login.failure" => "Failed sign-in",
- "logout" => "Signed out",
- "ssh_key.add" => "Added SSH key",
- "ssh_key.delete" => "Removed SSH key",
- "gpg_key.add" => "Added GPG key",
- "gpg_key.delete" => "Removed GPG key",
- "session.revoke" => "Revoked session",
- "session.revoke_others" => "Revoked other sessions",
- "mfa.enable" => "Enabled MFA",
- "mfa.disable" => "Disabled MFA",
- "username.change" => "Changed username",
- "password.change" => "Changed password",
- "email.add" => "Added email",
- "email.delete" => "Removed email",
- "privacy.update" => "Updated privacy settings",
- _ => "Account activity",
- }
-}
-
-pub fn audit_entries_view(rows: Vec<crate::db::models::AuditLog>) -> Vec<AuditEntryView> {
- rows.into_iter()
- .map(|e| {
- let meta = if e.metadata.is_null() || e.metadata == serde_json::json!({}) {
- String::new()
- } else {
- e.metadata.to_string()
- };
- AuditEntryView {
- action: e.action.clone(),
- action_label: audit_action_label(&e.action).to_string(),
- ip: e.ip.unwrap_or_default(),
- user_agent: e.user_agent.unwrap_or_default(),
- metadata: meta,
- created_at: e.created_at,
- }
- })
- .collect()
-}
-
pub fn avatar_url_for(user: &User) -> String {
let bust = user.updated_at.timestamp();
// Always go through our avatar endpoint when a local file exists.
@@ -209,7 +120,7 @@ pub fn avatar_url_for(user: &User) -> String {
return format!("/avatars/{}?v={}", user.id, bust);
}
// External OIDC pictures are fine in <img src>, but never use our own
- // /avatars/{id} URL as avatar_url — that creates a redirect loop.
+ // /avatars/{id} URL as avatar_url — that creates a redirect loop.
if let Some(ref url) = user.avatar_url {
if !url.is_empty() && !is_self_avatar_url(url, user.id) {
return url.clone();
@@ -333,12 +244,12 @@ fn language_stat_views(stats: serde_json::Value) -> Vec<LanguageStatView> {
fn reaction_label(emoji: &str) -> String {
match emoji {
- "+1" => "👍".into(),
- "-1" => "├░┼╕ΓÇÿ┼╜".into(),
- "heart" => "❤️".into(),
- "laugh" => "😄".into(),
- "rocket" => "🚀".into(),
- "eyes" => "├░┼╕ΓÇÿΓé¼".into(),
+ "+1" => "ðŸ‘".into(),
+ "-1" => "👎".into(),
+ "heart" => "â¤ï¸".into(),
+ "laugh" => "😄".into(),
+ "rocket" => "🚀".into(),
+ "eyes" => "👀".into(),
other => other.to_string(),
}
}
@@ -383,9 +294,9 @@ enum CommitRefKind {
Pull,
/// Explicit issue wording (`issue #N`).
Issue,
- /// Closing keywords (`fixes #N`, `closes #N`, …): prefer issue if it exists, else pull.
+ /// Closing keywords (`fixes #N`, `closes #N`, …): prefer issue if it exists, else pull.
Closing,
- /// Bare `#N` ΓÇö default to issues.
+ /// Bare `#N` — default to issues.
Bare,
}
@@ -451,7 +362,7 @@ async fn linkify_commit_message(
while i < bytes.len() {
if bytes[i] == b'#' {
if let Some((len, number)) = parse_hash_number(&message[i..]) {
- // Avoid matching mid-word like `C#1` or `foo#1` ΓÇö require start or non-alnum before.
+ // Avoid matching mid-word like `C#1` or `foo#1` — require start or non-alnum before.
let ok_boundary = i == 0
|| !message[..i]
.chars()
@@ -570,14 +481,16 @@ fn append_diff_line(out: &mut String, line: &str) {
out.push('\n');
}
-fn render_diff_html(diff: &str, full: bool) -> (String, bool, usize) {
+fn render_diff_html(diff: &str) -> String {
let lines: Vec<&str> = diff.lines().collect();
- let max = if full {
- None
+ let (html, truncated, total) = render_diff_lines(&lines, Some(DIFF_RENDER_MAX_LINES));
+ if truncated {
+ format!(
+ "{html}<p class=\"kg-diff__truncated\">Large diffs are not rendered by default. Showing the first {DIFF_RENDER_MAX_LINES} of {total} lines.</p>"
+ )
} else {
- Some(DIFF_RENDER_MAX_LINES)
- };
- render_diff_lines(&lines, max)
+ html
+ }
}
/// Render unified-diff lines. When `max_lines` is set, stop after that many
@@ -623,7 +536,7 @@ fn anchor_for_path(path: &str, idx: usize) -> String {
format!("diff-{idx}-{safe}")
}
-fn parse_diff_files(diff: &str, full: bool) -> Vec<DiffFileView> {
+fn parse_diff_files(diff: &str) -> Vec<DiffFileView> {
let mut files = Vec::new();
if diff.trim().is_empty() {
return files;
@@ -633,23 +546,23 @@ fn parse_diff_files(diff: &str, full: bool) -> Vec<DiffFileView> {
for line in diff.lines() {
if line.starts_with("diff --git ") {
if let Some((path, lines)) = current.take() {
- files.push(build_diff_file(files.len(), path, &lines, full));
+ files.push(build_diff_file(files.len(), path, &lines));
}
current = Some((diff_file_path(line), vec![line.to_string()]));
} else if let Some((_, ref mut lines)) = current {
lines.push(line.to_string());
} else {
- // Preamble without a file header — treat as one blob.
+ // Preamble without a file header — treat as one blob.
current = Some(("diff".into(), vec![line.to_string()]));
}
}
if let Some((path, lines)) = current {
- files.push(build_diff_file(files.len(), path, &lines, full));
+ files.push(build_diff_file(files.len(), path, &lines));
}
files
}
-fn build_diff_file(idx: usize, path: String, lines: &[String], full: bool) -> DiffFileView {
+fn build_diff_file(idx: usize, path: String, lines: &[String]) -> DiffFileView {
let mut additions = 0u32;
let mut deletions = 0u32;
let mut binary = false;
@@ -664,11 +577,6 @@ fn build_diff_file(idx: usize, path: String, lines: &[String], full: bool) -> Di
}
}
let refs: Vec<&str> = lines.iter().map(|s| s.as_str()).collect();
- let max = if full {
- None
- } else {
- Some(DIFF_RENDER_MAX_LINES)
- };
let (html, truncated, total_lines) = if binary {
(
String::from(
@@ -678,7 +586,7 @@ fn build_diff_file(idx: usize, path: String, lines: &[String], full: bool) -> Di
refs.len(),
)
} else {
- render_diff_lines(&refs, max)
+ render_diff_lines(&refs, Some(DIFF_RENDER_MAX_LINES))
};
DiffFileView {
anchor: anchor_for_path(&path, idx),
@@ -711,7 +619,7 @@ async fn latest_commit_view(
Some(commit_view(state, c, extracted).await)
}
-pub fn clone_urls(state: &AppState, owner: &str, repo: &str) -> (String, String) {
+fn clone_urls(state: &AppState, owner: &str, repo: &str) -> (String, String) {
let base = state.config.public_url.trim_end_matches('/');
// Always advertise the classic `.git` HTTP URL (middleware strips it).
let http = format!("{base}/{owner}/{repo}.git");
@@ -721,7 +629,7 @@ pub fn clone_urls(state: &AppState, owner: &str, repo: &str) -> (String, String)
.unwrap_or_else(|| "localhost".into());
let port = state.config.ssh_advertise_port();
// Port 22: GitHub-style `git@host:owner/repo.git` (default SSH port).
- // Other ports: ssh:// form ΓÇö `git@host:2222/path` is parsed as path `2222/path` on port 22.
+ // Other ports: ssh:// form — `git@host:2222/path` is parsed as path `2222/path` on port 22.
let ssh = if port == 22 {
format!("git@{host}:{owner}/{repo}.git")
} else {
@@ -806,33 +714,6 @@ fn breadcrumbs(path: &str) -> Vec<(String, String)> {
out
}
-fn query_full(v: Option<&str>) -> bool {
- matches!(v, Some("1") | Some("true") | Some("yes") | Some("on"))
-}
-
-async fn review_views(
- pool: &sqlx::PgPool,
- reviews: Vec<crate::db::models::PullReview>,
-) -> AppResult<Vec<PullReviewView>> {
- let mut out = Vec::with_capacity(reviews.len());
- for r in reviews {
- let reviewer = queries::get_user_by_id(pool, r.reviewer_id)
- .await?
- .ok_or_else(AppError::not_found)?;
- let avatar_url = avatar_url_for(&reviewer);
- out.push(PullReviewView {
- id: r.id,
- reviewer,
- avatar_url,
- state: r.state,
- body: r.body.clone(),
- body_html: render_markdown(&r.body),
- created_at: r.created_at,
- });
- }
- Ok(out)
-}
-
async fn comment_views(
pool: &sqlx::PgPool,
comments: Vec<Comment>,
@@ -946,7 +827,7 @@ fn map_activity_rows(
activities
}
-// ── home / auth ──────────────────────────────────────────────────────────────
+// ── home / auth ──────────────────────────────────────────────────────────────
pub async fn home(
State(state): State<AppState>,
@@ -1128,46 +1009,22 @@ pub struct LoginForm {
pub async fn auth_login_submit(
State(state): State<AppState>,
- headers: HeaderMap,
Form(form): Form<LoginForm>,
) -> AppResult<Response> {
match auth::login_with_password(&state.auth, &form.username, &form.password).await {
- Ok(LoginOutcome::Complete { user, token }) => {
- record_audit(
- &state,
- &headers,
- user.id,
- Some(user.id),
- "login.success",
- serde_json::json!({ "method": "password" }),
- )
- .await;
- Ok(redirect_with_cookies(
- "/",
- vec![
- session_cookie_header(&token, 14 * 24 * 3600),
- clear_mfa_pending_cookie(),
- ],
- ))
- }
+ Ok(LoginOutcome::Complete { token, .. }) => Ok(redirect_with_cookies(
+ "/",
+ vec![
+ session_cookie_header(&token, 14 * 24 * 3600),
+ clear_mfa_pending_cookie(),
+ ],
+ )),
Ok(LoginOutcome::MfaRequired { pending_token }) => Ok(redirect_with_cookie(
"/auth/mfa",
mfa_pending_cookie_header(&pending_token, 10 * 60),
)),
Err(e) => {
tracing::warn!("login failed: {e:#}");
- let uname = form.username.trim().to_ascii_lowercase();
- if let Ok(Some(u)) = queries::get_user_by_username(&state.pool, &uname).await {
- record_audit(
- &state,
- &headers,
- u.id,
- None,
- "login.failure",
- serde_json::json!({ "method": "password", "username": uname }),
- )
- .await;
- }
Ok(LoginTemplate {
viewer: None,
error: Some(crate::mfa::sanitize_user_error(&e.to_string())),
@@ -1208,24 +1065,13 @@ pub async fn auth_mfa_submit(
return Ok(redirect("/auth/login"));
};
match auth::complete_mfa_login(&state.auth, &pending, &form.code).await {
- Ok((user, token)) => {
- record_audit(
- &state,
- &headers,
- user.id,
- Some(user.id),
- "login.success",
- serde_json::json!({ "method": "password+mfa" }),
- )
- .await;
- Ok(redirect_with_cookies(
- "/",
- vec![
- session_cookie_header(&token, 14 * 24 * 3600),
- clear_mfa_pending_cookie(),
- ],
- ))
- }
+ Ok((_user, token)) => Ok(redirect_with_cookies(
+ "/",
+ vec![
+ session_cookie_header(&token, 14 * 24 * 3600),
+ clear_mfa_pending_cookie(),
+ ],
+ )),
Err(e) => {
tracing::warn!("mfa challenge failed: {e:#}");
let msg = crate::mfa::sanitize_user_error(&e.to_string());
@@ -1241,7 +1087,7 @@ pub async fn auth_mfa_submit(
}
}
-/// Legacy OIDC browser start ΓÇö disabled so users never leave kitgit UI.
+/// Legacy OIDC browser start — disabled so users never leave kitgit UI.
pub async fn auth_oidc_start() -> AppResult<Response> {
Ok(redirect("/auth/login"))
}
@@ -1375,7 +1221,6 @@ pub struct CallbackQuery {
pub async fn auth_callback(
State(state): State<AppState>,
- headers: HeaderMap,
Query(q): Query<CallbackQuery>,
) -> AppResult<Response> {
if let Some(err) = q.error {
@@ -1383,16 +1228,7 @@ pub async fn auth_callback(
}
let code = q.code.ok_or_else(|| AppError::bad("missing code"))?;
let st = q.state.ok_or_else(|| AppError::bad("missing state"))?;
- let (user, token) = auth::finish_login(&state.auth, &code, &st).await?;
- record_audit(
- &state,
- &headers,
- user.id,
- Some(user.id),
- "login.success",
- serde_json::json!({ "method": "oidc" }),
- )
- .await;
+ let (_user, token) = auth::finish_login(&state.auth, &code, &st).await?;
let cookie = session_cookie_header(&token, 14 * 24 * 3600);
Ok(redirect_with_cookie("/", cookie))
}
@@ -1401,22 +1237,11 @@ pub async fn auth_logout(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Response> {
- if let Ok(Some(user)) = current_user(&state.auth, &headers).await {
- record_audit(
- &state,
- &headers,
- user.id,
- Some(user.id),
- "logout",
- serde_json::json!({}),
- )
- .await;
- }
auth::logout(&state.auth, &headers).await?;
Ok(redirect_with_cookie("/", clear_session_cookie()))
}
-// ── site admin panel ─────────────────────────────────────────────────────────
+// ── site admin panel ─────────────────────────────────────────────────────────
async fn require_site_admin(auth: &AuthState, headers: &HeaderMap) -> AppResult<User> {
let user = require_login(auth, headers).await?;
@@ -1570,24 +1395,6 @@ pub async fn admin_panel(
})
}
-pub async fn admin_user_audit(
- State(state): State<AppState>,
- headers: HeaderMap,
- Path(username): Path<String>,
-) -> AppResult<impl IntoResponse> {
- let viewer = require_site_admin(&state.auth, &headers).await?;
- let user = queries::get_user_by_username(&state.pool, &username)
- .await?
- .ok_or_else(AppError::not_found)?;
- let audit_rows = queries::list_audit_log_for_user(&state.pool, user.id, 100).await?;
- let audit_entries = audit_entries_view(audit_rows);
- Ok(AdminUserAuditTemplate {
- viewer: Some(viewer),
- user,
- audit_entries,
- })
-}
-
#[derive(Deserialize)]
pub struct AdminToggleForm {
pub user_id: Uuid,
@@ -1788,61 +1595,7 @@ pub async fn site_banner_json(State(state): State<AppState>) -> impl IntoRespons
axum::Json(serde_json::json!({ "message": message }))
}
-fn notif_snippet(body: &str, max: usize) -> String {
- let compact: String = body.split_whitespace().collect::<Vec<_>>().join(" ");
- if compact.chars().count() <= max {
- compact
- } else {
- let truncated: String = compact.chars().take(max.saturating_sub(1)).collect();
- format!("{truncated}…")
- }
-}
-
-pub async fn notifications_list(
- State(state): State<AppState>,
- headers: HeaderMap,
-) -> AppResult<impl IntoResponse> {
- let viewer = require_login(&state.auth, &headers).await?;
- let notifications = queries::list_notifications(&state.pool, viewer.id, 100).await?;
- let unread_count = queries::unread_notification_count(&state.pool, viewer.id).await?;
- Ok(NotificationsTemplate {
- viewer: Some(viewer),
- notifications,
- unread_count,
- })
-}
-
-pub async fn notifications_unread_json(
- State(state): State<AppState>,
- headers: HeaderMap,
-) -> AppResult<impl IntoResponse> {
- let viewer = require_login(&state.auth, &headers).await?;
- let count = queries::unread_notification_count(&state.pool, viewer.id).await?;
- Ok(axum::Json(serde_json::json!({ "count": count })))
-}
-
-pub async fn notifications_mark_read(
- State(state): State<AppState>,
- headers: HeaderMap,
- Path(id): Path<Uuid>,
-) -> AppResult<Response> {
- let viewer = require_login(&state.auth, &headers).await?;
- queries::mark_notification_read(&state.pool, id, viewer.id)
- .await?
- .ok_or_else(AppError::not_found)?;
- Ok(redirect_see_other("/notifications"))
-}
-
-pub async fn notifications_mark_all_read(
- State(state): State<AppState>,
- headers: HeaderMap,
-) -> AppResult<Response> {
- let viewer = require_login(&state.auth, &headers).await?;
- queries::mark_all_notifications_read(&state.pool, viewer.id).await?;
- Ok(redirect_see_other("/notifications"))
-}
-
-// ── new repo ─────────────────────────────────────────────────────────────────
+// ── new repo ─────────────────────────────────────────────────────────────────
pub async fn new_repo_form(
State(state): State<AppState>,
@@ -1933,7 +1686,7 @@ pub async fn new_repo(
Ok(redirect_see_other(&format!("/{}/{}", user.username, name)))
}
-// ── profile settings ─────────────────────────────────────────────────────────
+// ── profile settings ─────────────────────────────────────────────────────────
pub async fn profile_settings(
State(state): State<AppState>,
@@ -2048,7 +1801,7 @@ pub async fn profile_settings_save(
Ok(redirect_see_other("/settings/profile"))
}
-// ── SSH keys ─────────────────────────────────────────────────────────────────
+// ── SSH keys ─────────────────────────────────────────────────────────────────
pub async fn keys_settings(
State(state): State<AppState>,
@@ -2096,15 +1849,6 @@ pub async fn keys_add(
queries::add_ssh_key(&state.pool, user.id, name, public_key, &fp, key_usage)
.await
.map_err(|e| AppError::bad(format!("could not add key: {e}")))?;
- record_audit(
- &state,
- &headers,
- user.id,
- Some(user.id),
- "ssh_key.add",
- serde_json::json!({ "name": name, "fingerprint": fp, "key_usage": key_usage }),
- )
- .await;
Ok(redirect_see_other("/settings/keys"))
}
@@ -2131,19 +1875,10 @@ pub async fn keys_delete(
) -> AppResult<Response> {
let user = require_login(&state.auth, &headers).await?;
queries::delete_ssh_key(&state.pool, user.id, id).await?;
- record_audit(
- &state,
- &headers,
- user.id,
- Some(user.id),
- "ssh_key.delete",
- serde_json::json!({ "key_id": id }),
- )
- .await;
Ok(redirect_see_other("/settings/keys"))
}
-// ── avatar ───────────────────────────────────────────────────────────────────
+// ── avatar ───────────────────────────────────────────────────────────────────
pub async fn avatar(
State(state): State<AppState>,
@@ -2190,7 +1925,7 @@ pub async fn avatar(
.unwrap())
}
-// ── profile ──────────────────────────────────────────────────────────────────
+// ── profile ──────────────────────────────────────────────────────────────────
pub async fn profile(
State(state): State<AppState>,
@@ -2232,7 +1967,7 @@ pub async fn profile(
})
}
-// ── repository browse ────────────────────────────────────────────────────────
+// ── repository browse ────────────────────────────────────────────────────────
pub async fn repo_home(
State(state): State<AppState>,
@@ -2256,11 +1991,16 @@ pub async fn repo_home(
branches = git::list_branches(g).unwrap_or_default();
if git::resolve_ref(g, ¤t_branch).is_ok() {
empty = false;
- entries = tree_entry_views(
- g,
- ¤t_branch,
- git::list_tree(g, ¤t_branch, "").unwrap_or_default(),
- );
+ entries = git::list_tree(g, ¤t_branch, "")
+ .unwrap_or_default()
+ .into_iter()
+ .map(|e| TreeEntryView {
+ name: e.name,
+ path: e.path,
+ is_dir: e.is_dir,
+ mode: e.mode,
+ })
+ .collect();
if let Ok(Some((name, src))) = git::find_readme(g, ¤t_branch, "") {
readme_html = Some(readme_to_html(
&name,
@@ -2290,10 +2030,6 @@ pub async fn repo_home(
}
None => None,
};
- let commit_count = match grepo.as_ref() {
- Some(g) => git::count_commits(g, ¤t_branch).unwrap_or(0),
- None => 0,
- };
let languages = language_stat_views(languages);
let forked_from = if let Some(fid) = repository.fork_of_id {
@@ -2329,7 +2065,6 @@ pub async fn repo_home(
languages,
empty,
latest_commit,
- commit_count,
forked_from,
starred,
watching,
@@ -2373,12 +2108,16 @@ pub async fn repo_tree(
let grepo = git::open_bare(&state.config.repos_dir(), &owner, &repo)
.map_err(|_| AppError::not_found())?;
let (branch, path) = split_ref_path(&grepo, &rest);
- let entries = tree_entry_views(
- &grepo,
- &branch,
- git::list_tree(&grepo, &branch, &path)
- .map_err(|e| AppError::not_found().with_message(e.to_string()))?,
- );
+ let entries = git::list_tree(&grepo, &branch, &path)
+ .map_err(|e| AppError::not_found().with_message(e.to_string()))?
+ .into_iter()
+ .map(|e| TreeEntryView {
+ name: e.name,
+ path: e.path,
+ is_dir: e.is_dir,
+ mode: e.mode,
+ })
+ .collect();
let readme_html = match git::find_readme(&grepo, &branch, &path) {
Ok(Some((name, src))) => Some(readme_to_html(
&name,
@@ -2394,7 +2133,6 @@ pub async fn repo_tree(
let branches = git::list_branches(&grepo).unwrap_or_default();
let prepared = prepare_latest_commit(&grepo, &branch);
let latest_commit = latest_commit_view(&state, prepared).await;
- let commit_count = git::count_commits(&grepo, &branch).unwrap_or(0);
let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo);
Ok(RepoTreeTemplate {
viewer,
@@ -2408,7 +2146,6 @@ pub async fn repo_tree(
entries,
readme_html,
latest_commit,
- commit_count,
clone_http,
clone_ssh,
})
@@ -2469,125 +2206,6 @@ pub async fn repo_blob(
})
}
-fn tree_entry_views(grepo: &git2::Repository, branch: &str, entries: Vec<git::TreeEntry>) -> Vec<TreeEntryView> {
- entries
- .into_iter()
- .map(|e| {
- let (commit_message, commit_time) = git::list_commits_for_path(grepo, branch, &e.path, 1)
- .ok()
- .and_then(|mut v| v.pop())
- .map(|c| (c.message, format_unix_time(c.time)))
- .unwrap_or_else(|| (String::new(), String::new()));
- TreeEntryView {
- name: e.name,
- path: e.path,
- is_dir: e.is_dir,
- commit_message,
- commit_time,
- }
- })
- .collect()
-}
-
-
-pub async fn repo_blame(
- State(state): State<AppState>,
- headers: HeaderMap,
- Path((owner, repo, rest)): Path<(String, String, String)>,
-) -> AppResult<impl IntoResponse> {
- let (repository, owner_user, viewer, access) =
- load_repo_context(&state, &owner, &repo, &headers).await?;
- let grepo = git::open_bare(&state.config.repos_dir(), &owner, &repo)
- .map_err(|_| AppError::not_found())?;
- let (branch, path) = split_ref_path(&grepo, &rest);
- if path.is_empty() {
- return Err(AppError::bad("missing file path"));
- }
- let binary = match git::read_blob(&grepo, &branch, &path) {
- Ok((_, binary)) => binary,
- Err(_) => return Err(AppError::not_found()),
- };
- let lines = if binary {
- Vec::new()
- } else {
- git::blame_file(&grepo, &branch, &path)
- .map_err(|e| AppError::not_found().with_message(e.to_string()))?
- .into_iter()
- .map(|l| BlameLineView {
- line_no: l.line_no,
- content: l.content,
- commit_id: l.commit_id,
- short_id: l.short_id,
- author: l.author,
- time_display: format_unix_time(l.time),
- summary: l.summary,
- hunk_start: l.hunk_start,
- })
- .collect()
- };
- let branches = git::list_branches(&grepo).unwrap_or_default();
- let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo);
- Ok(RepoBlameTemplate {
- viewer,
- owner: owner_user,
- repo: repository,
- access,
- branches,
- branch,
- path: path.clone(),
- breadcrumbs: breadcrumbs(&path),
- lines,
- binary,
- clone_http,
- clone_ssh,
- })
-}
-
-pub async fn repo_history(
- State(state): State<AppState>,
- headers: HeaderMap,
- Path((owner, repo, rest)): Path<(String, String, String)>,
-) -> AppResult<impl IntoResponse> {
- let (repository, owner_user, viewer, access) =
- load_repo_context(&state, &owner, &repo, &headers).await?;
- let grepo = git::open_bare(&state.config.repos_dir(), &owner, &repo)
- .map_err(|_| AppError::not_found())?;
- let (branch, path) = split_ref_path(&grepo, &rest);
- if path.is_empty() {
- return Err(AppError::bad("missing file path"));
- }
- let raw_commits = git::list_commits_for_path(&grepo, &branch, &path, 100).unwrap_or_default();
- if raw_commits.is_empty()
- && git::read_blob(&grepo, &branch, &path).is_err()
- && !git::path_is_dir(&grepo, &branch, &path)
- {
- return Err(AppError::not_found());
- }
- let prepared: Vec<_> = raw_commits
- .into_iter()
- .map(|c| {
- let extracted = git::extract_commit_signature(&grepo, &c.id);
- (c, extracted)
- })
- .collect();
- let commits = commit_views(&state, prepared).await;
- let branches = git::list_branches(&grepo).unwrap_or_default();
- let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo);
- Ok(RepoHistoryTemplate {
- viewer,
- owner: owner_user,
- repo: repository,
- access,
- branches,
- branch,
- path: path.clone(),
- breadcrumbs: breadcrumbs(&path),
- commits,
- clone_http,
- clone_ssh,
- })
-}
-
#[derive(Deserialize)]
pub struct BranchQuery {
pub branch: Option<String>,
@@ -2630,16 +2248,10 @@ pub async fn repo_commits(
})
}
-#[derive(Deserialize)]
-pub struct FullDiffQuery {
- pub full: Option<String>,
-}
-
pub async fn repo_commit(
State(state): State<AppState>,
headers: HeaderMap,
Path((owner, repo, id)): Path<(String, String, String)>,
- Query(q): Query<FullDiffQuery>,
) -> AppResult<impl IntoResponse> {
let (repository, owner_user, viewer, access) =
load_repo_context(&state, &owner, &repo, &headers).await?;
@@ -2648,8 +2260,7 @@ pub async fn repo_commit(
let commit = git::get_commit(&grepo, &id).map_err(|_| AppError::not_found())?;
let extracted = git::extract_commit_signature(&grepo, &commit.id);
let diff = git::commit_diff(&grepo, &id).unwrap_or_default();
- let show_full = query_full(q.full.as_deref());
- let (diff_html, truncated, total_lines) = render_diff_html(&diff, show_full);
+ let diff_html = render_diff_html(&diff);
let message_html = linkify_commit_message(
&state.pool,
repository.id,
@@ -2666,9 +2277,6 @@ pub async fn repo_commit(
commit: commit_view(&state, commit, extracted).await,
message_html,
diff_html,
- show_full,
- truncated,
- total_lines,
clone_http: clone_urls(&state, &owner, &repo).0,
clone_ssh: clone_urls(&state, &owner, &repo).1,
})
@@ -2678,7 +2286,6 @@ pub async fn repo_diff(
State(state): State<AppState>,
headers: HeaderMap,
Path((owner, repo, id)): Path<(String, String, String)>,
- Query(q): Query<FullDiffQuery>,
) -> AppResult<impl IntoResponse> {
let (repository, owner_user, viewer, access) =
load_repo_context(&state, &owner, &repo, &headers).await?;
@@ -2687,8 +2294,7 @@ pub async fn repo_diff(
let commit = git::get_commit(&grepo, &id).map_err(|_| AppError::not_found())?;
let extracted = git::extract_commit_signature(&grepo, &commit.id);
let diff = git::commit_diff(&grepo, &id).unwrap_or_default();
- let show_full = query_full(q.full.as_deref());
- let (diff_html, truncated, total_lines) = render_diff_html(&diff, show_full);
+ let diff_html = render_diff_html(&diff);
let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo);
Ok(RepoDiffTemplate {
viewer,
@@ -2697,9 +2303,6 @@ pub async fn repo_diff(
access,
commit: commit_view(&state, commit, extracted).await,
diff_html,
- show_full,
- truncated,
- total_lines,
clone_http,
clone_ssh,
})
@@ -2908,7 +2511,7 @@ pub async fn repo_branches(
let updated = git::list_commits(&grepo, &name, 1)
.ok()
.and_then(|mut v| v.pop())
- .map(|c| format_unix_time(c.time))
+ .map(|c| c.time.to_string())
.unwrap_or_default();
BranchRow {
name,
@@ -2936,7 +2539,7 @@ pub async fn repo_branches(
short_id: t.short_id,
target: t.target,
message: t.message,
- updated: format_unix_time(t.time),
+ updated: t.time.to_string(),
})
.collect();
let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo);
@@ -2958,7 +2561,6 @@ async fn commit_view(
c: git::CommitInfo,
extracted: Option<(String, Vec<u8>)>,
) -> CommitView {
- let signed = extracted.is_some();
let verification = match extracted {
Some((sig, payload)) => {
git::verify_commit_signature(
@@ -2991,13 +2593,8 @@ async fn commit_view(
short_id: c.short_id,
message: c.message,
author: c.author,
- email: c.email.clone(),
- author_username: queries::username_by_email(&state.pool, &c.email)
- .await
- .ok()
- .flatten(),
+ email: c.email,
time: c.time,
- signed,
verified,
verify_kind,
verify_fingerprint,
@@ -3017,107 +2614,11 @@ async fn commit_views(
out
}
-// ── issues ───────────────────────────────────────────────────────────────────
+// ── issues ───────────────────────────────────────────────────────────────────
#[derive(Deserialize)]
pub struct StateFilter {
pub state: Option<String>,
- pub label: Option<Uuid>,
- pub milestone: Option<Uuid>,
-}
-
-fn parse_optional_uuid(raw: &Option<String>) -> Option<Uuid> {
- raw.as_deref()
- .map(str::trim)
- .filter(|s| !s.is_empty())
- .and_then(|s| Uuid::parse_str(s).ok())
-}
-
-async fn build_issue_list_items(
- pool: &sqlx::PgPool,
- issues: Vec<crate::db::models::Issue>,
-) -> AppResult<Vec<IssueListItem>> {
- let ids: Vec<Uuid> = issues.iter().map(|i| i.id).collect();
- let label_rows = queries::labels_for_issues(pool, &ids).await?;
- let mut labels_by_issue: std::collections::HashMap<Uuid, Vec<crate::db::models::Label>> =
- std::collections::HashMap::new();
- for (issue_id, label) in label_rows {
- labels_by_issue.entry(issue_id).or_default().push(label);
- }
- let milestone_ids: Vec<Uuid> = issues.iter().filter_map(|i| i.milestone_id).collect();
- let milestones = queries::get_milestones_by_ids(pool, &milestone_ids).await?;
- let milestones_by_id: std::collections::HashMap<Uuid, crate::db::models::Milestone> =
- milestones.into_iter().map(|m| (m.id, m)).collect();
- Ok(issues
- .into_iter()
- .map(|issue| {
- let milestone = issue
- .milestone_id
- .and_then(|id| milestones_by_id.get(&id).cloned());
- let labels = labels_by_issue.remove(&issue.id).unwrap_or_default();
- IssueListItem {
- issue,
- labels,
- milestone,
- }
- })
- .collect())
-}
-
-async fn build_pull_list_items(
- pool: &sqlx::PgPool,
- pulls: Vec<crate::db::models::PullRequest>,
-) -> AppResult<Vec<PullListItem>> {
- let ids: Vec<Uuid> = pulls.iter().map(|p| p.id).collect();
- let label_rows = queries::labels_for_pulls(pool, &ids).await?;
- let mut labels_by_pull: std::collections::HashMap<Uuid, Vec<crate::db::models::Label>> =
- std::collections::HashMap::new();
- for (pull_id, label) in label_rows {
- labels_by_pull.entry(pull_id).or_default().push(label);
- }
- let milestone_ids: Vec<Uuid> = pulls.iter().filter_map(|p| p.milestone_id).collect();
- let milestones = queries::get_milestones_by_ids(pool, &milestone_ids).await?;
- let milestones_by_id: std::collections::HashMap<Uuid, crate::db::models::Milestone> =
- milestones.into_iter().map(|m| (m.id, m)).collect();
- Ok(pulls
- .into_iter()
- .map(|pull| {
- let milestone = pull
- .milestone_id
- .and_then(|id| milestones_by_id.get(&id).cloned());
- let labels = labels_by_pull.remove(&pull.id).unwrap_or_default();
- PullListItem {
- pull,
- labels,
- milestone,
- }
- })
- .collect())
-}
-
-fn label_options(
- all: Vec<crate::db::models::Label>,
- selected: &[crate::db::models::Label],
-) -> Vec<LabelOption> {
- let selected_ids: std::collections::HashSet<Uuid> = selected.iter().map(|l| l.id).collect();
- all.into_iter()
- .map(|label| LabelOption {
- selected: selected_ids.contains(&label.id),
- label,
- })
- .collect()
-}
-
-fn milestone_options(
- all: Vec<crate::db::models::Milestone>,
- selected_id: Option<Uuid>,
-) -> Vec<MilestoneOption> {
- all.into_iter()
- .map(|milestone| MilestoneOption {
- selected: Some(milestone.id) == selected_id,
- milestone,
- })
- .collect()
}
pub async fn issues_list(
@@ -3131,39 +2632,20 @@ pub async fn issues_list(
if !repository.issues_enabled {
return Err(AppError::not_found());
}
- let state_filter = match q.state.as_deref() {
+ // Default to open-only (GitHub-style) when no state query param is set.
+ let filter = match q.state.as_deref() {
Some("closed") => "closed",
- Some("all") => "all",
_ => "open",
};
- let state_arg = if state_filter == "all" {
- None
- } else {
- Some(state_filter)
- };
- let issues = queries::list_issues_filtered(
- &state.pool,
- repository.id,
- state_arg,
- q.label,
- q.milestone,
- )
- .await?;
- let items = build_issue_list_items(&state.pool, issues).await?;
- let labels = queries::list_labels(&state.pool, repository.id).await?;
- let milestones = queries::list_milestones(&state.pool, repository.id, None).await?;
+ let issues = queries::list_issues(&state.pool, repository.id, Some(filter)).await?;
let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo);
Ok(IssuesListTemplate {
viewer,
owner: owner_user,
repo: repository,
access,
- issues: items,
- labels,
- milestones,
- state_filter: state_filter.to_string(),
- label_filter: q.label,
- milestone_filter: q.milestone,
+ issues,
+ state_filter: filter.to_string(),
clone_http,
clone_ssh,
})
@@ -3180,16 +2662,12 @@ pub async fn issue_new(
return Err(AppError::not_found());
}
let _ = viewer.as_ref().ok_or_else(AppError::unauthorized)?;
- let labels = queries::list_labels(&state.pool, repository.id).await?;
- let milestones = queries::list_open_milestones(&state.pool, repository.id).await?;
let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo);
Ok(IssueNewTemplate {
viewer,
owner: owner_user,
repo: repository,
access,
- labels,
- milestones,
error: None,
clone_http,
clone_ssh,
@@ -3200,9 +2678,6 @@ pub async fn issue_new(
pub struct IssueCreateForm {
pub title: String,
pub body: Option<String>,
- #[serde(default)]
- pub label_id: Vec<Uuid>,
- pub milestone_id: Option<String>,
}
pub async fn issue_create(
@@ -3219,16 +2694,12 @@ pub async fn issue_create(
let user = viewer.ok_or_else(AppError::unauthorized)?;
let title = form.title.trim();
if title.is_empty() {
- let labels = queries::list_labels(&state.pool, repository.id).await?;
- let milestones = queries::list_open_milestones(&state.pool, repository.id).await?;
let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo);
return Ok(IssueNewTemplate {
viewer: Some(user),
owner: owner_user,
repo: repository,
access,
- labels,
- milestones,
error: Some("title required".into()),
clone_http,
clone_ssh,
@@ -3239,19 +2710,6 @@ pub async fn issue_create(
let number = queries::next_issue_number(&state.pool, repository.id).await?;
let issue =
queries::create_issue(&state.pool, repository.id, user.id, number, title, &body).await?;
- let label_ids =
- queries::filter_repo_label_ids(&state.pool, repository.id, &form.label_id).await?;
- if !label_ids.is_empty() {
- queries::set_issue_labels(&state.pool, issue.id, &label_ids).await?;
- }
- if let Some(mid) = parse_optional_uuid(&form.milestone_id) {
- if queries::get_milestone(&state.pool, repository.id, mid)
- .await?
- .is_some()
- {
- queries::set_issue_milestone(&state.pool, issue.id, Some(mid)).await?;
- }
- }
queries::record_activity(
&state.pool,
Some(user.id),
@@ -3261,32 +2719,10 @@ pub async fn issue_create(
serde_json::json!({ "number": number }),
)
.await?;
- crate::webhooks::spawn_dispatch(
- state.pool.clone(),
- crate::webhooks::EVENT_ISSUES,
- "opened".into(),
- repository.clone(),
- owner.clone(),
- Some(user.clone()),
- serde_json::json!({
- "number": issue.number,
- "title": issue.title,
- "body": issue.body,
- "state": issue.state,
- }),
- );
- let href = format!("/{owner}/{repo}/issues/{number}");
- queries::notify_repo_watchers(
- &state.pool,
- repository.id,
- user.id,
- "issue.open",
- &format!("{} opened issue #{number}: {title}", user.username),
- ¬if_snippet(&body, 160),
- &href,
- )
- .await?;
- Ok(redirect_see_other(&href))
+ Ok(redirect_see_other(&format!(
+ "/{owner}/{repo}/issues/{}",
+ issue.number
+ )))
}
pub async fn issue_view(
@@ -3311,24 +2747,6 @@ pub async fn issue_view(
viewer.as_ref().map(|u| u.id),
)
.await?;
- let labels = queries::list_issue_labels(&state.pool, issue.id).await?;
- let all_labels = queries::list_labels(&state.pool, repository.id).await?;
- let label_options = label_options(all_labels, &labels);
- let milestone = match issue.milestone_id {
- Some(id) => queries::get_milestone(&state.pool, repository.id, id).await?,
- None => None,
- };
- let mut milestone_choices = queries::list_open_milestones(&state.pool, repository.id).await?;
- if let Some(ref m) = milestone {
- if m.state != "open" && !milestone_choices.iter().any(|x| x.id == m.id) {
- milestone_choices.push(m.clone());
- }
- }
- let milestone_options = milestone_options(milestone_choices, issue.milestone_id);
- let can_triage = viewer
- .as_ref()
- .map(|u| access.can_write() || issue.author_id == u.id)
- .unwrap_or(false);
let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo);
Ok(IssueViewTemplate {
viewer,
@@ -3340,81 +2758,12 @@ pub async fn issue_view(
issue,
author,
comments,
- labels,
- label_options,
- milestone,
- milestone_options,
- can_triage,
clone_http,
clone_ssh,
})
}
#[derive(Deserialize)]
-pub struct LabelsAssignForm {
- #[serde(default)]
- pub label_id: Vec<Uuid>,
-}
-
-#[derive(Deserialize)]
-pub struct MilestoneAssignForm {
- pub milestone_id: Option<String>,
-}
-
-pub async fn issue_labels_save(
- State(state): State<AppState>,
- headers: HeaderMap,
- Path((owner, repo, number)): Path<(String, String, i32)>,
- Form(form): Form<LabelsAssignForm>,
-) -> AppResult<Response> {
- let (repository, _o, viewer, access) =
- load_repo_context(&state, &owner, &repo, &headers).await?;
- let user = viewer.ok_or_else(AppError::unauthorized)?;
- let issue = queries::get_issue(&state.pool, repository.id, number)
- .await?
- .ok_or_else(AppError::not_found)?;
- if !(access.can_write() || issue.author_id == user.id) {
- return Err(AppError::forbidden());
- }
- let label_ids =
- queries::filter_repo_label_ids(&state.pool, repository.id, &form.label_id).await?;
- queries::set_issue_labels(&state.pool, issue.id, &label_ids).await?;
- Ok(redirect_see_other(&format!(
- "/{owner}/{repo}/issues/{number}"
- )))
-}
-
-pub async fn issue_milestone_save(
- State(state): State<AppState>,
- headers: HeaderMap,
- Path((owner, repo, number)): Path<(String, String, i32)>,
- Form(form): Form<MilestoneAssignForm>,
-) -> AppResult<Response> {
- let (repository, _o, viewer, access) =
- load_repo_context(&state, &owner, &repo, &headers).await?;
- let user = viewer.ok_or_else(AppError::unauthorized)?;
- let issue = queries::get_issue(&state.pool, repository.id, number)
- .await?
- .ok_or_else(AppError::not_found)?;
- if !(access.can_write() || issue.author_id == user.id) {
- return Err(AppError::forbidden());
- }
- let milestone_id = parse_optional_uuid(&form.milestone_id);
- if let Some(mid) = milestone_id {
- if queries::get_milestone(&state.pool, repository.id, mid)
- .await?
- .is_none()
- {
- return Err(AppError::bad("unknown milestone"));
- }
- }
- queries::set_issue_milestone(&state.pool, issue.id, milestone_id).await?;
- Ok(redirect_see_other(&format!(
- "/{owner}/{repo}/issues/{number}"
- )))
-}
-
-#[derive(Deserialize)]
pub struct CommentForm {
pub body: String,
}
@@ -3453,20 +2802,9 @@ pub async fn issue_comment(
serde_json::json!({ "number": number }),
)
.await?;
- let participants = queries::list_issue_participant_ids(&state.pool, issue.id).await?;
- let href = format!("/{owner}/{repo}/issues/{number}");
- queries::notify_users(
- &state.pool,
- &participants,
- user.id,
- "issue.comment",
- &format!("{} commented on issue #{number}", user.username),
- ¬if_snippet(body, 160),
- &href,
- Some(repository.id),
- )
- .await?;
- Ok(redirect_see_other(&href))
+ Ok(redirect_see_other(&format!(
+ "/{owner}/{repo}/issues/{number}"
+ )))
}
pub async fn issue_close(
@@ -3484,20 +2822,6 @@ pub async fn issue_close(
return Err(AppError::forbidden());
}
queries::set_issue_state(&state.pool, issue.id, "closed").await?;
- crate::webhooks::spawn_dispatch(
- state.pool.clone(),
- crate::webhooks::EVENT_ISSUES,
- "closed".into(),
- repository.clone(),
- owner.clone(),
- Some(user.clone()),
- serde_json::json!({
- "number": issue.number,
- "title": issue.title,
- "body": issue.body,
- "state": "closed",
- }),
- );
Ok(redirect_see_other(&format!(
"/{owner}/{repo}/issues/{number}"
)))
@@ -3518,26 +2842,12 @@ pub async fn issue_reopen(
return Err(AppError::forbidden());
}
queries::set_issue_state(&state.pool, issue.id, "open").await?;
- crate::webhooks::spawn_dispatch(
- state.pool.clone(),
- crate::webhooks::EVENT_ISSUES,
- "reopened".into(),
- repository.clone(),
- owner.clone(),
- Some(user.clone()),
- serde_json::json!({
- "number": issue.number,
- "title": issue.title,
- "body": issue.body,
- "state": "open",
- }),
- );
Ok(redirect_see_other(&format!(
"/{owner}/{repo}/issues/{number}"
)))
}
-// --- pull requests ---
+// ── pull requests ────────────────────────────────────────────────────────────
pub async fn pulls_list(
State(state): State<AppState>,
@@ -3550,33 +2860,19 @@ pub async fn pulls_list(
if !repository.pulls_enabled {
return Err(AppError::not_found());
}
- let filter = match q.state.as_deref() {
- Some("closed") | Some("merged") | Some("open") => q.state.as_deref(),
- _ => Some("open"),
- };
- let pulls = queries::list_pulls_filtered(
- &state.pool,
- repository.id,
- filter,
- q.label,
- q.milestone,
- )
- .await?;
- let items = build_pull_list_items(&state.pool, pulls).await?;
- let labels = queries::list_labels(&state.pool, repository.id).await?;
- let milestones = queries::list_milestones(&state.pool, repository.id, None).await?;
+ let filter = q
+ .state
+ .as_deref()
+ .filter(|s| *s == "open" || *s == "closed" || *s == "merged");
+ let pulls = queries::list_pulls(&state.pool, repository.id, filter).await?;
let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo);
Ok(PullsListTemplate {
viewer,
owner: owner_user,
repo: repository,
access,
- pulls: items,
- labels,
- milestones,
+ pulls,
state_filter: filter.unwrap_or("open").to_string(),
- label_filter: q.label,
- milestone_filter: q.milestone,
clone_http,
clone_ssh,
})
@@ -3597,8 +2893,6 @@ pub async fn pull_new(
.ok()
.and_then(|g| git::list_branches(&g).ok())
.unwrap_or_default();
- let labels = queries::list_labels(&state.pool, repository.id).await?;
- let milestones = queries::list_open_milestones(&state.pool, repository.id).await?;
let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo);
Ok(PullNewTemplate {
viewer,
@@ -3606,8 +2900,6 @@ pub async fn pull_new(
repo: repository,
access,
branches,
- labels,
- milestones,
error: None,
clone_http,
clone_ssh,
@@ -3621,9 +2913,6 @@ pub struct PullCreateForm {
pub body: Option<String>,
pub source_branch: String,
pub target_branch: String,
- #[serde(default)]
- pub label_id: Vec<Uuid>,
- pub milestone_id: Option<String>,
}
pub async fn pull_create(
@@ -3646,8 +2935,6 @@ pub async fn pull_create(
.ok()
.and_then(|g| git::list_branches(&g).ok())
.unwrap_or_default();
- let labels = queries::list_labels(&state.pool, repository.id).await?;
- let milestones = queries::list_open_milestones(&state.pool, repository.id).await?;
let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo);
return Ok(PullNewTemplate {
viewer: Some(user),
@@ -3655,8 +2942,6 @@ pub async fn pull_create(
repo: repository,
access,
branches,
- labels,
- milestones,
error: Some("title, source, and target required".into()),
clone_http,
clone_ssh,
@@ -3680,19 +2965,6 @@ pub async fn pull_create(
target,
)
.await?;
- let label_ids =
- queries::filter_repo_label_ids(&state.pool, repository.id, &form.label_id).await?;
- if !label_ids.is_empty() {
- queries::set_pull_labels(&state.pool, pull.id, &label_ids).await?;
- }
- if let Some(mid) = parse_optional_uuid(&form.milestone_id) {
- if queries::get_milestone(&state.pool, repository.id, mid)
- .await?
- .is_some()
- {
- queries::set_pull_milestone(&state.pool, pull.id, Some(mid)).await?;
- }
- }
queries::record_activity(
&state.pool,
Some(user.id),
@@ -3702,40 +2974,15 @@ pub async fn pull_create(
serde_json::json!({ "number": number }),
)
.await?;
- crate::webhooks::spawn_dispatch(
- state.pool.clone(),
- crate::webhooks::EVENT_PULL_REQUEST,
- "opened".into(),
- repository.clone(),
- owner.clone(),
- Some(user.clone()),
- serde_json::json!({
- "number": pull.number,
- "title": pull.title,
- "body": pull.body,
- "state": pull.state,
- "source_branch": pull.source_branch,
- "target_branch": pull.target_branch,
- }),
- );
- let href = format!("/{owner}/{repo}/pulls/{number}");
- queries::notify_repo_watchers(
- &state.pool,
- repository.id,
- user.id,
- "pull.open",
- &format!("{} opened pull #{number}: {title}", user.username),
- ¬if_snippet(&body, 160),
- &href,
- )
- .await?;
- Ok(redirect_see_other(&href))
+ Ok(redirect_see_other(&format!(
+ "/{owner}/{repo}/pulls/{}",
+ pull.number
+ )))
}
#[derive(Deserialize)]
pub struct PullTabQuery {
pub tab: Option<String>,
- pub full: Option<String>,
}
pub async fn pull_view(
@@ -3761,11 +3008,6 @@ pub async fn pull_view(
viewer.as_ref().map(|u| u.id),
)
.await?;
- let reviews = review_views(
- &state.pool,
- queries::list_reviews_for_pull(&state.pool, pull.id).await?,
- )
- .await?;
let tab = match q.tab.as_deref() {
Some("commits") => "commits",
@@ -3773,7 +3015,6 @@ pub async fn pull_view(
_ => "conversation",
}
.to_string();
- let show_full = query_full(q.full.as_deref());
let mut commits = Vec::new();
let mut diff_files = Vec::new();
@@ -3789,7 +3030,7 @@ pub async fn pull_view(
commits = commit_views(&state, prepared).await;
let diff =
git::branch_diff(&g, &pull.source_branch, &pull.target_branch).unwrap_or_default();
- diff_files = parse_diff_files(&diff, show_full);
+ diff_files = parse_diff_files(&diff);
}
let can_merge = access.can_write() && pull.state == "open";
@@ -3804,30 +3045,7 @@ pub async fn pull_view(
merge_styles.push("rebase".to_string());
}
- let labels = queries::list_pull_labels(&state.pool, pull.id).await?;
- let all_labels = queries::list_labels(&state.pool, repository.id).await?;
- let label_options = label_options(all_labels, &labels);
- let milestone = match pull.milestone_id {
- Some(id) => queries::get_milestone(&state.pool, repository.id, id).await?,
- None => None,
- };
- let mut milestone_choices = queries::list_open_milestones(&state.pool, repository.id).await?;
- if let Some(ref m) = milestone {
- if m.state != "open" && !milestone_choices.iter().any(|x| x.id == m.id) {
- milestone_choices.push(m.clone());
- }
- }
- let milestone_options = milestone_options(milestone_choices, pull.milestone_id);
- let can_triage = viewer
- .as_ref()
- .map(|u| access.can_write() || pull.author_id == u.id)
- .unwrap_or(false);
- let is_author = viewer
- .as_ref()
- .map(|u| u.id == pull.author_id)
- .unwrap_or(false);
-
- let conversation_count = comments.len() + reviews.len() + 1;
+ let conversation_count = comments.len() + 1;
let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo);
Ok(PullViewTemplate {
viewer,
@@ -3839,78 +3057,17 @@ pub async fn pull_view(
pull,
author,
comments,
- reviews,
commits,
conversation_count,
diff_files,
tab,
- show_full,
can_merge,
merge_styles,
- labels,
- label_options,
- milestone,
- milestone_options,
- can_triage,
- is_author,
clone_http,
clone_ssh,
})
}
-pub async fn pull_labels_save(
- State(state): State<AppState>,
- headers: HeaderMap,
- Path((owner, repo, number)): Path<(String, String, i32)>,
- Form(form): Form<LabelsAssignForm>,
-) -> AppResult<Response> {
- let (repository, _o, viewer, access) =
- load_repo_context(&state, &owner, &repo, &headers).await?;
- let user = viewer.ok_or_else(AppError::unauthorized)?;
- let pull = queries::get_pull(&state.pool, repository.id, number)
- .await?
- .ok_or_else(AppError::not_found)?;
- if !(access.can_write() || pull.author_id == user.id) {
- return Err(AppError::forbidden());
- }
- let label_ids =
- queries::filter_repo_label_ids(&state.pool, repository.id, &form.label_id).await?;
- queries::set_pull_labels(&state.pool, pull.id, &label_ids).await?;
- Ok(redirect_see_other(&format!(
- "/{owner}/{repo}/pulls/{number}"
- )))
-}
-
-pub async fn pull_milestone_save(
- State(state): State<AppState>,
- headers: HeaderMap,
- Path((owner, repo, number)): Path<(String, String, i32)>,
- Form(form): Form<MilestoneAssignForm>,
-) -> AppResult<Response> {
- let (repository, _o, viewer, access) =
- load_repo_context(&state, &owner, &repo, &headers).await?;
- let user = viewer.ok_or_else(AppError::unauthorized)?;
- let pull = queries::get_pull(&state.pool, repository.id, number)
- .await?
- .ok_or_else(AppError::not_found)?;
- if !(access.can_write() || pull.author_id == user.id) {
- return Err(AppError::forbidden());
- }
- let milestone_id = parse_optional_uuid(&form.milestone_id);
- if let Some(mid) = milestone_id {
- if queries::get_milestone(&state.pool, repository.id, mid)
- .await?
- .is_none()
- {
- return Err(AppError::bad("unknown milestone"));
- }
- }
- queries::set_pull_milestone(&state.pool, pull.id, milestone_id).await?;
- Ok(redirect_see_other(&format!(
- "/{owner}/{repo}/pulls/{number}"
- )))
-}
-
pub async fn pull_comment(
State(state): State<AppState>,
headers: HeaderMap,
@@ -3945,86 +3102,7 @@ pub async fn pull_comment(
serde_json::json!({ "number": number }),
)
.await?;
- let participants = queries::list_pull_participant_ids(&state.pool, pull.id).await?;
- let href = format!("/{owner}/{repo}/pulls/{number}");
- queries::notify_users(
- &state.pool,
- &participants,
- user.id,
- "pull.comment",
- &format!("{} commented on pull #{number}", user.username),
- ¬if_snippet(body, 160),
- &href,
- Some(repository.id),
- )
- .await?;
- Ok(redirect_see_other(&href))
-}
-
-#[derive(Deserialize)]
-pub struct PullReviewForm {
- pub state: String,
- pub body: Option<String>,
-}
-
-pub async fn pull_review(
- State(state): State<AppState>,
- headers: HeaderMap,
- Path((owner, repo, number)): Path<(String, String, i32)>,
- Form(form): Form<PullReviewForm>,
-) -> AppResult<Response> {
- let (repository, _o, viewer, _a) =
- load_repo_context(&state, &owner, &repo, &headers).await?;
- let user = viewer.ok_or_else(AppError::unauthorized)?;
- let pull = queries::get_pull(&state.pool, repository.id, number)
- .await?
- .ok_or_else(AppError::not_found)?;
- if pull.state != "open" {
- return Err(AppError::bad("pull request is not open"));
- }
- let state_name = match form.state.trim() {
- "approved" => "approved",
- "changes_requested" => "changes_requested",
- "commented" => "commented",
- _ => return Err(AppError::bad("invalid review state")),
- };
- if user.id == pull.author_id && state_name != "commented" {
- return Err(AppError::bad(
- "cannot approve or request changes on your own pull request",
- ));
- }
- let body = form.body.as_deref().unwrap_or("").trim();
- queries::create_review(&state.pool, pull.id, user.id, state_name, body).await?;
- queries::record_activity(
- &state.pool,
- Some(user.id),
- Some(repository.id),
- "pull.review",
- &format!("reviewed pull #{number} ({state_name})"),
- serde_json::json!({ "number": number, "state": state_name }),
- )
- .await?;
- let participants = queries::list_pull_participant_ids(&state.pool, pull.id).await?;
- let href = format!("/{owner}/{repo}/pulls/{number}");
- let summary = match state_name {
- "approved" => format!("{} approved pull #{number}", user.username),
- "changes_requested" => {
- format!("{} requested changes on pull #{number}", user.username)
- }
- _ => format!("{} left a review on pull #{number}", user.username),
- };
- queries::notify_users(
- &state.pool,
- &participants,
- user.id,
- "pull.review",
- &summary,
- ¬if_snippet(body, 160),
- &href,
- Some(repository.id),
- )
- .await?;
- Ok(redirect_see_other(&href))
+ Ok(redirect_see_other(&format!("/{owner}/{repo}/pulls/{number}")))
}
#[derive(Deserialize)]
@@ -4096,23 +3174,6 @@ pub async fn pull_merge(
serde_json::json!({ "number": number, "commit": merge_commit }),
)
.await?;
- crate::webhooks::spawn_dispatch(
- state.pool.clone(),
- crate::webhooks::EVENT_PULL_REQUEST,
- "merged".into(),
- repository.clone(),
- owner.clone(),
- Some(user.clone()),
- serde_json::json!({
- "number": pull.number,
- "title": pull.title,
- "body": pull.body,
- "state": "merged",
- "source_branch": pull.source_branch,
- "target_branch": pull.target_branch,
- "merge_commit": merge_commit,
- }),
- );
Ok(redirect_see_other(&format!("/{owner}/{repo}/pulls/{number}")))
}
@@ -4134,26 +3195,10 @@ pub async fn pull_close(
return Err(AppError::bad("pull is not open"));
}
queries::set_pull_state(&state.pool, pull.id, "closed", None).await?;
- crate::webhooks::spawn_dispatch(
- state.pool.clone(),
- crate::webhooks::EVENT_PULL_REQUEST,
- "closed".into(),
- repository.clone(),
- owner.clone(),
- Some(user.clone()),
- serde_json::json!({
- "number": pull.number,
- "title": pull.title,
- "body": pull.body,
- "state": "closed",
- "source_branch": pull.source_branch,
- "target_branch": pull.target_branch,
- }),
- );
Ok(redirect_see_other(&format!("/{owner}/{repo}/pulls/{number}")))
}
-// ── releases ─────────────────────────────────────────────────────────────────
+// ── releases ─────────────────────────────────────────────────────────────────
fn asset_views(assets: Vec<crate::db::models::ReleaseAsset>) -> Vec<ReleaseAssetView> {
@@ -4341,35 +3386,10 @@ pub async fn release_create(
serde_json::json!({ "tag": tag }),
)
.await?;
- crate::webhooks::spawn_dispatch(
- state.pool.clone(),
- crate::webhooks::EVENT_RELEASE,
- "created".into(),
- repository.clone(),
- owner.clone(),
- Some(user.clone()),
- serde_json::json!({
- "tag_name": release.tag_name,
- "title": release.title,
- "body": release.body,
- "is_prerelease": release.is_prerelease,
- "is_draft": release.is_draft,
- }),
- );
- let href = format!("/{owner}/{repo}/releases/{}", release.tag_name);
- if !is_draft {
- queries::notify_repo_watchers(
- &state.pool,
- repository.id,
- user.id,
- "release.publish",
- &format!("{} published release {}", user.username, release.tag_name),
- &title,
- &href,
- )
- .await?;
- }
- Ok(redirect_see_other(&href))
+ Ok(redirect_see_other(&format!(
+ "/{owner}/{repo}/releases/{}",
+ release.tag_name
+ )))
}
pub async fn release_view(
@@ -4491,7 +3511,6 @@ pub async fn release_update(
let release = queries::get_release(&state.pool, repository.id, &tag)
.await?
.ok_or_else(AppError::not_found)?;
- let was_draft = release.is_draft;
let new_tag = form.tag_name.trim().to_string();
let title = form.title.trim().to_string();
@@ -4586,35 +3605,10 @@ pub async fn release_update(
serde_json::json!({ "tag": updated.tag_name }),
)
.await?;
- crate::webhooks::spawn_dispatch(
- state.pool.clone(),
- crate::webhooks::EVENT_RELEASE,
- "edited".into(),
- repository.clone(),
- owner.clone(),
- Some(user.clone()),
- serde_json::json!({
- "tag_name": updated.tag_name,
- "title": updated.title,
- "body": updated.body,
- "is_prerelease": updated.is_prerelease,
- "is_draft": updated.is_draft,
- }),
- );
- let href = format!("/{owner}/{repo}/releases/{}", updated.tag_name);
- if was_draft && !updated.is_draft {
- queries::notify_repo_watchers(
- &state.pool,
- repository.id,
- user.id,
- "release.publish",
- &format!("{} published release {}", user.username, updated.tag_name),
- &updated.title,
- &href,
- )
- .await?;
- }
- Ok(redirect_see_other(&href))
+ Ok(redirect_see_other(&format!(
+ "/{owner}/{repo}/releases/{}",
+ updated.tag_name
+ )))
}
pub async fn release_delete(
@@ -4655,21 +3649,6 @@ pub async fn release_delete(
serde_json::json!({ "tag": tag }),
)
.await?;
- crate::webhooks::spawn_dispatch(
- state.pool.clone(),
- crate::webhooks::EVENT_RELEASE,
- "deleted".into(),
- repository.clone(),
- owner.clone(),
- Some(user.clone()),
- serde_json::json!({
- "tag_name": tag,
- "title": release.title,
- "body": release.body,
- "is_prerelease": release.is_prerelease,
- "is_draft": release.is_draft,
- }),
- );
Ok(redirect_see_other(&format!("/{owner}/{repo}/releases")))
}
@@ -4801,7 +3780,7 @@ fn serve_file(path: &std::path::Path, filename: &str, content_type: &str) -> App
.unwrap())
}
-// ── repo settings ────────────────────────────────────────────────────────────
+// ── repo settings ────────────────────────────────────────────────────────────
pub async fn repo_settings(
State(state): State<AppState>,
@@ -4810,8 +3789,7 @@ pub async fn repo_settings(
) -> AppResult<impl IntoResponse> {
let (repository, owner_user, viewer, access) =
load_repo_context(&state, &owner, &repo, &headers).await?;
- let is_site_admin = viewer.as_ref().map(|u| u.is_site_admin).unwrap_or(false);
- if !access.can_admin() && !is_site_admin {
+ if !access.can_admin() {
return Err(AppError::forbidden());
}
let collaborators = queries::list_collaborators(&state.pool, repository.id).await?;
@@ -4835,46 +3813,7 @@ pub async fn repo_settings(
}
let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo);
let branch_rules = queries::list_branch_rules(&state.pool, repository.id).await?;
- let mirror = queries::get_repo_mirror(&state.pool, repository.id).await?;
- let deploy_keys = crate::db::deploy_keys::list_deploy_keys(&state.pool, repository.id).await?;
- let webhooks: Vec<WebhookView> = queries::list_webhooks(&state.pool, repository.id)
- .await?
- .into_iter()
- .map(|h| WebhookView {
- id: h.id,
- url: h.url,
- has_secret: !h.secret.is_empty(),
- events_label: if h.events.is_empty() {
- "(none)".into()
- } else {
- h.events.join(", ")
- },
- active: h.active,
- created_at: h.created_at.to_rfc3339(),
- })
- .collect();
- let webhook_deliveries: Vec<WebhookDeliveryView> =
- queries::list_recent_webhook_deliveries(&state.pool, repository.id, 20)
- .await?
- .into_iter()
- .map(|(d, url)| {
- let status_label = match (d.success, d.status_code, d.error.as_deref()) {
- (true, Some(code), _) => format!("{code}"),
- (false, Some(code), Some(err)) => format!("{code}: {err}"),
- (false, Some(code), None) => format!("{code}"),
- (false, None, Some(err)) => err.to_string(),
- _ => "unknown".into(),
- };
- WebhookDeliveryView {
- event: d.event,
- action: d.action,
- success: d.success,
- status_label,
- webhook_url: url,
- created_at: d.created_at.to_rfc3339(),
- }
- })
- .collect();
+ let is_site_admin = viewer.as_ref().map(|u| u.is_site_admin).unwrap_or(false);
Ok(RepoSettingsTemplate {
viewer,
owner: owner_user,
@@ -4885,10 +3824,6 @@ pub async fn repo_settings(
clone_http,
clone_ssh,
branch_rules,
- mirror,
- deploy_keys,
- webhooks,
- webhook_deliveries,
is_site_admin,
error: None,
})
@@ -5019,139 +3954,6 @@ pub async fn collab_remove(
}
#[derive(Deserialize)]
-pub struct DeployKeyAddForm {
- pub name: String,
- pub public_key: String,
- /// When set (checkbox), key may push; otherwise read-only.
- pub write_access: Option<String>,
-}
-
-pub async fn deploy_key_add(
- State(state): State<AppState>,
- headers: HeaderMap,
- Path((owner, repo)): Path<(String, String)>,
- Form(form): Form<DeployKeyAddForm>,
-) -> AppResult<Response> {
- let (repository, _o, viewer, access) =
- load_repo_context(&state, &owner, &repo, &headers).await?;
- if !access.can_admin() {
- return Err(AppError::forbidden());
- }
- let created_by = viewer.map(|u| u.id);
- let name = form.name.trim();
- let public_key = form.public_key.trim();
- if name.is_empty() || public_key.is_empty() {
- return Err(AppError::bad("name and public key required"));
- }
- let fp = fingerprint_ssh_pubkey(public_key).map_err(|e| AppError::bad(e.to_string()))?;
- let read_only = !checkbox(&form.write_access);
- crate::db::deploy_keys::add_deploy_key(
- &state.pool,
- repository.id,
- name,
- public_key,
- &fp,
- read_only,
- created_by,
- )
- .await
- .map_err(|e| AppError::bad(format!("could not add deploy key: {e}")))?;
- Ok(redirect_see_other(&format!("/{owner}/{repo}/settings")))
-}
-
-pub async fn deploy_key_delete(
- State(state): State<AppState>,
- headers: HeaderMap,
- Path((owner, repo, id)): Path<(String, String, Uuid)>,
-) -> AppResult<Response> {
- let (repository, _o, _viewer, access) =
- load_repo_context(&state, &owner, &repo, &headers).await?;
- if !access.can_admin() {
- return Err(AppError::forbidden());
- }
- crate::db::deploy_keys::delete_deploy_key(&state.pool, repository.id, id).await?;
- Ok(redirect_see_other(&format!("/{owner}/{repo}/settings")))
-}
-
-#[derive(Deserialize)]
-pub struct WebhookAddForm {
- pub url: String,
- pub secret: Option<String>,
- pub event_push: Option<String>,
- pub event_issues: Option<String>,
- pub event_pull_request: Option<String>,
- pub event_release: Option<String>,
-}
-
-pub async fn webhook_add(
- State(state): State<AppState>,
- headers: HeaderMap,
- Path((owner, repo)): Path<(String, String)>,
- Form(form): Form<WebhookAddForm>,
-) -> AppResult<Response> {
- let (repository, _o, _viewer, access) =
- load_repo_context(&state, &owner, &repo, &headers).await?;
- if !access.can_admin() {
- return Err(AppError::forbidden());
- }
- let url = form.url.trim();
- if !(url.starts_with("http://") || url.starts_with("https://")) {
- return Err(AppError::bad("url must start with http:// or https://"));
- }
- let mut events = Vec::new();
- if checkbox(&form.event_push) {
- events.push(crate::webhooks::EVENT_PUSH.to_string());
- }
- if checkbox(&form.event_issues) {
- events.push(crate::webhooks::EVENT_ISSUES.to_string());
- }
- if checkbox(&form.event_pull_request) {
- events.push(crate::webhooks::EVENT_PULL_REQUEST.to_string());
- }
- if checkbox(&form.event_release) {
- events.push(crate::webhooks::EVENT_RELEASE.to_string());
- }
- let events = crate::webhooks::normalize_events(&events);
- if events.is_empty() {
- return Err(AppError::bad("select at least one event"));
- }
- let secret = form.secret.as_deref().unwrap_or("").trim();
- queries::create_webhook(&state.pool, repository.id, url, secret, &events).await?;
- Ok(redirect_see_other(&format!("/{owner}/{repo}/settings")))
-}
-
-pub async fn webhook_delete(
- State(state): State<AppState>,
- headers: HeaderMap,
- Path((owner, repo, id)): Path<(String, String, Uuid)>,
-) -> AppResult<Response> {
- let (repository, _o, _viewer, access) =
- load_repo_context(&state, &owner, &repo, &headers).await?;
- if !access.can_admin() {
- return Err(AppError::forbidden());
- }
- queries::delete_webhook(&state.pool, id, repository.id).await?;
- Ok(redirect_see_other(&format!("/{owner}/{repo}/settings")))
-}
-
-pub async fn webhook_toggle(
- State(state): State<AppState>,
- headers: HeaderMap,
- Path((owner, repo, id)): Path<(String, String, Uuid)>,
-) -> AppResult<Response> {
- let (repository, _o, _viewer, access) =
- load_repo_context(&state, &owner, &repo, &headers).await?;
- if !access.can_admin() {
- return Err(AppError::forbidden());
- }
- let hook = queries::get_webhook(&state.pool, id, repository.id)
- .await?
- .ok_or_else(AppError::not_found)?;
- queries::set_webhook_active(&state.pool, id, repository.id, !hook.active).await?;
- Ok(redirect_see_other(&format!("/{owner}/{repo}/settings")))
-}
-
-#[derive(Deserialize)]
pub struct ArchiveForm {
pub archived: Option<String>,
}
src/web/templates.rs
+6
−237
diff --git a/src/web/templates.rs b/src/web/templates.rs
index f62af84..15b504a 100644
--- a/src/web/templates.rs
+++ b/src/web/templates.rs
@@ -1,9 +1,8 @@
use crate::og::SocialMeta;
use crate::db::models::{
- Access, BranchRule, CommitDay, GpgKey, Issue, Label, Milestone, Notification, PullRequest,
- Release, RepoMirror, Repository, SshKey, User, UserEmail,
+ Access, BranchRule, CommitDay, GpgKey, Issue, PullRequest, Release, Repository, SshKey, User,
+ UserEmail,
};
-use crate::db::DeployKey;
use askama::Template;
use askama_web::WebTemplate;
use chrono::{DateTime, Utc};
@@ -41,34 +40,6 @@ pub struct CommentView {
pub reactions: Vec<ReactionView>,
}
-pub struct PullReviewView {
- pub id: Uuid,
- pub reviewer: User,
- pub avatar_url: String,
- pub state: String,
- pub body: String,
- pub body_html: String,
- pub created_at: DateTime<Utc>,
-}
-
-impl PullReviewView {
- pub fn state_label(&self) -> &'static str {
- match self.state.as_str() {
- "approved" => "approved",
- "changes_requested" => "changes requested",
- _ => "commented",
- }
- }
-
- pub fn state_class(&self) -> &'static str {
- match self.state.as_str() {
- "approved" => "kg-badge--approved",
- "changes_requested" => "kg-badge--changes",
- _ => "kg-badge--commented",
- }
- }
-}
-
pub struct DiffFileView {
pub path: String,
pub anchor: String,
@@ -84,10 +55,7 @@ pub struct TreeEntryView {
pub name: String,
pub path: String,
pub is_dir: bool,
- /// Last commit that touched this path (summary).
- pub commit_message: String,
- /// Formatted timestamp for that commit.
- pub commit_time: String,
+ pub mode: String,
}
pub struct CommitView {
@@ -96,11 +64,7 @@ pub struct CommitView {
pub message: String,
pub author: String,
pub email: String,
- /// Kitgit username when the commit email matches a known account.
- pub author_username: Option<String>,
pub time: i64,
- /// True when a signature blob was present on the commit (SSH or GPG).
- pub signed: bool,
pub verified: bool,
pub verify_kind: String,
pub verify_fingerprint: String,
@@ -108,73 +72,12 @@ pub struct CommitView {
pub verified_at: String,
}
-impl CommitView {
- pub fn verify_kind_label(&self) -> &'static str {
- match self.verify_kind.as_str() {
- "gpg" => "GPG",
- "ssh" => "SSH",
- _ => "Unknown",
- }
- }
-
- pub fn time_display(&self) -> String {
- format_unix_time(self.time)
- }
-}
-
-/// Human-readable UTC date/time from a Unix timestamp (seconds).
-pub fn format_unix_time(secs: i64) -> String {
- chrono::DateTime::from_timestamp(secs, 0)
- .map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
- .unwrap_or_default()
-}
-
pub struct CollaboratorView {
pub user: User,
pub role: String,
pub avatar_url: String,
}
-pub struct WebhookView {
- pub id: Uuid,
- pub url: String,
- pub has_secret: bool,
- pub events_label: String,
- pub active: bool,
- pub created_at: String,
-}
-
-pub struct WebhookDeliveryView {
- pub event: String,
- pub action: String,
- pub success: bool,
- pub status_label: String,
- pub webhook_url: String,
- pub created_at: String,
-}
-
-pub struct LabelOption {
- pub label: Label,
- pub selected: bool,
-}
-
-pub struct MilestoneOption {
- pub milestone: Milestone,
- pub selected: bool,
-}
-
-pub struct IssueListItem {
- pub issue: Issue,
- pub labels: Vec<Label>,
- pub milestone: Option<Milestone>,
-}
-
-pub struct PullListItem {
- pub pull: PullRequest,
- pub labels: Vec<Label>,
- pub milestone: Option<Milestone>,
-}
-
pub struct LanguageStatView {
pub name: String,
pub percent: f64,
@@ -206,7 +109,7 @@ pub struct ReleaseAssetView {
pub content_type: String,
}
-// ── page templates ────────────────────────────────────────────────────────────
+// ── page templates ───────────────────────────────────────────────────────────
#[derive(Template, WebTemplate)]
#[template(path = "home.html")]
@@ -305,20 +208,10 @@ pub struct AccountSettingsTemplate {
pub emails: Vec<UserEmail>,
pub sessions: Vec<SessionView>,
pub current_session_id: Option<Uuid>,
- pub audit_entries: Vec<AuditEntryView>,
pub error: Option<String>,
pub message: Option<String>,
}
-pub struct AuditEntryView {
- pub action: String,
- pub action_label: String,
- pub ip: String,
- pub user_agent: String,
- pub metadata: String,
- pub created_at: DateTime<Utc>,
-}
-
#[derive(Template, WebTemplate)]
#[template(path = "mfa_challenge.html")]
pub struct MfaChallengeTemplate {
@@ -390,7 +283,6 @@ pub struct RepoHomeTemplate {
pub languages: Vec<LanguageStatView>,
pub empty: bool,
pub latest_commit: Option<CommitView>,
- pub commit_count: usize,
pub starred: bool,
pub watching: bool,
pub forked_from: Option<(String, String)>,
@@ -411,7 +303,6 @@ pub struct RepoTreeTemplate {
pub entries: Vec<TreeEntryView>,
pub readme_html: Option<String>,
pub latest_commit: Option<CommitView>,
- pub commit_count: usize,
pub clone_http: String,
pub clone_ssh: String,
}
@@ -435,50 +326,6 @@ pub struct RepoBlobTemplate {
pub clone_ssh: String,
}
-pub struct BlameLineView {
- pub line_no: usize,
- pub content: String,
- pub commit_id: String,
- pub short_id: String,
- pub author: String,
- pub time_display: String,
- pub summary: String,
- pub hunk_start: bool,
-}
-
-#[derive(Template, WebTemplate)]
-#[template(path = "repo_blame.html")]
-pub struct RepoBlameTemplate {
- pub viewer: Option<User>,
- pub owner: User,
- pub repo: Repository,
- pub access: Access,
- pub branches: Vec<String>,
- pub branch: String,
- pub path: String,
- pub breadcrumbs: Vec<(String, String)>,
- pub lines: Vec<BlameLineView>,
- pub binary: bool,
- pub clone_http: String,
- pub clone_ssh: String,
-}
-
-#[derive(Template, WebTemplate)]
-#[template(path = "repo_history.html")]
-pub struct RepoHistoryTemplate {
- pub viewer: Option<User>,
- pub owner: User,
- pub repo: Repository,
- pub access: Access,
- pub branches: Vec<String>,
- pub branch: String,
- pub path: String,
- pub breadcrumbs: Vec<(String, String)>,
- pub commits: Vec<CommitView>,
- pub clone_http: String,
- pub clone_ssh: String,
-}
-
#[derive(Template, WebTemplate)]
#[template(path = "repo_commits.html")]
pub struct RepoCommitsTemplate {
@@ -503,9 +350,6 @@ pub struct RepoCommitTemplate {
pub commit: CommitView,
pub message_html: String,
pub diff_html: String,
- pub show_full: bool,
- pub truncated: bool,
- pub total_lines: usize,
pub clone_http: String,
pub clone_ssh: String,
}
@@ -519,9 +363,6 @@ pub struct RepoDiffTemplate {
pub access: Access,
pub commit: CommitView,
pub diff_html: String,
- pub show_full: bool,
- pub truncated: bool,
- pub total_lines: usize,
pub clone_http: String,
pub clone_ssh: String,
}
@@ -547,12 +388,8 @@ pub struct IssuesListTemplate {
pub owner: User,
pub repo: Repository,
pub access: Access,
- pub issues: Vec<IssueListItem>,
- pub labels: Vec<Label>,
- pub milestones: Vec<Milestone>,
+ pub issues: Vec<Issue>,
pub state_filter: String,
- pub label_filter: Option<Uuid>,
- pub milestone_filter: Option<Uuid>,
pub clone_http: String,
pub clone_ssh: String,
}
@@ -564,8 +401,6 @@ pub struct IssueNewTemplate {
pub owner: User,
pub repo: Repository,
pub access: Access,
- pub labels: Vec<Label>,
- pub milestones: Vec<Milestone>,
pub error: Option<String>,
pub clone_http: String,
pub clone_ssh: String,
@@ -583,11 +418,6 @@ pub struct IssueViewTemplate {
pub author_avatar: String,
pub body_html: String,
pub comments: Vec<CommentView>,
- pub labels: Vec<Label>,
- pub label_options: Vec<LabelOption>,
- pub milestone: Option<Milestone>,
- pub milestone_options: Vec<MilestoneOption>,
- pub can_triage: bool,
pub clone_http: String,
pub clone_ssh: String,
}
@@ -599,12 +429,8 @@ pub struct PullsListTemplate {
pub owner: User,
pub repo: Repository,
pub access: Access,
- pub pulls: Vec<PullListItem>,
- pub labels: Vec<Label>,
- pub milestones: Vec<Milestone>,
+ pub pulls: Vec<PullRequest>,
pub state_filter: String,
- pub label_filter: Option<Uuid>,
- pub milestone_filter: Option<Uuid>,
pub clone_http: String,
pub clone_ssh: String,
}
@@ -617,8 +443,6 @@ pub struct PullNewTemplate {
pub repo: Repository,
pub access: Access,
pub branches: Vec<String>,
- pub labels: Vec<Label>,
- pub milestones: Vec<Milestone>,
pub error: Option<String>,
pub clone_http: String,
pub clone_ssh: String,
@@ -637,47 +461,12 @@ pub struct PullViewTemplate {
pub author_avatar: String,
pub body_html: String,
pub comments: Vec<CommentView>,
- pub reviews: Vec<PullReviewView>,
pub commits: Vec<CommitView>,
pub diff_files: Vec<DiffFileView>,
pub tab: String,
- pub show_full: bool,
pub conversation_count: usize,
pub can_merge: bool,
pub merge_styles: Vec<String>,
- pub labels: Vec<Label>,
- pub label_options: Vec<LabelOption>,
- pub milestone: Option<Milestone>,
- pub milestone_options: Vec<MilestoneOption>,
- pub can_triage: bool,
- pub is_author: bool,
- pub clone_http: String,
- pub clone_ssh: String,
-}
-
-#[derive(Template, WebTemplate)]
-#[template(path = "labels_list.html")]
-pub struct LabelsListTemplate {
- pub viewer: Option<User>,
- pub owner: User,
- pub repo: Repository,
- pub access: Access,
- pub labels: Vec<Label>,
- pub error: Option<String>,
- pub clone_http: String,
- pub clone_ssh: String,
-}
-
-#[derive(Template, WebTemplate)]
-#[template(path = "milestones_list.html")]
-pub struct MilestonesListTemplate {
- pub viewer: Option<User>,
- pub owner: User,
- pub repo: Repository,
- pub access: Access,
- pub milestones: Vec<Milestone>,
- pub state_filter: String,
- pub error: Option<String>,
pub clone_http: String,
pub clone_ssh: String,
}
@@ -756,10 +545,6 @@ pub struct RepoSettingsTemplate {
pub collaborators: Vec<CollaboratorView>,
pub branches: Vec<String>,
pub branch_rules: Vec<BranchRule>,
- pub mirror: Option<RepoMirror>,
- pub deploy_keys: Vec<DeployKey>,
- pub webhooks: Vec<WebhookView>,
- pub webhook_deliveries: Vec<WebhookDeliveryView>,
pub clone_http: String,
pub clone_ssh: String,
pub is_site_admin: bool,
@@ -789,14 +574,6 @@ pub struct AdminTemplate {
pub flash: Option<String>,
}
-#[derive(Template, WebTemplate)]
-#[template(path = "admin_user_audit.html")]
-pub struct AdminUserAuditTemplate {
- pub viewer: Option<User>,
- pub user: User,
- pub audit_entries: Vec<AuditEntryView>,
-}
-
pub struct AdminInviteView {
pub id: Uuid,
pub code: String,
@@ -820,11 +597,3 @@ pub struct AdminStatsView {
pub active_invites: i64,
pub disk_label: String,
}
-
-#[derive(Template, WebTemplate)]
-#[template(path = "notifications.html")]
-pub struct NotificationsTemplate {
- pub viewer: Option<User>,
- pub notifications: Vec<Notification>,
- pub unread_count: i64,
-}
diff --git a/src/webhooks.rs b/src/webhooks.rs
deleted file mode 100644
index f2af0e6..0000000
--- a/src/webhooks.rs
+++ /dev/null
@@ -1,189 +0,0 @@
-//! Repository webhook delivery (JSON POST + optional HMAC-SHA256).
-
-use crate::db::models::{Repository, User, Webhook};
-use crate::db::queries;
-use anyhow::Result;
-use hmac::{Hmac, Mac};
-use serde_json::{json, Value};
-use sha2::Sha256;
-use sqlx::PgPool;
-use std::time::Instant;
-use uuid::Uuid;
-
-type HmacSha256 = Hmac<Sha256>;
-
-pub const EVENT_PUSH: &str = "push";
-pub const EVENT_ISSUES: &str = "issues";
-pub const EVENT_PULL_REQUEST: &str = "pull_request";
-pub const EVENT_RELEASE: &str = "release";
-
-pub const ALL_EVENTS: &[&str] = &[EVENT_PUSH, EVENT_ISSUES, EVENT_PULL_REQUEST, EVENT_RELEASE];
-
-pub fn normalize_events(raw: &[String]) -> Vec<String> {
- let mut out = Vec::new();
- for e in raw {
- let e = e.trim();
- if ALL_EVENTS.contains(&e) && !out.iter().any(|x| x == e) {
- out.push(e.to_string());
- }
- }
- out
-}
-
-fn sign_body(secret: &str, body: &[u8]) -> Option<String> {
- if secret.is_empty() {
- return None;
- }
- let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).ok()?;
- mac.update(body);
- Some(format!(
- "sha256={}",
- hex::encode(mac.finalize().into_bytes())
- ))
-}
-
-fn build_envelope(
- event: &str,
- action: &str,
- delivery_id: Uuid,
- repo: &Repository,
- owner: &str,
- sender: Option<&User>,
- payload: Value,
-) -> Value {
- json!({
- "event": event,
- "action": action,
- "delivery_id": delivery_id.to_string(),
- "repository": {
- "id": repo.id,
- "name": repo.name,
- "owner": owner,
- "full_name": format!("{owner}/{}", repo.name),
- "default_branch": repo.default_branch,
- "visibility": repo.visibility,
- "private": repo.visibility == "private",
- },
- "sender": sender.map(|u| json!({
- "id": u.id,
- "username": u.username,
- "display_name": u.display_name,
- })),
- "payload": payload,
- })
-}
-
-/// Fire matching active webhooks for a repo. Failures are logged; callers should ignore errors.
-pub async fn dispatch(
- pool: &PgPool,
- event: &str,
- action: &str,
- repo: &Repository,
- owner: &str,
- sender: Option<&User>,
- payload: Value,
-) -> Result<()> {
- let hooks = queries::list_active_webhooks_for_event(pool, repo.id, event).await?;
- if hooks.is_empty() {
- return Ok(());
- }
- let delivery_id = Uuid::new_v4();
- let body_val = build_envelope(event, action, delivery_id, repo, owner, sender, payload);
- let body = serde_json::to_vec(&body_val)?;
- let client = reqwest::Client::builder()
- .timeout(std::time::Duration::from_secs(10))
- .redirect(reqwest::redirect::Policy::none())
- .user_agent("kitgit-webhooks/0.1")
- .build()?;
-
- for hook in hooks {
- deliver_one(pool, &client, &hook, event, action, delivery_id, &body).await;
- }
- Ok(())
-}
-
-/// Spawn a background dispatch so request handlers stay fast.
-pub fn spawn_dispatch(
- pool: PgPool,
- event: &'static str,
- action: String,
- repo: Repository,
- owner: String,
- sender: Option<User>,
- payload: Value,
-) {
- tokio::spawn(async move {
- if let Err(e) = dispatch(
- &pool,
- event,
- &action,
- &repo,
- &owner,
- sender.as_ref(),
- payload,
- )
- .await
- {
- tracing::warn!("webhook dispatch failed: {e:#}");
- }
- });
-}
-
-async fn deliver_one(
- pool: &PgPool,
- client: &reqwest::Client,
- hook: &Webhook,
- event: &str,
- action: &str,
- delivery_id: Uuid,
- body: &[u8],
-) {
- let started = Instant::now();
- let mut req = client
- .post(&hook.url)
- .header("Content-Type", "application/json")
- .header("X-Kitgit-Event", event)
- .header("X-Kitgit-Delivery", delivery_id.to_string())
- .header("X-Kitgit-Action", action)
- .body(body.to_vec());
-
- if let Some(sig) = sign_body(&hook.secret, body) {
- req = req.header("X-Hub-Signature-256", sig);
- }
-
- let (success, status_code, error) = match req.send().await {
- Ok(resp) => {
- let code = resp.status().as_u16() as i32;
- let ok = resp.status().is_success();
- let err = if ok {
- None
- } else {
- let text = resp.text().await.unwrap_or_default();
- let truncated: String = text.chars().take(500).collect();
- Some(if truncated.is_empty() {
- format!("HTTP {code}")
- } else {
- format!("HTTP {code}: {truncated}")
- })
- };
- (ok, Some(code), err)
- }
- Err(e) => (false, None, Some(e.to_string())),
- };
-
- let duration_ms = started.elapsed().as_millis().min(i32::MAX as u128) as i32;
- if let Err(e) = queries::record_webhook_delivery(
- pool,
- hook.id,
- event,
- action,
- success,
- status_code,
- error.as_deref(),
- duration_ms,
- )
- .await
- {
- tracing::warn!("failed to record webhook delivery: {e:#}");
- }
-}
\ No newline at end of file
static/brand.css
+49
−525
diff --git a/static/brand.css b/static/brand.css
index 8f082ff..db813d2 100644
--- a/static/brand.css
+++ b/static/brand.css
@@ -1,8 +1,7 @@
/* kitgit — brand theme
ink on paper. no chrome. */
-:root,
-[data-theme="light"] {
+:root {
--kg-bg: #ffffff;
--kg-bg-raised: #f7f7f7;
--kg-fg: #0a0a0a;
@@ -35,7 +34,6 @@
color-scheme: light;
}
-[data-theme="dark"],
html.kg-dark {
--kg-bg: #0a0a0a;
--kg-bg-raised: #141414;
@@ -45,7 +43,6 @@ html.kg-dark {
--kg-line: #262626;
--kg-line-strong: #f5f5f5;
--kg-link: #f5f5f5;
- --kg-focus: #f5f5f5;
--kg-danger: #ff4d5a;
--kg-danger-bg: #2a1214;
--kg-invert-bg: #f5f5f5;
@@ -54,26 +51,6 @@ html.kg-dark {
color-scheme: dark;
}
-@media (prefers-color-scheme: dark) {
- [data-theme="system"] {
- --kg-bg: #0a0a0a;
- --kg-bg-raised: #141414;
- --kg-fg: #f5f5f5;
- --kg-muted: #8a8a8a;
- --kg-faint: #5c5c5c;
- --kg-line: #262626;
- --kg-line-strong: #f5f5f5;
- --kg-link: #f5f5f5;
- --kg-focus: #f5f5f5;
- --kg-danger: #ff4d5a;
- --kg-danger-bg: #2a1214;
- --kg-invert-bg: #f5f5f5;
- --kg-invert-fg: #0a0a0a;
- --kg-invert-muted: #525252;
- color-scheme: dark;
- }
-}
-
*,
*::before,
*::after {
@@ -193,21 +170,12 @@ a:hover {
}
.kg-footer {
- display: flex;
- justify-content: space-between;
- align-items: baseline;
- gap: var(--kg-space-4);
padding: var(--kg-space-4) 0 var(--kg-space-6);
color: var(--kg-faint);
font-size: 0.75rem;
letter-spacing: 0.06em;
}
-.kg-footer__timing {
- letter-spacing: 0;
- font-variant-numeric: tabular-nums;
-}
-
.kg-display {
font-weight: 700;
font-size: clamp(1.5rem, 3vw, 2rem);
@@ -288,6 +256,12 @@ a:hover {
background: var(--kg-danger-bg);
}
+.kg-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--kg-space-2);
+}
+
.kg-list {
list-style: none;
margin: 0;
@@ -539,145 +513,32 @@ a:hover {
min-width: 0;
}
-.kg-toolbar {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: var(--kg-space-3);
- margin: var(--kg-space-3) 0 var(--kg-space-2);
- min-width: 0;
-}
-
-.kg-toolbar__branch {
- flex-shrink: 0;
-}
-
-.kg-toolbar__actions {
- display: flex;
- align-items: center;
- gap: var(--kg-space-2);
- flex-wrap: nowrap;
- flex-shrink: 0;
- margin-left: auto;
-}
-
-.kg-latest {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: var(--kg-space-3);
- flex-wrap: nowrap;
- border: 1px solid var(--kg-line);
- padding: 0.55rem 0.75rem;
- margin: 0 0 var(--kg-space-3);
- background: var(--kg-bg-raised);
- min-width: 0;
-}
-
-.kg-latest__main {
- display: flex;
- align-items: center;
- gap: 0.5rem;
- min-width: 0;
- flex: 1 1 auto;
- overflow: hidden;
-}
-
-.kg-latest__author {
- flex-shrink: 0;
- font-weight: 600;
- color: var(--kg-fg);
- text-decoration: none;
- white-space: nowrap;
-}
-
-a.kg-latest__author:hover {
- text-decoration: underline;
- text-underline-offset: 0.15em;
-}
-
-.kg-latest__msg {
- color: var(--kg-fg);
- text-decoration: none;
- font-size: 0.875rem;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- min-width: 0;
- flex: 1 1 auto;
-}
-
-.kg-latest__msg:hover {
- text-decoration: underline;
- text-underline-offset: 0.15em;
-}
-
-.kg-latest__meta {
- display: flex;
- align-items: center;
- gap: 0.65rem;
- flex-shrink: 0;
- white-space: nowrap;
-}
-
-.kg-latest__sha {
- color: var(--kg-muted);
- text-decoration: none;
- font-size: 0.8125rem;
-}
-
-.kg-latest__sha:hover {
- text-decoration: underline;
- text-underline-offset: 0.15em;
-}
-
-.kg-latest__sha code {
- font-size: inherit;
-}
-
-.kg-latest__time {
- white-space: nowrap;
-}
-
-.kg-latest__count {
- text-decoration: none;
- white-space: nowrap;
-}
-
-.kg-latest__count:hover {
- text-decoration: underline;
- text-underline-offset: 0.15em;
-}
-
.kg-branchbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--kg-space-3);
- flex-wrap: nowrap;
+ flex-wrap: wrap;
border: 1px solid var(--kg-line);
padding: 0.55rem 0.75rem;
margin: var(--kg-space-3) 0;
background: var(--kg-bg-raised);
- min-width: 0;
}
.kg-branchbar__left {
display: flex;
align-items: center;
gap: var(--kg-space-3);
- flex-wrap: nowrap;
+ flex-wrap: wrap;
min-width: 0;
flex: 1 1 auto;
- overflow: hidden;
}
.kg-branchbar__actions {
display: flex;
align-items: center;
gap: var(--kg-space-2);
- flex-wrap: nowrap;
- flex-shrink: 0;
+ flex-wrap: wrap;
}
.kg-branchbar__branch {
@@ -687,16 +548,15 @@ a.kg-latest__author:hover {
text-decoration: none;
font-weight: 700;
color: var(--kg-fg);
- flex-shrink: 0;
}
.kg-branchbar__commit {
display: flex;
align-items: center;
- gap: 0.5rem;
+ gap: var(--kg-space-2);
min-width: 0;
- flex: 1 1 auto;
- overflow: hidden;
+ flex: 1;
+ justify-content: flex-end;
}
.kg-branchbar__sha {
@@ -716,8 +576,7 @@ a.kg-latest__author:hover {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
- min-width: 0;
- flex: 1 1 auto;
+ max-width: 28rem;
}
.kg-branchbar__msg:hover,
@@ -788,102 +647,6 @@ a.kg-latest__author:hover {
align-items: center;
}
-.kg-blame {
- border: 1px solid var(--kg-line);
- margin-top: var(--kg-space-3);
- overflow-x: auto;
- font-family: var(--kg-font);
- font-size: 0.8125rem;
- line-height: 1.5;
-}
-
-.kg-blame__head,
-.kg-blame__row {
- display: grid;
- grid-template-columns: minmax(12rem, 16rem) 3.25rem minmax(0, 1fr);
- align-items: stretch;
- border-bottom: 1px solid var(--kg-line);
-}
-
-.kg-blame__head {
- background: var(--kg-bg);
- color: var(--kg-faint);
- font-size: 0.75rem;
- text-transform: uppercase;
- letter-spacing: 0.04em;
-}
-
-.kg-blame__head > span {
- padding: 0.45rem 0.65rem;
-}
-
-.kg-blame__row:last-child {
- border-bottom: 0;
-}
-
-.kg-blame__row--hunk {
- border-top: 1px solid var(--kg-line);
-}
-
-.kg-blame__row--hunk:first-of-type {
- border-top: 0;
-}
-
-.kg-blame__meta {
- display: flex;
- flex-wrap: wrap;
- align-items: baseline;
- gap: 0.35rem 0.55rem;
- padding: 0.15rem 0.65rem;
- background: var(--kg-bg);
- border-right: 1px solid var(--kg-line);
- min-width: 0;
- color: var(--kg-muted, var(--kg-faint));
-}
-
-.kg-blame__sha {
- font-family: inherit;
- font-variant-numeric: tabular-nums;
-}
-
-.kg-blame__author {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- max-width: 7rem;
- color: var(--kg-fg);
-}
-
-.kg-blame__date {
- font-variant-numeric: tabular-nums;
- color: var(--kg-faint);
-}
-
-.kg-blame__num {
- margin: 0;
- padding: 0.15rem 0.55rem;
- text-align: right;
- color: var(--kg-faint);
- user-select: none;
- font-variant-numeric: tabular-nums;
- border-right: 1px solid var(--kg-line);
- background: var(--kg-bg);
-}
-
-.kg-blame__code {
- margin: 0;
- padding: 0.15rem 0.75rem;
- min-width: 0;
- overflow-x: auto;
- white-space: pre;
- font-family: inherit;
- font-size: inherit;
- line-height: inherit;
- background: transparent;
- border: 0;
- color: inherit;
-}
-
.kg-danger-zone {
margin-top: var(--kg-space-5);
padding: var(--kg-space-3);
@@ -903,30 +666,15 @@ a.kg-latest__author:hover {
.kg-danger-row {
display: flex;
- flex-wrap: nowrap;
- align-items: center;
+ flex-wrap: wrap;
+ align-items: flex-end;
gap: var(--kg-space-2);
}
-.kg-danger-row label,
-.kg-danger-confirm {
- flex: 1 1 auto;
+.kg-danger-row label {
+ flex: 1 1 12rem;
margin: 0;
- display: flex;
- flex-direction: row;
- flex-wrap: nowrap;
- align-items: center;
- gap: 0.5rem;
- white-space: nowrap;
-}
-
-.kg-danger-confirm input {
- flex: 1 1 10rem;
- min-width: 8rem;
-}
-
-.kg-danger-row > button {
- flex-shrink: 0;
+ gap: 0.25rem;
}
.kg-settings-section {
@@ -941,7 +689,6 @@ a.kg-latest__author:hover {
.kg-social {
display: flex;
- flex-direction: row;
flex-wrap: wrap;
gap: var(--kg-space-2);
margin: 0;
@@ -952,29 +699,6 @@ a.kg-latest__author:hover {
.kg-social form {
margin: 0;
- display: inline-flex;
-}
-
-.kg-branchbar__count {
- display: inline-flex;
- align-items: center;
- gap: 0.35em;
- text-decoration: none;
- flex-shrink: 0;
- white-space: nowrap;
-}
-
-.kg-branchbar__count:hover {
- text-decoration: underline;
- text-underline-offset: 0.15em;
-}
-
-.kg-actions {
- display: flex;
- flex-direction: row;
- flex-wrap: wrap;
- gap: var(--kg-space-2);
- align-items: center;
}
.kg-reactions {
@@ -1499,38 +1223,12 @@ a.kg-latest__author:hover {
.kg-tree li {
display: grid;
- grid-template-columns: 1.5rem minmax(5rem, 12rem) minmax(0, 1fr) 9rem 2rem;
+ grid-template-columns: 1.5rem 1fr auto auto;
align-items: center;
gap: var(--kg-space-2);
padding: 0.35rem var(--kg-space-3);
border-bottom: 1px solid var(--kg-line);
font-size: 0.875rem;
- min-width: 0;
-}
-
-.kg-tree li > a {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- min-width: 0;
-}
-
-.kg-tree__commit {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- min-width: 0;
-}
-
-.kg-tree__time {
- text-align: right;
- white-space: nowrap;
- flex-shrink: 0;
-}
-
-.kg-tree__dl {
- justify-self: end;
- flex-shrink: 0;
}
.kg-tree li:last-child {
@@ -1643,67 +1341,35 @@ a.kg-latest__author:hover {
.kg-codeview .entity.other.attribute-name { color: #8a4b08; }
.kg-codeview .punctuation { color: #3d3d3d; }
-:is([data-theme="dark"], html.kg-dark) .kg-codeview .comment,
-:is([data-theme="dark"], html.kg-dark) .kg-codeview .punctuation.definition.comment { color: #8a8a8a; }
-:is([data-theme="dark"], html.kg-dark) .kg-codeview .string,
-:is([data-theme="dark"], html.kg-dark) .kg-codeview .string.quoted,
-:is([data-theme="dark"], html.kg-dark) .kg-codeview .constant.character.escape { color: #5ec49a; }
-:is([data-theme="dark"], html.kg-dark) .kg-codeview .constant.numeric,
-:is([data-theme="dark"], html.kg-dark) .kg-codeview .constant.language,
-:is([data-theme="dark"], html.kg-dark) .kg-codeview .constant.character { color: #e0a35a; }
-:is([data-theme="dark"], html.kg-dark) .kg-codeview .keyword,
-:is([data-theme="dark"], html.kg-dark) .kg-codeview .keyword.control,
-:is([data-theme="dark"], html.kg-dark) .kg-codeview .keyword.operator,
-:is([data-theme="dark"], html.kg-dark) .kg-codeview .storage,
-:is([data-theme="dark"], html.kg-dark) .kg-codeview .storage.type,
-:is([data-theme="dark"], html.kg-dark) .kg-codeview .storage.modifier { color: #f5f5f5; font-weight: 700; }
-:is([data-theme="dark"], html.kg-dark) .kg-codeview .entity.name.function,
-:is([data-theme="dark"], html.kg-dark) .kg-codeview .support.function { color: #7eb0f0; }
-:is([data-theme="dark"], html.kg-dark) .kg-codeview .entity.name.class,
-:is([data-theme="dark"], html.kg-dark) .kg-codeview .entity.name.struct,
-:is([data-theme="dark"], html.kg-dark) .kg-codeview .entity.name.type,
-:is([data-theme="dark"], html.kg-dark) .kg-codeview .entity.name.namespace,
-:is([data-theme="dark"], html.kg-dark) .kg-codeview .support.type,
-:is([data-theme="dark"], html.kg-dark) .kg-codeview .support.class { color: #c9a0ff; }
-:is([data-theme="dark"], html.kg-dark) .kg-codeview .variable,
-:is([data-theme="dark"], html.kg-dark) .kg-codeview .variable.parameter,
-:is([data-theme="dark"], html.kg-dark) .kg-codeview .variable.other { color: #f5f5f5; }
-:is([data-theme="dark"], html.kg-dark) .kg-codeview .meta.annotation,
-:is([data-theme="dark"], html.kg-dark) .kg-codeview .entity.name.tag { color: #ff8a8a; }
-:is([data-theme="dark"], html.kg-dark) .kg-codeview .entity.other.attribute-name { color: #e0a35a; }
-:is([data-theme="dark"], html.kg-dark) .kg-codeview .punctuation { color: #b0b0b0; }
-
-@media (prefers-color-scheme: dark) {
- [data-theme="system"] .kg-codeview .comment,
- [data-theme="system"] .kg-codeview .punctuation.definition.comment { color: #8a8a8a; }
- [data-theme="system"] .kg-codeview .string,
- [data-theme="system"] .kg-codeview .string.quoted,
- [data-theme="system"] .kg-codeview .constant.character.escape { color: #5ec49a; }
- [data-theme="system"] .kg-codeview .constant.numeric,
- [data-theme="system"] .kg-codeview .constant.language,
- [data-theme="system"] .kg-codeview .constant.character { color: #e0a35a; }
- [data-theme="system"] .kg-codeview .keyword,
- [data-theme="system"] .kg-codeview .keyword.control,
- [data-theme="system"] .kg-codeview .keyword.operator,
- [data-theme="system"] .kg-codeview .storage,
- [data-theme="system"] .kg-codeview .storage.type,
- [data-theme="system"] .kg-codeview .storage.modifier { color: #f5f5f5; font-weight: 700; }
- [data-theme="system"] .kg-codeview .entity.name.function,
- [data-theme="system"] .kg-codeview .support.function { color: #7eb0f0; }
- [data-theme="system"] .kg-codeview .entity.name.class,
- [data-theme="system"] .kg-codeview .entity.name.struct,
- [data-theme="system"] .kg-codeview .entity.name.type,
- [data-theme="system"] .kg-codeview .entity.name.namespace,
- [data-theme="system"] .kg-codeview .support.type,
- [data-theme="system"] .kg-codeview .support.class { color: #c9a0ff; }
- [data-theme="system"] .kg-codeview .variable,
- [data-theme="system"] .kg-codeview .variable.parameter,
- [data-theme="system"] .kg-codeview .variable.other { color: #f5f5f5; }
- [data-theme="system"] .kg-codeview .meta.annotation,
- [data-theme="system"] .kg-codeview .entity.name.tag { color: #ff8a8a; }
- [data-theme="system"] .kg-codeview .entity.other.attribute-name { color: #e0a35a; }
- [data-theme="system"] .kg-codeview .punctuation { color: #b0b0b0; }
-}
+html.kg-dark .kg-codeview .comment,
+html.kg-dark .kg-codeview .punctuation.definition.comment { color: #8a8a8a; }
+html.kg-dark .kg-codeview .string,
+html.kg-dark .kg-codeview .string.quoted,
+html.kg-dark .kg-codeview .constant.character.escape { color: #5ec49a; }
+html.kg-dark .kg-codeview .constant.numeric,
+html.kg-dark .kg-codeview .constant.language,
+html.kg-dark .kg-codeview .constant.character { color: #e0a35a; }
+html.kg-dark .kg-codeview .keyword,
+html.kg-dark .kg-codeview .keyword.control,
+html.kg-dark .kg-codeview .keyword.operator,
+html.kg-dark .kg-codeview .storage,
+html.kg-dark .kg-codeview .storage.type,
+html.kg-dark .kg-codeview .storage.modifier { color: #f5f5f5; font-weight: 700; }
+html.kg-dark .kg-codeview .entity.name.function,
+html.kg-dark .kg-codeview .support.function { color: #7eb0f0; }
+html.kg-dark .kg-codeview .entity.name.class,
+html.kg-dark .kg-codeview .entity.name.struct,
+html.kg-dark .kg-codeview .entity.name.type,
+html.kg-dark .kg-codeview .entity.name.namespace,
+html.kg-dark .kg-codeview .support.type,
+html.kg-dark .kg-codeview .support.class { color: #c9a0ff; }
+html.kg-dark .kg-codeview .variable,
+html.kg-dark .kg-codeview .variable.parameter,
+html.kg-dark .kg-codeview .variable.other { color: #f5f5f5; }
+html.kg-dark .kg-codeview .meta.annotation,
+html.kg-dark .kg-codeview .entity.name.tag { color: #ff8a8a; }
+html.kg-dark .kg-codeview .entity.other.attribute-name { color: #e0a35a; }
+html.kg-dark .kg-codeview .punctuation { color: #b0b0b0; }
.kg-readme {
border: 1px solid var(--kg-line);
@@ -1816,57 +1482,6 @@ a.kg-latest__author:hover {
color: var(--kg-muted);
}
-.kg-badge--approved {
- color: #1a7f37;
- border-color: #1a7f37;
- background: color-mix(in srgb, #1a7f37 12%, transparent);
-}
-
-.kg-badge--changes {
- color: #cf222e;
- border-color: #cf222e;
- background: color-mix(in srgb, #cf222e 12%, transparent);
-}
-
-.kg-badge--commented {
- color: var(--kg-muted);
- border-color: var(--kg-line-strong);
- background: transparent;
-}
-
-.kg-review-state {
- border: 1px solid var(--kg-line);
- border-radius: var(--kg-radius);
- padding: var(--kg-space-3);
- margin: 0 0 var(--kg-space-3);
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- gap: var(--kg-space-3);
-}
-
-.kg-review-state legend {
- padding: 0 var(--kg-space-1);
- font-size: 0.8125rem;
- color: var(--kg-muted);
- text-transform: lowercase;
- width: 100%;
- float: left;
- margin-bottom: var(--kg-space-1);
-}
-
-.kg-review-state .row {
- display: inline-flex;
- align-items: center;
- gap: var(--kg-space-2);
- margin: 0;
-}
-
-.kg-diff__truncated .kg-btn {
- margin-left: var(--kg-space-2);
- vertical-align: middle;
-}
-
.kg-badge--verified {
color: #1a7f37;
border-color: #1a7f37;
@@ -1877,20 +1492,6 @@ a.kg-latest__author:hover {
cursor: pointer;
}
-.kg-badge--unverified {
- color: var(--kg-muted);
- border-color: var(--kg-line-strong);
- background: transparent;
- text-transform: none;
- letter-spacing: 0.02em;
- font-weight: 600;
- cursor: default;
-}
-
-.kg-verify .kg-badge--unverified {
- cursor: pointer;
-}
-
.kg-verify {
position: relative;
display: inline-block;
@@ -1918,12 +1519,6 @@ a.kg-latest__author:hover {
box-shadow: 0 8px 24px color-mix(in srgb, var(--kg-fg) 12%, transparent);
}
-.kg-verify__row {
- margin: 0 0 0.35rem;
- font-size: 0.8125rem;
- line-height: 1.4;
-}
-
.kg-verify__fp {
margin: 0 0 0.35rem;
font-size: 0.8125rem;
@@ -1935,29 +1530,6 @@ a.kg-latest__author:hover {
font-size: 0.75rem;
}
-.kg-verify__panel a {
- color: inherit;
- text-decoration: underline;
-}
-
-.kg-signing-hint {
- margin: 1rem 0 0;
- padding: var(--kg-space-3);
- border: 1px dashed var(--kg-line-strong);
- background: color-mix(in srgb, var(--kg-fg) 3%, transparent);
- font-size: 0.875rem;
- line-height: 1.5;
-}
-
-.kg-signing-hint pre {
- margin: 0.5rem 0 0;
- padding: var(--kg-space-2);
- overflow-x: auto;
- font-size: 0.75rem;
- background: var(--kg-bg);
- border: 1px solid var(--kg-line);
-}
-
.kg-key-row {
align-items: flex-start;
}
@@ -2005,51 +1577,3 @@ a.kg-latest__author:hover {
animation: none !important;
}
}
-
-.kg-notif-count {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- min-width: 1.25rem;
- height: 1.25rem;
- padding: 0 0.35em;
- margin-left: 0.25em;
- border-radius: 999px;
- background: var(--kg-invert-bg);
- color: var(--kg-invert-fg);
- font-size: 0.65rem;
- font-weight: 700;
- line-height: 1;
- vertical-align: middle;
-}
-
-.kg-notif-list li {
- align-items: flex-start;
-}
-
-.kg-notif-main {
- display: flex;
- flex-direction: column;
- gap: 0.2rem;
- min-width: 0;
-}
-
-.kg-notif-actions {
- flex-shrink: 0;
-}
-
-.kg-notif--unread {
- border-left: 2px solid var(--kg-fg);
- padding-left: 0.65rem;
- margin-left: -0.65rem;
-}
-
-.kg-label-chip { display:inline-block; padding:0.12em 0.45em; font-size:0.75rem; line-height:1.3; border:1px solid transparent; vertical-align:middle; text-transform:none; letter-spacing:0.02em; }
-.kg-label-row { display:flex; flex-wrap:wrap; gap:0.35rem; margin-bottom:0.5rem; }
-.kg-issue-layout { display:grid; grid-template-columns:minmax(0,1fr) 14rem; gap:1.5rem; align-items:start; }
-.kg-issue-sidebar { font-size:0.9rem; }
-.kg-sidebar-block { margin-bottom:1.25rem; padding-bottom:1rem; border-bottom:1px solid var(--kg-line); }
-.kg-filters { display:flex; flex-wrap:wrap; gap:0.75rem; align-items:end; margin:1rem 0; }
-.kg-fieldset { border:1px solid var(--kg-line); padding:0.75rem; margin:0.75rem 0; }
-@media (max-width:800px) { .kg-issue-layout { grid-template-columns:1fr; } }
-
static/icons/bell.svg
+0
−1
diff --git a/static/icons/bell.svg b/static/icons/bell.svg
deleted file mode 100644
index 142388b..0000000
--- a/static/icons/bell.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.268 21a2 2 0 0 0 3.464 0"/><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/></svg>
templates/account_settings.html
+7
−62
diff --git a/templates/account_settings.html b/templates/account_settings.html
index 2b8cbee..248b87f 100644
--- a/templates/account_settings.html
+++ b/templates/account_settings.html
@@ -20,28 +20,6 @@
{% endif %}
<div class="kg-settings-section" style="margin-top:1rem;border-top:0;padding-top:0">
- <span class="kg-kicker">appearance</span>
- <p class="kg-muted">Ink on paper by day, charcoal by night — or follow your system.</p>
- <form class="kg-form" method="post" action="/settings/account/theme">
- <label class="row">
- <input type="radio" name="theme" value="light"{% if user.theme == "light" %} checked{% endif %} />
- light
- </label>
- <label class="row">
- <input type="radio" name="theme" value="dark"{% if user.theme == "dark" %} checked{% endif %} />
- dark
- </label>
- <label class="row">
- <input type="radio" name="theme" value="system"{% if user.theme != "light" && user.theme != "dark" %} checked{% endif %} />
- system
- </label>
- <div class="kg-actions">
- <button class="kg-btn" type="submit">save theme</button>
- </div>
- </form>
- </div>
-
- <div class="kg-settings-section">
<span class="kg-kicker">username</span>
<form class="kg-form" method="post" action="/settings/account/username">
<label>
@@ -97,16 +75,11 @@
{% if e.is_primary %}<span class="kg-badge">primary</span>{% endif %}
{% if e.verified %}<span class="kg-badge">verified</span>{% endif %}
</span>
- <span class="kg-actions">
- {% if !e.is_primary %}
- <form method="post" action="/settings/account/emails/{{ e.id }}/primary">
- <button class="kg-btn kg-btn--ghost" type="submit">make primary</button>
- </form>
- <form method="post" action="/settings/account/emails/{{ e.id }}/delete">
- <button class="kg-btn kg-btn--danger-ghost" type="submit">remove</button>
- </form>
- {% endif %}
- </span>
+ {% if !e.is_primary %}
+ <form method="post" action="/settings/account/emails/{{ e.id }}/delete">
+ <button class="kg-btn kg-btn--danger-ghost" type="submit">remove</button>
+ </form>
+ {% endif %}
</li>
{% endfor %}
</ul>
@@ -158,34 +131,6 @@
</div>
<div class="kg-settings-section">
- <span class="kg-kicker">security audit log</span>
- <p class="kg-muted">Recent sign-ins and account changes for this account.</p>
- {% if audit_entries.is_empty() %}
- <p class="kg-muted">No audit events yet.</p>
- {% else %}
- <ul class="kg-list" style="margin-top:0.75rem">
- {% for e in audit_entries %}
- <li>
- <span>
- <strong>{{ e.action_label }}</strong>
- <span class="kg-meta"> · {{ e.created_at }}</span>
- {% if !e.ip.is_empty() %}
- <span class="kg-meta"> · {{ e.ip }}</span>
- {% endif %}
- {% if !e.user_agent.is_empty() %}
- <span class="kg-meta"> · {{ e.user_agent }}</span>
- {% endif %}
- {% if !e.metadata.is_empty() %}
- <span class="kg-meta"> · <code>{{ e.metadata }}</code></span>
- {% endif %}
- </span>
- </li>
- {% endfor %}
- </ul>
- {% endif %}
- </div>
-
- <div class="kg-settings-section">
<span class="kg-kicker">GDPR</span>
<div class="kg-actions">
<a class="kg-btn kg-btn--ghost" href="/settings/account/export">export my data</a>
@@ -196,8 +141,8 @@
<span class="kg-kicker">delete account</span>
<form class="kg-form" method="post" action="/settings/account/delete">
<div class="kg-danger-row">
- <label class="kg-danger-confirm">
- <span>type <code>{{ user.username }}</code> to delete</span>
+ <label>
+ type <code>{{ user.username }}</code> to delete
<input type="text" name="confirm" required autocomplete="off" />
</label>
<button class="kg-btn kg-btn--danger" type="submit">delete account</button>
templates/admin.html
+0
−1
diff --git a/templates/admin.html b/templates/admin.html
index 2723110..f16f5c8 100644
--- a/templates/admin.html
+++ b/templates/admin.html
@@ -104,7 +104,6 @@
</span>
</div>
<div class="kg-admin-actions">
- <a class="kg-btn kg-btn--ghost" href="/admin/users/{{ u.username }}/audit">audit</a>
<form method="post" action="/admin/users/suspend" style="display:inline">
<input type="hidden" name="user_id" value="{{ u.id }}" />
{% if u.is_suspended %}
templates/admin_user_audit.html
+0
−37
diff --git a/templates/admin_user_audit.html b/templates/admin_user_audit.html
deleted file mode 100644
index 5dc5a6b..0000000
--- a/templates/admin_user_audit.html
+++ /dev/null
@@ -1,37 +0,0 @@
-{% extends "layout.html" %}
-{% block title %}audit — {{ user.username }} — kitgit{% endblock %}
-{% block content %}
-<section class="kg-section">
- <span class="kg-kicker">admin</span>
- <h1 class="kg-title">Audit log · {{ user.username }}</h1>
- <p class="kg-muted">
- <a href="/admin">← back to admin</a>
- · <a href="/{{ user.username }}">profile</a>
- </p>
-
- {% if audit_entries.is_empty() %}
- <p class="kg-muted" style="margin-top:1.5rem">No audit events for this user.</p>
- {% else %}
- <ul class="kg-list" style="margin-top:1.5rem">
- {% for e in audit_entries %}
- <li>
- <span>
- <strong>{{ e.action_label }}</strong>
- <code class="kg-meta">{{ e.action }}</code>
- <span class="kg-meta"> · {{ e.created_at }}</span>
- {% if !e.ip.is_empty() %}
- <span class="kg-meta"> · {{ e.ip }}</span>
- {% endif %}
- {% if !e.user_agent.is_empty() %}
- <span class="kg-meta"> · {{ e.user_agent }}</span>
- {% endif %}
- {% if !e.metadata.is_empty() %}
- <span class="kg-meta"> · <code>{{ e.metadata }}</code></span>
- {% endif %}
- </span>
- </li>
- {% endfor %}
- </ul>
- {% endif %}
-</section>
-{% endblock %}
templates/explore.html
+3
−3
diff --git a/templates/explore.html b/templates/explore.html
index 257b735..52b996b 100644
--- a/templates/explore.html
+++ b/templates/explore.html
@@ -62,7 +62,7 @@
<p class="kg-meta" style="margin:0.25rem 0 0">{{ item.repo.description }}</p>
{% endif %}
</div>
- <span class="kg-meta">{{ item.repo.updated_at.format("%Y-%m-%d") }}</span>
+ <span class="kg-meta">{{ item.repo.updated_at }}</span>
</li>
{% endfor %}
</ul>
@@ -113,7 +113,7 @@
{{ item.owner }}/{{ item.repo_name }}#{{ item.number }} {{ item.title }}
</a>
</div>
- <span class="kg-meta">{{ item.updated_at.format("%Y-%m-%d") }}</span>
+ <span class="kg-meta">{{ item.updated_at }}</span>
</li>
{% endfor %}
</ul>
@@ -138,7 +138,7 @@
{{ item.owner }}/{{ item.repo_name }}#{{ item.number }} {{ item.title }}
</a>
</div>
- <span class="kg-meta">{{ item.updated_at.format("%Y-%m-%d") }}</span>
+ <span class="kg-meta">{{ item.updated_at }}</span>
</li>
{% endfor %}
</ul>
templates/issue_new.html
+0
−22
diff --git a/templates/issue_new.html b/templates/issue_new.html
index 5cabf33..0319760 100644
--- a/templates/issue_new.html
+++ b/templates/issue_new.html
@@ -20,28 +20,6 @@
body
<textarea name="body" rows="8"></textarea>
</label>
- {% if !labels.is_empty() %}
- <fieldset class="kg-fieldset">
- <legend>labels</legend>
- {% for l in labels %}
- <label class="row">
- <input type="checkbox" name="label_id" value="{{ l.id }}" />
- <span class="kg-label-chip" style="background:{{ l.bg_color() }};color:{{ l.text_color() }}">{{ l.name }}</span>
- </label>
- {% endfor %}
- </fieldset>
- {% endif %}
- {% if !milestones.is_empty() %}
- <label>
- milestone
- <select name="milestone_id">
- <option value="">none</option>
- {% for m in milestones %}
- <option value="{{ m.id }}">{{ m.title }}</option>
- {% endfor %}
- </select>
- </label>
- {% endif %}
<div class="kg-actions">
<button class="kg-btn" type="submit">create</button>
<a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/issues">cancel</a>
templates/issue_view.html
+36
−91
diff --git a/templates/issue_view.html b/templates/issue_view.html
index 42e80ee..2158d27 100644
--- a/templates/issue_view.html
+++ b/templates/issue_view.html
@@ -12,101 +12,46 @@
{% let archive_ref = repo.default_branch %}
{% include "partials/repo_tabs.html" %}
- <div class="kg-issue-layout">
- <div class="kg-issue-main">
- <article class="kg-comment">
- <header>
- <span>
- <img class="kg-avatar kg-avatar--sm" src="{{ author_avatar }}" alt="" />
- <a href="/{{ author.username }}">{{ author.username }}</a>
- </span>
- <span>{{ issue.created_at }}</span>
- </header>
- <div class="kg-readme">{{ body_html|safe }}</div>
- </article>
+ <article class="kg-comment">
+ <header>
+ <span>
+ <img class="kg-avatar kg-avatar--sm" src="{{ author_avatar }}" alt="" />
+ <a href="/{{ author.username }}">{{ author.username }}</a>
+ </span>
+ <span>{{ issue.created_at }}</span>
+ </header>
+ <div class="kg-readme">{{ body_html|safe }}</div>
+ </article>
- {% for c in comments %}
- <article class="kg-comment">
- <header>
- <span>
- <img class="kg-avatar kg-avatar--sm" src="{{ c.avatar_url }}" alt="" />
- <a href="/{{ c.author.username }}">{{ c.author.username }}</a>
- </span>
- <span>{{ c.created_at }}</span>
- </header>
- <div class="kg-readme">{{ c.body_html|safe }}</div>
- {% include "partials/reactions.html" %}
- </article>
- {% endfor %}
+ {% for c in comments %}
+ <article class="kg-comment">
+ <header>
+ <span>
+ <img class="kg-avatar kg-avatar--sm" src="{{ c.avatar_url }}" alt="" />
+ <a href="/{{ c.author.username }}">{{ c.author.username }}</a>
+ </span>
+ <span>{{ c.created_at }}</span>
+ </header>
+ <div class="kg-readme">{{ c.body_html|safe }}</div>
+ {% include "partials/reactions.html" %}
+ </article>
+ {% endfor %}
- {% if viewer.is_some() %}
- <form class="kg-form" method="post" action="/{{ owner.username }}/{{ repo.name }}/issues/{{ issue.number }}">
- <label>
- comment
- <textarea name="body" rows="4" required></textarea>
- </label>
- <div class="kg-actions">
- <button class="kg-btn" type="submit">comment</button>
- {% if issue.state == "open" %}
- <button class="kg-btn kg-btn--ghost" formaction="/{{ owner.username }}/{{ repo.name }}/issues/{{ issue.number }}/close" formmethod="post" type="submit" formnovalidate>close</button>
- {% else %}
- <button class="kg-btn kg-btn--ghost" formaction="/{{ owner.username }}/{{ repo.name }}/issues/{{ issue.number }}/reopen" formmethod="post" type="submit" formnovalidate>reopen</button>
- {% endif %}
- </div>
- </form>
- {% endif %}
- </div>
-
- <aside class="kg-issue-sidebar">
- <div class="kg-sidebar-block">
- <h2 class="kg-kicker">labels</h2>
- {% if labels.is_empty() %}
- <p class="kg-muted">None yet.</p>
- {% else %}
- <div class="kg-label-row">
- {% for l in labels %}
- <span class="kg-label-chip" style="background:{{ l.bg_color() }};color:{{ l.text_color() }}">{{ l.name }}</span>
- {% endfor %}
- </div>
- {% endif %}
- {% if can_triage %}
- <form class="kg-form" method="post" action="/{{ owner.username }}/{{ repo.name }}/issues/{{ issue.number }}/labels">
- {% for opt in label_options %}
- <label class="row">
- <input type="checkbox" name="label_id" value="{{ opt.label.id }}"{% if opt.selected %} checked{% endif %} />
- <span class="kg-label-chip" style="background:{{ opt.label.bg_color() }};color:{{ opt.label.text_color() }}">{{ opt.label.name }}</span>
- </label>
- {% endfor %}
- {% if !label_options.is_empty() %}
- <button class="kg-btn kg-btn--ghost" type="submit">save labels</button>
- {% else %}
- <p class="kg-muted"><a href="/{{ owner.username }}/{{ repo.name }}/labels">Manage labels</a></p>
- {% endif %}
- </form>
- {% endif %}
- </div>
- <div class="kg-sidebar-block">
- <h2 class="kg-kicker">milestone</h2>
- {% if let Some(m) = milestone %}
- <p>{{ m.title }}{% if let Some(due) = m.due_on %} · due {{ due }}{% endif %}</p>
+ {% if viewer.is_some() %}
+ <form class="kg-form" method="post" action="/{{ owner.username }}/{{ repo.name }}/issues/{{ issue.number }}">
+ <label>
+ comment
+ <textarea name="body" rows="4" required></textarea>
+ </label>
+ <div class="kg-actions">
+ <button class="kg-btn" type="submit">comment</button>
+ {% if issue.state == "open" %}
+ <button class="kg-btn kg-btn--ghost" formaction="/{{ owner.username }}/{{ repo.name }}/issues/{{ issue.number }}/close" formmethod="post" type="submit" formnovalidate>close</button>
{% else %}
- <p class="kg-muted">No milestone</p>
- {% endif %}
- {% if can_triage %}
- <form class="kg-form" method="post" action="/{{ owner.username }}/{{ repo.name }}/issues/{{ issue.number }}/milestone">
- <label>
- <select name="milestone_id">
- <option value="">none</option>
- {% for opt in milestone_options %}
- <option value="{{ opt.milestone.id }}"{% if opt.selected %} selected{% endif %}>{{ opt.milestone.title }}</option>
- {% endfor %}
- </select>
- </label>
- <button class="kg-btn kg-btn--ghost" type="submit">save milestone</button>
- </form>
+ <button class="kg-btn kg-btn--ghost" formaction="/{{ owner.username }}/{{ repo.name }}/issues/{{ issue.number }}/reopen" formmethod="post" type="submit" formnovalidate>reopen</button>
{% endif %}
</div>
- </aside>
- </div>
+ </form>
+ {% endif %}
</section>
{% endblock %}
templates/issues_list.html
+4
−12
diff --git a/templates/issues_list.html b/templates/issues_list.html
index 0cfb259..6c1629d 100644
--- a/templates/issues_list.html
+++ b/templates/issues_list.html
@@ -18,27 +18,19 @@
{% if viewer.is_some() %}
<a class="kg-btn" href="/{{ owner.username }}/{{ repo.name }}/issues/new">new issue</a>
{% endif %}
- <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/labels">labels</a>
- <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/milestones">milestones</a>
</div>
{% if issues.is_empty() %}
<p class="kg-muted">No {{ state_filter }} issues.</p>
{% else %}
<ul class="kg-list">
- {% for item in issues %}
+ {% for issue in issues %}
<li>
<span>
- <span class="kg-badge {% if item.issue.state == "open" %}kg-badge--open{% else %}kg-badge--closed{% endif %}">{{ item.issue.state }}</span>
- <a href="/{{ owner.username }}/{{ repo.name }}/issues/{{ item.issue.number }}">#{{ item.issue.number }} {{ item.issue.title }}</a>
- {% for l in item.labels %}
- <span class="kg-label-chip" style="background:{{ l.bg_color() }};color:{{ l.text_color() }}">{{ l.name }}</span>
- {% endfor %}
- {% if let Some(m) = item.milestone %}
- <span class="kg-meta">milestone: {{ m.title }}</span>
- {% endif %}
+ <span class="kg-badge {% if issue.state == "open" %}kg-badge--open{% else %}kg-badge--closed{% endif %}">{{ issue.state }}</span>
+ <a href="/{{ owner.username }}/{{ repo.name }}/issues/{{ issue.number }}">#{{ issue.number }} {{ issue.title }}</a>
</span>
- <span class="kg-meta">{{ item.issue.updated_at.format("%Y-%m-%d") }}</span>
+ <span class="kg-meta">{{ issue.updated_at }}</span>
</li>
{% endfor %}
</ul>
templates/keys_settings.html
+1
−16
diff --git a/templates/keys_settings.html b/templates/keys_settings.html
index 64d02b4..ecd9ef5 100644
--- a/templates/keys_settings.html
+++ b/templates/keys_settings.html
@@ -17,7 +17,7 @@
{% endif %}
<span class="kg-kicker" style="margin-top:1.5rem">SSH keys</span>
- <p class="kg-muted">Authentication keys let you push/pull over SSH. Signing keys verify signed commits on kitgit (Verified badge).</p>
+ <p class="kg-muted">Authentication keys let you push/pull over SSH. Signing keys verify signed commits.</p>
{% if !keys.is_empty() %}
<ul class="kg-list">
{% for key in keys %}
@@ -70,14 +70,6 @@
</div>
</form>
- <div class="kg-signing-hint">
- <strong>Sign commits with SSH</strong>
- <p class="kg-muted" style="margin:0.35rem 0 0">Add the public key above with usage <em>Signing</em> or <em>Authentication & Signing</em>, then configure git locally. Author email must match a verified email on your account.</p>
- <pre>git config --global gpg.format ssh
-git config --global user.signingkey ~/.ssh/id_ed25519.pub
-git config --global commit.gpgsign true</pre>
- </div>
-
<span class="kg-kicker" style="margin-top:2.5rem">GPG keys</span>
<p class="kg-muted">Used for commit verification. Vigilant mode flags unsigned commits on your profile.</p>
{% if !gpg_keys.is_empty() %}
@@ -112,12 +104,5 @@ git config --global commit.gpgsign true</pre>
<button class="kg-btn" type="submit">add</button>
</div>
</form>
-
- <div class="kg-signing-hint">
- <strong>Sign commits with GPG</strong>
- <p class="kg-muted" style="margin:0.35rem 0 0">Paste your public key above, then point git at the key id. Author email must match a verified email on your account.</p>
- <pre>git config --global user.signingkey YOUR_KEY_ID
-git config --global commit.gpgsign true</pre>
- </div>
</section>
{% endblock %}
templates/labels_list.html
+0
−15
diff --git a/templates/labels_list.html b/templates/labels_list.html
deleted file mode 100644
index d31a1bc..0000000
--- a/templates/labels_list.html
+++ /dev/null
@@ -1,15 +0,0 @@
-{% extends "layout.html" %}
-{% block title %}labels · {{ owner.username }}/{{ repo.name }} — kitgit{% endblock %}
-{% block content %}
-<section class="kg-section">
- <h1 class="kg-title"><a href="/{{ owner.username }}/{{ repo.name }}">{{ owner.username }}/{{ repo.name }}</a> · labels</h1>
- {% let tab = "settings" %}
- {% let archive_ref = repo.default_branch %}
- {% include "partials/repo_tabs.html" %}
- {% if let Some(err) = error %}<div class="kg-flash">{{ err }}</div>{% endif %}
- <div class="kg-actions"><a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/milestones">milestones</a><a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/settings">settings</a></div>
- {% if labels.is_empty() %}<p class="kg-muted">No labels yet.</p>{% else %}
- <ul class="kg-list">{% for l in labels %}<li><span><span class="kg-label-chip" style="background:{{ l.bg_color() }};color:{{ l.text_color() }}">{{ l.name }}</span> <span class="kg-meta">{{ l.description }}</span></span>{% if access.can_admin() %}<form method="post" action="/{{ owner.username }}/{{ repo.name }}/labels/{{ l.id }}/delete"><button class="kg-btn kg-btn--danger-ghost" type="submit">delete</button></form>{% endif %}</li>{% if access.can_admin() %}<li><form class="kg-form" method="post" action="/{{ owner.username }}/{{ repo.name }}/labels/{{ l.id }}/update"><label>name <input name="name" value="{{ l.name }}" required /></label><label>color <input name="color" value="{{ l.color }}" required /></label><label>description <input name="description" value="{{ l.description }}" /></label><button class="kg-btn kg-btn--ghost" type="submit">update</button></form></li>{% endif %}{% endfor %}</ul>{% endif %}
- {% if access.can_admin() %}<form class="kg-form" method="post" action="/{{ owner.username }}/{{ repo.name }}/labels"><span class="kg-kicker">new label</span><label>name <input name="name" required /></label><label>color <input name="color" value="0969da" required /></label><label>description <input name="description" /></label><button class="kg-btn" type="submit">create label</button></form>{% endif %}
-</section>
-{% endblock %}
templates/layout.html
+2
−23
diff --git a/templates/layout.html b/templates/layout.html
index 16b2e34..f589a61 100644
--- a/templates/layout.html
+++ b/templates/layout.html
@@ -1,5 +1,5 @@
<!DOCTYPE html>
-<html lang="en" data-theme="{% if let Some(u) = viewer %}{{ u.theme_pref() }}{% else %}system{% endif %}">
+<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
@@ -23,11 +23,6 @@
<a href="/explore"><span class="kg-icon" style="--icon:url('/static/icons/code.svg')"></span> explore</a>
{% if let Some(u) = viewer %}
<a href="/new"><span class="kg-icon" style="--icon:url('/static/icons/plus.svg')"></span> new</a>
- <a id="kg-nav-notifications" href="/notifications">
- <span class="kg-icon" style="--icon:url('/static/icons/bell.svg')"></span>
- notifications
- <span id="kg-notif-count" class="kg-notif-count" hidden></span>
- </a>
{% if u.is_site_admin %}<a href="/admin"><span class="kg-icon" style="--icon:url('/static/icons/settings.svg')"></span> admin</a>{% endif %}
<a href="/settings/profile"><span class="kg-icon" style="--icon:url('/static/icons/user.svg')"></span> settings</a>
<a class="kg-userchip" href="/{{ u.username }}">
@@ -44,10 +39,7 @@
<main>
{% block content %}{% endblock %}
</main>
- <footer class="kg-footer">
- <span>kitgit</span>
- <span class="kg-footer__timing">rendered in <!--kg-render-ms--></span>
- </footer>
+ <footer class="kg-footer">kitgit</footer>
</div>
<script>
(async () => {
@@ -62,19 +54,6 @@
}
} catch (_) {}
})();
- (async () => {
- const badge = document.getElementById('kg-notif-count');
- if (!badge) return;
- try {
- const r = await fetch('/notifications/unread.json', { credentials: 'same-origin' });
- if (!r.ok) return;
- const j = await r.json();
- const n = Number(j && j.count);
- if (!n) return;
- badge.textContent = n > 99 ? '99+' : String(n);
- badge.hidden = false;
- } catch (_) {}
- })();
document.addEventListener('click', (e) => {
const btn = e.target.closest('[data-copy]');
if (!btn) return;
templates/milestones_list.html
+0
−19
diff --git a/templates/milestones_list.html b/templates/milestones_list.html
deleted file mode 100644
index 7947f22..0000000
--- a/templates/milestones_list.html
+++ /dev/null
@@ -1,19 +0,0 @@
-{% extends "layout.html" %}
-{% block title %}milestones · {{ owner.username }}/{{ repo.name }} — kitgit{% endblock %}
-{% block content %}
-<section class="kg-section">
- <h1 class="kg-title"><a href="/{{ owner.username }}/{{ repo.name }}">{{ owner.username }}/{{ repo.name }}</a> · milestones</h1>
- {% let tab = "settings" %}
- {% let archive_ref = repo.default_branch %}
- {% include "partials/repo_tabs.html" %}
- {% if let Some(err) = error %}<div class="kg-flash">{{ err }}</div>{% endif %}
- <div class="kg-actions">
- <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/milestones?state=open"{% if state_filter == "open" %} aria-current="page"{% endif %}>open</a>
- <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/milestones?state=closed"{% if state_filter == "closed" %} aria-current="page"{% endif %}>closed</a>
- <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/labels">labels</a>
- </div>
- {% if milestones.is_empty() %}<p class="kg-muted">No {{ state_filter }} milestones.</p>{% else %}
- <ul class="kg-list">{% for m in milestones %}<li><span><span class="kg-badge {% if m.state == "open" %}kg-badge--open{% else %}kg-badge--closed{% endif %}">{{ m.state }}</span> <strong>{{ m.title }}</strong>{% if let Some(due) = m.due_on %} <span class="kg-meta">due {{ due }}</span>{% endif %}</span>{% if access.can_admin() %}<span class="kg-actions">{% if m.state == "open" %}<form method="post" action="/{{ owner.username }}/{{ repo.name }}/milestones/{{ m.id }}/close"><button class="kg-btn kg-btn--ghost" type="submit">close</button></form>{% else %}<form method="post" action="/{{ owner.username }}/{{ repo.name }}/milestones/{{ m.id }}/reopen"><button class="kg-btn kg-btn--ghost" type="submit">reopen</button></form>{% endif %}<form method="post" action="/{{ owner.username }}/{{ repo.name }}/milestones/{{ m.id }}/delete"><button class="kg-btn kg-btn--danger-ghost" type="submit">delete</button></form></span>{% endif %}</li>{% endfor %}</ul>{% endif %}
- {% if access.can_admin() %}<form class="kg-form" method="post" action="/{{ owner.username }}/{{ repo.name }}/milestones"><span class="kg-kicker">new milestone</span><label>title <input name="title" required /></label><label>due on <input type="date" name="due_on" /></label><label>description <textarea name="description" rows="3"></textarea></label><button class="kg-btn" type="submit">create milestone</button></form>{% endif %}
-</section>
-{% endblock %}
templates/notifications.html
+0
−47
diff --git a/templates/notifications.html b/templates/notifications.html
deleted file mode 100644
index 59c90c1..0000000
--- a/templates/notifications.html
+++ /dev/null
@@ -1,47 +0,0 @@
-{% extends "layout.html" %}
-
-{% block title %}notifications — kitgit{% endblock %}
-
-{% block content %}
-<section class="kg-section">
- <div class="kg-actions" style="justify-content: space-between; align-items: baseline;">
- <h1 class="kg-title">notifications</h1>
- {% if unread_count > 0 %}
- <form method="post" action="/notifications/read-all">
- <button class="kg-btn kg-btn--ghost" type="submit">mark all read</button>
- </form>
- {% endif %}
- </div>
-
- {% if notifications.is_empty() %}
- <p class="kg-muted">No notifications yet.</p>
- {% else %}
- <ul class="kg-list kg-notif-list">
- {% for n in notifications %}
- <li class="{% if n.read_at.is_none() %}kg-notif--unread{% endif %}">
- <span class="kg-notif-main">
- {% if n.href.is_empty() %}
- <strong>{{ n.title }}</strong>
- {% else %}
- <a href="{{ n.href }}"><strong>{{ n.title }}</strong></a>
- {% endif %}
- {% if !n.body.is_empty() %}
- <span class="kg-muted">{{ n.body }}</span>
- {% endif %}
- <span class="kg-meta">{{ n.created_at }} · {{ n.kind }}</span>
- </span>
- <span class="kg-notif-actions">
- {% if n.read_at.is_none() %}
- <form method="post" action="/notifications/{{ n.id }}/read">
- <button class="kg-btn kg-btn--ghost" type="submit">mark read</button>
- </form>
- {% else %}
- <span class="kg-faint">read</span>
- {% endif %}
- </span>
- </li>
- {% endfor %}
- </ul>
- {% endif %}
-</section>
-{% endblock %}
templates/partials/branch_bar.html
+30
−31
diff --git a/templates/partials/branch_bar.html b/templates/partials/branch_bar.html
index 91ce761..5069541 100644
--- a/templates/partials/branch_bar.html
+++ b/templates/partials/branch_bar.html
@@ -1,10 +1,33 @@
-{# Expects: owner, repo, branch, latest_commit: Option<CommitView>, access, upload_path, commit_count #}
-<div class="kg-toolbar">
- <a class="kg-btn kg-btn--ghost kg-toolbar__branch" href="/{{ owner.username }}/{{ repo.name }}/branches">
- <span class="kg-icon" style="--icon:url('/static/icons/git-branch.svg')"></span>
- {{ branch }}
- </a>
- <div class="kg-toolbar__actions">
+{# Expects: owner, repo, branch, latest_commit: Option<CommitView>, access (optional for upload), upload_path (optional) #}
+<div class="kg-branchbar">
+ <div class="kg-branchbar__left">
+ <a class="kg-branchbar__branch" href="/{{ owner.username }}/{{ repo.name }}/branches">
+ <span class="kg-icon" style="--icon:url('/static/icons/git-branch.svg')"></span>
+ {{ branch }}
+ </a>
+ {% if let Some(c) = latest_commit %}
+ <div class="kg-branchbar__commit">
+ <a class="kg-branchbar__sha" href="/{{ owner.username }}/{{ repo.name }}/commits" title="View commits">
+ <span class="kg-icon" style="--icon:url('/static/icons/git-commit.svg')"></span>
+ {{ c.short_id }}
+ </a>
+ <a class="kg-branchbar__msg" href="/{{ owner.username }}/{{ repo.name }}/commit/{{ c.id }}" title="View commit">{{ c.message }}</a>
+ {% if c.verified %}
+ <details class="kg-verify">
+ <summary class="kg-badge kg-badge--verified">Verified</summary>
+ <div class="kg-verify__panel" role="dialog" aria-label="Commit signature">
+ <p class="kg-verify__fp">
+ <strong>{{ c.verify_fingerprint_label }}:</strong>
+ <code>{{ c.verify_fingerprint }}</code>
+ </p>
+ <p class="kg-meta">Verified on {{ c.verified_at }}</p>
+ </div>
+ </details>
+ {% endif %}
+ </div>
+ {% endif %}
+ </div>
+ <div class="kg-branchbar__actions">
{% if upload_path != "" %}
<a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/archive.zip?ref={{ branch }}&path={{ upload_path }}" title="Download this folder as ZIP">
<span class="kg-icon" style="--icon:url('/static/icons/download.svg')"></span>
@@ -45,27 +68,3 @@
{% endif %}
</div>
</div>
-
-{% if let Some(c) = latest_commit %}
-<div class="kg-latest">
- <div class="kg-latest__main">
- {% if let Some(uname) = c.author_username %}
- <a class="kg-latest__author" href="/{{ uname }}">{{ c.author }}</a>
- {% else %}
- <span class="kg-latest__author">{{ c.author }}</span>
- {% endif %}
- <a class="kg-latest__msg" href="/{{ owner.username }}/{{ repo.name }}/commit/{{ c.id }}" title="{{ c.message }}">{{ c.message }}</a>
- </div>
- <div class="kg-latest__meta">
- <a class="kg-latest__sha" href="/{{ owner.username }}/{{ repo.name }}/commit/{{ c.id }}" title="{{ c.id }}">
- <code>{{ c.short_id }}</code>
- </a>
- {% let show_unverified = false %}
- {% include "partials/verified_badge.html" %}
- <time class="kg-meta kg-latest__time">{{ c.time_display() }}</time>
- <a class="kg-latest__count kg-meta" href="/{{ owner.username }}/{{ repo.name }}/commits" title="{{ commit_count }} commits">
- {{ commit_count }} commits
- </a>
- </div>
-</div>
-{% endif %}
templates/partials/repo_tabs.html
+0
−2
diff --git a/templates/partials/repo_tabs.html b/templates/partials/repo_tabs.html
index d56147e..5ca44db 100644
--- a/templates/partials/repo_tabs.html
+++ b/templates/partials/repo_tabs.html
@@ -31,7 +31,6 @@
</a>
{% endif %}
</div>
- {% if tab == "code" %}
<details class="kg-dropdown">
<summary class="kg-btn kg-btn--ghost">
<span class="kg-icon" style="--icon:url('/static/icons/download.svg')"></span>
@@ -59,5 +58,4 @@
</div>
</div>
</details>
- {% endif %}
</nav>
templates/partials/verified_badge.html
+4
−16
diff --git a/templates/partials/verified_badge.html b/templates/partials/verified_badge.html
index 6697dca..d448c5c 100644
--- a/templates/partials/verified_badge.html
+++ b/templates/partials/verified_badge.html
@@ -1,24 +1,12 @@
-{# Expects CommitView as `c`. Optional: `show_unverified` (bool) — show Unverified for unsigned too. #}
-{% if c.verified %}
+{% if let Some(v) = verification %}
<details class="kg-verify">
<summary class="kg-badge kg-badge--verified">Verified</summary>
<div class="kg-verify__panel" role="dialog" aria-label="Commit signature">
- <p class="kg-verify__row"><strong>Type:</strong> {{ c.verify_kind_label() }}</p>
<p class="kg-verify__fp">
- <strong>{{ c.verify_fingerprint_label }}:</strong>
- <code>{{ c.verify_fingerprint }}</code>
+ <strong>{{ v.fingerprint_label() }}:</strong>
+ <code>{{ v.fingerprint }}</code>
</p>
- <p class="kg-meta">Verified on {{ c.verified_at }}</p>
+ <p class="kg-meta">Verified on {{ v.verified_at }}</p>
</div>
</details>
-{% else if c.signed %}
-<details class="kg-verify">
- <summary class="kg-badge kg-badge--unverified">Unverified</summary>
- <div class="kg-verify__panel" role="dialog" aria-label="Commit signature">
- <p class="kg-verify__row">Signature present but could not be verified.</p>
- <p class="kg-meta">Add a matching signing SSH or GPG key under <a href="/settings/keys">Settings → SSH / GPG</a>. The commit author email must match a verified email on that account.</p>
- </div>
-</details>
-{% else if show_unverified %}
-<span class="kg-badge kg-badge--unverified" title="Commit is not signed">Unverified</span>
{% endif %}
templates/pull_new.html
+33
−8
diff --git a/templates/pull_new.html b/templates/pull_new.html
index b8957a8..21d97f5 100644
--- a/templates/pull_new.html
+++ b/templates/pull_new.html
@@ -1,20 +1,45 @@
{% extends "layout.html" %}
+
{% block title %}new pull · {{ owner.username }}/{{ repo.name }} — kitgit{% endblock %}
+
{% block content %}
<section class="kg-section">
<h1 class="kg-title">New pull request</h1>
{% let tab = "pulls" %}
{% let archive_ref = repo.default_branch %}
{% include "partials/repo_tabs.html" %}
- {% if let Some(err) = error %}<div class="kg-flash">{{ err }}</div>{% endif %}
+ {% if let Some(err) = error %}
+ <div class="kg-flash">{{ err }}</div>
+ {% endif %}
<form class="kg-form" method="post" action="/{{ owner.username }}/{{ repo.name }}/pulls">
- <label>title <input type="text" name="title" required autofocus /></label>
- <label>source branch<select name="source_branch" required>{% for b in branches %}<option value="{{ b }}">{{ b }}</option>{% endfor %}</select></label>
- <label>target branch<select name="target_branch" required>{% for b in branches %}<option value="{{ b }}"{% if b.as_str() == repo.default_branch.as_str() %} selected{% endif %}>{{ b }}</option>{% endfor %}</select></label>
- <label>body <textarea name="body" rows="8"></textarea></label>
- {% if !labels.is_empty() %}<fieldset class="kg-fieldset"><legend>labels</legend>{% for l in labels %}<label class="row"><input type="checkbox" name="label_id" value="{{ l.id }}" /> <span class="kg-label-chip" style="background:{{ l.bg_color() }};color:{{ l.text_color() }}">{{ l.name }}</span></label>{% endfor %}</fieldset>{% endif %}
- {% if !milestones.is_empty() %}<label>milestone<select name="milestone_id"><option value="">none</option>{% for m in milestones %}<option value="{{ m.id }}">{{ m.title }}</option>{% endfor %}</select></label>{% endif %}
- <div class="kg-actions"><button class="kg-btn" type="submit">create</button><a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/pulls">cancel</a></div>
+ <label>
+ title
+ <input type="text" name="title" required autofocus />
+ </label>
+ <label>
+ source branch
+ <select name="source_branch" required>
+ {% for b in branches %}
+ <option value="{{ b }}">{{ b }}</option>
+ {% endfor %}
+ </select>
+ </label>
+ <label>
+ target branch
+ <select name="target_branch" required>
+ {% for b in branches %}
+ <option value="{{ b }}"{% if b.as_str() == repo.default_branch.as_str() %} selected{% endif %}>{{ b }}</option>
+ {% endfor %}
+ </select>
+ </label>
+ <label>
+ body
+ <textarea name="body" rows="8"></textarea>
+ </label>
+ <div class="kg-actions">
+ <button class="kg-btn" type="submit">create</button>
+ <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/pulls">cancel</a>
+ </div>
</form>
</section>
{% endblock %}
templates/pull_view.html
+14
−63
diff --git a/templates/pull_view.html b/templates/pull_view.html
index 843c4e1..92b3f55 100644
--- a/templates/pull_view.html
+++ b/templates/pull_view.html
@@ -9,19 +9,6 @@
#{{ pull.number }} {{ pull.title }}
</h1>
<p class="kg-meta">{{ pull.source_branch }} → {{ pull.target_branch }}</p>
- <aside class="kg-issue-sidebar" style="margin:1rem 0">
- <div class="kg-sidebar-block">
- <h2 class="kg-kicker">labels</h2>
- {% if labels.is_empty() %}<p class="kg-muted">None yet.</p>{% else %}<div class="kg-label-row">{% for l in labels %}<span class="kg-label-chip" style="background:{{ l.bg_color() }};color:{{ l.text_color() }}">{{ l.name }}</span>{% endfor %}</div>{% endif %}
- {% if can_triage %}<form class="kg-form" method="post" action="/{{ owner.username }}/{{ repo.name }}/pulls/{{ pull.number }}/labels">{% for opt in label_options %}<label class="row"><input type="checkbox" name="label_id" value="{{ opt.label.id }}"{% if opt.selected %} checked{% endif %} /> <span class="kg-label-chip" style="background:{{ opt.label.bg_color() }};color:{{ opt.label.text_color() }}">{{ opt.label.name }}</span></label>{% endfor %}{% if !label_options.is_empty() %}<button class="kg-btn kg-btn--ghost" type="submit">save labels</button>{% endif %}</form>{% endif %}
- </div>
- <div class="kg-sidebar-block">
- <h2 class="kg-kicker">milestone</h2>
- {% if let Some(m) = milestone %}<p>{{ m.title }}</p>{% else %}<p class="kg-muted">No milestone</p>{% endif %}
- {% if can_triage %}<form class="kg-form" method="post" action="/{{ owner.username }}/{{ repo.name }}/pulls/{{ pull.number }}/milestone"><label><select name="milestone_id"><option value="">none</option>{% for opt in milestone_options %}<option value="{{ opt.milestone.id }}"{% if opt.selected %} selected{% endif %}>{{ opt.milestone.title }}</option>{% endfor %}</select></label><button class="kg-btn kg-btn--ghost" type="submit">save milestone</button></form>{% endif %}
- </div>
- </aside>
-
{% let pr_tab = tab %}
{% let tab = "pulls" %}
{% let archive_ref = repo.default_branch %}
@@ -38,7 +25,7 @@
commits
<span class="kg-pr-tabs__count">{{ commits.len() }}</span>
</a>
- <a href="/{{ owner.username }}/{{ repo.name }}/pulls/{{ pull.number }}?tab=files{% if show_full %}&full=1{% endif %}"{% if pr_tab == "files" %} aria-current="page"{% endif %}>
+ <a href="/{{ owner.username }}/{{ repo.name }}/pulls/{{ pull.number }}?tab=files"{% if pr_tab == "files" %} aria-current="page"{% endif %}>
<span class="kg-icon" style="--icon:url('/static/icons/file.svg')"></span>
files
<span class="kg-pr-tabs__count">{{ diff_files.len() }}</span>
@@ -71,51 +58,6 @@
</article>
{% endfor %}
- {% for r in reviews %}
- <article class="kg-comment kg-review">
- <header>
- <span>
- <img class="kg-avatar kg-avatar--sm" src="{{ r.avatar_url }}" alt="" />
- <a href="/{{ r.reviewer.username }}">{{ r.reviewer.username }}</a>
- <span class="kg-badge {{ r.state_class() }}">{{ r.state_label() }}</span>
- </span>
- <span>{{ r.created_at }}</span>
- </header>
- {% if !r.body.is_empty() %}
- <div class="kg-readme">{{ r.body_html|safe }}</div>
- {% endif %}
- </article>
- {% endfor %}
-
- {% if viewer.is_some() && pull.state == "open" %}
- <form class="kg-form" method="post" action="/{{ owner.username }}/{{ repo.name }}/pulls/{{ pull.number }}/review">
- <fieldset class="kg-review-state">
- <legend>review</legend>
- {% if !is_author %}
- <label class="row">
- <input type="radio" name="state" value="approved" />
- approve
- </label>
- <label class="row">
- <input type="radio" name="state" value="changes_requested" />
- request changes
- </label>
- {% endif %}
- <label class="row">
- <input type="radio" name="state" value="commented" checked />
- comment
- </label>
- </fieldset>
- <label>
- review notes
- <textarea name="body" rows="4" placeholder="Leave a review comment (optional)"></textarea>
- </label>
- <div class="kg-actions">
- <button class="kg-btn" type="submit">submit review</button>
- </div>
- </form>
- {% endif %}
-
{% if viewer.is_some() %}
<form class="kg-form" method="post" action="/{{ owner.username }}/{{ repo.name }}/pulls/{{ pull.number }}">
<label>
@@ -156,8 +98,18 @@
<span>
<a href="/{{ owner.username }}/{{ repo.name }}/commits" title="View commits"><code>{{ c.short_id }}</code></a>
<a href="/{{ owner.username }}/{{ repo.name }}/commit/{{ c.id }}">{{ c.message }}</a>
- {% let show_unverified = false %}
- {% include "partials/verified_badge.html" %}
+ {% if c.verified %}
+ <details class="kg-verify">
+ <summary class="kg-badge kg-badge--verified">Verified</summary>
+ <div class="kg-verify__panel" role="dialog" aria-label="Commit signature">
+ <p class="kg-verify__fp">
+ <strong>{{ c.verify_fingerprint_label }}:</strong>
+ <code>{{ c.verify_fingerprint }}</code>
+ </p>
+ <p class="kg-meta">Verified on {{ c.verified_at }}</p>
+ </div>
+ </details>
+ {% endif %}
</span>
<span class="kg-meta">{{ c.author }}</span>
</li>
@@ -204,11 +156,10 @@
</span>
</header>
{{ f.html|safe }}
- {% if f.truncated && !show_full %}
+ {% if f.truncated %}
<p class="kg-diff__truncated">
Large diffs are not rendered by default.
Showing the first 50 of {{ f.total_lines }} lines.
- <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/pulls/{{ pull.number }}?tab=files&full=1#{{ f.anchor }}">Show full diff</a>
</p>
{% endif %}
</section>
templates/pulls_list.html
+27
−6
diff --git a/templates/pulls_list.html b/templates/pulls_list.html
index 50da817..69425f5 100644
--- a/templates/pulls_list.html
+++ b/templates/pulls_list.html
@@ -1,20 +1,41 @@
{% extends "layout.html" %}
+
{% block title %}pulls · {{ owner.username }}/{{ repo.name }} — kitgit{% endblock %}
+
{% block content %}
<section class="kg-section">
- <h1 class="kg-title"><a href="/{{ owner.username }}/{{ repo.name }}">{{ owner.username }}/{{ repo.name }}</a> · pulls</h1>
+ <h1 class="kg-title">
+ <a href="/{{ owner.username }}/{{ repo.name }}">{{ owner.username }}/{{ repo.name }}</a>
+ · pulls
+ </h1>
{% let tab = "pulls" %}
{% let archive_ref = repo.default_branch %}
{% include "partials/repo_tabs.html" %}
+
<div class="kg-actions">
<a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/pulls?state=open"{% if state_filter == "open" %} aria-current="page"{% endif %}>open</a>
<a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/pulls?state=closed"{% if state_filter == "closed" %} aria-current="page"{% endif %}>closed</a>
<a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/pulls?state=merged"{% if state_filter == "merged" %} aria-current="page"{% endif %}>merged</a>
- {% if viewer.is_some() %}<a class="kg-btn" href="/{{ owner.username }}/{{ repo.name }}/pulls/new">new pull</a>{% endif %}
- <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/labels">labels</a>
- <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/milestones">milestones</a>
+ {% if viewer.is_some() %}
+ <a class="kg-btn" href="/{{ owner.username }}/{{ repo.name }}/pulls/new">new pull</a>
+ {% endif %}
</div>
- {% if pulls.is_empty() %}<p class="kg-muted">No {{ state_filter }} pulls.</p>{% else %}
- <ul class="kg-list">{% for item in pulls %}<li><span><span class="kg-badge {% if item.pull.state == "open" %}kg-badge--open{% else %}kg-badge--closed{% endif %}">{{ item.pull.state }}</span> <a href="/{{ owner.username }}/{{ repo.name }}/pulls/{{ item.pull.number }}">#{{ item.pull.number }} {{ item.pull.title }}</a> <span class="kg-meta">{{ item.pull.source_branch }} → {{ item.pull.target_branch }}</span>{% for l in item.labels %} <span class="kg-label-chip" style="background:{{ l.bg_color() }};color:{{ l.text_color() }}">{{ l.name }}</span>{% endfor %}{% if let Some(m) = item.milestone %} <span class="kg-meta">milestone: {{ m.title }}</span>{% endif %}</span><span class="kg-meta">{{ item.pull.updated_at.format("%Y-%m-%d") }}</span></li>{% endfor %}</ul>{% endif %}
+
+ {% if pulls.is_empty() %}
+ <p class="kg-muted">No {{ state_filter }} pulls.</p>
+ {% else %}
+ <ul class="kg-list">
+ {% for pull in pulls %}
+ <li>
+ <span>
+ <span class="kg-badge {% if pull.state == "open" %}kg-badge--open{% else %}kg-badge--closed{% endif %}">{{ pull.state }}</span>
+ <a href="/{{ owner.username }}/{{ repo.name }}/pulls/{{ pull.number }}">#{{ pull.number }} {{ pull.title }}</a>
+ <span class="kg-meta">{{ pull.source_branch }} → {{ pull.target_branch }}</span>
+ </span>
+ <span class="kg-meta">{{ pull.updated_at }}</span>
+ </li>
+ {% endfor %}
+ </ul>
+ {% endif %}
</section>
{% endblock %}
templates/repo_blame.html
+0
−66
diff --git a/templates/repo_blame.html b/templates/repo_blame.html
deleted file mode 100644
index e3f291d..0000000
--- a/templates/repo_blame.html
+++ /dev/null
@@ -1,66 +0,0 @@
-{% extends "layout.html" %}
-
-{% block title %}blame · {{ path }} · {{ owner.username }}/{{ repo.name }} — kitgit{% endblock %}
-
-{% block content %}
-<section class="kg-section">
- <h1 class="kg-title">
- <a href="/{{ owner.username }}/{{ repo.name }}">{{ owner.username }}/{{ repo.name }}</a>
- · blame
- </h1>
- {% let tab = "code" %}
- {% let archive_ref = branch %}
- {% include "partials/repo_tabs.html" %}
-
- <p class="kg-meta kg-breadcrumbs">
- <a href="/{{ owner.username }}/{{ repo.name }}/tree/{{ branch }}">{{ branch }}</a>
- {% for (name, full) in breadcrumbs %}
- /
- {% if loop.last %}
- {{ name }}
- {% else %}
- <a href="/{{ owner.username }}/{{ repo.name }}/tree/{{ branch }}/{{ full }}">{{ name }}</a>
- {% endif %}
- {% endfor %}
- </p>
-
- <div class="kg-blobbar">
- <span class="kg-meta">{{ path }}</span>
- <div class="kg-blobbar__actions">
- <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/blob/{{ branch }}/{{ path }}">View file</a>
- <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/history/{{ branch }}/{{ path }}">History</a>
- <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/raw/{{ branch }}/{{ path }}">
- <span class="kg-icon" style="--icon:url('/static/icons/download.svg')"></span>
- Download raw
- </a>
- </div>
- </div>
-
- {% if binary %}
- <p class="kg-muted">Binary file — blame is not available.</p>
- {% else if lines.is_empty() %}
- <p class="kg-muted">Empty file.</p>
- {% else %}
- <div class="kg-blame" role="table" aria-label="Blame for {{ path }}">
- <div class="kg-blame__head" role="row">
- <span class="kg-blame__meta" role="columnheader">Commit</span>
- <span class="kg-blame__num" role="columnheader">#</span>
- <span class="kg-blame__code" role="columnheader">Line</span>
- </div>
- {% for line in lines %}
- <div class="kg-blame__row{% if line.hunk_start %} kg-blame__row--hunk{% endif %}" role="row">
- <div class="kg-blame__meta" role="cell">
- {% if line.hunk_start && !line.commit_id.is_empty() %}
- <a class="kg-blame__sha" href="/{{ owner.username }}/{{ repo.name }}/commit/{{ line.commit_id }}" title="{{ line.summary }}">{{ line.short_id }}</a>
- <span class="kg-blame__author" title="{{ line.summary }}">{{ line.author }}</span>
- <span class="kg-blame__date">{{ line.time_display }}</span>
- {% endif %}
- </div>
- <span class="kg-blame__num" role="cell">{{ line.line_no }}</span>
- <pre class="kg-blame__code" role="cell">{{ line.content }}</pre>
- </div>
- {% endfor %}
- </div>
- {% endif %}
-</section>
-{% endblock %}
templates/repo_blob.html
+0
−2
diff --git a/templates/repo_blob.html b/templates/repo_blob.html
index f00dbf7..5b0ff48 100644
--- a/templates/repo_blob.html
+++ b/templates/repo_blob.html
@@ -33,8 +33,6 @@
<div class="kg-blobbar">
<span class="kg-meta">{{ path }}</span>
<div class="kg-blobbar__actions">
- <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/blame/{{ branch }}/{{ path }}">Blame</a>
- <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/history/{{ branch }}/{{ path }}">History</a>
<a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/raw/{{ branch }}/{{ path }}">
<span class="kg-icon" style="--icon:url('/static/icons/download.svg')"></span>
Download raw
templates/repo_branches.html
+4
−8
diff --git a/templates/repo_branches.html b/templates/repo_branches.html
index 03f22f1..5691acd 100644
--- a/templates/repo_branches.html
+++ b/templates/repo_branches.html
@@ -61,15 +61,13 @@
<td>
{% if access.can_write() && !b.is_default %}
<div class="kg-branch-actions">
- <form method="post" action="/{{ owner.username }}/{{ repo.name }}/branches/rename" style="display:inline-flex;gap:0.35rem">
- <input type="hidden" name="branch" value="{{ b.name }}" />
+ <form method="post" action="/{{ owner.username }}/{{ repo.name }}/branches/{{ b.name }}/rename" style="display:inline-flex;gap:0.35rem">
<input type="text" name="new_name" placeholder="rename" required style="width:7rem;font:inherit;border:1px solid var(--kg-line);padding:0.25em 0.4em" />
<button class="kg-btn kg-btn--ghost" type="submit" title="Rename">
<span class="kg-icon" style="--icon:url('/static/icons/pencil.svg')"></span>
</button>
</form>
- <form method="post" action="/{{ owner.username }}/{{ repo.name }}/branches/delete" style="display:inline" onsubmit="return confirm('Delete branch {{ b.name }}?')">
- <input type="hidden" name="branch" value="{{ b.name }}" />
+ <form method="post" action="/{{ owner.username }}/{{ repo.name }}/branches/{{ b.name }}/delete" style="display:inline" onsubmit="return confirm('Delete branch {{ b.name }}?')">
<button class="kg-btn kg-btn--danger-ghost" type="submit" title="Delete">
<span class="kg-icon" style="--icon:url('/static/icons/trash.svg')"></span>
</button>
@@ -141,15 +139,13 @@
<td>
{% if access.can_write() %}
<div class="kg-branch-actions">
- <form method="post" action="/{{ owner.username }}/{{ repo.name }}/tags/rename" style="display:inline-flex;gap:0.35rem">
- <input type="hidden" name="tag" value="{{ t.name }}" />
+ <form method="post" action="/{{ owner.username }}/{{ repo.name }}/tags/{{ t.name }}/rename" style="display:inline-flex;gap:0.35rem">
<input type="text" name="new_name" placeholder="rename" required style="width:7rem;font:inherit;border:1px solid var(--kg-line);padding:0.25em 0.4em" />
<button class="kg-btn kg-btn--ghost" type="submit" title="Rename">
<span class="kg-icon" style="--icon:url('/static/icons/pencil.svg')"></span>
</button>
</form>
- <form method="post" action="/{{ owner.username }}/{{ repo.name }}/tags/delete" style="display:inline" onsubmit="return confirm('Delete tag {{ t.name }}?')">
- <input type="hidden" name="tag" value="{{ t.name }}" />
+ <form method="post" action="/{{ owner.username }}/{{ repo.name }}/tags/{{ t.name }}/delete" style="display:inline" onsubmit="return confirm('Delete tag {{ t.name }}?')">
<button class="kg-btn kg-btn--danger-ghost" type="submit" title="Delete">
<span class="kg-icon" style="--icon:url('/static/icons/trash.svg')"></span>
</button>
templates/repo_commit.html
+14
−12
diff --git a/templates/repo_commit.html b/templates/repo_commit.html
index 3a965a9..0b1244e 100644
--- a/templates/repo_commit.html
+++ b/templates/repo_commit.html
@@ -15,20 +15,22 @@
<p><code>{{ commit.id }}</code></p>
<p class="kg-title">
{{ message_html|safe }}
- {% let c = commit %}
- {% let show_unverified = true %}
- {% include "partials/verified_badge.html" %}
+ {% if commit.verified %}
+ <details class="kg-verify">
+ <summary class="kg-badge kg-badge--verified">Verified</summary>
+ <div class="kg-verify__panel" role="dialog" aria-label="Commit signature">
+ <p class="kg-verify__fp">
+ <strong>{{ commit.verify_fingerprint_label }}:</strong>
+ <code>{{ commit.verify_fingerprint }}</code>
+ </p>
+ <p class="kg-meta">Verified on {{ commit.verified_at }}</p>
+ </div>
+ </details>
+ {% endif %}
</p>
- <p class="kg-meta">{{ commit.author }} <{{ commit.email }}> · {{ commit.time_display() }}</p>
+ <p class="kg-meta">{{ commit.author }} <{{ commit.email }}> · {{ commit.time }}</p>
- <p><a href="/{{ owner.username }}/{{ repo.name }}/diff/{{ commit.id }}{% if show_full %}?full=1{% endif %}">view full diff</a></p>
+ <p><a href="/{{ owner.username }}/{{ repo.name }}/diff/{{ commit.id }}">view full diff</a></p>
{{ diff_html|safe }}
- {% if truncated && !show_full %}
- <p class="kg-diff__truncated">
- Large diffs are not rendered by default.
- Showing the first 50 of {{ total_lines }} lines.
- <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/commit/{{ commit.id }}?full=1">Show full diff</a>
- </p>
- {% endif %}
</section>
{% endblock %}
templates/repo_commits.html
+13
−3
diff --git a/templates/repo_commits.html b/templates/repo_commits.html
index 517248d..a763fd2 100644
--- a/templates/repo_commits.html
+++ b/templates/repo_commits.html
@@ -31,11 +31,21 @@
<code>{{ c.short_id }}</code>
</a>
<a href="/{{ owner.username }}/{{ repo.name }}/commit/{{ c.id }}">{{ c.message }}</a>
- {% let show_unverified = false %}
- {% include "partials/verified_badge.html" %}
+ {% if c.verified %}
+ <details class="kg-verify">
+ <summary class="kg-badge kg-badge--verified">Verified</summary>
+ <div class="kg-verify__panel" role="dialog" aria-label="Commit signature">
+ <p class="kg-verify__fp">
+ <strong>{{ c.verify_fingerprint_label }}:</strong>
+ <code>{{ c.verify_fingerprint }}</code>
+ </p>
+ <p class="kg-meta">Verified on {{ c.verified_at }}</p>
+ </div>
+ </details>
+ {% endif %}
<span class="kg-muted"> — {{ c.author }}</span>
</span>
- <span class="kg-meta">{{ c.time_display() }}</span>
+ <span class="kg-meta">{{ c.time }}</span>
</li>
{% endfor %}
</ul>
templates/repo_diff.html
+1
−11
diff --git a/templates/repo_diff.html b/templates/repo_diff.html
index 122515a..129dab6 100644
--- a/templates/repo_diff.html
+++ b/templates/repo_diff.html
@@ -20,19 +20,9 @@
· {{ commit.author }}
</p>
<p class="kg-title">
- <a href="/{{ owner.username }}/{{ repo.name }}/commit/{{ commit.id }}{% if show_full %}?full=1{% endif %}">{{ commit.message }}</a>
- {% let c = commit %}
- {% let show_unverified = true %}
- {% include "partials/verified_badge.html" %}
+ <a href="/{{ owner.username }}/{{ repo.name }}/commit/{{ commit.id }}">{{ commit.message }}</a>
</p>
{{ diff_html|safe }}
- {% if truncated && !show_full %}
- <p class="kg-diff__truncated">
- Large diffs are not rendered by default.
- Showing the first 50 of {{ total_lines }} lines.
- <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/diff/{{ commit.id }}?full=1">Show full diff</a>
- </p>
- {% endif %}
</section>
{% endblock %}
templates/repo_history.html
+0
−67
diff --git a/templates/repo_history.html b/templates/repo_history.html
deleted file mode 100644
index d8e95b6..0000000
--- a/templates/repo_history.html
+++ /dev/null
@@ -1,67 +0,0 @@
-{% extends "layout.html" %}
-
-{% block title %}history · {{ path }} · {{ owner.username }}/{{ repo.name }} — kitgit{% endblock %}
-
-{% block content %}
-<section class="kg-section">
- <h1 class="kg-title">
- <a href="/{{ owner.username }}/{{ repo.name }}">{{ owner.username }}/{{ repo.name }}</a>
- · history
- </h1>
- {% let tab = "code" %}
- {% let archive_ref = branch %}
- {% include "partials/repo_tabs.html" %}
-
- <p class="kg-meta kg-breadcrumbs">
- <a href="/{{ owner.username }}/{{ repo.name }}/tree/{{ branch }}">{{ branch }}</a>
- {% for (name, full) in breadcrumbs %}
- /
- {% if loop.last %}
- {{ name }}
- {% else %}
- <a href="/{{ owner.username }}/{{ repo.name }}/tree/{{ branch }}/{{ full }}">{{ name }}</a>
- {% endif %}
- {% endfor %}
- </p>
-
- <div class="kg-blobbar">
- <span class="kg-meta">{{ path }}</span>
- <div class="kg-blobbar__actions">
- <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/blob/{{ branch }}/{{ path }}">View file</a>
- <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/blame/{{ branch }}/{{ path }}">Blame</a>
- </div>
- </div>
-
- {% if commits.is_empty() %}
- <p class="kg-muted">No commits touch this path.</p>
- {% else %}
- <ul class="kg-list">
- {% for c in commits %}
- <li>
- <span>
- <a href="/{{ owner.username }}/{{ repo.name }}/commit/{{ c.id }}" title="View commit">
- <span class="kg-icon" style="--icon:url('/static/icons/git-commit.svg')"></span>
- <code>{{ c.short_id }}</code>
- </a>
- <a href="/{{ owner.username }}/{{ repo.name }}/commit/{{ c.id }}">{{ c.message }}</a>
- {% if c.verified %}
- <details class="kg-verify">
- <summary class="kg-badge kg-badge--verified">Verified</summary>
- <div class="kg-verify__panel" role="dialog" aria-label="Commit signature">
- <p class="kg-verify__fp">
- <strong>{{ c.verify_fingerprint_label }}:</strong>
- <code>{{ c.verify_fingerprint }}</code>
- </p>
- <p class="kg-meta">Verified on {{ c.verified_at }}</p>
- </div>
- </details>
- {% endif %}
- <span class="kg-muted"> — {{ c.author }}</span>
- </span>
- <span class="kg-meta">{{ c.time_display() }}</span>
- </li>
- {% endfor %}
- </ul>
- {% endif %}
-</section>
-{% endblock %}
templates/repo_home.html
+2
−4
diff --git a/templates/repo_home.html b/templates/repo_home.html
index f2e3424..7ee008a 100644
--- a/templates/repo_home.html
+++ b/templates/repo_home.html
@@ -104,8 +104,7 @@
{% if e.is_dir %}
<span class="kg-icon" style="--icon:url('/static/icons/folder.svg')"></span>
<a href="/{{ owner.username }}/{{ repo.name }}/tree/{{ current_branch }}/{{ e.path }}">{{ e.name }}</a>
- <span class="kg-tree__commit kg-meta" title="{{ e.commit_message }}">{{ e.commit_message }}</span>
- <span class="kg-tree__time kg-meta">{{ e.commit_time }}</span>
+ <span class="kg-meta">{{ e.mode }}</span>
<a class="kg-tree__dl kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/archive.zip?ref={{ current_branch }}&path={{ e.path }}" title="Download {{ e.name }} as ZIP">
<span class="kg-icon" style="--icon:url('/static/icons/download.svg')"></span>
<span class="kg-sr-only">Download</span>
@@ -113,8 +112,7 @@
{% else %}
<span class="kg-icon" style="--icon:url('/static/icons/file.svg')"></span>
<a href="/{{ owner.username }}/{{ repo.name }}/blob/{{ current_branch }}/{{ e.path }}">{{ e.name }}</a>
- <span class="kg-tree__commit kg-meta" title="{{ e.commit_message }}">{{ e.commit_message }}</span>
- <span class="kg-tree__time kg-meta">{{ e.commit_time }}</span>
+ <span class="kg-meta">{{ e.mode }}</span>
{% endif %}
</li>
{% endfor %}
templates/repo_settings.html
+2
−176
diff --git a/templates/repo_settings.html b/templates/repo_settings.html
index e6ae11e..effe71d 100644
--- a/templates/repo_settings.html
+++ b/templates/repo_settings.html
@@ -146,73 +146,6 @@
</div>
<div class="kg-settings-section">
- <span class="kg-kicker">mirror</span>
- <p class="kg-muted">Pull from another remote into this repository (fetch heads and tags).</p>
- {% if let Some(m) = mirror %}
- <p class="kg-meta" style="margin:0.5rem 0">
- {% if m.enabled %}
- <span class="kg-badge">enabled</span>
- {% else %}
- <span class="kg-badge">disabled</span>
- {% endif %}
- {% if let Some(synced) = m.last_synced_at %}
- · last synced {{ synced }}
- {% else %}
- · never synced
- {% endif %}
- </p>
- {% if let Some(err) = m.last_error %}
- <div class="kg-flash">{{ err }}</div>
- {% endif %}
- <form class="kg-form" method="post" action="/{{ owner.username }}/{{ repo.name }}/settings/mirror" style="margin-top:1rem">
- <label>
- remote URL
- <input type="text" name="remote_url" required value="{{ m.remote_url }}" placeholder="https://github.com/org/repo.git" />
- </label>
- <label class="row">
- <input type="checkbox" name="enabled"{% if m.enabled %} checked{% endif %} />
- enabled
- </label>
- <div class="kg-actions">
- <button class="kg-btn" type="submit">save mirror</button>
- </div>
- </form>
- <div class="kg-actions" style="margin-top:0.75rem;gap:0.5rem;display:flex;flex-wrap:wrap">
- <form method="post" action="/{{ owner.username }}/{{ repo.name }}/settings/mirror/sync">
- <button class="kg-btn kg-btn--ghost" type="submit">sync now</button>
- </form>
- <form method="post" action="/{{ owner.username }}/{{ repo.name }}/settings/mirror/delete">
- <button class="kg-btn kg-btn--danger-ghost" type="submit">remove mirror</button>
- </form>
- </div>
- {% else %}
- <p class="kg-muted">No mirror configured.</p>
- <form class="kg-form" method="post" action="/{{ owner.username }}/{{ repo.name }}/settings/mirror" style="margin-top:1rem">
- <label>
- remote URL
- <input type="text" name="remote_url" required placeholder="https://github.com/org/repo.git" />
- </label>
- <label class="row">
- <input type="checkbox" name="enabled" checked />
- enabled
- </label>
- <div class="kg-actions">
- <button class="kg-btn" type="submit">save mirror</button>
- </div>
- </form>
- {% endif %}
- </div>
-
- <div class="kg-settings-section">
- <span class="kg-kicker">labels & milestones</span>
- <p class="kg-muted">Organize issues and pull requests.</p>
- <div class="kg-actions">
- <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/labels">manage labels</a>
- <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/milestones">manage milestones</a>
- </div>
- </div>
-
- <div class="kg-settings-section">
<span class="kg-kicker">collaborators</span>
{% if collaborators.is_empty() %}
<p class="kg-muted">No collaborators.</p>
@@ -252,113 +185,6 @@
</form>
</div>
- <div class="kg-settings-section">
- <span class="kg-kicker">deploy keys</span>
- <p class="kg-muted">Repo-scoped SSH keys for CI and automation. Read-only by default; write access allows push to this repository only.</p>
- {% if deploy_keys.is_empty() %}
- <p class="kg-muted">No deploy keys.</p>
- {% else %}
- <ul class="kg-list">
- {% for key in deploy_keys %}
- <li>
- <span>
- <strong>{{ key.name }}</strong>
- <span class="kg-meta">{{ key.fingerprint }}</span>
- <span class="kg-badge">{{ key.permission_label() }}</span>
- </span>
- <form method="post" action="/{{ owner.username }}/{{ repo.name }}/settings/deploy-keys/{{ key.id }}/delete">
- <button class="kg-btn kg-btn--danger-ghost" type="submit">delete</button>
- </form>
- </li>
- {% endfor %}
- </ul>
- {% endif %}
-
- <form class="kg-form" method="post" action="/{{ owner.username }}/{{ repo.name }}/settings/deploy-keys" style="margin-top:1rem">
- <label>
- name
- <input type="text" name="name" required placeholder="ci" />
- </label>
- <label>
- public key
- <textarea name="public_key" rows="3" required placeholder="ssh-ed25519 AAAA…"></textarea>
- </label>
- <label class="row">
- <input type="checkbox" name="write_access" />
- allow write access (push)
- </label>
- <div class="kg-actions">
- <button class="kg-btn" type="submit">add deploy key</button>
- </div>
- </form>
- </div>
-
- <div class="kg-settings-section">
- <span class="kg-kicker">webhooks</span>
- <p class="kg-muted">HTTP callbacks for repository events. Optional secret signs payloads as <code>X-Hub-Signature-256</code>.</p>
- {% if webhooks.is_empty() %}
- <p class="kg-muted">No webhooks yet.</p>
- {% else %}
- <ul class="kg-list">
- {% for hook in webhooks %}
- <li>
- <span>
- <code>{{ hook.url }}</code>
- <span class="kg-meta">{{ hook.events_label }}</span>
- {% if hook.has_secret %}<span class="kg-badge">signed</span>{% endif %}
- {% if hook.active %}<span class="kg-badge">active</span>{% else %}<span class="kg-badge">inactive</span>{% endif %}
- </span>
- <span class="kg-actions" style="gap:0.35rem">
- <form method="post" action="/{{ owner.username }}/{{ repo.name }}/settings/webhooks/{{ hook.id }}/toggle">
- <button class="kg-btn kg-btn--ghost" type="submit">{% if hook.active %}disable{% else %}enable{% endif %}</button>
- </form>
- <form method="post" action="/{{ owner.username }}/{{ repo.name }}/settings/webhooks/{{ hook.id }}/delete">
- <button class="kg-btn kg-btn--danger-ghost" type="submit">delete</button>
- </form>
- </span>
- </li>
- {% endfor %}
- </ul>
- {% endif %}
- <form class="kg-form" method="post" action="/{{ owner.username }}/{{ repo.name }}/settings/webhooks" style="margin-top:1rem">
- <label>
- payload URL
- <input type="url" name="url" required placeholder="https://example.com/hooks/kitgit" />
- </label>
- <label>
- secret <span class="kg-muted">(optional)</span>
- <input type="text" name="secret" placeholder="HMAC secret" autocomplete="off" />
- </label>
- <fieldset style="border:0;padding:0;margin:0">
- <legend class="kg-muted" style="margin-bottom:0.35rem">events</legend>
- <label class="row"><input type="checkbox" name="event_push" checked /> push</label>
- <label class="row"><input type="checkbox" name="event_issues" checked /> issues</label>
- <label class="row"><input type="checkbox" name="event_pull_request" checked /> pull_request</label>
- <label class="row"><input type="checkbox" name="event_release" checked /> release</label>
- </fieldset>
- <div class="kg-actions">
- <button class="kg-btn" type="submit">add webhook</button>
- </div>
- </form>
- {% if !webhook_deliveries.is_empty() %}
- <div style="margin-top:1.25rem">
- <span class="kg-kicker">recent deliveries</span>
- <ul class="kg-list">
- {% for d in webhook_deliveries %}
- <li>
- <span>
- <strong>{{ d.event }}</strong>{% if !d.action.is_empty() %}.{{ d.action }}{% endif %}
- <span class="kg-badge">{% if d.success %}ok{% else %}fail{% endif %}</span>
- <span class="kg-meta">{{ d.status_label }} · {{ d.created_at }}</span>
- <span class="kg-meta">{{ d.webhook_url }}</span>
- </span>
- </li>
- {% endfor %}
- </ul>
- </div>
- {% endif %}
- </div>
-
{% if access.can_owner() || is_site_admin %}
<div class="kg-danger-zone">
<span class="kg-kicker">danger zone</span>
@@ -385,8 +211,8 @@
<form class="kg-form" method="post" action="/{{ owner.username }}/{{ repo.name }}/settings/danger/delete">
<div class="kg-danger-row">
- <label class="kg-danger-confirm">
- <span>type <code>{{ repo.name }}</code> to delete</span>
+ <label>
+ type <code>{{ repo.name }}</code> to delete
<input type="text" name="confirm" required autocomplete="off" />
</label>
<button class="kg-btn kg-btn--danger" type="submit">delete repository</button>
templates/repo_tree.html
+2
−4
diff --git a/templates/repo_tree.html b/templates/repo_tree.html
index 3124b29..9a27884 100644
--- a/templates/repo_tree.html
+++ b/templates/repo_tree.html
@@ -27,8 +27,7 @@
{% if e.is_dir %}
<span class="kg-icon" style="--icon:url('/static/icons/folder.svg')"></span>
<a href="/{{ owner.username }}/{{ repo.name }}/tree/{{ branch }}/{{ e.path }}">{{ e.name }}</a>
- <span class="kg-tree__commit kg-meta" title="{{ e.commit_message }}">{{ e.commit_message }}</span>
- <span class="kg-tree__time kg-meta">{{ e.commit_time }}</span>
+ <span class="kg-meta">{{ e.mode }}</span>
<a class="kg-tree__dl kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/archive.zip?ref={{ branch }}&path={{ e.path }}" title="Download {{ e.name }} as ZIP">
<span class="kg-icon" style="--icon:url('/static/icons/download.svg')"></span>
<span class="kg-sr-only">Download</span>
@@ -36,8 +35,7 @@
{% else %}
<span class="kg-icon" style="--icon:url('/static/icons/file.svg')"></span>
<a href="/{{ owner.username }}/{{ repo.name }}/blob/{{ branch }}/{{ e.path }}">{{ e.name }}</a>
- <span class="kg-tree__commit kg-meta" title="{{ e.commit_message }}">{{ e.commit_message }}</span>
- <span class="kg-tree__time kg-meta">{{ e.commit_time }}</span>
+ <span class="kg-meta">{{ e.mode }}</span>
{% endif %}
</li>
{% endfor %}
templates/signup.html
+14
−8
diff --git a/templates/signup.html b/templates/signup.html
index 9ed43de..5eceaf6 100644
--- a/templates/signup.html
+++ b/templates/signup.html
@@ -5,13 +5,7 @@
{% block content %}
<section class="kg-section kg-auth">
{% if !signups_enabled %}
- <div class="kg-flash kg-flash--banner">
- {% if invite.is_empty() %}
- {{ signup_disabled_message }}
- {% else %}
- Invite-only signup — your invite code unlocks registration.
- {% endif %}
- </div>
+ <div class="kg-flash kg-flash--banner">{{ signup_disabled_message }}</div>
{% endif %}
<p class="kg-landing__brand">
<span class="kg-mark__glyph" aria-hidden="true">狐</span>
@@ -21,11 +15,12 @@
{% if let Some(err) = error %}
<div class="kg-flash">{{ err }}</div>
{% endif %}
+ {% if signups_enabled || !invite.is_empty() %}
<form class="kg-form" method="post" action="/auth/signup" autocomplete="on">
{% if !signups_enabled %}
<label>
invite code
- <input type="text" name="invite" required value="{{ invite }}" autocomplete="off" spellcheck="false" placeholder="Enter invite code" />
+ <input type="text" name="invite" required value="{{ invite }}" autocomplete="off" spellcheck="false" />
</label>
{% endif %}
<label>
@@ -48,6 +43,17 @@
<button class="kg-btn" type="submit">create account</button>
</div>
</form>
+ {% else %}
+ <form class="kg-form" method="get" action="/auth/signup" style="margin-top:1rem">
+ <label>
+ invite code
+ <input type="text" name="invite" required autocomplete="off" spellcheck="false" placeholder="Enter an invite code" />
+ </label>
+ <div class="kg-actions">
+ <button class="kg-btn" type="submit">continue</button>
+ </div>
+ </form>
+ {% endif %}
<p class="kg-meta" style="margin-top:1.25rem">
Already have an account? <a href="/auth/login">Log in</a>
</p>