Skip to content

Commit e998334

Browse files
devlintLaurent Guitton
andauthored
feat(forge): close GitLab/Bitbucket gaps for v2.14 (#22)
Wires GitLab diff-line discussions, Bitbucket CI checks and draft→ready, plus updateComment/deleteComment on both providers. Makes conflict preview, hotspots, and file history forge-agnostic by routing through the resolved ForgeProvider instead of gh* calls. 🪄 Commit via GitWand Co-authored-by: Laurent Guitton <laurent.guitton@dendreo.com>
1 parent 3b64a8f commit e998334

29 files changed

Lines changed: 1472 additions & 913 deletions

CHANGELOG.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [2.14.0] - 2026-05-19
11+
12+
v2.14 closes the remaining `ForgeNotImplementedError` gaps on GitLab and Bitbucket and makes all three intelligence methods (conflict preview, hotspots, file history) forge-agnostic.
13+
14+
### Added
15+
16+
- **GitLab diff-line discussions**`createComment` now routes to the GitLab Discussions API (`POST /projects/:id/merge_requests/:iid/discussions`) when `path` + `line` are supplied, anchoring the comment to the exact diff line instead of posting a general MR note. New Rust command `gl_mr_create_discussion`; new TS wrapper `glMrCreateDiscussion` in `backend-gitlab.ts`.
17+
- **Bitbucket CI checks**`getCIChecks` wired to the Bitbucket Pipelines commit statuses endpoint (`/commit/{sha}/statuses`). The PR's `source.commit.hash` is extracted to form the URL, then each status is mapped to the common `CICheck` shape. New Rust command `bb_pr_ci_checks`; new TS wrapper `bbPrCiChecks`.
18+
- **Bitbucket draft → ready**`convertDraftToReady` strips the `"Draft: "` title prefix (case-insensitive) via a `PUT` PR update. New Rust command `bb_convert_draft_to_ready`; new TS wrapper `bbConvertDraftToReady`.
19+
- **`updateComment` / `deleteComment` on GitLab and Bitbucket** — both methods now fully wired: GitLab via `gl_mr_update_note` / `gl_mr_delete_note`, Bitbucket via `bb_update_comment` / `bb_delete_comment`. `prNumber` added as optional 4th parameter to the `ForgeProvider` interface (required at runtime for non-GitHub providers; ignored on GitHub where comment IDs are globally unique).
20+
- **`setAccount(account: Account | null): void`** — new method on `ForgeProvider` interface implemented by all three providers; stores an `Account` context in-memory to enable multi-workspace credential resolution (Bitbucket) and multi-profile switching (GitLab, GitHub) without re-authenticating.
21+
22+
### Changed
23+
24+
- **`getConflictPreview` / `getHotspots` — forge-agnostic** — both methods now delegate to the same local-git implementation (`ghPrConflictPreview` / `ghPrHotspots`) in `GitLabProvider` and `BitbucketProvider`. These methods run `git merge-tree` and `git log --merges` locally — no forge API required. The `forge.name === "github"` guard that was previously needed in consumers is no longer necessary.
25+
- **`PullRequestPanel.vue` — forge bypass fixed** — the panel now uses `forge.value.getConflictPreview`, `forge.value.getHotspots`, and `forge.value.getFileHistory` through the resolved `ForgeProvider` instead of calling `gh*` functions directly.
26+
27+
### Technical
28+
29+
- `ForgeNotImplementedError` import removed from `GitLabProvider` and `BitbucketProvider` — all stubs replaced; no remaining `throw new ForgeNotImplementedError(…)` in either provider.
30+
- `PrFileHistory` zeroed stubs corrected: `{ reviewCommentCount: 0, reviewers: [], lastComment: null }` — matches the actual interface shape.
31+
- `CreatePrCommentParams.diff_hunk` reference removed from `GitLabProvider.createComment``diff_hunk` is not part of the interface; condition simplified to `params.path && params.line != null`.
32+
- 128 tests green; TypeScript strict mode — 0 errors.
33+
1034
## [2.13.0] - 2026-05-18
1135

1236
v2.13 extends the AI commit workflow with named system-prompt presets and adds inline code suggestions directly in PR diffs.

ROADMAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -790,7 +790,7 @@ Deux extensions du workflow de review déjà en place.
790790

791791
---
792792

793-
### v2.14.0 — Forge completeness
793+
### v2.14.0 — Forge completeness
794794

795795
_Suite de v2.10 : combler les stubs `ForgeNotImplementedError` sur GitLab et Bitbucket, et ouvrir les trois méthodes d'intelligence forge-agnostiques._
796796

apps/desktop/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@gitwand/desktop",
3-
"version": "2.13.1",
3+
"version": "2.14.0",
44
"private": true,
55
"description": "GitWand Desktop — lightweight Git client with smart conflict resolution (Tauri + Vue 3)",
66
"type": "module",

apps/desktop/src-tauri/src/commands/bitbucket.rs

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -845,3 +845,99 @@ pub(crate) fn bb_reviewer_candidates(cwd: String) -> Result<Vec<ReviewerCandidat
845845
candidates.sort_by(|a, b| a.login.to_lowercase().cmp(&b.login.to_lowercase()));
846846
Ok(candidates)
847847
}
848+
849+
/// Get CI status checks for a PR via Bitbucket Pipelines commit statuses endpoint.
850+
///
851+
/// Endpoint: GET /2.0/repositories/{ws}/{slug}/commit/{sha}/statuses
852+
/// The head commit SHA is read from the PR's `source.commit.hash` field.
853+
#[tauri::command]
854+
pub(crate) fn bb_pr_ci_checks(cwd: String, pr_id: i64) -> Result<Vec<CICheck>, String> {
855+
let (workspace, slug) = parse_workspace_slug(&cwd)?;
856+
let (username, app_password) = get_bb_creds(&cwd)?;
857+
let auth = basic_auth_header(&username, &app_password);
858+
859+
// Step 1: get the PR to find the head commit SHA.
860+
let pr_url = format!("{}/pullrequests/{}", repo_api(&workspace, &slug), pr_id);
861+
let pr = bb_curl("GET", &pr_url, None, &auth)?;
862+
let head_sha = jdeep(&pr, "source", "commit", "hash");
863+
if head_sha.is_empty() {
864+
return Ok(Vec::new());
865+
}
866+
867+
// Step 2: fetch commit statuses.
868+
let url = format!(
869+
"https://api.bitbucket.org/2.0/repositories/{}/{}/commit/{}/statuses?pagelen=30",
870+
workspace, slug, head_sha
871+
);
872+
let resp = bb_curl("GET", &url, None, &auth).unwrap_or(serde_json::Value::Null);
873+
let values = resp
874+
.get("values")
875+
.and_then(|v| v.as_array())
876+
.cloned()
877+
.unwrap_or_default();
878+
879+
let checks: Vec<CICheck> = values
880+
.iter()
881+
.map(|s| {
882+
// Bitbucket status states: SUCCESSFUL, FAILED, INPROGRESS, STOPPED
883+
let bb_state = js(s, "state");
884+
let conclusion = match bb_state.as_str() {
885+
"SUCCESSFUL" => "success".to_string(),
886+
"FAILED" => "failure".to_string(),
887+
"STOPPED" => "cancelled".to_string(),
888+
_ => "pending".to_string(),
889+
};
890+
let status = if bb_state == "INPROGRESS" {
891+
"in_progress".to_string()
892+
} else {
893+
"completed".to_string()
894+
};
895+
CICheck {
896+
name: js(s, "name"),
897+
status,
898+
conclusion,
899+
url: {
900+
let u = jnested(s, "url", "href");
901+
if u.is_empty() { js(s, "url") } else { u }
902+
},
903+
}
904+
})
905+
.collect();
906+
907+
Ok(checks)
908+
}
909+
910+
/// Convert a "Draft: …" Bitbucket PR to ready-for-review by stripping the prefix.
911+
///
912+
/// Bitbucket has no native draft concept — the convention is a "Draft: " title
913+
/// prefix. This command strips it via a PUT update on the PR.
914+
#[tauri::command]
915+
pub(crate) fn bb_convert_draft_to_ready(cwd: String, pr_id: i64) -> Result<(), String> {
916+
let (workspace, slug) = parse_workspace_slug(&cwd)?;
917+
let (username, app_password) = get_bb_creds(&cwd)?;
918+
let auth = basic_auth_header(&username, &app_password);
919+
920+
// Step 1: get current title.
921+
let pr_url = format!("{}/pullrequests/{}", repo_api(&workspace, &slug), pr_id);
922+
let pr = bb_curl("GET", &pr_url, None, &auth)?;
923+
let title = js(&pr, "title");
924+
925+
// Step 2: strip "Draft: " prefix (case-insensitive).
926+
let ready_title = if title.to_lowercase().starts_with("draft: ") {
927+
title[7..].to_string()
928+
} else if title.to_lowercase().starts_with("draft:") {
929+
title[6..].trim_start().to_string()
930+
} else {
931+
// Already not a draft — nothing to do.
932+
return Ok(());
933+
};
934+
935+
if ready_title.is_empty() {
936+
return Err("Cannot remove 'Draft:' prefix — resulting title would be empty.".to_string());
937+
}
938+
939+
// Step 3: PATCH the title via PUT (Bitbucket uses PUT for PR updates).
940+
let body = serde_json::json!({ "title": ready_title }).to_string();
941+
bb_curl("PUT", &pr_url, Some(&body), &auth)?;
942+
Ok(())
943+
}

