git-stk 0.10.7

Git-native stacked branch workflow helper
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
use anyhow::{Context, Result, bail};
use serde_json::Value;

use crate::git;

use super::json::{all_reviews, optional_bool, optional_string, parse_state, required_string};
use super::{
    CHECK_GRACE_POLLS, CheckStatus, MergeBlocker, ReviewProvider, ReviewRequest, ReviewState,
    ReviewSummary, WaitOutcome, check_poll_interval, checks_timed_out, command_output,
    merge_with_resettle, review_merged_out_of_band,
};

pub(super) struct GiteaProvider;

/// Gitea has no draft flag on PR creation; the convention is a `WIP:` title
/// prefix that the server treats as a work-in-progress marker.
const DRAFT_PREFIX: &str = "WIP: ";

impl ReviewProvider for GiteaProvider {
    fn review_for_branch(&self, branch: &str) -> Result<Option<ReviewRequest>> {
        find_review(branch, false)
    }

    fn review_for_branch_including_closed(&self, branch: &str) -> Result<Option<ReviewRequest>> {
        find_review(branch, true)
    }

    fn create_review(&self, branch: &str, base: &str, draft: bool) -> Result<String> {
        // Like the glab path: the branch is already pushed, so set title and
        // description explicitly and let git-stk overwrite the body afterward.
        let title = git::commit_subject(branch)?;
        let body = git::commit_body(branch)?;
        let description = if body.trim().is_empty() {
            &title
        } else {
            &body
        };
        // Drafts are a `WIP:` title prefix, not a flag.
        let title = if draft {
            format!("{DRAFT_PREFIX}{title}")
        } else {
            title.clone()
        };
        command_output(
            "tea",
            &[
                "pr",
                "create",
                "--head",
                branch,
                "--base",
                base,
                "--title",
                &title,
                "--description",
                description,
            ],
        )
    }

    fn update_review_base(&self, review: &ReviewRequest, base: &str) -> Result<String> {
        // `tea pr edit` cannot change the target branch, so PATCH it through
        // the API passthrough (which still uses tea's stored auth).
        let endpoint = format!("repos/{}/pulls/{}", repo_slug()?, review.id_value());
        let data = serde_json::json!({ "base": base }).to_string();
        command_output(
            "tea",
            &["api", "--method", "PATCH", &endpoint, "--data", &data],
        )
    }

    fn review_body(&self, review: &ReviewRequest) -> Result<String> {
        Ok(optional_string(&api_pull(review.id_value())?, "body"))
    }

    fn update_review_body(&self, review: &ReviewRequest, body: &str) -> Result<String> {
        command_output(
            "tea",
            &["pr", "edit", review.id_value(), "--description", body],
        )
    }

    fn merge_review(&self, review: &ReviewRequest, strategy: &str, _auto: bool) -> Result<String> {
        let style = match strategy {
            "rebase" => "rebase",
            "merge" => "merge",
            _ => "squash",
        };
        // Gitea/tea has no scheduled ("merge when checks pass") merge, so `auto`
        // falls back to an immediate merge; a real block surfaces as an error.
        let args = vec!["pr", "merge", review.id_value(), "--style", style];
        // A merge right after a force-push (restack, merge --all) can race Gitea
        // recomputing mergeability and get rejected ("is it still open?"). Wait
        // for it to settle, and re-poll between retries rather than guess a delay.
        wait_until_mergeable(review.id_value());
        merge_with_resettle(
            || wait_until_mergeable(review.id_value()),
            || command_output("tea", &args),
        )
    }

    fn merge_blocker(&self, _review: &ReviewRequest) -> Result<MergeBlocker> {
        // Gitea's PR object exposes only a coarse `mergeable` bool, which can't
        // tell a conflict from a pending check - so report nothing structural
        // and let the caller fall back to the merge error text, as the GitLab
        // provider does for its coarse status. (PR 2 may use a richer API.)
        Ok(MergeBlocker::None)
    }

