kitgit

tirbofish/kitgit · commit

1738e3a0bfd521372ccb8cd4743e466a23f37a2c

feat: add labels and milestones for issues and pulls

Verified

Thribhu K <4tkbytes@pm.me> · 2026-07-28 15:12

view full diff

diff --git a/migrations/017_labels_milestones.sql b/migrations/017_labels_milestones.sql
new file mode 100644
index 0000000..f4ce6e1
--- /dev/null
+++ b/migrations/017_labels_milestones.sql
@@ -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);
diff --git a/src/db/models.rs b/src/db/models.rs
index 874e00c..580e8e5 100644
--- a/src/db/models.rs
+++ b/src/db/models.rs
@@ -167,6 +167,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>>,
@@ -185,6 +186,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>>,
@@ -192,6 +194,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,
diff --git a/src/db/queries.rs b/src/db/queries.rs
index a236a11..779e673 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::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>(
@@ -2349,3 +2405,385 @@ pub async fn lfs_object_size(pool: &PgPool, oid: &str) -> Result<Option<i64>> {
         .await?;
     Ok(row.map(|r| r.0))
 }
+
+// ── 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/web/mod.rs b/src/web/mod.rs
index c4b4cbb..f0d3d5d 100644
--- a/src/web/mod.rs
+++ b/src/web/mod.rs
@@ -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();
@@ -210,6 +210,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),
         )
@@ -227,6 +235,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),
         )
diff --git a/src/web/routes.rs b/src/web/routes.rs
index c080f6f..6aa7941 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>;
 
@@ -120,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();
@@ -244,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(),
     }
 }
@@ -294,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,
 }
 
@@ -362,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()
@@ -552,7 +552,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()]));
         }
     }
@@ -619,7 +619,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");
@@ -629,7 +629,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 {
@@ -827,7 +827,7 @@ fn map_activity_rows(
     activities
 }
 
-// ── home / auth ──────────────────────────────────────────────────────────────
+// ── home / auth ──────────────────────────────────────────────────────────────
 
 pub async fn home(
     State(state): State<AppState>,
@@ -1017,7 +1017,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"))
 }
@@ -1171,7 +1171,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?;
@@ -1525,7 +1525,7 @@ pub async fn site_banner_json(State(state): State<AppState>) -> impl IntoRespons
     axum::Json(serde_json::json!({ "message": message }))
 }
 
-// ── new repo ─────────────────────────────────────────────────────────────────
+// ── new repo ─────────────────────────────────────────────────────────────────
 
 pub async fn new_repo_form(
     State(state): State<AppState>,
@@ -1616,7 +1616,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>,
@@ -1731,7 +1731,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>,
@@ -1808,7 +1808,7 @@ pub async fn keys_delete(
     Ok(redirect_see_other("/settings/keys"))
 }
 
-// ── avatar ───────────────────────────────────────────────────────────────────
+// ── avatar ───────────────────────────────────────────────────────────────────
 
 pub async fn avatar(
     State(state): State<AppState>,
@@ -1855,7 +1855,7 @@ pub async fn avatar(
         .unwrap())
 }
 
-// ── profile ──────────────────────────────────────────────────────────────────
+// ── profile ──────────────────────────────────────────────────────────────────
 
 pub async fn profile(
     State(state): State<AppState>,
@@ -1897,7 +1897,7 @@ pub async fn profile(
     })
 }
 
-// ── repository browse ────────────────────────────────────────────────────────
+// ── repository browse ────────────────────────────────────────────────────────
 
 pub async fn repo_home(
     State(state): State<AppState>,
@@ -2544,11 +2544,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(
@@ -2562,20 +2658,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,
     })
@@ -2592,12 +2707,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,
@@ -2608,6 +2727,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(
@@ -2624,12 +2746,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,
@@ -2640,6 +2766,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),
@@ -2677,6 +2816,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,
@@ -2688,12 +2845,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,
 }
@@ -2777,7 +3003,7 @@ pub async fn issue_reopen(
     )))
 }
 
-// ── pull requests ────────────────────────────────────────────────────────────
+// --- pull requests ---
 
 pub async fn pulls_list(
     State(state): State<AppState>,
@@ -2790,19 +3016,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,
     })
