plan_my_stuff 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +27 -0
  3. data/app/controllers/plan_my_stuff/application_controller.rb +4 -1
  4. data/app/controllers/plan_my_stuff/comments_controller.rb +24 -6
  5. data/app/controllers/plan_my_stuff/issues_controller.rb +23 -17
  6. data/app/controllers/plan_my_stuff/labels_controller.rb +5 -5
  7. data/app/controllers/plan_my_stuff/project_items_controller.rb +6 -0
  8. data/app/controllers/plan_my_stuff/projects_controller.rb +54 -0
  9. data/app/views/plan_my_stuff/issues/index.html.erb +4 -4
  10. data/app/views/plan_my_stuff/issues/partials/_form.html.erb +10 -6
  11. data/app/views/plan_my_stuff/issues/partials/_viewers.html.erb +2 -2
  12. data/app/views/plan_my_stuff/issues/show.html.erb +4 -4
  13. data/app/views/plan_my_stuff/projects/edit.html.erb +7 -0
  14. data/app/views/plan_my_stuff/projects/index.html.erb +2 -0
  15. data/app/views/plan_my_stuff/projects/new.html.erb +7 -0
  16. data/app/views/plan_my_stuff/projects/partials/_form.html.erb +29 -0
  17. data/app/views/plan_my_stuff/projects/show.html.erb +6 -4
  18. data/config/routes.rb +1 -1
  19. data/lib/generators/plan_my_stuff/install/templates/initializer.rb +10 -1
  20. data/lib/plan_my_stuff/application_record.rb +37 -1
  21. data/lib/plan_my_stuff/base_metadata.rb +23 -15
  22. data/lib/plan_my_stuff/client.rb +2 -22
  23. data/lib/plan_my_stuff/comment.rb +22 -8
  24. data/lib/plan_my_stuff/comment_metadata.rb +8 -2
  25. data/lib/plan_my_stuff/configuration.rb +82 -1
  26. data/lib/plan_my_stuff/custom_fields.rb +70 -0
  27. data/lib/plan_my_stuff/issue.rb +23 -19
  28. data/lib/plan_my_stuff/issue_metadata.rb +8 -2
  29. data/lib/plan_my_stuff/markdown.rb +1 -1
  30. data/lib/plan_my_stuff/project.rb +280 -19
  31. data/lib/plan_my_stuff/project_item.rb +19 -11
  32. data/lib/plan_my_stuff/project_metadata.rb +41 -0
  33. data/lib/plan_my_stuff/repo.rb +107 -0
  34. data/lib/plan_my_stuff/test_helpers.rb +10 -2
  35. data/lib/plan_my_stuff/version.rb +2 -2
  36. data/lib/plan_my_stuff.rb +2 -0
  37. metadata +8 -2
@@ -18,11 +18,21 @@ module PlanMyStuff
18
18
  attr_reader :number
19
19
  # @return [Boolean] whether the project is closed
20
20
  attr_reader :closed
21
+ # @return [PlanMyStuff::ProjectMetadata] parsed metadata (empty when no PMS metadata present)
22
+ attr_reader :metadata
23
+ # @return [String] full readme as stored on GitHub
24
+ attr_reader :raw_readme
25
+ # @return [String, nil] project short description (from shortDescription)
26
+ attr_reader :description
27
+ # @return [Time, nil] GitHub's updatedAt timestamp
28
+ attr_reader :updated_at
21
29
 
22
30
  # @return [String] project title
23
31
  attr_accessor :title
24
32
  # @return [String] project URL
25
33
  attr_accessor :url
34
+ # @return [String, nil] user-visible readme content (without metadata comment)
35
+ attr_accessor :readme
26
36
  # @return [Array<Hash>] status options ({id:, name:})
27
37
  attr_accessor :statuses
28
38
  # @return [Array<Hash>] all field definitions
@@ -35,25 +45,97 @@ module PlanMyStuff
35
45
  attr_accessor :has_next_page
36
46
 
37
47
  class << self
38
- # Creates a new project in the configured organization.
48
+ # Creates a new project in the configured organization with PMS metadata.
39
49
  #