    fn wait_for_checks(&self, review: &ReviewRequest) -> Result<WaitOutcome> {
        // Poll the head commit's combined status. A just-pushed head has no
        // status for a moment; tolerate that for a grace window before
        // concluding there are no checks (mirrors the gitlab path).
        let head_sha = api_pull(review.id_value())?
            .get("head")
            .and_then(|head| head.get("sha"))
            .and_then(Value::as_str)
            .unwrap_or_default()
            .to_owned();
        if head_sha.is_empty() {
            return Ok(WaitOutcome::Passed);
        }
        let endpoint = format!("repos/{}/commits/{head_sha}/status", repo_slug()?);
        let started = std::time::Instant::now();
        let timeout = crate::settings::check_timeout()?;
        let mut no_status = 0u32;
        loop {
            let value: Value = serde_json::from_str(&command_output("tea", &["api", &endpoint])?)
                .context("failed to parse gitea status JSON")?;
            let state = optional_string(&value, "state");
            // No statuses yet (empty state / zero count): wait out the grace
            // window, then treat as "no checks".
            let none_yet =
                value.get("total_count").and_then(Value::as_u64) == Some(0) || state.is_empty();
            match state.as_str() {
                _ if none_yet && no_status >= CHECK_GRACE_POLLS => return Ok(WaitOutcome::Passed),
                _ if none_yet => no_status += 1,
                "success" => return Ok(WaitOutcome::Passed),
                "failure" | "error" => return Ok(WaitOutcome::Failed),
                // pending/warning: statuses exist and are running - reset.
                _ => no_status = 0,
            }

            if let Some(timeout) = timeout
                && started.elapsed() >= timeout
            {
                return Err(checks_timed_out(review, timeout));
            }
            if review_merged_out_of_band(self, review)? {
                return Ok(WaitOutcome::Landed);
            }
            std::thread::sleep(check_poll_interval());
        }
    }

    fn open_reviews(&self) -> Result<Vec<ReviewRequest>> {
        list_pulls("open")
    }

    fn check_status(&self, review: &ReviewRequest) -> Result<CheckStatus> {
        let head_sha = api_pull(review.id_value())?
            .get("head")
            .and_then(|head| head.get("sha"))
            .and_then(Value::as_str)
            .unwrap_or_default()
            .to_owned();
        if head_sha.is_empty() {
            return Ok(CheckStatus::None);
        }
        let endpoint = format!("repos/{}/commits/{head_sha}/status", repo_slug()?);
        let value: Value = serde_json::from_str(&command_output("tea", &["api", &endpoint])?)
            .context("failed to parse gitea status JSON")?;
        let total = value
            .get("total_count")
            .and_then(Value::as_u64)
            .unwrap_or(0);
        Ok(map_gitea_state(&optional_string(&value, "state"), total))
    }

    fn review_summary(&self, review: &ReviewRequest) -> Result<ReviewSummary> {
        let endpoint = format!("repos/{}/pulls/{}/reviews", repo_slug()?, review.id_value());
        let value: Value = serde_json::from_str(&command_output("tea", &["api", &endpoint])?)
            .context("failed to parse gitea reviews JSON")?;
        Ok(count_gitea_reviews(&value))
    }

    fn mark_ready(&self, review: &ReviewRequest) -> Result<String> {
        // Clearing the draft state means dropping the `WIP:` title prefix.
        let title = review
            .title
            .strip_prefix(DRAFT_PREFIX)
            .unwrap_or(&review.title);
        command_output("tea", &["pr", "edit", review.id_value(), "--title", title])
    }

    fn close_review(&self, review: &ReviewRequest, _delete_branch: bool) -> Result<String> {
        // tea's close has no delete-source-branch flag, so the remote branch may
        // linger; closing is what retires the superseded review.
        command_output("tea", &["pr", "close", review.id_value()])
    }

    fn open_review(&self, review: &ReviewRequest) -> Result<String> {
        command_output("tea", &["open", &format!("pulls/{}", review.id_value())])
    }
}

/// Find the branch's review, preferring open, then merged, then (only when
/// `include_closed`) closed. Gitea's `pr list` has no head filter, so list and
/// match client-side.
fn find_review(branch: &str, include_closed: bool) -> Result<Option<ReviewRequest>> {
    let mut matches: Vec<ReviewRequest> = list_pulls("all")?
        .into_iter()
        .filter(|review| review.branch == branch)
        .collect();
    matches.sort_by_key(|review| state_rank(&review.state));
    Ok(matches.into_iter().find(|review| match review.state {
        // Open and merged are the live review; closed or unrecognized states
        // surface only when explicitly asked, matching GitHub/GitLab.
        ReviewState::Open | ReviewState::Merged => true,
        ReviewState::Closed | ReviewState::Unknown(_) => include_closed,
    }))
}

fn state_rank(state: &ReviewState) -> u8 {
    match state {
        ReviewState::Open => 0,
        ReviewState::Merged => 1,
        ReviewState::Closed => 2,
        ReviewState::Unknown(_) => 3,
    }
}

/// One pull request's canonical API object.
fn api_pull(id_value: &str) -> Result<Value> {
    let endpoint = format!("repos/{}/pulls/{id_value}", repo_slug()?);
    serde_json::from_str(&command_output("tea", &["api", &endpoint])?)
        .context("failed to parse gitea pull JSON")
}

