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
Defined Under Namespace
Classes: PageInfo
Instance Attribute Summary collapse
- #issues ⇒ Array<PlanMyStuff::Issue> readonly
-
#page ⇒ Integer
readonly
Echo of the requested page.
-
#per_page ⇒ Integer
readonly
Echo of the requested per_page.
Attributes inherited from ApplicationRecord
Class Method Summary collapse
-
.check_import!(import_id, repo: nil) ⇒ Hash
Polls a previously-submitted import for its current status.
-
.count(repo: nil, state: :open, labels: [], issue_type: nil, issue_fields: nil, priority_list: nil) ⇒ Integer
Counts GitHub issues matching the given filters without paginating full payloads.
-
.create!(title:, body:, repo: nil, labels: [], user: nil, metadata: {}, add_to_project: nil, visibility: 'public', visibility_allowlist: [], issue_type: nil, issue_fields: nil, attachments: []) ⇒ PlanMyStuff::Issue
Creates a GitHub issue with PMS metadata embedded in the body.
-
.find(id_or_number, repo: nil) ⇒ PlanMyStuff::Issue
Finds a single GitHub issue by number and parses its PMS metadata.
-
.from_param(param) ⇒ Array(PlanMyStuff::Repo, Integer)
Parses an Issue#to_param string of the form “Nickname-1234” back into [Repo, Integer].
-
.import!(payloads) ⇒ Array<Hash>
Submits one or more pre-built payloads to GitHub’s “Import Issues” preview endpoint (+POST /repos/:repo/import/issues+).
-
.list ⇒ Array<PlanMyStuff::Issue>
Lists GitHub issues with optional filters and pagination.
-
.list_page_info(repo: nil, state: :open, labels: [], issue_type: nil, issue_fields: nil, priority_list: nil, page: 1, per_page: 25) ⇒ PlanMyStuff::Issue::PageInfo
Lists GitHub issues like
.list, but returns aPageInfovalue object carrying the issues plus pagination metadata read from the response’sLinkheader in the same request. -
.priority_list ⇒ Array<PlanMyStuff::Issue>
Convenience shortcut for list(priority_list: true, …).
- .to_param(number, repo) ⇒ String
-
.update!(number:, repo: nil, title: nil, body: nil, metadata: nil, labels: nil, state: nil, assignees: nil, issue_type: ISSUE_TYPE_UNCHANGED, issue_fields: nil) ⇒ Object
Updates an existing GitHub issue.
Instance Method Summary collapse
-
#add_to_priority_list!(priority:) ⇒ self
(also: #update_priority_list_priority!)
Adds this issue to the Priority List at the given priority (or re-prioritizes if already listed).
-
#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_fields ⇒ PlanMyStuff::IssueFieldValueSet
Returns a hash-like view of GitHub Issue Field values currently set on this 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). -
#mark_responded!(user) ⇒ void
Stamps
metadata.responded_aton the first support-user engagement with this issue. -
#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
- #priority_list? ⇒ Boolean
- #priority_list_priority ⇒ Integer?
-
#raw_body ⇒ String?
Full body as stored on GitHub.
-
#reload ⇒ self
Re-fetches this issue from GitHub and updates all local attributes.
-
#remove_from_priority_list! ⇒ self
Removes this issue from the Priority List.
- #repo ⇒ PlanMyStuff::Repo?
- #repo=(value) ⇒ Object
-
#save!(user: nil, skip_notification: false) ⇒ self
Persists the issue.
-
#set_issue_fields!(updates) ⇒ self
Bulk-updates GitHub Issue Field values in a single
setIssueFieldValuemutation. -
#state ⇒ String?
Issue state (“open” or “closed”).
-
#title ⇒ String?
Issue title.
-
#to_param ⇒ String?
Single-segment URL token combining repo nickname and issue number, used by Rails route helpers (+youtrack_issue_path(@issue)+ -> “/issues/Rawr-1234”).
-
#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+ “/” +to_param, ornilwhen prefix, number, or repo is missing).
Methods included from PlanMyStuff::IssueExtractions::Waiting
#awaiting_reply?, #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.
809 810 811 812 |
# File 'lib/plan_my_stuff/issue.rb', line 809 def initialize(**attrs) @body_dirty = false super end |
Instance Attribute Details
#issues ⇒ Array<PlanMyStuff::Issue> (readonly)
22 23 24 25 |
# File 'lib/plan_my_stuff/issue.rb', line 22 PageInfo = Data.define(:issues, :page, :per_page, :has_next, :has_prev) do alias_method :has_next?, :has_next alias_method :has_prev?, :has_prev end |
#page ⇒ Integer (readonly)
Returns echo of the requested page.
22 23 24 25 |
# File 'lib/plan_my_stuff/issue.rb', line 22 PageInfo = Data.define(:issues, :page, :per_page, :has_next, :has_prev) do alias_method :has_next?, :has_next alias_method :has_prev?, :has_prev end |
#per_page ⇒ Integer (readonly)
Returns echo of the requested per_page.
22 23 24 25 |
# File 'lib/plan_my_stuff/issue.rb', line 22 PageInfo = Data.define(:issues, :page, :per_page, :has_next, :has_prev) do alias_method :has_next?, :has_next alias_method :has_prev?, :has_prev end |
Class Method Details
.check_import!(import_id, repo: nil) ⇒ Hash
Polls a previously-submitted import for its current status.
537 538 539 540 541 542 543 544 545 546 547 |
# File 'lib/plan_my_stuff/issue.rb', line 537 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 |
.count(repo: nil, state: :open, labels: [], issue_type: nil, issue_fields: nil, priority_list: nil) ⇒ Integer
Counts GitHub issues matching the given filters without paginating full payloads.
Uses GitHub’s Search API (search/issues), which returns total_count in a single request. The is:issue qualifier excludes PRs server-side.
Caveats:
-
The search index lags writes by up to ~1 minute, so freshly created/closed issues may not be reflected immediately.
-
The Search API has its own rate limit (30 req/min authenticated) separate from the core REST API.
465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 |
# File 'lib/plan_my_stuff/issue.rb', line 465 def count(repo: nil, state: :open, labels: [], issue_type: nil, issue_fields: nil, priority_list: nil) if priority_list == false raise(ArgumentError, 'priority_list: false is not supported (no GitHub negation qualifier)') end if issue_fields.present? && !PlanMyStuff.configuration.issue_fields_enabled raise(PlanMyStuff::IssueFieldsNotEnabledError) end client = PlanMyStuff.client resolved_repo = client.resolve_repo!(repo) normalized_state = state.to_s qualifiers = ["repo:#{resolved_repo}", 'is:issue'] qualifiers << "is:#{normalized_state}" unless normalized_state == 'all' labels_to_use = Array.wrap(labels).sort qualifiers += labels_to_use.map do |label| "label:\"#{label}\"" end resolved_type = resolve_issue_types_filter(issue_type) qualifiers << "type:#{resolved_type}" if resolved_type.present? field_pairs = [] field_pairs.concat(build_issue_field_filter_pairs(issue_fields)) if issue_fields.present? if priority_list && PlanMyStuff.configuration.issue_fields_enabled field_pairs.concat(build_issue_field_filter_pairs('Priority List' => 'Yes')) end qualifiers += field_pairs.map { |pair| "field.#{pair}" } = { per_page: 1 } [:advanced_search] = true if field_pairs.present? client.rest(:search_issues, qualifiers.join(' '), **).total_count end |
.create!(title:, body:, repo: nil, labels: [], user: nil, metadata: {}, add_to_project: nil, visibility: 'public', visibility_allowlist: [], issue_type: nil, issue_fields: nil, attachments: []) ⇒ PlanMyStuff::Issue
Creates a GitHub issue with PMS metadata embedded in the body.
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 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 |
# File 'lib/plan_my_stuff/issue.rb', line 108 def create!( title:, body:, repo: nil, labels: [], user: nil, metadata: {}, add_to_project: nil, visibility: 'public', visibility_allowlist: [], issue_type: nil, issue_fields: nil, attachments: [] ) if issue_fields.present? && !PlanMyStuff.configuration.issue_fields_enabled raise(PlanMyStuff::IssueFieldsNotEnabledError) end if issue_fields.present? issue_fields = issue_fields.to_h.transform_keys(&:to_s) issue_fields['Issue Status'] = 'Submitted' if issue_fields['Issue Status'].blank? end 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, attachments: , ) issue.set_issue_fields!(issue_fields) if issue_fields.present? issue.reload PlanMyStuff::Notifications.instrument('issue_created', issue, user: user) issue end |
.find(id_or_number, repo: nil) ⇒ PlanMyStuff::Issue
Finds a single GitHub issue by number and parses its PMS metadata.
Accepts a numeric id (Integer or all-digit String) plus an optional repo: kwarg, or a nickname-id String (e.g. “Rawr-1234”) where the repo is encoded in the prefix and repo: is ignored.
289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 |
# File 'lib/plan_my_stuff/issue.rb', line 289 def find(id_or_number, repo: nil) number, resolved_repo = resolve_find_args(id_or_number, repo) client = PlanMyStuff.client 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 |
.from_param(param) ⇒ Array(PlanMyStuff::Repo, Integer)
Parses an Issue#to_param string of the form “Nickname-1234” back into [Repo, Integer]. The repo is looked up via PlanMyStuff::Repo.from_nickname!, which scans config.repos for the key whose config.repo_nickname_for matches.
584 585 586 587 588 589 |
# File 'lib/plan_my_stuff/issue.rb', line 584 def from_param(param) match = param.to_s.match(/\A(?<nickname>.+)-(?<number>\d+)\z/) raise(ArgumentError, "Invalid issue param: #{param.inspect}") if match.nil? [PlanMyStuff::Repo.from_nickname!(match[:nickname]), match[:number].to_i] 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.
516 517 518 519 520 521 522 523 524 525 526 |
# File 'lib/plan_my_stuff/issue.rb', line 516 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 ⇒ Array<PlanMyStuff::Issue>
Lists GitHub issues with optional filters and pagination.
issue_fields: is a Hash keyed by GitHub Issue Field display name (String / Symbol). Each value is either a scalar (equality match – Date / Time are emitted as ISO 8601, everything else as to_s) or a Range for numeric / date bounds:
-
Date.parse(‘2026-01-01’)..Date.today -> start-date:>=2026-01-01,start-date:<=2026-05-21
-
Date.parse(‘2026-01-01’)…Date.today -> start-date:>=2026-01-01,start-date:<2026-05-21 (exclusive end)
-
..Date.today(beginless) / Date.parse(‘2026-01-01’).. (endless) drop the unbounded side
Multiple field constraints AND together. Composes with the existing priority_list: filter: both feed the same issue_field_values query param.
350 351 352 |
# File 'lib/plan_my_stuff/issue.rb', line 350 def list(**) list_page_info(**).issues end |
.list_page_info(repo: nil, state: :open, labels: [], issue_type: nil, issue_fields: nil, priority_list: nil, page: 1, per_page: 25) ⇒ PlanMyStuff::Issue::PageInfo
Lists GitHub issues like .list, but returns a PageInfo value object carrying the issues plus pagination metadata read from the response’s Link header in the same request. Use this over .list when a caller needs to know whether more pages exist (e.g. to render “Next”/“Prev” controls) without an optimistic page 1+ probe.
Shares the entire parameter surface, filtering, and PR-rejection behavior of .list; see it for semantics.
Note the PR-filter wart: per_page caps GitHub’s raw item count (issues + PRs), but PRs are stripped client-side afterward, so page_info.issues.length may be smaller than per_page. has_next? comes straight from the Link header, so it reflects raw items too – a page can report has_next? == true while showing fewer than per_page issues.
381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 |
# File 'lib/plan_my_stuff/issue.rb', line 381 def list_page_info( repo: nil, state: :open, labels: [], issue_type: nil, issue_fields: nil, priority_list: nil, page: 1, per_page: 25 ) if priority_list == false raise(ArgumentError, 'priority_list: false is not supported (no GitHub negation qualifier)') end if issue_fields.present? && !PlanMyStuff.configuration.issue_fields_enabled raise(PlanMyStuff::IssueFieldsNotEnabledError) end 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? resolved_type = resolve_issue_types_filter(issue_type) params[:type] = resolved_type if resolved_type.present? field_pairs = [] field_pairs.concat(build_issue_field_filter_pairs(issue_fields)) if issue_fields.present? if priority_list && PlanMyStuff.configuration.issue_fields_enabled field_pairs.concat(build_issue_field_filter_pairs('Priority List' => 'Yes')) end params[:issue_field_values] = field_pairs.join(',') if field_pairs.present? github_issues = client.rest(:list_issues, resolved_repo, **params) rels = client.last_response&.rels || {} filtered = github_issues.reject { |gi| gi.respond_to?(:pull_request) && gi.pull_request } PageInfo.new( issues: filtered.map { |gi| build(gi, repo: resolved_repo) }, page: page, per_page: per_page, has_next: rels[:next].present?, has_prev: rels[:prev].present?, ) end |
.priority_list ⇒ Array<PlanMyStuff::Issue>
Convenience shortcut for list(priority_list: true, …). See .list for parameter semantics.
431 432 433 |
# File 'lib/plan_my_stuff/issue.rb', line 431 def priority_list(**) list(**, priority_list: true) end |
.to_param(number, repo) ⇒ String
557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 |
# File 'lib/plan_my_stuff/issue.rb', line 557 def to_param(number, repo) return if number.blank? return if repo.blank? repo_obj = PlanMyStuff::Repo.resolve!(repo) if repo_obj.key.nil? raise( ArgumentError, "Repo #{repo_obj.full_name.inspect} is not configured in config.repos; " \ 'cannot build reversible Issue#to_param token', ) end "#{repo_obj.nickname}-#{number}" end |
.update!(number:, repo: nil, title: nil, body: nil, metadata: nil, labels: nil, state: nil, assignees: nil, issue_type: ISSUE_TYPE_UNCHANGED, issue_fields: nil) ⇒ 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.
221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 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 221 def update!( number:, repo: nil, title: nil, body: nil, metadata: nil, labels: nil, state: nil, assignees: nil, issue_type: ISSUE_TYPE_UNCHANGED, issue_fields: nil ) 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 updated_issue = find(number, repo: resolved_repo).set_issue_fields!(issue_fields) if issue_fields.present? return updated_issue 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
#add_to_priority_list!(priority:) ⇒ self Also known as: update_priority_list_priority!
Adds this issue to the Priority List at the given priority (or re-prioritizes if already listed). Sets Priority List and Priority List Priority together in a single setIssueFieldValue mutation so the two fields never drift out of sync.
1082 1083 1084 1085 1086 |
# File 'lib/plan_my_stuff/issue.rb', line 1082 def add_to_priority_list!(priority:) raise(ArgumentError, 'Priority must be an integer') unless priority.is_a?(Integer) set_issue_fields!('Priority List' => 'Yes', 'Priority List Priority' => priority) end |
#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 issue_archived.plan_my_stuff 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).
867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 |
# File 'lib/plan_my_stuff/issue.rb', line 867 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.
997 998 999 |
# File 'lib/plan_my_stuff/issue.rb', line 997 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.
56 |
# File 'lib/plan_my_stuff/issue.rb', line 56 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.
826 827 828 829 |
# File 'lib/plan_my_stuff/issue.rb', line 826 def body=(value) super @body_dirty = true end |
#body_comment ⇒ PlanMyStuff::Comment?
Returns the comment marked as the issue body, if any.
1015 1016 1017 |
# File 'lib/plan_my_stuff/issue.rb', line 1015 def body_comment pms_comments.find { |c| c..issue_body? } end |
#closed_at ⇒ Time?
Returns GitHub’s closed_at timestamp (nil while open).
49 |
# File 'lib/plan_my_stuff/issue.rb', line 49 attribute :closed_at |
#comments ⇒ Array<PlanMyStuff::Comment>
Lazy-loads and memoizes comments from the GitHub API.
981 982 983 |
# File 'lib/plan_my_stuff/issue.rb', line 981 def comments @comments ||= load_comments end |
#created_at ⇒ Time?
Returns GitHub’s created_at timestamp; settable on unpersisted issues for use with Issue.import.
47 |
# File 'lib/plan_my_stuff/issue.rb', line 47 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).
1048 1049 1050 |
# File 'lib/plan_my_stuff/issue.rb', line 1048 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.
1039 1040 1041 |
# File 'lib/plan_my_stuff/issue.rb', line 1039 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.
989 990 991 |
# File 'lib/plan_my_stuff/issue.rb', line 989 def html_url safe_read_field(github_response, :html_url) end |
#issue_fields ⇒ PlanMyStuff::IssueFieldValueSet
Returns a hash-like view of GitHub Issue Field values currently set on this issue. Reads on first access and memoizes; set_issue_fields! invalidates the cache. Returns an empty set without making a request when config.issue_fields_enabled is false.
1058 1059 1060 |
# File 'lib/plan_my_stuff/issue.rb', line 1058 def issue_fields @issue_fields ||= load_issue_fields! 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!.
60 |
# File 'lib/plan_my_stuff/issue.rb', line 60 attribute :issue_type, :string |
#labels ⇒ Array<String>
Returns label names.
43 |
# File 'lib/plan_my_stuff/issue.rb', line 43 attribute :labels, default: -> { [] } |
#locked ⇒ Boolean Also known as: locked?
Returns GitHub’s locked flag; true for archived or manually-locked issues (no new comments).
51 |
# File 'lib/plan_my_stuff/issue.rb', line 51 attribute :locked, :boolean, default: false |
#mark_responded!(user) ⇒ void
This method returns an undefined value.
Stamps metadata.responded_at on the first support-user engagement with this issue. Centralizes the guards so every engagement path (first support comment, Pipeline.take!, self-assign webhook) can funnel through one method. No-ops unless user resolves to a support user on a PMS issue that hasn’t been responded to yet.
951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 |
# File 'lib/plan_my_stuff/issue.rb', line 951 def mark_responded!(user) resolved = PlanMyStuff::UserResolver.resolve(user) return if resolved.blank? return unless PlanMyStuff::UserResolver.support?(resolved) return unless pms_issue? return if .responded? self.class.update!( number: number, repo: repo, metadata: { responded_at: PlanMyStuff.format_time(Time.now.utc) }, ) end |
#metadata ⇒ PlanMyStuff::IssueMetadata
Returns parsed metadata (empty when no PMS metadata present).
37 |
# File 'lib/plan_my_stuff/issue.rb', line 37 attribute :metadata, default: -> { PlanMyStuff::IssueMetadata.new } |
#number ⇒ Integer?
Returns GitHub issue number.
33 |
# File 'lib/plan_my_stuff/issue.rb', line 33 attribute :number, :integer |
#pms_comments ⇒ Array<PlanMyStuff::Comment>
Returns only comments created via PMS.
1007 1008 1009 |
# File 'lib/plan_my_stuff/issue.rb', line 1007 def pms_comments comments.select(&:pms_comment?) end |
#pms_issue? ⇒ Boolean
1002 1003 1004 |
# File 'lib/plan_my_stuff/issue.rb', line 1002 def pms_issue? .schema_version.present? end |
#priority_list? ⇒ Boolean
1063 1064 1065 |
# File 'lib/plan_my_stuff/issue.rb', line 1063 def priority_list? issue_fields['Priority List'] == 'Yes' end |
#priority_list_priority ⇒ Integer?
1068 1069 1070 |
# File 'lib/plan_my_stuff/issue.rb', line 1068 def priority_list_priority issue_fields['Priority List Priority'] end |
#raw_body ⇒ String?
Returns full body as stored on GitHub.
35 |
# File 'lib/plan_my_stuff/issue.rb', line 35 attribute :raw_body, :string |
#reload ⇒ self
Re-fetches this issue from GitHub and updates all local attributes.
971 972 973 974 975 |
# File 'lib/plan_my_stuff/issue.rb', line 971 def reload fresh = self.class.find(number, repo: repo) hydrate_from_issue(fresh) self end |
#remove_from_priority_list! ⇒ self
Removes this issue from the Priority List. Clears both Priority List and Priority List Priority in a single setIssueFieldValue mutation so the two fields never drift out of sync.
1097 1098 1099 |
# File 'lib/plan_my_stuff/issue.rb', line 1097 def remove_from_priority_list! set_issue_fields!('Priority List' => nil, 'Priority List Priority' => nil) end |
#repo=(value) ⇒ Object
815 816 817 |
# File 'lib/plan_my_stuff/issue.rb', line 815 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.
903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 |
# File 'lib/plan_my_stuff/issue.rb', line 903 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, issue_fields: @pending_issue_fields, ) hydrate_from_issue(created) else captured_changes = changes.dup persist_update! instrument_update(captured_changes, user) unless skip_notification end @pending_issue_fields = nil self end |
#set_issue_fields!(updates) ⇒ self
Bulk-updates GitHub Issue Field values in a single setIssueFieldValue mutation. Each key is the field display name; values are coerced to the right input fragment based on the field’s type. Passing nil as a value clears that field.
1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 |
# File 'lib/plan_my_stuff/issue.rb', line 1112 def set_issue_fields!(updates) raise(PlanMyStuff::IssueFieldsNotEnabledError) unless PlanMyStuff.configuration.issue_fields_enabled fields_by_name = PlanMyStuff::IssueField.list(org: repo.organization).index_by { |field| field.name.downcase } inputs = updates.map do |name, value| build_issue_field_input( fields_by_name, PlanMyStuff::IssueFieldTranslation.consumer_field_name(name), PlanMyStuff::IssueFieldTranslation.consumer_value(name, value), ) end PlanMyStuff.client.graphql( PlanMyStuff::GraphQL::Queries::SET_ISSUE_FIELD_VALUES, variables: { issueId: github_node_id, issueFields: inputs }, ) @issue_fields = nil self end |
#state ⇒ String?
Returns issue state (“open” or “closed”).
41 |
# File 'lib/plan_my_stuff/issue.rb', line 41 attribute :state, :string |
#title ⇒ String?
Returns issue title.
39 |
# File 'lib/plan_my_stuff/issue.rb', line 39 attribute :title, :string |
#to_param ⇒ String?
Single-segment URL token combining repo nickname and issue number, used by Rails route helpers (+youtrack_issue_path(@issue)+ -> “/issues/Rawr-1234”). Returns nil for new records or when number or repo is unset; Issue.from_param parses the same shape back into [Repo, Integer].
837 838 839 840 841 |
# File 'lib/plan_my_stuff/issue.rb', line 837 def to_param return if new_record? self.class.to_param(number, repo) end |
#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).
938 939 940 941 |
# File 'lib/plan_my_stuff/issue.rb', line 938 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.
45 |
# File 'lib/plan_my_stuff/issue.rb', line 45 attribute :updated_at |
#user_link ⇒ String?
Returns per-issue URL in the consuming app (config.issues_url_prefix + “/” + to_param, or nil when prefix, number, or repo is missing). Also rendered as the destination of the markdown link in the GitHub issue body.
846 847 848 849 850 851 852 853 854 |
# File 'lib/plan_my_stuff/issue.rb', line 846 def user_link prefix = PlanMyStuff.configuration.issues_url_prefix return if prefix.blank? to_par = to_param return if to_par.blank? "#{prefix.to_s.chomp('/')}/#{to_par}" end |