40
50
  # @param title [String]
51
+ # @param user [Object, Integer, nil] user object or user_id
52
+ # @param visibility [String] "public" or "internal"
53
+ # @param custom_fields [Hash] app-defined field values
54
+ # @param readme [String] user-visible readme content
55
+ # @param description [String, nil] project short description
41
56
  #
42
- # @return [Object]
57
+ # @return [PlanMyStuff::Project]
43
58
  #
44
- def create(title:)
45
- raise(NotImplementedError, "#{name}.create is not yet implemented")
59
+ def create!(title:, user: nil, visibility: 'internal', custom_fields: {}, readme: '', description: nil)
60
+ org = PlanMyStuff.configuration.organization
61
+
62
+ project_metadata = PlanMyStuff::ProjectMetadata.build(
63
+ user: user,
64
+ visibility: visibility,
65
+ custom_fields: custom_fields,
66
+ )
67
+ project_metadata.validate_custom_fields!
68
+
69
+ org_id = resolve_org_id(org)
70
+ data = PlanMyStuff.client.graphql(
71
+ create_mutation,
72
+ variables: { input: { ownerId: org_id, title: title } },
73
+ )
74
+
75
+ new_project = data.dig(:createProjectV2, :projectV2) || {}
76
+ project_id = new_project[:id]
77
+ project_number = new_project[:number]
78
+
79
+ serialized_readme = PlanMyStuff::MetadataParser.serialize(project_metadata.to_h, readme)
80
+ update_input = { projectId: project_id, readme: serialized_readme }
81
+ update_input[:shortDescription] = description if description.present?
82
+
83
+ PlanMyStuff.client.graphql(
84
+ update_mutation,
85
+ variables: { input: update_input },
86
+ )
87
+
88
+ find(project_number)
46
89
  end
47
90
 
48
91
  # Updates an existing project.
49
92
  #
50
93
  # @param project_number [Integer]
51
94
  # @param title [String, nil]
95
+ # @param readme [String, nil] user-visible readme content (metadata preserved)
96
+ # @param description [String, nil] project short description
97
+ # @param metadata [Hash, nil] custom fields to merge into existing metadata
52
98
  #
53
- # @return [Object]
99
+ # @return [PlanMyStuff::Project]
54
100
  #
55
- def update(project_number:, title: nil)
56
- raise(NotImplementedError, "#{name}.update is not yet implemented")
101
+ def update!(project_number:, title: nil, readme: nil, description: nil, metadata: nil)
102
+ org = PlanMyStuff.configuration.organization
103
+ project_id = resolve_project_id(org, project_number)
104
+
105
+ update_input = { projectId: project_id }
106
+ update_input[:title] = title unless title.nil?
107
+ update_input[:shortDescription] = description unless description.nil?
108
+
109
+ if metadata.present? || !readme.nil?
110
+ current = find(project_number)
111
+ parsed = PlanMyStuff::MetadataParser.parse(current.raw_readme)
112
+ existing_metadata = parsed[:metadata]
113
+
114
+ if metadata.present?
115
+ # Seed with fresh metadata when project has no existing PMS metadata
116
+ if existing_metadata[:schema_version].blank?
117
+ existing_metadata = PlanMyStuff::ProjectMetadata.build(user: nil).to_h
118
+ end
119
+
120
+ merged_custom_fields = (existing_metadata[:custom_fields] || {}).merge(metadata[:custom_fields] || {})
121
+ existing_metadata = existing_metadata.merge(metadata)
122
+ existing_metadata[:custom_fields] = merged_custom_fields
123
+ PlanMyStuff::CustomFields.new(
124
+ PlanMyStuff.configuration.custom_fields_for(:project),
125
+ merged_custom_fields,
126
+ ).validate!
127
+ end
128
+
129
+ body = readme.nil? ? parsed[:body] : readme
130
+ update_input[:readme] = PlanMyStuff::MetadataParser.serialize(existing_metadata, body)
131
+ end
132
+
133
+ PlanMyStuff.client.graphql(
134
+ update_mutation,
135
+ variables: { input: update_input },
136
+ )
137
+
138
+ find(project_number)
57
139
  end
