plan_my_stuff 0.17.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -0
  3. data/CONFIGURATION.md +53 -1
  4. data/README.md +2 -30
  5. data/app/controllers/plan_my_stuff/comments_controller.rb +21 -10
  6. data/app/controllers/plan_my_stuff/issues/approvals_controller.rb +13 -4
  7. data/app/controllers/plan_my_stuff/issues/closures_controller.rb +12 -6
  8. data/app/controllers/plan_my_stuff/issues/links_controller.rb +14 -8
  9. data/app/controllers/plan_my_stuff/issues/takes_controller.rb +19 -17
  10. data/app/controllers/plan_my_stuff/issues/viewers_controller.rb +15 -9
  11. data/app/controllers/plan_my_stuff/issues/waitings_controller.rb +14 -8
  12. data/app/controllers/plan_my_stuff/issues_controller.rb +19 -5
  13. data/app/controllers/plan_my_stuff/labels_controller.rb +14 -8
  14. data/app/controllers/plan_my_stuff/project_items/assignments_controller.rb +6 -0
  15. data/app/controllers/plan_my_stuff/project_items/statuses_controller.rb +3 -0
  16. data/app/controllers/plan_my_stuff/project_items_controller.rb +11 -4
  17. data/app/controllers/plan_my_stuff/projects_controller.rb +14 -0
  18. data/app/controllers/plan_my_stuff/testing_project_items/results_controller.rb +3 -0
  19. data/app/controllers/plan_my_stuff/testing_project_items_controller.rb +5 -0
  20. data/app/controllers/plan_my_stuff/testing_projects_controller.rb +12 -0
  21. data/app/views/plan_my_stuff/issues/index.html.erb +1 -1
  22. data/app/views/plan_my_stuff/issues/partials/_approvals.html.erb +5 -5
  23. data/app/views/plan_my_stuff/issues/partials/_form.html.erb +1 -1
  24. data/app/views/plan_my_stuff/issues/partials/_links.html.erb +3 -3
  25. data/app/views/plan_my_stuff/issues/partials/_viewers.html.erb +2 -2
  26. data/app/views/plan_my_stuff/issues/show.html.erb +8 -8
  27. data/app/views/plan_my_stuff/projects/show.html.erb +3 -1
  28. data/app/views/plan_my_stuff/testing_projects/partials/_item.html.erb +1 -1
  29. data/lib/generators/plan_my_stuff/install/templates/initializer.rb +6 -0
  30. data/lib/plan_my_stuff/configuration.rb +61 -5
  31. data/lib/plan_my_stuff/issue.rb +91 -15
  32. data/lib/plan_my_stuff/repo.rb +28 -0
  33. data/lib/plan_my_stuff/version.rb +1 -1
  34. metadata +2 -2
@@ -7,40 +7,46 @@ module PlanMyStuff
7
7
  labels = parse_labels(params[:label_name])
8
8
  if labels.blank?
9
9
  flash[:error] = 'Label name is required.'
10
- redirect_to(plan_my_stuff.issue_path(params[:issue_id], repo: params[:repo]))
10
+ redirect_to(plan_my_stuff.issue_path(params[:issue_id]))
11
11
  return
12
12
  end
13
13
 
14
- issue = PlanMyStuff::Issue.find(params[:issue_id].to_i, repo: params[:repo])
14
+ issue = PlanMyStuff::Issue.find(params[:issue_id])
15
15
 
16
16
  missing = labels.reject { |label| PlanMyStuff::Label.exists?(repo: issue.repo, name: label) }
17
17
  if missing.any?
18
18
  flash[:error] = "Label#{'s' if missing.size > 1} not found in repo: #{missing.join(', ')}"
19
- redirect_to(plan_my_stuff.issue_path(issue.number, repo: issue.repo.full_name))
19
+ redirect_to(plan_my_stuff.issue_path(issue))
20
20
  return
21
21
  end
22
22
 
23
23
  PlanMyStuff::Label.add!(issue: issue, labels: labels)
24
24
 
25
+ yield(issue) if block_given?
26
+ return if performed?
27
+
25
28
  flash[:success] = 'Label was successfully added.'
