Add owned pipeline schedules section to user account page

What does this MR do and why?

Add owned pipeline schedules section to user account page

Users need a way to view the scheduled pipelines they own, across all projects. A primary use case is for users leaving an organization who need to identify scheduled pipelines they own before their account gets blocked, to prevent operational disruptions.

This adds a simple list of active pipeline schedules to the Profile > Account page.

This list could also be viewed by an Administrator who uses GitLab's Impersonation feature.

The implementation:

  • Adds has_many :pipeline_schedules association to User model
  • Displays active pipeline schedules in account settings
  • Links directly to project pipeline schedules pages
  • Uses existing database indexes for efficient querying

Addresses the user visibility gap described in Allow a user to see all their owned scheduled p... (#558979 - closed) • Nick Malcolm • 18.4 • On track

Changelog: added

Design Decisions / FAQ

  • Q: Why add it to this page?
    • No other page fit better
    • This page already shows a list of solely owned groups. Owned pipelines feels like a close fit for a very similar use case. However, we don't want to have Owned Scheduled Pipelines be blockers to account deletion, so I put them just above
  • Q: Why not group by Project? Vs. individual links
    • So that you can, at a glance, see the different descriptions of the pipelines. Some might be not worth transferring or updating
    • It's likely more database performant most of the time, since we can rely on the existing owner + active index and not GROUP_BY
  • Q: Why link to the Project Scheduled Pipelines page, then?
    • I don't want to assume you want to edit the pipeline. You might want to view the latest job, or delete it. Linking to the Project page allows the user to make those choices.
  • Q: Why no pagination?
    • I don't expect people to own a lot of pipelines.

Database Calls

This adds six queries to the page, but none of them are N+1:

  • Pipeline schedules: 2 queries (existence + load)
    Index Only Scan using index_ci_pipeline_schedules_on_owner_id_and_id_and_active on public.ci_pipeline_schedules  (cost=0.14..3.16 rows=1 width=4) (actual time=0.007..0.007 rows=0 loops=1)`
  • Projects: 1 batched query
     Index Scan using projects_pkey on public.projects  (cost=0.56..6.90 rows=2 width=821) (actual time=2.356..2.356 rows=0 loops=1)  
     Index Cond: (projects.id = ANY ('{53,42}'::integer[])) 
  • Namespaces: 1 batched query
  • Project routes: 1 batched query
  • Namespace routes: 1 batched query

Why take this approach?

  • Most users probably have 0 scheduled pipelines. For them, 1 query vs 5 queries is a huge difference
  • For users with schedules, 6 vs 5 queries is negligible. We use includes to reduce N+1 queries.
Click to expand SQL logs
  Ci::PipelineSchedule Exists? (2.1ms)  SELECT 1 AS one FROM "ci_pipeline_schedules" WHERE "ci_pipeline_schedules"."owner_id" = 22 AND "ci_pipeline_schedules"."active" = TRUE LIMIT 1
   app/views/profiles/accounts/show.html.haml:79
  Ci::PipelineSchedule Load (2.6ms)  SELECT "ci_pipeline_schedules".* FROM "ci_pipeline_schedules" WHERE "ci_pipeline_schedules"."owner_id" = 22 AND "ci_pipeline_schedules"."active" = TRUE
   app/views/profiles/accounts/show.html.haml:82
  Project Load (7.5ms)  SELECT "projects"."id", "projects"."name", "projects"."path", "projects"."description", "projects"."created_at", "projects"."updated_at", "projects"."creator_id", "projects"."namespace_id", "projects"."last_activity_at", "projects"."import_url", "projects"."visibility_level", "projects"."archived", "projects"."avatar", "projects"."merge_requests_template", "projects"."star_count", "projects"."merge_requests_rebase_enabled", "projects"."import_type", "projects"."import_source", "projects"."approvals_before_merge", "projects"."reset_approvals_on_push", "projects"."merge_requests_ff_only_enabled", "projects"."issues_template", "projects"."mirror", "projects"."mirror_last_update_at", "projects"."mirror_last_successful_update_at", "projects"."mirror_user_id", "projects"."shared_runners_enabled", "projects"."runners_token", "projects"."build_allow_git_fetch", "projects"."build_timeout", "projects"."mirror_trigger_builds", "projects"."pending_delete", "projects"."public_builds", "projects"."last_repository_check_failed", "projects"."last_repository_check_at", "projects"."only_allow_merge_if_pipeline_succeeds", "projects"."has_external_issue_tracker", "projects"."repository_storage", "projects"."repository_read_only", "projects"."request_access_enabled", "projects"."has_external_wiki", "projects"."ci_config_path", "projects"."lfs_enabled", "projects"."description_html", "projects"."only_allow_merge_if_all_discussions_are_resolved", "projects"."repository_size_limit", "projects"."printing_merge_request_link_enabled", "projects"."auto_cancel_pending_pipelines", "projects"."service_desk_enabled", "projects"."cached_markdown_version", "projects"."delete_error", "projects"."last_repository_updated_at", "projects"."disable_overriding_approvers_per_merge_request", "projects"."storage_version", "projects"."resolve_outdated_diff_discussions", "projects"."remote_mirror_available_overridden", "projects"."only_mirror_protected_branches", "projects"."pull_mirror_available_overridden", "projects"."jobs_cache_index", "projects"."external_authorization_classification_label", "projects"."mirror_overwrites_diverged_branches", "projects"."pages_https_only", "projects"."external_webhook_token", "projects"."packages_enabled", "projects"."merge_requests_author_approval", "projects"."pool_repository_id", "projects"."runners_token_encrypted", "projects"."bfg_object_map", "projects"."detected_repository_languages", "projects"."merge_requests_disable_committers_approval", "projects"."require_password_to_approve", "projects"."max_pages_size", "projects"."max_artifacts_size", "projects"."pull_mirror_branch_prefix", "projects"."remove_source_branch_after_merge", "projects"."marked_for_deletion_at", "projects"."marked_for_deletion_by_user_id", "projects"."autoclose_referenced_issues", "projects"."suggestion_commit_message", "projects"."project_namespace_id", "projects"."hidden", "projects"."organization_id" FROM "projects" WHERE "projects"."id" IN (53, 42)
   app/models/concerns/use_sql_function_for_primary_key_lookups.rb:10:in `_query_by_sql'
  Namespace Load (2.0ms)  SELECT "namespaces"."id", "namespaces"."name", "namespaces"."path", "namespaces"."owner_id", "namespaces"."created_at", "namespaces"."updated_at", "namespaces"."type", "namespaces"."avatar", "namespaces"."membership_lock", "namespaces"."share_with_group_lock", "namespaces"."visibility_level", "namespaces"."request_access_enabled", "namespaces"."ldap_sync_status", "namespaces"."ldap_sync_error", "namespaces"."ldap_sync_last_update_at", "namespaces"."ldap_sync_last_successful_update_at", "namespaces"."ldap_sync_last_sync_at", "namespaces"."lfs_enabled", "namespaces"."parent_id", "namespaces"."shared_runners_minutes_limit", "namespaces"."repository_size_limit", "namespaces"."require_two_factor_authentication", "namespaces"."two_factor_grace_period", "namespaces"."project_creation_level", "namespaces"."runners_token", "namespaces"."file_template_project_id", "namespaces"."saml_discovery_token", "namespaces"."runners_token_encrypted", "namespaces"."custom_project_templates_group_id", "namespaces"."auto_devops_enabled", "namespaces"."extra_shared_runners_minutes_limit", "namespaces"."last_ci_minutes_notification_at", "namespaces"."last_ci_minutes_usage_notification_level", "namespaces"."subgroup_creation_level", "namespaces"."max_pages_size", "namespaces"."max_artifacts_size", "namespaces"."mentions_disabled", "namespaces"."default_branch_protection", "namespaces"."max_personal_access_token_lifetime", "namespaces"."push_rule_id", "namespaces"."shared_runners_enabled", "namespaces"."allow_descendants_override_disabled_shared_runners", "namespaces"."traversal_ids", "namespaces"."organization_id" FROM "namespaces" WHERE "namespaces"."id" IN (101, 44)
  ↳ app/models/concerns/use_sql_function_for_primary_key_lookups.rb:10:in `_query_by_sql'
  Route Load (1.6ms)  SELECT "routes".* FROM "routes" WHERE "routes"."source_type" = 'Project' AND "routes"."source_id" IN (42, 53)
  ↳ app/views/profiles/accounts/show.html.haml:82
  Route Load (1.6ms)  SELECT "routes".* FROM "routes" WHERE "routes"."source_type" = 'Namespace' AND "routes"."source_id" IN (44, 101)
  ↳ app/views/profiles/accounts/show.html.haml:82

References

Screenshots or screen recordings

What Image
With active owned pipelines with-owned-pipelines
No owned active pipelines with-no-owned-pipelines
Added documentation docs-inline

How to set up and validate locally

MR acceptance checklist

Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.

Edited by Nick Malcolm

Merge request reports

Loading