tirbofish/kitgit · commit
559a4ef7978d9e31e1fc17f027ff6de76400d018
merge: feat/labels-milestones into integrate/all-features
Type: SSH
SSH Key Fingerprint:
Verified
SgQHY4vUORbJC3ZtdixCl62ek/4QGh/iG2FRSyjfGPc
@@ -0,0 +1,55 @@ +-- 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); @@ -204,6 +204,7 @@ 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>>, @@ -222,6 +223,7 @@ 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>>, @@ -229,6 +231,51 @@ 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, @@ -1,7 +1,7 @@ use super::models::*; use anyhow::Result; use chrono::{DateTime, NaiveDate, Utc}; -use sqlx::PgPool; +use sqlx::{FromRow, PgPool}; use uuid::Uuid; pub async fn upsert_user_from_oidc( @@ -1252,22 +1252,50 @@ pub async fn create_issue( } pub async fn list_issues(pool: &PgPool, repo_id: Uuid, state: Option<&str>) -> Result<Vec<Issue>> { - 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?) - } + 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(()) } pub async fn get_issue(pool: &PgPool, repo_id: Uuid, number: i32) -> Result<Option<Issue>> { @@ -1340,22 +1368,50 @@ pub async fn create_pull_ex( } pub async fn list_pulls(pool: &PgPool, repo_id: Uuid, state: Option<&str>) -> Result<Vec<PullRequest>> { - 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?) - } + 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(()) } pub async fn get_pull(pool: &PgPool, repo_id: Uuid, number: i32) -> Result<Option<PullRequest>> { @@ -1615,7 +1671,7 @@ pub async fn rewrite_asset_paths_for_tag( } -// ── stars / watches / forks ────────────────────────────────────────────────── +// ΓöÇΓöÇ stars / watches / forks ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ pub async fn toggle_star(pool: &PgPool, repo_id: Uuid, user_id: Uuid) -> Result<bool> { let existing: Option<(Uuid,)> = sqlx::query_as( @@ -1861,7 +1917,7 @@ pub async fn get_fork_of_user(pool: &PgPool, fork_of_id: Uuid, owner_id: Uuid) - .await?) } -// ── reactions ──────────────────────────────────────────────────────────────── +// ΓöÇΓöÇ reactions ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ pub async fn toggle_reaction( pool: &PgPool, @@ -1928,7 +1984,7 @@ pub async fn list_reaction_counts( Ok(rows.into_iter().map(|r| (r.emoji, r.count, r.mine)).collect()) } -// ── branch rules ───────────────────────────────────────────────────────────── +// ΓöÇΓöÇ branch rules ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ pub async fn list_branch_rules(pool: &PgPool, repo_id: Uuid) -> Result<Vec<BranchRule>> { Ok(sqlx::query_as::<_, BranchRule>( @@ -1972,7 +2028,7 @@ pub async fn delete_branch_rule(pool: &PgPool, id: Uuid, repo_id: Uuid) -> Resul Ok(()) } -// ── user emails / privacy / sessions / gpg ─────────────────────────────────── +// ΓöÇΓöÇ user emails / privacy / sessions / gpg ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ pub async fn update_username(pool: &PgPool, id: Uuid, username: &str) -> Result<User> { Ok(sqlx::query_as::<_, User>( @@ -2364,7 +2420,7 @@ pub async fn lfs_object_size(pool: &PgPool, oid: &str) -> Result<Option<i64>> { Ok(row.map(|r| r.0)) } -// ── repo mirrors ───────────────────────────────────────────────────────────── +// ── repo mirrors ───────────────────────────────────────────────────────────── pub async fn get_repo_mirror(pool: &PgPool, repo_id: Uuid) -> Result<Option<RepoMirror>> { Ok(sqlx::query_as::<_, RepoMirror>( @@ -2443,7 +2499,7 @@ pub async fn delete_repo_mirror(pool: &PgPool, repo_id: Uuid) -> Result<()> { Ok(()) } -// ── per-user audit log ─────────────────────────────────────────────────────── +// ── per-user audit log ─────────────────────────────────────────────────────── pub async fn record_audit_log( pool: &PgPool, @@ -2485,7 +2541,7 @@ pub async fn list_audit_log_for_user( .await?) } -// ── webhooks ───────────────────────────────────────────────────────────────── +// ── webhooks ───────────────────────────────────────────────────────────────── pub async fn list_webhooks(pool: &PgPool, repo_id: Uuid) -> Result<Vec<Webhook>> { Ok(sqlx::query_as::<_, Webhook>( @@ -2653,7 +2709,7 @@ pub async fn list_recent_webhook_deliveries( .collect()) } -// ── notifications ──────────────────────────────────────────────────────────── +// ── notifications ──────────────────────────────────────────────────────────── pub async fn create_notification( pool: &PgPool, @@ -2822,3 +2878,385 @@ pub async fn mark_all_notifications_read(pool: &PgPool, user_id: Uuid) -> Result .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()) +} @@ -13,7 +13,7 @@ use axum::Router; use tower_http::limit::RequestBodyLimitLayer; use tower_http::services::ServeDir; -/// 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(); @@ -228,6 +228,14 @@ 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), ) @@ -245,6 +253,46 @@ 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}/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), ) @@ -20,7 +20,7 @@ use serde::Deserialize; use std::path::PathBuf; use uuid::Uuid; -// ── helpers ────────────────────────────────────────────────────────────────── +// ├óΓÇ¥Γé¼├óΓÇ¥Γé¼ helpers ├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼ pub type AppResult<T> = Result<T, AppError>; @@ -209,7 +209,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 +333,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 +383,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 +451,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() @@ -641,7 +641,7 @@ fn parse_diff_files(diff: &str) -> Vec<DiffFileView> { } 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()])); } } @@ -708,7 +708,7 @@ async fn latest_commit_view( Some(commit_view(state, c, extracted).await) } -fn clone_urls(state: &AppState, owner: &str, repo: &str) -> (String, String) { +pub 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"); @@ -718,7 +718,7 @@ 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 { @@ -916,7 +916,7 @@ fn map_activity_rows( activities } -// ── home / auth ────────────────────────────────────────────────────────────── +// ├óΓÇ¥Γé¼├óΓÇ¥Γé¼ home / auth ├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼ pub async fn home( State(state): State<AppState>, @@ -1141,7 +1141,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")) } @@ -1316,7 +1316,7 @@ pub async fn auth_logout( 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?; @@ -1742,7 +1742,7 @@ pub async fn notifications_mark_all_read( Ok(redirect_see_other("/notifications")) } -// ── new repo ───────────────────────────────────────────────────────────────── +// ── new repo ───────────────────────────────────────────────────────────────── pub async fn new_repo_form( State(state): State<AppState>, @@ -1833,7 +1833,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>, @@ -1948,7 +1948,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>, @@ -2043,7 +2043,7 @@ pub async fn keys_delete( Ok(redirect_see_other("/settings/keys")) } -// ── avatar ─────────────────────────────────────────────────────────────────── +// ├óΓÇ¥Γé¼├óΓÇ¥Γé¼ avatar ├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼ pub async fn avatar( State(state): State<AppState>, @@ -2090,7 +2090,7 @@ pub async fn avatar( .unwrap()) } -// ── profile ────────────────────────────────────────────────────────────────── +// ├óΓÇ¥Γé¼├óΓÇ¥Γé¼ profile ├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼ pub async fn profile( State(state): State<AppState>, @@ -2132,7 +2132,7 @@ pub async fn profile( }) } -// ── repository browse ──────────────────────────────────────────────────────── +// ├óΓÇ¥Γé¼├óΓÇ¥Γé¼ repository browse ├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼ pub async fn repo_home( State(state): State<AppState>, @@ -2779,11 +2779,107 @@ 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( @@ -2797,20 +2893,39 @@ pub async fn issues_list( if !repository.issues_enabled { return Err(AppError::not_found()); } - // Default to open-only (GitHub-style) when no state query param is set. - let filter = match q.state.as_deref() { + let state_filter = match q.state.as_deref() { Some("closed") => "closed", + Some("all") => "all", _ => "open", }; - let issues = queries::list_issues(&state.pool, repository.id, Some(filter)).await?; + 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 (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo); Ok(IssuesListTemplate { viewer, owner: owner_user, repo: repository, access, - issues, - state_filter: filter.to_string(), + issues: items, + labels, + milestones, + state_filter: state_filter.to_string(), + label_filter: q.label, + milestone_filter: q.milestone, clone_http, clone_ssh, }) @@ -2827,12 +2942,16 @@ 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, @@ -2843,6 +2962,9 @@ 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( @@ -2859,12 +2981,16 @@ 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, @@ -2875,6 +3001,19 @@ 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), @@ -2934,6 +3073,24 @@ 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, @@ -2945,12 +3102,81 @@ 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, } @@ -3073,7 +3299,7 @@ pub async fn issue_reopen( ))) } -// ── pull requests ──────────────────────────────────────────────────────────── +// --- pull requests --- pub async fn pulls_list( State(state): State<AppState>, @@ -3086,19 +3312,33 @@ pub async fn pulls_list( if !repository.pulls_enabled { return Err(AppError::not_found()); } - 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 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 (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo); Ok(PullsListTemplate { viewer, owner: owner_user, repo: repository, access, - pulls, + pulls: items, + labels, + milestones, state_filter: filter.unwrap_or("open").to_string(), + label_filter: q.label, + milestone_filter: q.milestone, clone_http, clone_ssh, }) @@ -3119,6 +3359,8 @@ 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, @@ -3126,6 +3368,8 @@ pub async fn pull_new( repo: repository, access, branches, + labels, + milestones, error: None, clone_http, clone_ssh, @@ -3139,6 +3383,9 @@ 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( @@ -3161,6 +3408,8 @@ 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), @@ -3168,6 +3417,8 @@ pub async fn pull_create( repo: repository, access, branches, + labels, + milestones, error: Some("title, source, and target required".into()), clone_http, clone_ssh, @@ -3191,6 +3442,19 @@ 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), @@ -3295,6 +3559,25 @@ 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 conversation_count = comments.len() + 1; let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo); Ok(PullViewTemplate { @@ -3313,11 +3596,69 @@ pub async fn pull_view( tab, can_merge, merge_styles, + labels, + label_options, + milestone, + milestone_options, + can_triage, 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, @@ -3494,7 +3835,7 @@ pub async fn pull_close( Ok(redirect_see_other(&format!("/{owner}/{repo}/pulls/{number}"))) } -// ── releases ───────────────────────────────────────────────────────────────── +// ├óΓÇ¥Γé¼├óΓÇ¥Γé¼ releases ├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼ fn asset_views(assets: Vec<crate::db::models::ReleaseAsset>) -> Vec<ReleaseAssetView> { @@ -4142,7 +4483,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>, @@ -1,15 +1,15 @@ //! Social, account, branch, raw, and admin helpers. use super::routes::{ - audit_entries_view, avatar_url_for, load_repo_context, record_audit, redirect_see_other, - require_login, AppError, AppResult, + audit_entries_view, avatar_url_for, clone_urls, load_repo_context, record_audit, + redirect_see_other, require_login, AppError, AppResult, }; use crate::auth::{self, clear_session_cookie, hash_token, token_from_headers}; use crate::db::queries; use crate::git; use crate::state::AppState; use crate::web::templates::*; -use axum::extract::{Form, Path, State}; +use axum::extract::{Form, Path, Query, State}; use axum::http::header::{CONTENT_DISPOSITION, CONTENT_TYPE, SET_COOKIE}; use axum::http::{HeaderMap, HeaderValue, StatusCode}; use axum::response::{IntoResponse, Response}; @@ -384,7 +384,7 @@ pub async fn tag_delete( Ok(redirect_see_other(&format!("/{owner}/{repo}/branches"))) } -// ── account settings ───────────────────────────────────────────────────────── +// ΓöÇΓöÇ account settings ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ pub async fn account_settings( State(state): State<AppState>, @@ -1038,3 +1038,301 @@ pub async fn mirror_delete( fn _use_avatar(u: &crate::db::models::User) -> String { avatar_url_for(u) } + +// labels & milestones management handlers for routes_extra.rs + +fn normalize_label_color(raw: &str) -> Option<String> { + let hex = raw.trim().trim_start_matches('#').to_lowercase(); + if hex.len() == 6 && hex.chars().all(|c| c.is_ascii_hexdigit()) { + Some(hex) + } else { + None + } +} + +fn parse_due_on(raw: &Option<String>) -> Option<chrono::NaiveDate> { + raw.as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .and_then(|s| chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d").ok()) +} + +pub async fn labels_list( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo)): Path<(String, String)>, +) -> AppResult<impl IntoResponse> { + let (repository, owner_user, viewer, access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + let labels = queries::list_labels(&state.pool, repository.id).await?; + let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo); + Ok(LabelsListTemplate { + viewer, + owner: owner_user, + repo: repository, + access, + labels, + error: None, + clone_http, + clone_ssh, + }) +} + +#[derive(Deserialize)] +pub struct LabelForm { + pub name: String, + pub color: String, + pub description: Option<String>, +} + +pub async fn label_create( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo)): Path<(String, String)>, + Form(form): Form<LabelForm>, +) -> AppResult<Response> { + let (repository, owner_user, viewer, access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + let user = viewer.ok_or_else(AppError::unauthorized)?; + if !access.can_admin() { + return Err(AppError::forbidden()); + } + let name = form.name.trim(); + let color = normalize_label_color(&form.color); + let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo); + if name.is_empty() || color.is_none() { + let labels = queries::list_labels(&state.pool, repository.id).await?; + return Ok(LabelsListTemplate { + viewer: Some(user), + owner: owner_user, + repo: repository, + access, + labels, + error: Some("name and a 6-digit hex color are required".into()), + clone_http, + clone_ssh, + } + .into_response()); + } + let desc = form.description.unwrap_or_default(); + queries::create_label(&state.pool, repository.id, name, &color.unwrap(), desc.trim()) + .await + .map_err(|e| AppError::bad(format!("could not create label: {e}")))?; + Ok(redirect_see_other(&format!("/{owner}/{repo}/labels"))) +} + +pub async fn label_update( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo, id)): Path<(String, String, Uuid)>, + Form(form): Form<LabelForm>, +) -> AppResult<Response> { + let (repository, _o, viewer, access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + let _user = viewer.ok_or_else(AppError::unauthorized)?; + if !access.can_admin() { + return Err(AppError::forbidden()); + } + let _ = queries::get_label(&state.pool, repository.id, id) + .await? + .ok_or_else(AppError::not_found)?; + let name = form.name.trim(); + let color = normalize_label_color(&form.color).ok_or_else(|| AppError::bad("bad color"))?; + if name.is_empty() { + return Err(AppError::bad("name required")); + } + let desc = form.description.unwrap_or_default(); + queries::update_label(&state.pool, id, name, &color, desc.trim()).await?; + Ok(redirect_see_other(&format!("/{owner}/{repo}/labels"))) +} + +pub async fn label_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?; + let _user = viewer.ok_or_else(AppError::unauthorized)?; + if !access.can_admin() { + return Err(AppError::forbidden()); + } + let _ = queries::get_label(&state.pool, repository.id, id) + .await? + .ok_or_else(AppError::not_found)?; + queries::delete_label(&state.pool, id).await?; + Ok(redirect_see_other(&format!("/{owner}/{repo}/labels"))) +} + +#[derive(Deserialize)] +pub struct MilestoneStateFilter { + pub state: Option<String>, +} + +pub async fn milestones_list( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo)): Path<(String, String)>, + Query(q): Query<MilestoneStateFilter>, +) -> AppResult<impl IntoResponse> { + let (repository, owner_user, viewer, access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + let state_filter = match q.state.as_deref() { + Some("closed") => "closed", + Some("all") => "all", + _ => "open", + }; + let milestones = if state_filter == "all" { + queries::list_milestones(&state.pool, repository.id, None).await? + } else { + queries::list_milestones(&state.pool, repository.id, Some(state_filter)).await? + }; + let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo); + Ok(MilestonesListTemplate { + viewer, + owner: owner_user, + repo: repository, + access, + milestones, + state_filter: state_filter.to_string(), + error: None, + clone_http, + clone_ssh, + }) +} + +#[derive(Deserialize)] +pub struct MilestoneForm { + pub title: String, + pub description: Option<String>, + pub due_on: Option<String>, +} + +pub async fn milestone_create( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo)): Path<(String, String)>, + Form(form): Form<MilestoneForm>, +) -> AppResult<Response> { + let (repository, owner_user, viewer, access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + let user = viewer.ok_or_else(AppError::unauthorized)?; + if !access.can_admin() { + return Err(AppError::forbidden()); + } + let title = form.title.trim(); + let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo); + if title.is_empty() { + let milestones = queries::list_open_milestones(&state.pool, repository.id).await?; + return Ok(MilestonesListTemplate { + viewer: Some(user), + owner: owner_user, + repo: repository, + access, + milestones, + state_filter: "open".into(), + error: Some("title required".into()), + clone_http, + clone_ssh, + } + .into_response()); + } + let desc = form.description.unwrap_or_default(); + queries::create_milestone( + &state.pool, + repository.id, + title, + desc.trim(), + parse_due_on(&form.due_on), + ) + .await + .map_err(|e| AppError::bad(format!("could not create milestone: {e}")))?; + Ok(redirect_see_other(&format!("/{owner}/{repo}/milestones"))) +} + +pub async fn milestone_update( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo, id)): Path<(String, String, Uuid)>, + Form(form): Form<MilestoneForm>, +) -> AppResult<Response> { + let (repository, _o, viewer, access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + let _user = viewer.ok_or_else(AppError::unauthorized)?; + if !access.can_admin() { + return Err(AppError::forbidden()); + } + let _ = queries::get_milestone(&state.pool, repository.id, id) + .await? + .ok_or_else(AppError::not_found)?; + let title = form.title.trim(); + if title.is_empty() { + return Err(AppError::bad("title required")); + } + let desc = form.description.unwrap_or_default(); + queries::update_milestone( + &state.pool, + id, + title, + desc.trim(), + parse_due_on(&form.due_on), + ) + .await?; + Ok(redirect_see_other(&format!("/{owner}/{repo}/milestones"))) +} + +pub async fn milestone_close( + 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?; + let _user = viewer.ok_or_else(AppError::unauthorized)?; + if !access.can_admin() { + return Err(AppError::forbidden()); + } + let _ = queries::get_milestone(&state.pool, repository.id, id) + .await? + .ok_or_else(AppError::not_found)?; + queries::set_milestone_state(&state.pool, id, "closed").await?; + Ok(redirect_see_other(&format!( + "/{owner}/{repo}/milestones?state=closed" + ))) +} + +pub async fn milestone_reopen( + 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?; + let _user = viewer.ok_or_else(AppError::unauthorized)?; + if !access.can_admin() { + return Err(AppError::forbidden()); + } + let _ = queries::get_milestone(&state.pool, repository.id, id) + .await? + .ok_or_else(AppError::not_found)?; + queries::set_milestone_state(&state.pool, id, "open").await?; + Ok(redirect_see_other(&format!("/{owner}/{repo}/milestones"))) +} + +pub async fn milestone_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?; + let _user = viewer.ok_or_else(AppError::unauthorized)?; + if !access.can_admin() { + return Err(AppError::forbidden()); + } + let _ = queries::get_milestone(&state.pool, repository.id, id) + .await? + .ok_or_else(AppError::not_found)?; + queries::delete_milestone(&state.pool, id).await?; + Ok(redirect_see_other(&format!("/{owner}/{repo}/milestones"))) +} @@ -1,7 +1,7 @@ use crate::og::SocialMeta; use crate::db::models::{ - Access, BranchRule, CommitDay, GpgKey, Issue, Notification, PullRequest, Release, RepoMirror, - Repository, SshKey, User, UserEmail, + Access, BranchRule, CommitDay, GpgKey, Issue, Label, Milestone, Notification, PullRequest, + Release, RepoMirror, Repository, SshKey, User, UserEmail, }; use crate::db::DeployKey; use askama::Template; @@ -9,7 +9,7 @@ use askama_web::WebTemplate; use chrono::{DateTime, Utc}; use uuid::Uuid; -// ── view models ────────────────────────────────────────────────────────────── +// ΓöÇΓöÇ view models ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ pub struct ActivityRow { pub kind: String, @@ -97,6 +97,28 @@ pub struct WebhookDeliveryView { 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, @@ -128,7 +150,7 @@ pub struct ReleaseAssetView { pub content_type: String, } -// ── page templates ─────────────────────────────────────────────────────────── +// ΓöÇΓöÇ page templates ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ #[derive(Template, WebTemplate)] #[template(path = "home.html")] @@ -393,8 +415,12 @@ pub struct IssuesListTemplate { pub owner: User, pub repo: Repository, pub access: Access, - pub issues: Vec<Issue>, + pub issues: Vec<IssueListItem>, + pub labels: Vec<Label>, + pub milestones: Vec<Milestone>, pub state_filter: String, + pub label_filter: Option<Uuid>, + pub milestone_filter: Option<Uuid>, pub clone_http: String, pub clone_ssh: String, } @@ -406,6 +432,8 @@ 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, @@ -423,6 +451,11 @@ 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, } @@ -434,8 +467,12 @@ pub struct PullsListTemplate { pub owner: User, pub repo: Repository, pub access: Access, - pub pulls: Vec<PullRequest>, + pub pulls: Vec<PullListItem>, + pub labels: Vec<Label>, + pub milestones: Vec<Milestone>, pub state_filter: String, + pub label_filter: Option<Uuid>, + pub milestone_filter: Option<Uuid>, pub clone_http: String, pub clone_ssh: String, } @@ -448,6 +485,8 @@ 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, @@ -472,6 +511,38 @@ pub struct PullViewTemplate { 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 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, } @@ -1645,3 +1645,13 @@ a:hover { 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; } } + @@ -20,6 +20,28 @@ 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> @@ -12,46 +12,101 @@ {% let archive_ref = repo.default_branch %} {% include "partials/repo_tabs.html" %} - <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> + <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> - {% 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> + {% 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> {% else %} - <button class="kg-btn kg-btn--ghost" formaction="/{{ owner.username }}/{{ repo.name }}/issues/{{ issue.number }}/reopen" formmethod="post" type="submit" formnovalidate>reopen</button> + <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> {% endif %} </div> - </form> - {% endif %} + </aside> + </div> </section> {% endblock %} @@ -13,24 +13,55 @@ {% include "partials/repo_tabs.html" %} <div class="kg-actions"> - <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/issues?state=open"{% if state_filter == "open" %} aria-current="page"{% endif %}>open</a> - <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/issues?state=closed"{% if state_filter == "closed" %} aria-current="page"{% endif %}>closed</a> + <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/issues?state=open{% if let Some(lid) = label_filter %}&label={{ lid }}{% endif %}{% if let Some(mid) = milestone_filter %}&milestone={{ mid }}{% endif %}"{% if state_filter == "open" %} aria-current="page"{% endif %}>open</a> + <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/issues?state=closed{% if let Some(lid) = label_filter %}&label={{ lid }}{% endif %}{% if let Some(mid) = milestone_filter %}&milestone={{ mid }}{% endif %}"{% if state_filter == "closed" %} aria-current="page"{% endif %}>closed</a> {% 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> + <form class="kg-form kg-filters" method="get" action="/{{ owner.username }}/{{ repo.name }}/issues"> + <input type="hidden" name="state" value="{{ state_filter }}" /> + <label> + label + <select name="label"> + <option value="">any</option> + {% for l in labels %} + <option value="{{ l.id }}"{% if let Some(lid) = label_filter %}{% if lid.to_string() == l.id.to_string() %} selected{% endif %}{% endif %}>{{ l.name }}</option> + {% endfor %} + </select> + </label> + <label> + milestone + <select name="milestone"> + <option value="">any</option> + {% for m in milestones %} + <option value="{{ m.id }}"{% if let Some(mid) = milestone_filter %}{% if mid.to_string() == m.id.to_string() %} selected{% endif %}{% endif %}>{{ m.title }}</option> + {% endfor %} + </select> + </label> + <button class="kg-btn kg-btn--ghost" type="submit">filter</button> + </form> + {% if issues.is_empty() %} <p class="kg-muted">No {{ state_filter }} issues.</p> {% else %} <ul class="kg-list"> - {% for issue in issues %} + {% for item in issues %} <li> <span> - <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 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> - <span class="kg-meta">{{ issue.updated_at }}</span> + <span class="kg-meta">{{ item.issue.updated_at }}</span> </li> {% endfor %} </ul> @@ -0,0 +1,15 @@ +{% 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 %} @@ -0,0 +1,19 @@ +{% 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 %} @@ -1,45 +1,20 @@ {% 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> - <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> + {% 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> </form> </section> {% endblock %} @@ -9,6 +9,19 @@ #{{ 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 %} @@ -1,41 +1,26 @@ {% 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 %} + {% 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> </div> - - {% 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 %} + <form class="kg-form kg-filters" method="get" action="/{{ owner.username }}/{{ repo.name }}/pulls"> + <input type="hidden" name="state" value="{{ state_filter }}" /> + <label>label<select name="label"><option value="">any</option>{% for l in labels %}<option value="{{ l.id }}"{% if let Some(lid) = label_filter %}{% if lid.to_string() == l.id.to_string() %} selected{% endif %}{% endif %}>{{ l.name }}</option>{% endfor %}</select></label> + <label>milestone<select name="milestone"><option value="">any</option>{% for m in milestones %}<option value="{{ m.id }}"{% if let Some(mid) = milestone_filter %}{% if mid.to_string() == m.id.to_string() %} selected{% endif %}{% endif %}>{{ m.title }}</option>{% endfor %}</select></label> + <button class="kg-btn kg-btn--ghost" type="submit">filter</button> + </form> + {% 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 }}</span></li>{% endfor %}</ul>{% endif %} </section> {% endblock %} @@ -204,6 +204,15 @@ </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>