branch: main
detail.rs
22320 bytesRaw
use crate::{api, diff, issues, presentation, web};
use worker::*;

enum PrDiffData {
    SourceMissing {
        source_branch: String,
    },
    TargetMissing {
        target_branch: String,
    },
    Compared {
        files: Vec<diff::FileDiff>,
        stats: diff::DiffStats,
    },
}

pub fn page_issue_detail(
    sql: &SqlStorage,
    owner: &str,
    repo_name: &str,
    number: i64,
    actor_name: Option<&str>,
) -> Result<Response> {
    let (default_branch, _) = web::resolve_default_branch(sql)?;

    let issue = match issues::get_issue(sql, number)? {
        Some(issue) => issue,
        None => return Response::error("Not Found", 404),
    };

    let comments = issues::list_comments(sql, issue.id)?;

    let is_pr = issue.kind == "pr";
    let kind_url = if is_pr { "pulls" } else { "issues" };

    let state_class = match issue.state.as_str() {
        "merged" => "merged",
        "closed" => "closed",
        _ => "open",
    };
    let state_label = match issue.state.as_str() {
        "merged" => "Merged",
        "closed" => "Closed",
        _ => "Open",
    };

    let pr_diff_section = if is_pr {
        render_pr_diff_section(sql, owner, repo_name, &issue, actor_name)?
    } else {
        String::new()
    };

    let mut comments_html = String::new();
    for comment in &comments {
        comments_html.push_str(&render_comment(
            &comment.author_name,
            comment.created_at,
            &comment.body,
            false,
            "",
        ));
    }

    let comment_form = if actor_name.is_some() {
        format!(
            r#"<div class="comment comment-form-wrap">
              <div class="comment-header-row">
                <strong>{actor}</strong>
              </div>
              <form method="POST" action="/{owner}/{repo}/{kind_url}/{num}/comment" class="comment-form">
                <textarea name="body" class="comment-textarea" placeholder="Leave a comment..." rows="5"></textarea>
                <div class="comment-form-footer">
                  {state_btn}
                  <button type="submit" class="btn-primary">Comment</button>
                </div>
              </form>
            </div>"#,
            actor = web::html_escape(actor_name.unwrap_or("")),
            owner = web::html_escape(owner),
            repo = web::html_escape(repo_name),
            kind_url = kind_url,
            num = number,
            state_btn = render_state_button(&issue, owner, repo_name, kind_url, actor_name),
        )
    } else {
        String::new()
    };

    let branch_meta = if is_pr {
        match (&issue.source_branch, &issue.target_branch) {
            (Some(src), Some(tgt)) => format!(
                r#"<div class="pr-branch-info">
                  <code class="branch-tag">{}</code>
                  <span class="arrow">→</span>
                  <code class="branch-tag">{}</code>
                </div>"#,
                web::html_escape(src),
                web::html_escape(tgt),
            ),
            _ => String::new(),
        }
    } else {
        String::new()
    };

    let page_title = if is_pr { "Pull Request" } else { "Issue" };

    let content = format!(
        r#"<div class="issue-detail-header">
          <div class="issue-title-row">
            <h1>{title} <span class="issue-number-heading">#{num}</span></h1>
          </div>
          <div class="issue-meta-row">
            <span class="issue-badge {state_class}">{state_label}</span>
            {branch_meta}
            <span class="issue-meta-text">
              opened {time} by <strong>{author}</strong>
            </span>
          </div>
        </div>

        <div class="issue-body-wrap">
          {body_html}
        </div>

        {pr_diff_section}

        <div class="comment-thread">
          {comments_html}
        </div>

        {comment_form}"#,
        title = web::html_escape(&issue.title),
        num = number,
        state_class = state_class,
        state_label = state_label,
        branch_meta = branch_meta,
        time = web::format_time(issue.created_at),
        author = web::html_escape(&issue.author_name),
        body_html = render_issue_body(&issue.body),
        pr_diff_section = pr_diff_section,
        comments_html = comments_html,
        comment_form = comment_form,
    );

    web::html_response(&web::layout(
        &format!("{} #{}", page_title, number),
        owner,
        repo_name,
        &default_branch,
        actor_name,
        &content,
    ))
}

