@leftoff/cli 0.8.8 → 0.8.9

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.
package/ui/prs.js CHANGED
@@ -89,11 +89,13 @@ function sessionLinks(r, opts) {
89
89
 
90
90
  // One PR row on the PRs tab. 0.8.5: tap the row to expand it in place
91
91
  // (`open`, with `detail` as /v1/prs/detail answered: { loading } | { ok, detail } | { ok: false, code }).
92
- export function prRowHtml(r, { team = false, now = Date.now(), mayEdit = true, sessionAction = null, showMachine = false, open = false, detail = null } = {}) {
92
+ export function prRowHtml(r, { team = false, now = Date.now(), mayEdit = true, sessionAction = null, showMachine = false, open = false, detail = null, me = null } = {}) {
93
93
  const p = r.pr || {}
94
94
  const url = safeUrl(p.url)
95
+ // 0.8.9: what the checks and review chips don't already say (a conflict, your review)
96
+ const bwhy = r.bucketWhy !== undefined ? r.bucketWhy : prBucket(p, me).why
95
97
  const line1 = '<span class="pr-num">#' + esc(p.number) + '</span>' + stateHtml(p.state) + checksHtml(p.checks) +
96
- (r.attention && r.attention !== 'Checks failing' ? '<span class="pr-att">' + esc(r.attention) + '</span>' : p.review && REVIEW[p.review] && (p.state === 'open' || p.state === 'draft') ? '<span class="pr-review ' + esc(p.review) + '">' + REVIEW[p.review] + '</span>' : '')
98
+ (bwhy === 'Merge conflict' || bwhy === 'Your review' ? '<span class="pr-att">' + esc(bwhy) + '</span>' : p.review && REVIEW[p.review] && (p.state === 'open' || p.state === 'draft') ? '<span class="pr-review ' + esc(p.review) + '">' + REVIEW[p.review] + '</span>' : '')
97
99
  const when = p.state === 'merged' && p.mergedAt ? 'merged ' + shortAgo(p.mergedAt, now) + ' ago' : p.updatedAt ? 'updated ' + shortAgo(p.updatedAt, now) + ' ago' : ''
98
100
  const meta = team
99
101
  ? [p.repo, p.author ? '@' + p.author : '', p.branch || '', when].filter(Boolean)
@@ -134,7 +136,7 @@ export function prDetailHtml(p, st, { now = Date.now() } = {}) {
134
136
  return '<li class="' + esc(c.state) + '"><span class="pd-ic" aria-hidden="true">' + (c.state === 'failed' ? '✕' : '◌') + '</span>' + (u ? '<a href="' + esc(u) + '" target="_blank" rel="noopener noreferrer">' + name + ' ↗</a>' : name) + '<span class="pd-st">' + (c.state === 'failed' ? 'failed' : 'running') + '</span></li>'
135
137
  }).join('') + '</ul>' : '') + (passed ? '<div class="pd-pass">✓ ' + passed + (bad.length ? ' passed' : passed === 1 ? ' check passed' : ' checks passed') + '</div>' : '') + '</div>'
136
138
  }
137
- const rv = REVIEW[d.review] || (d.state === 'open' ? 'No review yet' : '')
139
+ const rv = REVIEW[d.review] || (d.review === 'none' && d.state === 'open' ? 'No review required' : d.state === 'open' ? 'No review yet' : '')
138
140
  const who = (d.reviews || []).map((r) => esc('@' + r.author) + ' ' + esc(REVIEW_STATE[r.state] || r.state))