apps/desktop/src-tauri/src/commands/gitlab.rs

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -758,3 +758,72 @@ pub(crate) fn gl_mr_files(cwd: String, iid: i64) -> Result<Vec<String>, String>
758758
.filter_map(|d| d.get("new_path").and_then(|v| v.as_str()).map(String::from))
759759
.collect())
760760
}
761+
762+
/// Create a diff-line anchored discussion on a MR via the GitLab Discussions API.
763+
///
764+
/// This provides parité with GitHub's inline review comment anchoring.
765+
/// When `path`, `head_sha`, `base_sha`, and `start_sha` are all non-empty, a
766+
/// position object is included so the discussion is anchored to the diff line.
767+
/// When they are empty, falls back to a general MR note.
768+
///
769+
/// GitLab Discussions API:
770+
/// POST /projects/:fullpath/merge_requests/:iid/discussions
771+
/// Body: { body, position: { base_sha, start_sha, head_sha, position_type,
772+
/// new_path, new_line, old_path, old_line } }
773+
#[tauri::command]
774+
pub(crate) fn gl_mr_create_discussion(
775+
cwd: String,
776+
iid: i64,
777+
body: String,
778+
base_sha: String,
779+
start_sha: String,
780+
head_sha: String,
781+
old_line: Option<i64>,
782+
new_line: Option<i64>,
783+
path: String,
784+
) -> Result<serde_json::Value, String> {
785+
let endpoint = format!("projects/:fullpath/merge_requests/{}/discussions", iid);
786+
787+
// Build args for `glab api -X POST`.
788+
let mut args: Vec<String> = vec![
789+
"api".to_string(),
790+
"-X".to_string(), "POST".to_string(),
791+
endpoint.clone(),
792+
"-f".to_string(), format!("body={}", body),
793+
];
794+
795+
// Attach diff-line position when we have enough context.
796+
let has_position = !base_sha.is_empty() && !head_sha.is_empty() && !path.is_empty();
797+
if has_position {
798+
args.extend([
799+
"-f".to_string(), format!("position[base_sha]={}", base_sha),
800+
"-f".to_string(), format!("position[start_sha]={}", start_sha),
801+
"-f".to_string(), format!("position[head_sha]={}", head_sha),
802+
"-f".to_string(), "position[position_type]=text".to_string(),
803+
"-f".to_string(), format!("position[new_path]={}", path),
804+
"-f".to_string(), format!("position[old_path]={}", path),
805+
]);
806+
if let Some(nl) = new_line {
807+
args.extend(["-f".to_string(), format!("position[new_line]={}", nl)]);
808+
}
809+
if let Some(ol) = old_line {
810+
args.extend(["-f".to_string(), format!("position[old_line]={}", ol)]);
811+
}
812+
}
813+
814+
let output = hidden_cmd("glab")
815+
.args(&args)
816+
.current_dir(&cwd)
817+
.output()
818+
.map_err(|e| format!("glab api create discussion: {}", e))?;
819+
820+
if !output.status.success() {
821+
return Err(format!(
822+
"gl create discussion failed: {}",
823+
String::from_utf8_lossy(&output.stderr)
824+
));
825+
}
826+
827+
let stdout = String::from_utf8_lossy(&output.stdout);
828+
serde_json::from_str(stdout.trim()).map_err(|e| format!("Parse discussion: {}", e))
829+
}

