| 19 |
44
|
|
|> assign(:owner, owner)
|
| 20 |
45
|
|
|> assign(:repo, repo)
|
| 21 |
46
|
|
|> assign(:repository, repository)
|
| 22 |
|
- |
|> assign(:issue, issue)
|
| 23 |
|
- |
|> assign(:comments, comments)
|
| 24 |
47
|
|
|> assign(:editing, false)
|
| 25 |
|
- |
|> assign(:form, to_form(Issues.change_issue(issue)))
|
| 26 |
|
- |
|> assign(:comment_form, to_form(Comment.changeset(%Comment{}, %{})))}
|
|
48
|
+ |
|> assign(:comment_form, to_form(Comment.changeset(%Comment{}, %{})))
|
|
49
|
+ |
|> assign(:repo_labels, Labels.list_labels(repository))
|
|
50
|
+ |
|> assign(:repo_milestones, Milestones.list_milestones(repository))
|
|
51
|
+ |
|> assign(:assignable, Repositories.list_assignable_users(repository))
|
|
52
|
+ |
|> load(issue)}
|
| 27 |
53
|
|
end
|
| 28 |
54
|
|
|
| 29 |
55
|
|
def handle_event("toggle_edit", _params, socket) do
|
| 30 |
56
|
|
issue = socket.assigns.issue
|
| 31 |
57
|
|
|
| 32 |
|
- |
socket =
|
| 33 |
|
- |
socket
|
| 34 |
|
- |
|> assign(:editing, !socket.assigns.editing)
|
| 35 |
|
- |
|> assign(:form, to_form(Issues.change_issue(issue)))
|
| 36 |
|
- |
|
| 37 |
|
- |
{:noreply, socket}
|
|
58
|
+ |
{:noreply,
|
|
59
|
+ |
socket
|
|
60
|
+ |
|> assign(:editing, !socket.assigns.editing)
|
|
61
|
+ |
|> assign(:form, to_form(Issues.change_issue(issue)))}
|
| 38 |
62
|
|
end
|
| 39 |
63
|
|
|
| 40 |
64
|
|
def handle_event("save", %{"issue" => issue_params}, socket) do
|
| 41 |
65
|
|
issue = socket.assigns.issue
|
| 42 |
|
- |
title = issue_params["title"]
|
| 43 |
|
- |
body = issue_params["body"]
|
|
66
|
+ |
attrs = %{"title" => issue_params["title"], "body" => issue_params["body"]}
|
| 44 |
67
|
|
|
| 45 |
|
- |
case Issues.update_issue(issue, %{"title" => title, "body" => body}) do
|
|
68
|
+ |
case Issues.update_issue(issue, attrs) do
|
| 46 |
69
|
|
{:ok, updated} ->
|
| 47 |
70
|
|
{:noreply,
|
| 48 |
71
|
|
socket
|
| 49 |
|
- |
|> assign(:issue, updated)
|
| 50 |
72
|
|
|> assign(:editing, false)
|
| 51 |
73
|
|
|> put_flash(:info, "Issue updated")
|
| 52 |
|
- |
|> assign(:form, to_form(Issues.change_issue(updated)))}
|
|
74
|
+ |
|> load(updated)}
|
| 53 |
75
|
|
|
| 54 |
76
|
|
{:error, changeset} ->
|
| 55 |
77
|
|
{:noreply, assign(socket, :form, to_form(changeset))}
|
| 56 |
78
|
|
end
|
| 57 |
79
|
|
end
|
| 58 |
80
|
|
|
| 59 |
|
- |
def handle_event("close", _params, socket) do
|
|
81
|
+ |
def handle_event("close", _params, socket), do: set_state(socket, "closed", "completed")
|
|
82
|
+ |
def handle_event("reopen", _params, socket), do: set_state(socket, "open", nil)
|
|
83
|
+ |
|
|
84
|
+ |
# The rail's state menu picks a close reason as well as a state, which the
|
|
85
|
+ |
# header's two buttons cannot. Both end in the same write.
|
|
86
|
+ |
def handle_event("set_state", %{"state" => "open"}, socket),
|
|
87
|
+ |
do: set_state(socket, "open", nil)
|
|
88
|
+ |
|
|
89
|
+ |
def handle_event("set_state", %{"state" => "closed"} = params, socket),
|
|
90
|
+ |
do: set_state(socket, "closed", params["reason"] || "completed")
|
|
91
|
+ |
|
|
92
|
+ |
def handle_event("toggle_label", %{"name" => name}, socket) do
|
| 60 |
93
|
|
issue = socket.assigns.issue
|
| 61 |
94
|
|
|
| 62 |
95
|
|
{:ok, updated} =
|
| 63 |
|
- |
Issues.update_issue(issue, %{
|
| 64 |
|
- |
"state" => "closed",
|
| 65 |
|
- |
"state_reason" => "completed"
|
| 66 |
|
- |
})
|
|
96
|
+ |
if Enum.any?(issue.labels || [], &(&1["name"] == name)) do
|
|
97
|
+ |
Issues.remove_label(issue, name)
|
|
98
|
+ |
else
|
|
99
|
+ |
Issues.add_labels(issue, [name])
|
|
100
|
+ |
end
|
| 67 |
101
|
|
|
| 68 |
|
- |
{:noreply,
|
| 69 |
|
- |
socket
|
| 70 |
|
- |
|> assign(:issue, updated)
|
| 71 |
|
- |
|> put_flash(:info, "Issue closed")}
|
|
102
|
+ |
{:noreply, load(socket, updated)}
|
| 72 |
103
|
|
end
|
| 73 |
104
|
|
|
| 74 |
|
- |
def handle_event("reopen", _params, socket) do
|
|
105
|
+ |
def handle_event("toggle_assignee", %{"login" => login}, socket) do
|
| 75 |
106
|
|
issue = socket.assigns.issue
|
| 76 |
|
- |
{:ok, updated} = Issues.update_issue(issue, %{"state" => "open"})
|
| 77 |
107
|
|
|
| 78 |
|
- |
{:noreply,
|
| 79 |
|
- |
socket
|
| 80 |
|
- |
|> assign(:issue, updated)
|
| 81 |
|
- |
|> put_flash(:info, "Issue reopened")}
|
|
108
|
+ |
{:ok, updated} =
|
|
109
|
+ |
if Enum.any?(issue.assignees || [], &(&1["login"] == login)) do
|
|
110
|
+ |
Issues.remove_assignees(issue, [login])
|
|
111
|
+ |
else
|
|
112
|
+ |
Issues.add_assignees(issue, [login])
|
|
113
|
+ |
end
|
|
114
|
+ |
|
|
115
|
+ |
{:noreply, load(socket, updated)}
|
|
116
|
+ |
end
|
|
117
|
+ |
|
|
118
|
+ |
def handle_event("set_milestone", %{"number" => number}, socket) do
|
|
119
|
+ |
{:ok, updated} = Issues.set_milestone(socket.assigns.issue, number_or_nil(number))
|
|
120
|
+ |
{:noreply, load(socket, updated)}
|
| 82 |
121
|
|
end
|
| 83 |
122
|
|
|
| 84 |
123
|
|
def handle_event("add_comment", %{"comment" => %{"body" => body}}, socket) do
|
| 85 |
124
|
|
issue = socket.assigns.issue
|
| 86 |
125
|
|
|
| 87 |
126
|
|
case Issues.create_comment(issue, %{body: body}, socket.assigns.current_user) do
|
| 88 |
|
- |
{:ok, comment} ->
|
|
127
|
+ |
{:ok, _comment} ->
|
| 89 |
128
|
|
{:noreply,
|
| 90 |
129
|
|
socket
|
| 91 |
|
- |
|> assign(:comments, socket.assigns.comments ++ [comment])
|
| 92 |
130
|
|
|> assign(:comment_form, to_form(Comment.changeset(%Comment{}, %{})))
|
| 93 |
|
- |
|> assign(:issue, %{issue | comments: issue.comments + 1})
|
| 94 |
|
- |
|> put_flash(:info, "Comment added")}
|
|
131
|
+ |
|> put_flash(:info, "Comment added")
|
|
132
|
+ |
|> load(%{issue | comments: issue.comments + 1})}
|
| 95 |
133
|
|
|
| 96 |
134
|
|
{:error, changeset} ->
|
| 97 |
135
|
|
{:noreply, assign(socket, :comment_form, to_form(changeset))}
|
| 98 |
136
|
|
end
|
| 99 |
137
|
|
end
|
| 100 |
138
|
|
|
|
139
|
+ |
defp set_state(socket, state, reason) do
|
|
140
|
+ |
attrs = %{"state" => state, "state_reason" => reason}
|
|
141
|
+ |
{:ok, updated} = Issues.update_issue(socket.assigns.issue, attrs)
|
|
142
|
+ |
|
|
143
|
+ |
flash = if state == "closed", do: "Issue closed", else: "Issue reopened"
|
|
144
|
+ |
|
|
145
|
+ |
{:noreply,
|
|
146
|
+ |
socket
|
|
147
|
+ |
|> put_flash(:info, flash)
|
|
148
|
+ |
|> load(updated)}
|
|
149
|
+ |
end
|
|
150
|
+ |
|
|
151
|
+ |
# One place rebuilds everything derived from the issue, so a write cannot
|
|
152
|
+ |
# leave the timeline describing the previous version of the page.
|
|
153
|
+ |
defp load(socket, issue) do
|
|
154
|
+ |
comments = Issues.list_comments(issue)
|
|
155
|
+ |
|
|
156
|
+ |
socket
|
|
157
|
+ |
|> assign(:issue, issue)
|
|
158
|
+ |
|> assign(:comments, comments)
|
|
159
|
+ |
|> assign(:form, to_form(Issues.change_issue(issue)))
|
|
160
|
+ |
|> assign(:events, timeline(issue, comments))
|
|
161
|
+ |
end
|
|
162
|
+ |
|
| 101 |
163
|
|
def render(assigns) do
|
| 102 |
164
|
|
~H"""
|
| 103 |
|
- |
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
| 104 |
|
- |
<%= if @editing do %>
|
| 105 |
|
- |
<.form
|
| 106 |
|
- |
for={@form}
|
| 107 |
|
- |
id="issue-edit-form"
|
| 108 |
|
- |
phx-submit="save"
|
| 109 |
|
- |
class="card !mx-0 !mt-0 mb-6"
|
| 110 |
|
- |
>
|
| 111 |
|
- |
<.input field={@form[:title]} label="Title" />
|
| 112 |
|
- |
<.input field={@form[:body]} type="textarea" label="Body" />
|
| 113 |
|
- |
<footer class="flex justify-end mt-2 gap-2">
|
| 114 |
|
- |
<button type="button" class="btn" data-variant="ghost" phx-click="toggle_edit">
|
| 115 |
|
- |
Cancel
|
| 116 |
|
- |
</button>
|
| 117 |
|
- |
<.button type="submit" variant={:primary}>Save</.button>
|
| 118 |
|
- |
</footer>
|
| 119 |
|
- |
</.form>
|
| 120 |
|
- |
<% else %>
|
| 121 |
|
- |
<div class="flex flex-col md:flex-row md:items-start md:justify-between gap-4 mb-6">
|
| 122 |
|
- |
<div class="flex-1">
|
| 123 |
|
- |
<h1 class="text-3xl font-bold mb-2">
|
|
165
|
+ |
<Layouts.app flash={@flash} current_scope={@current_scope} wide>
|
|
166
|
+ |
<Circle.issue_detail>
|
|
167
|
+ |
<:heading>
|
|
168
|
+ |
<.form
|
|
169
|
+ |
:if={@editing}
|
|
170
|
+ |
for={@form}
|
|
171
|
+ |
id="issue-edit-form"
|
|
172
|
+ |
phx-submit="save"
|
|
173
|
+ |
class="issue-editor"
|
|
174
|
+ |
>
|
|
175
|
+ |
<.input field={@form[:title]} label="Title" />
|
|
176
|
+ |
<.input field={@form[:body]} type="textarea" label="Body" />
|
|
177
|
+ |
<footer class="issue-editor__foot">
|
|
178
|
+ |
<button type="button" class="btn" data-variant="ghost" phx-click="toggle_edit">
|
|
179
|
+ |
Cancel
|
|
180
|
+ |
</button>
|
|
181
|
+ |
<.button type="submit" variant={:primary}>Save</.button>
|
|
182
|
+ |
</footer>
|
|
183
|
+ |
</.form>
|
|
184
|
+ |
|
|
185
|
+ |
<div :if={!@editing} class="issue-heading">
|
|
186
|
+ |
<h1 class="issue-heading__title">
|
| 124 |
187
|
|
{@issue.title}
|
| 125 |
|
- |
<span class="text-muted-foreground text-xl font-normal">
|
| 126 |
|
- |
#{@issue.number}
|
| 127 |
|
- |
</span>
|
|
188
|
+ |
<span class="issue-heading__number">#{@issue.number}</span>
|
| 128 |
189
|
|
</h1>
|
| 129 |
|
- |
<div class="flex items-center gap-2 mb-3">
|
| 130 |
|
- |
<span class="badge" data-variant={if @issue.state == "open", do: "success", else: "dim"}>
|
| 131 |
|
- |
{@issue.state}
|
|
190
|
+ |
<p class="issue-heading__meta">
|
|
191
|
+ |
<Circle.issue_state state={@issue.state} reason={@issue.state_reason} show_label />
|
|
192
|
+ |
<span class="issue-heading__dot" aria-hidden="true">·</span>
|
|
193
|
+ |
<span>
|
|
194
|
+ |
{author(@issue)} opened this {relative(@issue.inserted_at)} ago
|
|
195
|
+ |
</span>
|
|
196
|
+ |
<span :if={@issue.comments > 0} class="issue-heading__dot" aria-hidden="true">·</span>
|
|
197
|
+ |
<span :if={@issue.comments > 0}>
|
|
198
|
+ |
{@issue.comments} {ngettext_comments(@issue.comments)}
|
| 132 |
199
|
|
</span>
|
| 133 |
|
- |
<p class="text-sm text-muted-foreground">
|
| 134 |
|
- |
Opened on {Calendar.strftime(@issue.inserted_at, "%b %d, %Y")} by {(@issue.user &&
|
| 135 |
|
- |
@issue.user[
|
| 136 |
|
- |
"login"
|
| 137 |
|
- |
]) ||
|
| 138 |
|
- |
"anonymous"}
|
| 139 |
|
- |
</p>
|
| 140 |
|
- |
</div>
|
| 141 |
|
- |
</div>
|
| 142 |
|
- |
<div class="flex gap-2 shrink-0">
|
| 143 |
|
- |
<button
|
| 144 |
|
- |
:if={@issue.state == "open"}
|
| 145 |
|
- |
class="btn"
|
| 146 |
|
- |
data-variant="primary"
|
| 147 |
|
- |
data-size="sm"
|
| 148 |
|
- |
phx-click="close"
|
| 149 |
|
- |
>
|
| 150 |
|
- |
Close issue
|
| 151 |
|
- |
</button>
|
| 152 |
|
- |
<button
|
| 153 |
|
- |
:if={@issue.state == "closed"}
|
| 154 |
|
- |
class="btn"
|
| 155 |
|
- |
data-variant="ghost"
|
| 156 |
|
- |
data-size="sm"
|
| 157 |
|
- |
phx-click="reopen"
|
| 158 |
|
- |
>
|
| 159 |
|
- |
Reopen issue
|
| 160 |
|
- |
</button>
|
| 161 |
|
- |
<button class="btn" data-variant="ghost" data-size="sm" phx-click="toggle_edit">
|
| 162 |
|
- |
Edit
|
| 163 |
|
- |
</button>
|
| 164 |
|
- |
</div>
|
| 165 |
|
- |
</div>
|
| 166 |
|
- |
<% end %>
|
| 167 |
|
- |
|
| 168 |
|
- |
<div class="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
| 169 |
|
- |
<div class="lg:col-span-3 space-y-6">
|
| 170 |
|
- |
<div class="card !m-0">
|
| 171 |
|
- |
<p class="whitespace-pre-wrap text-foreground">
|
| 172 |
|
- |
{@issue.body || "No description provided."}
|
| 173 |
200
|
|
</p>
|
|
201
|
+ |
<div class="issue-heading__actions">
|
|
202
|
+ |
<button
|
|
203
|
+ |
:if={@issue.state == "open"}
|
|
204
|
+ |
class="btn"
|
|
205
|
+ |
data-variant="primary"
|
|
206
|
+ |
data-size="sm"
|
|
207
|
+ |
phx-click="close"
|
|
208
|
+ |
>
|
|
209
|
+ |
Close issue
|
|
210
|
+ |
</button>
|
|
211
|
+ |
<button
|
|
212
|
+ |
:if={@issue.state == "closed"}
|
|
213
|
+ |
class="btn"
|
|
214
|
+ |
data-variant="ghost"
|
|
215
|
+ |
data-size="sm"
|
|
216
|
+ |
phx-click="reopen"
|
|
217
|
+ |
>
|
|
218
|
+ |
Reopen issue
|
|
219
|
+ |
</button>
|
|
220
|
+ |
<button class="btn" data-variant="ghost" data-size="sm" phx-click="toggle_edit">
|
|
221
|
+ |
<.icon name="edit" /> Edit
|
|
222
|
+ |
</button>
|
|
223
|
+ |
</div>
|
| 174 |
224
|
|
</div>
|
|
225
|
+ |
</:heading>
|
|
226
|
+ |
|
|
227
|
+ |
<:rail>
|
|
228
|
+ |
<Circle.properties_panel>
|
|
229
|
+ |
<:group heading="State">
|
|
230
|
+ |
<Circle.field_menu id="issue-state-menu" label="Change the state of this issue">
|
|
231
|
+ |
<:trigger>
|
|
232
|
+ |
<Circle.issue_state
|
|
233
|
+ |
state={@issue.state}
|
|
234
|
+ |
reason={@issue.state_reason}
|
|
235
|
+ |
show_label
|
|
236
|
+ |
/>
|
|
237
|
+ |
</:trigger>
|
|
238
|
+ |
<Circle.field_menu_item
|
|
239
|
+ |
:for={{label, state, reason} <- state_options()}
|
|
240
|
+ |
label={label}
|
|
241
|
+ |
mode={:choice}
|
|
242
|
+ |
selected={@issue.state == state and close_reason(@issue) == reason}
|
|
243
|
+ |
closes="issue-state-menu"
|
|
244
|
+ |
on_select={JS.push("set_state", value: %{state: state, reason: reason})}
|
|
245
|
+ |
>
|
|
246
|
+ |
<:glyph><Circle.issue_state state={state} reason={reason} /></:glyph>
|
|
247
|
+ |
</Circle.field_menu_item>
|
|
248
|
+ |
</Circle.field_menu>
|
|
249
|
+ |
</:group>
|
| 175 |
250
|
|
|
| 176 |
|
- |
<h2 class="text-xl font-bold">Comments</h2>
|
| 177 |
|
- |
|
| 178 |
|
- |
<div class="space-y-4">
|
| 179 |
|
- |
<%= for comment <- @comments do %>
|
| 180 |
|
- |
<div class="card !m-0">
|
| 181 |
|
- |
<div class="flex items-center gap-2 mb-2">
|
| 182 |
|
- |
<span class="avatar size-8 font-semibold">
|
| 183 |
|
- |
<span>
|
| 184 |
|
- |
{(comment.user && comment.user["login"] |> String.first() |> String.upcase()) ||
|
| 185 |
|
- |
"?"}
|
| 186 |
|
- |
</span>
|
| 187 |
|
- |
</span>
|
| 188 |
|
- |
<span class="font-semibold text-sm">
|
| 189 |
|
- |
{(comment.user && comment.user["login"]) || "anonymous"}
|
| 190 |
|
- |
</span>
|
| 191 |
|
- |
<span class="text-sm text-muted-foreground">
|
| 192 |
|
- |
{Calendar.strftime(comment.created_at, "%b %d, %Y %H:%M")}
|
| 193 |
|
- |
</span>
|
| 194 |
|
- |
</div>
|
| 195 |
|
- |
<p class="whitespace-pre-wrap text-foreground">
|
| 196 |
|
- |
{comment.body}
|
|
251
|
+ |
<:group heading="Assignees">
|
|
252
|
+ |
<Circle.assignee
|
|
253
|
+ |
:for={assignee <- @issue.assignees}
|
|
254
|
+ |
name={assignee["login"]}
|
|
255
|
+ |
show_name
|
|
256
|
+ |
size={:sm}
|
|
257
|
+ |
/>
|
|
258
|
+ |
<span :if={@issue.assignees == []} class="properties-panel__none">No one assigned</span>
|
|
259
|
+ |
<Circle.field_menu id="issue-assignee-menu" label="Assign this issue" align={:end}>
|
|
260
|
+ |
<:trigger><.icon name="user-add" class="properties-panel__add" /></:trigger>
|
|
261
|
+ |
<Circle.field_menu_item
|
|
262
|
+ |
:for={user <- @assignable}
|
|
263
|
+ |
label={user.github_login}
|
|
264
|
+ |
selected={assigned?(@issue, user.github_login)}
|
|
265
|
+ |
on_select={JS.push("toggle_assignee", value: %{login: user.github_login})}
|
|
266
|
+ |
>
|
|
267
|
+ |
<:glyph><Circle.assignee name={user.github_login} size={:sm} /></:glyph>
|
|
268
|
+ |
</Circle.field_menu_item>
|
|
269
|
+ |
<p :if={@assignable == []} class="properties-panel__none">
|
|
270
|
+ |
Nobody in this repository can be assigned yet.
|
| 197 |
271
|
|
</p>
|
| 198 |
|
- |
</div>
|
| 199 |
|
- |
<% end %>
|
|
272
|
+ |
</Circle.field_menu>
|
|
273
|
+ |
</:group>
|
|
274
|
+ |
|
|
275
|
+ |
<:group heading="Labels">
|
|
276
|
+ |
<Circle.issue_label
|
|
277
|
+ |
:for={label <- @issue.labels}
|
|
278
|
+ |
name={label["name"]}
|
|
279
|
+ |
tone={label_tone(label["color"])}
|
|
280
|
+ |
/>
|
|
281
|
+ |
<span :if={@issue.labels == []} class="properties-panel__none">None yet</span>
|
|
282
|
+ |
<Circle.field_menu
|
|
283
|
+ |
id="issue-label-menu"
|
|
284
|
+ |
label="Change the labels on this issue"
|
|
285
|
+ |
align={:end}
|
|
286
|
+ |
>
|
|
287
|
+ |
<:trigger><.icon name="tag" class="properties-panel__add" /></:trigger>
|
|
288
|
+ |
<Circle.field_menu_item
|
|
289
|
+ |
:for={label <- @repo_labels}
|
|
290
|
+ |
label={label.name}
|
|
291
|
+ |
selected={labelled?(@issue, label.name)}
|
|
292
|
+ |
on_select={JS.push("toggle_label", value: %{name: label.name})}
|
|
293
|
+ |
>
|
|
294
|
+ |
<:glyph>
|
|
295
|
+ |
<span class="issue-label__dot" data-tone={label_tone(label.color)} />
|
|
296
|
+ |
</:glyph>
|
|
297
|
+ |
</Circle.field_menu_item>
|
|
298
|
+ |
<p :if={@repo_labels == []} class="properties-panel__none">
|
|
299
|
+ |
This repository has no labels yet.
|
|
300
|
+ |
</p>
|
|
301
|
+ |
</Circle.field_menu>
|
|
302
|
+ |
</:group>
|
|
303
|
+ |
|
|
304
|
+ |
<:group heading="Milestone">
|
|
305
|
+ |
<.link
|
|
306
|
+ |
:if={@issue.milestone}
|
|
307
|
+ |
navigate={~p"/#{@owner}/#{@repo}/milestones"}
|
|
308
|
+ |
class="properties-panel__link"
|
|
309
|
+ |
>
|
|
310
|
+ |
<.icon name="flag" /> {@issue.milestone["title"]}
|
|
311
|
+ |
</.link>
|
|
312
|
+ |
<span :if={!@issue.milestone} class="properties-panel__none">No milestone</span>
|
|
313
|
+ |
<Circle.field_menu id="issue-milestone-menu" label="Set the milestone" align={:end}>
|
|
314
|
+ |
<:trigger><.icon name="edit-pencil" class="properties-panel__add" /></:trigger>
|
|
315
|
+ |
<Circle.field_menu_item
|
|
316
|
+ |
label="No milestone"
|
|
317
|
+ |
mode={:choice}
|
|
318
|
+ |
selected={is_nil(@issue.milestone)}
|
|
319
|
+ |
closes="issue-milestone-menu"
|
|
320
|
+ |
on_select={JS.push("set_milestone", value: %{number: ""})}
|
|
321
|
+ |
/>
|
|
322
|
+ |
<Circle.field_menu_item
|
|
323
|
+ |
:for={milestone <- @repo_milestones}
|
|
324
|
+ |
label={milestone.title}
|
|
325
|
+ |
icon="flag"
|
|
326
|
+ |
mode={:choice}
|
|
327
|
+ |
selected={milestoned?(@issue, milestone.number)}
|
|
328
|
+ |
closes="issue-milestone-menu"
|
|
329
|
+ |
on_select={JS.push("set_milestone", value: %{number: milestone.number})}
|
|
330
|
+ |
/>
|
|
331
|
+ |
</Circle.field_menu>
|
|
332
|
+ |
</:group>
|
|
333
|
+ |
</Circle.properties_panel>
|
|
334
|
+ |
</:rail>
|
|
335
|
+ |
|
|
336
|
+ |
<section class="issue-body">
|
|
337
|
+ |
<div :if={@issue.body} class="timeline-comment__body !p-0">
|
|
338
|
+ |
{Markdown.to_html(@issue.body)}
|
| 200 |
339
|
|
</div>
|
|
340
|
+ |
<p :if={!@issue.body} class="properties-panel__none">No description provided.</p>
|
|
341
|
+ |
</section>
|
| 201 |
342
|
|
|
| 202 |
|
- |
<.form
|
| 203 |
|
- |
for={@comment_form}
|
| 204 |
|
- |
id="comment-form"
|
| 205 |
|
- |
phx-submit="add_comment"
|
| 206 |
|
- |
class="card !m-0"
|
| 207 |
|
- |
>
|
| 208 |
|
- |
<.input field={@comment_form[:body]} type="textarea" label="Write a comment" />
|
| 209 |
|
- |
<footer class="flex justify-end mt-2">
|
| 210 |
|
- |
<.button type="submit" variant={:primary}>Comment</.button>
|
| 211 |
|
- |
</footer>
|
| 212 |
|
- |
</.form>
|
| 213 |
|
- |
</div>
|
| 214 |
|
- |
|
| 215 |
|
- |
<div class="space-y-4">
|
| 216 |
|
- |
<%= if @issue.labels != [] do %>
|
| 217 |
|
- |
<div>
|
| 218 |
|
- |
<h3 class="font-semibold text-sm text-muted-foreground mb-2">Labels</h3>
|
| 219 |
|
- |
<div class="flex flex-wrap gap-1">
|
| 220 |
|
- |
<%= for label <- @issue.labels do %>
|
| 221 |
|
- |
<span
|
| 222 |
|
- |
class="badge rounded-full px-2 py-0.5"
|
| 223 |
|
- |
style={"background-color: ##{label["color"]}; color: #000;"}
|
| 224 |
|
- |
>
|
| 225 |
|
- |
{label["name"]}
|
| 226 |
|
- |
</span>
|
| 227 |
|
- |
<% end %>
|
| 228 |
|
- |
</div>
|
| 229 |
|
- |
</div>
|
|
343
|
+ |
<Circle.timeline>
|
|
344
|
+ |
<%= for event <- @events do %>
|
|
345
|
+ |
<Circle.timeline_comment
|
|
346
|
+ |
:if={event.kind == :comment}
|
|
347
|
+ |
id={"comment-#{event.id}"}
|
|
348
|
+ |
author={event.actor}
|
|
349
|
+ |
at={event.at}
|
|
350
|
+ |
badge={event.badge}
|
|
351
|
+ |
>
|
|
352
|
+ |
{Markdown.to_html(event.body)}
|
|
353
|
+ |
</Circle.timeline_comment>
|
|
354
|
+ |
<Circle.timeline_event
|
|
355
|
+ |
:if={event.kind == :event}
|
|
356
|
+ |
actor={event.actor}
|
|
357
|
+ |
text={event.text}
|
|
358
|
+ |
icon={event.icon}
|
|
359
|
+ |
tone={event.tone}
|
|
360
|
+ |
at={event.at}
|
|
361
|
+ |
/>
|
| 230 |
362
|
|
<% end %>
|
|
363
|
+ |
</Circle.timeline>
|
| 231 |
364
|
|
|
| 232 |
|
- |
<%= if @issue.assignees != [] do %>
|
| 233 |
|
- |
<div>
|
| 234 |
|
- |
<h3 class="font-semibold text-sm text-muted-foreground mb-2">Assignees</h3>
|
| 235 |
|
- |
<div class="avatar-group">
|
| 236 |
|
- |
<%= for assignee <- @issue.assignees do %>
|
| 237 |
|
- |
<span class="avatar size-7 font-semibold" title={assignee["login"]}>
|
| 238 |
|
- |
<span class="!text-xs">
|
| 239 |
|
- |
{assignee["login"] |> String.first() |> String.upcase()}
|
| 240 |
|
- |
</span>
|
| 241 |
|
- |
</span>
|
| 242 |
|
- |
<% end %>
|
| 243 |
|
- |
</div>
|
| 244 |
|
- |
</div>
|
| 245 |
|
- |
<% end %>
|
|
365
|
+ |
<.alert :if={@issue.locked} variant={:warning} appearance={:notice}>
|
|
366
|
+ |
This conversation is locked{if @issue.locked_reason, do: " as #{@issue.locked_reason}"}.
|
|
367
|
+ |
</.alert>
|
| 246 |
368
|
|
|
| 247 |
|
- |
<%= if @issue.milestone do %>
|
| 248 |
|
- |
<div>
|
| 249 |
|
- |
<h3 class="font-semibold text-sm text-muted-foreground mb-2">Milestone</h3>
|
| 250 |
|
- |
<.link navigate={~p"/#{@owner}/#{@repo}/milestones"} class="badge" data-variant="dim">
|
| 251 |
|
- |
{@issue.milestone["title"]}
|
| 252 |
|
- |
</.link>
|
| 253 |
|
- |
</div>
|
| 254 |
|
- |
<% end %>
|
| 255 |
|
- |
</div>
|
| 256 |
|
- |
</div>
|
|
369
|
+ |
<.form :if={!@issue.locked} for={@comment_form} id="comment-form" phx-submit="add_comment">
|
|
370
|
+ |
<Circle.comment_composer id="issue-composer" author={viewer(@current_user)}>
|
|
371
|
+ |
<.input
|
|
372
|
+ |
field={@comment_form[:body]}
|
|
373
|
+ |
type="textarea"
|
|
374
|
+ |
label="Comment"
|
|
375
|
+ |
placeholder="Leave a comment"
|
|
376
|
+ |
/>
|
|
377
|
+ |
<:hint>Markdown is supported.</:hint>
|
|
378
|
+ |
<:actions>
|
|
379
|
+ |
<.button type="submit" variant={:primary} size={:sm}>Comment</.button>
|
|
380
|
+ |
</:actions>
|
|
381
|
+ |
</Circle.comment_composer>
|
|
382
|
+ |
</.form>
|
|
383
|
+ |
</Circle.issue_detail>
|
| 257 |
384
|
|
</Layouts.app>
|
| 258 |
385
|
|
"""
|
| 259 |
386
|
|
end
|
|
387
|
+ |
|
|
388
|
+ |
# ── the derived history ────────────────────────────────────────────────────
|
|
389
|
+ |
|
|
390
|
+ |
# GitHub's timeline is an endpoint backed by an event log. This schema has no
|
|
391
|
+ |
# such table, so the feed is assembled from the columns that do exist. The
|
|
392
|
+ |
# result is honest but partial: a label added and removed leaves no trace,
|
|
393
|
+ |
# and a close records when but not who.
|
|
394
|
+ |
defp timeline(issue, comments) do
|
|
395
|
+ |
opened = %{
|
|
396
|
+ |
kind: :event,
|
|
397
|
+ |
actor: author(issue),
|
|
398
|
+ |
text: "opened this issue",
|
|
399
|
+ |
icon: "plus-circle",
|
|
400
|
+ |
tone: :neutral,
|
|
401
|
+ |
at: stamp(issue.inserted_at),
|
|
402
|
+ |
sort: issue.inserted_at
|
|
403
|
+ |
}
|
|
404
|
+ |
|
|
405
|
+ |
commented =
|
|
406
|
+ |
Enum.map(comments, fn comment ->
|
|
407
|
+ |
login = login(comment.user)
|
|
408
|
+ |
|
|
409
|
+ |
%{
|
|
410
|
+ |
kind: :comment,
|
|
411
|
+ |
id: comment.id,
|
|
412
|
+ |
actor: login,
|
|
413
|
+ |
badge: if(login == author(issue), do: "Author"),
|
|
414
|
+ |
body: comment.body,
|
|
415
|
+ |
at: stamp(comment.created_at),
|
|
416
|
+ |
sort: comment.created_at
|
|
417
|
+ |
}
|
|
418
|
+ |
end)
|
|
419
|
+ |
|
|
420
|
+ |
closed =
|
|
421
|
+ |
if issue.state == "closed" and issue.closed_at do
|
|
422
|
+ |
[
|
|
423
|
+ |
%{
|
|
424
|
+ |
kind: :event,
|
|
425
|
+ |
actor: nil,
|
|
426
|
+ |
text: close_text(issue),
|
|
427
|
+ |
icon: close_icon(issue),
|
|
428
|
+ |
tone: close_tone(issue),
|
|
429
|
+ |
at: stamp(issue.closed_at),
|
|
430
|
+ |
sort: issue.closed_at
|
|
431
|
+ |
}
|
|
432
|
+ |
]
|
|
433
|
+ |
else
|
|
434
|
+ |
[]
|
|
435
|
+ |
end
|
|
436
|
+ |
|
|
437
|
+ |
Enum.sort_by([opened | commented] ++ closed, & &1.sort, DateTime)
|
|
438
|
+ |
end
|
|
439
|
+ |
|
|
440
|
+ |
defp close_text(%{state_reason: "not_planned"}), do: "closed this as not planned"
|
|
441
|
+ |
defp close_text(%{state_reason: "duplicate"}), do: "closed this as a duplicate"
|
|
442
|
+ |
defp close_text(_issue), do: "closed this as completed"
|
|
443
|
+ |
|
|
444
|
+ |
defp close_icon(%{state_reason: reason}) when reason in ["not_planned", "duplicate"],
|
|
445
|
+ |
do: "x-circle-filled"
|
|
446
|
+ |
|
|
447
|
+ |
defp close_icon(_issue), do: "check-circle-filled"
|
|
448
|
+ |
|
|
449
|
+ |
defp close_tone(%{state_reason: reason}) when reason in ["not_planned", "duplicate"],
|
|
450
|
+ |
do: :danger
|
|
451
|
+ |
|
|
452
|
+ |
defp close_tone(_issue), do: :success
|
|
453
|
+ |
|
|
454
|
+ |
# ── GitHub's fields, read the way this interface reads them ────────────────
|
|
455
|
+ |
|
|
456
|
+ |
# The three states this application can express. `duplicate` is a valid
|
|
457
|
+ |
# GitHub close reason and is rendered when it arrives from the API, but it is
|
|
458
|
+ |
# not offered here: the menu that sets it has nowhere to record which issue
|
|
459
|
+ |
# it duplicates, and a duplicate that does not say of what is worse than a
|
|
460
|
+ |
# plain close.
|
|
461
|
+ |
defp state_options do
|
|
462
|
+ |
[
|
|
463
|
+ |
{"Open", "open", nil},
|
|
464
|
+ |
{"Closed as completed", "closed", "completed"},
|
|
465
|
+ |
{"Closed as not planned", "closed", "not_planned"}
|
|
466
|
+ |
]
|
|
467
|
+ |
end
|
|
468
|
+ |
|
|
469
|
+ |
defp close_reason(%{state: "closed", state_reason: reason}), do: reason || "completed"
|
|
470
|
+ |
defp close_reason(_issue), do: nil
|
|
471
|
+ |
|
|
472
|
+ |
# `JS.push` sends a number as a number and a form sends it as a string, so
|
|
473
|
+ |
# the handler takes whichever arrives rather than making the call sites agree.
|
|
474
|
+ |
defp number_or_nil(""), do: nil
|
|
475
|
+ |
defp number_or_nil(number) when is_integer(number), do: number
|
|
476
|
+ |
defp number_or_nil(number) when is_binary(number), do: String.to_integer(number)
|
|
477
|
+ |
|
|
478
|
+ |
defp assigned?(issue, login), do: Enum.any?(issue.assignees || [], &(&1["login"] == login))
|
|
479
|
+ |
defp labelled?(issue, name), do: Enum.any?(issue.labels || [], &(&1["name"] == name))
|
|
480
|
+ |
defp milestoned?(%{milestone: %{"number" => n}}, number), do: n == number
|
|
481
|
+ |
defp milestoned?(_issue, _number), do: false
|
|
482
|
+ |
|
|
483
|
+ |
defp author(issue), do: login(issue.user)
|
|
484
|
+ |
|
|
485
|
+ |
defp login(%{} = user), do: user["login"] || user[:login] || "anonymous"
|
|
486
|
+ |
defp login(_user), do: "anonymous"
|
|
487
|
+ |
|
|
488
|
+ |
defp viewer(%{github_login: login}) when is_binary(login), do: login
|
|
489
|
+ |
defp viewer(_user), do: nil
|
|
490
|
+ |
|
|
491
|
+ |
defp ngettext_comments(1), do: "comment"
|
|
492
|
+ |
defp ngettext_comments(_count), do: "comments"
|
|
493
|
+ |
|
|
494
|
+ |
# A GitHub label carries a hex colour chosen by whoever made it. Rendering it
|
|
495
|
+ |
# would put a second palette on the page, and dropping it would make every
|
|
496
|
+ |
# label grey. So the hue is read and mapped to the nearest tone on our
|
|
497
|
+ |
# ladder: the author's intent survives -- red means broken, green means new
|
|
498
|
+ |
# -- while the values stay ours.
|
|
499
|
+ |
defp label_tone(color) when is_binary(color) do
|
|
500
|
+ |
case Integer.parse(String.trim_leading(color, "#"), 16) do
|
|
501
|
+ |
{value, _rest} -> hue_tone(value)
|
|
502
|
+ |
:error -> :neutral
|
|
503
|
+ |
end
|
|
504
|
+ |
end
|
|
505
|
+ |
|
|
506
|
+ |
defp label_tone(_color), do: :neutral
|
|
507
|
+ |
|
|
508
|
+ |
defp hue_tone(value) do
|
|
509
|
+ |
r = div(value, 65_536)
|
|
510
|
+ |
g = value |> div(256) |> rem(256)
|
|
511
|
+ |
b = rem(value, 256)
|
|
512
|
+ |
high = Enum.max([r, g, b])
|
|
513
|
+ |
low = Enum.min([r, g, b])
|
|
514
|
+ |
|
|
515
|
+ |
# A grey label is as deliberate a choice as a red one, so anything close to
|
|
516
|
+ |
# the diagonal stays neutral rather than being forced into a hue.
|
|
517
|
+ |
if high == 0 or (high - low) / high < 0.2 do
|
|
518
|
+ |
:neutral
|
|
519
|
+ |
else
|
|
520
|
+ |
r |> hue(g, b, high, low) |> tone_for_hue()
|
|
521
|
+ |
end
|
|
522
|
+ |
end
|
|
523
|
+ |
|
|
524
|
+ |
defp hue(r, g, b, high, low) do
|
|
525
|
+ |
delta = high - low
|
|
526
|
+ |
|
|
527
|
+ |
sextant =
|
|
528
|
+ |
cond do
|
|
529
|
+ |
high == r -> wrap((g - b) / delta)
|
|
530
|
+ |
high == g -> (b - r) / delta + 2
|
|
531
|
+ |
true -> (r - g) / delta + 4
|
|
532
|
+ |
end
|
|
533
|
+ |
|
|
534
|
+ |
sextant * 60
|
|
535
|
+ |
end
|
|
536
|
+ |
|
|
537
|
+ |
# The red sextant straddles zero: a value of -0.5 is 330 degrees, not -30.
|
|
538
|
+ |
defp wrap(sextant) when sextant < 0, do: sextant + 6
|
|
539
|
+ |
defp wrap(sextant), do: sextant
|
|
540
|
+ |
|
|
541
|
+ |
defp tone_for_hue(h) when h < 20 or h >= 340, do: :danger
|
|
542
|
+ |
defp tone_for_hue(h) when h < 70, do: :warning
|
|
543
|
+ |
defp tone_for_hue(h) when h < 170, do: :success
|
|
544
|
+ |
defp tone_for_hue(h) when h < 260, do: :info
|
|
545
|
+ |
defp tone_for_hue(_h), do: :primary
|
|
546
|
+ |
|
|
547
|
+ |
defp stamp(nil), do: nil
|
|
548
|
+ |
defp stamp(at), do: "#{relative(at)} ago"
|
|
549
|
+ |
|
|
550
|
+ |
defp relative(nil), do: nil
|
|
551
|
+ |
|
|
552
|
+ |
defp relative(at) do
|
|
553
|
+ |
at = if is_struct(at, NaiveDateTime), do: DateTime.from_naive!(at, "Etc/UTC"), else: at
|
|
554
|
+ |
|
|
555
|
+ |
case DateTime.diff(DateTime.utc_now(), at, :second) do
|
|
556
|
+ |
s when s < 3_600 -> "#{max(div(s, 60), 1)}m"
|
|
557
|
+ |
s when s < 86_400 -> "#{div(s, 3_600)}h"
|
|
558
|
+ |
s -> "#{div(s, 86_400)}d"
|
|
559
|
+ |
end
|
|
560
|
+ |
end
|
| 260 |
561
|
|
end
|