26
- redirect_to(plan_my_stuff.issue_path(issue.number, repo: issue.repo.full_name))
29
+ redirect_to(plan_my_stuff.issue_path(issue))
27
30
  rescue PlanMyStuff::Error, Octokit::Error => e
28
31
  pms_handle_rescue(e)
29
32
  flash[:error] = e.message
30
- redirect_to(plan_my_stuff.issue_path(params[:issue_id], repo: params[:repo]))
33
+ redirect_to(plan_my_stuff.issue_path(params[:issue_id]))
31
34
  end
32
35
 
33
36
  # DELETE /issues/:issue_id/labels/:id
34
37
  def destroy
35
- issue = PlanMyStuff::Issue.find(params[:issue_id].to_i, repo: params[:repo])
38
+ issue = PlanMyStuff::Issue.find(params[:issue_id])
36
39
  PlanMyStuff::Label.remove!(issue: issue, labels: [params[:id]])
37
40
 
41
+ yield(issue) if block_given?
42
+ return if performed?
43
+
38
44
  flash[:success] = 'Label was successfully removed.'
39
- redirect_to(plan_my_stuff.issue_path(issue.number, repo: issue.repo.full_name))
45
+ redirect_to(plan_my_stuff.issue_path(issue))
40
46
  rescue PlanMyStuff::Error, Octokit::Error => e
41
47
  pms_handle_rescue(e)
42
48
  flash[:error] = e.message
43
- redirect_to(plan_my_stuff.issue_path(params[:issue_id], repo: params[:repo]))
49
+ redirect_to(plan_my_stuff.issue_path(params[:issue_id]))
44
50
  end
45
51
  end
46
52
  end
@@ -16,6 +16,9 @@ module PlanMyStuff
16
16
 
17
17
  item.assign!(assignees)
18
18
 
19
+ yield(item) if block_given?
20
+ return if performed?
21
+
19
22
  flash[:success] =
20
23
  if assignees.present?
21
24
  "Item assigned to #{assignees.join(', ')}."
@@ -43,6 +46,9 @@ module PlanMyStuff
43
46
 
44
47
  item.assign!(remaining)
45
48
 
49
+ yield(item) if block_given?
50
+ return if performed?
51
+
46
52
  flash[:success] = "#{params[:username]} unassigned."
47
53
  redirect_to(plan_my_stuff.project_path(params[:project_id]))
48
54
  rescue ArgumentError, PlanMyStuff::Error => e
@@ -14,6 +14,9 @@ module PlanMyStuff
14
14
 
15
15
  item.move_to!(params[:status])
16
16
 
17
+ yield(item) if block_given?
18
+ return if performed?
19
+
17
20
  flash[:success] = "Item moved to #{params[:status]}."
18
21
  redirect_to(plan_my_stuff.project_path(params[:project_id]))
19
22
  rescue ArgumentError, PlanMyStuff::Error => e
@@ -7,19 +7,23 @@ module PlanMyStuff
7
7
  project_number = params[:project_id].to_i
8
8
 
9
9
  if params[:draft] == '1'
10
- PlanMyStuff::ProjectItem.create!(
10
+ item = PlanMyStuff::ProjectItem.create!(
11
11
  params[:title],
12
12
  draft: true,
13
13
  body: params[:body],
14
14
  project_number: project_number,
15
15
  )
16
- flash[:success] = 'Draft item created.'
16
+ flash_message = 'Draft item created.'
17
17
  else
18
18
  issue = PlanMyStuff::Issue.find(params[:issue_number].to_i)
19
- PlanMyStuff::ProjectItem.create!(issue, project_number: project_number)
20
- flash[:success] = "Issue ##{issue.number} added to project."
19
+ item = PlanMyStuff::ProjectItem.create!(issue, project_number: project_number)
20
+ flash_message = "Issue ##{issue.number} added to project."
21
21
  end
22
22
 
23
+ yield(item) if block_given?
24
+ return if performed?
25
+
26
+ flash[:success] = flash_message
23
27
  redirect_to(plan_my_stuff.project_path(project_number))
24
28
  rescue ArgumentError, PlanMyStuff::Error, Octokit::Error => e
25
29
  pms_handle_rescue(e)
@@ -38,6 +42,9 @@ module PlanMyStuff
38
42
 
39
43
  item.destroy!(user: pms_current_user)
40
44
 
45
+ yield(item) if block_given?
46
+ return if performed?
47
+
41
48
  flash[:success] = 'Item removed from project.'
42
49
  redirect_to(plan_my_stuff.project_path(project_number))
43
50
  rescue ArgumentError, PlanMyStuff::Error, Octokit::Error => e
@@ -12,11 +12,15 @@ module PlanMyStuff
12
12
  when 'regular' then all_projects.reject { |p| p.is_a?(PlanMyStuff::TestingProject) }
13
13
  else all_projects
14
14
  end
15
+
16
+ yield(@projects) if block_given?
15
17
  end
16
18
 
17
19
  # GET /projects/new
18
20
  def new
19
21
  @project = PlanMyStuff::Project.new
22
+
23
+ yield(@project) if block_given?
20
24
  end
21
25
 
22
26
  # POST /projects
@@ -28,6 +32,9 @@ module PlanMyStuff
28
32
  user: pms_current_user,
29
33
  )