apps/desktop/src-tauri/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,7 @@ pub fn run() {
375375
commands::gitlab::gl_current_user,
376376
commands::gitlab::gl_reviewer_candidates,
377377
commands::gitlab::gl_mr_files,
378+
commands::gitlab::gl_mr_create_discussion,
378379
// ── Credentials (OS keychain) ──
379380
commands::credentials::set_credential,
380381
commands::credentials::get_credential,
@@ -395,6 +396,8 @@ pub fn run() {
395396
commands::bitbucket::bb_pr_files,
396397
commands::bitbucket::bb_current_user,
397398
commands::bitbucket::bb_reviewer_candidates,
399+
commands::bitbucket::bb_pr_ci_checks,
400+
commands::bitbucket::bb_convert_draft_to_ready,
398401
// ── MCP catalog ──
399402
commands::mcp_catalog::mcp_detect_configs,
400403
commands::mcp_catalog::mcp_read_config,

apps/desktop/src-tauri/tauri.conf.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json",
33
"productName": "GitWand",
4-
"version": "2.13.1",
4+
"version": "2.14.0",
55
"identifier": "com.gitwand.desktop",
66
"build": {
77
"frontendDist": "../dist",

apps/desktop/src/components/BaseModal.vue

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -294,10 +294,23 @@ onUnmounted(() => {
294294
}
295295
</style>
296296

297-
<!-- Non-scoped helpers: canonical button styles usable from modal footer slots.
298-
Kept at (0,1,0) specificity so modifier classes (--primary / --danger)
299-
can win over the base style without needing !important. -->
297+
<!-- Non-scoped helpers: canonical button styles usable from modal footer slots
298+
and title-icon slots (slot content can't reliably receive scoped attributes).
299+
Kept at (0,1,0) specificity so modifier classes can win without !important. -->
300300
<style>
301+
/* ── Title icon pill ── */
302+
.bm-title-icon {
303+
display: inline-flex;
304+
align-items: center;
305+
justify-content: center;
306+
width: 36px;
307+
height: 36px;
308+
border-radius: var(--radius-pill);
309+
background: var(--color-accent-soft, rgba(124, 58, 237, 0.14));
310+
color: var(--color-accent);
311+
flex-shrink: 0;
312+
}
313+
301314
.bm-btn {
302315
display: inline-flex;
303316
align-items: center;

apps/desktop/src/components/PullRequestPanel.vue

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,7 @@ import {
1515
ghPrDeleteComment,
1616
ghPrListReviews,
1717
ghPrSubmitReview,
18-
ghPrConflictPreview,
19-
ghPrHotspots,
2018
gitFileCount,
21-
ghPrFileHistory,
2219
gitRemoteInfo,
2320
type PullRequest,
2421
type PullRequestDetail,
@@ -38,6 +35,7 @@ import {
3835
import PrInlineDiff from "./PrInlineDiff.vue";
3936
import PrReviewModal from "./PrReviewModal.vue";
4037
import PrIntelligencePanel from "./PrIntelligencePanel.vue";
38+
import { forgeFromRemoteInfo, githubProvider } from "../composables/forge/useForge";
4139
import type { DiffMode } from "../utils/diffMode";
4240
import { getPersistedDiffMode } from "../utils/diffMode";
4341
@@ -133,6 +131,11 @@ const mergeReadiness = computed<{ ready: boolean; reason: string } | null>(() =>
133131
return { ready: false, reason: `En attente de : ${reasons.join(", ")}.` };
134132
});
135133
134+
/** Active ForgeProvider — derived from remote once loaded, github as fallback. */
135+
const forge = computed(() =>
136+
remote.value ? forgeFromRemoteInfo(remote.value) : githubProvider,
137+
);
138+
136139
const isGitHub = computed(() => remote.value?.provider === "github");
137140
const hasDetail = computed(() => !!selectedPr.value);
138141
@@ -399,7 +402,7 @@ async function loadConflictPreview() {
399402
conflictLoading.value = true;
400403
conflictError.value = null;
401404
try {
402-
conflictPreview.value = await ghPrConflictPreview(props.cwd, selectedPr.value.number);
405+
conflictPreview.value = await forge.value.getConflictPreview(props.cwd, selectedPr.value.number);
403406
} catch (err: any) {
404407
conflictError.value = err.message;
405408
} finally {
@@ -412,7 +415,7 @@ async function loadHotspots() {
412415
hotspotsLoading.value = true;
413416
try {
414417
const paths = prDiffFiles.value.map((f) => f.path);
415-
hotspots.value = await ghPrHotspots(props.cwd, paths);
418+
hotspots.value = await forge.value.getHotspots(props.cwd, paths);
416419
} catch { /* silent */ } finally {
417420
hotspotsLoading.value = false;
418421
}
@@ -423,7 +426,7 @@ async function loadFileHistory() {
423426
fileHistoryLoading.value = true;
424427
try {
425428
const paths = prDiffFiles.value.map((f) => f.path);
426-
fileHistory.value = await ghPrFileHistory(props.cwd, paths);
429+
fileHistory.value = await forge.value.getFileHistory(props.cwd, paths);
427430
} catch { /* silent */ } finally {
428431
fileHistoryLoading.value = false;
429432
}

0 commit comments

Comments
 (0)