Class: PlanMyStuff::Issue
- Inherits:
-
ApplicationRecord
- Object
- ApplicationRecord
- PlanMyStuff::Issue
- Includes:
- PlanMyStuff::IssueExtractions::Approvals, PlanMyStuff::IssueExtractions::Links, PlanMyStuff::IssueExtractions::Viewers, PlanMyStuff::IssueExtractions::Waiting
- Defined in:
- lib/plan_my_stuff/issue.rb
Overview
Wraps a GitHub issue with parsed PMS metadata and comments. Class methods provide the public API for CRUD operations.
Follows an ActiveRecord-style pattern:
-
‘Issue.new(**attrs)` creates an unpersisted instance
-
‘Issue.create!` / `Issue.find` / `Issue.list` return persisted instances
-
‘issue.save!` / `issue.update!` / `issue.reload` for persistence
Instance Attribute Summary
Attributes inherited from ApplicationRecord
Class Method Summary collapse
-
.check_import!(import_id, repo: nil) ⇒ Hash
Polls a previously-submitted import for its current status.
-
.create!(title:, body:, repo: nil, labels: [], user: nil, metadata: {}, add_to_project: nil, visibility: 'public', visibility_allowlist: [], issue_type: nil) ⇒ PlanMyStuff::Issue
Creates a GitHub issue with PMS metadata embedded in the body.
-
.find(number, repo: nil) ⇒ PlanMyStuff::Issue
Finds a single GitHub issue by number and parses its PMS metadata.
-
.import!(payloads) ⇒ Array<Hash>
Submits one or more pre-built payloads to GitHub’s “Import Issues” preview endpoint (+POST /repos/:repo/import/issues+).
-
.list(repo: nil, state: :open, labels: [], page: 1, per_page: 25) ⇒ Array<PlanMyStuff::Issue>
Lists GitHub issues with optional filters and pagination.
-
.update!(number:, repo: nil, title: nil, body: nil, metadata: nil, labels: nil, state: nil, assignees: nil, issue_type: ISSUE_TYPE_UNCHANGED) ⇒ Object
Updates an existing GitHub issue.
Instance Method Summary collapse
-
#archive!(now: Time.now.utc) ⇒ self
Tags the issue with the configured
archived_label, removes it from every Projects V2 board it belongs to, locks its conversation on GitHub, and stampsmetadata.archived_at. -
#assignees ⇒ Array<String>
GitHub assignees for this issue, by login.
-
#body ⇒ String?
Returns the issue body content.
-
#body=(value) ⇒ String
Assigning a new body marks the instance dirty so the next
save!rewrites the backing PMS body comment. -
#body_comment ⇒ PlanMyStuff::Comment?
Returns the comment marked as the issue body, if any.
-
#closed_at ⇒ Time?
GitHub’s closed_at timestamp (nil while open).
-
#comments ⇒ Array<PlanMyStuff::Comment>
Lazy-loads and memoizes comments from the GitHub API.
-
#created_at ⇒ Time?
GitHub’s created_at timestamp; settable on unpersisted issues for use with
Issue.import. -
#github_id ⇒ Integer?
GitHub database ID (required for the REST issue-dependency API, which takes integer issue_id rather than issue number).
-
#github_node_id ⇒ String?
GitHub GraphQL node ID (required for native sub-issue mutations).
-
#html_url ⇒ String?
GitHub web URL for this issue, for escape-hatch “View on GitHub” links.
-
#initialize(**attrs) ⇒ Issue
constructor
A new instance of Issue.
-
#issue_type ⇒ String?
GitHub issue type name (e.g. “Bug”, “Feature”) or
nilwhen no type is assigned. -
#labels ⇒ Array<String>
Label names.
-
#locked ⇒ Boolean
(also: #locked?)
GitHub’s
lockedflag;truefor archived or manually-locked issues (no new comments). -
#metadata ⇒ PlanMyStuff::IssueMetadata
Parsed metadata (empty when no PMS metadata present).
-
#number ⇒ Integer?
GitHub issue number.
-
#pms_comments ⇒ Array<PlanMyStuff::Comment>
Only comments created via PMS.
- #pms_issue? ⇒ Boolean
-
#raw_body ⇒ String?
Full body as stored on GitHub.
-
#reload ⇒ self
Re-fetches this issue from GitHub and updates all local attributes.
- #repo ⇒ PlanMyStuff::Repo?
- #repo=(value) ⇒ Object
-
#save!(user: nil, skip_notification: false) ⇒ self
Persists the issue.
-
#state ⇒ String?
Issue state (“open” or “closed”).
-
#title ⇒ String?
Issue title.
-
#update!(user: nil, skip_notification: false, **attrs) ⇒ self
Applies
attrsto this instance in-memory then callssave!. -
#updated_at ⇒ Time?
GitHub’s updated_at timestamp.
-
#user_link ⇒ String?
Per-issue URL in the consuming app (
config.issues_url_prefix+ “/” +number+ “?repo=Org/Repo”, ornilwhen either prefix or number is missing).
Methods included from PlanMyStuff::IssueExtractions::Waiting
#clear_waiting_on_user!, #enter_waiting_on_user!, #reopen_by_reply!
Methods included from PlanMyStuff::IssueExtractions::Viewers
#add_viewers!, #remove_viewers!, #visible_to?
Methods included from PlanMyStuff::IssueExtractions::Links
#add_blocker!, #add_related!, #add_sub_issue!, #blocked_by, #blocking, #duplicate_of, #mark_duplicate!, #parent, #related, #remove_blocker!, #remove_parent!, #remove_related!, #remove_sub_issue!, #set_parent!, #sub_tickets
Methods included from PlanMyStuff::IssueExtractions::Approvals
#approvals_required?, #approve!, #approvers, #fully_approved?, #pending_approvals, #reject!, #rejected_approvals, #remove_approvers!, #request_approvals!, #revoke_approval!
Methods inherited from ApplicationRecord
#destroyed?, #new_record?, #persisted?, read_field
Constructor Details
#initialize(**attrs) ⇒ Issue
Returns a new instance of Issue.
468 469 470 471 |
# File 'lib/plan_my_stuff/issue.rb', line 468 def initialize(**attrs) @body_dirty = false super end |
Class Method Details
.check_import!(import_id, repo: nil) ⇒ Hash
Polls a previously-submitted import for its current status.
345 346 347 348 349 350 351 352 353 354 355 |
# File 'lib/plan_my_stuff/issue.rb', line 345 def check_import!(import_id, repo: nil) client = PlanMyStuff.import_client resolved_repo = client.resolve_repo!(repo) client.octokit.get( "/repos/#{resolved_repo}/import/issues/#{import_id}", accept: 'application/vnd.github.golden-comet-preview+json', ) rescue Octokit::ClientError, Octokit::ServerError => e raise(PlanMyStuff::APIError.new(e., status: e.respond_to?(:response_status) ? e.response_status : nil)) end |
.create!(title:, body:, repo: nil, labels: [], user: nil, metadata: {}, add_to_project: nil, visibility: 'public', visibility_allowlist: [], issue_type: nil) ⇒ PlanMyStuff::Issue
Creates a GitHub issue with PMS metadata embedded in the body.
90 91 92 93 94 95 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 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 |
# File 'lib/plan_my_stuff/issue.rb', line 90 def create!( title:, body:, repo: nil, labels: [], user: nil, metadata: {}, add_to_project: nil, visibility: 'public', visibility_allowlist: [], issue_type: nil ) if body.blank? raise(PlanMyStuff::ValidationError.new('body must be present', field: :body, expected_type: :string)) end client = PlanMyStuff.client resolved_repo = client.resolve_repo!(repo) = PlanMyStuff::IssueMetadata.build( user: user, visibility: visibility, custom_fields: , ) .visibility_allowlist = Array.wrap(visibility_allowlist) .validate_custom_fields! serialized_body = PlanMyStuff::MetadataParser.serialize!(.to_h, '') resolved_type = resolve_issue_type!(issue_type) = {} [:labels] = labels if labels.present? [:type] = resolved_type if resolved_type.present? result = client.rest(:create_issue, resolved_repo, title, serialized_body, **) number = read_field(result, :number) store_etag_to_cache(client, resolved_repo, number, result, cache_writer: :write_issue) link_body = visible_body_for(number, resolved_repo) if link_body.present? result = client.rest( :update_issue, resolved_repo, number, body: PlanMyStuff::MetadataParser.serialize!(.to_h, link_body), ) store_etag_to_cache(client, resolved_repo, number, result, cache_writer: :write_issue) end issue = find(number, repo: resolved_repo) if add_to_project.present? project_number = resolve_project_number!(add_to_project) PlanMyStuff::ProjectItem.create!(issue, project_number: project_number) end PlanMyStuff::Comment.create!( issue: issue, body: body, user: user, visibility: .visibility.to_sym, skip_responded: true, issue_body: true, ) issue.reload PlanMyStuff::Notifications.instrument('issue.created', issue, user: user) issue end |
.find(number, repo: nil) ⇒ PlanMyStuff::Issue
Finds a single GitHub issue by number and parses its PMS metadata.
248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 |
# File 'lib/plan_my_stuff/issue.rb', line 248 def find(number, repo: nil) client = PlanMyStuff.client resolved_repo = client.resolve_repo!(repo) github_issue = fetch_with_etag_cache( client, resolved_repo, number, rest_method: :issue, cache_reader: :read_issue, cache_writer: :write_issue, ) pull_request = if github_issue.respond_to?(:pull_request) github_issue.pull_request elsif github_issue.is_a?(Hash) github_issue[:pull_request] || github_issue['pull_request'] end if pull_request raise(Octokit::NotFound, { method: 'GET', url: "repos/#{resolved_repo}/issues/#{number}" }) end build(github_issue, repo: resolved_repo) end |
.import!(payloads) ⇒ Array<Hash>
Submits one or more pre-built payloads to GitHub’s “Import Issues” preview endpoint (+POST /repos/:repo/import/issues+). One request per payload: the endpoint only accepts a single {issue:, comments:} payload at a time.
Each payload hash MUST include a :repo key (symbol, string, or PlanMyStuff::Repo) and the GitHub-shaped :issue /+ :comments+ keys; :repo is extracted before the POST. Payloads are passed through to GitHub unchanged otherwise - callers are responsible for shape, encoding, and any PlanMyStuff metadata they want to embed.
The endpoint is async: each response carries an id and url for polling via Issue.check_import.
324 325 326 327 328 329 330 331 332 333 334 |
# File 'lib/plan_my_stuff/issue.rb', line 324 def import!(payloads) client = PlanMyStuff.import_client Array.wrap(payloads).map do |payload| repo = payload[:repo] || payload['repo'] || PlanMyStuff.configuration.default_repo raise(ArgumentError, 'import payload must include :repo') if repo.blank? body = payload.except(:repo, 'repo') submit_import_request!(client, client.resolve_repo!(repo), body) end end |
.list(repo: nil, state: :open, labels: [], page: 1, per_page: 25) ⇒ Array<PlanMyStuff::Issue>
Lists GitHub issues with optional filters and pagination.
286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 |
# File 'lib/plan_my_stuff/issue.rb', line 286 def list(repo: nil, state: :open, labels: [], page: 1, per_page: 25) client = PlanMyStuff.client resolved_repo = client.resolve_repo!(repo) params = { state: state.to_s, page: page, per_page: per_page } params[:labels] = labels.sort.join(',') if labels.present? cached = PlanMyStuff::Cache.read_list(:issue, resolved_repo, params) = cached ? params.merge(headers: { 'If-None-Match' => cached[:etag] }) : params github_issues = client.rest(:list_issues, resolved_repo, **) if cached && not_modified?(client) return cached[:body].map { |gi| build(gi, repo: resolved_repo) } end filtered = github_issues.reject { |gi| gi.respond_to?(:pull_request) && gi.pull_request } store_list_etag_to_cache(client, :issue, resolved_repo, params, filtered) filtered.map { |gi| build(gi, repo: resolved_repo) } end |
.update!(number:, repo: nil, title: nil, body: nil, metadata: nil, labels: nil, state: nil, assignees: nil, issue_type: ISSUE_TYPE_UNCHANGED) ⇒ Object
Updates an existing GitHub issue.
metadata: accepts either:
-
a
PlanMyStuff::IssueMetadatainstance - treated as the full authoritative metadata and serialized as-is (used by instancesave!/update!so local @metadata mutations like metadata.commit_sha = … actually persist). -
a
Hash- patch-style merge against the CURRENT remote metadata. Top-level keys are merged in;:custom_fieldsis merged separately so unrelated fields stay intact.
186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 |
# File 'lib/plan_my_stuff/issue.rb', line 186 def update!( number:, repo: nil, title: nil, body: nil, metadata: nil, labels: nil, state: nil, assignees: nil, issue_type: ISSUE_TYPE_UNCHANGED ) client = PlanMyStuff.client resolved_repo = client.resolve_repo!(repo) = {} [:title] = title unless title.nil? [:labels] = labels unless labels.nil? [:state] = state.to_s unless state.nil? [:assignees] = Array.wrap(assignees) unless assignees.nil? [:type] = resolve_issue_type!(issue_type) unless issue_type.equal?(ISSUE_TYPE_UNCHANGED) case when PlanMyStuff::IssueMetadata .validate_custom_fields! [:body] = PlanMyStuff::MetadataParser.serialize!(.to_h, visible_body_for(number, resolved_repo)) when Hash current = client.rest(:issue, resolved_repo, number) current_body = current.respond_to?(:body) ? current.body : current[:body] parsed = PlanMyStuff::MetadataParser.parse(current_body) = parsed[:metadata] merged_custom_fields = ([:custom_fields] || {}).merge([:custom_fields] || {}) = .merge() [:custom_fields] = merged_custom_fields PlanMyStuff::CustomFields.new( PlanMyStuff.configuration.custom_fields_for(:issue), merged_custom_fields, ).validate! [:body] = PlanMyStuff::MetadataParser.serialize!(, visible_body_for(number, resolved_repo)) end update_body_comment!(number, resolved_repo, body) if body return if .none? result = client.rest(:update_issue, resolved_repo, number, **) store_etag_to_cache(client, resolved_repo, number, result, cache_writer: :write_issue) result end |
Instance Method Details
#archive!(now: Time.now.utc) ⇒ self
Tags the issue with the configured archived_label, removes it from every Projects V2 board it belongs to, locks its conversation on GitHub, and stamps metadata.archived_at. Emits plan_my_stuff.issue.archived on success.
No-op (no network calls, no event) when the issue is already archived (either metadata.archived_at is set or the archived label is already on the issue).
514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 |
# File 'lib/plan_my_stuff/issue.rb', line 514 def archive!(now: Time.now.utc) label = PlanMyStuff.configuration.archived_label return self unless state == 'closed' return self if .archived_at.present? return self if labels.include?(label) self.class.update!( number: number, repo: repo, metadata: { archived_at: PlanMyStuff.format_time(now) }, ) PlanMyStuff::Label.ensure!(repo: repo, name: label) PlanMyStuff::Label.add!(issue: self, labels: [label]) remove_from_all_projects! PlanMyStuff.client.rest(:lock_issue, repo.full_name, number) reload PlanMyStuff::Notifications.instrument( 'issue.archived', self, reason: :aged_closed, ) self end |
#assignees ⇒ Array<String>
GitHub assignees for this issue, by login.
616 617 618 |
# File 'lib/plan_my_stuff/issue.rb', line 616 def assignees extract_assignee_logins(github_response) end |
#body ⇒ String?
Returns the issue body content. For PMS issues, this is the body from the body comment (stripped of its header). Falls back to the parsed issue body for non-PMS issues.
46 |
# File 'lib/plan_my_stuff/issue.rb', line 46 attribute :body, :string |
#body=(value) ⇒ String
Assigning a new body marks the instance dirty so the next save! rewrites the backing PMS body comment. Unsaved assignments are reflected by #body until persisted or reloaded.
485 486 487 488 |
# File 'lib/plan_my_stuff/issue.rb', line 485 def body=(value) super @body_dirty = true end |
#body_comment ⇒ PlanMyStuff::Comment?
Returns the comment marked as the issue body, if any.
634 635 636 |
# File 'lib/plan_my_stuff/issue.rb', line 634 def body_comment pms_comments.find { |c| c..issue_body? } end |
#closed_at ⇒ Time?
Returns GitHub’s closed_at timestamp (nil while open).
39 |
# File 'lib/plan_my_stuff/issue.rb', line 39 attribute :closed_at |
#comments ⇒ Array<PlanMyStuff::Comment>
Lazy-loads and memoizes comments from the GitHub API.
600 601 602 |
# File 'lib/plan_my_stuff/issue.rb', line 600 def comments @comments ||= load_comments end |
#created_at ⇒ Time?
Returns GitHub’s created_at timestamp; settable on unpersisted issues for use with Issue.import.
37 |
# File 'lib/plan_my_stuff/issue.rb', line 37 attribute :created_at |
#github_id ⇒ Integer?
GitHub database ID (required for the REST issue-dependency API, which takes integer issue_id rather than issue number).
667 668 669 |
# File 'lib/plan_my_stuff/issue.rb', line 667 def github_id safe_read_field(github_response, :id) end |
#github_node_id ⇒ String?
GitHub GraphQL node ID (required for native sub-issue mutations). Read from the hydrated REST response.
658 659 660 |
# File 'lib/plan_my_stuff/issue.rb', line 658 def github_node_id safe_read_field(github_response, :node_id) end |
#html_url ⇒ String?
GitHub web URL for this issue, for escape-hatch “View on GitHub” links.
608 609 610 |
# File 'lib/plan_my_stuff/issue.rb', line 608 def html_url safe_read_field(github_response, :html_url) end |
#issue_type ⇒ String?
Returns GitHub issue type name (e.g. “Bug”, “Feature”) or nil when no type is assigned. Read from the nested type.name field on the REST response. Settable via the issue_type: kwarg on Issue.create! / Issue.update!.
50 |
# File 'lib/plan_my_stuff/issue.rb', line 50 attribute :issue_type, :string |
#labels ⇒ Array<String>
Returns label names.
33 |
# File 'lib/plan_my_stuff/issue.rb', line 33 attribute :labels, default: -> { [] } |
#locked ⇒ Boolean Also known as: locked?
Returns GitHub’s locked flag; true for archived or manually-locked issues (no new comments).
41 |
# File 'lib/plan_my_stuff/issue.rb', line 41 attribute :locked, :boolean, default: false |
#metadata ⇒ PlanMyStuff::IssueMetadata
Returns parsed metadata (empty when no PMS metadata present).
27 |
# File 'lib/plan_my_stuff/issue.rb', line 27 attribute :metadata, default: -> { PlanMyStuff::IssueMetadata.new } |
#number ⇒ Integer?
Returns GitHub issue number.
23 |
# File 'lib/plan_my_stuff/issue.rb', line 23 attribute :number, :integer |
#pms_comments ⇒ Array<PlanMyStuff::Comment>
Returns only comments created via PMS.
626 627 628 |
# File 'lib/plan_my_stuff/issue.rb', line 626 def pms_comments comments.select(&:pms_comment?) end |
#pms_issue? ⇒ Boolean
621 622 623 |
# File 'lib/plan_my_stuff/issue.rb', line 621 def pms_issue? .schema_version.present? end |
#raw_body ⇒ String?
Returns full body as stored on GitHub.
25 |
# File 'lib/plan_my_stuff/issue.rb', line 25 attribute :raw_body, :string |
#reload ⇒ self
Re-fetches this issue from GitHub and updates all local attributes.
590 591 592 593 594 |
# File 'lib/plan_my_stuff/issue.rb', line 590 def reload fresh = self.class.find(number, repo: repo) hydrate_from_issue(fresh) self end |
#repo=(value) ⇒ Object
474 475 476 |
# File 'lib/plan_my_stuff/issue.rb', line 474 def repo=(value) super(value.present? ? PlanMyStuff::Repo.resolve!(value) : nil) end |
#save!(user: nil, skip_notification: false) ⇒ self
Persists the issue. Creates if new, otherwise performs a full write: serializes @metadata into the GitHub issue body and PATCHes title/state/labels. When #body= has been called since the last load, also rewrites the PMS body comment. Always reloads afterwards.
550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 |
# File 'lib/plan_my_stuff/issue.rb', line 550 def save!(user: nil, skip_notification: false) if new_record? created = self.class.create!( title: title, body: body, repo: repo, labels: labels || [], user: user || .created_by, metadata: .custom_fields.to_h, visibility: .visibility, visibility_allowlist: Array.wrap(.visibility_allowlist), issue_type: issue_type, ) hydrate_from_issue(created) else captured_changes = changes.dup persist_update! instrument_update(captured_changes, user) unless skip_notification end self end |
#state ⇒ String?
Returns issue state (“open” or “closed”).
31 |
# File 'lib/plan_my_stuff/issue.rb', line 31 attribute :state, :string |
#title ⇒ String?
Returns issue title.
29 |
# File 'lib/plan_my_stuff/issue.rb', line 29 attribute :title, :string |
#update!(user: nil, skip_notification: false, **attrs) ⇒ self
Applies attrs to this instance in-memory then calls save!. Supports title:, body:, state:, labels:, assignees:, and metadata:. The metadata: kwarg is a hash whose keys are merged into the existing metadata (top-level attributes assigned directly; :custom_fields merged key-by-key).
581 582 583 584 |
# File 'lib/plan_my_stuff/issue.rb', line 581 def update!(user: nil, skip_notification: false, **attrs) apply_update_attrs(attrs) save!(user: user, skip_notification: skip_notification) end |
#updated_at ⇒ Time?
Returns GitHub’s updated_at timestamp.
35 |
# File 'lib/plan_my_stuff/issue.rb', line 35 attribute :updated_at |
#user_link ⇒ String?
Returns per-issue URL in the consuming app (config.issues_url_prefix + “/” + number + “?repo=Org/Repo”, or nil when either prefix or number is missing). Also rendered as the destination of the markdown link in the GitHub issue body.
493 494 495 496 497 498 499 500 501 |
# File 'lib/plan_my_stuff/issue.rb', line 493 def user_link prefix = PlanMyStuff.configuration.issues_url_prefix return if prefix.blank? || number.blank? base = "#{prefix.to_s.chomp('/')}/#{number}" return base if repo.blank? "#{base}?repo=#{URI.encode_www_form_component(repo.full_name)}" end |