/// Every PR in `state` (open|closed|all) as ReviewRequests, following
/// pagination. Uses the API passthrough rather than `tea pr list` (whose JSON
/// is a lossy projection: no `merged`, `draft`, or nested refs), and pages so a
/// branch's review can't fall off the first page on a busy repo.
fn list_pulls(state: &str) -> Result<Vec<ReviewRequest>> {
    // Gitea caps page size at 50, so request exactly that and page past it.
    const PAGE_SIZE: usize = 50;
    // Bound the walk so a misbehaving server can't loop forever.
    const MAX_PAGES: usize = 50;
    let slug = repo_slug()?;
    let mut reviews = Vec::new();
    for page in 1..=MAX_PAGES {
        let endpoint = format!("repos/{slug}/pulls?state={state}&page={page}&limit={PAGE_SIZE}");
        let batch = all_reviews(
            &command_output("tea", &["api", &endpoint])?,
            gitea_review_from,
        )?;
        let full_page = batch.len() == PAGE_SIZE;
        reviews.extend(batch);
        if !full_page {
            break;
        }
    }
    Ok(reviews)
}

/// How long to wait for Gitea to recompute mergeability after a push before a
/// merge (5 polls, 2s apart) - the same shape as the gitlab settle.
const MERGE_SETTLE_POLLS: u32 = 5;
const MERGE_SETTLE_INTERVAL: std::time::Duration = std::time::Duration::from_secs(2);

/// Poll until the PR reports mergeable, bounded by the settle budget. An absent
/// or false `mergeable` (still computing, or a genuine block) just exhausts the
/// budget; the merge attempt then surfaces the real reason.
fn wait_until_mergeable(id_value: &str) {
    for attempt in 0..MERGE_SETTLE_POLLS {
        if attempt > 0 {
            std::thread::sleep(MERGE_SETTLE_INTERVAL);
        }
        if api_pull(id_value)
            .ok()
            .and_then(|pull| pull.get("mergeable").and_then(Value::as_bool))
            == Some(true)
        {
            return;
        }
    }
}

fn gitea_review_from(review: &Value) -> Result<ReviewRequest> {
    // A merged Gitea PR reports state "closed" with merged=true; surface that
    // as Merged rather than Closed.
    let state = if optional_bool(review, "merged") {
        ReviewState::Merged
    } else {
        parse_state(&required_string(review, &["state"])?)
    };

    Ok(ReviewRequest {
        id: format!("#{}", required_string(review, &["number", "index", "id"])?),
        branch: branch_ref(review, &["head", "head_branch", "headBranch"])?,
        base: branch_ref(review, &["base", "base_branch", "baseBranch"])?,
        state,
        url: required_string(review, &["html_url", "htmlUrl", "url"])?,
        title: optional_string(review, "title"),
        draft: optional_bool(review, "draft"),
    })
}

/// Map a Gitea combined commit `state` to a check dot. No statuses (an empty
/// state or zero total) means no dot.
fn map_gitea_state(state: &str, total: u64) -> CheckStatus {
    if total == 0 || state.is_empty() {
        return CheckStatus::None;
    }
    match state {
        "success" => CheckStatus::Passing,
        "failure" | "error" => CheckStatus::Failing,
        // pending / warning: statuses exist and are still running.
        _ => CheckStatus::Pending,
    }
}

/// Tally a Gitea PR reviews array by state.
fn count_gitea_reviews(value: &Value) -> ReviewSummary {
    let mut summary = ReviewSummary::default();
    let Some(items) = value.as_array() else {
        return summary;
    };
    for item in items {
        match item.get("state").and_then(Value::as_str) {
            Some("APPROVED") => summary.approvals += 1,
            Some("REQUEST_CHANGES") => summary.changes_requested += 1,
            Some("COMMENT") => summary.comments += 1,
            _ => {}
        }
    }
    summary
}

/// A branch name from a PR's base/head, tolerating the shapes tea may emit: a
/// bare string, a nested `{ "ref": ... }`/`{ "label": ... }` object.
fn branch_ref(review: &Value, keys: &[&str]) -> Result<String> {
    for key in keys {
        let Some(field) = review.get(*key) else {
            continue;
        };
        if let Some(value) = field.as_str() {
            return Ok(value.to_owned());
        }
        for nested in ["ref", "name", "label"] {
            if let Some(value) = field.get(nested).and_then(Value::as_str) {
                return Ok(value.to_owned());
            }
        }
    }
    bail!("provider JSON missing branch field: {}", keys.join(" or "));
}

