Class: PlanMyStuff::Issue
- Inherits:
-
ApplicationRecord
- Object
- ApplicationRecord
- PlanMyStuff::Issue
- 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
-
#add_blocker!(target) ⇒ PlanMyStuff::Link
Records that
targetblocks self. -
#add_related!(target, user: nil, reciprocal: false) ⇒ PlanMyStuff::Link
Adds a
:relatedlink totargetand, unless this call is already a reciprocal, mirrors the link back ontargetso the pairing is symmetric. -
#add_sub_issue!(target) ⇒ PlanMyStuff::Link
Adds
targetas a sub-issue of self via POST /issues/{number}/sub_issues. -
#add_viewers!(user_ids:, user: nil) ⇒ Array<Integer>
Adds user IDs to this issue’s visibility allowlist (non-support users whose ID is in the allowlist can see internal comments).
-
#approvals_required? ⇒ Boolean
True when at least one approver is required on this issue.
-
#approve!(user:) ⇒ PlanMyStuff::Approval
Flips the caller’s approval to
approvedfrom any other state (pendingorrejected). -
#approvers ⇒ Array<PlanMyStuff::Approval>
All required approvers (pending + approved + rejected).
-
#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.
-
#blocked_by ⇒ Array<PlanMyStuff::Issue>
Lazy-memoized issues that block self (i.e. self is blocked by each returned issue) via GitHub’s native issue-dependency REST API.
-
#blocking ⇒ Array<PlanMyStuff::Issue>
Lazy-memoized issues that self blocks.
-
#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.
-
#clear_waiting_on_user! ⇒ self
Clears the waiting-on-user state: removes the label, clears
metadata.waiting_on_user_at, and clearsmetadata.next_reminder_atunless a waiting-on-approval timer is still active. -
#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. -
#duplicate_of ⇒ PlanMyStuff::Issue?
Lazy-memoized issue that self was marked as duplicate of, via GitHub’s native close-as-duplicate.
-
#enter_waiting_on_user!(user: nil) ⇒ self
Marks the issue as waiting on an end-user reply.
-
#fully_approved? ⇒ Boolean
True when approvers are required AND every approver has approved.
-
#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). -
#mark_duplicate!(target, user: nil) ⇒ PlanMyStuff::Link
Closes self as a duplicate of
targetvia GitHub’s native close-as-duplicate, carrying over viewers, assignees, and a back-pointer comment on the target. -
#metadata ⇒ PlanMyStuff::IssueMetadata
Parsed metadata (empty when no PMS metadata present).
-
#number ⇒ Integer?
GitHub issue number.
-
#parent ⇒ PlanMyStuff::Issue?
Lazy-memoized parent issue via GitHub’s native sub-issues API.
-
#pending_approvals ⇒ Array<PlanMyStuff::Approval>
Approvers who have not yet acted (pending only; rejections are NOT pending – the approver has responded).
-
#pms_comments ⇒ Array<PlanMyStuff::Comment>
Only comments created via PMS.
- #pms_issue? ⇒ Boolean
-
#raw_body ⇒ String?
Full body as stored on GitHub.
-
#reject!(user:) ⇒ PlanMyStuff::Approval
Flips the caller’s approval to
rejectedfrom any other state (pendingorapproved). -
#rejected_approvals ⇒ Array<PlanMyStuff::Approval>
Approvers who have rejected.
-
#related ⇒ Array<PlanMyStuff::Issue>
Lazy-memoized array of
Issueobjects for:relatedlinks. -
#reload ⇒ self
Re-fetches this issue from GitHub and updates all local attributes.
-
#remove_approvers!(user_ids:, user: nil) ⇒ Array<PlanMyStuff::Approval>
Removes approvers from this issue’s required-approvals list.
-
#remove_blocker!(target) ⇒ PlanMyStuff::Link
Removes the record that
targetblocks self. -
#remove_parent! ⇒ PlanMyStuff::Link?
Detaches self from its current parent, if any.
-
#remove_related!(target, user: nil, reciprocal: false) ⇒ PlanMyStuff::Link
Removes a
:relatedlink totargetand, unless this call is already a reciprocal, mirrors the removal ontarget. -
#remove_sub_issue!(target) ⇒ PlanMyStuff::Link
Removes
targetas a sub-issue of self via DELETE /issues/{number}/sub_issue (singular). -
#remove_viewers!(user_ids:, user: nil) ⇒ Array<Integer>
Removes user IDs from this issue’s visibility allowlist.
-
#reopen_by_reply!(comment:, user: nil) ⇒ self
Reopens an issue that was auto-closed by the inactivity sweep, clears
metadata.closed_by_inactivity, and emitsplan_my_stuff.issue.reopened_by_replycarrying the reopening comment. - #repo ⇒ PlanMyStuff::Repo?
- #repo=(value) ⇒ Object
-
#request_approvals!(user_ids:, user: nil) ⇒ Array<PlanMyStuff::Approval>
Adds approvers to this issue’s required-approvals list.
-
#revoke_approval!(user:, target_user_id: nil) ⇒ PlanMyStuff::Approval
Flips an approved or rejected record back to
pending. -
#save!(user: nil, skip_notification: false) ⇒ self
Persists the issue.
-
#set_parent!(target) ⇒ PlanMyStuff::Link
Makes
targetthe parent of self. -
#state ⇒ String?
Issue state (“open” or “closed”).
-
#sub_tickets ⇒ Array<PlanMyStuff::Issue>
Lazy-memoized sub-issues via GitHub’s native sub-issues API.
-
#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). -
#visible_to?(user) ⇒ Boolean
Delegates visibility check to metadata.
Methods inherited from ApplicationRecord
#destroyed?, #new_record?, #persisted?, read_field
Constructor Details
#initialize(**attrs) ⇒ Issue
Returns a new instance of Issue.
458 459 460 461 |
# File 'lib/plan_my_stuff/issue.rb', line 458 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.
335 336 337 338 339 340 341 342 343 344 345 |
# File 'lib/plan_my_stuff/issue.rb', line 335 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.
80 81 82 83 84 85 86 87 88 89 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 |
# File 'lib/plan_my_stuff/issue.rb', line 80 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.
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 |
# File 'lib/plan_my_stuff/issue.rb', line 238 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.
314 315 316 317 318 319 320 321 322 323 324 |
# File 'lib/plan_my_stuff/issue.rb', line 314 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.
276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 |
# File 'lib/plan_my_stuff/issue.rb', line 276 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.
176 177 178 179 180 181 182 183 184 185 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 |
# File 'lib/plan_my_stuff/issue.rb', line 176 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
#add_blocker!(target) ⇒ PlanMyStuff::Link
Records that target blocks self. Native GitHub action; notifications are handled by GitHub itself.
1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 |
# File 'lib/plan_my_stuff/issue.rb', line 1115 def add_blocker!(target) link = build_link!(target, type: :blocked_by) validate_not_self!(link) target_issue = resolve_target_issue(target, type: :blocked_by) PlanMyStuff.client.rest( :post, dependency_path('blocked_by'), { issue_id: target_issue.__send__(:require_github_id!) }, ) invalidate_links_cache! link end |
#add_related!(target, user: nil, reciprocal: false) ⇒ PlanMyStuff::Link
Adds a :related link to target and, unless this call is already a reciprocal, mirrors the link back on target so the pairing is symmetric. Dedups on (type, issue_number, repo) - re-adding is a no-op.
982 983 984 985 986 987 988 989 990 991 992 993 994 995 |
# File 'lib/plan_my_stuff/issue.rb', line 982 def (target, user: nil, reciprocal: false) link = build_link!(target, type: :related) validate_not_self!(link) existing = current_links return link if existing.include?(link) persist_links!(existing + [link]) unless reciprocal mirror_on_target(link, user: user) { |other| other.(self, user: user, reciprocal: true) } end link end |
#add_sub_issue!(target) ⇒ PlanMyStuff::Link
Adds target as a sub-issue of self via POST /issues/{number}/sub_issues. Native GitHub action; notifications are handled by GitHub itself.
1046 1047 1048 |
# File 'lib/plan_my_stuff/issue.rb', line 1046 def add_sub_issue!(target) mutate_sub_issue!(target, method: :post, path: sub_issues_path) end |
#add_viewers!(user_ids:, user: nil) ⇒ Array<Integer>
Adds user IDs to this issue’s visibility allowlist (non-support users whose ID is in the allowlist can see internal comments).
Fires plan_my_stuff.issue.viewers_added.
530 531 532 533 534 535 |
# File 'lib/plan_my_stuff/issue.rb', line 530 def add_viewers!(user_ids:, user: nil) ids = Array.wrap(user_ids) modify_allowlist! { |allowlist| allowlist | ids } PlanMyStuff::Notifications.instrument('issue.viewers_added', self, user: user, user_ids: ids) .visibility_allowlist end |
#approvals_required? ⇒ Boolean
Returns true when at least one approver is required on this issue.
510 511 512 |
# File 'lib/plan_my_stuff/issue.rb', line 510 def approvals_required? approvers.present? end |
#approve!(user:) ⇒ PlanMyStuff::Approval
Flips the caller’s approval to approved from any other state (pending or rejected). Only the approver themselves may call this. Fires plan_my_stuff.issue.approval_granted and, when this flip completes the approval set, plan_my_stuff.issue.all_approved.
613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 |
# File 'lib/plan_my_stuff/issue.rb', line 613 def approve!(user:) actor_id = resolve_actor_id!(user) just_approved, was_fully_approved = modify_approvals! do |current| approval = current.find { |a| a.user_id == actor_id } raise(PlanMyStuff::ValidationError, "User #{actor_id} is not in the approvers list") if approval.nil? raise(PlanMyStuff::ValidationError, "User #{actor_id} has already approved") if approval.approved? approval.status = 'approved' approval.approved_at = Time.current approval.rejected_at = nil [current, approval] end finish_state_change(:approval_granted, just_approved, user: user, was_fully_approved: was_fully_approved) just_approved end |
#approvers ⇒ Array<PlanMyStuff::Approval>
Returns all required approvers (pending + approved + rejected).
494 495 496 |
# File 'lib/plan_my_stuff/issue.rb', line 494 def approvers .approvals 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 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).
809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 |
# File 'lib/plan_my_stuff/issue.rb', line 809 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.
911 912 913 |
# File 'lib/plan_my_stuff/issue.rb', line 911 def assignees extract_assignee_logins(github_response) end |
#blocked_by ⇒ Array<PlanMyStuff::Issue>
Lazy-memoized issues that block self (i.e. self is blocked by each returned issue) via GitHub’s native issue-dependency REST API.
1097 1098 1099 |
# File 'lib/plan_my_stuff/issue.rb', line 1097 def blocked_by links_cache[:blocked_by] ||= fetch_dependencies('blocked_by') end |
#blocking ⇒ Array<PlanMyStuff::Issue>
Lazy-memoized issues that self blocks.
1105 1106 1107 |
# File 'lib/plan_my_stuff/issue.rb', line 1105 def blocking links_cache[:blocking] ||= fetch_dependencies('blocking') 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.
36 |
# File 'lib/plan_my_stuff/issue.rb', line 36 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.
475 476 477 478 |
# File 'lib/plan_my_stuff/issue.rb', line 475 def body=(value) super @body_dirty = true end |
#body_comment ⇒ PlanMyStuff::Comment?
Returns the comment marked as the issue body, if any.
929 930 931 |
# File 'lib/plan_my_stuff/issue.rb', line 929 def body_comment pms_comments.find { |c| c..issue_body? } end |
#clear_waiting_on_user! ⇒ self
Clears the waiting-on-user state: removes the label, clears metadata.waiting_on_user_at, and clears metadata.next_reminder_at unless a waiting-on-approval timer is still active. No-ops if the issue is not currently waiting on a user reply.
751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 |
# File 'lib/plan_my_stuff/issue.rb', line 751 def clear_waiting_on_user! label = PlanMyStuff.configuration.waiting_on_user_label return self if .waiting_on_user_at.nil? && labels.exclude?(label) PlanMyStuff::Label.remove!(issue: self, labels: [label]) if labels.include?(label) self.class.update!( number: number, repo: repo, metadata: { waiting_on_user_at: nil, next_reminder_at: .waiting_on_approval_at ? PlanMyStuff.format_time(.next_reminder_at) : nil, }, ) reload end |
#closed_at ⇒ Time?
Returns GitHub’s closed_at timestamp (nil while open).
29 |
# File 'lib/plan_my_stuff/issue.rb', line 29 attribute :closed_at |
#comments ⇒ Array<PlanMyStuff::Comment>
Lazy-loads and memoizes comments from the GitHub API.
895 896 897 |
# File 'lib/plan_my_stuff/issue.rb', line 895 def comments @comments ||= load_comments end |
#created_at ⇒ Time?
Returns GitHub’s created_at timestamp; settable on unpersisted issues for use with Issue.import.
27 |
# File 'lib/plan_my_stuff/issue.rb', line 27 attribute :created_at |
#duplicate_of ⇒ PlanMyStuff::Issue?
Lazy-memoized issue that self was marked as duplicate of, via GitHub’s native close-as-duplicate. Returns nil for issues that are open or closed for other reasons.
1153 1154 1155 1156 1157 |
# File 'lib/plan_my_stuff/issue.rb', line 1153 def duplicate_of return links_cache[:duplicate_of] if links_cache.key?(:duplicate_of) links_cache[:duplicate_of] = fetch_duplicate_of end |
#enter_waiting_on_user!(user: nil) ⇒ self
Marks the issue as waiting on an end-user reply. Sets metadata.waiting_on_user_at to now, (re)computes metadata.next_reminder_at, and adds the configured waiting_on_user_label to the issue. Called from Comment.create! when a support user posts a comment with waiting_on_reply: true, and from the Issues::WaitingsController toggle.
727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 |
# File 'lib/plan_my_stuff/issue.rb', line 727 def enter_waiting_on_user!(user: nil) now = Time.now.utc label = PlanMyStuff.configuration.waiting_on_user_label PlanMyStuff::Label.ensure!(repo: repo, name: label) PlanMyStuff::Label.add!(issue: self, labels: [label], user: user) if labels.exclude?(label) self.class.update!( number: number, repo: repo, metadata: { waiting_on_user_at: PlanMyStuff.format_time(now), next_reminder_at: format_next_reminder_at(from: now), }, ) reload end |
#fully_approved? ⇒ Boolean
Returns true when approvers are required AND every approver has approved. A single rejection blocks this gate until the approver revokes.
516 517 518 |
# File 'lib/plan_my_stuff/issue.rb', line 516 def fully_approved? approvals_required? && approvers.all?(&:approved?) end |
#github_id ⇒ Integer?
GitHub database ID (required for the REST issue-dependency API, which takes integer issue_id rather than issue number).
1211 1212 1213 |
# File 'lib/plan_my_stuff/issue.rb', line 1211 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.
1202 1203 1204 |
# File 'lib/plan_my_stuff/issue.rb', line 1202 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.
903 904 905 |
# File 'lib/plan_my_stuff/issue.rb', line 903 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!.
40 |
# File 'lib/plan_my_stuff/issue.rb', line 40 attribute :issue_type, :string |
#labels ⇒ Array<String>
Returns label names.
23 |
# File 'lib/plan_my_stuff/issue.rb', line 23 attribute :labels, default: -> { [] } |
#locked ⇒ Boolean Also known as: locked?
Returns GitHub’s locked flag; true for archived or manually-locked issues (no new comments).
31 |
# File 'lib/plan_my_stuff/issue.rb', line 31 attribute :locked, :boolean, default: false |
#mark_duplicate!(target, user: nil) ⇒ PlanMyStuff::Link
Closes self as a duplicate of target via GitHub’s native close-as-duplicate, carrying over viewers, assignees, and a back-pointer comment on the target.
Side effects, in order:
-
Resolves
target; raisesValidationErrorif missing. -
Raises
ValidationErrorwhen self is already closed. -
Merges self’s
visibility_allowlistonto target. -
Merges self’s assignees onto target.
-
Posts a PMS comment on target with the back-pointer.
-
Closes self with state_reason: :duplicate and duplicate_of: { owner:, repo:, number: }.
-
Reloads self; invalidates link caches.
-
Fires
plan_my_stuff.issue.marked_duplicate.
Partial failures are not rolled back - GitHub retains whatever side effects succeeded before the failing step.
1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 |
# File 'lib/plan_my_stuff/issue.rb', line 1182 def mark_duplicate!(target, user: nil) raise(PlanMyStuff::ValidationError, 'Cannot mark a closed issue as duplicate') if state == 'closed' target_issue = resolve_duplicate_target!(target) merge_visibility_allowlist_onto!(target_issue) merge_assignees_onto!(target_issue) post_duplicate_back_pointer!(target_issue, user: user) close_as_duplicate!(target_issue) reload invalidate_links_cache! PlanMyStuff::Notifications.instrument('issue.marked_duplicate', self, target: target_issue, user: user) build_link!(target_issue, type: :duplicate_of) end |
#metadata ⇒ PlanMyStuff::IssueMetadata
Returns parsed metadata (empty when no PMS metadata present).
17 |
# File 'lib/plan_my_stuff/issue.rb', line 17 attribute :metadata, default: -> { PlanMyStuff::IssueMetadata.new } |
#number ⇒ Integer?
Returns GitHub issue number.
13 |
# File 'lib/plan_my_stuff/issue.rb', line 13 attribute :number, :integer |
#parent ⇒ PlanMyStuff::Issue?
Lazy-memoized parent issue via GitHub’s native sub-issues API. GitHub enforces at most one parent per issue.
1025 1026 1027 1028 1029 |
# File 'lib/plan_my_stuff/issue.rb', line 1025 def parent return links_cache[:parent] if links_cache.key?(:parent) links_cache[:parent] = fetch_parent end |
#pending_approvals ⇒ Array<PlanMyStuff::Approval>
Returns approvers who have not yet acted (pending only; rejections are NOT pending – the approver has responded).
500 501 502 |
# File 'lib/plan_my_stuff/issue.rb', line 500 def pending_approvals approvers.select(&:pending?) end |
#pms_comments ⇒ Array<PlanMyStuff::Comment>
Returns only comments created via PMS.
921 922 923 |
# File 'lib/plan_my_stuff/issue.rb', line 921 def pms_comments comments.select(&:pms_comment?) end |
#pms_issue? ⇒ Boolean
916 917 918 |
# File 'lib/plan_my_stuff/issue.rb', line 916 def pms_issue? .schema_version.present? end |
#raw_body ⇒ String?
Returns full body as stored on GitHub.
15 |
# File 'lib/plan_my_stuff/issue.rb', line 15 attribute :raw_body, :string |
#reject!(user:) ⇒ PlanMyStuff::Approval
Flips the caller’s approval to rejected from any other state (pending or approved). Only the approver themselves may call this. Fires plan_my_stuff.issue.approval_rejected and, when this flip drops the issue out of fully_approved? (i.e. the caller was the last approved approver), plan_my_stuff.issue.approvals_invalidated (+trigger: :rejected+).
642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 |
# File 'lib/plan_my_stuff/issue.rb', line 642 def reject!(user:) actor_id = resolve_actor_id!(user) just_rejected, was_fully_approved = modify_approvals! do |current| approval = current.find { |a| a.user_id == actor_id } raise(PlanMyStuff::ValidationError, "User #{actor_id} is not in the approvers list") if approval.nil? raise(PlanMyStuff::ValidationError, "User #{actor_id} has already rejected") if approval.rejected? approval.status = 'rejected' approval.rejected_at = Time.current approval.approved_at = nil [current, approval] end finish_state_change( :approval_rejected, just_rejected, user: user, was_fully_approved: was_fully_approved, trigger: :rejected, ) just_rejected end |
#rejected_approvals ⇒ Array<PlanMyStuff::Approval>
Returns approvers who have rejected.
505 506 507 |
# File 'lib/plan_my_stuff/issue.rb', line 505 def rejected_approvals approvers.select(&:rejected?) end |
#related ⇒ Array<PlanMyStuff::Issue>
Lazy-memoized array of Issue objects for :related links. Silently drops targets that 404 so a dangling pointer doesn’t break the rest of the list.
969 970 971 |
# File 'lib/plan_my_stuff/issue.rb', line 969 def links_cache[:related] ||= end |
#reload ⇒ self
Re-fetches this issue from GitHub and updates all local attributes.
885 886 887 888 889 |
# File 'lib/plan_my_stuff/issue.rb', line 885 def reload fresh = self.class.find(number, repo: repo) hydrate_from_issue(fresh) self end |
#remove_approvers!(user_ids:, user: nil) ⇒ Array<PlanMyStuff::Approval>
Removes approvers from this issue’s required-approvals list. Only support users may call this. Removing a pending approver may flip the issue into fully_approved? (fires all_approved). Removing an approved approver fires no events (state does not flip). Removing the last approver never fires aggregate events (issue no longer has approvals_required?).
590 591 592 593 594 595 596 597 598 599 600 601 |
# File 'lib/plan_my_stuff/issue.rb', line 590 def remove_approvers!(user_ids:, user: nil) guard_support!(user) ids = Array.wrap(user_ids).map(&:to_i) just_removed, was_fully_approved = modify_approvals! do |current| removed = current.select { |a| ids.include?(a.user_id) } [current - removed, removed] end emit_aggregate_events(was_fully_approved: was_fully_approved, trigger: nil, user: user) just_removed end |
#remove_blocker!(target) ⇒ PlanMyStuff::Link
Removes the record that target blocks self.
1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 |
# File 'lib/plan_my_stuff/issue.rb', line 1135 def remove_blocker!(target) link = build_link!(target, type: :blocked_by) validate_not_self!(link) target_issue = resolve_target_issue(target, type: :blocked_by) PlanMyStuff.client.rest( :delete, "#{dependency_path('blocked_by')}/#{target_issue.__send__(:require_github_id!)}", ) invalidate_links_cache! link end |
#remove_parent! ⇒ PlanMyStuff::Link?
Detaches self from its current parent, if any. Returns the Link that was removed, or nil when there was no parent.
1082 1083 1084 1085 1086 1087 1088 1089 1090 |
# File 'lib/plan_my_stuff/issue.rb', line 1082 def remove_parent! current = parent return if current.nil? current.remove_sub_issue!(self) invalidate_links_cache! build_link!(current, type: :parent) end |
#remove_related!(target, user: nil, reciprocal: false) ⇒ PlanMyStuff::Link
Removes a :related link to target and, unless this call is already a reciprocal, mirrors the removal on target. No-op when the link isn’t present locally.
1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 |
# File 'lib/plan_my_stuff/issue.rb', line 1006 def (target, user: nil, reciprocal: false) link = build_link!(target, type: :related) validate_not_self!(link) existing = current_links return link if existing.exclude?(link) persist_links!(existing.reject { |l| l == link }) unless reciprocal mirror_on_target(link, user: user) { |other| other.(self, user: user, reciprocal: true) } end link end |
#remove_sub_issue!(target) ⇒ PlanMyStuff::Link
Removes target as a sub-issue of self via DELETE /issues/{number}/sub_issue (singular).
1056 1057 1058 |
# File 'lib/plan_my_stuff/issue.rb', line 1056 def remove_sub_issue!(target) mutate_sub_issue!(target, method: :delete, path: remove_sub_issue_path) end |
#remove_viewers!(user_ids:, user: nil) ⇒ Array<Integer>
Removes user IDs from this issue’s visibility allowlist.
Fires plan_my_stuff.issue.viewers_removed.
546 547 548 549 550 551 |
# File 'lib/plan_my_stuff/issue.rb', line 546 def remove_viewers!(user_ids:, user: nil) ids = Array.wrap(user_ids) modify_allowlist! { |allowlist| allowlist - ids } PlanMyStuff::Notifications.instrument('issue.viewers_removed', self, user: user, user_ids: ids) .visibility_allowlist end |
#reopen_by_reply!(comment:, user: nil) ⇒ self
Reopens an issue that was auto-closed by the inactivity sweep, clears metadata.closed_by_inactivity, and emits plan_my_stuff.issue.reopened_by_reply carrying the reopening comment. Does not emit the regular issue.reopened event - subscribers that specifically care about this flow subscribe to the dedicated event.
777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 |
# File 'lib/plan_my_stuff/issue.rb', line 777 def reopen_by_reply!(comment:, user: nil) inactive_label = PlanMyStuff.configuration.user_inactive_label PlanMyStuff::Label.remove!(issue: self, labels: [inactive_label]) if labels.include?(inactive_label) self.class.update!( number: number, repo: repo, state: :open, metadata: { closed_by_inactivity: false }, ) reload PlanMyStuff::Notifications.instrument( 'issue.reopened_by_reply', self, user: user, comment: comment, ) self end |
#repo=(value) ⇒ Object
464 465 466 |
# File 'lib/plan_my_stuff/issue.rb', line 464 def repo=(value) super(value.present? ? PlanMyStuff::Repo.resolve!(value) : nil) end |
#request_approvals!(user_ids:, user: nil) ⇒ Array<PlanMyStuff::Approval>
Adds approvers to this issue’s required-approvals list. Idempotent: users already present are no-ops. Only support users may call this.
Fires plan_my_stuff.issue.approval_requested when any user is newly added. Also fires plan_my_stuff.issue.approvals_invalidated (+trigger: :approver_added+) when the new approvers flip the issue out of a fully-approved state.
565 566 567 568 569 570 571 572 573 574 575 576 577 578 |
# File 'lib/plan_my_stuff/issue.rb', line 565 def request_approvals!(user_ids:, user: nil) guard_support!(user) ids = Array.wrap(user_ids).map(&:to_i) just_added, was_fully_approved = modify_approvals! do |current| existing_ids = current.map(&:user_id) new_ids = ids - existing_ids added = new_ids.map { |id| PlanMyStuff::Approval.new(user_id: id, status: 'pending') } [current + added, added] end finish_request_approvals(just_added, user: user, was_fully_approved: was_fully_approved) just_added end |
#revoke_approval!(user:, target_user_id: nil) ⇒ PlanMyStuff::Approval
Flips an approved or rejected record back to pending. Approvers may revoke their own response; support users may revoke any approver’s response by passing target_user_id:. Non-support callers passing a target_user_id: that is not their own raise AuthorizationError.
Emits the granular event keyed off the source state: plan_my_stuff.issue.approval_revoked from approved, or plan_my_stuff.issue.rejection_revoked from rejected. When revoking an approval drops the issue out of fully_approved?, also fires plan_my_stuff.issue.approvals_invalidated (+trigger: :revoked+). Revoking a rejection cannot change fully_approved? (the issue was already gated), so no aggregate event fires.
683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 |
# File 'lib/plan_my_stuff/issue.rb', line 683 def revoke_approval!(user:, target_user_id: nil) actor_id = resolve_actor_id!(user) caller_is_support = PlanMyStuff::UserResolver.support?(PlanMyStuff::UserResolver.resolve(user)) target_id = target_user_id&.to_i || actor_id if !caller_is_support && target_id != actor_id raise(PlanMyStuff::AuthorizationError, "Only support users may revoke another user's response") end revoked_from = nil just_revoked, was_fully_approved = modify_approvals! do |current| approval = current.find { |a| a.user_id == target_id } raise(PlanMyStuff::ValidationError, "User #{target_id} is not in the approvers list") if approval.nil? if approval.pending? raise(PlanMyStuff::ValidationError, "User #{target_id} has not responded — nothing to revoke") end revoked_from = approval.status approval.status = 'pending' approval.approved_at = nil approval.rejected_at = nil [current, approval] end event = (revoked_from == 'approved') ? :approval_revoked : :rejection_revoked finish_state_change( event, just_revoked, user: user, was_fully_approved: was_fully_approved, trigger: (event == :approval_revoked) ? :revoked : nil, ) just_revoked 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.
845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 |
# File 'lib/plan_my_stuff/issue.rb', line 845 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 |
#set_parent!(target) ⇒ PlanMyStuff::Link
Makes target the parent of self. If self already has a parent, it is detached first. Returns a Link describing the new :parent relationship.
1067 1068 1069 1070 1071 1072 1073 1074 1075 |
# File 'lib/plan_my_stuff/issue.rb', line 1067 def set_parent!(target) parent.presence&.remove_sub_issue!(self) target_issue = resolve_target_issue(target, type: :parent) target_issue.add_sub_issue!(self) invalidate_links_cache! build_link!(target_issue, type: :parent) end |
#state ⇒ String?
Returns issue state (“open” or “closed”).
21 |
# File 'lib/plan_my_stuff/issue.rb', line 21 attribute :state, :string |
#sub_tickets ⇒ Array<PlanMyStuff::Issue>
Lazy-memoized sub-issues via GitHub’s native sub-issues API.
1035 1036 1037 |
# File 'lib/plan_my_stuff/issue.rb', line 1035 def sub_tickets links_cache[:sub_tickets] ||= fetch_sub_tickets end |
#title ⇒ String?
Returns issue title.
19 |
# File 'lib/plan_my_stuff/issue.rb', line 19 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).
876 877 878 879 |
# File 'lib/plan_my_stuff/issue.rb', line 876 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.
25 |
# File 'lib/plan_my_stuff/issue.rb', line 25 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.
483 484 485 486 487 488 489 490 491 |
# File 'lib/plan_my_stuff/issue.rb', line 483 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 |
#visible_to?(user) ⇒ Boolean
Delegates visibility check to metadata. Non-PMS issues are always visible.
956 957 958 959 960 961 962 |
# File 'lib/plan_my_stuff/issue.rb', line 956 def visible_to?(user) if pms_issue? .visible_to?(user) else PlanMyStuff::UserResolver.support?(PlanMyStuff::UserResolver.resolve(user)) end end |