58
140
 
59
141
  # Lists all projects in the configured organization.
@@ -90,20 +172,20 @@ module PlanMyStuff
90
172
  end
91
173
  end
92
174
 
93
- private
175
+ # Resolves a project number, falling back to config.default_project_number.
176
+ #
177
+ # @param project_number [Integer, nil]
178
+ #
179
+ # @return [Integer]
180
+ #
181
+ def resolve_default_project_number(project_number)
182
+ return project_number if project_number.present?
94
183
 
95
- # Resolves a project number, falling back to config.default_project_number.
96
- #
97
- # @param project_number [Integer, nil]
98
- #
99
- # @return [Integer]
100
- #
101
- def resolve_default_project_number(project_number)
102
- return project_number if project_number.present?
184
+ PlanMyStuff.configuration.default_project_number ||
185
+ raise(ArgumentError, 'project_number is required when config.default_project_number is not set')
186
+ end
103
187
 
104
- PlanMyStuff.configuration.default_project_number ||
105
- raise(ArgumentError, 'project_number is required when config.default_project_number is not set')
106
- end
188
+ private
107
189
 
108
190
  # @return [String]
109
191
  def list_query
@@ -115,8 +197,10 @@ module PlanMyStuff
115
197
  id
116
198
  number
117
199
  title
200
+ shortDescription
118
201
  url
119
202
  closed
203
+ updatedAt
120
204
  }
121
205
  }
122
206
  }
@@ -133,8 +217,11 @@ module PlanMyStuff
133
217
  id
134
218
  number
135
219
  title
220
+ shortDescription
221
+ readme
136
222
  url
137
223
  closed