30
34
 
35
+ yield(@project) if block_given?
36
+ return if performed?
37
+
31
38
  flash[:success] = 'Project was successfully created.'
32
39
  redirect_to(plan_my_stuff.project_path(@project.number))
33
40
  rescue PlanMyStuff::ValidationError => e
@@ -46,11 +53,15 @@ module PlanMyStuff
46
53
  @project = PlanMyStuff::Project.find(params[:id].to_i)
47
54
  @statuses = @project.statuses.pluck(:name)
48
55
  @items_by_status = @project.items.group_by(&:status)
56
+
57
+ yield(@project) if block_given?
49
58
  end
50
59
 
51
60
  # GET /projects/:id/edit
52
61
  def edit
53
62
  @project = PlanMyStuff::Project.find(params[:id].to_i)
63
+
64
+ yield(@project) if block_given?
54
65
  end
55
66
 
56
67
  # PATCH/PUT /projects/:id
@@ -63,6 +74,9 @@ module PlanMyStuff
63
74
  description: project_params[:description],
64
75
  )
65
76
 
77
+ yield(@project) if block_given?
78
+ return if performed?
79
+
66
80
  flash[:success] = 'Project was successfully updated.'
67
81
  redirect_to(plan_my_stuff.project_path(@project.number))
68
82
  rescue PlanMyStuff::StaleObjectError => e
@@ -30,6 +30,9 @@ module PlanMyStuff
30
30
  raise(ArgumentError, "Invalid result: #{params[:result].inspect}")
31
31
  end
32
32
 
33
+ yield(item) if block_given?
34
+ return if performed?
35
+
33
36
  redirect_to(plan_my_stuff.testing_project_path(project_number))
34
37
  rescue PlanMyStuff::ValidationError => e
35
38
  pms_handle_rescue(e)
@@ -5,6 +5,8 @@ module PlanMyStuff
5
5
  # GET /testing_projects/:testing_project_id/items/new
6
6
  def new
7
7
  @project = PlanMyStuff::TestingProject.find(params[:testing_project_id].to_i)
8
+
9
+ yield(@project) if block_given?
8
10
  rescue PlanMyStuff::Error, Octokit::Error => e
9
11
  pms_handle_rescue(e)
10
12
  flash[:error] = e.message
@@ -26,6 +28,9 @@ module PlanMyStuff
26
28
  item.update_due_date!(Date.parse(item_params[:due_date])) if item_params[:due_date].present?
27
29
  item.update_pass_mode!(item_params[:pass_mode]) if item_params[:pass_mode].present?
28
30
 
31
+ yield(item) if block_given?
32
+ return if performed?
33
+
29
34
  flash[:success] = 'Item added.'
30
35
  redirect_to(plan_my_stuff.testing_project_path(project_number))
31
36
  rescue ArgumentError, PlanMyStuff::Error, Octokit::Error => e
@@ -6,6 +6,8 @@ module PlanMyStuff
6
6
  def new
7
7
  @project = PlanMyStuff::TestingProject.new
8
8
  @project.metadata.subject_urls = [params[:subject_url]] if params[:subject_url].present?
9
+
10
+ yield(@project) if block_given?
9
11
  end
10
12
 
11
13
  # POST /testing_projects