/// The `owner/repo` slug from the configured remote's URL, for API calls that
/// need it in the path.
fn repo_slug() -> Result<String> {
    let remote = crate::settings::remote()?;
    let url = git::remote_url(&remote)?.with_context(|| format!("remote {remote} has no URL"))?;
    slug_from_url(&url).with_context(|| format!("could not parse owner/repo from {url}"))
}

fn slug_from_url(url: &str) -> Option<String> {
    let rest = url.split_once("://").map_or(url, |(_, rest)| rest);
    let rest = rest.rsplit_once('@').map_or(rest, |(_, rest)| rest); // drop userinfo
    let rest = rest.trim_end_matches('/');
    let rest = rest.strip_suffix(".git").unwrap_or(rest);
    // owner/repo are the last two segments across `host/owner/repo`,
    // `host:port/owner/repo`, and scp `host:owner/repo`.
    let parts: Vec<&str> = rest.split(['/', ':']).filter(|s| !s.is_empty()).collect();
    if parts.len() < 3 {
        return None;
    }
    Some(format!(
        "{}/{}",
        parts[parts.len() - 2],
        parts[parts.len() - 1]
    ))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn gitea_review_from_maps_merged_closed_state() {
        let merged = gitea_review_from(&serde_json::json!({
            "number": 7, "state": "closed", "merged": true,
            "head": {"ref": "feature/b"}, "base": {"ref": "feature/a"},
            "html_url": "https://gitea.com/owner/repo/pulls/7", "title": "B"
        }))
        .expect("parse");
        assert_eq!(merged.id, "#7");
        assert_eq!(merged.branch, "feature/b");
        assert_eq!(merged.base, "feature/a");
        assert_eq!(merged.state, ReviewState::Merged);
        assert_eq!(merged.url, "https://gitea.com/owner/repo/pulls/7");

        let closed = gitea_review_from(&serde_json::json!({
            "number": 8, "state": "closed", "merged": false,
            "head": {"ref": "x"}, "base": {"ref": "main"},
            "html_url": "https://gitea.com/o/r/pulls/8"
        }))
        .expect("parse");
        assert_eq!(closed.state, ReviewState::Closed);
    }

    #[test]
    fn gitea_review_from_tolerates_flat_branch_strings() {
        let review = gitea_review_from(&serde_json::json!({
            "index": 3, "state": "open",
            "head": "feature/x", "base": "main",
            "url": "https://gitea.com/o/r/pulls/3"
        }))
        .expect("parse");
        assert_eq!(review.id, "#3");
        assert_eq!(review.branch, "feature/x");
        assert_eq!(review.base, "main");
        assert_eq!(review.state, ReviewState::Open);
    }

    #[test]
    fn slug_from_url_handles_url_shapes() {
        assert_eq!(
            slug_from_url("https://gitea.com/owner/repo.git").as_deref(),
            Some("owner/repo")
        );
        assert_eq!(
            slug_from_url("git@gitea.com:owner/repo.git").as_deref(),
            Some("owner/repo")
        );
        assert_eq!(
            slug_from_url("ssh://git@gitea.example.com:2222/owner/repo").as_deref(),
            Some("owner/repo")
        );
        assert_eq!(
            slug_from_url("https://user:token@codeberg.org/owner/repo").as_deref(),
            Some("owner/repo")
        );
        assert_eq!(slug_from_url("https://gitea.com/").as_deref(), None);
    }

    #[test]
    fn map_gitea_state_maps_combined_status() {
        assert_eq!(map_gitea_state("success", 2), CheckStatus::Passing);
        assert_eq!(map_gitea_state("failure", 1), CheckStatus::Failing);
        assert_eq!(map_gitea_state("error", 1), CheckStatus::Failing);
        assert_eq!(map_gitea_state("pending", 1), CheckStatus::Pending);
        // No statuses at all -> no dot.
        assert_eq!(map_gitea_state("", 0), CheckStatus::None);
        assert_eq!(map_gitea_state("success", 0), CheckStatus::None);
    }

    #[test]
    fn count_gitea_reviews_tallies_by_state() {
        let reviews = serde_json::json!([
            {"state": "APPROVED"},
            {"state": "REQUEST_CHANGES"},
            {"state": "COMMENT"},
            {"state": "COMMENT"},
            {"state": "PENDING"},
        ]);
        assert_eq!(
            count_gitea_reviews(&reviews),
            ReviewSummary {
                approvals: 1,
                comments: 2,
                changes_requested: 1,
            }
        );
        assert_eq!(
            count_gitea_reviews(&serde_json::json!({})),
            ReviewSummary::default()
        );
    }
}