224
+ updatedAt
138
225
  fields(first: 50) {
139
226
  nodes {
140
227
  ... on ProjectV2SingleSelectField {
@@ -170,6 +257,7 @@ module PlanMyStuff
170
257
  number
171
258
  url
172
259
  state
260
+ repository { nameWithOwner }
173
261
  }
174
262
  ... on PullRequest {
175
263
  id
@@ -177,6 +265,7 @@ module PlanMyStuff
177
265
  number
178
266
  url
179
267
  state
268
+ repository { nameWithOwner }
180
269
  }
181
270
  ... on DraftIssue {
182
271
  id
@@ -342,6 +431,7 @@ module PlanMyStuff
342
431
  def parse_project_item(item)
343
432
  content = item[:content] || {}
344
433
  field_values = item.dig(:fieldValues, :nodes) || []
434
+ repo_name = content.dig(:repository, :nameWithOwner)
345
435
 
346
436
  {
347
437
  id: item[:id],
@@ -351,6 +441,7 @@ module PlanMyStuff
351
441
  number: content[:number],
352
442
  url: content[:url],
353
443
  state: content[:state],
444
+ repo: repo_name.present? ? PlanMyStuff::Repo.resolve(repo_name) : nil,
354
445
  status: extract_item_status(field_values),
355
446
  field_values: parse_field_values(field_values),
356
447
  }
@@ -404,6 +495,60 @@ module PlanMyStuff
404
495
 
405
496
  data.dig(:organization, :projectV2, :id)
406
497
  end
498
+
499
+ # Resolves an organization login to its node ID.
500
+ #
501
+ # @param org [String]
502
+ #
503
+ # @return [String]
504
+ #
505
+ def resolve_org_id(org)
506
+ data = PlanMyStuff.client.graphql(
507
+ org_id_query,
508
+ variables: { org: org },
509
+ )
510
+
511
+ data.dig(:organization, :id)
512
+ end
513
+
514
+ # @return [String]
515
+ def org_id_query
516
+ <<~GRAPHQL
517
+ query($org: String!) {
518
+ organization(login: $org) {
519
+ id
520
+ }
521
+ }
522
+ GRAPHQL
523
+ end
524
+
525
+ # @return [String]
526
+ def create_mutation
527
+ <<~GRAPHQL
528
+ mutation($input: CreateProjectV2Input!) {
529
+ createProjectV2(input: $input) {
530
+ projectV2 {
531
+ id
532
+ number
533
+ }
534
+ }
535
+ }
536
+ GRAPHQL
537
+ end
538
+
539
+ # @return [String]
540
+ def update_mutation
541
+ <<~GRAPHQL
542
+ mutation($input: UpdateProjectV2Input!) {
543
+ updateProjectV2(input: $input) {
544
+ projectV2 {
545
+ id
546
+ number
547
+ }
548
+ }
549
+ }
550
+ GRAPHQL
551
+ end
407
552
  end
408
553
 
409
554
  # @see super
@@ -411,6 +556,11 @@ module PlanMyStuff
411
556
  @id = attrs.delete(:id)
412
557
  @number = attrs.delete(:number)
413
558
  @closed = attrs.delete(:closed)
559
+ @metadata = PlanMyStuff::ProjectMetadata.new
560
+ @raw_readme = nil
561
+ @readme = nil
562
+ @description = nil
563
+ @updated_at = nil
414
564
  super
415
565
  @statuses ||= []
416
566
  @fields ||= []
@@ -431,6 +581,58 @@ module PlanMyStuff
431
581
  field
432
582
  end
433
583
 
584
+ # @return [Boolean]
585
+ def pms_project?
586
+ metadata.schema_version.present?
587
+ end
588
+
589
+ # Persists the project. Creates if new, updates if persisted.
590
+ #
591
+ # @raise [PlanMyStuff::StaleObjectError] on update if stale
592
+ #
593
+ # @return [self]
594
+ #
595
+ def save!
596
+ if new_record?
597
+ created = self.class.create!(title: title, readme: readme || '')
598
+ hydrate_from_project(created)
599
+ else
600
+ update!(title: title, readme: readme)
601
+ end
602
+
603
+ self
604
+ end
605
+
606
+ # Updates this project on GitHub. Raises StaleObjectError if the remote
607
+ # has been modified since this instance was loaded.
608
+ #
609
+ # @param attrs [Hash] attributes to update (title:, readme:, description:, metadata:)
610
+ #
611
+ # @raise [PlanMyStuff::StaleObjectError] if remote updated_at differs from local
612
+ #
613
+ # @return [self]
614
+ #
615
+ def update!(**attrs)
616
+ raise_if_stale!
617
+
618
+ self.class.update!(
619
+ project_number: number,
620
+ **attrs,
621
+ )
622
+
623
+ reload
624
+ end
625
+
626
+ # Re-fetches this project from GitHub and updates all local attributes.
627
+ #
628
+ # @return [self]
629
+ #
630
+ def reload
631
+ fresh = self.class.find(number)
632
+ hydrate_from_project(fresh)
633
+ self
634
+ end
635
+
434
636
  private
435
637
 
436
638
  # Populates this instance from a list query node (summary only).
@@ -443,8 +645,10 @@ module PlanMyStuff
443
645
  @id = node[:id]
444
646
  @number = node[:number]
445
647
  @title = node[:title]
648
+ @description = node[:shortDescription]
446
649
  @url = node[:url]
447
650
  @closed = node[:closed]
651
+ @updated_at = parse_github_time(node[:updatedAt])
448
652
  @persisted = true
449
653
  end
450
654
 
@@ -461,8 +665,15 @@ module PlanMyStuff
461
665
  @id = graphql_project[:id]
462
666
  @number = graphql_project[:number]
463
667
  @title = graphql_project[:title]
668
+ @description = graphql_project[:shortDescription]
464
669
  @url = graphql_project[:url]
465
670
  @closed = graphql_project[:closed]
671
+ @updated_at = parse_github_time(graphql_project[:updatedAt])
672
+
673
+ @raw_readme = graphql_project[:readme] || ''
674
+ parsed = PlanMyStuff::MetadataParser.parse(@raw_readme)
675
+ @metadata = PlanMyStuff::ProjectMetadata.from_hash(parsed[:metadata])
676
+ @readme = parsed[:body]
466
677
 
467
678
  fields_nodes = graphql_project.dig(:fields, :nodes) || []
468
679
  @statuses = extract_statuses(fields_nodes)
@@ -473,6 +684,56 @@ module PlanMyStuff
473
684
  @persisted = true
474
685
  end
475
686
 
687
+ # Copies attributes from another Project instance into self.
688
+ #
689
+ # @param other [PlanMyStuff::Project]
690
+ #
691
+ # @return [void]
692
+ #
693
+ def hydrate_from_project(other)
694
+ @id = other.id
695
+ @number = other.number
696
+ @title = other.title
697
+ @description = other.description
698
+ @url = other.url
699
+ @closed = other.closed
700
+ @updated_at = other.updated_at
701
+ @raw_readme = other.raw_readme
702
+ @readme = other.readme
703
+ @metadata = other.metadata
704
+ @statuses = other.statuses
705
+ @fields = other.fields
706
+ @items = other.items
707
+ @next_cursor = other.next_cursor
708
+ @has_next_page = other.has_next_page
709
+ @persisted = true
710
+ end
711
+
712
+ # Raises StaleObjectError if the remote project has been modified
713
+ # since this instance was loaded.
714
+ #
715
+ # @raise [PlanMyStuff::StaleObjectError]
716
+ #
717
+ # @return [void]
718
+ #
719
+ def raise_if_stale!
720
+ return if new_record?
721
+ return if updated_at.nil?
722
+
723
+ remote = self.class.find(number)
724
+ remote_time = remote.updated_at
725
+ local_time = updated_at
726
+
727
+ return if remote_time.nil?
728
+ return if local_time && remote_time.to_i == local_time.to_i
729
+
730
+ raise(PlanMyStuff::StaleObjectError.new(
731
+ "Project ##{number} has been modified remotely",
732
+ local_updated_at: local_time,
733
+ remote_updated_at: remote_time,
734
+ ))
735
+ end
736
+
476
737
  # Extracts status options from the "Status" single-select field.
477
738
  #
478
739
  # @param fields_nodes [Array<Hash>]
@@ -29,6 +29,8 @@ module PlanMyStuff
29
29
  attr_accessor :number
30
30
  # @return [String, nil]
31
31
  attr_accessor :url
32
+ # @return [PlanMyStuff::Repo, nil]
33
+ attr_accessor :repo
32
34
  # @return [String, nil]
33
35
  attr_accessor :state
34
36
  # @return [String, nil]
@@ -38,7 +40,7 @@ module PlanMyStuff
38
40
  # @return [PlanMyStuff::Project, nil]
39
41
  attr_accessor :project
40
42
  # @return [PlanMyStuff::Issue, nil] linked issue (nil for draft items)
41
- attr_accessor :issue
43
+ attr_writer :issue
42
44
 
43
45
  class << self
44
46
  # Builds a persisted ProjectItem from parsed item data.
@@ -56,6 +58,7 @@ module PlanMyStuff
56
58
  title: item_hash[:title],
57
59
  number: item_hash[:number],
58
60
  url: item_hash[:url],
61
+ repo: item_hash[:repo],
59
62
  state: item_hash[:state],
60
63
  status: item_hash[:status],
61
64
  field_values: item_hash[:field_values] || {},
@@ -170,6 +173,7 @@ module PlanMyStuff
170
173
  item = build(
171
174
  {
172
175
  id: item_data[:id],
176
+ content_node_id: node_id,
173
177
  title: issue.title,
174
178
  number: issue.number,
175
179
  url: nil,
@@ -210,6 +214,8 @@ module PlanMyStuff
210
214
  build(
211
215
  {
212
216
  id: item_data[:id],
217
+ content_node_id: item_data.dig(:content, :id),
218
+ type: 'DRAFT_ISSUE',
213
219
  title: title,
214
220
  number: nil,
215
221
  url: nil,
@@ -221,17 +227,9 @@ module PlanMyStuff
221
227
  )
222
228
  end
223
229
 
224
- # Resolves a project number, falling back to config.default_project_number.
225
- #
226
- # @param project_number [Integer, nil]
227
- #
228
- # @return [Integer]
229
- #
230
+ # @see PlanMyStuff::Project.resolve_default_project_number
230
231
  def resolve_default_project_number(project_number)
231
- return project_number if project_number.present?
232
-
233
- PlanMyStuff.configuration.default_project_number ||
234
- raise(ArgumentError, 'project_number is required when config.default_project_number is not set')
232
+ PlanMyStuff::Project.resolve_default_project_number(project_number)
235
233
  end
236
234
 
237
235
  # @return [String]
@@ -261,6 +259,7 @@ module PlanMyStuff
261
259
  }) {
262
260
  projectItem {
263
261
  id
262
+ content { ... on DraftIssue { id } }
264
263
  }
265
264
  }
266
265
  }
@@ -393,6 +392,7 @@ module PlanMyStuff
393
392
  content_node_id: content_node_id,
394
393
  assignees: Array.wrap(assignees),
395
394
  draft: draft?,
395
+ repo: repo,
396
396
  )
397
397
  end
398
398
 
@@ -401,6 +401,14 @@ module PlanMyStuff
401
401
  type == 'DRAFT_ISSUE'
402
402
  end
403
403
 
404
+ # @return [PlanMyStuff::Issue, nil]
405
+ def issue
406
+ return @issue if defined?(@issue)
407
+ return if draft?
408
+
409
+ @issue = PMS::Issue.find(number, repo: repo)
410
+ end
411
+
404
412
  private
405
413
 
406
414
  # Marks this record as persisted.
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanMyStuff
4
+ class ProjectMetadata < BaseMetadata
5
+ class << self
6
+ # Builds a ProjectMetadata from a parsed hash (e.g. from MetadataParser)
7
+ #
8
+ # @param hash [Hash]
9
+ #
10
+ # @return [PlanMyStuff::ProjectMetadata]
11
+ #
12
+ def from_hash(hash)
13
+ metadata = new
14
+ apply_common_from_hash(metadata, hash, PlanMyStuff.configuration.custom_fields_for(:project))
15
+
16
+ metadata
17
+ end
18
+
19
+ # Builds a new ProjectMetadata for project creation, auto-filling gem defaults
20
+ #
21
+ # @param user [Object, Integer] user object or user_id
22
+ # @param visibility [String] "public" or "internal"
23
+ # @param custom_fields [Hash] app-defined field values
24
+ #
25
+ # @return [PlanMyStuff::ProjectMetadata]
26
+ #
27
+ def build(user:, visibility: 'internal', custom_fields: {})
28
+ metadata = new
29
+ apply_common_build(
30
+ metadata,
31
+ user: user,
32
+ visibility: visibility,
33
+ custom_fields_data: custom_fields,
34
+ custom_fields_schema: PlanMyStuff.configuration.custom_fields_for(:project),
35
+ )
36
+
37
+ metadata
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanMyStuff
4
+ class Repo
5
+ # @return [Symbol, nil] configured key (e.g. :my_repo)
6
+ attr_reader :key
7
+
8
+ # @return [String] repo name (e.g. "MyRepository")
9
+ attr_reader :name
10
+
11
+ # @return [String] organization name (e.g. "YourOrgName")
12
+ attr_reader :organization
13
+
14
+ delegate :split, to: :to_s
15
+
16
+ class << self
17
+ # Builds a Repo instance from a Symbol key, full name String, or nil (default).
18
+ #
19
+ # @param repo [Symbol, String, PlanMyStuff::Repo, nil]
20
+ #
21
+ # @return [PlanMyStuff::Repo]
22
+ #
23
+ def resolve(repo = nil)
24
+ return repo if repo.is_a?(PlanMyStuff::Repo)
25
+
26
+ repo ||= PlanMyStuff.configuration.default_repo
27
+
28
+ if repo.nil?
29
+ raise(
30
+ PlanMyStuff::ConfigurationError,
31
+ 'No repo provided and config.default_repo is not set. ' \
32
+ 'Either pass repo: explicitly or set config.default_repo in your initializer.',
33
+ )
34
+ end
35
+
36
+ case repo
37
+ when Symbol
38
+ full_name = PlanMyStuff.configuration.repos[repo]
39
+ raise(ArgumentError, "Unknown repo key: #{repo.inspect}") if full_name.nil?
40
+
41
+ from_full_name(full_name, key: repo)
42
+ when String
43
+ key = PlanMyStuff.configuration.repos.key(repo)
44
+ from_full_name(repo, key: key)
45
+ else
46
+ raise(ArgumentError, "Cannot resolve repo: #{repo.inspect}")
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ # @param full_name [String] e.g. "YourOrgName/MyRepository"
53
+ # @param key [Symbol, nil]
54
+ #
55
+ # @return [PlanMyStuff::Repo]
56
+ #
57
+ def from_full_name(full_name, key: nil)
58
+ org, name = full_name.split('/', 2)
59
+
60
+ raise(ArgumentError, "Invalid repo full_name: #{full_name.inspect}") if name.nil?
61
+
62
+ new(name: name, organization: org, key: key)
63
+ end
64
+ end
65
+
66
+ # @param name [String]
67
+ # @param organization [String]
68
+ # @param key [Symbol, nil]
69
+ #
70
+ def initialize(name:, organization:, key: nil)
71
+ @key = key
72
+ @name = name
73
+ @organization = organization
74
+ end
75
+
76
+ # @return [String] full repo path (e.g. "YourOrgName/MyRepository")
77
+ def full_name
78
+ "#{organization}/#{name}"
79
+ end
80
+
81
+ # @see #full_name
82
+ alias to_s full_name
83
+
84
+ # Enables implicit string coercion so Repo instances behave as
85
+ # strings when passed to Octokit or compared with String#==.
86
+ #
87
+ # @see #full_name
88
+ alias to_str full_name
89
+
90
+ # Compares by full_name. Accepts another Repo or a String.
91
+ #
92
+ # @param other [PlanMyStuff::Repo, String, Object]
93
+ #
94
+ # @return [Boolean]
95
+ #
96
+ def ==(other)
97
+ case other
98
+ when PlanMyStuff::Repo
99
+ full_name == other.full_name
100
+ when String
101
+ full_name == other
102
+ else
103
+ super
104
+ end
105
+ end
106
+ end
107
+ end
@@ -316,10 +316,17 @@ module PlanMyStuff
316
316
  },
317
317
  }