pub fn page_issue_detail_markdown(
    sql: &SqlStorage,
    owner: &str,
    repo_name: &str,
    number: i64,
    actor_name: Option<&str>,
    selection: &presentation::NegotiatedRepresentation,
) -> Result<Response> {
    let issue = match issues::get_issue(sql, number)? {
        Some(issue) => issue,
        None => return Response::error("Not Found", 404),
    };

    let comments = issues::list_comments(sql, issue.id)?;
    let markdown = render_issue_detail_markdown(
        sql, owner, repo_name, &issue, &comments, actor_name, selection,
    )?;
    presentation::markdown_response(&markdown, selection)
}

fn render_issue_detail_markdown(
    sql: &SqlStorage,
    owner: &str,
    repo_name: &str,
    issue: &issues::IssueRow,
    comments: &[issues::CommentRow],
    actor_name: Option<&str>,
    selection: &presentation::NegotiatedRepresentation,
) -> Result<String> {
    let is_pr = issue.kind == "pr";
    let kind_label = if is_pr { "Pull Request" } else { "Issue" };
    let kind_url = if is_pr { "pulls" } else { "issues" };
    let detail_path = format!("/{}/{}/{}/{}", owner, repo_name, kind_url, issue.number);
    let list_path = format!("/{}/{}/{}", owner, repo_name, kind_url);

    let mut markdown = format!(
        "# {} #{}: {}\n\n- State: `{}`\n- Author: `{}`\n- Created: {}\n- Updated: {}\n- Comments: `{}`\n- Path: `{}`\n",
        kind_label,
        issue.number,
        issue.title,
        issue_state_label(&issue.state),
        issue.author_name,
        web::format_time(issue.created_at),
        web::format_time(issue.updated_at),
        comments.len(),
        detail_path,
    );

    if is_pr {
        markdown.push_str(&render_pr_markdown_section(
            sql, owner, repo_name, issue, actor_name,
        )?);
    }

    markdown.push_str("\n## Description\n");
    if issue.body.trim().is_empty() {
        markdown.push_str("No description provided.\n");
    } else {
        markdown.push('\n');
        markdown.push_str(&markdown_literal_block(&issue.body));
    }

    markdown.push_str("\n## Comments\n");
    if comments.is_empty() {
        markdown.push_str("No comments yet.\n");
    } else {
        for (idx, comment) in comments.iter().enumerate() {
            markdown.push_str(&format!(
                "\n### Comment {} - {} - {}\n\n",
                idx + 1,
                comment.author_name,
                web::format_time(comment.created_at),
            ));
            if comment.body.trim().is_empty() {
                markdown.push_str("(empty comment)\n");
            } else {
                markdown.push_str(&markdown_literal_block(&comment.body));
            }
        }
    }

    let mut related_paths = vec![
        ("detail", detail_path.clone()),
        (if is_pr { "pull requests" } else { "issues" }, list_path),
    ];
    if is_pr {
        if let Some(source_branch) = &issue.source_branch {
            related_paths.push((
                "source branch",
                format!("/{}/{}/?ref={}", owner, repo_name, source_branch),
            ));
        }
        if let Some(target_branch) = &issue.target_branch {
            related_paths.push((
                "target branch",
                format!("/{}/{}/?ref={}", owner, repo_name, target_branch),
            ));
        }
        if let Some(merge_hash) = &issue.merge_commit_hash {
            related_paths.push((
                "merge commit",
                format!("/{}/{}/commit/{}", owner, repo_name, merge_hash),
            ));
        }
    }
    markdown.push_str(&render_related_paths_section(&related_paths));

    let actions = build_issue_detail_actions(issue, owner, repo_name);
    let mut hints = vec![presentation::text_navigation_hint(*selection)];
    hints.push(presentation::Hint::new(
        "Paths outside Actions are GET routes and are shown without a method prefix.",
    ));
    if is_pr {
        hints.push(presentation::Hint::new(
            "Pull request file lists summarize the comparison shown on the HTML detail page; full patch hunks remain HTML-only.",
        ));
    }

    markdown.push_str(&presentation::render_actions_section(&actions));
    markdown.push_str(&presentation::render_hints_section(&hints));
    Ok(markdown)
}

