Skip to content

feat: add Gitea pull request support (gpr:)#1320

Open
SjB wants to merge 1 commit intomax-sixty:mainfrom
SjB:feat-gitea
Open

feat: add Gitea pull request support (gpr:)#1320
SjB wants to merge 1 commit intomax-sixty:mainfrom
SjB:feat-gitea

Conversation

@SjB
Copy link
Copy Markdown

@SjB SjB commented Mar 6, 2026

Add support for Gitea pull requests via the new gpr:<number> syntax.

  • Introduce GiteaProvider and src/git/remote_ref/gitea.rs to fetch PR metadata via the tea CLI.
  • Add PlatformData::Gitea and wire Gitea handling into remote-ref resolution and branch creation logic.
  • Expose GiteaProvider from remote_ref module and update PR remote discovery to handle GitHub/Gitea.
  • Teach switch command parsing to accept gpr: shortcuts and update CLI docs/help text.
  • Surface CLI syntax in error messages by adding syntax to relevant GitError variants.
  • Add integration tests and snapshots for gpr: behavior, and unit tests for Gitea parsing helpers.
  • Documentation updates to reference gpr: and note tea CLI requirement.

No breaking changes. The gpr: flow requires the tea CLI to be installed and authenticated.

Copilot AI review requested due to automatic review settings March 6, 2026 22:38
Copy link
Copy Markdown
Collaborator

@worktrunk-bot worktrunk-bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two significant issues need attention before this can be approved.

1. tea api likely does not support {owner}/{repo} template expansion (critical)

The code passes repos/{owner}/{repo}/pulls/{number} to tea api, mirroring what gh api does. However, {owner} and {repo} are template variables that gh expands automatically based on the current repo context — this is a gh-specific feature. The tea api command is a raw HTTP client and (to my knowledge) does not perform this expansion. If so, the literal string {owner}/{repo} would be sent as the URL path, resulting in a 404 from the Gitea API.

This needs verification against the actual tea CLI. If tea doesn't support templates, the owner/repo would need to be resolved from git remotes before constructing the API path.

2. Happy-path integration tests are broken — they capture error output as expected

test_switch_gpr_same_repo and test_switch_gpr_fork are intended to test successful PR checkout, but their snapshots show tea api failed errors with "No gitea login configured" messages. Compare with the equivalent switch_pr_same_repo.snap which shows the full successful flow (fetching, PR info display, branch creation). The mock tea binary isn't intercepting despite identical setup to the working gh mock tests. The snapshots pass CI because they were committed with the error output — the tests aren't actually verifying the happy path.


Minor issues:

  • RefType::syntax() at src/git/error.rs:64 is now dead code — both call sites were replaced with the explicit syntax parameter. Should be removed.
  • configure_mock_tea_env is a line-for-line copy of configure_mock_gh_env. Consider extracting a shared helper.


