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:



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

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



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

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:



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

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



178
179
180
181
182
183
# File 'lib/plan_my_stuff/base_project.rb', line 178

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:



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
137
138
# File 'lib/plan_my_stuff/base_project.rb', line 100

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



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

attribute :closed

#descriptionString?

Returns project short description (from shortDescription).

Returns:

  • (String, nil)

    project short description (from shortDescription)



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

attribute :description, :string

#fieldsArray<Hash>

Returns all field definitions.

Returns:

  • (Array<Hash>)

    all field definitions



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

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)



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

attribute :has_next_page

#idString?

Returns GitHub node ID.

Returns:

  • (String, nil)

    GitHub node ID



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

attribute :id, :string

#itemsArray<PlanMyStuff::ProjectItem>

Returns project items.

Returns:



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

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

#metadataPlanMyStuff::BaseProjectMetadata

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

Returns:



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

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)



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

attribute :next_cursor, :string

#numberInteger?

Returns project number.

Returns:

  • (Integer, nil)

    project number



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

attribute :number, :integer

#pms_project?Boolean

Returns:

  • (Boolean)


257
258
259
# File 'lib/plan_my_stuff/base_project.rb', line 257

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



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

attribute :raw_readme, :string

#readmeString?

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

Returns:

  • (String, nil)

    user-visible readme content (without metadata comment)



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

attribute :readme, :string

#reloadself

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

Returns:

  • (self)


308
309
310
311
312
# File 'lib/plan_my_stuff/base_project.rb', line 308

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)


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

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



236
237
238
239
240
# File 'lib/plan_my_stuff/base_project.rb', line 236

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:



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

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:)



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

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

#titleString?

Returns project title.

Returns:

  • (String, nil)

    project title



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

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)


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

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



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

attribute :updated_at

#urlString?

Returns project URL.

Returns:

  • (String, nil)

    project URL



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

attribute :url, :string