tenseleyflow/shithub / 810896d

Browse files

Gate issue and PR controls by policy

Authored by espadonne
SHA
810896d6852e77c122fd8d124477b4742b835b56
Parents
b9c83c2
Tree
731fcd4

9 changed files

StatusFile+-
M docs/internal/issues.md 9 0
M docs/internal/pr-review.md 6 0
M internal/web/handlers/repo/issues.go 28 12
M internal/web/handlers/repo/labels_milestones.go 18 10
M internal/web/handlers/repo/pulls.go 12 0
M internal/web/templates/repo/issue_view.html 20 5
M internal/web/templates/repo/labels.html 2 2
M internal/web/templates/repo/milestones.html 2 2
M internal/web/templates/repo/pull_view.html 9 5
docs/internal/issues.mdmodified
@@ -90,6 +90,15 @@ Issue creation and commenting follow GitHub's public-participation
90
 model: any logged-in user may open or comment on issues in a public
90
 model: any logged-in user may open or comment on issues in a public
91
 repo, while private repos require `read` access.
91
 repo, while private repos require `read` access.
92
 
92
 
93
+The issue page is capability-driven:
94
+
95
+- The comment box appears only when `policy.Can(issue:comment)` allows
96
+  and the issue is not locked, unless the viewer is triage+.
97
+- Close/reopen appears for triage+ users and for the issue author.
98
+- Labels, assignees, milestones, and lock controls appear only for the
99
+  matching triage-level policy action. Public participants should not
100
+  see forms that only lead to 403s.
101
+
93
 ## Cross-reference indexing
102
 ## Cross-reference indexing
94
 
103
 
95
 `internal/issues/references.go::insertReferencesFromBody` parses
104
 `internal/issues/references.go::insertReferencesFromBody` parses
docs/internal/pr-review.mdmodified
@@ -85,6 +85,12 @@ without starting a review" path) is `pending=false` from the start,
85
 The settings/branches handler now also accepts
85
 The settings/branches handler now also accepts
86
 `required_review_count` and `dismiss_stale_reviews_on_push`.
86
 `required_review_count` and `dismiss_stale_reviews_on_push`.
87
 
87
 
88
+The PR template hides review-request, review-submit, inline-comment,
89
+and thread-resolve controls unless `policy.Can(pull:review)` allows
90
+the viewer. Merge and close controls are similarly driven by
91
+`pull:merge` and `pull:close` decisions. Public viewers who can read a
92
+PR should not see forms that only lead to 403s.
93
+
88
 ## Required-review gate
94
 ## Required-review gate
89
 
95
 
90
 `internal/pulls/review/required.go::Evaluate` is the authoritative
96
 `internal/pulls/review/required.go::Evaluate` is the authoritative
internal/web/handlers/repo/issues.gomodified
@@ -288,21 +288,37 @@ func (h *Handlers) issueView(w http.ResponseWriter, r *http.Request) {
288
 			authorName = u.Username
288
 			authorName = u.Username
289
 		}
289
 		}
290
 	}
290
 	}
291
+	viewer := middleware.CurrentUserFromContext(r.Context())
292
+	actor := policy.UserActor(viewer.ID, viewer.Username, viewer.IsSuspended, false)
293
+	pdeps := policy.Deps{Pool: h.d.Pool}
294
+	repoRef := policy.NewRepoRefFromRepo(row)
295
+	stateRef := repoRef
296
+	if issue.AuthorUserID.Valid {
297
+		stateRef.AuthorUserID = issue.AuthorUserID.Int64
298
+	}
299
+	canCommentAction := policy.Can(r.Context(), pdeps, actor, policy.ActionIssueComment, repoRef).Allow
300
+	canCommentThroughLock := policy.HasRoleAtLeast(r.Context(), pdeps, actor, repoRef, policy.RoleTriage)
291
 
301
 
292
 	w.Header().Set("Content-Type", "text/html; charset=utf-8")
302
 	w.Header().Set("Content-Type", "text/html; charset=utf-8")
