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:
committed by
GitHub
parent
86705f9c8e
commit
9107b4b384
@@ -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(),
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user