Skip to main content

acorn/io/api/gitlab/
mod.rs

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