Class: PlanMyStuff::BaseProject

Inherits:
ApplicationRecord show all
Defined in:
lib/plan_my_stuff/base_project.rb

Overview

Shared base for GitHub Projects V2 wrappers. Holds attribute definitions, generic find/list/update machinery, hydration, and instance helpers. Concrete subclasses (Project, TestingProject) add their own create! behavior and optional filtering on find/list.

Not instantiated directly. find and list dispatch via build_detail/build_summary to the correct concrete subclass based on the metadata kind field in the project readme.

Direct Known Subclasses

Project, TestingProject

Constant Summary collapse

MAX_AUTO_PAGINATE_ITEMS =
500
ITEMS_PER_PAGE =
100

Instance Attribute Summary

Attributes inherited from ApplicationRecord

#github_response

Class Method Summary collapse

Instance Method Summary collapse

Methods inherited from ApplicationRecord

#destroyed?, #initialize, #new_record?, #persisted?, read_field

Constructor Details

This class inherits a constructor from PlanMyStuff::ApplicationRecord

Class Method Details

.clone!(source_number:, title:) ⇒ PlanMyStuff::BaseProject

Clones an existing GitHub Project into the configured organization. The copy inherits all custom fields and board layout from the source. Returns the newly created project via find, dispatched to the correct concrete subclass (Project or TestingProject) based on the cloned readme.

Parameters:

  • source_number (Integer)

    project number of the project to copy from

  • title (String)

    title for the new project

Returns:



145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
# File 'lib/plan_my_stuff/base_project.rb', line 145

def clone!(source_number:, title:)
  org = PlanMyStuff.configuration.organization
  org_id = resolve_org_id(org)
  source_project_id = resolve_project_id(org, source_number)

  data = PlanMyStuff.client.graphql(
    PlanMyStuff::GraphQL::Queries::COPY_PROJECT_V2,
    variables: {
      input: {
        ownerId: org_id,
        projectId: source_project_id,
        title: title,
        includeDraftIssues: false,
      },
    },
  )

  new_number = data.dig(:copyProjectV2, :projectV2, :number)
  PlanMyStuff::BaseProject.find(new_number)
end

.find(number, paginate: :auto, cursor: nil) ⇒ PlanMyStuff::BaseProject

Generic find - returns whichever concrete project type is at the given number, dispatching on metadata kind. Subclasses may override to apply filtering (e.g. Project raises for testing projects by default).

Parameters:

  • number (Integer)
  • paginate (Symbol) (defaults to: :auto)

    :auto (default) or :cursor

  • cursor (String, nil) (defaults to: nil)

    pagination cursor for :cursor mode

Returns:

Raises:

  • (ArgumentError)

    if paginate mode is invalid



57
58
59
60
61
62
63
64
65
66
67
68
# File 'lib/plan_my_stuff/base_project.rb', line 57

def find(number, paginate: :auto, cursor: nil)
  org = PlanMyStuff.configuration.organization

  case paginate
  when :auto
    find_auto_paginated(org, number)
  when :cursor
    find_with_cursor(org, number, cursor: cursor)
  else
    raise(ArgumentError, "Unknown paginate mode: #{paginate.inspect}. Use :auto or :cursor")
  end
end

.listArray<PlanMyStuff::BaseProject>

Generic list - returns all projects in the configured organization, each dispatched to its concrete type (Project or TestingProject). Subclasses may override to apply filtering.

Returns:



75
76
77
78
79
80
81
82
83
84
# File 'lib/plan_my_stuff/base_project.rb', line 75

def list
  org = PlanMyStuff.configuration.organization
  data = PlanMyStuff.client.graphql(
    PlanMyStuff::GraphQL::Queries::LIST_PROJECTS,
    variables: { org: org },
  )

  nodes = data.dig(:organization, :projectsV2, :nodes) || []
  nodes.map { |node| build_summary(node) }
end

.resolve_default_project_number!(project_number) ⇒ Integer

Resolves a project number, falling back to config.default_project_number.