@@ -19,6 +21,9 @@ module PlanMyStuff
19
21
  user: pms_current_user,
20
22
  )
21
23
 
24
+ yield(@project) if block_given?
25
+ return if performed?
26
+
22
27
  flash[:success] = 'Testing project was successfully created.'
23
28
  redirect_to(plan_my_stuff.testing_project_path(@project.number))
24
29
  rescue PlanMyStuff::ValidationError => e
@@ -39,11 +44,15 @@ module PlanMyStuff
39
44
  @project = PlanMyStuff::TestingProject.find(params[:id].to_i)
40
45
  @statuses = @project.statuses.pluck(:name)
41
46
  @items_by_status = @project.items.group_by(&:status)
47
+
48
+ yield(@project) if block_given?
42
49
  end
43
50
 
44
51
  # GET /testing_projects/:id/edit
45
52
  def edit
46
53
  @project = PlanMyStuff::TestingProject.find(params[:id].to_i)
54
+
55
+ yield(@project) if block_given?
47
56
  end
48
57
 
49
58
  # PATCH/PUT /testing_projects/:id
@@ -60,6 +69,9 @@ module PlanMyStuff
60
69
  },
61
70
  )
62
71
 
72
+ yield(@project) if block_given?
73
+ return if performed?
74
+
63
75
  flash[:success] = 'Testing project was successfully updated.'
64
76
  redirect_to(plan_my_stuff.testing_project_path(@project.number))
65
77
  rescue PlanMyStuff::StaleObjectError => e
@@ -14,7 +14,7 @@
14
14
  <% @issues.each do |issue| %>
15
15
  <tr>
16
16
  <td><%= issue.number %></td>
17
- <td><%= link_to(issue.title, plan_my_stuff.issue_path(issue.number, repo: issue.repo.full_name)) %></td>
17
+ <td><%= link_to(issue.title, plan_my_stuff.issue_path(issue)) %></td>
18
18
  <td><%= issue.state %></td>
19
19
  <td><%= issue.labels.join(', ') %></td>
20
20
  </tr>
@@ -44,7 +44,7 @@
44
44
  <%=