139
141
  if (rv || who.length || (d.reviewRequested || []).length) {
140
142
  h += '<div class="pd-sec"><div class="pd-h">Review</div><div class="pd-review">' + (rv ? '<span class="pr-review ' + esc(d.review || '') + '">' + esc(rv) + '</span>' : '') +
@@ -154,20 +156,63 @@ export function prDetailHtml(p, st, { now = Date.now() } = {}) {
154
156
  return h + '</div>'
155
157
  }
156
158
 
159
+ // ---------------------------------------------------------------- 0.8.9: buckets
160
+
161
+ // What an open PR is waiting on, in one of four buckets (idea: "merge ready /
162
+ // needs fix / waiting on others"), from the fields gh already gives us:
163
+ // review Your review: you're a requested reviewer and it isn't yours
164
+ // fix Needs fix: a check failing, changes requested, or a merge conflict
165
+ // ready Merge ready: not a draft, approved or no review required, every
166
+ // check passed (or none), and GitHub says it merges cleanly
167
+ // waiting Waiting on others: everything else (review pending, checks
168
+ // running, a draft, or anything we can't tell yet)
169
+ // Anything unknown (no mergeable from an older daemon, GitHub still working
170
+ // it out, no check list) lands in waiting, never in ready. Merged and closed
171
+ // PRs keep their state. -> { bucket, why }
172
+ export const PR_BUCKETS = [['review', 'Your review'], ['fix', 'Needs fix'], ['ready', 'Merge ready'], ['waiting', 'Waiting on others'], ['merged', 'Merged'], ['closed', 'Closed']]
173
+ const sameUser = (a, b) => !!a && !!b && String(a).toLowerCase() === String(b).toLowerCase()
174
+ export function prBucket(pr, me = null) {
175
+ if (!pr || typeof pr !== 'object') return { bucket: 'waiting', why: null }
176
+ if (pr.state === 'merged') return { bucket: 'merged', why: null }
177
+ if (pr.state === 'closed') return { bucket: 'closed', why: null }
178
+ const ck = pr.checks && typeof pr.checks === 'object' ? pr.checks : null
179
+ if (me && !sameUser(pr.author, me) && (pr.reviewRequested || []).some((x) => sameUser(x, me))) return { bucket: 'review', why: 'Your review' }
180
+ if (ck && ck.failed > 0) return { bucket: 'fix', why: 'Checks failing' }
181
+ if (pr.review === 'changes_requested') return { bucket: 'fix', why: 'Changes requested' }
182
+ if (pr.mergeable === 'conflicting') return { bucket: 'fix', why: 'Merge conflict' }
183
+ if (pr.state === 'draft') return { bucket: 'waiting', why: 'Draft' }
184
+ if (pr.state !== 'open') return { bucket: 'waiting', why: null }
185
+ if (!ck || ck.unknown || !Number.isFinite(ck.total)) return { bucket: 'waiting', why: null }
186
+ if (ck.pending > 0) return { bucket: 'waiting', why: 'Checks running' }
187
+ if (pr.review === 'review_required') return { bucket: 'waiting', why: 'Review pending' }
188
+ if (pr.review !== 'approved' && pr.review !== 'none') return { bucket: 'waiting', why: null }
189
+ if (pr.mergeable !== 'mergeable') return { bucket: 'waiting', why: null }
190
+ return { bucket: 'ready', why: null }
191
+ }
192
+ // A row's bucket: what the daemon worked out, else (an older daemon) worked out here.
193
+ export function bucketOf(r, me = null) {
194
+ if (r && PR_BUCKETS.some(([k]) => k === r.bucket)) return r.bucket
195
+ return prBucket(r && r.pr, me).bucket
196
+ }
197
+
157
198
  // ---------------------------------------------------------------- 0.8.5: filters
158
199
 
159
- // The page's filter, from the hash (#/prs?view=team&repo=web&state=open&checks=failing&q=search).
160
- export const PR_STATES = [['attention', 'Needs attention'], ['open', 'Open'], ['draft', 'Draft'], ['merged', 'Merged'], ['closed', 'Closed']]
161
- export const PR_CHECKS = [['failing', 'Checks failing'], ['passing', 'Checks passing']]
200
+ // The page's filter, from the hash (#/prs?view=team&repo=web&state=ready&q=search).
201
+ // 0.8.9: `state` is a bucket (PR_BUCKETS). Links from 0.8.5 still open:
202
+ // state=attention or checks=failing -> Needs fix, state=draft -> Waiting on
203
+ // others, state=open and checks=passing -> no filter.
204
+ export const PR_STATES = PR_BUCKETS
162
205
  export const QUIET_DAYS = 14
206
+ const LEGACY_STATE = { attention: 'fix', draft: 'waiting' }
163
207
  const splitList = (v) => String(v || '').split(',').map((x) => x.trim()).filter(Boolean)
164
208
  export function readPrFilter(params) {
165
209
  const p = typeof params === 'string' ? new URLSearchParams(params.replace(/^.*\?/, '')) : (params || new URLSearchParams())
166
- const state = p.get('state'), checks = p.get('checks')
210
+ let state = p.get('state')
211
+ state = PR_BUCKETS.some(([k]) => k === state) ? state : LEGACY_STATE[state] || null
212
+ if (!state && p.get('checks') === 'failing') state = 'fix'
167
213
  return {
168
214
  repos: [...new Set(p.getAll('repo').flatMap(splitList))],
169
- state: PR_STATES.some(([k]) => k === state) ? state : null,
170
- checks: PR_CHECKS.some(([k]) => k === checks) ? checks : null,
215
+ state,
171
216
  q: String(p.get('q') || '').slice(0, 200),
172
217
  }
173
218
  }
@@ -176,11 +221,10 @@ export function prsHash(view, f = {}) {
176
221
  if (view === 'team') parts.push('view=team')
177
222
  if (f.repos && f.repos.length) parts.push('repo=' + f.repos.map((r) => encodeURIComponent(r).replace(/%2F/gi, '/')).join(','))
178
223
  if (f.state) parts.push('state=' + f.state)
179
- if (f.checks) parts.push('checks=' + f.checks)
180
224
  if (f.q && f.q.trim()) parts.push('q=' + encodeURIComponent(f.q.trim()))
181
225
  return '#/prs' + (parts.length ? '?' + parts.join('&') : '')
182
226
  }
183
- export const prFilterActive = (f) => !!(f && ((f.repos && f.repos.length) || f.state || f.checks || (f.q && f.q.trim())))
227
+ export const prFilterActive = (f) => !!(f && ((f.repos && f.repos.length) || f.state || (f.q && f.q.trim())))
184
228
 
185
229
  // Every row, flat: the daemon's `rows`, or an older daemon's groups.
186
230
  export function prRows(d) {
@@ -192,13 +236,8 @@ export function prRows(d) {
192
236
  }
193
237
  const lastAt = (pr) => Math.max(Date.parse(pr.updatedAt || '') || 0, Date.parse(pr.mergedAt || '') || 0)
194
238
  export const isQuiet = (r, now = Date.now()) => { const t = lastAt(r.pr || {}); return !!t && now - t >= QUIET_DAYS * 864e5 }
195
- export function prStateOf(r) { return r.attention && (r.pr.state === 'open' || r.pr.state === 'draft') ? 'attention' : r.pr.state }
196
- export function inPrState(r, state) { return !state || (state === 'attention' ? !!r.attention && (r.pr.state === 'open' || r.pr.state === 'draft') : r.pr.state === state) }
197
- export function inPrChecks(r, checks) {
198
- const c = r.pr.checks || {}
199
- if (!checks) return true
200
- return checks === 'failing' ? c.failed > 0 : c.total > 0 && !c.failed && !c.pending
201
- }
239
+ export function prStateOf(r, me = null) { return bucketOf(r, me) }
240
+ export function inPrState(r, state, me = null) { return !state || bucketOf(r, me) === state }
202
241
  export function inPrSearch(r, q) {
203
242
  const words = String(q || '').toLowerCase().split(/\s+/).filter(Boolean)
204
243
  if (!words.length) return true
@@ -215,17 +254,18 @@ export function inPrRepos(r, repos, labels = null) {
215
254
  const key = r.pr.repo || 'no-repo'
216
255
  return repos.some((t) => repoMatches({ repoKey: key, repoLabel: labels ? labels.get(key) : null }, t))
217
256
  }
218
- export function filterPrRows(rows, f = {}, { skip = null } = {}) {
257
+ export function filterPrRows(rows, f = {}, { skip = null, me = null } = {}) {
219
258
  const labels = labelRepos(rows.map((r) => r.pr.repo || 'no-repo'))
220
- return rows.filter((r) => (skip === 'repos' || inPrRepos(r, f.repos, labels)) && (skip === 'state' || inPrState(r, f.state)) && (skip === 'checks' || inPrChecks(r, f.checks)) && inPrSearch(r, f.q))
259
+ return rows.filter((r) => (skip === 'repos' || inPrRepos(r, f.repos, labels)) && (skip === 'state' || inPrState(r, f.state, me)) && inPrSearch(r, f.q))
221
260
  }
222
- // Groups, newest first in each; anything with no activity for 14+ days in Quiet.
223
- export function groupPrRows(rows, { now = Date.now() } = {}) {
224
- const g = Object.fromEntries(PR_STATES.map(([k]) => [k, []]))
261
+ // Groups (0.8.9: the buckets), newest first in each; anything with no
262
+ // activity for 14+ days in Quiet.
263
+ export function groupPrRows(rows, { now = Date.now(), me = null } = {}) {
264
+ const g = Object.fromEntries(PR_BUCKETS.map(([k]) => [k, []]))
225
265
  const quiet = []
226
- for (const r of rows) { if (isQuiet(r, now)) quiet.push(r); else (g[prStateOf(r)] || (g[prStateOf(r)] = [])).push(r) }
266
+ for (const r of rows) { if (isQuiet(r, now)) quiet.push(r); else g[bucketOf(r, me)].push(r) }
227
267
  const by = (a, b) => lastAt(b.pr) - lastAt(a.pr)
228
- const out = PR_STATES.map(([key, label]) => ({ key, label, rows: g[key].sort(by) })).filter((x) => x.rows.length)
268
+ const out = PR_BUCKETS.map(([key, label]) => ({ key, label, rows: g[key].sort(by) })).filter((x) => x.rows.length)
229
269
  return { groups: out, quiet: quiet.sort(by) }
230
270
  }
231
271
 
@@ -238,12 +278,13 @@ function fchip({ on, count, label, attrs, cls = '' }) {
238
278
 
239
279
  // The chip rows and the grouped list (repainted as you type in the search box).
240
280
  export function prsBodyHtml(d, { view = 'mine', now = Date.now(), mayEdit = true, sessionAction = null, filter = {}, open = null, details = null, quietOpen = false } = {}) {
241
- const f = { repos: [], state: null, checks: null, q: '', ...filter }
281
+ const f = { repos: [], state: null, q: '', ...filter }
242
282
  const all = prRows(d)
283
+ const me = d.me || null
243
284
  let h = ''
244
285
  if (all.length) {
245
286
  // Repos: All, then by latest activity; a chosen one always shows, first and removable.
246
- const repoCount = filterPrRows(all, f, { skip: 'repos' })
287
+ const repoCount = filterPrRows(all, f, { skip: 'repos', me })
247
288
  let chips = prRepoChips(all, { countIn: repoCount })
248
289
  for (const tok of f.repos) if (!chips.some((c) => chipSelected([tok], c))) chips.push({ key: tok, label: tok, count: 0, total: 0, last: 0 })
249
290
  chips = orderSelectedFirst(chips, f.repos)
@@ -251,23 +292,26 @@ export function prsBodyHtml(d, { view = 'mine', now = Date.now(), mayEdit = true
251
292
  h += chipRow('Filter by repo', [fchip({ on: !f.repos.length, count: repoCount.length, label: 'All', cls: 'all', attrs: 'data-action="prs-filter" data-k="repo" data-v=""' })]
252
293
  .concat(chips.map((c) => fchip({ on: chipSelected(f.repos, c), count: c.count, label: c.label, attrs: 'data-action="prs-filter" data-k="repo" data-v="' + esc(c.label) + '" data-key="' + esc(c.key) + '" title="' + esc(c.key + ' · shift- or ⌘-click to pick several') + '"' }))), 'prs-repos')
253
294
  }
254
- const stCount = filterPrRows(all, f, { skip: 'state' }), ckCount = filterPrRows(all, f, { skip: 'checks' })
255
- const sts = PR_STATES.map(([k, l]) => ({ k: 'state', v: k, l, n: stCount.filter((r) => inPrState(r, k)).length, on: f.state === k }))
256
- const cks = PR_CHECKS.map(([k, l]) => ({ k: 'checks', v: k, l, n: ckCount.filter((r) => inPrChecks(r, k)).length, on: f.checks === k }))
257
- const both = [...sts, ...cks]
258
- const ordered = [...both.filter((x) => x.on), ...both.filter((x) => !x.on && (x.n || x.k === 'state'))]
259
- h += chipRow('Filter by state and checks', ordered.map((x) => fchip({ on: x.on, count: x.n, label: x.l, cls: x.k === 'checks' ? 'ck' : '', attrs: 'data-action="prs-filter" data-k="' + x.k + '" data-v="' + x.v + '"' })), 'prs-states')
295
+ // 0.8.9: one row of buckets. The four open ones always show (dimmed at 0,
296
+ // after the rest), Merged and Closed only when there are some; a chosen
297
+ // one comes first.
298
+ const stCount = filterPrRows(all, f, { skip: 'state', me })
299
+ const bs = PR_BUCKETS.map(([k, l]) => ({ v: k, l, n: stCount.filter((r) => bucketOf(r, me) === k).length, on: f.state === k }))
300
+ const shown = bs.filter((x) => !x.on && (x.n || ['review', 'fix', 'ready', 'waiting'].includes(x.v)) && !(x.v === 'review' && !x.n && !me))
301
+ // the ones with PRs lead, so on a phone the empty ones are what scrolls off
302
+ const ordered = [...bs.filter((x) => x.on), ...shown.filter((x) => x.n), ...shown.filter((x) => !x.n)]
303
+ h += chipRow('Filter by what each PR is waiting on', ordered.map((x) => fchip({ on: x.on, count: x.n, label: x.l, cls: 'b-' + x.v, attrs: 'data-action="prs-filter" data-k="state" data-v="' + x.v + '"' })), 'prs-states prs-buckets')
260
304
  }
261
- const rows = filterPrRows(all, f)
262
- const { groups, quiet } = groupPrRows(rows, { now })
305
+ const rows = filterPrRows(all, f, { me })
306
+ const { groups, quiet } = groupPrRows(rows, { now, me })
263
307
  const showMachine = !!d.multiMachine
264
- const rowOpts = (r) => ({ team: view === 'team', now, mayEdit, sessionAction, showMachine, open: !!(open && open.has(r.pr.url)), detail: details ? details[r.pr.url] : null })
308
+ const rowOpts = (r) => ({ team: view === 'team', now, mayEdit, sessionAction, showMachine, me, open: !!(open && open.has(r.pr.url)), detail: details ? details[r.pr.url] : null })
265
309
  if (!all.length) {
266
310
  h += '<div class="empty">' + (d.loading ? 'Loading pull requests…' : view === 'team' ? (d.repos && d.repos.length ? 'No open PRs in ' + esc(d.repos.join(', ')) + '.' : 'None of your sessions are in a GitHub repo yet.') : 'No PRs linked to your sessions yet. They link on their own when a session runs gh pr create, works on a branch with an open PR, or you paste a PR’s link in it.') + '</div>'
267
311
  } else if (!rows.length) {
268
312
  h += '<div class="empty">No PRs match. <button type="button" class="linkish" data-action="prs-clear">Clear filters</button></div>'
269
313
  } else {
270
- for (const g of groups) h += '<section class="prgroup' + (g.key === 'attention' ? ' attention' : '') + '" data-group="' + g.key + '"><h2>' + esc(g.label) + ' <span class="count">' + g.rows.length + '</span></h2>' + g.rows.map((r) => prRowHtml(r, rowOpts(r))).join('') + '</section>'
314
+ for (const g of groups) h += '<section class="prgroup b-' + g.key + (g.key === 'fix' || g.key === 'review' ? ' attention' : '') + '" data-group="' + g.key + '"><h2>' + esc(g.label) + ' <span class="count">' + g.rows.length + '</span></h2>' + g.rows.map((r) => prRowHtml(r, rowOpts(r))).join('') + '</section>'
271
315
  if (quiet.length) h += '<details class="prgroup quiet" data-group="quiet"' + (quietOpen || !groups.length ? ' open' : '') + '><summary><h2>Quiet <span class="count">' + quiet.length + '</span> <span class="faint qhint">no activity for ' + QUIET_DAYS + '+ days</span></h2></summary>' + quiet.map((r) => prRowHtml(r, rowOpts(r))).join('') + '</details>'
272
316
  }
273
317
  if (view === 'team' && (d.failed || []).length) h += '<div class="hint">Couldn’t load ' + esc(d.failed.join(', ')) + ' right now.</div>'