318
318
 
319
+ resolved_repo =
320
+ if params[:repo]
321
+ PlanMyStuff::Repo.resolve(params[:repo]).full_name
322
+ else
323
+ 'TestOrg/TestRepo'
324
+ end
325
+
319
326
  PlanMyStuff::TestHelpers.build_issue(
320
327
  title: params[:title],
321
328
  body: params[:body],
322
- repo: params[:repo]&.to_s || 'TestOrg/TestRepo',
329
+ repo: resolved_repo,
323
330
  labels: params[:labels] || [],
324
331
  )
325
332
  end
@@ -330,7 +337,8 @@ module PlanMyStuff
330
337
  params: { number: number, repo: repo },
331
338
  }
332
339
 
333
- PlanMyStuff::TestHelpers.build_issue(number: number, repo: repo&.to_s || 'TestOrg/TestRepo')
340
+ resolved_repo = repo ? PlanMyStuff::Repo.resolve(repo).full_name : 'TestOrg/TestRepo'
341
+ PlanMyStuff::TestHelpers.build_issue(number: number, repo: resolved_repo)
334
342
  end
335
343
 
336
344
  issue_mod.define_singleton_method(:list) do |**params|
@@ -3,8 +3,8 @@
3
3
  module PlanMyStuff
4
4
  module VERSION
5
5
  MAJOR = 0
6
- MINOR = 1
7
- TINY = 1
6
+ MINOR = 3
7
+ TINY = 0
8
8
 
9
9
  # Set PRE to nil unless it's a pre-release (beta, rc, etc.)
10
10
  PRE = nil