45
45
  button_to(
46
46
  'Approve',
47
- plan_my_stuff.issue_approval_path(issue.number, approval.user_id, repo: issue.repo.full_name),
47
+ plan_my_stuff.issue_approval_path(issue, approval.user_id),
48
48
  method: :patch,
49
49
  params: { approval: { status: 'approved' } },
50
50
  form: { style: 'display:inline' },
@@ -53,7 +53,7 @@
53
53
  <%=
54
54
  button_to(
55
55
  'Reject',
56
- plan_my_stuff.issue_approval_path(issue.number, approval.user_id, repo: issue.repo.full_name),
56
+ plan_my_stuff.issue_approval_path(issue, approval.user_id),
57
57
  method: :patch,
58
58
  params: { approval: { status: 'rejected' } },
59
59
  form: { style: 'display:inline' },
@@ -65,7 +65,7 @@
65
65
  <%=
66
66
  button_to(
67
67
  'Revoke',
68
- plan_my_stuff.issue_approval_path(issue.number, approval.user_id, repo: issue.repo.full_name),
68
+ plan_my_stuff.issue_approval_path(issue, approval.user_id),
69
69
  method: :patch,
70
70
  params: { approval: { status: 'pending' } },
71
71
  form: { style: 'display:inline' },
@@ -77,7 +77,7 @@
77
77
  <%=
78
78
  button_to(
79
79
  'Remove',
80
- plan_my_stuff.issue_approval_path(issue.number, approval.user_id, repo: issue.repo.full_name),
80
+ plan_my_stuff.issue_approval_path(issue, approval.user_id),
81
81
  method: :delete,
82
82
  form: { style: 'display:inline' },
83
83
  )
@@ -94,7 +94,7 @@
94
94
  <%=
95
95
  form_with(
96
96
  scope: :approval,
97
- url: plan_my_stuff.issue_approvals_path(issue.number, repo: issue.repo.full_name),
97
+ url: plan_my_stuff.issue_approvals_path(issue),
98
98
  method: :post,
99
99
  local: true,
100
100
  ) do |form|
@@ -3,7 +3,7 @@
3
3
  persisted = issue.persisted?
4
4
  url =
5
5
  if persisted
6
- plan_my_stuff.issue_path(issue.number, repo: issue.repo&.full_name)
6
+ plan_my_stuff.issue_path(issue)
7
7
  else
8
8
  plan_my_stuff.issues_path(repo: issue.repo&.full_name)
9
9
  end
@@ -68,7 +68,7 @@
68
68
  <%=
69
69
  link_to(
70
70
  "#{target.repo.full_name}##{target.number} - #{target.title}",
71
- plan_my_stuff.issue_path(target.number, repo: target.repo.full_name),
71
+ plan_my_stuff.issue_path(target),
72
72
  )
73
73
  %>
74
74
  <% if section[:removable] %>
@@ -78,7 +78,7 @@
78
78
  <%=
79
79
  button_to(
80
80
  'Remove',
81
- plan_my_stuff.issue_link_path(issue.number, link_id, repo: issue.repo.full_name),
81
+ plan_my_stuff.issue_link_path(issue, link_id),
82
82
  method: :delete,
83
83
  form: { style: 'display:inline' },
84
84
  )
@@ -95,7 +95,7 @@
95
95
  <%=
96
96
  form_with(
97
97
  scope: :link,
98
- url: plan_my_stuff.issue_links_path(issue.number, repo: issue.repo.full_name),
98
+ url: plan_my_stuff.issue_links_path(issue),
99
99
  method: :post,
100
100
  local: true,
101
101
  ) do |form|
@@ -10,7 +10,7 @@
10
10
  <%=
11
11
  button_to(
12
12
  'Remove',
13
- plan_my_stuff.issue_viewer_path(issue.number, viewer_id, repo: issue.repo.full_name),
13
+ plan_my_stuff.issue_viewer_path(issue, viewer_id),
14
14
  method: :delete
15
15
  )
16
16
  %>
@@ -21,7 +21,7 @@
21
21
  <p>No viewers added.</p>
22
22
  <% end %>
23
23
 
24
- <%= form_with(url: plan_my_stuff.issue_viewers_path(issue.number, repo: issue.repo.full_name), method: :post) do |form| %>
24
+ <%= form_with(url: plan_my_stuff.issue_viewers_path(issue), method: :post) do |form| %>
25
25
  <div>
26
26
  <%= form.label(:viewer_ids, 'Add viewer IDs (comma-separated)') %>
27
27
  <%= form.text_field(:viewer_ids) %>
@@ -1,27 +1,27 @@
1
1
  <h1><%= @issue.title %> <small>#<%= @issue.number %></small></h1>
2
2
 
3
3
  <p>
4
- <%= link_to('Edit', plan_my_stuff.edit_issue_path(@issue.number, repo: @issue.repo.full_name)) %>
4
+ <%= link_to('Edit', plan_my_stuff.edit_issue_path(@issue)) %>
5
5
  <% if @support_user && @issue.html_url.present? %>
6
6
  <%= link_to('View on GitHub', @issue.html_url, target: '_blank', rel: 'noopener') %>
7
7
  <% end %>
8
8
  <% if @issue.state == 'open' %>
9
- <%= button_to('Close Issue', plan_my_stuff.issue_closure_path(@issue.number, repo: @issue.repo.full_name), method: :post) %>
9
+ <%= button_to('Close Issue', plan_my_stuff.issue_closure_path(@issue), method: :post) %>
10
10
  <% else %>
11
- <%= button_to('Reopen Issue', plan_my_stuff.issue_closure_path(@issue.number, repo: @issue.repo.full_name), method: :delete) %>
11
+ <%= button_to('Reopen Issue', plan_my_stuff.issue_closure_path(@issue), method: :delete) %>
12
12
  <% end %>
13
13
  <% if @support_user && @issue.state == 'open' %>
14
14
  <% if @issue.metadata.waiting_on_user_at.present? %>
15
- <%= button_to('Mark replied', plan_my_stuff.issue_waiting_path(@issue.number, repo: @issue.repo.full_name), method: :delete) %>
15
+ <%= button_to('Mark replied', plan_my_stuff.issue_waiting_path(@issue), method: :delete) %>
16
16
  <% else %>
17
- <%= button_to('Mark waiting', plan_my_stuff.issue_waiting_path(@issue.number, repo: @issue.repo.full_name), method: :post) %>
17
+ <%= button_to('Mark waiting', plan_my_stuff.issue_waiting_path(@issue), method: :post) %>
18
18
  <% end %>
19
19
  <% end %>
20
20
  <% if @support_user && @pipeline_enabled && @pipeline_item.nil? && @issue.assignees.blank? %>
21
- <%= button_to('Take', plan_my_stuff.issue_take_path(@issue.number, repo: @issue.repo.full_name), method: :post) %>
21
+ <%= button_to('Take', plan_my_stuff.issue_take_path(@issue), method: :post) %>
22
22
  <% end %>
23
23
  <% if @support_user && @pipeline_enabled && @current_user_login.present? && @issue.assignees.include?(@current_user_login) %>
24
- <%= button_to('Unassign', plan_my_stuff.issue_take_path(@issue.number, repo: @issue.repo.full_name), method: :delete) %>
24
+ <%= button_to('Unassign', plan_my_stuff.issue_take_path(@issue), method: :delete) %>
25
25
  <% end %>
26
26
  <% if @support_user %>
27
27
  <%= link_to('Start Testing Project', plan_my_stuff.new_testing_project_path(subject_url: @issue.html_url)) %>
@@ -76,7 +76,7 @@
76
76
  <%= PlanMyStuff::Markdown.render(comment.body || '').html_safe %>
77
77
  <% if comment.pms_comment? && (@support_user || comment.metadata.created_by == @current_user_id) %>
78
78
  <p>
79
- <%= link_to('Edit', plan_my_stuff.edit_issue_comment_path(@issue.number, comment.id, repo: @issue.repo.full_name)) %>
79
+ <%= link_to('Edit', plan_my_stuff.edit_issue_comment_path(@issue, comment.id)) %>
80
80
  </p>
81
81
  <% end %>
82
82
  <% if @support_user && comment.html_url.present? %>
@@ -24,7 +24,9 @@
24
24
  <% items.each do |item| %>
25
25
  <div style="border: 1px solid black; margin: 1em">
26
26
  <% unless item.draft? %>
27
- <strong><%= link_to(item.title, plan_my_stuff.issue_path(item.number, repo: item.repo.full_name)) %></strong>
27
+ <strong>
28
+ <%= link_to(item.title, plan_my_stuff.issue_path(item.issue.to_param)) %>
29
+ </strong>
28
30
  <% else %>
29
31
  <strong><%= item.title %></strong>
30
32
  <% end %>
@@ -3,7 +3,7 @@
3
3
  <% if item.draft? %>
4
4
  <strong><%= item.title %></strong>
5
5
  <% else %>
6
- <strong><%= link_to(item.title, plan_my_stuff.issue_path(item.number, repo: item.repo.full_name)) %></strong>
6
+ <strong><%= link_to(item.title, plan_my_stuff.issue_path(item.issue.to_param)) %></strong>
7
7
  <small>#<%= item.number %></small>
8
8
  <% end %>
9
9
 
@@ -24,6 +24,12 @@ PlanMyStuff.configure do |config|
24
24
  # config.repos[:underwriter] = 'YourOrganization/Underwriter'
25
25
  # config.default_repo = :element
26
26
 
27
+ # Human-readable prefix used as the `Issue#to_param` so URLs read
28
+ # `/issues/Element-1234` instead of `/issues/1234`. Missing keys fall back
29
+ # to `key.to_s.titleize`; only repos whose key doesn't titleize cleanly
30
+ # need an entry.
31
+ # config.repo_nicknames = { safety: 'Compliance' }
32
+
27
33
  # --------------------------------------------------------------------------
28
34
  # Projects
29
35
  # --------------------------------------------------------------------------
@@ -348,6 +348,14 @@ module PlanMyStuff
348
348
  #
349
349
  attr_accessor :repos
350
350
 
351
+ # Human-readable repo names used as the +to_param+ prefix on +PlanMyStuff::Issue+ instances. Symbol-keyed
352
+ # against +repos+ -- missing keys fall back to +key.to_s.titleize+, so only entries that diverge from a simple
353
+ # +titleize+ of the key (e.g. +:safety+ -> +"Compliance"+) need to be listed.
354
+ #
355
+ # @return [Hash{Symbol => String}]
356
+ #
357
+ attr_accessor :repo_nicknames
358
+
351
359
  # Bare repo name (under +config.organization+) that stores uploaded attachment binaries. Defaults to
352
360
  # +'pms-attachments'+. The repo must exist; the uploader does not create it. Attachments commit onto
353
361
  # +config.main_branch+ and live under +<repo_key_or_name>/issue-<number>/<uuid>.<ext>+.
@@ -368,6 +376,7 @@ module PlanMyStuff
368
376
  # @return [Configuration]
369
377
  def initialize
370
378
  @repos = {}
379
+ @repo_nicknames = {}
371
380
  @attachment_repo = 'pms-attachments'
372
381
  @user_class = 'User'
373
382
  @display_name_method = :to_s
@@ -435,12 +444,14 @@ module PlanMyStuff
435
444
  missing << 'access_token' if access_token.nil? || access_token.to_s.strip.empty?
436
445
  missing << 'organization' if organization.nil? || organization.to_s.strip.empty?
437
446
 
438
- return if missing.empty?
447
+ if missing.present?
448
+ raise(
449
+ PlanMyStuff::ConfigurationError,
450
+ "Missing required PlanMyStuff configuration: #{missing.join(', ')}",
451
+ )
452
+ end
439
453
 
440
- raise(
441
- PlanMyStuff::ConfigurationError,
442
- "Missing required PlanMyStuff configuration: #{missing.join(', ')}",
443
- )
454
+ validate_repo_nicknames!
444
455
  end
445
456
 
446
457
  # Returns the merged custom fields schema for the given context. Context-specific fields deep-merge on top of
@@ -477,6 +488,51 @@ module PlanMyStuff
477
488
  path = controllers[key] || DEFAULT_CONTROLLERS.fetch(key)
478
489
  path.start_with?('/') ? path : "/#{path}"
479
490
  end
491
+
492
+ # Human-readable nickname for a repo key, used as the +to_param+ prefix on +PlanMyStuff::Issue+ instances. Falls
493
+ # back to +key.to_s.titleize+ when no explicit entry exists in +repo_nicknames+.
494
+ #
495
+ # @param key [Symbol, String]
496
+ #
497
+ # @return [String]
498
+ #
499
+ def repo_nickname_for(key)
500
+ repo_nicknames[key&.to_sym] || key.to_s.titleize
501
+ end
502
+
503
+ private
504
+
505
+ # Resolved nicknames feed directly into +Issue#to_param+ and route +:id+ tokens, so collisions or chars outside
506
+ # +[A-Za-z0-9_]+ (notably +-+, the nickname/number separator) break URL round-tripping.
507
+ #
508
+ # @raise [ConfigurationError] when any resolved nickname collides with another or contains non-token chars
509
+ #
510
+ # @return [void]
511
+ #
512
+ def validate_repo_nicknames!
513
+ resolved = repos.keys.index_with { |key| repo_nickname_for(key).to_s }
514
+
515
+ invalid = resolved.reject { |_key, nickname| nickname.match?(/\A[A-Za-z0-9_]+\z/) }
516
+ if invalid.present?
517
+ pairs = invalid.map { |key, nickname| "#{key.inspect} => #{nickname.inspect}" }
518
+ raise(
519
+ PlanMyStuff::ConfigurationError,
520
+ "Invalid repo nickname(s) (must match /\\A[A-Za-z0-9_]+\\z/): #{pairs.join(', ')}",
521
+ )
522
+ end
523
+
524
+ dupes = resolved.group_by { |_key, nickname| nickname }.select { |_n, entries| entries.size > 1 }
525
+ return if dupes.blank?
526
+
527
+ details = dupes.map do |nickname, entries|
528
+ keys = entries.map { |entry| entry.first.inspect }.join(', ')
529
+ "#{nickname.inspect} (#{keys})"
530
+ end
531
+ raise(
532
+ PlanMyStuff::ConfigurationError,
533
+ "Duplicate repo nickname(s): #{details.join('; ')}",
534
+ )
535
+ end
480
536
  end
481
537
 
482
538
  class ConfigurationError < StandardError