Add GitHub comments to WYSIWYG (vibe-kanban) (#1449)

* The implementation is complete. Here's a summary of what was added:

## Summary

I've implemented the endpoint to fetch all GitHub PR comments for a task attempt. The new endpoint is:

**`GET /api/task-attempts/{id}/pr/comments`**

### Files Modified

1. **`crates/services/src/services/github/cli.rs`**
   - Added `PrCommentAuthor` and `PrComment` types
   - Added `get_pr_comments()` method to `GhCli` that runs `gh pr view N --repo owner/repo --json comments`
   - Added `parse_pr_comments()` helper to parse the JSON response

2. **`crates/services/src/services/github.rs`**
   - Exported `PrComment` and `PrCommentAuthor` types
   - Added async `get_pr_comments()` method with retry logic (exponential backoff)

3. **`crates/server/src/routes/task_attempts.rs`**
   - Added `PrCommentsResponse` struct
   - Added `GetPrCommentsError` enum with variants: `NoPrAttached`, `GithubCliNotInstalled`, `GithubCliNotLoggedIn`
   - Added `get_pr_comments()` handler function
   - Registered the route at `/pr/comments`

4. **`crates/server/src/bin/generate_types.rs`**
   - Registered all new types for TypeScript generation

### Generated TypeScript Types

```typescript
type PrCommentsResponse = { comments: Array<PrComment> };

type GetPrCommentsError =
  | { "type": "no_pr_attached" }
  | { "type": "github_cli_not_installed" }
  | { "type": "github_cli_not_logged_in" };

type PrComment = {
  id: string,
  author: PrCommentAuthor,
  authorAssociation: string,
  body: string,
  createdAt: string,
  url: string
};

type PrCommentAuthor = { login: string };
```

* The enhancement is complete. Here's a summary of the changes:

## Summary

Enhanced the `GET /api/task-attempts/{id}/pr/comments` endpoint to fetch **both** general PR comments AND inline review comments, merged into a unified timeline.

### Response Format

```json
{
  "success": true,
  "data": {
    "comments": [
      {
        "comment_type": "general",
        "id": "IC_kwDOO7s21M7XzqRP",
        "author": "stunningpixels",
        "author_association": "MEMBER",
        "body": "Testing comments",
        "created_at": "2025-12-06T16:15:10Z",
        "url": "https://github.com/BloopAI/vibe-kanban/pull/1447#issuecomment-..."
      },
      {
        "comment_type": "review",
        "id": 2595085092,
        "author": "stunningpixels",
        "author_association": "MEMBER",
        "body": "undo this",
        "created_at": "2025-12-06T16:15:18Z",
        "url": "https://github.com/BloopAI/vibe-kanban/pull/1447#discussion_r...",
        "path": "frontend/src/components/layout/Navbar.tsx",
        "line": 23,
        "diff_hunk": "@@ -20,7 +20,6 @@ import {...}"
      }
    ]
  }
}
```

### TypeScript Types

```typescript
type UnifiedPrComment =
  | { comment_type: "general"; id: string; author: string; author_association: string; body: string; created_at: string; url: string; }
  | { comment_type: "review"; id: bigint; author: string; author_association: string; body: string; created_at: string; url: string; path: string; line: bigint | null; diff_hunk: string; };
```

* Add GitHub review comments (vibe-kanban b9ab9ab2)

frontend/src/components/tasks/TaskFollowUpSection.tsx

- New button to the right of attachment with github comment icon
- If user clicks, opens dialog
- Dialog will display list of comments, format like this:

```javascript

{

  "comments": [

    {

      "id": "IC_kwDOO7s21M7XAc3c",

      "author": {

        "login": "LSRCT"

      },

      "authorAssociation": "MEMBER",

      "body": "Hi! I tried to get claude to use the `AskUserQuestion` tool in vibe kanban but did not manage, it does not seem to have access to the tool at all. Could you give me a hint on how to best test this PR?",

      "createdAt": "2025-12-03T14:46:49Z",

      "includesCreatedEdit": false,

      "isMinimized": false,

      "minimizedReason": "",

      "reactionGroups": [],

      "url": "https://github.com/BloopAI/vibe-kanban/pull/1395#issuecomment-3607219676",

      "viewerDidAuthor": false

    },

    {

      "id": "IC_kwDOO7s21M7Xc1Pi",

      "author": {

        "login": "davidrudduck"

      },

      "authorAssociation": "NONE",

      "body": "> Hi! I tried to get claude to use the `AskUserQuestion` tool in vibe kanban but did not manage, it does not seem to have access to the tool at all. Could you give me a hint on how to best test this PR?\r\n\r\nI must have been having a daft night when I submitted this - am fixing this at the moment.",

      "createdAt": "2025-12-04T22:57:18Z",

      "includesCreatedEdit": false,

      "isMinimized": false,

      "minimizedReason": "",

      "reactionGroups": [],

      "url": "https://github.com/BloopAI/vibe-kanban/pull/1395#issuecomment-3614659554",

      "viewerDidAuthor": false

    }

  ]

}

```

The user will select a comment, this will then be added as a component to the chat.

The component should be fully serializable/deserializable as markdown <> WYSIWYG, just like we do for images atm.

The backend will be implemented separately so just hardcode a mock response in the frontend for now.

* PR 1449 failed because of i18n regressions (vibe-kanban 723e309c)

Please resolve the 7 hardcoded strings this PR introduces https://github.com/BloopAI/vibe-kanban/pull/1449

* PR 1449 failed because of i18n regressions (vibe-kanban 723e309c)

Please resolve the 7 hardcoded strings this PR introduces https://github.com/BloopAI/vibe-kanban/pull/1449
This commit is contained in:
Louis Knight-Webb
2025-12-06 19:26:29 +00:00
committed by GitHub
parent 86705f9c8e
commit 9107b4b384
19 changed files with 1465 additions and 164 deletions

View File

@@ -116,6 +116,10 @@ fn generate_types_content() -> String {
server::routes::task_attempts::CreatePrError::decl(),
server::routes::task_attempts::BranchStatus::decl(),
server::routes::task_attempts::RunScriptError::decl(),
server::routes::task_attempts::AttachPrResponse::decl(),
server::routes::task_attempts::PrCommentsResponse::decl(),
server::routes::task_attempts::GetPrCommentsError::decl(),
services::services::github::UnifiedPrComment::decl(),
services::services::filesystem::DirectoryEntry::decl(),
services::services::filesystem::DirectoryListResponse::decl(),
services::services::config::Config::decl(),

View File

@@ -39,7 +39,7 @@ use serde::{Deserialize, Serialize};
use services::services::{
container::ContainerService,
git::{ConflictOp, GitCliError, GitServiceError, WorktreeResetOptions},
github::{CreatePrRequest, GitHubService, GitHubServiceError},
github::{CreatePrRequest, GitHubService, GitHubServiceError, UnifiedPrComment},
};
use sqlx::Error as SqlxError;
use ts_rs::TS;
@@ -1411,6 +1411,20 @@ pub struct AttachPrResponse {
pub pr_status: Option<MergeStatus>,
}
#[derive(Debug, Serialize, TS)]
pub struct PrCommentsResponse {
pub comments: Vec<UnifiedPrComment>,
}
#[derive(Debug, Serialize, Deserialize, TS)]
#[serde(tag = "type", rename_all = "snake_case")]
#[ts(tag = "type", rename_all = "snake_case")]
pub enum GetPrCommentsError {
NoPrAttached,
GithubCliNotInstalled,
GithubCliNotLoggedIn,
}
pub async fn attach_existing_pr(
Extension(task_attempt): Extension<TaskAttempt>,
State(deployment): State<DeploymentImpl>,
@@ -1507,6 +1521,67 @@ pub async fn attach_existing_pr(
}
}
pub async fn get_pr_comments(
Extension(task_attempt): Extension<TaskAttempt>,
State(deployment): State<DeploymentImpl>,
) -> Result<ResponseJson<ApiResponse<PrCommentsResponse, GetPrCommentsError>>, ApiError> {
let pool = &deployment.db().pool;
// Find the latest merge for this task attempt
let merge = Merge::find_latest_by_task_attempt_id(pool, task_attempt.id).await?;
// Ensure there's an attached PR
let pr_info = match merge {
Some(Merge::Pr(pr_merge)) => pr_merge.pr_info,
_ => {
return Ok(ResponseJson(ApiResponse::error_with_data(
GetPrCommentsError::NoPrAttached,
)));
}
};
// Get project and repo info
let task = task_attempt
.parent_task(pool)
.await?
.ok_or(ApiError::TaskAttempt(TaskAttemptError::TaskNotFound))?;
let project = Project::find_by_id(pool, task.project_id)
.await?
.ok_or(ApiError::Project(ProjectError::ProjectNotFound))?;
let github_service = GitHubService::new()?;
let repo_info = deployment
.git()
.get_github_repo_info(&project.git_repo_path)?;
// Fetch comments from GitHub
match github_service
.get_pr_comments(&repo_info, pr_info.number)
.await
{
Ok(comments) => Ok(ResponseJson(ApiResponse::success(PrCommentsResponse {
comments,
}))),
Err(e) => {
tracing::error!(
"Failed to fetch PR comments for attempt {}, PR #{}: {}",
task_attempt.id,
pr_info.number,
e
);
match &e {
GitHubServiceError::GhCliNotInstalled(_) => Ok(ResponseJson(
ApiResponse::error_with_data(GetPrCommentsError::GithubCliNotInstalled),
)),
GitHubServiceError::AuthFailed(_) => Ok(ResponseJson(
ApiResponse::error_with_data(GetPrCommentsError::GithubCliNotLoggedIn),
)),
_ => Err(ApiError::GitHubService(e)),
}
}
}
}
#[derive(Debug, Serialize, Deserialize, TS)]
#[serde(tag = "type", rename_all = "snake_case")]
#[ts(tag = "type", rename_all = "snake_case")]
@@ -1713,6 +1788,7 @@ pub fn router(deployment: &DeploymentImpl) -> Router<DeploymentImpl> {
.route("/conflicts/abort", post(abort_conflicts_task_attempt))
.route("/pr", post(create_github_pr))
.route("/pr/attach", post(attach_existing_pr))
.route("/pr/comments", get(get_pr_comments))
.route("/open-editor", post(open_task_attempt_in_editor))
.route("/children", get(get_task_attempt_children))
.route("/stop", post(stop_task_attempt_execution))

View File

@@ -1,15 +1,56 @@
use std::time::Duration;
use backon::{ExponentialBuilder, Retryable};
use chrono::{DateTime, Utc};
use db::models::merge::PullRequestInfo;
use regex::Regex;
use serde::Serialize;
use thiserror::Error;
use tokio::task;
use tracing::info;
use ts_rs::TS;
mod cli;
use cli::{GhCli, GhCliError};
use cli::{GhCli, GhCliError, PrComment, PrReviewComment};
pub use cli::{PrCommentAuthor, ReviewCommentUser};
/// Unified PR comment that can be either a general comment or review comment
#[derive(Debug, Clone, Serialize, TS)]
#[serde(tag = "comment_type", rename_all = "snake_case")]
#[ts(tag = "comment_type", rename_all = "snake_case")]
pub enum UnifiedPrComment {
/// General PR comment (conversation)
General {
id: String,
author: String,
author_association: String,
body: String,
created_at: DateTime<Utc>,
url: String,
},
/// Inline review comment (on code)
Review {
id: i64,
author: String,
author_association: String,
body: String,
created_at: DateTime<Utc>,
url: String,
path: String,
line: Option<i64>,
diff_hunk: String,
},
}
impl UnifiedPrComment {
fn created_at(&self) -> DateTime<Utc> {
match self {
UnifiedPrComment::General { created_at, .. } => *created_at,
UnifiedPrComment::Review { created_at, .. } => *created_at,
}
}
}
#[derive(Debug, Error)]
pub enum GitHubServiceError {
@@ -279,4 +320,133 @@ impl GitHubService {
})
.await
}
/// Fetch all comments (both general and review) for a pull request
pub async fn get_pr_comments(
&self,
repo_info: &GitHubRepoInfo,
pr_number: i64,
) -> Result<Vec<UnifiedPrComment>, GitHubServiceError> {
// Fetch both types of comments in parallel
let (general_result, review_result) = tokio::join!(
self.fetch_general_comments(repo_info, pr_number),
self.fetch_review_comments(repo_info, pr_number)
);
let general_comments = general_result?;
let review_comments = review_result?;
// Convert and merge into unified timeline
let mut unified: Vec<UnifiedPrComment> = Vec::new();
for c in general_comments {
unified.push(UnifiedPrComment::General {
id: c.id,
author: c.author.login,
author_association: c.author_association,
body: c.body,
created_at: c.created_at,
url: c.url,
});
}
for c in review_comments {
unified.push(UnifiedPrComment::Review {
id: c.id,
author: c.user.login,
author_association: c.author_association,
body: c.body,
created_at: c.created_at,
url: c.html_url,
path: c.path,
line: c.line,
diff_hunk: c.diff_hunk,
});
}
// Sort by creation time
unified.sort_by_key(|c| c.created_at());
Ok(unified)
}
async fn fetch_general_comments(
&self,
repo_info: &GitHubRepoInfo,
pr_number: i64,
) -> Result<Vec<PrComment>, GitHubServiceError> {
(|| async {
let owner = repo_info.owner.clone();
let repo = repo_info.repo_name.clone();
let cli = self.gh_cli.clone();
let comments = task::spawn_blocking({
let owner = owner.clone();
let repo = repo.clone();
move || cli.get_pr_comments(&owner, &repo, pr_number)
})
.await
.map_err(|err| {
GitHubServiceError::PullRequest(format!(
"Failed to execute GitHub CLI for fetching PR #{pr_number} comments: {err}"
))
})?;
comments.map_err(GitHubServiceError::from)
})
.retry(
&ExponentialBuilder::default()
.with_min_delay(Duration::from_secs(1))
.with_max_delay(Duration::from_secs(30))
.with_max_times(3)
.with_jitter(),
)
.when(|e: &GitHubServiceError| e.should_retry())
.notify(|err: &GitHubServiceError, dur: Duration| {
tracing::warn!(
"GitHub API call failed, retrying after {:.2}s: {}",
dur.as_secs_f64(),
err
);
})
.await
}
async fn fetch_review_comments(
&self,
repo_info: &GitHubRepoInfo,
pr_number: i64,
) -> Result<Vec<PrReviewComment>, GitHubServiceError> {
(|| async {
let owner = repo_info.owner.clone();
let repo = repo_info.repo_name.clone();
let cli = self.gh_cli.clone();
let comments = task::spawn_blocking({
let owner = owner.clone();
let repo = repo.clone();
move || cli.get_pr_review_comments(&owner, &repo, pr_number)
})
.await
.map_err(|err| {
GitHubServiceError::PullRequest(format!(
"Failed to execute GitHub CLI for fetching PR #{pr_number} review comments: {err}"
))
})?;
comments.map_err(GitHubServiceError::from)
})
.retry(
&ExponentialBuilder::default()
.with_min_delay(Duration::from_secs(1))
.with_max_delay(Duration::from_secs(30))
.with_max_times(3)
.with_jitter(),
)
.when(|e: &GitHubServiceError| e.should_retry())
.notify(|err: &GitHubServiceError, dur: Duration| {
tracing::warn!(
"GitHub API call failed, retrying after {:.2}s: {}",
dur.as_secs_f64(),
err
);
})
.await
}
}

View File

@@ -11,12 +11,53 @@ use std::{
use chrono::{DateTime, Utc};
use db::models::merge::{MergeStatus, PullRequestInfo};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use thiserror::Error;
use ts_rs::TS;
use utils::shell::resolve_executable_path_blocking;
use crate::services::github::{CreatePrRequest, GitHubRepoInfo};
/// Author information for a PR comment
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
pub struct PrCommentAuthor {
pub login: String,
}
/// A single comment on a GitHub PR
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
pub struct PrComment {
pub id: String,
pub author: PrCommentAuthor,
pub author_association: String,
pub body: String,
pub created_at: DateTime<Utc>,
pub url: String,
}
/// User information for a review comment (from API response)
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
pub struct ReviewCommentUser {
pub login: String,
}
/// An inline review comment on a GitHub PR (from gh api)
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
pub struct PrReviewComment {
pub id: i64,
pub user: ReviewCommentUser,
pub body: String,
pub created_at: DateTime<Utc>,
pub html_url: String,
pub path: String,
pub line: Option<i64>,
pub side: Option<String>,
pub diff_hunk: String,
pub author_association: String,
}
/// High-level errors originating from the GitHub CLI.
#[derive(Debug, Error)]
pub enum GhCliError {
@@ -167,6 +208,39 @@ impl GhCli {
])?;
Self::parse_pr_list(&raw)
}
/// Fetch comments for a pull request.
pub fn get_pr_comments(
&self,
owner: &str,
repo: &str,
pr_number: i64,
) -> Result<Vec<PrComment>, GhCliError> {
let raw = self.run([
"pr",
"view",
&pr_number.to_string(),
"--repo",
&format!("{owner}/{repo}"),
"--json",
"comments",
])?;
Self::parse_pr_comments(&raw)
}
/// Fetch inline review comments for a pull request via API.
pub fn get_pr_review_comments(
&self,
owner: &str,
repo: &str,
pr_number: i64,
) -> Result<Vec<PrReviewComment>, GhCliError> {
let raw = self.run([
"api",
&format!("repos/{owner}/{repo}/pulls/{pr_number}/comments"),
])?;
Self::parse_pr_review_comments(&raw)
}
}
impl GhCli {
@@ -243,6 +317,42 @@ impl GhCli {
.collect()
}
fn parse_pr_comments(raw: &str) -> Result<Vec<PrComment>, GhCliError> {
let value: Value = serde_json::from_str(raw.trim()).map_err(|err| {
GhCliError::UnexpectedOutput(format!(
"Failed to parse gh pr view --json comments response: {err}; raw: {raw}"
))
})?;
let comments_arr = value
.get("comments")
.and_then(|v| v.as_array())
.ok_or_else(|| {
GhCliError::UnexpectedOutput(format!(
"gh pr view --json comments response missing 'comments' array: {value:#?}"
))
})?;
comments_arr
.iter()
.map(|item| {
serde_json::from_value(item.clone()).map_err(|err| {
GhCliError::UnexpectedOutput(format!(
"Failed to parse PR comment: {err}; item: {item:#?}"
))
})
})
.collect()
}
fn parse_pr_review_comments(raw: &str) -> Result<Vec<PrReviewComment>, GhCliError> {
serde_json::from_str(raw.trim()).map_err(|err| {
GhCliError::UnexpectedOutput(format!(
"Failed to parse review comments API response: {err}; raw: {raw}"
))
})
}
fn extract_pr_info(value: &Value) -> Option<PullRequestInfo> {
let number = value.get("number")?.as_i64()?;
let url = value.get("url")?.as_str()?.to_string();