Skip to content

Commit acb97a3

Browse files
committed
Feature: in-app Git integration with GitHub + GitLab (Talend-style)
A Git panel inside Duckle for the user's workspace folder - the user never leaves the app for routine push / pull / merge / branch work. Plus a CI build-status badge in the topbar that polls GitHub Actions or GitLab CI for the latest pipeline on the current branch. Backend (Rust, apps/desktop/src/): - workspace_git.rs: wraps the system git CLI. status (branch + ahead/behind + file list), init, add_all, commit, push, pull, branches, branch_create, branch_checkout, remote_set. PAT storage at <workspace>/.duckle/secrets/git.json with auto-written .duckle/.gitignore so the secret never enters the repo. Push auth follows the user's preference: try without explicit creds (system credential helper - GitHub CLI, osxkeychain, manager-core), on 401 surface AUTH_REQUIRED to the frontend, prompt for PAT, retry with the token injected as https://x-token-auth:TOKEN@host/... - ci_status.rs: detect provider from the remote URL (github / gitlab / other) and poll the latest pipeline status for the current branch. GitHub: GET /repos/{owner}/{repo}/actions/runs. GitLab: GET /api/v4/projects/{url-encoded-path}/pipelines. Uses the same PAT for auth. Returns success / failure / in_progress / pending / cancelled / none / unknown. - 12 new Tauri commands wiring these to the frontend. - 9 new unit tests covering: provider detection from URL, status parser for clean/ahead/behind/changed-files, PAT injection into HTTPS remote URLs (SSH untouched, existing user:pass stripped), auth-failure stderr classifier, GitHub slug parser, GitLab nested-namespace parser, URL encoding. Frontend (React): - GitPanel.tsx: sidebar panel opened from a GitBranch icon in the topbar. Headed by current branch, counters for changed / ahead / behind. Sections: Remote (set if missing), Changes (file list with mod/staged/untracked badges), Commit (textarea + stage-all CTA), Sync (Push + Pull buttons; Push disabled when nothing ahead), Branches (clickable list + create), Auth (PAT input with a 'create one' deep link to the right vendor's token page). - CiStatusBadge.tsx: tiny topbar badge that polls every 30 s. Green check / red X / yellow spinner / gray. Click to open the build in a browser. Hidden when there's no workspace or remote. - Wired into App.tsx topbar (Git icon + badge), modal overlay slot. - Themed using --bg-1 / --text-* / --accent / --border tokens so both light + dark modes work without per-mode overrides. README: - New 'Git integration (GitHub + GitLab)' section under Quick Links with feature table + workflow diagram + provider-detection table. - TOC updated to surface the new section.
1 parent 14fea20 commit acb97a3

9 files changed

Lines changed: 2082 additions & 2 deletions

File tree

