ReviewOS

also looking at this

reviewos/reviewos.org

feat(reviews): the suggestions get an interface, priced as designed

#6
Merged glennmichael123 wants to merge feat/suggested-reviewers-panel into main
9 files +741 -10
resources/components/SuggestedReviewers.stxadded+150-0
Changes to resources/components/SuggestedReviewers.stx
@@ -0,0 +1,150 @@
1<script server>
2/**
3 * Who might usefully look at this pull request, fetched when somebody asks.
4 *
5 * The endpoint behind this costs a `git log` over the changed paths, and the
6 * roadmap's whole reason for it being an endpoint is that the cost is not paid
7 * on every render of a page most readers scroll straight past. So the panel
8 * ships closed with nothing in it, and the first open - and only the first -
9 * asks. A `<details>` does the opening and closing with no script at all; the
10 * script's one job is to notice the first open and fetch.
11 *
12 * A suggestion, never a request. Nothing here submits anything: `CODEOWNERS`
13 * requests automatically because a file in the repository said to, and this is
14 * the forge having an opinion, which is offered rather than acted on. Each name
15 * carries its reason verbatim - "3 commits here, last 5d ago, 2 waiting on
16 * them" - because a name nobody can account for is one people either click
17 * without thinking or ignore.
18 *
19 * Props: url, the suggested-reviewers endpoint with owner, repo and number
20 * already in it. Built by the page, so this component does not know how a pull
21 * request is addressed.
22 */
23const fetchUrl = String(url ?? '')
24</script>
25
26<details class="panel side-panel suggest-box" data-suggested-reviewers data-url="">
27 <summary class="side-heading suggest-summary">Suggested reviewers</summary>
28
29 {{--
30 The sentence is the closed state, the no-JavaScript state, and the loading
31 state's starting point, all three. A reader with scripts off who opens the
32 panel gets an honest sentence rather than a spinner that never stops.
33 --}}
34 <p class="muted side-empty" data-suggest-status>Who has worked on these files.</p>
35 <ul class="suggest-list" data-suggest-list hidden></ul>
36</details>
37
38<script type="module">
39 const box = document.querySelector('[data-suggested-reviewers]')
40
41 if (box) {
42 const status = box.querySelector('[data-suggest-status]')
43 const list = box.querySelector('[data-suggest-list]')
44 let asked = false
45
46 box.addEventListener('toggle', async () => {
47 // Only the first open fetches. The answer does not change while the page
48 // is up, and the git log behind it is not free.
49 if (!box.open || asked)
50 return
51
52 asked = true
53 status.textContent = 'Looking through the history…'
54
55 try {
56 // A bare fetch, no options, and that is load-bearing: stx's client
57 // bridge seeds any identifier a client script shares with the server
58 // scope into the page, and this page's server scope has a `headers`
59 // binding holding the request headers - cookie included. Writing
60 // `{ headers: ... }` here serialized the reader's session token into
61 // the HTML. The endpoint answers JSON without being asked.
62 const answer = await fetch(box.dataset.url)
63 if (!answer.ok)
64 throw new Error(String(answer.status))
65
66 const body = await answer.json()
67 const suggestions = Array.isArray(body?.suggestions) ? body.suggestions : []
68
69 if (suggestions.length === 0) {
70 status.textContent = 'Nobody else has history in these files.'
71 return
72 }
73
74 // textContent throughout: a handle is user-chosen text, and building
75 // markup out of one would hand every user a script slot in every
76 // sidebar that suggests them.
77 for (const suggestion of suggestions) {
78 const item = document.createElement('li')
79 item.className = 'suggest-item'
80
81 const who = document.createElement('a')
82 who.className = 'mono suggest-handle'
83 who.href = `/${suggestion.handle}`
84 who.textContent = suggestion.handle
85
86 const why = document.createElement('span')
87 why.className = 'muted suggest-reason'
88 why.textContent = suggestion.reason
89
90 item.append(who, why)
91 list.append(item)
92 }
93
94 status.hidden = true
95 list.hidden = false
96 }
97 catch {
98 // Nothing to offer is the same outcome as not being able to ask.
99 status.textContent = 'Suggestions are unavailable.'
100 }
101 })
102 }
103</script>
104
105<style>
106 .suggest-summary {
107 cursor: pointer;
108 list-style: none;
109 display: flex;
110 align-items: center;
111 gap: 6px;
112 }
113
114 .suggest-summary::-webkit-details-marker { display: none; }
115
116 {{--
117 A drawn marker rather than the native one: the native triangle renders at
118 text size beside a 12px uppercase heading and dwarfs it.
119 --}}
120 .suggest-summary::before {
121 content: '';
122 width: 7px;
123 height: 7px;
124 border-right: 1.5px solid var(--muted);
125 border-bottom: 1.5px solid var(--muted);
126 transform: rotate(-45deg);
127 transition: transform 120ms ease;
128 flex: none;
129 }
130
131 .suggest-box[open] .suggest-summary::before { transform: rotate(45deg); }
132
133 .suggest-list {
134 margin-top: 9px;
135 display: flex;
136 flex-direction: column;
137 gap: 7px;
138 }
139
140 .suggest-item {
141 display: flex;
142 flex-direction: column;
143 gap: 1px;
144 font-size: 13.5px;
145 }
146
147 .suggest-handle { width: fit-content; }
148
149 .suggest-reason { font-size: 12.5px; }
150</style>