293
 	_ = h.d.Render.RenderPage(w, r, "repo/issue_view", map[string]any{
303
 	_ = h.d.Render.RenderPage(w, r, "repo/issue_view", map[string]any{
294
-		"Title":      issue.Title + " · " + row.Name,
304
+		"Title":                 issue.Title + " · " + row.Name,
295
-		"Owner":      owner.Username,
305
+		"Owner":                 owner.Username,
296
-		"Repo":       row,
306
+		"Repo":                  row,
297
-		"Issue":      issue,
307
+		"Issue":                 issue,
298
-		"AuthorName": authorName,
308
+		"AuthorName":            authorName,
299
-		"Comments":   cs,
309
+		"Comments":              cs,
300
-		"Events":     events,
310
+		"Events":                events,
301
-		"Labels":     labels,
311
+		"Labels":                labels,
302
-		"Assignees":  assignees,
312
+		"Assignees":             assignees,
303
-		"AllLabels":  allLabels,
313
+		"AllLabels":             allLabels,
304
-		"Milestones": milestones,
314
+		"Milestones":            milestones,
305
-		"CSRFToken":  middleware.CSRFTokenForRequest(r),
315
+		"CanComment":            canCommentAction && (!issue.Locked || canCommentThroughLock),
316
+		"CanSetIssueState":      policy.Can(r.Context(), pdeps, actor, policy.ActionIssueClose, stateRef).Allow,
317
+		"CanEditIssueLabels":    policy.Can(r.Context(), pdeps, actor, policy.ActionIssueLabel, repoRef).Allow,
318
+		"CanEditIssueAssignees": policy.Can(r.Context(), pdeps, actor, policy.ActionIssueAssign, repoRef).Allow,
319
+		"CanEditIssueMilestone": policy.Can(r.Context(), pdeps, actor, policy.ActionIssueLabel, repoRef).Allow,
320
+		"CanLockIssue":          policy.Can(r.Context(), pdeps, actor, policy.ActionIssueClose, repoRef).Allow,
321
+		"CSRFToken":             middleware.CSRFTokenForRequest(r),
306
 	})
322
 	})
307
 }
323
 }
308
 
324
 
internal/web/handlers/repo/labels_milestones.gomodified
@@ -24,13 +24,17 @@ func (h *Handlers) labelsList(w http.ResponseWriter, r *http.Request) {
24
 		return
24
 		return
25
 	}
25
 	}
26
 	labels, _ := h.iq.ListLabels(r.Context(), h.d.Pool, row.ID)
26
 	labels, _ := h.iq.ListLabels(r.Context(), h.d.Pool, row.ID)
27
+	viewer := middleware.CurrentUserFromContext(r.Context())
28
+	actor := policy.UserActor(viewer.ID, viewer.Username, viewer.IsSuspended, false)
29
+	canManage := policy.Can(r.Context(), policy.Deps{Pool: h.d.Pool}, actor, policy.ActionIssueLabel, policy.NewRepoRefFromRepo(row)).Allow
27
 	w.Header().Set("Content-Type", "text/html; charset=utf-8")
30
 	w.Header().Set("Content-Type", "text/html; charset=utf-8")
28
 	_ = h.d.Render.RenderPage(w, r, "repo/labels", map[string]any{
31
 	_ = h.d.Render.RenderPage(w, r, "repo/labels", map[string]any{
29
-		"Title":     "Labels · " + row.Name,
32
+		"Title":          "Labels · " + row.Name,
30
-		"Owner":     owner.Username,
33
+		"Owner":          owner.Username,
31
-		"Repo":      row,
34
+		"Repo":           row,
32
-		"Labels":    labels,
35
+		"Labels":         labels,
33
-		"CSRFToken": middleware.CSRFTokenForRequest(r),
36
+		"CanManageIssue": canManage,
37
+		"CSRFToken":      middleware.CSRFTokenForRequest(r),
34
 	})
38
 	})
35
 }
39
 }
36
 
40
 
@@ -119,13 +123,17 @@ func (h *Handlers) milestonesList(w http.ResponseWriter, r *http.Request) {
119
 		return
123
 		return
120
 	}
124
 	}
121
 	ms, _ := h.iq.ListMilestones(r.Context(), h.d.Pool, row.ID)
125
 	ms, _ := h.iq.ListMilestones(r.Context(), h.d.Pool, row.ID)
126
+	viewer := middleware.CurrentUserFromContext(r.Context())
127
+	actor := policy.UserActor(viewer.ID, viewer.Username, viewer.IsSuspended, false)
128
+	canManage := policy.Can(r.Context(), policy.Deps{Pool: h.d.Pool}, actor, policy.ActionIssueLabel, policy.NewRepoRefFromRepo(row)).Allow
122
 	w.Header().Set("Content-Type", "text/html; charset=utf-8")
129
 	w.Header().Set("Content-Type", "text/html; charset=utf-8")
123
 	_ = h.d.Render.RenderPage(w, r, "repo/milestones", map[string]any{
130
 	_ = h.d.Render.RenderPage(w, r, "repo/milestones", map[string]any{
124
-		"Title":      "Milestones · " + row.Name,
131
+		"Title":          "Milestones · " + row.Name,
125
-		"Owner":      owner.Username,
132
+		"Owner":          owner.Username,
126
-		"Repo":       row,
133
+		"Repo":           row,
127
-		"Milestones": ms,
134
+		"Milestones":     ms,
128
-		"CSRFToken":  middleware.CSRFTokenForRequest(r),
135
+		"CanManageIssue": canManage,
136
+		"CSRFToken":      middleware.CSRFTokenForRequest(r),
129
 	})
137
 	})
130
 }
138
 }
131
 
139
 
internal/web/handlers/repo/pulls.gomodified
@@ -281,6 +281,18 @@ func (h *Handlers) renderPullPage(w http.ResponseWriter, r *http.Request, tab st
281
 		"Tab":        tab,
281
 		"Tab":        tab,
282
 		"CSRFToken":  middleware.CSRFTokenForRequest(r),
282
 		"CSRFToken":  middleware.CSRFTokenForRequest(r),
283
 	}
283
 	}
284
+	viewer := middleware.CurrentUserFromContext(r.Context())
285
+	actor := policy.UserActor(viewer.ID, viewer.Username, viewer.IsSuspended, false)
286
+	pdeps := policy.Deps{Pool: h.d.Pool}
287
+	repoRef := policy.NewRepoRefFromRepo(row)
288
+	stateRef := repoRef
289
+	if pr.IAuthorUserID.Valid {
290
+		stateRef.AuthorUserID = pr.IAuthorUserID.Int64
291
+	}
292
+	data["CanReviewPull"] = policy.Can(r.Context(), pdeps, actor, policy.ActionPullReview, repoRef).Allow
293
+	data["CanMergePull"] = policy.Can(r.Context(), pdeps, actor, policy.ActionPullMerge, repoRef).Allow
294
+	data["CanSetPullState"] = policy.Can(r.Context(), pdeps, actor, policy.ActionPullClose, stateRef).Allow
295
+	data["CanReadyPull"] = policy.Can(r.Context(), pdeps, actor, policy.ActionPullCreate, repoRef).Allow
284
 	for k, v := range extras {
296
 	for k, v := range extras {
285
 		data[k] = v
297
 		data[k] = v
286
 	}
298
 	}
internal/web/templates/repo/issue_view.htmlmodified
@@ -49,7 +49,7 @@
49
       </div>
49
       </div>
50
       {{ end }}
50
       {{ end }}
51
 
51
 
52
-      {{ if .Viewer.ID }}
52
+      {{ if .CanComment }}
53
       <form method="post" action="/{{ .Owner }}/{{ .Repo.Name }}/issues/{{ .Issue.Number }}/comments" class="shithub-comment-form">
53
       <form method="post" action="/{{ .Owner }}/{{ .Repo.Name }}/issues/{{ .Issue.Number }}/comments" class="shithub-comment-form">
54
         <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
54
         <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
55
         <label>
55
         <label>
@@ -57,14 +57,29 @@
57
           <textarea name="body" rows="6" maxlength="65535" required></textarea>
57
           <textarea name="body" rows="6" maxlength="65535" required></textarea>
58
         </label>
58
         </label>
59
         <div class="shithub-form-actions">
59
         <div class="shithub-form-actions">
60
+          {{ if .CanSetIssueState }}
60
           {{ if eq (printf "%s" .Issue.State) "open" }}
61
           {{ if eq (printf "%s" .Issue.State) "open" }}
61
           <button type="submit" formaction="/{{ .Owner }}/{{ .Repo.Name }}/issues/{{ .Issue.Number }}/state" name="state" value="closed" class="shithub-button">Close issue</button>
62
           <button type="submit" formaction="/{{ .Owner }}/{{ .Repo.Name }}/issues/{{ .Issue.Number }}/state" name="state" value="closed" class="shithub-button">Close issue</button>
62
           {{ else }}
63
           {{ else }}
63
           <button type="submit" formaction="/{{ .Owner }}/{{ .Repo.Name }}/issues/{{ .Issue.Number }}/state" name="state" value="open" class="shithub-button">Reopen issue</button>
64
           <button type="submit" formaction="/{{ .Owner }}/{{ .Repo.Name }}/issues/{{ .Issue.Number }}/state" name="state" value="open" class="shithub-button">Reopen issue</button>
64
           {{ end }}
65
           {{ end }}
66
+          {{ end }}
65
           <button type="submit" class="shithub-button shithub-button-primary">Comment</button>
67
           <button type="submit" class="shithub-button shithub-button-primary">Comment</button>
66
         </div>
68
         </div>
67
       </form>
69
       </form>
70
+      {{ else if .CanSetIssueState }}
71
+      <form method="post" action="/{{ .Owner }}/{{ .Repo.Name }}/issues/{{ .Issue.Number }}/state" class="shithub-comment-form">
72
+        <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
73
+        <div class="shithub-form-actions">
74
+          {{ if eq (printf "%s" .Issue.State) "open" }}
75
+          <button type="submit" name="state" value="closed" class="shithub-button">Close issue</button>
76
+          {{ else }}
77
+          <button type="submit" name="state" value="open" class="shithub-button">Reopen issue</button>
78
+          {{ end }}
79
+        </div>
80
+      </form>
81
+      {{ else if .Viewer.ID }}
82
+        {{ if .Issue.Locked }}<p class="shithub-issue-signedout">This conversation is locked.</p>{{ end }}
68
       {{ else }}
83
       {{ else }}
69
       <p class="shithub-issue-signedout"><a href="/login">Sign in</a> to comment.</p>
84
       <p class="shithub-issue-signedout"><a href="/login">Sign in</a> to comment.</p>
70
       {{ end }}
85
       {{ end }}
@@ -76,7 +91,7 @@
76
         {{ if .Labels }}
91
         {{ if .Labels }}
77
           {{ range .Labels }}<span class="shithub-label" style="background-color: #{{ .Color }}">{{ .Name }}</span>{{ end }}
92
           {{ range .Labels }}<span class="shithub-label" style="background-color: #{{ .Color }}">{{ .Name }}</span>{{ end }}
78
         {{ else }}<p class="shithub-muted">None yet</p>{{ end }}
93
         {{ else }}<p class="shithub-muted">None yet</p>{{ end }}
79
-        {{ if .Viewer.ID }}
94
+        {{ if .CanEditIssueLabels }}
80
         <details>
95
         <details>
81
           <summary>Edit labels</summary>
96
           <summary>Edit labels</summary>
82
           <form method="post" action="/{{ .Owner }}/{{ .Repo.Name }}/issues/{{ .Issue.Number }}/labels">
97
           <form method="post" action="/{{ .Owner }}/{{ .Repo.Name }}/issues/{{ .Issue.Number }}/labels">
@@ -101,7 +116,7 @@
101
         {{ if .Assignees }}
116
         {{ if .Assignees }}
102
           {{ range .Assignees }}<a href="/{{ .Username }}">@{{ .Username }}</a>{{ end }}
117
           {{ range .Assignees }}<a href="/{{ .Username }}">@{{ .Username }}</a>{{ end }}
103
         {{ else }}<p class="shithub-muted">No one assigned</p>{{ end }}
118
         {{ else }}<p class="shithub-muted">No one assigned</p>{{ end }}
104
-        {{ if .Viewer.ID }}
119
+        {{ if .CanEditIssueAssignees }}
105
         <form method="post" action="/{{ .Owner }}/{{ .Repo.Name }}/issues/{{ .Issue.Number }}/assignees" class="shithub-assignee-form">
120
         <form method="post" action="/{{ .Owner }}/{{ .Repo.Name }}/issues/{{ .Issue.Number }}/assignees" class="shithub-assignee-form">
106
           <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
121
           <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
107
           <input type="text" name="username" placeholder="username" required>
122
           <input type="text" name="username" placeholder="username" required>
@@ -117,7 +132,7 @@
117
           {{ $mid := .Issue.MilestoneID.Int64 }}
132
           {{ $mid := .Issue.MilestoneID.Int64 }}
118
           {{ range .Milestones }}{{ if eq .ID $mid }}<a href="/{{ $.Owner }}/{{ $.Repo.Name }}/milestones">{{ .Title }}</a>{{ end }}{{ end }}
133
           {{ range .Milestones }}{{ if eq .ID $mid }}<a href="/{{ $.Owner }}/{{ $.Repo.Name }}/milestones">{{ .Title }}</a>{{ end }}{{ end }}
119
         {{ else }}<p class="shithub-muted">No milestone</p>{{ end }}
134
         {{ else }}<p class="shithub-muted">No milestone</p>{{ end }}
120
-        {{ if .Viewer.ID }}
135
+        {{ if .CanEditIssueMilestone }}
121
         <form method="post" action="/{{ .Owner }}/{{ .Repo.Name }}/issues/{{ .Issue.Number }}/milestone">
136
         <form method="post" action="/{{ .Owner }}/{{ .Repo.Name }}/issues/{{ .Issue.Number }}/milestone">
122
           <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
137
           <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
123
           <select name="milestone_id">
138
           <select name="milestone_id">
@@ -129,7 +144,7 @@
129
         {{ end }}
144
         {{ end }}
130
       </section>
145
       </section>
131
 
146
 
132
-      {{ if .Viewer.ID }}
147
+      {{ if .CanLockIssue }}
133
       <section>
148
       <section>
134
         <h3>Lock</h3>
149
         <h3>Lock</h3>
135
         <form method="post" action="/{{ .Owner }}/{{ .Repo.Name }}/issues/{{ .Issue.Number }}/lock">
150
         <form method="post" action="/{{ .Owner }}/{{ .Repo.Name }}/issues/{{ .Issue.Number }}/lock">
internal/web/templates/repo/labels.htmlmodified
@@ -8,7 +8,7 @@
8
     </h1>
8
     </h1>
9
   </header>
9
   </header>
10
 
10
 
11
-  {{ if .Viewer.ID }}
11
+  {{ if .CanManageIssue }}
12
   <details class="shithub-label-create">
12
   <details class="shithub-label-create">
13
     <summary class="shithub-button shithub-button-primary">New label</summary>
13
     <summary class="shithub-button shithub-button-primary">New label</summary>
14
     <form method="post" action="/{{ .Owner }}/{{ .Repo.Name }}/labels" class="shithub-label-form">
14
     <form method="post" action="/{{ .Owner }}/{{ .Repo.Name }}/labels" class="shithub-label-form">
@@ -26,7 +26,7 @@
26
     <li class="shithub-labels-row">
26
     <li class="shithub-labels-row">
27
       <span class="shithub-label" style="background-color: #{{ .Color }}">{{ .Name }}</span>
27
       <span class="shithub-label" style="background-color: #{{ .Color }}">{{ .Name }}</span>
28
       {{ if .Description }}<span class="shithub-muted">{{ .Description }}</span>{{ end }}
28
       {{ if .Description }}<span class="shithub-muted">{{ .Description }}</span>{{ end }}
29
-      {{ if $.Viewer.ID }}
29
+      {{ if $.CanManageIssue }}
30
       <details class="shithub-label-edit">
30
       <details class="shithub-label-edit">
31
         <summary class="shithub-button">Edit</summary>
31
         <summary class="shithub-button">Edit</summary>
32
         <form method="post" action="/{{ $.Owner }}/{{ $.Repo.Name }}/labels/{{ .ID }}/update" class="shithub-label-form">
32
         <form method="post" action="/{{ $.Owner }}/{{ $.Repo.Name }}/labels/{{ .ID }}/update" class="shithub-label-form">
internal/web/templates/repo/milestones.htmlmodified
@@ -8,7 +8,7 @@
8
     </h1>
8
     </h1>
9
   </header>
9
   </header>
10
 
10
 
11
-  {{ if .Viewer.ID }}
11
+  {{ if .CanManageIssue }}
12
   <details class="shithub-milestone-create">
12
   <details class="shithub-milestone-create">
13
     <summary class="shithub-button shithub-button-primary">New milestone</summary>
13
     <summary class="shithub-button shithub-button-primary">New milestone</summary>
14
     <form method="post" action="/{{ .Owner }}/{{ .Repo.Name }}/milestones" class="shithub-milestone-form">
14
     <form method="post" action="/{{ .Owner }}/{{ .Repo.Name }}/milestones" class="shithub-milestone-form">
@@ -29,7 +29,7 @@
29
         {{ if .DueOn.Valid }}<small>due {{ .DueOn.Time.Format "Jan 2, 2006" }}</small>{{ end }}
29
         {{ if .DueOn.Valid }}<small>due {{ .DueOn.Time.Format "Jan 2, 2006" }}</small>{{ end }}
30
       </h3>
30
       </h3>
31
       {{ if .Description }}<p>{{ .Description }}</p>{{ end }}
31
       {{ if .Description }}<p>{{ .Description }}</p>{{ end }}
32
-      {{ if $.Viewer.ID }}
32
+      {{ if $.CanManageIssue }}
33
       <div class="shithub-milestone-actions">
33
       <div class="shithub-milestone-actions">
34
         <form method="post" action="/{{ $.Owner }}/{{ $.Repo.Name }}/milestones/{{ .ID }}/state">
34
         <form method="post" action="/{{ $.Owner }}/{{ $.Repo.Name }}/milestones/{{ .ID }}/state">
35
           <input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
35
           <input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
internal/web/templates/repo/pull_view.htmlmodified
@@ -96,7 +96,7 @@
96
         </section>
96
         </section>
97
         {{ end }}
97
         {{ end }}
98
 
98
 
99
-        {{ if .Viewer.ID }}
99
+        {{ if .CanReviewPull }}
100
         <details class="shithub-pull-request-reviewer">
100
         <details class="shithub-pull-request-reviewer">
101
           <summary class="shithub-button">Request review</summary>
101
           <summary class="shithub-button">Request review</summary>
102
           <form method="post" action="/{{ .Owner }}/{{ .Repo.Name }}/pulls/{{ .PR.INumber }}/reviewers">
102
           <form method="post" action="/{{ .Owner }}/{{ .Repo.Name }}/pulls/{{ .PR.INumber }}/reviewers">
@@ -136,7 +136,8 @@
136
           <p class="shithub-pull-state-{{ printf "%s" .PR.MergeableState }}">
136
           <p class="shithub-pull-state-{{ printf "%s" .PR.MergeableState }}">
137
             {{ printf "%s" .PR.MergeableState }}
137
             {{ printf "%s" .PR.MergeableState }}
138
           </p>
138
           </p>
139
-          {{ if .Viewer.ID }}
139
+        {{ if or .CanMergePull .CanSetPullState .CanReadyPull }}
140
+            {{ if .CanMergePull }}
140
             {{ if eq (printf "%s" .PR.MergeableState) "clean" }}
141
             {{ if eq (printf "%s" .PR.MergeableState) "clean" }}
141
               <form method="post" action="/{{ .Owner }}/{{ .Repo.Name }}/pulls/{{ .PR.INumber }}/merge" class="shithub-pull-merge-form">
142
               <form method="post" action="/{{ .Owner }}/{{ .Repo.Name }}/pulls/{{ .PR.INumber }}/merge" class="shithub-pull-merge-form">
142
                 <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
143
                 <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
@@ -154,11 +155,14 @@
154
             {{ else }}
155
             {{ else }}
155
               <p class="shithub-muted">Mergeability is being computed…</p>
156
               <p class="shithub-muted">Mergeability is being computed…</p>
156
             {{ end }}
157
             {{ end }}
158
+            {{ end }}
159
+            {{ if .CanSetPullState }}
157
             <form method="post" action="/{{ .Owner }}/{{ .Repo.Name }}/pulls/{{ .PR.INumber }}/state" class="shithub-pull-state-form">
160
             <form method="post" action="/{{ .Owner }}/{{ .Repo.Name }}/pulls/{{ .PR.INumber }}/state" class="shithub-pull-state-form">
158
               <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
161
               <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
159
               <button type="submit" name="state" value="closed" class="shithub-button">Close pull request</button>
162
               <button type="submit" name="state" value="closed" class="shithub-button">Close pull request</button>
160
             </form>
163
             </form>
161
-            {{ if .PR.Draft }}
164
+            {{ end }}
165
+            {{ if and .CanReadyPull .PR.Draft }}
162
               <form method="post" action="/{{ .Owner }}/{{ .Repo.Name }}/pulls/{{ .PR.INumber }}/ready">
166
               <form method="post" action="/{{ .Owner }}/{{ .Repo.Name }}/pulls/{{ .PR.INumber }}/ready">
163
                 <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
167
                 <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
164
                 <button type="submit" class="shithub-button">Ready for review</button>
168
                 <button type="submit" class="shithub-button">Ready for review</button>
@@ -206,7 +210,7 @@
206
               <div class="shithub-comment-body markdown-body">
210
               <div class="shithub-comment-body markdown-body">
207
                 {{ if .C.BodyHtmlCached.Valid }}{{ safeHTML .C.BodyHtmlCached.String }}{{ else }}<p>{{ .C.Body }}</p>{{ end }}
211
                 {{ if .C.BodyHtmlCached.Valid }}{{ safeHTML .C.BodyHtmlCached.String }}{{ else }}<p>{{ .C.Body }}</p>{{ end }}
208
               </div>
212
               </div>
209
-              {{ if $.Viewer.ID }}
213
+              {{ if $.CanReviewPull }}
210
               <div class="shithub-pull-thread-actions">
214
               <div class="shithub-pull-thread-actions">
211
                 <form method="post" action="/{{ $.Owner }}/{{ $.Repo.Name }}/pulls/{{ $.PR.INumber }}/review-comments/{{ .C.ID }}/reply">
215
                 <form method="post" action="/{{ $.Owner }}/{{ $.Repo.Name }}/pulls/{{ $.PR.INumber }}/review-comments/{{ .C.ID }}/reply">
212
                   <input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
216
                   <input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
@@ -225,7 +229,7 @@
225
         {{ end }}
229
         {{ end }}
226
       {{ end }}
230
       {{ end }}
227
 
231
 
228
-      {{ if .Viewer.ID }}
232
+      {{ if .CanReviewPull }}
229
       <details class="shithub-pull-add-comment">
233
       <details class="shithub-pull-add-comment">
230
         <summary class="shithub-button">Add inline comment</summary>
234
         <summary class="shithub-button">Add inline comment</summary>
231
         <form method="post" action="/{{ .Owner }}/{{ .Repo.Name }}/pulls/{{ .PR.INumber }}/review-comments">
235
         <form method="post" action="/{{ .Owner }}/{{ .Repo.Name }}/pulls/{{ .PR.INumber }}/review-comments">