Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 29 additions & 4 deletions src/commands/list/ci_status/gitlab.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,30 +14,55 @@
//!
//! See: <https://github.com/max-sixty/worktrunk/issues/764>

use dashmap::DashMap;
use serde::Deserialize;
use std::path::Path;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use worktrunk::git::Repository;

use super::{
CiBranchName, CiSource, CiStatus, MAX_PRS_TO_FETCH, PrRef, PrStatus, ReviewState,
is_retriable_error, non_interactive_cmd, parse_json,
};

/// Project IDs already resolved in this process, keyed by the worktree root
/// `glab` was run from.
///
/// Keyed by path rather than held in a bare static because tests run many
/// commands in one process, matching the path-keyed process-wide caches in
/// `git::repository`. A lookup that failed caches as `None`: retrying it once
/// per row is the cost this exists to remove.
Comment thread
worktrunk-bot marked this conversation as resolved.
Outdated
static GITLAB_PROJECT_IDS: LazyLock<DashMap<PathBuf, Option<u64>>> = LazyLock::new(DashMap::new);

/// Get the GitLab project ID for a repository.
///
/// Used for client-side filtering of MRs by source project.
/// This is the GitLab equivalent of `get_origin_owner` for GitHub.
/// Used for client-side filtering of MRs by source project. The GitHub
/// equivalent is [`branch_owner_repo`](super::branch_owner_repo), which reads
/// local git config; this one has to ask the GitLab API, so it is memoized for
/// the process. [`detect_gitlab`] runs once per branch and the project ID is a
/// repo-level constant, so without that a `wt list --full` over N worktrees
/// would spend N round trips resolving the same value — and `wt list` cannot
/// finish until every row's task returns.
///
/// The `entry` API holds the shard lock across the fetch, so concurrent rows
/// sharing the key wait on the first call rather than each spawning their own.
///
/// Returns None if glab is not configured for this repo (e.g., non-GitLab
/// remote, auth issues).
fn gitlab_project_id(repo: &Repository) -> Option<u64> {
let repo_root = repo.current_worktree().root().ok()?;
*GITLAB_PROJECT_IDS
.entry(repo_root.clone())
.or_insert_with(|| fetch_gitlab_project_id(&repo_root))
}

/// Ask `glab` for this repository's project ID.
fn fetch_gitlab_project_id(repo_root: &Path) -> Option<u64> {
// Use glab repo view to get the project info as JSON
// Disable color/pager to avoid ANSI noise in JSON output
let output = non_interactive_cmd("glab")
.args(["repo", "view", "--output", "json"])
.current_dir(&repo_root)
.current_dir(repo_root)
.env("PAGER", "cat")
.run()
.ok()?;
Expand Down
61 changes: 60 additions & 1 deletion tests/integration_tests/ci_status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

use crate::common::{
TestRepo, make_snapshot_cmd,
mock_commands::{MockConfig, MockResponse},
mock_commands::{MockConfig, MockResponse, mock_calls},
repo, setup_snapshot_settings, wt_command,
};
use ansi_str::AnsiStr;
Expand Down Expand Up @@ -641,6 +641,65 @@ fn test_list_full_with_gitlab_filters_by_project_id(mut repo: TestRepo) {
);
}

/// `glab repo view` answers a repo-level question over the GitLab API, but MR
/// detection runs once per row — so a table of N worktrees used to spend N
/// round trips resolving one constant, on a command that cannot finish until
/// every row's task returns. Assert the spawn count, not the rendering: the
/// table looks identical either way.
#[rstest]
fn test_gitlab_project_id_resolved_once_per_command(mut repo: TestRepo) {
let head_sha = setup_gitlab_repo_with_feature(&mut repo);

let mr_json = format!(
r#"[{{
"iid": 1,
"sha": "{}",
"has_conflicts": false,
"detailed_merge_status": null,
"head_pipeline": {{"status": "success"}},
"source_project_id": 12345,
"web_url": "https://gitlab.com/test-group/test-project/-/merge_requests/1"
}}]"#,
head_sha
);
repo.setup_mock_glab_with_ci_data(&mr_json, Some(12345));

// Outside the repo under test, so the log can't dirty the working tree the
// command is inspecting.
let call_log = tempfile::tempdir().unwrap();
let mut cmd = repo.wt_command();
cmd.args(["list", "--full"]);
repo.configure_mock_commands(&mut cmd);
cmd.env("WORKTRUNK_TEST_MOCK_CALL_LOG_DIR", call_log.path());
let output = cmd.output().unwrap();
assert!(
output.status.success(),
"wt list --full should succeed: {}",
String::from_utf8_lossy(&output.stderr)
);

let calls = mock_calls(call_log.path(), "glab");
let repo_views: Vec<_> = calls
.iter()
.filter(|call| call.starts_with("repo view"))
.collect();
// More than one row reached MR detection, so a per-row lookup would show up
// as more than one `repo view`.
let mr_lists = calls
.iter()
.filter(|call| call.starts_with("mr list"))
.count();
assert!(
mr_lists > 1,
"the fixture must exercise several rows, else the count below proves nothing. calls: {calls:#?}"
);
assert_eq!(
repo_views.len(),
1,
"the project ID is repo-level, so it costs exactly one `glab repo view` however many rows ask for it. calls: {calls:#?}"
);
}

// =============================================================================
// GitLab project ID edge cases (PR #846 panic prevention)
// =============================================================================
Expand Down
Loading