@@ -2823,6 +3063,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,
@@ -2830,6 +3072,8 @@ pub async fn pull_new(
         repo: repository,
         access,
         branches,
+        labels,
+        milestones,
         error: None,
         clone_http,
         clone_ssh,
@@ -2843,6 +3087,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(
@@ -2865,6 +3112,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),
@@ -2872,6 +3121,8 @@ pub async fn pull_create(
             repo: repository,
             access,
             branches,
+            labels,
+            milestones,
             error: Some("title, source, and target required".into()),
             clone_http,
             clone_ssh,
@@ -2895,6 +3146,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),
@@ -2975,6 +3239,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 {
@@ -2993,11 +3276,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,
@@ -3128,7 +3469,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> {
@@ -3710,7 +4051,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>,
diff --git a/src/web/routes_extra.rs b/src/web/routes_extra.rs
index 712ab5f..9a47d98 100644
--- a/src/web/routes_extra.rs
+++ b/src/web/routes_extra.rs
@@ -1,14 +1,15 @@
 //! Social, account, branch, raw, and admin helpers.
 
 use super::routes::{
-    avatar_url_for, load_repo_context, redirect_see_other, require_login, AppError, AppResult,
+    avatar_url_for, clone_urls, load_repo_context, 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};
@@ -383,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>,
@@ -805,3 +806,301 @@ pub async fn gpg_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")))
+}
diff --git a/src/web/templates.rs b/src/web/templates.rs
index a2932d4..8fc1062 100644
--- a/src/web/templates.rs
+++ b/src/web/templates.rs
@@ -1,14 +1,14 @@
 use crate::og::SocialMeta;
 use crate::db::models::{
-    Access, BranchRule, CommitDay, GpgKey, Issue, PullRequest, Release, Repository, SshKey, User,
-    UserEmail,
+    Access, BranchRule, CommitDay, GpgKey, Issue, Label, Milestone, PullRequest, Release,
+    Repository, SshKey, User, UserEmail,
 };
 use askama::Template;
 use askama_web::WebTemplate;
 use chrono::{DateTime, Utc};
 use uuid::Uuid;
 
-// ── view models ──────────────────────────────────────────────────────────────
+// ΓöÇΓöÇ view models ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ
 
 pub struct ActivityRow {
     pub kind: String,
@@ -78,6 +78,28 @@ pub struct CollaboratorView {
     pub avatar_url: 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,
@@ -109,7 +131,7 @@ pub struct ReleaseAssetView {
     pub content_type: String,
 }
 
-// ── page templates ───────────────────────────────────────────────────────────
+// ΓöÇΓöÇ page templates ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ
 
 #[derive(Template, WebTemplate)]
 #[template(path = "home.html")]
@@ -364,8 +386,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,
 }
@@ -377,6 +403,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,
@@ -394,6 +422,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,
 }
@@ -405,8 +438,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,
 }
@@ -419,6 +456,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,
@@ -443,6 +482,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,
 }
diff --git a/static/brand.css b/static/brand.css
index d50db11..e3446f3 100644
--- a/static/brand.css
+++ b/static/brand.css
@@ -1552,3 +1552,13 @@ html.kg-dark .kg-codeview .punctuation { color: #b0b0b0; }
     animation: none !important;
   }
 }
+
+.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; } }
+
diff --git a/templates/issue_new.html b/templates/issue_new.html
index 0319760..5cabf33 100644
--- a/templates/issue_new.html
+++ b/templates/issue_new.html
@@ -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>
diff --git a/templates/issue_view.html b/templates/issue_view.html
index 2158d27..42e80ee 100644
--- a/templates/issue_view.html
+++ b/templates/issue_view.html
@@ -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 %}
diff --git a/templates/issues_list.html b/templates/issues_list.html
index 6c1629d..2dcf9cc 100644
--- a/templates/issues_list.html
+++ b/templates/issues_list.html
@@ -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>
diff --git a/templates/labels_list.html b/templates/labels_list.html
new file mode 100644
index 0000000..d31a1bc
--- /dev/null
+++ b/templates/labels_list.html
@@ -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 %}
diff --git a/templates/milestones_list.html b/templates/milestones_list.html
new file mode 100644
index 0000000..7947f22
--- /dev/null
+++ b/templates/milestones_list.html
@@ -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 %}
diff --git a/templates/pull_new.html b/templates/pull_new.html
index 21d97f5..b8957a8 100644
--- a/templates/pull_new.html
+++ b/templates/pull_new.html
@@ -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 %}
diff --git a/templates/pull_view.html b/templates/pull_view.html
index 92b3f55..898d425 100644
--- a/templates/pull_view.html
+++ b/templates/pull_view.html
@@ -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 %}
diff --git a/templates/pulls_list.html b/templates/pulls_list.html
index 69425f5..1b26d41 100644
--- a/templates/pulls_list.html
+++ b/templates/pulls_list.html
@@ -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 %}
diff --git a/templates/repo_settings.html b/templates/repo_settings.html
index effe71d..52e9962 100644
--- a/templates/repo_settings.html
+++ b/templates/repo_settings.html
@@ -146,6 +146,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>