Parameters:

  • project_number (Integer, nil)

Returns:

  • (Integer)

Raises:

  • (ArgumentError)

    if project_number is nil and config.default_project_number is not set



174
175
176
177
178
179
# File 'lib/plan_my_stuff/base_project.rb', line 174

def resolve_default_project_number!(project_number)
  return project_number if project_number.present?

  PlanMyStuff.configuration.default_project_number ||
    raise(ArgumentError, 'project_number is required when config.default_project_number is not set')
end

.update!(project_number:, title: nil, readme: nil, description: nil, metadata: nil) ⇒ PlanMyStuff::BaseProject

Updates an existing project.

Parameters:

  • project_number (Integer)
  • title (String, nil) (defaults to: nil)
  • readme (String, nil) (defaults to: nil)

    user-visible readme content (metadata preserved)

  • description (String, nil) (defaults to: nil)

    project short description

  • metadata (Hash, nil) (defaults to: nil)

    custom fields to merge into existing metadata

Returns:



96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
# File 'lib/plan_my_stuff/base_project.rb', line 96

def update!(project_number:, title: nil, readme: nil, description: nil, metadata: nil)
  org = PlanMyStuff.configuration.organization
  project_id = resolve_project_id(org, project_number)

  update_input = { projectId: project_id }
  update_input[:title] = title unless title.nil?
  update_input[:shortDescription] = description unless description.nil?

  if .present? || !readme.nil?
    current = find(project_number)
    parsed = PlanMyStuff::MetadataParser.parse(current.raw_readme)
     = parsed[:metadata]

    if .present?
      # Seed with fresh metadata when project has no existing PMS metadata
      if [:schema_version].blank?
         = PlanMyStuff::ProjectMetadata.build(user: nil).to_h
      end

      merged_custom_fields = ([:custom_fields] || {}).merge([:custom_fields] || {})
       = .merge()
      [:custom_fields] = merged_custom_fields
      PlanMyStuff::CustomFields.new(
        PlanMyStuff.configuration.custom_fields_for(:project),
        merged_custom_fields,
      ).validate!
    end

    body = readme.nil? ? parsed[:body] : readme
    update_input[:readme] = PlanMyStuff::MetadataParser.serialize!(, body)
  end

  PlanMyStuff.client.graphql(
    PlanMyStuff::GraphQL::Queries::UPDATE_PROJECT,
    variables: { input: update_input },
  )

  find(project_number)
end

Instance Method Details

#closedBoolean?

Returns whether the project is closed.

Returns:

  • (Boolean, nil)

    whether the project is closed



19
# File 'lib/plan_my_stuff/base_project.rb', line 19

attribute :closed

#descriptionString?

Returns project short description (from shortDescription).

Returns:

  • (String, nil)

    project short description (from shortDescription)



25
# File 'lib/plan_my_stuff/base_project.rb', line 25

attribute :description, :string

#fieldsArray<Hash>

Returns all field definitions.

Returns:

  • (Array<Hash>)

    all field definitions



37
# File 'lib/plan_my_stuff/base_project.rb', line 37

attribute :fields, default: -> { [] }

#has_next_pageBoolean?

Returns whether more pages exist (only in cursor mode).

Returns:

  • (Boolean, nil)

    whether more pages exist (only in cursor mode)



43
# File 'lib/plan_my_stuff/base_project.rb', line 43

attribute :has_next_page

#idString?

Returns GitHub node ID.

Returns:

  • (String, nil)

    GitHub node ID



15
# File 'lib/plan_my_stuff/base_project.rb', line 15

attribute :id, :string

#itemsArray<PlanMyStuff::ProjectItem>

Returns project items.

Returns:



39
# File 'lib/plan_my_stuff/base_project.rb', line 39

attribute :items, default: -> { [] }

#metadataPlanMyStuff::BaseProjectMetadata

Returns parsed metadata (empty when no PMS metadata present).

Returns:



21
# File 'lib/plan_my_stuff/base_project.rb', line 21

attribute :metadata, default: -> { PlanMyStuff::ProjectMetadata.new }

#next_cursorString?

Returns cursor for next page (only in cursor mode).

Returns:

  • (String, nil)

    cursor for next page (only in cursor mode)



41
# File 'lib/plan_my_stuff/base_project.rb', line 41

attribute :next_cursor, :string

#numberInteger?

Returns project number.

Returns:

  • (Integer, nil)

    project number



17
# File 'lib/plan_my_stuff/base_project.rb', line 17

attribute :number, :integer

#pms_project?Boolean

Returns:

  • (Boolean)


428
429
430
# File 'lib/plan_my_stuff/base_project.rb', line 428

def pms_project?
  .schema_version.present?
end

#raw_readmeString?

Returns full readme as stored on GitHub.

Returns:

  • (String, nil)

    full readme as stored on GitHub



23
# File 'lib/plan_my_stuff/base_project.rb', line 23

attribute :raw_readme, :string

#readmeString?

Returns user-visible readme content (without metadata comment).

Returns:

  • (String, nil)

    user-visible readme content (without metadata comment)



33
# File 'lib/plan_my_stuff/base_project.rb', line 33

attribute :readme, :string

#reloadself

Re-fetches this project from GitHub and updates all local attributes.

Returns:

  • (self)


479
480
481
482
483
# File 'lib/plan_my_stuff/base_project.rb', line 479

def reload
  fresh = self.class.find(number)
  hydrate_from_project(fresh)
  self
end

#save!self

Persists the project. Creates if new, updates if persisted.

Returns:

  • (self)


436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
# File 'lib/plan_my_stuff/base_project.rb', line 436

def save!
  if new_record?
    created = self.class.create!(
      title: title,
      readme: readme || '',
      description: description,
      **,
    )
    hydrate_from_project(created)
  else
    update!(
      title: title,
      readme: readme,
      description: description,
      metadata: ,
    )
  end

  self
end

#status_fieldHash

Returns the Status single-select field definition.

Returns:

  • (Hash)

    with :id and :options keys



407
408
409
410
411
# File 'lib/plan_my_stuff/base_project.rb', line 407

def status_field
  status_field!
rescue
  nil
end

#status_field!Hash

Returns the Status single-select field definition.

Returns:

  • (Hash)

    with :id and :options keys

Raises:



419
420
421
422
423
424
425
# File 'lib/plan_my_stuff/base_project.rb', line 419

def status_field!
  status_field = fields.find { |f| f[:name] == 'Status' && f[:options] }

  raise(PlanMyStuff::APIError, "No 'Status' field found on project ##{number}") if status_field.nil?

  status_field
end

#statusesArray<Hash>

Returns status options (name:).

Returns:

  • (Array<Hash>)

    status options (name:)



35
# File 'lib/plan_my_stuff/base_project.rb', line 35

attribute :statuses, default: -> { [] }

#titleString?

Returns project title.

Returns:

  • (String, nil)

    project title



29
# File 'lib/plan_my_stuff/base_project.rb', line 29

attribute :title, :string

#update!(**attrs) ⇒ self

Updates this project on GitHub. Raises StaleObjectError if the remote has been modified since this instance was loaded.

Parameters:

  • attrs (Hash)

    attributes to update (title:, readme:, description:, metadata:)

Returns:

  • (self)


464
465
466
467
468
469
470
471
472
473
# File 'lib/plan_my_stuff/base_project.rb', line 464

def update!(**attrs)
  raise_if_stale!

  self.class.update!(
    project_number: number,
    **attrs,
  )

  reload
end

#updated_atTime?

Returns GitHub’s updatedAt timestamp.

Returns:

  • (Time, nil)

    GitHub’s updatedAt timestamp



27
# File 'lib/plan_my_stuff/base_project.rb', line 27

attribute :updated_at

#urlString?

Returns project URL.

Returns:

  • (String, nil)

    project URL



31
# File 'lib/plan_my_stuff/base_project.rb', line 31

attribute :url, :string