use async_trait::async_trait;
use serde::Deserialize;
use crate::fetcher::forge_items::{self, LIST_SHAPES};
use crate::options::OptionSchema;
use crate::payload::{Body, Payload};
use crate::render::Shape;
use super::super::{FetchContext, FetchError, Fetcher, Safety};
use super::client::{default_host, rest_get};
use super::common::{ProjectPath, cache_key, parse_options, payload, resolve_project};
use super::items::{GitlabIssueLike, sample_rows, to_forge_rows};
use super::my_mrs::resolve_host;
const DEFAULT_LIMIT: u32 = 10;
const OPTION_SCHEMAS: &[OptionSchema] = &[
OptionSchema {
name: "host",
type_hint: "hostname",
required: false,
default: Some("gitlab.com"),
description: "GitLab instance host. Use this for self-hosted GitLab.",
},
OptionSchema {
name: "project",
type_hint: "\"group/name\" or \"group/sub/name\"",
required: false,
default: Some("git remote of cwd"),
description: "Project to query. Falls back to the current directory's GitLab remote.",
},
OptionSchema {
name: "limit",
type_hint: "integer (1..=30)",
required: false,
default: Some("10"),
description: "Maximum number of issues to show.",
},
];
pub struct GitlabRepoIssues;
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Options {
#[serde(default)]
pub host: Option<String>,
#[serde(default)]
pub project: Option<String>,
#[serde(default)]
pub limit: Option<u32>,
}
#[async_trait]
impl Fetcher for GitlabRepoIssues {
fn name(&self) -> &str {
"gitlab_repo_issues"
}
fn safety(&self) -> Safety {
Safety::Safe
}
fn description(&self) -> &'static str {
"Open issues against one specific GitLab project. Mirrors `github_repo_issues` for GitLab."
}
fn refresh_interval(&self) -> u64 {
60 * 10
}
fn shapes(&self) -> &[Shape] {
LIST_SHAPES
}
fn option_schemas(&self) -> &[OptionSchema] {
OPTION_SCHEMAS
}
fn cache_key(&self, ctx: &FetchContext) -> String {
cache_key(self.name(), ctx, &cache_extra(ctx))
}
fn sample_body(&self, shape: Shape) -> Option<Body> {
let rows = sample_rows(&[
(
"#75",
"Heatmap renderer eats narrow columns",
Some("https://gitlab.com/g/splashboard/-/issues/75"),
3,
1_774_000_000,
),
(
"#71",
"gitlab_pipeline_status: respect branch option",
Some("https://gitlab.com/g/splashboard/-/issues/71"),
1,
1_773_800_000,
),
]);
forge_items::dispatch_sample(&rows, shape, "open issue", "open issues")
}
async fn fetch(&self, ctx: &FetchContext) -> Result<Payload, FetchError> {
let opts: Options = parse_options(ctx.options.as_ref()).map_err(FetchError::Failed)?;
let host = resolve_host(opts.host.as_deref())?;
let project = resolve_project(opts.project.as_deref(), &host)?;
let limit = opts.limit.unwrap_or(DEFAULT_LIMIT).clamp(1, 30);
let path = format!(
"/projects/{}/issues?state=opened&per_page={limit}&order_by=updated_at",
project.encoded()
);
let items: Vec<GitlabIssueLike> = rest_get(&host, &path).await?;
let rows = to_forge_rows(&items, false);
let shape = ctx.shape.unwrap_or(Shape::LinkedTextBlock);
let body = forge_items::dispatch_rows_async(rows, shape, "open issue", "open issues").await;
Ok(payload(body))
}
}
fn cache_extra(ctx: &FetchContext) -> String {
let host = ctx
.options
.as_ref()
.and_then(|v| v.get("host"))
.and_then(|v| v.as_str())
.unwrap_or(default_host())
.to_string();
let project = ctx
.options
.as_ref()
.and_then(|v| v.get("project"))
.and_then(|v| v.as_str())
.map(String::from)
.or_else(|| {
resolve_project(None, &host)
.ok()
.map(|p: ProjectPath| p.as_str().to_string())
})
.unwrap_or_default();
format!("{host}|{project}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn options_deserialize_and_reject_unknown_keys() {
let opts: Options =
toml::from_str("host = \"git.example.org\"\nproject = \"g/p\"\nlimit = 3").unwrap();
assert_eq!(opts.host.as_deref(), Some("git.example.org"));
assert_eq!(opts.project.as_deref(), Some("g/p"));
assert_eq!(opts.limit, Some(3));
assert!(toml::from_str::<Options>("bogus = 1").is_err());
}
#[test]
fn shapes_table_matches_shared_list() {
let fetcher = GitlabRepoIssues;
assert_eq!(fetcher.shapes(), LIST_SHAPES);
assert_eq!(fetcher.safety(), Safety::Safe);
assert_eq!(fetcher.default_shape(), Shape::LinkedTextBlock);
}
#[test]
fn sample_body_covers_every_list_shape() {
let fetcher = GitlabRepoIssues;
for shape in LIST_SHAPES {
assert!(fetcher.sample_body(*shape).is_some(), "missing {shape:?}");
}
}
}