README.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
- [Meet Duckie (AI)](#meet-duckie---the-local-ai-pipeline-assistant)
4545
- [How to use Duckle](#how-to-use-duckle)
4646
- [Recipes / examples](#recipes-and-examples)
47+
- [In-app Git (GitHub/GitLab)](#git-integration-github--gitlab)
4748
- [Workspace + Git flow](#workspace-and-git-flow)
4849
- [Schedules](#schedules-and-triggers)
4950
- [Connection management](#connection-management)
@@ -545,6 +546,43 @@ More examples live in [`samples/`](samples) - drop the pipeline files into a wor
545546

546547
---
547548

549+
## Git integration (GitHub + GitLab)
550+
551+
> Push, pull, branch, and watch CI from inside Duckle. No terminal required.
552+
553+
Click the **Git icon** in the topbar to open the workspace Git panel. Talend-style integration with GitHub and GitLab, built on the system `git` CLI (no FFI, no embedded git library):
554+
555+
| Feature | What it does |
556+
|---|---|
557+
| **Status snapshot** | Current branch, ahead/behind counts, list of modified / staged / untracked / conflicted files |
558+
| **Stage all + commit** | One-click `git add -A && git commit -m "..."` with your message |
559+
| **Push / Pull** | `git push` and `git pull --ff-only` against `origin`. The button stays disabled when there's nothing to push |
560+
| **Branch list, switch, create** | Lists local branches; click to switch; create new branches inline |
561+
| **Remote URL config** | Add or change `origin` URL from inside the panel - auto-detects GitHub vs GitLab from the host |
562+
| **PAT-prompt fallback** | First tries `git push` using your system credential helper (GitHub CLI, osxkeychain, manager-core). On a 401, prompts for a Personal Access Token, saves it AES-encrypted in `<workspace>/.duckle/secrets/git.json` (auto-gitignored), retries with the token injected into the HTTPS URL |
563+
| **CI build badge in topbar** | Polls GitHub Actions or GitLab CI every 30 s for the latest pipeline on your current branch. Shows green / red / yellow / gray. Click to open the build in your browser |
564+
565+
**Workflow.** Workspaces are plain folders (see [Workspace and Git flow](#workspace-and-git-flow)) - any standard Git workflow works:
566+
567+
```
568+
Create / clone -> open in Duckle -> edit pipelines -> commit + push ->
569+
PR / MR -> CI runs your pipeline tests -> merge -> pull
570+
```
571+
572+
You can do the entire push / pull / merge loop without leaving Duckle. Heavy operations (interactive rebase, conflict resolution, log archaeology) still live in your terminal or external Git tool - the panel is designed for the everyday flow, not as a full Git replacement.
573+
574+
**Provider detection.** The remote URL host determines which CI API the badge polls:
575+
576+
| Provider | CI source | API |
577+
|---|---|---|
578+
| `github.com` | GitHub Actions | `GET /repos/{owner}/{repo}/actions/runs` |
579+
| `gitlab.com` or self-hosted GitLab | GitLab CI | `GET /api/v4/projects/{id}/pipelines` |
580+
| Other / bitbucket | (no CI badge for now) | - |
581+
582+
The badge uses the same PAT you saved for pushes - no separate auth step.
583+
584+
---
585+
548586
## Workspace and Git flow
549587

550588
A workspace is a folder you pick on first launch. Everything you build lives there as plain text:

apps/desktop/src/ci_status.rs

Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
//! CI build-status poller for the workspace's configured remote.
2+
//!
3+
//! Reads the latest build for the current branch from GitHub Actions
4+
//! or GitLab CI (auto-detected from the remote URL) and returns a
5+
//! tiny status struct the frontend turns into a topbar badge.
6+
//!
7+
//! Auth uses the same PAT the user saved for pushes (see
8+
//! `workspace_git::load_pat`). Without a PAT we still try the public
9+
//! API - works for public repos, 404s / 401s for private ones.
10+
11+
use serde::Serialize;
12+
use std::path::Path;
13+
use std::time::Duration;
14+
15+
#[derive(Debug, Clone, Serialize)]
16+
pub struct CiStatus {
17+
/// "github", "gitlab", "unknown".
18+
pub provider: String,
19+
/// "success", "failure", "in_progress", "pending", "cancelled", "none", "unknown".
20+
pub state: String,
21+
/// One-line summary the badge tooltip shows.
22+
pub label: String,
23+
/// Browser URL for the build (open-in-browser link on click).
24+
pub url: Option<String>,
25+
/// Commit SHA the build is for (short form).
26+
pub sha: Option<String>,
27+
}
28+
29+
impl CiStatus {
30+
fn none(provider: &str) -> Self {
31+
CiStatus {
32+
provider: provider.into(),
33+
state: "none".into(),
34+
label: "No builds yet".into(),
35+
url: None,
36+
sha: None,
37+
}
38+
}
39+
fn unknown(provider: &str, msg: impl Into<String>) -> Self {
40+
CiStatus {
41+
provider: provider.into(),
42+
state: "unknown".into(),
43+
label: msg.into(),
44+
url: None,
45+
sha: None,
46+
}
47+
}
48+
}
49+
50+
/// Fetch latest build status for the current workspace branch.
51+
/// Resolves the remote + branch from the workspace's git config, then
52+
/// dispatches to the GitHub or GitLab path based on the remote host.
53+
pub fn poll(workspace: &Path) -> Result<CiStatus, String> {
54+
use crate::workspace_git;
55+
let st = workspace_git::status(workspace)?;
56+
let Some(remote) = st.remote else {
57+
return Ok(CiStatus::none("unknown"));
58+
};
59+
let branch = st.branch.as_deref().unwrap_or("main");
60+
let token = workspace_git::load_pat(workspace).ok();
61+
match remote.provider.as_str() {
62+
"github" => poll_github(&remote.url, branch, token.as_deref()),
63+
"gitlab" => poll_gitlab(&remote.url, branch, token.as_deref()),
64+
other => Ok(CiStatus::unknown(other, "CI status not supported for this provider")),
65+
}
66+
}
67+
68+
/// `owner/repo` from a GitHub URL. Handles both:
69+
/// https://github.com/owner/repo(.git)?
70+
/// git@github.com:owner/repo.git
71+
fn parse_github_slug(url: &str) -> Option<String> {
72+
let after = if let Some(s) = url.strip_prefix("https://github.com/") {
73+
s
74+
} else if let Some(s) = url.strip_prefix("git@github.com:") {
75+
s
76+
} else {
77+
return None;
78+
};
79+
let cleaned = after.trim_end_matches('/').trim_end_matches(".git");
80+
let parts: Vec<&str> = cleaned.split('/').collect();
81+
if parts.len() < 2 {
82+
return None;
83+
}
84+
Some(format!("{}/{}", parts[0], parts[1]))
85+
}
86+
87+
/// `host/path` for GitLab. The project path is URL-encoded into the
88+
/// API call so nested-namespace projects (`group/sub/repo`) work.
89+
fn parse_gitlab(url: &str) -> Option<(String, String)> {
90+
// https://gitlab.com/group/sub/repo(.git)?
91+
// git@gitlab.com:group/sub/repo.git
92+
let (host, path) = if let Some(rest) = url.strip_prefix("https://") {
93+
let slash = rest.find('/')?;
94+
(rest[..slash].to_string(), rest[slash + 1..].to_string())
95+
} else if let Some(rest) = url.strip_prefix("git@") {
96+
let colon = rest.find(':')?;
97+
(rest[..colon].to_string(), rest[colon + 1..].to_string())
98+
} else {
99+
return None;
100+
};
101+
let clean_path = path.trim_end_matches('/').trim_end_matches(".git").to_string();
102+
Some((host, clean_path))
103+
}
104+
105+
fn poll_github(remote_url: &str, branch: &str, token: Option<&str>) -> Result<CiStatus, String> {
106+
let slug = parse_github_slug(remote_url).ok_or_else(|| "couldn't parse GitHub URL".to_string())?;
107+
let api = format!(
108+
"https://api.github.com/repos/{}/actions/runs?branch={}&per_page=1",
109+
slug,
110+
urlencoding_minimal(branch)
111+
);
112+
let mut req = ureq::get(&api)
113+
.set("User-Agent", "duckle-app")
114+
.set("Accept", "application/vnd.github+json")
115+
.timeout(Duration::from_secs(8));
116+
if let Some(t) = token {
117+
req = req.set("Authorization", &format!("Bearer {}", t));
118+
}
119+
let resp = req.call();
120+
let body: serde_json::Value = match resp {
121+
Ok(r) => r.into_json().map_err(|e| format!("github parse: {}", e))?,
122+
Err(ureq::Error::Status(404, _)) => return Ok(CiStatus::none("github")),
123+
Err(ureq::Error::Status(401, _)) | Err(ureq::Error::Status(403, _)) => {
124+
return Ok(CiStatus::unknown(
125+
"github",
126+
"Auth required - save a PAT in the Git panel",
127+
));
128+
}
129+
Err(e) => return Err(format!("github transport: {}", e)),
130+
};
131+
let run = body
132+
.pointer("/workflow_runs/0")
133+
.ok_or_else(|| "no workflow_runs in response".to_string())?;
134+
let status = run
135+
.pointer("/status")
136+
.and_then(|v| v.as_str())
137+
.unwrap_or("");
138+
let conclusion = run
139+
.pointer("/conclusion")
140+
.and_then(|v| v.as_str())
141+
.unwrap_or("");
142+
let state = match (status, conclusion) {
143+
("completed", "success") => "success",
144+
("completed", "failure") => "failure",
145+
("completed", "cancelled") => "cancelled",
146+
("completed", _) => "failure",
147+
("in_progress", _) => "in_progress",
148+
("queued", _) | ("requested", _) | ("waiting", _) => "pending",
149+
_ => "unknown",
150+
};
151+
let url = run
152+
.pointer("/html_url")
153+
.and_then(|v| v.as_str())
154+
.map(String::from);
155+
let sha = run
156+
.pointer("/head_sha")
157+
.and_then(|v| v.as_str())
158+
.map(|s| s[..s.len().min(7)].to_string());
159+
let label = format!(
160+
"GitHub Actions: {} ({})",
161+
state,
162+
run.pointer("/name").and_then(|v| v.as_str()).unwrap_or("workflow")
163+
);
164+
Ok(CiStatus {
165+
provider: "github".into(),
166+
state: state.into(),
167+
label,
168+
url,
169+
sha,
170+
})
171+
}
172+
173+
fn poll_gitlab(remote_url: &str, branch: &str, token: Option<&str>) -> Result<CiStatus, String> {
174+
let (host, path) = parse_gitlab(remote_url).ok_or_else(|| "couldn't parse GitLab URL".to_string())?;
175+
let project_id = urlencoding_full(&path);
176+
let api = format!(
177+
"https://{}/api/v4/projects/{}/pipelines?ref={}&per_page=1",
178+
host,
179+
project_id,
180+
urlencoding_minimal(branch)
181+
);
182+
let mut req = ureq::get(&api)
183+
.set("User-Agent", "duckle-app")
184+
.timeout(Duration::from_secs(8));
185+
if let Some(t) = token {
186+
// GitLab uses PRIVATE-TOKEN header (not Bearer).
187+
req = req.set("PRIVATE-TOKEN", t);
188+
}
189+
let resp = req.call();
190+
let body: serde_json::Value = match resp {
191+
Ok(r) => r.into_json().map_err(|e| format!("gitlab parse: {}", e))?,
192+
Err(ureq::Error::Status(404, _)) => return Ok(CiStatus::none("gitlab")),
193+
Err(ureq::Error::Status(401, _)) | Err(ureq::Error::Status(403, _)) => {
194+
return Ok(CiStatus::unknown(
195+
"gitlab",
196+
"Auth required - save a PAT in the Git panel",
197+
));
198+
}
199+
Err(e) => return Err(format!("gitlab transport: {}", e)),
200+
};
201+
let arr = body.as_array().cloned().unwrap_or_default();
202+
let pipeline = match arr.first() {
203+
Some(p) => p,
204+
None => return Ok(CiStatus::none("gitlab")),
205+
};
206+
let status = pipeline
207+
.get("status")
208+
.and_then(|v| v.as_str())
209+
.unwrap_or("");
210+
let state = match status {
211+
"success" => "success",
212+
"failed" => "failure",
213+
"canceled" | "cancelled" => "cancelled",
214+
"running" => "in_progress",
215+
"pending" | "created" | "waiting_for_resource" | "scheduled" => "pending",
216+
"skipped" | "manual" => "pending",
217+
_ => "unknown",
218+
};
219+
let url = pipeline
220+
.get("web_url")
221+
.and_then(|v| v.as_str())
222+
.map(String::from);
223+
let sha = pipeline
224+
.get("sha")
225+
.and_then(|v| v.as_str())
226+
.map(|s| s[..s.len().min(7)].to_string());
227+
Ok(CiStatus {
228+
provider: "gitlab".into(),
229+
state: state.into(),
230+
label: format!("GitLab CI: {}", state),
231+
url,
232+
sha,
233+
})
234+
}
235+
236+
/// Conservative URL-encoder used for query parameter values.
237+
fn urlencoding_minimal(s: &str) -> String {
238+
s.chars()
239+
.map(|c| match c {
240+
'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => c.to_string(),
241+
' ' => "%20".to_string(),
242+
'/' => "%2F".to_string(),
243+
other => format!("%{:02X}", other as u32),
244+
})
245+
.collect()
246+
}
247+
248+
/// Full URL-encoder for the GitLab project path (slashes must be %2F).
249+
fn urlencoding_full(s: &str) -> String {
250+
urlencoding_minimal(s)
251+
}
252+
253+
#[cfg(test)]
254+
mod tests {
255+
use super::*;
256+
257+
#[test]
258+
fn parse_github_slug_handles_both_forms() {
259+
assert_eq!(
260+
parse_github_slug("https://github.com/SouravRoy-ETL/duckle.git").as_deref(),
261+
Some("SouravRoy-ETL/duckle")
262+
);
263+
assert_eq!(
264+
parse_github_slug("https://github.com/SouravRoy-ETL/duckle").as_deref(),
265+
Some("SouravRoy-ETL/duckle")
266+
);
267+
assert_eq!(
268+
parse_github_slug("git@github.com:SouravRoy-ETL/duckle.git").as_deref(),
269+
Some("SouravRoy-ETL/duckle")
270+
);
271+
assert!(parse_github_slug("https://gitlab.com/foo/bar").is_none());
272+
}
273+
274+
#[test]
275+
fn parse_gitlab_handles_nested_groups() {
276+
assert_eq!(
277+
parse_gitlab("https://gitlab.com/group/sub/project.git"),
278+
Some(("gitlab.com".into(), "group/sub/project".into()))
279+
);
280+
assert_eq!(
281+
parse_gitlab("git@gitlab.internal:team/repo.git"),
282+
Some(("gitlab.internal".into(), "team/repo".into()))
283+
);
284+
}
285+
286+
#[test]
287+
fn urlencoding_replaces_slashes_and_specials() {
288+
assert_eq!(urlencoding_full("foo/bar"), "foo%2Fbar");
289+
assert_eq!(urlencoding_minimal("feature/x"), "feature%2Fx");
290+
assert_eq!(urlencoding_minimal("my branch"), "my%20branch");
291+
}
292+
}

0 commit comments

Comments
 (0)