fn render_issue_body(body: &str) -> String {
    if body.trim().is_empty() {
        r#"<p class="issue-no-description">No description provided.</p>"#.to_string()
    } else {
        format!(
            r#"<div class="issue-body readme-body">{}</div>"#,
            web::render_markdown(body)
        )
    }
}

fn issue_state_label(state: &str) -> &'static str {
    match state {
        "merged" => "Merged",
        "closed" => "Closed",
        _ => "Open",
    }
}

fn render_comment(
    author: &str,
    created_at: i64,
    body: &str,
    is_first: bool,
    _extra_class: &str,
) -> String {
    let _ = is_first;
    format!(
        r#"<div class="comment">
          <div class="comment-header">
            <strong>{author}</strong>
            <span class="comment-time">{time}</span>
          </div>
          <div class="comment-body readme-body">{body}</div>
        </div>"#,
        author = web::html_escape(author),
        time = web::format_time(created_at),
        body = web::render_markdown(body),
    )
}

fn render_state_button(
    issue: &issues::IssueRow,
    owner: &str,
    repo_name: &str,
    kind_url: &str,
    actor_name: Option<&str>,
) -> String {
    let actor = match actor_name {
        Some(actor) => actor,
        None => return String::new(),
    };
    if issue.state == "merged" {
        return String::new();
    }
    if actor != issue.author_name && actor != owner {
        return String::new();
    }
    if issue.state == "open" {
        format!(
            r#"<button type="submit"
                formaction="/{owner}/{repo}/{kind}/{num}/close"
                class="btn-action">Close</button>"#,
            owner = web::html_escape(owner),
            repo = web::html_escape(repo_name),
            kind = kind_url,
            num = issue.number,
        )
    } else {
        format!(
            r#"<button type="submit"
                formaction="/{owner}/{repo}/{kind}/{num}/reopen"
                class="btn-action">Reopen</button>"#,
            owner = web::html_escape(owner),
            repo = web::html_escape(repo_name),
            kind = kind_url,
            num = issue.number,
        )
    }
}

fn render_pr_diff_section(
    sql: &SqlStorage,
    owner: &str,
    repo_name: &str,
    issue: &issues::IssueRow,
    actor_name: Option<&str>,
) -> Result<String> {
    let target_branch = match &issue.target_branch {
        Some(branch) => branch.as_str(),
        None => return Ok(String::new()),
    };

    let diff_data = match load_pr_diff_data(sql, issue)? {
        Some(diff_data) => diff_data,
        None => return Ok(String::new()),
    };

    let (files, stats) = match diff_data {
        PrDiffData::SourceMissing { source_branch } => {
            return Ok(format!(
                r#"<div class="pr-diff-section">
                      <p class="pr-branch-missing">Branch <code>{}</code> no longer exists.</p>
                    </div>"#,
                web::html_escape(&source_branch)
            ));
        }
        PrDiffData::TargetMissing { target_branch } => {
            return Ok(format!(
                r#"<div class="pr-diff-section">
                      <p class="pr-branch-missing">Target branch <code>{}</code> not found.</p>
                    </div>"#,
                web::html_escape(&target_branch)
            ));
        }
        PrDiffData::Compared { files, stats } => (files, stats),
    };

    let stats_html = format!(
        r#"<div class="pr-diff-stats">
          <span>{} file{} changed</span>
          <span class="stat-add">+{}</span>
          <span class="stat-del">-{}</span>
        </div>"#,
        stats.files_changed,
        if stats.files_changed == 1 { "" } else { "s" },
        stats.additions,
        stats.deletions,
    );

    let mut files_html = String::new();
    for file in &files {
        files_html.push_str(&crate::web::render_file_diff(file));
    }

    let merge_section = if issue.state == "open" && actor_name == Some(owner) {
        format!(
            r#"<div class="pr-merge-box">
              <form method="POST"
                    action="/{owner}/{repo}/pulls/{num}/merge">
                <button type="submit" class="btn-merge">Merge pull request</button>
              </form>
              <p class="pr-merge-hint">
                Creates a merge commit on <code>{target}</code>.
                Conflicts return a 409 error.
              </p>
            </div>"#,
            owner = web::html_escape(owner),
            repo = web::html_escape(repo_name),
            num = issue.number,
            target = web::html_escape(target_branch),
        )
    } else if issue.state == "merged" {
        let merge_hash = issue.merge_commit_hash.as_deref().unwrap_or("");
        format!(
            r#"<div class="pr-merged-box">
              PR merged in <a href="/{owner}/{repo}/commit/{hash}">{short}</a>.
            </div>"#,
            owner = web::html_escape(owner),
            repo = web::html_escape(repo_name),
            hash = web::html_escape(merge_hash),
            short = web::html_escape(&merge_hash[..merge_hash.len().min(7)]),
        )
    } else {
        String::new()
    };

    Ok(format!(
        r#"<div class="pr-diff-section">
          <h2>Files changed</h2>
          {stats_html}
          {merge_section}
          {files_html}
        </div>"#,
        stats_html = stats_html,
        merge_section = merge_section,
        files_html = files_html,
    ))
}

