Skip to content

feat: add per-project GitLab hosting support - #57

Closed
AdamKaabyia wants to merge 1 commit into
flightctl:mainfrom
AdamKaabyia:gitlab-support-upstream
Closed

AdamKaabyia wants to merge 1 commit into
flightctl:mainfrom
AdamKaabyia:gitlab-support-upstream

Conversation

@AdamKaabyia

@AdamKaabyia AdamKaabyia commented Jul 22, 2026

Copy link
Copy Markdown

Enable GitLab as a VCS backend alongside GitHub, selectable per workspace via the hosting: gitlab configuration field. A hosting router dispatches operations to the correct provider based on owner/repo mapping.

Key changes:

  • Extract provider-agnostic git operations into shared GitOps helper
  • Add GitLabServiceImpl (PAT auth, MR creation, pipeline status, notes)
  • Add hosting router that satisfies all consumer interfaces
  • Make GitHub validation conditional (only when workspaces use GitHub)
  • Add GitLab config struct with environment variable bindings
  • Add comprehensive tests for GitLab service, router, and shared GitOps
  • Add docs/gitlab-support.md deployment and usage guide

Design:

  • GitLab commits via local git push (vs GitHub's Git Data API)
  • PAT/Project/Group Access Token authentication
  • Workspace-level hosting field with fallback to GitHub (backward compat)
  • Zero changes to existing GitHub-only deployments

Affected packages

  • models/: Adds per-workspace hosting selection, GitLab configuration, environment bindings, defaults, and conditional provider validation.
  • services/: Introduces shared Git operations, a GitLab provider, and a hosting router for GitHub/GitLab dispatch. Existing GitHub operations now use shared Git logic.
  • Other listed packages—executor/, scanner/, container/, workspace/, jobmanager/, taskfile/, tracker/, projectresolver/, repoconfig/, commentfilter/, recovery/, and costtracker/—are not directly changed.

Execution pipeline

Updates hosting-dependent pipeline operations, including repository synchronization, local commits and pushes, PR/MR creation, comment and review handling, labels, CI status, and mergeability. GitHub retains its existing Git Data API workflow, while GitLab uses local git push and GitLab APIs.

Infrastructure

No changes to container management or workspace lifecycle orchestration. Shared Git workspace operations are centralized in GitOps, with routing tracked by repository and cloned workspace directory.

Configuration and deployment

Adds optional GitLab configuration with environment variable support and workspace-level hosting: gitlab selection, while preserving GitHub fallback behavior. Adds GitLab setup, deployment, usage, workflow, and behavior-difference documentation, plus example configuration and tests for GitLab services, routing, and Git operations.

Enable GitLab as a VCS backend alongside GitHub, selectable per workspace
via the `hosting: gitlab` configuration field. A hosting router dispatches
operations to the correct provider based on owner/repo mapping.

Key changes:
- Extract provider-agnostic git operations into shared GitOps helper
- Add GitLabServiceImpl (PAT auth, MR creation, pipeline status, notes)
- Add hosting router that satisfies all consumer interfaces
- Make GitHub validation conditional (only when workspaces use GitHub)
- Add GitLab config struct with environment variable bindings
- Add comprehensive tests for GitLab service, router, and shared GitOps
- Add docs/gitlab-support.md deployment and usage guide

Design:
- GitLab commits via local git push (vs GitHub's Git Data API)
- PAT/Project/Group Access Token authentication
- Workspace-level hosting field with fallback to GitHub (backward compat)
- Zero changes to existing GitHub-only deployments
@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Adds GitLab configuration and hosting support, introduces shared Git operations, implements GitLab repository and merge-request APIs, and routes GitHub or GitLab workspaces through a provider-aware router.

Changes

GitLab hosting support

Layer / File(s) Summary
Hosting configuration and documentation
AGENTS.md, config.example.yaml, docs/gitlab-support.md, models/config.go
Adds workspace provider selection, GitLab settings, environment bindings, validation, examples, and operational documentation.
Shared Git workspace operations
services/gitops.go, services/github.go, services/gitops_test.go
Centralizes branch, sync, commit, authentication, clone, merge, and Git configuration behavior in GitOps, with GitHub delegation and tests.
GitLab provider implementation
services/gitlab.go, services/gitlab_test.go
Adds GitLab repository, merge request, comment, label, CI, recovery, mergeability, and authentication operations with HTTP tests.
Provider routing and startup wiring
main.go, services/hosting/*
Initializes required providers, maps repositories to hosting services, routes directory and repository operations, and tests fallback and URL parsing behavior.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

Suggested labels: config, github-service

Suggested reviewers: amir-yogev-gh


Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (1 error, 2 warnings)

Check name Status Explanation Resolution
No-Sensitive-Data-In-Logs ❌ Error FAIL: medium risk—CloneImport logs raw repoURL, which can expose internal hostnames or embedded credentials in logs. Redact the URL before logging (or log owner/repo only) and avoid emitting any auth-bearing clone URL in debug/error paths.
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Unchecked-Errors ⚠️ Warning FAIL: Multiple changed paths ignore returned errors without justification (os.RemoveAll, logger.Sync, fmt.Fprint, io.ReadAll, time.Parse), masking I/O/API failures. Handle or log these errors, or add a clear comment when the ignore is truly best-effort; then add tests for the affected branches.
✅ Passed checks (10 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: per-project GitLab hosting support.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
No-Hardcoded-Secrets ✅ Passed No real hardcoded secrets found; token use is runtime/config-driven and the literals are placeholders in docs/examples/tests.
No-Weak-Crypto ✅ Passed Scanned the changed code paths; no MD5/SHA1/DES/RC4/3DES/Blowfish/ECB, custom crypto, or secret/token comparisons were added.
No-Injection-Vectors ✅ Passed No new SQL, eval/exec, yaml.load, os.system, or shell-based command execution was introduced; changed code uses exec.Command with fixed argv.
Container-Privileges ✅ Passed No changed container/K8s manifests contain privileged, hostPID/hostNetwork/hostIPC, SYS_ADMIN, root, or allowPrivilegeEscalation settings; compose/Docker remain non-root.
Resource-Leaks ✅ Passed No unclosed files, HTTP bodies, or connections found; the server goroutine has a shutdown path via stop signal and server.Shutdown.
Ai-Attribution ✅ Passed No AI-tool usage is mentioned in the PR commit, and I found no Assisted-by/Generated-by/Made-with or Co-Authored-By trailers to enforce.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 10

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/gitlab-support.md`:
- Line 123: Update the GitLab prerequisite token scope list to include api,
read_repository, and write_repository, matching the requirements documented
elsewhere. Modify only the GitLab entry in the prerequisites section.

In `@main.go`:
- Around line 536-546: Consolidate extractOwnerRepoFromURL,
hosting.extractOwnerRepo, and extractGitLabRepoInfo into one shared URL parser
with consistent owner/repository fallback behavior. Update each caller,
including the flow using extractOwnerRepoFromURL, to reuse the shared parser and
remove the duplicate implementations, preserving the expected routing keys
across providers.
- Around line 50-71: The application still derives the bot identity from
config.GitHub, which breaks GitLab-only configurations. Resolve BotUsername from
the active hosting provider configuration after RequiredHostingProviders and
reuse that shared value when wiring the container manager, executor, recovery
runner, and scanners; preserve the existing GitHub behavior while selecting
GitLab’s configured username for GitLab-only setups.

In `@services/gitlab_test.go`:
- Around line 299-318: Add coverage for RestoreRemoteAuth with a unit test that
captures the URL supplied to SetRemoteURL and asserts the exact
https://oauth2:<token>@<host>/owner/repo.git format, including the happy path
and relevant error/edge cases required by the path instructions. In
TestGitLabCloneRepository, check os.MkdirAll’s error and call t.Fatalf instead
of ignoring it.

In `@services/gitlab.go`:
- Around line 374-443: Add a status-code check in GetPRComments immediately
after doRequest and before decoding resp.Body; for non-2xx responses, close the
body and return an error consistent with sibling methods, while preserving the
existing JSON decoding and pagination behavior for successful responses.
- Around line 84-94: Correct RestoreRemoteAuth so authURL uses a valid URL
authority: preserve the configured http/https scheme, place oauth2 and the
access token before the GitLab host, and append only the owner/repo.git path
without duplicating baseURL. Remove the no-op strings.Replace logic and ensure
both http:// and https:// BaseURL values are handled.
- Around line 107-147: URL-escape branch names before constructing the GitLab
branch API path. In both RemoteBranchExists and DeleteRemoteBranch, apply
url.PathEscape(branch) when interpolating the branch segment so names containing
slashes remain a single path component; preserve the existing status handling.

In `@services/hosting/router_test.go`:
- Around line 150-259: Add a test alongside TestRouter_RoutesToCorrectProvider
that invokes UpdateIssueComment with an owner/repo mapped to a non-default
provider, then assert that provider receives UpdateIssueComment and the default
provider receives no call. Include the required issue-comment arguments and
verify routing uses the owner/repo mapping rather than fallback.

In `@services/hosting/router.go`:
- Around line 279-302: The parse-failure path in extractOwnerRepo currently
returns a fabricated repository name. Change its final return to empty owner and
repository strings, matching extractOwnerRepoFromURL in main.go, and leave
callers to apply any explicit fallback behavior.
- Around line 232-235: Update Router.UpdateIssueComment to dispatch through
r.forRepo(owner, repo) before invoking UpdateIssueComment, so the repository’s
configured provider handles the request. Remove the stale fallback-only comment
and preserve the existing method arguments and error propagation.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Enterprise

Run ID: 286f1022-26c9-42c3-92cf-3c27b1cf9686

📥 Commits

Reviewing files that changed from the base of the PR and between 033b797 and 5c5d2fc.

📒 Files selected for processing (12)
  • AGENTS.md
  • config.example.yaml
  • docs/gitlab-support.md
  • main.go
  • models/config.go
  • services/github.go
  • services/gitlab.go
  • services/gitlab_test.go
  • services/gitops.go
  • services/gitops_test.go
  • services/hosting/router.go
  • services/hosting/router_test.go

Comment thread docs/gitlab-support.md
### Prerequisites

- **Jira**: API token with read/write access to your project(s)
- **GitLab**: Personal Access Token with `api` + `write_repository` scopes

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Minor: scope list inconsistency. Line 54 requires api, read_repository, write_repository, but the Prerequisites here list only api + write_repository. Align them to avoid users creating under-scoped tokens.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/gitlab-support.md` at line 123, Update the GitLab prerequisite token
scope list to include api, read_repository, and write_repository, matching the
requirements documented elsewhere. Modify only the GitLab entry in the
prerequisites section.

Source: Linters/SAST tools

Comment thread main.go
Comment on lines +50 to +71

// Build the VCS hosting router from per-project workspace config.
needsGitHub, needsGitLab := config.RequiredHostingProviders()

var ghService *services.GitHubServiceImpl
if needsGitHub {
ghService = services.NewGitHubService(config, logger)
}

var glService *services.GitLabServiceImpl
if needsGitLab {
glService = services.NewGitLabService(config, logger)
}

repoProviders := buildRepoProviderMap(config, ghService, glService)
var fallback hosting.Provider
if ghService != nil {
fallback = ghService
} else {
fallback = glService
}
gitService := hosting.NewRouter(repoProviders, fallback)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm branch/prefix/identity all source from GitHub.BotUsername only.
rg -nP 'config\.GitHub\.BotUsername' main.go
rg -nP 'BotUsername' -C2 executor/ recovery/ scanner/ | rg -n 'BotUsername'

Repository: flightctl/jira-ai-issue-solver

Length of output: 6665


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- main.go relevant slices ---'
sed -n '1,330p' main.go

echo '--- config search ---'
rg -n 'RequiredHostingProviders|BotUsername|GitLab\.BotUsername|GitHub\.BotUsername' -S . --glob '!**/*test.go'

Repository: flightctl/jira-ai-issue-solver

Length of output: 15724


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '823,1320p' models/config.go

Repository: flightctl/jira-ai-issue-solver

Length of output: 16430


Major: route BotUsername from the active hosting config, not config.GitHub. main.go still injects config.GitHub.BotUsername into the container manager, executor, recovery runner, and scanners. GitLab-only configs validate gitlab.bot_username, so this leaves those paths with an empty username and breaks startup/branch naming/comment filtering. Use a shared resolved bot identity before wiring these components.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@main.go` around lines 50 - 71, The application still derives the bot identity
from config.GitHub, which breaks GitLab-only configurations. Resolve BotUsername
from the active hosting provider configuration after RequiredHostingProviders
and reuse that shared value when wiring the container manager, executor,
recovery runner, and scanners; preserve the existing GitHub behavior while
selecting GitLab’s configured username for GitLab-only setups.

Comment thread main.go
Comment on lines +536 to +546
// extractOwnerRepoFromURL parses owner and repo from a clone URL.
func extractOwnerRepoFromURL(repoURL string) (string, string) {
repoURL = strings.TrimSuffix(repoURL, ".git")
parts := strings.Split(repoURL, "/")
if len(parts) >= 5 {
repo := parts[len(parts)-1]
owner := strings.Join(parts[3:len(parts)-1], "/")
return owner, repo
}
return "", ""
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

Recommended: consolidate the three URL parsers. extractOwnerRepoFromURL here duplicates hosting.extractOwnerRepo (services/hosting/router.go) and extractGitLabRepoInfo (services/gitlab.go), with divergent fallbacks (this one returns "",""; the router returns "", "unknown-<url>"). Divergence in owner/repo keys will cause silent routing mismatches. Extract one shared parser and reuse it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@main.go` around lines 536 - 546, Consolidate extractOwnerRepoFromURL,
hosting.extractOwnerRepo, and extractGitLabRepoInfo into one shared URL parser
with consistent owner/repository fallback behavior. Update each caller,
including the flow using extractOwnerRepoFromURL, to reuse the shared parser and
remove the duplicate implementations, preserving the expected routing keys
across providers.

Comment thread services/gitlab_test.go
Comment on lines +299 to +318
func TestGitLabCloneRepository(t *testing.T) {
tmpDir := t.TempDir()
repoDir := filepath.Join(tmpDir, "repo")

// Create a fake git repo.
os.MkdirAll(filepath.Join(repoDir, ".git"), 0750)

fakeExec := func(name string, args ...string) *exec.Cmd {
return exec.Command("true")
}

config := newTestGitLabConfig("https://gitlab.example.com")
logger := zap.NewNop()
svc := NewGitLabService(config, logger, fakeExec)

err := svc.CloneRepository("https://gitlab.example.com/org/repo.git", repoDir)
if err != nil {
t.Fatalf("CloneRepository failed: %v", err)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Add coverage for RestoreRemoteAuth. TestGitLabCloneRepository stubs the executor with true, so the malformed auth URL flagged in services/gitlab.go is never asserted. Add a unit test that captures the URL passed to SetRemoteURL and asserts it equals https://oauth2:<token>@<host>/owner/repo.git. Also prefer t.Fatalf on the os.MkdirAll at Line 304 rather than ignoring its error.

As per path instructions: "Every code change must include corresponding unit tests covering new functions' happy paths, errors, and edge cases".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/gitlab_test.go` around lines 299 - 318, Add coverage for
RestoreRemoteAuth with a unit test that captures the URL supplied to
SetRemoteURL and asserts the exact https://oauth2:<token>@<host>/owner/repo.git
format, including the happy path and relevant error/edge cases required by the
path instructions. In TestGitLabCloneRepository, check os.MkdirAll’s error and
call t.Fatalf instead of ignoring it.

Source: Path instructions

Comment thread services/gitlab.go
Comment on lines +84 to +94
func (s *GitLabServiceImpl) RestoreRemoteAuth(directory, owner, repo string) error {
token := s.config.GitLab.AccessToken
baseURL := strings.TrimSuffix(s.config.GitLab.BaseURL, "/")
authURL := fmt.Sprintf("%s/oauth2:%s@%s/%s/%s.git",
strings.Replace(baseURL, "https://", "https://", 1),
token,
strings.TrimPrefix(strings.TrimPrefix(baseURL, "https://"), "http://"),
owner, repo)

return s.gitOps.SetRemoteURL(directory, authURL)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Critical: RestoreRemoteAuth builds a malformed remote URL. With baseURL = "https://gitlab.com", the format string yields https://gitlab.com/oauth2:<token>@gitlab.com/owner/repo.git`` — the base URL is duplicated and credentials land in the path, not the authority. The subsequent git push in `CommitChanges` and the auth restore in `CloneRepository` will fail. Also `strings.Replace(baseURL, "https://", "https://", 1)` is a no-op and `http://` bases are not handled.

🐛 Proposed fix
-	token := s.config.GitLab.AccessToken
-	baseURL := strings.TrimSuffix(s.config.GitLab.BaseURL, "/")
-	authURL := fmt.Sprintf("%s/oauth2:%s@%s/%s/%s.git",
-		strings.Replace(baseURL, "https://", "https://", 1),
-		token,
-		strings.TrimPrefix(strings.TrimPrefix(baseURL, "https://"), "http://"),
-		owner, repo)
-
-	return s.gitOps.SetRemoteURL(directory, authURL)
+	token := s.config.GitLab.AccessToken
+	baseURL := strings.TrimSuffix(s.config.GitLab.BaseURL, "/")
+	scheme := "https"
+	if strings.HasPrefix(baseURL, "http://") {
+		scheme = "http"
+	}
+	host := strings.TrimPrefix(strings.TrimPrefix(baseURL, "https://"), "http://")
+	authURL := fmt.Sprintf("%s://oauth2:%s@%s/%s/%s.git", scheme, token, host, owner, repo)
+
+	return s.gitOps.SetRemoteURL(directory, authURL)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func (s *GitLabServiceImpl) RestoreRemoteAuth(directory, owner, repo string) error {
token := s.config.GitLab.AccessToken
baseURL := strings.TrimSuffix(s.config.GitLab.BaseURL, "/")
authURL := fmt.Sprintf("%s/oauth2:%s@%s/%s/%s.git",
strings.Replace(baseURL, "https://", "https://", 1),
token,
strings.TrimPrefix(strings.TrimPrefix(baseURL, "https://"), "http://"),
owner, repo)
return s.gitOps.SetRemoteURL(directory, authURL)
}
func (s *GitLabServiceImpl) RestoreRemoteAuth(directory, owner, repo string) error {
token := s.config.GitLab.AccessToken
baseURL := strings.TrimSuffix(s.config.GitLab.BaseURL, "/")
scheme := "https"
if strings.HasPrefix(baseURL, "http://") {
scheme = "http"
}
host := strings.TrimPrefix(strings.TrimPrefix(baseURL, "https://"), "http://")
authURL := fmt.Sprintf("%s://oauth2:%s@%s/%s/%s.git", scheme, token, host, owner, repo)
return s.gitOps.SetRemoteURL(directory, authURL)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/gitlab.go` around lines 84 - 94, Correct RestoreRemoteAuth so
authURL uses a valid URL authority: preserve the configured http/https scheme,
place oauth2 and the access token before the GitLab host, and append only the
owner/repo.git path without duplicating baseURL. Remove the no-op
strings.Replace logic and ensure both http:// and https:// BaseURL values are
handled.

Comment thread services/gitlab.go
Comment on lines +107 to +147
func (s *GitLabServiceImpl) RemoteBranchExists(owner, repo, branch string) (bool, error) {
projectID := s.projectPath(owner, repo)
url := fmt.Sprintf("%s/api/v4/projects/%s/repository/branches/%s",
s.config.GitLab.BaseURL, projectID, branch)

resp, err := s.doRequest("GET", url, nil)
if err != nil {
return false, fmt.Errorf("check branch existence: %w", err)
}
defer resp.Body.Close()

switch resp.StatusCode {
case http.StatusOK:
return true, nil
case http.StatusNotFound:
return false, nil
default:
return false, fmt.Errorf("unexpected status %d checking branch %s", resp.StatusCode, branch)
}
}

func (s *GitLabServiceImpl) DeleteRemoteBranch(owner, repo, branch string) error {
projectID := s.projectPath(owner, repo)
url := fmt.Sprintf("%s/api/v4/projects/%s/repository/branches/%s",
s.config.GitLab.BaseURL, projectID, branch)

resp, err := s.doRequest("DELETE", url, nil)
if err != nil {
return fmt.Errorf("delete branch: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode == http.StatusNotFound {
return nil
}
if resp.StatusCode >= 300 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("delete branch %s: status %d, body: %s", branch, resp.StatusCode, string(body))
}
return nil
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

GitLab REST API repository branches endpoint URL encode branch name slash

💡 Result:

Yes, when using the GitLab REST API to interact with repository branches, any branch name that contains a forward slash (/) must be URL-encoded [1][2]. In the URL path, the forward slash character must be replaced with its URL-encoded representation, which is %2F [1][2]. For example, if you are attempting to retrieve information for a branch named "my/branch" in a project, you should encode the slash as follows: GET /api/v4/projects/:id/repository/branches/my%2Fbranch [1][2] Failure to encode the slash will typically result in a 404 Not Found error from the API, as the system will interpret the unencoded slash as a path delimiter rather than part of the branch name [3]. This encoding requirement applies to all endpoints that include the branch name in the URL path, such as GET, DELETE, or other operations targeting a specific branch [4][5].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the relevant file and surrounding helpers.
git ls-files services/gitlab.go
printf '\n--- outline ---\n'
ast-grep outline services/gitlab.go --view expanded || true
printf '\n--- relevant lines ---\n'
sed -n '1,220p' services/gitlab.go | cat -n

Repository: flightctl/jira-ai-issue-solver

Length of output: 13225


🏁 Script executed:

#!/bin/bash
set -euo pipefail

git ls-files services/gitlab.go
printf '\n--- outline ---\n'
ast-grep outline services/gitlab.go --view expanded || true
printf '\n--- relevant lines ---\n'
sed -n '1,220p' services/gitlab.go | cat -n

Repository: flightctl/jira-ai-issue-solver

Length of output: 13225


URL-escape branch names in GitLab branch API paths. Branches like {botUsername}/{ticketKey} include /, so interpolating branch raw into /repository/branches/%s turns the slash into a path separator. RemoteBranchExists will report existing branches as missing, and DeleteRemoteBranch will accept the resulting 404 as success. Use url.PathEscape(branch) in both calls.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/gitlab.go` around lines 107 - 147, URL-escape branch names before
constructing the GitLab branch API path. In both RemoteBranchExists and
DeleteRemoteBranch, apply url.PathEscape(branch) when interpolating the branch
segment so names containing slashes remain a single path component; preserve the
existing status handling.

Comment thread services/gitlab.go
Comment on lines +374 to +443
func (s *GitLabServiceImpl) GetPRComments(owner, repo string, number int, since time.Time) ([]models.PRComment, error) {
projectID := s.projectPath(owner, repo)
var allComments []models.PRComment
page := 1

for {
url := fmt.Sprintf("%s/api/v4/projects/%s/merge_requests/%d/notes?per_page=100&page=%d&sort=asc",
s.config.GitLab.BaseURL, projectID, number, page)

resp, err := s.doRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("get MR notes: %w", err)
}

var notes []struct {
ID int64 `json:"id"`
Body string `json:"body"`
Author struct {
Username string `json:"username"`
} `json:"author"`
CreatedAt string `json:"created_at"`
System bool `json:"system"`
Resolvable bool `json:"resolvable"`
Position *struct {
NewPath string `json:"new_path"`
NewLine int `json:"new_line"`
} `json:"position"`
}
if err := json.NewDecoder(resp.Body).Decode(&notes); err != nil {
resp.Body.Close()
return nil, fmt.Errorf("decode MR notes: %w", err)
}
resp.Body.Close()

for _, note := range notes {
if note.System {
continue
}
ts, _ := time.Parse(time.RFC3339, note.CreatedAt)
if !since.IsZero() && ts.Before(since) {
continue
}

comment := models.PRComment{
ID: note.ID,
Author: models.Author{Name: note.Author.Username},
Body: note.Body,
URL: fmt.Sprintf("%s/api/v4/projects/%s/merge_requests/%d#note_%d",
s.config.GitLab.BaseURL, projectID, number, note.ID),
Timestamp: ts,
IsReviewComment: note.Resolvable,
}
if note.Position != nil {
comment.FilePath = note.Position.NewPath
comment.Line = note.Position.NewLine
}
allComments = append(allComments, comment)
}

if len(notes) < 100 {
break
}
page++
}

if allComments == nil {
allComments = []models.PRComment{}
}
return allComments, nil
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Minor: GetPRComments decodes the body without checking resp.StatusCode. Unlike the sibling methods, a non-2xx response (auth failure, rate limit) is decoded as an empty note list and silently returns zero comments, masking errors. Add a status-code guard before decoding.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/gitlab.go` around lines 374 - 443, Add a status-code check in
GetPRComments immediately after doRequest and before decoding resp.Body; for
non-2xx responses, close the body and return an error consistent with sibling
methods, while preserving the existing JSON decoding and pagination behavior for
successful responses.

Comment on lines +150 to +259
func TestRouter_RoutesToCorrectProvider(t *testing.T) {
github := &stubProvider{name: "github"}
gitlab := &stubProvider{name: "gitlab"}

repoProviders := map[string]Provider{
"gitlab-org/repo": gitlab,
"github-org/repo": github,
}

router := NewRouter(repoProviders, github)

// API-based call should route to gitlab for gitlab-org/repo.
router.GetPRForBranch("gitlab-org", "repo", "feature")
if len(gitlab.calls) != 1 || gitlab.calls[0] != "GetPRForBranch" {
t.Errorf("expected gitlab to receive GetPRForBranch, got %v", gitlab.calls)
}
if len(github.calls) != 0 {
t.Errorf("expected github to receive no calls, got %v", github.calls)
}

// API-based call should route to github for github-org/repo.
router.GetPRForBranch("github-org", "repo", "feature")
if len(github.calls) != 1 || github.calls[0] != "GetPRForBranch" {
t.Errorf("expected github to receive GetPRForBranch, got %v", github.calls)
}
}

func TestRouter_FallbackToDefault(t *testing.T) {
github := &stubProvider{name: "github"}
gitlab := &stubProvider{name: "gitlab"}

repoProviders := map[string]Provider{
"gitlab-org/repo": gitlab,
}

router := NewRouter(repoProviders, github)

// Unknown repo should fall back to the default (github).
router.GetPRForBranch("unknown-org", "unknown-repo", "feature")
if len(github.calls) != 1 || github.calls[0] != "GetPRForBranch" {
t.Errorf("expected github (fallback) to receive call, got %v", github.calls)
}
}

func TestRouter_DirBasedMethodsUseWorkspaceRegistry(t *testing.T) {
github := &stubProvider{name: "github"}
gitlab := &stubProvider{name: "gitlab"}

repoProviders := map[string]Provider{
"gitlab-org/repo": gitlab,
}

router := NewRouter(repoProviders, github)

// Register a workspace directory with the gitlab provider.
router.RegisterWorkspace("/tmp/workspaces/ticket-1/repo", "gitlab-org", "repo")

// Dir-based call should route to gitlab.
router.CreateBranch("/tmp/workspaces/ticket-1/repo", "feature", "main")
if len(gitlab.calls) != 1 || gitlab.calls[0] != "CreateBranch" {
t.Errorf("expected gitlab to receive CreateBranch, got %v", gitlab.calls)
}

// Unknown dir should fall back.
router.CreateBranch("/tmp/workspaces/ticket-2/repo", "feature", "main")
if len(github.calls) != 1 || github.calls[0] != "CreateBranch" {
t.Errorf("expected github (fallback) for unknown dir, got %v", github.calls)
}
}

func TestRouter_CloneRegistersWorkspace(t *testing.T) {
github := &stubProvider{name: "github"}
gitlab := &stubProvider{name: "gitlab"}

repoProviders := map[string]Provider{
"gitlab-org/repo": gitlab,
}

router := NewRouter(repoProviders, github)

// Clone registers the workspace directory.
router.CloneRepository("https://gitlab.com/gitlab-org/repo.git", "/tmp/workspaces/ws1")

if len(gitlab.calls) != 1 || gitlab.calls[0] != "CloneRepository" {
t.Errorf("expected gitlab to receive CloneRepository, got %v", gitlab.calls)
}

// Subsequent dir-based call should route to gitlab.
router.HasChanges("/tmp/workspaces/ws1", "main")
if len(gitlab.calls) != 2 || gitlab.calls[1] != "HasChanges" {
t.Errorf("expected gitlab to receive HasChanges after clone, got %v", gitlab.calls)
}
}

func TestRouter_CaseInsensitiveRouting(t *testing.T) {
github := &stubProvider{name: "github"}
gitlab := &stubProvider{name: "gitlab"}

repoProviders := map[string]Provider{
"GitLab-Org/Repo": gitlab,
}

router := NewRouter(repoProviders, github)

// Lowercase lookup should still find the provider.
router.GetPRForBranch("gitlab-org", "repo", "feature")
if len(gitlab.calls) != 1 {
t.Errorf("expected case-insensitive match to gitlab, got %v", gitlab.calls)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Add a routing test for UpdateIssueComment. The suite covers GetPRForBranch and dir-based dispatch but not UpdateIssueComment, which is why the fallback-misroute bug in router.go went unnoticed. Add a case asserting it routes by owner/repo.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/hosting/router_test.go` around lines 150 - 259, Add a test alongside
TestRouter_RoutesToCorrectProvider that invokes UpdateIssueComment with an
owner/repo mapped to a non-default provider, then assert that provider receives
UpdateIssueComment and the default provider receives no call. Include the
required issue-comment arguments and verify routing uses the owner/repo mapping
rather than fallback.

Comment on lines +232 to +235
func (r *Router) UpdateIssueComment(owner, repo string, commentID int64, body string) error {
// commentID-based calls don't have owner/repo; fallback for now.
return r.fallback.UpdateIssueComment(owner, repo, commentID, body)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Major: UpdateIssueComment ignores its own owner/repo and always hits the fallback provider. The signature carries owner, repo, so in a mixed GitHub+GitLab deployment a GitLab comment update is dispatched to the GitHub fallback. The stale comment ("commentID-based calls don't have owner/repo") contradicts the actual signature. Route via r.forRepo(owner, repo).

🐛 Proposed fix
 func (r *Router) UpdateIssueComment(owner, repo string, commentID int64, body string) error {
-	// commentID-based calls don't have owner/repo; fallback for now.
-	return r.fallback.UpdateIssueComment(owner, repo, commentID, body)
+	return r.forRepo(owner, repo).UpdateIssueComment(owner, repo, commentID, body)
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func (r *Router) UpdateIssueComment(owner, repo string, commentID int64, body string) error {
// commentID-based calls don't have owner/repo; fallback for now.
return r.fallback.UpdateIssueComment(owner, repo, commentID, body)
}
func (r *Router) UpdateIssueComment(owner, repo string, commentID int64, body string) error {
return r.forRepo(owner, repo).UpdateIssueComment(owner, repo, commentID, body)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/hosting/router.go` around lines 232 - 235, Update
Router.UpdateIssueComment to dispatch through r.forRepo(owner, repo) before
invoking UpdateIssueComment, so the repository’s configured provider handles the
request. Remove the stale fallback-only comment and preserve the existing method
arguments and error propagation.

Comment on lines +279 to +302
func extractOwnerRepo(repoURL string) (string, string) {
repoURL = strings.TrimSuffix(repoURL, ".git")

// SSH format: git@host:owner/repo
if strings.Contains(repoURL, ":") && !strings.Contains(repoURL, "://") {
parts := strings.SplitN(repoURL, ":", 2)
if len(parts) == 2 {
pathParts := strings.Split(parts[1], "/")
if len(pathParts) >= 2 {
return strings.Join(pathParts[:len(pathParts)-1], "/"), pathParts[len(pathParts)-1]
}
}
}

// HTTPS format: https://host/owner/[subgroups/]repo
parts := strings.Split(repoURL, "/")
if len(parts) >= 5 {
repo := parts[len(parts)-1]
owner := strings.Join(parts[3:len(parts)-1], "/")
return owner, repo
}

return "", fmt.Sprintf("unknown-%s", repoURL)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Minor: odd sentinel return on parse failure. On an unparseable URL, extractOwnerRepo returns ("", "unknown-<url>"), which then becomes the workspaceDirs/repoProviders key. This silently maps to a bogus repo instead of failing loudly, and diverges from main.go's extractOwnerRepoFromURL (which returns "",""). Return "","" and let callers fall back explicitly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/hosting/router.go` around lines 279 - 302, The parse-failure path in
extractOwnerRepo currently returns a fabricated repository name. Change its
final return to empty owner and repository strings, matching
extractOwnerRepoFromURL in main.go, and leave callers to apply any explicit
fallback behavior.

@AdamKaabyia AdamKaabyia closed this Aug 4, 2026
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.

1 participant