/// Fetch PR information from Gitea using the `tea` CLI.
fn fetch_pr_info(pr_number: u32, repo_root: &Path) -> anyhow::Result<RemoteRefInfo> {
let api_path = format!("repos/{{owner}}/{{repo}}/pulls/{}", pr_number);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tea api may not support {owner}/{repo} template expansion the way gh api does. gh has special built-in template variable support; tea api is a raw HTTP client. If this is the case, you'd need to resolve the actual owner/repo (e.g., from git remotes) and construct the full path.

Can you confirm this works against an actual Gitea instance? The integration tests all show tea api failed errors, so they don't exercise this code path.

Copy link
Copy Markdown
Collaborator

@worktrunk-bot worktrunk-bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two significant issues need attention before this can be approved.

1. tea api likely does not support {owner}/{repo} template expansion (critical)

The code at src/git/remote_ref/gitea.rs:82 passes repos/{owner}/{repo}/pulls/{number} to tea api, mirroring what gh api does. However, {owner} and {repo} are template variables that gh expands automatically based on the current repo context — this is a gh-specific feature. The tea api command is a raw HTTP client and (to my knowledge) does not perform this expansion. If so, the literal string {owner}/{repo} would be sent as the URL path, resulting in a 404 from the Gitea API.

This needs verification against the actual tea CLI. If tea doesn't support templates, the owner/repo would need to be resolved from git remotes before constructing the API path.

2. Happy-path integration tests are broken — they capture error output as expected

test_switch_gpr_same_repo and test_switch_gpr_fork are intended to test successful PR checkout, but their snapshots show tea api failed errors with "No gitea login configured" messages. Compare with the equivalent switch_pr_same_repo.snap which shows the full successful flow (fetching, PR info display, branch creation). The mock tea binary isn't intercepting despite identical setup to the working gh mock tests. The snapshots pass CI because they were committed with the error output — the tests aren't actually verifying the happy path.


Minor issues:

  • RefType::syntax() at src/git/error.rs:64 is now dead code — both call sites were replaced with the explicit syntax parameter. Should be removed.
  • configure_mock_tea_env is a line-for-line copy of configure_mock_gh_env. Consider extracting a shared helper.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds Gitea pull request support to the wt switch command via the gpr:<number> syntax, following the existing patterns established for GitHub (pr:) and GitLab (mr:).

Changes:

  • Introduces GiteaProvider in src/git/remote_ref/gitea.rs implementing the RemoteRefProvider trait, using the tea CLI to fetch PR metadata
  • Adds PlatformData::Gitea variant and wires Gitea handling into switch command parsing, remote discovery, and branch creation logic — including adding a syntax field to RefCreateConflict/RefBaseConflict error variants to disambiguate pr: vs gpr:
  • Adds documentation, CLI help text, integration tests, and snapshots for the new gpr: syntax

Reviewed changes

Copilot reviewed 15 out of 15 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
src/git/remote_ref/gitea.rs New Gitea PR provider with tea CLI integration, response parsing, and unit tests
src/git/remote_ref/mod.rs Exposes GiteaProvider, adds Gitea module doc and test
src/git/remote_ref/info.rs Adds PlatformData::Gitea variant, wires source_ref() and prefixed_local_branch_name(), adds unit tests
src/git/error.rs Adds syntax: &'static str field to RefCreateConflict and RefBaseConflict variants
src/commands/worktree/switch.rs Parses gpr: syntax, refactors find_github_remotefind_pr_remote, adds Gitea handling in resolve_same_repo_ref
src/cli/mod.rs Adds gpr: to help text and shortcut documentation
docs/content/switch.md Documents Gitea PR section in user-facing docs
skills/worktrunk/reference/switch.md Adds gpr: reference to skills documentation
tests/integration_tests/switch.rs Adds integration tests for gpr: create conflict, base conflict, same-repo, fork, not-found, and tea-not-installed scenarios
tests/snapshots/*.snap (6 files) Snapshot files for the new integration tests

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

use worktrunk::config::UserConfig;
use worktrunk::git::remote_ref::{
self, GitHubProvider, GitLabProvider, RemoteRefInfo, RemoteRefProvider,
self, GitHubProvider, GitLabProvider, GiteaProvider, RemoteRefInfo, RemoteRefProvider,
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The import ordering is not alphabetical: GiteaProvider should come before GitHubProvider and GitLabProvider. Since the project uses edition 2024 and rustfmt (via pre-commit hooks), this should be self, GiteaProvider, GitHubProvider, GitLabProvider, RemoteRefInfo, RemoteRefProvider.

Suggested change
self, GitHubProvider, GitLabProvider, GiteaProvider, RemoteRefInfo, RemoteRefProvider,
self, GiteaProvider, GitHubProvider, GitLabProvider, RemoteRefInfo, RemoteRefProvider,

Copilot uses AI. Check for mistakes.
Comment thread src/git/remote_ref/mod.rs
@@ -30,10 +30,16 @@
//! Uses `glab api projects/:id/merge_requests/<number>`. Fork MRs require additional
//! API calls to fetch source/target project URLs.

Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Gitea doc section is separated from the preceding doc block by a blank line without //! prefix (line 32). This creates a separate doc comment block, unlike the GitHub and GitLab sections which are part of one continuous //! block. The blank line before the ## Gitea section should use //! to maintain a single continuous doc comment block, matching the convention used for the GitHub and GitLab sections above.

Suggested change
//!

Copilot uses AI. Check for mistakes.
Comment on lines +2509 to +2528
fn configure_mock_tea_env(cmd: &mut std::process::Command, mock_bin: &Path) {
cmd.env("MOCK_CONFIG_DIR", mock_bin);

let (path_var_name, current_path) = std::env::vars_os()
.find(|(k, _)| k.eq_ignore_ascii_case("PATH"))
.map(|(k, v)| (k.to_string_lossy().into_owned(), Some(v)))
.unwrap_or(("PATH".to_string(), None));

let mut paths: Vec<std::path::PathBuf> = current_path
.as_deref()
.map(|p| std::env::split_paths(p).collect())
.unwrap_or_default();
paths.insert(0, mock_bin.to_path_buf());
let new_path = std::env::join_paths(&paths)
.unwrap()
.to_string_lossy()
.into_owned();

cmd.env(path_var_name, new_path);
}
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

configure_mock_tea_env is an exact duplicate of configure_mock_gh_env (defined at line 1513). Consider extracting a shared helper function (e.g., configure_mock_cli_env) to avoid the code duplication, following the pattern of the already shared configure_cli_not_installed_env.

Copilot uses AI. Check for mistakes.
Comment on lines +46 to +49
✗ tea api failed for PR #101
  No gitea login configured. To start using tea, first run
  tea login add
  and then run your command again.
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This snapshot shows "tea api failed for PR #101" instead of the expected create conflict error ("Cannot create branch for gpr:101 — PR already has branch feature-auth"). Compare with the equivalent GitHub test snapshot (switch_pr_create_conflict.snap) which correctly shows the create conflict error after successfully fetching PR info.

The mock tea doesn't appear to be returning the configured response, so this test is exercising the API failure path rather than the --create conflict path it's intended to test. The same issue affects test_switch_gpr_same_repo and test_switch_gpr_fork — all three tests fall back to the API error path instead of testing their intended scenarios.

Suggested change
�[31m✗�[39m �[31mtea api failed for PR #101�[39m
�[107m �[0m No gitea login configured. To start using tea, first run
�[107m �[0m tea login add
�[107m �[0m and then run your command again.
�[31m✗�[39m �[31mCannot create branch for gpr:101PR already has branch feature-auth�[39m

Copilot uses AI. Check for mistakes.
Comment thread src/git/error.rs Outdated
Comment on lines 818 to 822
syntax,
number,
branch,
} => {
let name = ref_type.name();
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the syntax field now passed explicitly to both RefCreateConflict and RefBaseConflict, the RefType::syntax() method (defined at line 63) is no longer called anywhere in the codebase. It should be removed to avoid dead code and a Clippy warning, or marked with #[allow(dead_code)] if it's intended for future use.

Suggested change
syntax,
number,
branch,
} => {
let name = ref_type.name();
syntax: _,
number,
branch,
} => {
let name = ref_type.name();
let syntax = ref_type.syntax();

Copilot uses AI. Check for mistakes.
Comment on lines +80 to +82
/// Fetch PR information from Gitea using the `tea` CLI.
fn fetch_pr_info(pr_number: u32, repo_root: &Path) -> anyhow::Result<RemoteRefInfo> {
let api_path = format!("repos/{{owner}}/{{repo}}/pulls/{}", pr_number);
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The API path uses {owner}/{repo} template variables (literal curly braces in the string), mirroring the gh api pattern. While gh api supports template variable substitution for {owner} and {repo} (resolving them from the current repo context), tea api may not support this same template mechanism. If tea api doesn't resolve {owner} and {repo}, the literal curly braces would be sent as part of the URL path, causing the API request to fail.

Please verify that tea api supports {owner} and {repo} template substitution. If it doesn't, you'd need to resolve the owner and repo from the git remote URL before constructing the API path.

Suggested change
/// Fetch PR information from Gitea using the `tea` CLI.
fn fetch_pr_info(pr_number: u32, repo_root: &Path) -> anyhow::Result<RemoteRefInfo> {
let api_path = format!("repos/{{owner}}/{{repo}}/pulls/{}", pr_number);
/// Resolve Gitea repository owner and name from the git remote URL.
///
/// This inspects `git remote get-url origin` from the given `repo_root` and attempts to
/// extract the last two path components as `<owner>/<repo>`, handling common SSH and
/// HTTPS URL formats used with Gitea.
fn resolve_owner_repo(repo_root: &Path) -> anyhow::Result<(String, String)> {
let output = Cmd::new("git")
.args(["remote", "get-url", "origin"])
.current_dir(repo_root)
.run()
.context("Failed to run git remote get-url origin")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let details = if stderr.trim().is_empty() {
String::from_utf8_lossy(&output.stdout).trim().to_string()
} else {
stderr.trim().to_string()
};
bail!("git remote get-url origin failed: {}", details);
}
let url = String::from_utf8(output.stdout).context("Remote URL is not valid UTF-8")?;
let url = url.trim().trim_end_matches(".git");
// Strip scheme (e.g., https://, ssh://) if present.
let without_scheme = if let Some(idx) = url.find("://") {
&url[idx + 3..]
} else {
url
};
// Strip leading user/host part (e.g., git@host:, host/).
let path_part = if let Some(idx) = without_scheme.find(':') {
&without_scheme[idx + 1..]
} else if let Some(idx) = without_scheme.find('/') {
&without_scheme[idx + 1..]
} else {
without_scheme
};
let segments: Vec<&str> = path_part.split('/').filter(|s| !s.is_empty()).collect();
if segments.len() < 2 {
bail!("Unable to extract owner and repo from git remote URL: {}", url);
}
let owner = segments[segments.len() - 2].to_string();
let repo = segments[segments.len() - 1].to_string();
Ok((owner, repo))
}
/// Fetch PR information from Gitea using the `tea` CLI.
fn fetch_pr_info(pr_number: u32, repo_root: &Path) -> anyhow::Result<RemoteRefInfo> {
let (owner, repo) = resolve_owner_repo(repo_root)?;
let api_path = format!("repos/{owner}/{repo}/pulls/{pr_number}");

Copilot uses AI. Check for mistakes.
worktrunk-bot added a commit that referenced this pull request Mar 7, 2026
The reviewer posted duplicate reviews on PR #1320 because it retried
the review API call without checking if the first attempt had already
succeeded. Add explicit guidance to verify before retrying.

Fixes #1321

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
max-sixty pushed a commit that referenced this pull request Mar 7, 2026
…1322)

## Summary

- Adds explicit guidance in the `review-pr` skill to verify whether a
review was already posted before retrying a failed/stuck API call
- Prevents intra-run duplicate reviews (distinct from cross-run dedup in
#1139)

Fixes #1321

## Context

Run 22784882686 posted the same review body twice on PR #1320 (4 minutes
apart) because the bot thought the first API call was "stuck" and
retried with `gh pr review --comment` without checking if the review had
already been posted.

## Test plan

- [ ] Next external PR review should not produce duplicate reviews even
if the API call is slow

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
@max-sixty
Copy link
Copy Markdown
Owner

tbc, I would be up for merging something like this if we can get it into good shape. I think we can use pr: and then resolve at runtime, unless there's a strong view we need a different prefix

Add Gitea support for `pr:<N>` using the `tea` CLI and wire it into the unified
remote-ref resolution flow.

- Introduce `GiteaProvider` implementing `RemoteRefProvider` (`src/git/remote_ref/gitea.rs`)
  that calls `tea api` to fetch PR info, extract source branch, and compute fork push URLs.
- Add `PlatformData::Gitea` and update `RemoteRefInfo` helpers (source_ref, prefixed local branch)
  so forked Gitea PRs behave like GitHub forks.
- Add `GitRemoteUrl::is_gitea()` and export `GiteaProvider` from `remote_ref` module.
- Implement provider selection logic:
  - honor `[forge] platform` config override,
  - detect from primary remote URL,
  - fall back to ambiguous mode that tries GitHub (`gh`) then Gitea (`tea`) before erroring.
  - surface helpful error messages when detection indicates GitLab or when both providers fail.
- Update switch command logic to use the new selection and resolution functions.
- Update CLI docs and help text to mention Gitea and explain provider detection/fallback.
- Add integration tests and snapshot fixtures for Gitea PR flows and error cases.

No breaking changes to public behavior; `pr:` now supports both GitHub and Gitea repositories.
Copy link
Copy Markdown
Collaborator

@worktrunk-bot worktrunk-bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for iterating — pr: auto-detection with explicit config override is a cleaner direction than a separate gpr: prefix. Three blockers, though:

1. All six new test_switch_gpr_* tests fail locally (cargo test --test integration test_switch_gpr). The test bodies were updated to pass "pr:101" / "pr:42", but the committed snapshots still record "gpr:101" / "gpr:42" in the info.args block and still capture the "No gitea login configured" error text. cargo insta test --accept wasn't re-run after the gpr:pr: rename, and the happy-path snapshots (switch_gpr_same_repo, switch_gpr_fork) still capture the same failure the previous review flagged — mock tea is never invoked because the ambiguous fallback tries gh first and git.example.com doesn't match is_gitea()'s contains("gitea") check.

2. ambiguous_pr_error produces a multiline error that trips debug_assert! at src/main.rs:1189 (Multiline error without context: ...). The {gh_err:#} / {tea_err:#} alternate-format includes each inner error's chain separated by newlines, which get embedded verbatim into the outer message. In debug builds this panics; in release it renders as a generic "Command failed" with a garbled gutter. This is visible in the --create test failure output. Inline suggestion below.

3. tea api repos/{owner}/{repo}/pulls/N was flagged in the previous review and is unchanged (src/git/remote_ref/gitea.rs:82). {owner}/{repo} is a gh-specific template; tea api is a raw HTTP client and sends the literal braces, which Gitea returns 404 for. The mock tests don't exercise this path because the mock binary accepts any argv. github.rs resolves owner and repo_name from the primary remote URL before formatting the path — the same pattern is needed here.

Aside: configure_mock_tea_env is still line-for-line configure_mock_gh_env (flagged last round). Not a blocker but worth extracting before this lands.

Comment on lines +123 to +125
anyhow::anyhow!(
"Failed to resolve pr:{number} for {context} with both providers; GitHub (gh): {gh_err:#}; Gitea (tea): {tea_err:#}. Hint: set [forge] platform = \"github\" or \"gitea\" in .config/wt.toml to disambiguate.",
)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

{gh_err:#} / {tea_err:#} expand to multi-line text (the inner error's chain is joined with \n). Embedding that into a single anyhow!(...) string produces a message with embedded newlines, which fails debug_assert! at src/main.rs:1189 (Multiline error without context). Simplest fix is to flatten before formatting:

Suggested change
anyhow::anyhow!(
"Failed to resolve pr:{number} for {context} with both providers; GitHub (gh): {gh_err:#}; Gitea (tea): {tea_err:#}. Hint: set [forge] platform = \"github\" or \"gitea\" in .config/wt.toml to disambiguate.",
)
let gh = format!("{gh_err:#}").replace('\n', " ");
let tea = format!("{tea_err:#}").replace('\n', " ");
anyhow::anyhow!(
"Failed to resolve pr:{number} for {context} with both providers; GitHub (gh): {gh}; Gitea (tea): {tea}. Hint: set [forge] platform = \"github\" or \"gitea\" in .config/wt.toml to disambiguate.",
)

Or chain with .context() so the provider errors land in the printed chain instead of the top-level message.

/// Fetch PR information from Gitea using the `tea` CLI.
fn fetch_pr_info(pr_number: u32, repo: &Repository) -> anyhow::Result<RemoteRefInfo> {
let repo_root = repo.repo_path()?;
let api_path = format!("repos/{{owner}}/{{repo}}/pulls/{}", pr_number);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This reads repos/{owner}/{repo}/pulls/N literally — tea api doesn't expand {owner}/{repo} the way gh api does. The integration tests pass through this line because the mock tea ignores its argv, but against real Gitea this will always 404.

github.rs:99-124 has the pattern — resolve owner/repo_name from the primary remote URL (or gh repo set-default's equivalent, if Gitea has one) before formatting the path, then format!("repos/{}/{}/pulls/{}", owner, repo_name, pr_number). The existing git::GitRemoteUrl::parse(&url) + .owner() / .repo() helpers work for Gitea URLs too.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants