Class: PlanMyStuff::BaseProject

Inherits:
ApplicationRecord show all
Extended by:
PlanMyStuff::BaseProjectExtractions::GraphqlHydration
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:



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

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



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

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:



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

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



176
177
178
179
180
181
# File 'lib/plan_my_stuff/base_project.rb', line 176

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:



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
135
136
# File 'lib/plan_my_stuff/base_project.rb', line 98

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)


255
256
257
# File 'lib/plan_my_stuff/base_project.rb', line 255

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)


306
307
308
309
310
# File 'lib/plan_my_stuff/base_project.rb', line 306

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)


263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
# File 'lib/plan_my_stuff/base_project.rb', line 263

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



234
235
236
237
238
# File 'lib/plan_my_stuff/base_project.rb', line 234

def status_field
  status_field!
rescue PlanMyStuff::APIError
  nil
end

#status_field!Hash

Returns the Status single-select field definition.

Returns:

  • (Hash)

    with :id and :options keys

Raises:



246
247
248
249
250
251
252
# File 'lib/plan_my_stuff/base_project.rb', line 246

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)


291
292
293
294
295
296
297
298
299
300
# File 'lib/plan_my_stuff/base_project.rb', line 291

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