fn render_pr_markdown_section(
    sql: &SqlStorage,
    owner: &str,
    repo_name: &str,
    issue: &issues::IssueRow,
    actor_name: Option<&str>,
) -> Result<String> {
    let mut markdown = String::from("\n## Pull Request\n");

    match (&issue.source_branch, &issue.target_branch) {
        (Some(source_branch), Some(target_branch)) => {
            markdown.push_str(&format!(
                "- Branches: `{}` -> `{}`\n",
                source_branch, target_branch
            ));
        }
        _ => markdown.push_str("- Branches: unavailable\n"),
    }

    if let Some(source_hash) = &issue.source_hash {
        markdown.push_str(&format!(
            "- Source head at creation: `{}`\n",
            &source_hash[..source_hash.len().min(12)]
        ));
    }

    markdown.push_str(&format!(
        "- Merge status: {}\n",
        render_merge_status_line(owner, repo_name, issue, actor_name)
    ));

    markdown.push_str("\n## Changed Files\n");
    match load_pr_diff_data(sql, issue)? {
        Some(PrDiffData::SourceMissing { source_branch }) => {
            markdown.push_str(&format!(
                "Source branch `{}` no longer exists.\n",
                source_branch
            ));
        }
        Some(PrDiffData::TargetMissing { target_branch }) => {
            markdown.push_str(&format!(
                "Target branch `{}` was not found.\n",
                target_branch
            ));
        }
        Some(PrDiffData::Compared { files, stats }) => {
            markdown.push_str(&format!(
                "- Files changed: `{}`\n- Additions: `+{}`\n- Deletions: `-{}`\n",
                stats.files_changed, stats.additions, stats.deletions
            ));
            if files.is_empty() {
                markdown.push_str("\nNo changed files detected.\n");
            } else {
                markdown.push('\n');
                for file in &files {
                    markdown.push_str(&format!(
                        "- {} `{}`\n",
                        diff_status_label(&file.status),
                        file.path
                    ));
                }
            }
        }
        None => markdown.push_str("Changed file summary is unavailable for this pull request.\n"),
    }

    Ok(markdown)
}

fn render_merge_status_line(
    owner: &str,
    repo_name: &str,
    issue: &issues::IssueRow,
    actor_name: Option<&str>,
) -> String {
    if issue.state == "merged" {
        if let Some(merge_hash) = &issue.merge_commit_hash {
            return format!(
                "merged in `{}`",
                format!("/{}/{}/commit/{}", owner, repo_name, merge_hash)
            );
        }
        return "merged; merge commit record unavailable".to_string();
    }

    if issue.state == "closed" {
        return "closed without merge".to_string();
    }

    let merge_path = format!("/{}/{}/pulls/{}/merge", owner, repo_name, issue.number);
    let target_branch = issue
        .target_branch
        .as_deref()
        .unwrap_or("the target branch");
    if actor_name == Some(owner) {
        format!(
            "open; repo owner can `POST {}` to create a merge commit on `{}`; conflicts return `409`",
            merge_path, target_branch
        )
    } else {
        format!(
            "open; repo owner can merge with `POST {}` to create a merge commit on `{}`",
            merge_path, target_branch
        )
    }
}

