Skip to main content

acorn/io/api/gitlab/
mod.rs

1//! Module for interacting with GitLab API
2//!
3use crate::io::api::{
4    require_non_empty_secret, Configuration, DatabasePersistence, EmptyField, Endpoint, Fallback, Identifier, Param, Params, RemoteResource,
5    RepositoryFileMetadata, ResponseContent, TreeEntry, ValueValidator, INCLUDED_ENDPOINTS,
6};
7use crate::io::config::{RunnerDetails, RunnerStatus, RunnerType};
8use crate::io::database::schema::{ProgrammingLanguageRow, Table};
9use crate::io::database::{Database, Operations};
10use crate::io::{first_env_var, with_progress, ApiResult, ProgressType};
11use crate::prelude::var;
12use crate::prelude::HashMap;
13use crate::schema::validate::is_iso_date_or_rfc3339_timestamp;
14use crate::util::constants::env::GITLAB_TOKEN_VARIABLE_NAMES;
15use crate::util::{Label, Searchable, SemanticVersion};
16use async_trait::async_trait;
17use bon::Builder;
18use color_eyre::eyre::{self, eyre};
19use core::fmt;
20use data_encoding::BASE64;
21use derive_more::Display;
22use futures::future::BoxFuture;
23use futures::FutureExt;
24use serde::{Deserialize, Serialize};
25use serde_with::skip_serializing_none;
26use strum::EnumIs;
27use tracing::debug;
28use validator::Validate;
29
30pub mod bot;
31pub mod database;
32#[cfg(feature = "analysis")]
33pub mod intake;
34#[cfg(feature = "analysis")]
35pub mod review;
36pub mod service;
37pub mod webhook;
38
39pub use service::*;
40pub use webhook::{HookActor, HookPayload, MergeRequestAction, WebhookDelivery, WebhookOperation, WebhookOperationHandler};
41
42/// Type for GitLab events response
43pub type EventsResponse = Vec<EventDetails>;
44/// Minimal stable GitLab user identity
45pub type GitLabIdentity = Identifier<u64>;
46/// Type for GitLab API descendent groups response
47pub type GroupsResponse = Vec<GroupDetails>;
48/// Type for a merge request diff list response
49pub type MergeRequestDiffsResponse = Vec<MergeRequestDiff>;
50/// Type for a GitLab note list response
51pub type NotesResponse = Vec<Note>;
52/// Type for GitLab programming language entries
53pub type ProgrammingLanguageEntries = Vec<ProgrammingLanguageMetadata>;
54/// Type for GitLab project programming language usage entries
55pub type ProgrammingLanguageUseEntries = Vec<ProgrammingLanguageUseMetadata>;
56/// Type for a project webhook list response
57pub type ProjectWebhooksResponse = Vec<ProjectWebhook>;
58/// Type for GitLab API all runners response
59pub type RunnersResponse = Vec<RunnerMetadata>;
60/// Trait for adding creation and registration functionality (e.g., runners, issues, merge requests, etc.)
61pub trait Create {
62    /// Create a new instance of the struct with default values
63    fn create(_options: &Options) -> ApiResult<Self>
64    where
65        Self: Sized,
66    {
67        Err(eyre!("GitLab struct creation is not implemented"))
68    }
69    /// Register a new instance of the struct with specified values
70    fn register(self) -> ApiResult<Self>
71    where
72        Self: Sized,
73    {
74        Err(eyre!("GitLab struct registration is not implemented"))
75    }
76}
77/// Access level of the runner
78#[derive(Clone, Debug, Serialize, Deserialize)]
79#[serde(rename_all = "snake_case")]
80pub enum AccessLevel {
81    /// Not protected
82    NotProtected,
83    /// Ref protected
84    RefProtected,
85}
86/// State published through GitLab's external commit status API
87#[derive(Clone, Copy, Debug, Display, Eq, PartialEq)]
88pub enum CommitStatusState {
89    /// Analysis is running
90    #[display("running")]
91    Running,
92    /// Analysis passed
93    #[display("success")]
94    Success,
95    /// Analysis failed
96    #[display("failed")]
97    Failed,
98}
99/// GitLab emoji shortcodes
100///
101/// See <https://www.webfx.com/tools/emoji-cheat-sheet/> for full list of supported emoji shortcodes
102#[derive(Clone, Debug, Display, Serialize, Deserialize)]
103pub enum Emoji {
104    /// :seedling: 🌱
105    #[display(":seedling:")]
106    Seedling,
107}
108/// GitLab event action name
109///
110/// See <https://docs.gitlab.com/user/profile/contributions_calendar/#user-contribution-events> for more information
111#[derive(Clone, Debug, EnumIs, Serialize, Deserialize)]
112pub enum EventAction {
113    /// Approved a merge request
114    #[serde(rename = "approved")]
115    Approved,
116    /// Closed an item
117    #[serde(rename = "closed")]
118    Closed,
119    /// Commented on any Noteable record
120    #[serde(rename = "commented")]
121    Commented,
122    /// Commented on (legacy format)
123    #[serde(rename = "commented on")]
124    CommentedOn,
125    /// Created an item
126    #[serde(rename = "created")]
127    Created,
128    /// Destroyed an item
129    #[serde(rename = "destroyed")]
130    Destroyed,
131    /// Expired membership
132    #[serde(rename = "expired")]
133    Expired,
134    /// Joined a project
135    #[serde(rename = "joined")]
136    Joined,
137    /// Left a project
138    #[serde(rename = "left")]
139    Left,
140    /// Merged a merge request
141    #[serde(rename = "merged")]
142    Merged,
143    /// Pushed commits
144    #[serde(rename = "pushed")]
145    Pushed,
146    /// Pushed to a branch (legacy format)
147    #[serde(rename = "pushed to")]
148    PushedTo,
149    /// Reopened an item
150    #[serde(rename = "reopened")]
151    Reopened,
152    /// Updated an item
153    #[serde(rename = "updated")]
154    Updated,
155    /// Deleted a branch
156    #[serde(rename = "deleted")]
157    Deleted,
158    /// Accepted a merge request
159    #[serde(rename = "accepted")]
160    Accepted,
161    /// Other/unknown action
162    #[serde(other)]
163    Unknown,
164}
165/// Event filter keys used to filter events for a given project
166///
167/// See <https://docs.gitlab.com/api/events/#list-all-visible-events-for-a-project> for more information
168#[derive(Clone, Debug, Serialize, Deserialize)]
169#[serde(rename_all = "snake_case")]
170pub enum EventFilterKey {
171    /// Contribution event action type ([`EventAction`])
172    ///
173    /// See [GitLab API docs for user contribution events](https://docs.gitlab.com/user/profile/contributions_calendar/#user-contribution-events) for more information
174    Action,
175    /// Specified target event ([`TargetType`])
176    TargetType,
177    /// If defined, returns events created after the specified date.
178    After,
179    /// If defined, returns events created before the specified date.
180    Before,
181    /// Sort order
182    Sort,
183}
184/// Group visibility level
185#[derive(Clone, Debug, Default, Serialize, Deserialize)]
186#[serde(rename_all = "snake_case")]
187pub enum GroupVisibility {
188    /// Public visibility
189    #[default]
190    Public,
191    /// Internal visibility
192    Internal,
193    /// Private visibility
194    Private,
195}
196/// GitLab note metadata used by merge request and work-item workflows
197#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
198#[serde(untagged)]
199pub enum Note {
200    /// Issue or Task note used for citation intake
201    WorkItem {
202        /// Numeric note identifier
203        #[serde(rename = "id")]
204        identifier: u64,
205        /// Note body
206        body: String,
207        /// Note author
208        author: WorkItemUser,
209        /// Whether this is a system note
210        #[serde(default)]
211        system: bool,
212        /// Whether this note is confidential
213        #[serde(default)]
214        confidential: bool,
215        /// Whether this note is internal
216        #[serde(default)]
217        internal: bool,
218    },
219    /// Merge request note used for idempotent report updates
220    MergeRequest {
221        /// Numeric note identifier
222        #[serde(rename = "id")]
223        identifier: u64,
224        /// Note body
225        body: String,
226        /// Note author
227        author: Option<GitLabIdentity>,
228    },
229}
230/// Valid values for pagination order_by field
231///
232/// Projects can be ordered by
233/// - `created_at` (default)
234/// - `id`
235/// - `last_activity_at`
236/// - `name`
237/// - `path`
238/// - `similarity`
239/// - `star_count`
240/// - `updated_at`
241///
242/// Groups can be ordered by
243/// - `name` (default)
244/// - `id`
245/// - `path`
246/// - `similarity`
247///
248/// Issues can be ordered by
249/// - `created_at` (default)
250/// - `due_date`
251/// - `label_priority`
252/// - `milestone_due`
253/// - `popularity`
254/// - `priority`
255/// - `relative_position`
256/// - `title`
257/// - `updated_at`
258/// - `weight`
259#[derive(Clone, Debug, Serialize, Deserialize)]
260#[serde(rename_all = "snake_case")]
261pub enum OrderByValue {
262    /// Sort by creation timestamp
263    CreatedAt,
264    /// Sort by full hierarchical name
265    FullName,
266    /// Sort by unique identifier
267    #[serde(rename = "id")]
268    Identifier,
269    /// Sort by label priority
270    LabelPriority,
271    /// Sort by last activity timestamp
272    LastActivityAt,
273    /// Sort by milestone due date
274    MilestoneDue,
275    /// Sort by human-readable name
276    Name,
277    /// Sort by URL-encoded path
278    Path,
279    /// Sort by popularity
280    Popularity,
281    /// Sort by due date
282    DueDate,
283    /// Sort by priority
284    Priority,
285    /// Sort by manual relative position
286    RelativePosition,
287    /// Sort by search similarity score
288    Similarity,
289    /// Sort by title
290    Title,
291    /// Sort by last update timestamp
292    UpdatedAt,
293    /// Sort by weight/priority
294    Weight,
295}
296/// Pagination list parameters
297///
298/// See <https://docs.gitlab.com/api/rest/#keyset-based-pagination> for more information
299#[derive(Clone, Debug, Serialize, Deserialize)]
300#[serde(rename_all = "snake_case")]
301pub enum PaginationKey {
302    /// Column by which to order by
303    OrderBy,
304    /// Page number to retrieve (default: 1)
305    Page,
306    /// Enable keyset pagination
307    Pagination,
308    /// Number of items to list per page (default: 20, max: 100)
309    PerPage,
310    /// Sort order
311    Sort,
312}
313/// Valid values for pagination sort field
314#[derive(Clone, Debug, Default, Serialize, Deserialize)]
315#[serde(rename_all = "snake_case")]
316pub enum SortValue {
317    /// Descending order
318    #[default]
319    #[serde(rename = "desc")]
320    Descending,
321    /// Ascending order
322    #[serde(rename = "asc")]
323    Ascending,
324}
325/// GitLab event target type
326#[derive(Clone, Debug, EnumIs, Serialize, Deserialize)]
327#[serde(rename_all = "PascalCase")]
328pub enum TargetType {
329    /// Epic
330    Epic,
331    /// Issue
332    Issue,
333    /// Merge request
334    MergeRequest,
335    /// Milestone
336    Milestone,
337    /// Note/comment
338    Note,
339    /// Project
340    Project,
341    /// Snippet
342    Snippet,
343    /// User
344    User,
345    /// Other/unknown target type
346    #[serde(other)]
347    Unknown,
348}
349/// Minimal response from GitLab's external commit status API
350#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
351pub struct CommitStatus {
352    /// Status context name
353    pub name: String,
354    /// Commit SHA
355    pub sha: String,
356    /// Published state
357    pub status: String,
358    /// Human-readable description
359    pub description: Option<String>,
360    /// Optional target URL
361    pub target_url: Option<String>,
362}
363/// GitLab API error response
364///
365/// Captures error responses from the GitLab API, which can be a message string,
366/// a message object with field-level errors, or an error/error_description pair.
367#[derive(Clone, Debug, Serialize, Deserialize)]
368pub struct ErrorResponse {
369    /// Error message string or object with field-level errors
370    message: Option<serde_json::Value>,
371    /// Simple error string (OAuth-style)
372    error: Option<String>,
373    /// Detailed error description (OAuth-style)
374    error_description: Option<String>,
375}
376/// GitLab event details
377///
378/// See <https://docs.gitlab.com/api/events/>
379#[skip_serializing_none]
380#[derive(Clone, Debug, Serialize, Deserialize)]
381pub struct EventDetails {
382    /// Numeric ID of the event
383    #[serde(rename = "id")]
384    pub identifier: u64,
385    /// Numeric project identifier
386    pub project_id: u64,
387    /// Event action name
388    pub action_name: EventAction,
389    /// Numeric target identifier
390    pub target_id: Option<u64>,
391    /// Internal target identifier
392    pub target_iid: Option<u64>,
393    /// Target type
394    pub target_type: TargetType,
395    /// Numeric author identifier
396    pub author_id: u64,
397    /// Target title
398    pub target_title: String,
399    /// Creation timestamp in ISO-8601 format
400    pub created_at: String,
401    /// Author details
402    pub author: UserMetadata,
403    /// Whether the event was imported
404    pub imported: bool,
405    /// Source from which the event was imported
406    pub imported_from: String,
407    /// Push data details (present only for push events)
408    pub push_data: Option<PushData>,
409    /// Author username
410    pub author_username: String,
411    /// Note details for note events
412    pub note: Option<NoteMetadata>,
413}
414/// Runner group details
415#[skip_serializing_none]
416#[derive(Clone, Debug, Serialize, Deserialize)]
417pub struct GroupDetails {
418    /// Numeric ID of the group
419    #[serde(rename = "id")]
420    pub identifier: u64,
421    /// URL of the group page
422    #[serde(rename = "web_url")]
423    pub url: String,
424    /// Group name
425    pub name: String,
426    /// Group path
427    pub path: Option<String>,
428    /// Group description
429    pub description: Option<String>,
430    /// Whether emails are disabled
431    #[serde(default)]
432    pub emails_disabled: bool,
433    /// Whether emails are enabled
434    #[serde(default)]
435    pub emails_enabled: bool,
436    /// Whether diff previews appear in emails
437    #[serde(default)]
438    pub show_diff_preview_in_email: bool,
439    /// Group visibility level
440    pub visibility: Option<GroupVisibility>,
441    /// Whether sharing with other groups is locked
442    #[serde(default)]
443    pub share_with_group_lock: bool,
444    /// Whether two-factor authentication is required
445    #[serde(default)]
446    pub require_two_factor_authentication: bool,
447    /// Whether LFS is enabled
448    #[serde(default)]
449    pub lfs_enabled: bool,
450    /// Whether the group is archived
451    #[serde(default)]
452    pub archived: bool,
453    /// Duo features enabled flag
454    #[serde(default)]
455    pub duo_features_enabled: bool,
456    /// Duo features lock flag
457    #[serde(default)]
458    pub lock_duo_features_enabled: bool,
459    /// Auto Duo code review enabled flag
460    #[serde(default)]
461    pub auto_duo_code_review_enabled: bool,
462    /// Whether math rendering limits are enabled
463    #[serde(default)]
464    pub math_rendering_limits_enabled: bool,
465    /// Whether math rendering limits are locked
466    #[serde(default)]
467    pub lock_math_rendering_limits_enabled: bool,
468    /// Whether access requests are enabled
469    #[serde(default)]
470    pub request_access_enabled: bool,
471    /// Grace period for two-factor authentication
472    pub two_factor_grace_period: Option<u64>,
473    /// Project creation level
474    pub project_creation_level: Option<String>,
475    /// Auto DevOps enabled flag
476    pub auto_devops_enabled: Option<bool>,
477    /// Subgroup creation level
478    pub subgroup_creation_level: Option<String>,
479    /// Whether mentions are disabled
480    pub mentions_disabled: Option<bool>,
481    /// Default branch name
482    pub default_branch: Option<String>,
483    /// Default branch protection mode
484    pub default_branch_protection: Option<u64>,
485    /// Default branch protection policy details
486    pub default_branch_protection_defaults: Option<RunnerGroupBranchProtectionDefaults>,
487    /// Group avatar URL
488    #[serde(rename = "avatar_url")]
489    pub avatar_url: Option<String>,
490    /// Group full display name
491    pub full_name: Option<String>,
492    /// Group full path
493    pub full_path: Option<String>,
494    /// Group creation timestamp in ISO-8601 format
495    pub created_at: Option<String>,
496    /// Parent group identifier
497    pub parent_id: Option<u64>,
498    /// Organization identifier
499    pub organization_id: Option<u64>,
500    /// Shared runners setting
501    pub shared_runners_setting: Option<String>,
502    /// Maximum artifacts size limit
503    pub max_artifacts_size: Option<u64>,
504    /// Group deletion schedule date
505    pub marked_for_deletion_on: Option<String>,
506    /// LDAP common name
507    #[serde(rename = "ldap_cn")]
508    pub ldap_common_name: Option<String>,
509    /// LDAP access value
510    pub ldap_access: Option<String>,
511    /// File template project identifier
512    pub file_template_project_id: Option<u64>,
513    /// Wiki access level
514    pub wiki_access_level: Option<String>,
515    /// Duo core features enabled flag
516    pub duo_core_features_enabled: Option<bool>,
517}
518/// GitLab instance version metadata
519#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
520pub struct InstanceVersion {
521    /// Semantic version string reported by GitLab
522    pub version: String,
523    /// Build revision when reported
524    pub revision: Option<String>,
525}
526/// Merge request metadata required for head-specific analysis
527#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
528pub struct MergeRequestDetails {
529    /// Merge request IID within the target project
530    pub iid: u64,
531    /// Target project identifier
532    pub project_id: u64,
533    /// Source project identifier, including fork projects
534    pub source_project_id: Option<u64>,
535    /// Current source head commit SHA
536    pub sha: String,
537    /// Merge request title
538    pub title: String,
539    /// Merge request description
540    #[serde(default)]
541    pub description: String,
542    /// Browser URL for the merge request
543    pub web_url: String,
544}
545/// One changed file returned by GitLab's merge request diffs API
546#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
547pub struct MergeRequestDiff {
548    /// Path before the change
549    pub old_path: String,
550    /// Path after the change
551    pub new_path: String,
552    /// Whether this is a newly added file
553    #[serde(default)]
554    pub new_file: bool,
555    /// Whether the file was renamed
556    #[serde(default)]
557    pub renamed_file: bool,
558    /// Whether the file was deleted
559    #[serde(default)]
560    pub deleted_file: bool,
561    /// Whether GitLab classified the file as generated
562    #[serde(default)]
563    pub generated_file: bool,
564    /// Whether the displayed diff was collapsed
565    #[serde(default)]
566    pub collapsed: bool,
567    /// Whether the diff exceeded GitLab's display limits
568    #[serde(default)]
569    pub too_large: bool,
570}
571/// GitLab API response for creating a merge request comment
572/// ### Example JSON response
573/// ```json
574/// {
575///     "id": 1774626,
576///     "type": null,
577///     "body": "comment text",
578///     "author": {
579///         "id": 4862,
580///         "username": "o9w",
581///         "public_email": "wohlgemuthjh@ornl.gov",
582///         "name": "Wohlgemuth, Jason",
583///         "state": "active",
584///         "locked": false,
585///         "avatar_url": "https://code.ornl.gov/uploads/-/system/user/avatar/4862/avatar.png",
586///         "web_url": "https://code.ornl.gov/o9w"
587///     },
588///     "created_at": "2026-04-11T22:47:15.052Z",
589///     "updated_at": "2026-04-11T22:47:15.052Z",
590///     "system": false,
591///     "noteable_id": 116322,
592///     "noteable_type": "MergeRequest",
593///     "project_id": 16689,
594///     "resolvable": false,
595///     "confidential": false,
596///     "internal": false,
597///     "imported": false,
598///     "imported_from": "none",
599///     "noteable_iid": 11,
600///     "commands_changes": {}
601/// }
602///
603/// ```
604#[skip_serializing_none]
605#[derive(Clone, Debug, Serialize, Deserialize)]
606pub struct NoteMetadata {
607    /// Numeric ID of the note
608    #[serde(rename = "id")]
609    pub identifier: u64,
610    /// Optional note type
611    #[serde(rename = "type")]
612    pub note_type: Option<String>,
613    /// Comment body text
614    pub body: String,
615    /// Author details
616    pub author: UserMetadata,
617    /// Creation timestamp in ISO-8601 format
618    pub created_at: String,
619    /// Last update timestamp in ISO-8601 format
620    pub updated_at: String,
621    /// Whether this is a system note
622    pub system: bool,
623    /// Numeric identifier of associated noteable object
624    pub noteable_id: Option<u64>,
625    /// Internal identifier (IID) of associated noteable object
626    pub noteable_iid: Option<u64>,
627    /// Type of associated noteable object
628    pub noteable_type: String,
629    /// Numeric project identifier
630    pub project_id: u64,
631    /// Whether the note is resolvable
632    pub resolvable: bool,
633    /// Whether the note is confidential
634    pub confidential: bool,
635    /// Whether the note is internal
636    pub internal: bool,
637    /// Whether the note was imported
638    pub imported: bool,
639    /// Source from which note was imported
640    pub imported_from: String,
641    /// Parsed quick action command changes
642    pub commands_changes: serde_json::Value,
643}
644/// Options for GitLab API requests
645#[derive(Builder, Clone, Debug)]
646#[builder(start_fn = with_token, on(String, into))]
647pub struct Options {
648    /// Authentication token
649    #[builder(start_fn)]
650    pub token: String,
651    /// Request body payload
652    pub body: Option<String>,
653    /// GitLab domain (defaults to gitlab.com)
654    #[builder(default = String::from("gitlab.com"))]
655    pub domain: String,
656    /// Project or group identifier
657    pub identifier: Option<String>,
658    /// Repository path used for tree requests
659    pub path: Option<String>,
660    /// Page number to retrieve
661    #[builder(default = 1)]
662    pub page: u32,
663    /// Internal resource identifier (e.g., merge request IID)
664    pub internal_identifier: Option<String>,
665    /// Exact commit SHA used by head-specific operations
666    pub sha: Option<String>,
667    /// GitLab runner metadata necessary for creation
668    #[builder(default = RunnerMetadata::default())]
669    pub runner_metadata: RunnerMetadata,
670    /// Custom API parameters to include in every request
671    #[builder(default = vec![])]
672    pub custom_params: Vec<Param>,
673}
674/// Language metadata details from GitLab languages YAML file
675#[skip_serializing_none]
676#[derive(Clone, Debug, Default, Serialize, Deserialize)]
677pub struct ProgrammingLanguageDetails {
678    /// Canonical language identifier
679    pub language_id: Option<u64>,
680    /// Language category (for example, `programming`, `data`, `markup`, or `prose`)
681    #[serde(rename = "type")]
682    pub language_type: Option<String>,
683    /// Display color (hex string)
684    pub color: Option<String>,
685    /// Optional parent language group
686    pub group: Option<String>,
687}
688/// Normalized programming language metadata with explicit language name
689#[skip_serializing_none]
690#[derive(Clone, Debug, Default, Serialize, Deserialize)]
691pub struct ProgrammingLanguageMetadata {
692    /// Display name of the language
693    pub name: String,
694    /// Canonical language identifier
695    pub language_id: Option<u64>,
696    /// Language category (for example, `programming`, `data`, `markup`, or `prose`)
697    pub language_type: Option<String>,
698    /// Display color (hex string)
699    pub color: Option<String>,
700    /// Optional parent language group
701    pub group: Option<String>,
702}
703/// Parsed response for GitLab language metadata
704#[derive(Clone, Debug, Default, Serialize)]
705pub struct ProgrammingLanguagesResponse {
706    /// Flattened language metadata entries
707    pub languages: ProgrammingLanguageEntries,
708}
709/// Programming language usage entry for a project
710#[derive(Clone, Debug, Default, Serialize, Deserialize)]
711pub struct ProgrammingLanguageUseMetadata {
712    /// Display name of the language
713    pub name: String,
714    /// Relative share of repository content for this language
715    pub percentage: f64,
716}
717/// Parsed response for GitLab project language usage
718#[derive(Clone, Debug, Default, Serialize)]
719pub struct ProgrammingLanguageUseResponse {
720    /// Flattened language usage entries
721    pub languages: ProgrammingLanguageUseEntries,
722}
723/// Effective project membership used for command authorization
724#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
725pub struct ProjectMember {
726    /// Numeric user identifier
727    #[serde(rename = "id")]
728    pub identifier: u64,
729    /// Effective GitLab access level
730    pub access_level: u64,
731}
732/// GitLab project webhook metadata used for idempotent registration
733#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
734pub struct ProjectWebhook {
735    /// Numeric hook identifier
736    pub id: u64,
737    /// Delivery URL
738    pub url: String,
739    /// Whether merge request events are enabled
740    #[serde(default)]
741    pub merge_requests_events: bool,
742    /// Whether note events are enabled
743    #[serde(default)]
744    pub note_events: bool,
745    /// Whether TLS certificate verification is enabled
746    #[serde(default)]
747    pub enable_ssl_verification: bool,
748    /// Whether a legacy secret token exists
749    #[serde(default)]
750    pub token_present: bool,
751    /// Whether a Standard Webhooks signing token exists
752    #[serde(default)]
753    pub signing_token_present: bool,
754}
755#[derive(Serialize)]
756struct ProjectWebhookRequest<'a> {
757    url: &'a str,
758    name: &'static str,
759    description: &'static str,
760    merge_requests_events: bool,
761    note_events: bool,
762    enable_ssl_verification: bool,
763    #[serde(skip_serializing_if = "Option::is_none")]
764    token: Option<&'a str>,
765    #[serde(skip_serializing_if = "Option::is_none")]
766    signing_token: Option<&'a str>,
767}
768/// Push data details for a GitLab event
769#[skip_serializing_none]
770#[derive(Clone, Debug, Serialize, Deserialize)]
771pub struct PushData {
772    /// Number of commits pushed
773    pub commit_count: u64,
774    /// Push action type
775    pub action: EventAction,
776    /// Reference type (branch or tag)
777    pub ref_type: String,
778    /// SHA of the commit before the push
779    pub commit_from: Option<String>,
780    /// SHA of the commit after the push
781    pub commit_to: Option<String>,
782    /// Reference name (branch or tag name)
783    #[serde(rename = "ref")]
784    pub ref_name: String,
785    /// Title of the most recent commit
786    pub commit_title: Option<String>,
787    /// Number of references affected
788    pub ref_count: Option<u64>,
789}
790/// Repository file metadata and Base64-encoded content
791#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
792pub struct RepositoryFile {
793    /// Full repository-relative path
794    pub file_path: String,
795    /// File size in bytes
796    pub size: u64,
797    /// Content encoding reported by GitLab
798    pub encoding: String,
799    /// Encoded file content
800    pub content: String,
801}
802/// GitLab API response for creating a runner
803/// ### Example JSON response
804/// ```json
805/// {
806///     "id": 9171,
807///     "token": "<access-token>",
808///     "token_expires_at": null
809/// }
810/// ```
811#[skip_serializing_none]
812#[derive(Clone, Debug, Serialize, Deserialize)]
813pub struct RunnerCreationResponse {
814    /// Numeric ID of the runner
815    #[serde(default)]
816    #[serde(rename = "id")]
817    pub identifier: u64,
818    /// Runner access token
819    pub token: Option<String>,
820    /// Runner access token expiration timestamp
821    pub token_expires_at: Option<String>,
822}
823/// Access level entry for branch protection settings
824#[skip_serializing_none]
825#[derive(Clone, Debug, Serialize, Deserialize)]
826pub struct RunnerGroupAccessLevel {
827    /// Numeric access level
828    pub access_level: u64,
829}
830/// Runner group branch protection defaults
831#[skip_serializing_none]
832#[derive(Clone, Debug, Serialize, Deserialize)]
833pub struct RunnerGroupBranchProtectionDefaults {
834    /// Access levels allowed to push
835    pub allowed_to_push: Vec<RunnerGroupAccessLevel>,
836    /// Whether force push is allowed
837    pub allow_force_push: bool,
838    /// Access levels allowed to merge
839    pub allowed_to_merge: Vec<RunnerGroupAccessLevel>,
840}
841/// GitLab API response for runner details
842/// ### Example JSON response
843/// ```json
844/// {
845///     "active": true,
846///     "paused": false,
847///     "architecture": null,
848///     "description": "test-1-20150125",
849///     "id": 6,
850///     "ip_address": "",
851///     "is_shared": false,
852///     "runner_type": "project_type",
853///     "contacted_at": "2016-01-25T16:39:48.066Z",
854///     "maintenance_note": null,
855///     "name": null,
856///     "online": true,
857///     "status": "online",
858///     "platform": null,
859///     "projects": [
860///         {
861///             "id": 1,
862///             "name": "GitLab Community Edition",
863///             "name_with_namespace": "GitLab.org / GitLab Community Edition",
864///             "path": "gitlab-foss",
865///             "path_with_namespace": "gitlab-org/gitlab-foss"
866///         }
867///     ],
868///     "revision": null,
869///     "tag_list": [
870///         "ruby",
871///         "mysql"
872///     ],
873///     "version": null,
874///     "access_level": "ref_protected",
875///     "maximum_timeout": 3600
876/// }
877/// ```
878#[skip_serializing_none]
879#[derive(Clone, Debug, Builder, Serialize, Deserialize)]
880#[builder(start_fn = init, on(String, into), on(&str, into))]
881pub struct RunnerMetadata {
882    /// Numeric ID of the runner
883    #[serde(rename = "id")]
884    pub identifier: Option<u64>,
885    /// Whether the runner is active
886    #[builder(default)]
887    #[serde(default)]
888    pub active: bool,
889    /// Whether the runner is online
890    ///
891    /// Apparently, GitLab's API may return `null` for this field when the runner has never been contacted, so we use an `Option<bool>` to capture that possibility.
892    #[serde(default)]
893    pub online: Option<bool>,
894    /// Whether the runner is paused
895    #[builder(default)]
896    #[serde(default)]
897    pub paused: bool,
898    /// Whether the runner runs untagged jobs
899    #[builder(default)]
900    #[serde(default)]
901    pub run_untagged: bool,
902    /// Whether the runner is shared
903    #[builder(default)]
904    #[serde(default, rename = "is_shared")]
905    pub shared: bool,
906    /// CPU architecture reported by the runner
907    pub architecture: Option<String>,
908    /// Runner description
909    pub description: Option<String>,
910    /// Runner IP address
911    pub ip_address: Option<String>,
912    /// Type of runner (for example, `project_type`)
913    #[builder(with = |value: &str| RunnerType::from(value))]
914    #[builder(default = RunnerType::Project)]
915    pub runner_type: RunnerType,
916    /// Created by user
917    pub created_by: Option<UserMetadata>,
918    /// Created timestamp in ISO-8601 format
919    pub created_at: Option<String>,
920    /// Last contact timestamp in ISO-8601 format
921    pub contacted_at: Option<String>,
922    /// Optional maintenance note
923    pub maintenance_note: Option<String>,
924    /// Optional display name
925    pub name: Option<String>,
926    /// Current runner status
927    pub status: Option<RunnerStatus>,
928    /// Current job execution status
929    pub job_execution_status: Option<String>,
930    /// Optional platform string
931    pub platform: Option<String>,
932    /// Projects associated with this runner
933    pub projects: Option<Vec<RunnerScope>>,
934    /// Groups associated with this runner
935    pub groups: Option<Vec<RunnerScope>>,
936    /// Optional Git revision for the runner version
937    pub revision: Option<String>,
938    /// Runner tags
939    #[builder(with = |values: &[&str]| values.iter().map(|s| s.to_string()).collect::<Vec<String>>())]
940    #[serde(rename = "tag_list")]
941    pub tags: Option<Vec<String>>,
942    /// Optional runner version
943    pub version: Option<String>,
944    /// Access level for this runner
945    pub access_level: Option<AccessLevel>,
946    /// Maximum timeout in seconds
947    pub maximum_timeout: Option<u64>,
948}
949/// Runner scope details for project or group entries
950#[skip_serializing_none]
951#[derive(Clone, Debug, Serialize, Deserialize)]
952pub struct RunnerScope {
953    /// Numeric ID of the scope entry
954    #[serde(rename = "id")]
955    pub identifier: u64,
956    /// Scope name
957    pub name: String,
958    /// Project path
959    pub path: Option<String>,
960    /// Project name including namespace
961    pub name_with_namespace: Option<String>,
962    /// Project path including namespace
963    pub path_with_namespace: Option<String>,
964    /// URL of the group page
965    #[serde(rename = "web_url")]
966    pub url: Option<String>,
967}
968#[derive(Clone, Debug, Default, Serialize)]
969/// GitLab tree response normalized to blob file paths
970pub struct TreeResponse {
971    /// Blob file paths extracted from the tree response payload
972    pub paths: Vec<String>,
973    pub(crate) entry_count: usize,
974    /// Embedded GitLab error response when the API returns an error object
975    #[serde(skip_serializing)]
976    pub(crate) error: Option<ErrorResponse>,
977}
978/// User details
979/// ### Example JSON response
980/// ```json
981/// {
982///     "avatar_url": String("https://code.ornl.gov/uploads/-/system/user/avatar/4862/avatar.png"),
983///     "id": Number(4862),
984///     "locked": Bool(false),
985///     "name": String("Wohlgemuth, Jason"),
986///     "public_email": String("wohlgemuthjh@ornl.gov"),
987///     "state": String("active"),
988///     "username": String("o9w"),
989///     "web_url": String("https://code.ornl.gov/o9w"),
990/// }
991/// ```
992#[skip_serializing_none]
993#[derive(Clone, Debug, Serialize, Deserialize)]
994pub struct UserMetadata {
995    /// URL of user avatar image
996    pub avatar_url: String,
997    /// Numeric ID of user
998    #[serde(rename = "id")]
999    pub identifier: u64,
1000    /// Whether the user is locked
1001    pub locked: bool,
1002    /// User's full name
1003    pub name: String,
1004    /// User's public email address
1005    #[serde(rename = "public_email")]
1006    pub email: Option<String>,
1007    /// User state (for example, "active")
1008    // TODO: Make an enum for this field
1009    pub state: String,
1010    /// Username/handle of the user
1011    pub username: String,
1012    /// URL of the user's profile page
1013    #[serde(rename = "web_url")]
1014    pub url: String,
1015}
1016/// Webhook endpoint and inbound authentication options
1017#[derive(Clone, Debug, Default, Eq, PartialEq, Validate)]
1018pub struct WebhookOptions {
1019    #[validate(url)]
1020    public_url: Option<String>,
1021    #[validate(required)]
1022    webhook_token: Option<String>,
1023    #[validate(required)]
1024    signing_token: Option<String>,
1025}
1026/// Result of ensuring the configured project webhook exists
1027#[derive(Clone, Debug, Eq, PartialEq)]
1028pub struct WebhookRegistration {
1029    /// Registered hook metadata
1030    pub hook: ProjectWebhook,
1031    /// True when a new hook was created rather than reused or updated
1032    pub created: bool,
1033}
1034/// GitLab Issue or Task metadata required for citation intake
1035#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1036pub struct WorkItem {
1037    /// Numeric database identifier
1038    #[serde(rename = "id")]
1039    pub identifier: u64,
1040    /// Project-scoped work-item identifier
1041    pub iid: u64,
1042    /// Project identifier
1043    pub project_id: u64,
1044    /// Work-item title
1045    pub title: String,
1046    /// Work-item description
1047    #[serde(default)]
1048    pub description: String,
1049    /// Stable creator identity
1050    pub author: WorkItemUser,
1051    /// GitLab issue type, including `issue` or `task`
1052    #[serde(default)]
1053    pub issue_type: String,
1054    /// Whether the work item is confidential
1055    #[serde(default)]
1056    pub confidential: bool,
1057}
1058/// Minimal GitLab user metadata used by work-item intake
1059#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1060pub struct WorkItemUser {
1061    /// Numeric stable user identifier
1062    #[serde(rename = "id")]
1063    pub identifier: u64,
1064    /// Current GitLab username
1065    pub username: String,
1066    /// Whether GitLab identifies the account as a bot
1067    #[serde(default)]
1068    pub bot: bool,
1069}
1070impl From<bool> for CommitStatusState {
1071    fn from(success: bool) -> Self {
1072        if success {
1073            Self::Success
1074        } else {
1075            Self::Failed
1076        }
1077    }
1078}
1079impl ErrorResponse {
1080    fn is_terminal_pagination_message(message: &str) -> bool {
1081        let message = message.to_lowercase();
1082        let invalid_page =
1083            message.contains("page") && (message.contains("invalid") || message.contains("out of range") || message.contains("not found"));
1084        let forbidden_page = message.contains("403") && message.contains("forbidden");
1085        invalid_page || forbidden_page
1086    }
1087    fn is_terminal_pagination_error(&self) -> bool {
1088        Self::is_terminal_pagination_message(&self.message())
1089    }
1090    fn message(&self) -> String {
1091        let message = self
1092            .message
1093            .as_ref()
1094            .and_then(|value| serde_json::to_string(value).ok())
1095            .unwrap_or_default();
1096        let error = self.error.clone().unwrap_or_default();
1097        let description = self.error_description.clone().unwrap_or_default();
1098        [message, error, description]
1099            .into_iter()
1100            .filter(|value| !value.trim().is_empty())
1101            .collect::<Vec<_>>()
1102            .join(" ")
1103    }
1104}
1105impl fmt::Display for EventAction {
1106    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1107        let s = match self {
1108            | EventAction::Approved => "approved",
1109            | EventAction::Closed => "closed",
1110            | EventAction::Commented => "commented",
1111            | EventAction::CommentedOn => "commented on",
1112            | EventAction::Created => "created",
1113            | EventAction::Destroyed => "destroyed",
1114            | EventAction::Expired => "expired",
1115            | EventAction::Joined => "joined",
1116            | EventAction::Left => "left",
1117            | EventAction::Merged => "merged",
1118            | EventAction::Pushed => "pushed",
1119            | EventAction::PushedTo => "pushed to",
1120            | EventAction::Reopened => "reopened",
1121            | EventAction::Updated => "updated",
1122            | EventAction::Deleted => "deleted",
1123            | EventAction::Accepted => "accepted",
1124            | EventAction::Unknown => "unknown",
1125        };
1126        write!(f, "{}", s)
1127    }
1128}
1129impl core::str::FromStr for EventAction {
1130    type Err = String;
1131
1132    fn from_str(value: &str) -> Result<Self, Self::Err> {
1133        match value {
1134            | "approved" => Ok(EventAction::Approved),
1135            | "closed" => Ok(EventAction::Closed),
1136            | "commented" => Ok(EventAction::Commented),
1137            | "commented on" => Ok(EventAction::CommentedOn),
1138            | "created" => Ok(EventAction::Created),
1139            | "destroyed" => Ok(EventAction::Destroyed),
1140            | "expired" => Ok(EventAction::Expired),
1141            | "joined" => Ok(EventAction::Joined),
1142            | "left" => Ok(EventAction::Left),
1143            | "merged" => Ok(EventAction::Merged),
1144            | "pushed" => Ok(EventAction::Pushed),
1145            | "pushed to" => Ok(EventAction::PushedTo),
1146            | "reopened" => Ok(EventAction::Reopened),
1147            | "updated" => Ok(EventAction::Updated),
1148            | "deleted" => Ok(EventAction::Deleted),
1149            | "accepted" => Ok(EventAction::Accepted),
1150            | _ => Err(format!("Invalid GitLab event action value: {value}")),
1151        }
1152    }
1153}
1154impl TryFrom<&str> for EventAction {
1155    type Error = String;
1156
1157    fn try_from(value: &str) -> Result<Self, Self::Error> {
1158        value.parse()
1159    }
1160}
1161impl fmt::Display for EventFilterKey {
1162    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1163        let s = match self {
1164            | EventFilterKey::Action => "action",
1165            | EventFilterKey::TargetType => "target_type",
1166            | EventFilterKey::After => "after",
1167            | EventFilterKey::Before => "before",
1168            | EventFilterKey::Sort => "sort",
1169        };
1170        write!(f, "{}", s)
1171    }
1172}
1173impl TryFrom<&str> for EventFilterKey {
1174    type Error = String;
1175
1176    fn try_from(value: &str) -> Result<Self, Self::Error> {
1177        match value {
1178            | "action" => Ok(EventFilterKey::Action),
1179            | "target_type" => Ok(EventFilterKey::TargetType),
1180            | "after" => Ok(EventFilterKey::After),
1181            | "before" => Ok(EventFilterKey::Before),
1182            | "sort" => Ok(EventFilterKey::Sort),
1183            | _ => Err(format!("Invalid EventFilterKey: {}", value)),
1184        }
1185    }
1186}
1187impl ValueValidator for EventFilterKey {
1188    /// Validate event filter key values according to GitLab API documentation
1189    fn is_valid(&self, value: &str) -> bool {
1190        match self {
1191            | EventFilterKey::Action => EventAction::try_from(value).is_ok(),
1192            | EventFilterKey::TargetType => TargetType::try_from(value).is_ok(),
1193            | EventFilterKey::After | EventFilterKey::Before => is_iso_date_or_rfc3339_timestamp(value),
1194            | EventFilterKey::Sort => SortValue::try_from(value).is_ok(),
1195        }
1196    }
1197}
1198impl InstanceVersion {
1199    /// Whether the instance supports Standard Webhooks signing tokens
1200    pub fn supports_signing_tokens(&self) -> bool {
1201        SemanticVersion::from(self.version.as_str()).major >= 19
1202    }
1203}
1204impl From<SemanticVersion> for InstanceVersion {
1205    fn from(version: SemanticVersion) -> Self {
1206        Self {
1207            version: version.to_string(),
1208            revision: None,
1209        }
1210    }
1211}
1212impl Note {
1213    /// Return the note's numeric identifier.
1214    pub fn identifier(&self) -> u64 {
1215        match self {
1216            | Self::WorkItem { identifier, .. } | Self::MergeRequest { identifier, .. } => *identifier,
1217        }
1218    }
1219    /// Return the note body.
1220    pub fn body(&self) -> &str {
1221        match self {
1222            | Self::WorkItem { body, .. } | Self::MergeRequest { body, .. } => body,
1223        }
1224    }
1225    /// Return the note author's numeric identifier when available.
1226    pub fn author_id(&self) -> Option<u64> {
1227        match self {
1228            | Self::WorkItem { author, .. } => Some(author.identifier),
1229            | Self::MergeRequest { author, .. } => author.as_ref().map(|author| author.identifier),
1230        }
1231    }
1232}
1233impl Options {
1234    /// Return a copy of options with an internal resource identifier set
1235    pub fn with_internal_identifier(self, value: impl Into<String>) -> Self {
1236        Self {
1237            internal_identifier: Some(value.into()),
1238            ..self
1239        }
1240    }
1241    /// Return a copy of options with an exact commit SHA set
1242    pub fn with_sha(self, value: impl Into<String>) -> Self {
1243        Self {
1244            sha: Some(value.into()),
1245            ..self
1246        }
1247    }
1248    /// Return the configured internal resource identifier
1249    pub fn internal_identifier(&self) -> ApiResult<&str> {
1250        self.internal_identifier
1251            .as_deref()
1252            .filter(|value| !value.trim().is_empty())
1253            .ok_or_else(|| eyre!("GitLab internal resource identifier is required"))
1254    }
1255    /// Return the configured exact commit SHA
1256    pub fn sha(&self) -> ApiResult<&str> {
1257        self.sha
1258            .as_deref()
1259            .filter(|value| !value.trim().is_empty())
1260            .ok_or_else(|| eyre!("GitLab commit SHA is required"))
1261    }
1262    /// Return the configured repository path
1263    pub fn path(&self) -> ApiResult<&str> {
1264        self.path
1265            .as_deref()
1266            .filter(|value| !value.trim().is_empty())
1267            .ok_or_else(|| eyre!("GitLab repository path is required"))
1268    }
1269    /// Return a copy of options with page number set
1270    pub fn with_page(self, value: u32) -> Self {
1271        Self { page: value, ..self }
1272    }
1273    /// Return a copy of options with repository path set
1274    pub fn with_path(self, value: impl Into<String>) -> Self {
1275        Self {
1276            path: Some(value.into()),
1277            ..self
1278        }
1279    }
1280    /// Return a copy of options with runner metadata set
1281    pub fn with_runner(self, metadata: RunnerMetadata) -> Self {
1282        Self {
1283            runner_metadata: metadata,
1284            ..self
1285        }
1286    }
1287}
1288impl Configuration for Options {
1289    /// Build options from common GitLab CI environment variables.
1290    /// - `CI_JOB_TOKEN` or `GITLAB_TOKEN` -> `token`
1291    /// - `CI_PROJECT_ID` -> `identifier`
1292    /// - `CI_MERGE_REQUEST_IID` -> `internal_identifier`
1293    /// - `CI_SERVER_HOST` -> `domain` (defaults to gitlab.com when unset)
1294    ///
1295    /// See <https://docs.gitlab.com/ci/variables/predefined_variables> for more information on available GitLab CI environment variables
1296    fn from_env() -> Self {
1297        if let Err(why) = dotenvy::from_filename(".env") {
1298            debug!("=> {} Load .env — {why}", Label::skip());
1299        }
1300        Self {
1301            token: first_env_var(&GITLAB_TOKEN_VARIABLE_NAMES).unwrap_or_default(),
1302            identifier: var("CI_PROJECT_ID").ok(),
1303            internal_identifier: var("CI_MERGE_REQUEST_IID").ok(),
1304            sha: var("CI_COMMIT_SHA").ok(),
1305            domain: var("CI_SERVER_HOST").unwrap_or_else(|_| "gitlab.com".to_string()),
1306            body: None,
1307            path: None,
1308            page: 1,
1309            runner_metadata: RunnerMetadata::default(),
1310            custom_params: vec![],
1311        }
1312    }
1313    /// Return a copy of options with request body payload set
1314    fn with_body(self, value: impl Into<String>) -> Self {
1315        Self {
1316            body: Some(value.into()),
1317            ..self
1318        }
1319    }
1320    /// Return a copy of options with GitLab domain set
1321    fn with_domain(self, value: impl Into<String>) -> Self {
1322        Self {
1323            domain: value.into(),
1324            ..self
1325        }
1326    }
1327    /// Return a copy of options with project or group identifier set
1328    fn with_identifier(self, value: impl Into<String>) -> Self {
1329        Self {
1330            identifier: Some(value.into()),
1331            ..self
1332        }
1333    }
1334    /// Return the authentication token
1335    fn token(&self) -> &str {
1336        &self.token
1337    }
1338    /// Return the GitLab domain
1339    fn domain(&self) -> &str {
1340        &self.domain
1341    }
1342    /// Return the optional project or group identifier
1343    fn identifier(&self) -> Option<&str> {
1344        self.identifier.as_deref()
1345    }
1346    /// Return a copy of options with custom API parameters set
1347    fn with_params(self, params: Vec<Param>) -> Self {
1348        Self {
1349            custom_params: params,
1350            ..self
1351        }
1352    }
1353    /// Return any custom API parameters
1354    fn params(&self) -> &[Param] {
1355        &self.custom_params
1356    }
1357}
1358impl Default for Options {
1359    fn default() -> Self {
1360        Self::from_env()
1361    }
1362}
1363impl TryFrom<&str> for OrderByValue {
1364    type Error = String;
1365
1366    fn try_from(value: &str) -> eyre::Result<Self, Self::Error> {
1367        match value {
1368            | "created_at" => Ok(OrderByValue::CreatedAt),
1369            | "due_date" => Ok(OrderByValue::DueDate),
1370            | "full_name" => Ok(OrderByValue::FullName),
1371            | "id" => Ok(OrderByValue::Identifier),
1372            | "label_priority" => Ok(OrderByValue::LabelPriority),
1373            | "last_activity_at" => Ok(OrderByValue::LastActivityAt),
1374            | "milestone_due" => Ok(OrderByValue::MilestoneDue),
1375            | "name" => Ok(OrderByValue::Name),
1376            | "path" => Ok(OrderByValue::Path),
1377            | "popularity" => Ok(OrderByValue::Popularity),
1378            | "priority" => Ok(OrderByValue::Priority),
1379            | "relative_position" => Ok(OrderByValue::RelativePosition),
1380            | "similarity" => Ok(OrderByValue::Similarity),
1381            | "title" => Ok(OrderByValue::Title),
1382            | "updated_at" => Ok(OrderByValue::UpdatedAt),
1383            | "weight" => Ok(OrderByValue::Weight),
1384            | _ => Err(format!("Invalid GitLab order_by value: {value}")),
1385        }
1386    }
1387}
1388impl fmt::Display for PaginationKey {
1389    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1390        let s = match self {
1391            | PaginationKey::OrderBy => "order_by",
1392            | PaginationKey::Page => "page",
1393            | PaginationKey::Pagination => "pagination",
1394            | PaginationKey::PerPage => "per_page",
1395            | PaginationKey::Sort => "sort",
1396        };
1397        write!(f, "{}", s)
1398    }
1399}
1400impl TryFrom<&str> for PaginationKey {
1401    type Error = String;
1402
1403    fn try_from(value: &str) -> eyre::Result<Self, Self::Error> {
1404        match value {
1405            | "order_by" => Ok(PaginationKey::OrderBy),
1406            | "page" => Ok(PaginationKey::Page),
1407            | "pagination" => Ok(PaginationKey::Pagination),
1408            | "per_page" => Ok(PaginationKey::PerPage),
1409            | "sort" => Ok(PaginationKey::Sort),
1410            | _ => Err(format!("Invalid GitLab pagination field: {value}")),
1411        }
1412    }
1413}
1414impl ValueValidator for PaginationKey {
1415    /// Validate pagination field values according to GitLab API documentation
1416    fn is_valid(&self, value: &str) -> bool {
1417        match self {
1418            | PaginationKey::OrderBy => OrderByValue::try_from(value).is_ok(),
1419            | PaginationKey::Page | PaginationKey::PerPage => value.parse::<u64>().is_ok(),
1420            | PaginationKey::Sort => SortValue::try_from(value).is_ok(),
1421            | _ => true,
1422        }
1423    }
1424}
1425impl From<ProgrammingLanguageMetadata> for ProgrammingLanguageRow {
1426    fn from(value: ProgrammingLanguageMetadata) -> Self {
1427        let ProgrammingLanguageMetadata {
1428            name,
1429            language_id,
1430            language_type,
1431            color,
1432            group,
1433        } = value;
1434        ProgrammingLanguageRow::init()
1435            .name(name)
1436            .maybe_language_id(language_id.and_then(|value| i64::try_from(value).ok()))
1437            .maybe_language_type(language_type)
1438            .maybe_color(color)
1439            .maybe_group_name(group)
1440            .build()
1441    }
1442}
1443impl ProgrammingLanguagesResponse {
1444    /// Parse a raw language map, retaining only `programming` type entries
1445    pub fn parse(data: HashMap<String, ProgrammingLanguageDetails>) -> Self {
1446        let languages = data
1447            .into_iter()
1448            .filter_map(|(name, details)| {
1449                details
1450                    .language_type
1451                    .as_ref()
1452                    .map(|kind| kind.eq_ignore_ascii_case("programming"))
1453                    .filter(|is_programming| *is_programming)
1454                    .map(|_| ProgrammingLanguageMetadata {
1455                        name,
1456                        language_id: details.language_id,
1457                        language_type: details.language_type,
1458                        color: details.color,
1459                        group: details.group,
1460                    })
1461            })
1462            .collect();
1463        Self { languages }
1464    }
1465}
1466#[async_trait]
1467impl DatabasePersistence for ProgrammingLanguagesResponse {
1468    /// Persist GitLab programming language metadata to local database
1469    async fn persist(self, database: Database<Table>) -> ApiResult<usize> {
1470        let Self { languages } = self;
1471        let message: fn(&ProgrammingLanguageMetadata) -> String = |item| format!("Saving \"{}\" language metadata", item.name);
1472        let operation = |item| async { database.insert(ProgrammingLanguageRow::from(item)) };
1473        let finish = |count| format!("{}Saved metadata for {count} programming languages", Label::CHECKMARK);
1474        with_progress(languages, message, operation, finish, None, ProgressType::Bar)
1475            .await
1476            .map(|counts| counts.into_iter().sum())
1477            .map_err(eyre::Report::msg)
1478    }
1479}
1480impl<'de> serde::Deserialize<'de> for ProgrammingLanguagesResponse {
1481    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1482        HashMap::<String, ProgrammingLanguageDetails>::deserialize(deserializer).map(Self::parse)
1483    }
1484}
1485impl ProgrammingLanguageUseResponse {
1486    /// Parse a raw language-to-percentage map into normalized entries
1487    pub fn parse(data: HashMap<String, f64>) -> Self {
1488        let mut languages = data
1489            .into_iter()
1490            .map(|(name, percentage)| ProgrammingLanguageUseMetadata { name, percentage })
1491            .collect::<ProgrammingLanguageUseEntries>();
1492        languages.sort_by(|a, b| a.name.cmp(&b.name));
1493        Self { languages }
1494    }
1495    /// Get language data entry tuples, (name, percentage), sorted by percentage in descending order
1496    pub fn entries(&self) -> Vec<(String, f64)> {
1497        let mut entries = self
1498            .languages
1499            .iter()
1500            .map(|ProgrammingLanguageUseMetadata { name, percentage }| (name.clone(), *percentage))
1501            .collect::<Vec<_>>();
1502        entries.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(core::cmp::Ordering::Equal));
1503        entries
1504    }
1505    /// Get language names, sorted by percentage in descending order
1506    pub fn names(&self) -> Vec<String> {
1507        self.entries().into_iter().map(|(name, _)| name).collect()
1508    }
1509}
1510impl<'de> serde::Deserialize<'de> for ProgrammingLanguageUseResponse {
1511    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1512        HashMap::<String, f64>::deserialize(deserializer).map(Self::parse)
1513    }
1514}
1515impl RepositoryFile {
1516    /// Decode the file content returned by GitLab
1517    pub fn decoded_content(&self) -> ApiResult<Vec<u8>> {
1518        if self.encoding.eq_ignore_ascii_case("base64") {
1519            let content = self.content.chars().filter(|character| !character.is_whitespace()).collect::<String>();
1520            BASE64
1521                .decode(content.as_bytes())
1522                .map_err(|why| eyre!("Failed to decode GitLab repository file {} — {why}", self.file_path))
1523        } else {
1524            Ok(self.content.as_bytes().to_vec())
1525        }
1526    }
1527}
1528impl RepositoryFileMetadata for RepositoryFile {
1529    fn path(&self) -> &str {
1530        &self.file_path
1531    }
1532    fn size(&self) -> Option<u64> {
1533        Some(self.size)
1534    }
1535}
1536impl RunnerMetadata {
1537    /// Whether the runner is active and online
1538    pub fn is_available(&self) -> bool {
1539        let Self { active, online, paused, .. } = self;
1540        *active && online.unwrap_or(false) && !*paused
1541    }
1542    /// Return a copy of runner metadata with project or group identifier set
1543    pub fn with_identifier(self, value: u64) -> Self {
1544        Self {
1545            identifier: Some(value),
1546            ..self
1547        }
1548    }
1549}
1550impl Default for RunnerMetadata {
1551    fn default() -> Self {
1552        Self::init().build()
1553    }
1554}
1555impl From<RunnerDetails> for RunnerMetadata {
1556    fn from(value: RunnerDetails) -> Self {
1557        let RunnerDetails {
1558            name,
1559            runner_type,
1560            description,
1561            tags,
1562            ..
1563        } = value;
1564        Self {
1565            access_level: None,
1566            active: false,
1567            architecture: None,
1568            contacted_at: None,
1569            created_at: None,
1570            created_by: None,
1571            description,
1572            groups: None,
1573            identifier: None,
1574            ip_address: None,
1575            job_execution_status: None,
1576            paused: false,
1577            maintenance_note: None,
1578            maximum_timeout: None,
1579            name,
1580            online: Some(false),
1581            platform: None,
1582            projects: None,
1583            revision: None,
1584            shared: matches!(runner_type, RunnerType::Instance),
1585            runner_type,
1586            run_untagged: false,
1587            status: None,
1588            tags,
1589            version: None,
1590        }
1591    }
1592}
1593impl TryFrom<&str> for SortValue {
1594    type Error = String;
1595
1596    fn try_from(value: &str) -> eyre::Result<Self, Self::Error> {
1597        match value {
1598            | "asc" => Ok(SortValue::Ascending),
1599            | "desc" => Ok(SortValue::Descending),
1600            | _ => Err(format!("Invalid GitLab sort order: {value}")),
1601        }
1602    }
1603}
1604impl fmt::Display for TargetType {
1605    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1606        let s = match self {
1607            | TargetType::Epic => "epic",
1608            | TargetType::Issue => "issue",
1609            | TargetType::MergeRequest => "merge_request",
1610            | TargetType::Milestone => "milestone",
1611            | TargetType::Note => "note",
1612            | TargetType::Project => "project",
1613            | TargetType::Snippet => "snippet",
1614            | TargetType::User => "user",
1615            | TargetType::Unknown => "unknown",
1616        };
1617        write!(f, "{}", s)
1618    }
1619}
1620impl core::str::FromStr for TargetType {
1621    type Err = String;
1622
1623    fn from_str(value: &str) -> Result<Self, Self::Err> {
1624        match value.to_lowercase().as_str() {
1625            | "epic" => Ok(TargetType::Epic),
1626            | "issue" => Ok(TargetType::Issue),
1627            | "merge_request" | "mergerequest" => Ok(TargetType::MergeRequest),
1628            | "milestone" => Ok(TargetType::Milestone),
1629            | "note" => Ok(TargetType::Note),
1630            | "project" => Ok(TargetType::Project),
1631            | "snippet" => Ok(TargetType::Snippet),
1632            | "user" => Ok(TargetType::User),
1633            | _ => Err(format!("Invalid GitLab target type value: {value}")),
1634        }
1635    }
1636}
1637impl TryFrom<&str> for TargetType {
1638    type Error = String;
1639
1640    fn try_from(value: &str) -> Result<Self, Self::Error> {
1641        value.parse()
1642    }
1643}
1644impl<'de> serde::Deserialize<'de> for TreeResponse {
1645    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1646        #[derive(Deserialize)]
1647        #[serde(untagged)]
1648        enum TreeResponseValue {
1649            Entries(Vec<TreeEntry>),
1650            Error(ErrorResponse),
1651        }
1652
1653        match TreeResponseValue::deserialize(deserializer)? {
1654            | TreeResponseValue::Entries(entries) => {
1655                let entry_count = entries.len();
1656                Ok(Self {
1657                    paths: entries.into_iter().filter(TreeEntry::is_file).map(TreeEntry::path).collect(),
1658                    entry_count,
1659                    error: None,
1660                })
1661            }
1662            | TreeResponseValue::Error(why) => Ok(Self {
1663                paths: vec![],
1664                entry_count: 0,
1665                error: Some(why),
1666            }),
1667        }
1668    }
1669}
1670impl WebhookOptions {
1671    /// Read webhook credentials from the environment for an optional public URL
1672    pub fn from_env(public_url: Option<&str>) -> Self {
1673        Self {
1674            public_url: public_url.map(str::to_string),
1675            webhook_token: var("GITLAB_WEBHOOK_TOKEN").ok().filter(|value| !value.trim().is_empty()),
1676            signing_token: var("GITLAB_WEBHOOK_SIGNING_TOKEN").ok().filter(|value| !value.trim().is_empty()),
1677        }
1678    }
1679    /// Create explicit webhook options
1680    pub fn new(public_url: Option<&str>, webhook_token: Option<&str>, signing_token: Option<&str>) -> Self {
1681        Self {
1682            public_url: public_url.map(str::to_string),
1683            webhook_token: webhook_token.map(str::to_string),
1684            signing_token: signing_token.map(str::to_string),
1685        }
1686    }
1687    fn url(&self) -> Option<String> {
1688        self.public_url
1689            .as_deref()
1690            .map(|public_url| format!("{}/webhooks/gitlab", public_url.trim_end_matches('/')))
1691    }
1692    fn credentials(&self, supports_signing: bool) -> (Option<&str>, Option<&str>) {
1693        let signing_token = supports_signing.then_some(self.signing_token.as_deref()).flatten();
1694        let webhook_token = signing_token.is_none().then_some(self.webhook_token.as_deref()).flatten();
1695        (webhook_token, signing_token)
1696    }
1697
1698    fn for_registration(&self, supports_signing: bool) -> Self {
1699        let (webhook_token, signing_token) = self.credentials(supports_signing);
1700        Self {
1701            public_url: self.url(),
1702            webhook_token: webhook_token.map(str::to_string),
1703            signing_token: signing_token.map(str::to_string),
1704        }
1705    }
1706}
1707impl WorkItemUser {
1708    /// Create work-item user metadata.
1709    pub fn new(identifier: u64, username: impl Into<String>, bot: bool) -> Self {
1710        Self {
1711            identifier,
1712            username: username.into(),
1713            bot,
1714        }
1715    }
1716}
1717#[cfg(test)]
1718mod tests;