fn load_pr_diff_data(sql: &SqlStorage, issue: &issues::IssueRow) -> Result<Option<PrDiffData>> {
    let source_branch = match &issue.source_branch {
        Some(branch) => branch.as_str(),
        None => return Ok(None),
    };
    let target_branch = match &issue.target_branch {
        Some(branch) => branch.as_str(),
        None => return Ok(None),
    };

    if issue.state == "merged" {
        if let Some(ref merge_hash) = issue.merge_commit_hash {
            let diff_result = diff::diff_commit(sql, merge_hash, true, 3)?;
            return Ok(Some(PrDiffData::Compared {
                files: diff_result.files,
                stats: diff_result.stats,
            }));
        }
        return Ok(None);
    }

    let source_ref = format!("refs/heads/{}", source_branch);
    let target_ref = format!("refs/heads/{}", target_branch);

    let source_hash = match api::resolve_ref(sql, &source_ref)? {
        Some(hash) => hash,
        None => {
            return Ok(Some(PrDiffData::SourceMissing {
                source_branch: source_branch.to_string(),
            }));
        }
    };
    let target_hash = match api::resolve_ref(sql, &target_ref)? {
        Some(hash) => hash,
        None => {
            return Ok(Some(PrDiffData::TargetMissing {
                target_branch: target_branch.to_string(),
            }));
        }
    };

    let base_hash = issues::find_merge_base(sql, &source_hash, &target_hash)?
        .unwrap_or_else(|| target_hash.clone());
    let comparison = diff::compare(sql, &base_hash, &source_hash, true, 3)?;
    Ok(Some(PrDiffData::Compared {
        files: comparison.files,
        stats: comparison.stats,
    }))
}

fn diff_status_label(status: &diff::DiffStatus) -> &'static str {
    match status {
        diff::DiffStatus::Added => "added",
        diff::DiffStatus::Deleted => "deleted",
        diff::DiffStatus::Modified => "modified",
    }
}

fn render_related_paths_section(paths: &[(&str, String)]) -> String {
    let mut markdown = String::from("\n## Related Paths\n");
    for (label, path) in paths {
        markdown.push_str(&format!("- {}: `{}`\n", label, path));
    }
    markdown
}

fn build_issue_detail_actions(
    issue: &issues::IssueRow,
    owner: &str,
    repo_name: &str,
) -> Vec<presentation::Action> {
    let kind_url = if issue.kind == "pr" {
        "pulls"
    } else {
        "issues"
    };
    let base_path = format!("/{}/{}/{}/{}", owner, repo_name, kind_url, issue.number);
    let mut actions = vec![presentation::Action::post(
        format!("{}/comment", base_path),
        "add a comment to this thread",
    )
    .with_fields(vec![presentation::ActionField::required(
        "body",
        "markdown or plain text comment body",
    )])
    .with_requires("authenticated user")
    .with_effect("appends a new comment and updates the thread timestamp")];

    match issue.state.as_str() {
        "open" => {
            actions.push(
                presentation::Action::post(format!("{}/close", base_path), "close this item")
                    .with_requires("issue or pull author, or repo owner")
                    .with_effect("marks the item closed"),
            );
            if issue.kind == "pr" {
                let target_branch = issue
                    .target_branch
                    .as_deref()
                    .unwrap_or("the target branch");
                actions.push(
                    presentation::Action::post(
                        format!("{}/merge", base_path),
                        "merge this pull request",
                    )
                    .with_requires("repo owner and a conflict-free merge")
                    .with_effect(format!(
                        "creates a merge commit on `{}` and marks the pull request merged; conflicts return `409`",
                        target_branch
                    )),
                );
            }
        }
        "closed" => {
            actions.push(
                presentation::Action::post(format!("{}/reopen", base_path), "reopen this item")
                    .with_requires("issue or pull author, or repo owner")
                    .with_effect("marks the item open again"),
            );
        }
        _ => {}
    }

    actions
}

fn markdown_literal_block(text: &str) -> String {
    let mut output = String::new();
    for line in text.lines() {
        output.push_str("    ");
        output.push_str(line);
        output.push('\n');
    }
    if output.is_empty() {
        output.push_str("    \n");
    }
    output
}