@preventive/triage 1.0.0-alpha.48 → 1.0.0-alpha.49

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/out/view.js CHANGED
@@ -1,4 +1,4 @@
1
- import"./chunk-R3NZFXVR.js";import{a as e,b as t,e as n,f as r,g as i,i as a}from"./chunk-MOACFRJ4.js";import{$b as o,$c as s,Ab as c,Ac as l,Ba as u,Bb as d,Bc as f,Bd as p,C as m,Cc as h,Cd as g,D as ee,Dc as te,Dd as ne,Ec as re,Ed as ie,Ee as ae,Fc as _,Fd as oe,Fe as se,G as ce,Ge as le,H as ue,He as de,I as fe,Ib as pe,Ie as me,K as he,Kb as ge,Kd as _e,L as v,Ld as ve,Mb as ye,N as be,Nc as xe,Ob as Se,Oc as Ce,P as we,Pb as Te,Q as Ee,Qb as De,R as Oe,Rb as ke,S as Ae,Sb as je,T as y,Tb as Me,Tc as Ne,Ua as Pe,Ub as Fe,Uc as Ie,V as Le,Va as Re,Vb as ze,Vc as Be,Wa as Ve,Wc as He,Xa as Ue,Xb as b,Xc as We,Ya as Ge,Yb as Ke,Yc as qe,Zb as Je,Zc as Ye,_b as Xe,ac as Ze,ad as Qe,bc as $e,bd as et,be as tt,c as nt,cb as rt,cc as x,ce as it,d as S,db as at,dc as ot,de as st,e as ct,eb as C,ec as lt,f as ut,fc as dt,fe as ft,gb as pt,gc as w,ge as mt,gf as ht,h as gt,hb as T,hc as _t,he as vt,hf as yt,i as bt,ia as xt,ib as St,ic as Ct,ie as E,if as wt,ja as Tt,jb as Et,jc as Dt,je as Ot,jf as D,k as kt,ka as At,kc as jt,ke as Mt,kf as Nt,l as Pt,la as Ft,lb as It,lc as Lt,le as Rt,lf as zt,m as Bt,ma as Vt,mb as Ht,mc as Ut,me as O,na as Wt,nc as Gt,nd as Kt,ne as qt,o as Jt,oa as Yt,oc as Xt,oe as Zt,of as Qt,p as $t,pc as en,pe as tn,pf as nn,q as rn,qc as an,qe as on,qf as sn,r as cn,ra as ln,rc as un,rf as dn,s as fn,sc as k,sf as pn,t as mn,tc as A,te as hn,ue as gn,v as _n,vb as vn,ve as yn,w as j,wb as bn,x as M,xb as xn,xc as Sn,y as Cn,yb as wn,yc as Tn,zb as En,zc as Dn}from"./chunk-PENGGKX7.js";import"./chunk-JCIIBLKR.js";import"./chunk-VH4XMZIW.js";import{d as On}from"./chunk-O6M574JI.js";import{D as kn,L as An,N as jn,T as Mn,Y as Nn,_ as N,b as Pn,n as Fn,t as In,u as Ln,v as Rn,x as zn,y as Bn}from"./chunk-6LNQTKAV.js";import{f as Vn,g as Hn}from"./chunk-7VAWINQF.js";import"./chunk-NRPC74AM.js";import"./chunk-7BKKYVWR.js";import{f as P}from"./chunk-7C7DWL7I.js";import"./chunk-PNHBZGDD.js";import"./chunk-VFWMQ3LG.js";import{C as Un,D as Wn,J as Gn,K as Kn,M as qn,S as Jn,V as Yn,W as Xn,Z as Zn,_ as Qn,a as F,b as $n,d as I,e as L,ea as er,f as tr,ga as nr,ia as rr,j as ir,r as ar,s as or,u as sr}from"./chunk-C2AVODGZ.js";import{g as cr}from"./chunk-IO7JTZ6O.js";import"./chunk-WCPJDAYA.js";import"./chunk-IYPY47MC.js";import{a as lr,d as ur}from"./chunk-PWFUOLZK.js";import{a as dr,g as fr,h as pr,n as mr,o as hr,q as gr}from"./chunk-BC2UCLFJ.js";import{B as _r,I as vr,l as yr}from"./chunk-SXV2T5C4.js";import{d as br,i as xr,k as Sr,l as Cr,q as wr,w as Tr}from"./chunk-WFYLMF6Z.js";import"./chunk-C4Z6ZBXV.js";import{a as R,b as Er,c as z,g as B,i as Dr,j as V}from"./chunk-2XUEZHIP.js";import"./chunk-TUII34K2.js";import"./chunk-OOJM4CTU.js";var Or={attach:{title:`Synced triage on report attach`,intro:`have triage from your workspace that disagrees with your local view on`,trailingNote:`Other findings in this report whose chain value didn't conflict were applied automatically; this list only covers disagreements. Pick which side to keep.`,importedSideLabel:`Apply from chain`,applyButton:`Apply`},chain:{title:`Triage conflict from peer update`,intro:`were changed by a peer (or by another tab) in a way that disagrees with your local triage on`,trailingNote:`Other findings whose remote value didn't conflict were applied automatically; this list only covers disagreements. Pick which side to keep for each — a "keep current" pick re-publishes your local value to the chain on your next save.`,importedSideLabel:`Apply from chain`,applyButton:`Apply`}};function kr(){d(async(e,t,n)=>{let r=we(e);try{return await vn(e,r,Or[n]??Or.attach)}catch{let t=e.length;return alert(`${n===`attach`?`A report attach`:`A workspace sync`} had ${t} triage disagreement${t===1?``:`s`} with your local view. Kept your local values because another dialog is open. ${n===`attach`?`Detach and re-attach the report to pick again.`:`Your local values will re-publish to the chain on the next change.`}`),null}})}var Ar=class extends It{static properties={retry:{type:Boolean},_value:{state:!0}};constructor(){super(),this.retry=!1,this._value=``}_finish(e){this._value=``,super._finish(e)}_onCancel=()=>this._finish(null);_onInput=e=>{this._value=e.target.value};_onSubmit=()=>{let e=this._value??``;e&&this._finish(e)};_onKeydown=e=>{e.key===`Enter`&&(e.preventDefault(),this._onSubmit())};render(){let e=(this._value??``).length>0,t=this.retry?`That password didn't match. Try again, or cancel to leave the workspace unsynced for now.`:`This sync server requires a password before it accepts a new workspace. Enter it to create the workspace on the server.`;return z`<dialog @close="${this._onClose}"><header><h3>Sync server password</h3></header><p class="nwd-intro">${t}</p><input type="password" class="nwd-input" placeholder="Password" autocomplete="off" data-1p-ignore="true" data-lpignore="true" data-bwignore="true" data-form-type="other" name="dv-sync-auth" maxlength="4096" .value="${this._value}" @input="${this._onInput}" @keydown="${this._onKeydown}"><footer class="nwd-actions"><span class="nwd-spacer"></span> <button type="button" @click="${this._onCancel}">Cancel</button> <button type="button" class="primary" ?disabled="${!e}" @click="${this._onSubmit}">${this.retry?`Retry`:`Authenticate`}</button></footer></dialog>`}};customElements.define(`sync-auth-dialog`,Ar);function jr({retry:e=!1}={}){return Ht(`sync-auth-dialog`,{retry:!!e})}function Mr(){c(async({retry:e})=>{try{return await jr({retry:e})}catch(e){return console.warn(`Sync auth dialog failed to open:`,e),null}})}var Nr=`https://triage.space/`,Pr=[{hostname:`deepaudit.dev`,pathPrefix:`/`,supportsSync:!0},{hostname:`chalker.github.io`,pathPrefix:`/deepview/`,supportsSync:!1}];function Fr(){for(let e of Pr)if(location.hostname===e.hostname&&location.pathname.startsWith(e.pathPrefix))return e;return null}var Ir=[`deepview.triage`,`deepview.triage.pending`],Lr=`deepview.repoUrls`;function Rr(){try{for(let e of Ir)if(localStorage.getItem(e))return!0}catch{return!0}try{let e=localStorage.getItem(Lr);if(e){let t=JSON.parse(e);if(t&&typeof t==`object`&&Object.keys(t).length>0)return!0}}catch{return!0}return!1}async function zr(){if(Rr())return!0;let e=[];try{e=await Cn()}catch{return!0}if(e.length>0)return!0;try{if(v().length>0)return!0}catch{return!0}try{return await m()}catch{return!0}}var Br=class extends V{static properties={supportsSync:{attribute:!1}};createRenderRoot(){return this}constructor(){super(),this.supportsSync=!1}firstUpdated(){let e=this.querySelector(`dialog`);e&&e.showModal();let t=this.querySelector(`button.om-stay`);t&&t.focus()}_finish(e){if(this._settled)return;this._settled=!0;let t=this.querySelector(`dialog`);t&&t.close(),this.dispatchEvent(new CustomEvent(`resolve`,{detail:e}))}_onClose=()=>this._finish(!1);_onStay=()=>this._finish(!1);_onMigrate=()=>this._finish(!0);render(){let e=()=>z`<a class="om-link" href="${Nr}" target="_blank" rel="noopener noreferrer">${Nr}</a>`;return z`<dialog class="origin-migration-dialog" @close="${this._onClose}"><header class="om-head"><h3>This deepview build has moved</h3><p><code>${location.host}${location.pathname}</code> has moved to ${e()}.</p></header><section class="om-body"><p>Workspaces and reports don't migrate automatically — they live in this origin's browser storage (OPFS where available, localStorage as a fallback) and have to be re-imported on ${e()}.</p>${this.supportsSync?z`<p>For each workspace, use the <strong>Export workspace</strong> button in the sidebar to download a <code>.gz</code> bundle (workspace + its reports + its triage), then drop that file on ${e()}. Workspaces that had sync configured will pick their triage state back up from the sync server once you re-import them with the same key.</p><p>For triage attached to loose reports (or as a top-up after re-importing workspaces), call <code>DeepView.export()</code> in the browser console — that downloads just the triage entries plus saved repo URLs, no reports or workspaces.</p>`:z`<p>This build has no sync layer. For each workspace, use the <strong>Export workspace</strong> button in the sidebar to download a <code>.gz</code> bundle (workspace + its reports + its triage), then drop that file on ${e()}.</p><p>For triage attached to loose reports, call <code>DeepView.export()</code> in the browser console — that downloads just the triage entries plus saved repo URLs, no reports or workspaces. Reports themselves need to be re-dropped from their original JSON on the new origin.</p>`}</section><footer class="om-actions"><button type="button" class="om-stay" @click="${this._onStay}">Keep editing on ${location.host}</button> <button type="button" class="primary om-go" @click="${this._onMigrate}">Switch to triage.space</button></footer></dialog>`}};customElements.define(`origin-migration-dialog`,Br);function Vr(e){return new Promise(t=>{let n=document.createElement(`origin-migration-dialog`);n.supportsSync=e.supportsSync,n.addEventListener(`resolve`,e=>{n.remove(),t(!!e.detail)}),document.body.append(n)})}async function Hr(){let e=Fr();return e?await zr()?await Vr(e)?(location.assign(Nr),!0):!1:(location.replace(Nr),!0):!1}var H=null,Ur=!1,Wr=null;function Gr(e){if(!e)return`Could not unlock. Try again.`;switch(e.name??``){case`NotAllowedError`:case`AbortError`:return``;case`SecurityError`:return`The site identity does not match the one this passkey was registered for. If you opened DeepView through a different URL (different subdomain, different port), reload via the original URL.`;case`InvalidStateError`:return`The passkey state on this device is inconsistent. Try reloading the page or removing and re-creating the passkey.`;case`NotSupportedError`:return`This browser does not support the WebAuthn PRF extension this site needs. Try a recent Chrome, Safari, or Edge with a platform authenticator.`;case`NetworkError`:return`A network error prevented the passkey from completing. Check your connection and try again.`;case`UnknownError`:return`The authenticator returned an unknown error. Try again, or use a different passkey.`;default:{let t=e.message??String(e);return t.length>200?t.slice(0,197)+`…`:t}}}function Kr(){if(H)return H;H=document.createElement(`div`),H.id=`lock-overlay`,H.setAttribute(`role`,`dialog`),H.setAttribute(`aria-modal`,`true`),H.setAttribute(`aria-label`,`Unlock DeepView`),Dr(z`<div class="lock-overlay-card"><div class="lock-overlay-icon" aria-hidden="true"><svg viewBox="0 0 32 32" width="40" height="40" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="7" y="15" width="18" height="12" rx="1.5"/><path d="M11 15v-4a5 5 0 0 1 10 0v4"/></svg></div><h2 class="lock-overlay-title">DeepView is locked</h2><p class="lock-overlay-body">Your triage data and reports are encrypted with a passkey on this device. Unlock to continue.</p><p class="lock-overlay-error" hidden></p><button type="button" class="lock-overlay-button primary">Unlock with passkey</button><p class="lock-overlay-footer">Lost your passkey? <a href="#" class="lock-overlay-wipe">Wipe local data and start over</a></p></div>`,H);let e=H.querySelector(`button`),t=H.querySelector(`.lock-overlay-error`);return e.addEventListener(`click`,async()=>{if(!Ur&&qr()){Ur=!0,t.hidden=!0,t.textContent=``,e.disabled=!0,e.textContent=`Waiting…`,Wr=new AbortController;try{await bt({signal:Wr.signal})}catch(e){let n=Gr(e);t.textContent=n,t.hidden=!n}finally{Ur=!1,Wr=null,e.disabled=!1,e.textContent=`Unlock with passkey`}}}),H.querySelector(`.lock-overlay-wipe`).addEventListener(`click`,async e=>{if(e.preventDefault(),!Ur&&qr()&&confirm(`Wipe ALL local DeepView data and start over?
1
+ import"./chunk-R3NZFXVR.js";import{a as e,b as t,e as n,f as r,g as i,i as a}from"./chunk-ULIF7A7N.js";import{$b as o,$c as s,Ab as c,Ac as l,Ba as u,Bb as d,Bc as f,Bd as p,C as m,Cc as h,Cd as g,D as ee,Dc as te,Dd as ne,Ec as re,Ed as ie,Ee as ae,Fc as _,Fd as oe,Fe as se,G as ce,Ge as le,H as ue,He as de,I as fe,Ib as pe,Ie as me,K as he,Kb as ge,Kd as _e,L as v,Ld as ve,Mb as ye,N as be,Nc as xe,Ob as Se,Oc as Ce,P as we,Pb as Te,Q as Ee,Qb as De,R as Oe,Rb as ke,S as Ae,Sb as je,T as y,Tb as Me,Tc as Ne,Ua as Pe,Ub as Fe,Uc as Ie,V as Le,Va as Re,Vb as ze,Vc as Be,Wa as Ve,Wc as He,Xa as Ue,Xb as b,Xc as We,Ya as Ge,Yb as Ke,Yc as qe,Zb as Je,Zc as Ye,_b as Xe,ac as Ze,ad as Qe,bc as $e,bd as et,be as tt,c as nt,cb as rt,cc as x,ce as it,d as S,db as at,dc as ot,de as st,e as ct,eb as C,ec as lt,f as ut,fc as dt,fe as ft,gb as pt,gc as w,ge as mt,gf as ht,h as gt,hb as T,hc as _t,he as vt,hf as yt,i as bt,ia as xt,ib as St,ic as Ct,ie as E,if as wt,ja as Tt,jb as Et,jc as Dt,je as Ot,jf as D,k as kt,ka as At,kc as jt,ke as Mt,kf as Nt,l as Pt,la as Ft,lb as It,lc as Lt,le as Rt,lf as zt,m as Bt,ma as Vt,mb as Ht,mc as Ut,me as O,na as Wt,nc as Gt,nd as Kt,ne as qt,o as Jt,oa as Yt,oc as Xt,oe as Zt,of as Qt,p as $t,pc as en,pe as tn,pf as nn,q as rn,qc as an,qe as on,qf as sn,r as cn,ra as ln,rc as un,rf as dn,s as fn,sc as k,sf as pn,t as mn,tc as A,te as hn,ue as gn,v as _n,vb as vn,ve as yn,w as j,wb as bn,x as M,xb as xn,xc as Sn,y as Cn,yb as wn,yc as Tn,zb as En,zc as Dn}from"./chunk-JEAUAJVF.js";import"./chunk-JCIIBLKR.js";import"./chunk-VH4XMZIW.js";import{d as On}from"./chunk-O6M574JI.js";import{D as kn,L as An,N as jn,T as Mn,Y as Nn,_ as N,b as Pn,n as Fn,t as In,u as Ln,v as Rn,x as zn,y as Bn}from"./chunk-6LNQTKAV.js";import{f as Vn,g as Hn}from"./chunk-7VAWINQF.js";import"./chunk-NRPC74AM.js";import"./chunk-7BKKYVWR.js";import{f as P}from"./chunk-7C7DWL7I.js";import"./chunk-PNHBZGDD.js";import"./chunk-VFWMQ3LG.js";import{C as Un,D as Wn,J as Gn,K as Kn,M as qn,S as Jn,V as Yn,W as Xn,Z as Zn,_ as Qn,a as F,b as $n,d as I,e as L,ea as er,f as tr,ga as nr,ia as rr,j as ir,r as ar,s as or,u as sr}from"./chunk-C2AVODGZ.js";import{g as cr}from"./chunk-IO7JTZ6O.js";import"./chunk-WCPJDAYA.js";import"./chunk-IYPY47MC.js";import{a as lr,d as ur}from"./chunk-PWFUOLZK.js";import{a as dr,g as fr,h as pr,n as mr,o as hr,q as gr}from"./chunk-BC2UCLFJ.js";import{B as _r,I as vr,l as yr}from"./chunk-SXV2T5C4.js";import{d as br,i as xr,k as Sr,l as Cr,q as wr,w as Tr}from"./chunk-WFYLMF6Z.js";import"./chunk-C4Z6ZBXV.js";import{a as R,b as Er,c as z,g as B,i as Dr,j as V}from"./chunk-2XUEZHIP.js";import"./chunk-TUII34K2.js";import"./chunk-OOJM4CTU.js";var Or={attach:{title:`Synced triage on report attach`,intro:`have triage from your workspace that disagrees with your local view on`,trailingNote:`Other findings in this report whose chain value didn't conflict were applied automatically; this list only covers disagreements. Pick which side to keep.`,importedSideLabel:`Apply from chain`,applyButton:`Apply`},chain:{title:`Triage conflict from peer update`,intro:`were changed by a peer (or by another tab) in a way that disagrees with your local triage on`,trailingNote:`Other findings whose remote value didn't conflict were applied automatically; this list only covers disagreements. Pick which side to keep for each — a "keep current" pick re-publishes your local value to the chain on your next save.`,importedSideLabel:`Apply from chain`,applyButton:`Apply`}};function kr(){d(async(e,t,n)=>{let r=we(e);try{return await vn(e,r,Or[n]??Or.attach)}catch{let t=e.length;return alert(`${n===`attach`?`A report attach`:`A workspace sync`} had ${t} triage disagreement${t===1?``:`s`} with your local view. Kept your local values because another dialog is open. ${n===`attach`?`Detach and re-attach the report to pick again.`:`Your local values will re-publish to the chain on the next change.`}`),null}})}var Ar=class extends It{static properties={retry:{type:Boolean},_value:{state:!0}};constructor(){super(),this.retry=!1,this._value=``}_finish(e){this._value=``,super._finish(e)}_onCancel=()=>this._finish(null);_onInput=e=>{this._value=e.target.value};_onSubmit=()=>{let e=this._value??``;e&&this._finish(e)};_onKeydown=e=>{e.key===`Enter`&&(e.preventDefault(),this._onSubmit())};render(){let e=(this._value??``).length>0,t=this.retry?`That password didn't match. Try again, or cancel to leave the workspace unsynced for now.`:`This sync server requires a password before it accepts a new workspace. Enter it to create the workspace on the server.`;return z`<dialog @close="${this._onClose}"><header><h3>Sync server password</h3></header><p class="nwd-intro">${t}</p><input type="password" class="nwd-input" placeholder="Password" autocomplete="off" data-1p-ignore="true" data-lpignore="true" data-bwignore="true" data-form-type="other" name="dv-sync-auth" maxlength="4096" .value="${this._value}" @input="${this._onInput}" @keydown="${this._onKeydown}"><footer class="nwd-actions"><span class="nwd-spacer"></span> <button type="button" @click="${this._onCancel}">Cancel</button> <button type="button" class="primary" ?disabled="${!e}" @click="${this._onSubmit}">${this.retry?`Retry`:`Authenticate`}</button></footer></dialog>`}};customElements.define(`sync-auth-dialog`,Ar);function jr({retry:e=!1}={}){return Ht(`sync-auth-dialog`,{retry:!!e})}function Mr(){c(async({retry:e})=>{try{return await jr({retry:e})}catch(e){return console.warn(`Sync auth dialog failed to open:`,e),null}})}var Nr=`https://triage.space/`,Pr=[{hostname:`deepaudit.dev`,pathPrefix:`/`,supportsSync:!0},{hostname:`chalker.github.io`,pathPrefix:`/deepview/`,supportsSync:!1}];function Fr(){for(let e of Pr)if(location.hostname===e.hostname&&location.pathname.startsWith(e.pathPrefix))return e;return null}var Ir=[`deepview.triage`,`deepview.triage.pending`],Lr=`deepview.repoUrls`;function Rr(){try{for(let e of Ir)if(localStorage.getItem(e))return!0}catch{return!0}try{let e=localStorage.getItem(Lr);if(e){let t=JSON.parse(e);if(t&&typeof t==`object`&&Object.keys(t).length>0)return!0}}catch{return!0}return!1}async function zr(){if(Rr())return!0;let e=[];try{e=await Cn()}catch{return!0}if(e.length>0)return!0;try{if(v().length>0)return!0}catch{return!0}try{return await m()}catch{return!0}}var Br=class extends V{static properties={supportsSync:{attribute:!1}};createRenderRoot(){return this}constructor(){super(),this.supportsSync=!1}firstUpdated(){let e=this.querySelector(`dialog`);e&&e.showModal();let t=this.querySelector(`button.om-stay`);t&&t.focus()}_finish(e){if(this._settled)return;this._settled=!0;let t=this.querySelector(`dialog`);t&&t.close(),this.dispatchEvent(new CustomEvent(`resolve`,{detail:e}))}_onClose=()=>this._finish(!1);_onStay=()=>this._finish(!1);_onMigrate=()=>this._finish(!0);render(){let e=()=>z`<a class="om-link" href="${Nr}" target="_blank" rel="noopener noreferrer">${Nr}</a>`;return z`<dialog class="origin-migration-dialog" @close="${this._onClose}"><header class="om-head"><h3>This deepview build has moved</h3><p><code>${location.host}${location.pathname}</code> has moved to ${e()}.</p></header><section class="om-body"><p>Workspaces and reports don't migrate automatically — they live in this origin's browser storage (OPFS where available, localStorage as a fallback) and have to be re-imported on ${e()}.</p>${this.supportsSync?z`<p>For each workspace, use the <strong>Export workspace</strong> button in the sidebar to download a <code>.gz</code> bundle (workspace + its reports + its triage), then drop that file on ${e()}. Workspaces that had sync configured will pick their triage state back up from the sync server once you re-import them with the same key.</p><p>For triage attached to loose reports (or as a top-up after re-importing workspaces), call <code>DeepView.export()</code> in the browser console — that downloads just the triage entries plus saved repo URLs, no reports or workspaces.</p>`:z`<p>This build has no sync layer. For each workspace, use the <strong>Export workspace</strong> button in the sidebar to download a <code>.gz</code> bundle (workspace + its reports + its triage), then drop that file on ${e()}.</p><p>For triage attached to loose reports, call <code>DeepView.export()</code> in the browser console — that downloads just the triage entries plus saved repo URLs, no reports or workspaces. Reports themselves need to be re-dropped from their original JSON on the new origin.</p>`}</section><footer class="om-actions"><button type="button" class="om-stay" @click="${this._onStay}">Keep editing on ${location.host}</button> <button type="button" class="primary om-go" @click="${this._onMigrate}">Switch to triage.space</button></footer></dialog>`}};customElements.define(`origin-migration-dialog`,Br);function Vr(e){return new Promise(t=>{let n=document.createElement(`origin-migration-dialog`);n.supportsSync=e.supportsSync,n.addEventListener(`resolve`,e=>{n.remove(),t(!!e.detail)}),document.body.append(n)})}async function Hr(){let e=Fr();return e?await zr()?await Vr(e)?(location.assign(Nr),!0):!1:(location.replace(Nr),!0):!1}var H=null,Ur=!1,Wr=null;function Gr(e){if(!e)return`Could not unlock. Try again.`;switch(e.name??``){case`NotAllowedError`:case`AbortError`:return``;case`SecurityError`:return`The site identity does not match the one this passkey was registered for. If you opened DeepView through a different URL (different subdomain, different port), reload via the original URL.`;case`InvalidStateError`:return`The passkey state on this device is inconsistent. Try reloading the page or removing and re-creating the passkey.`;case`NotSupportedError`:return`This browser does not support the WebAuthn PRF extension this site needs. Try a recent Chrome, Safari, or Edge with a platform authenticator.`;case`NetworkError`:return`A network error prevented the passkey from completing. Check your connection and try again.`;case`UnknownError`:return`The authenticator returned an unknown error. Try again, or use a different passkey.`;default:{let t=e.message??String(e);return t.length>200?t.slice(0,197)+`…`:t}}}function Kr(){if(H)return H;H=document.createElement(`div`),H.id=`lock-overlay`,H.setAttribute(`role`,`dialog`),H.setAttribute(`aria-modal`,`true`),H.setAttribute(`aria-label`,`Unlock DeepView`),Dr(z`<div class="lock-overlay-card"><div class="lock-overlay-icon" aria-hidden="true"><svg viewBox="0 0 32 32" width="40" height="40" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="7" y="15" width="18" height="12" rx="1.5"/><path d="M11 15v-4a5 5 0 0 1 10 0v4"/></svg></div><h2 class="lock-overlay-title">DeepView is locked</h2><p class="lock-overlay-body">Your triage data and reports are encrypted with a passkey on this device. Unlock to continue.</p><p class="lock-overlay-error" hidden></p><button type="button" class="lock-overlay-button primary">Unlock with passkey</button><p class="lock-overlay-footer">Lost your passkey? <a href="#" class="lock-overlay-wipe">Wipe local data and start over</a></p></div>`,H);let e=H.querySelector(`button`),t=H.querySelector(`.lock-overlay-error`);return e.addEventListener(`click`,async()=>{if(!Ur&&qr()){Ur=!0,t.hidden=!0,t.textContent=``,e.disabled=!0,e.textContent=`Waiting…`,Wr=new AbortController;try{await bt({signal:Wr.signal})}catch(e){let n=Gr(e);t.textContent=n,t.hidden=!n}finally{Ur=!1,Wr=null,e.disabled=!1,e.textContent=`Unlock with passkey`}}}),H.querySelector(`.lock-overlay-wipe`).addEventListener(`click`,async e=>{if(e.preventDefault(),!Ur&&qr()&&confirm(`Wipe ALL local DeepView data and start over?
2
2
 
3
3
  This deletes your encrypted triage, all reports, and the passkey registration on this device. The data CANNOT be recovered after this — there is no backup.
4
4
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@preventive/triage",
3
- "version": "1.0.0-alpha.48",
3
+ "version": "1.0.0-alpha.49",
4
4
  "description": "Client & relay server for triaging of automated reports",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "author": {
@@ -37,14 +37,18 @@ const timeOf = (value: unknown) => {
37
37
  // rebased onto the branch by their last commit there, when merged, and
38
38
  // their commits' subjects, the oldest first (agentCommits); when the newest
39
39
  // read was last updated, those updated since read again (`through`, null
40
- // till all are read once); and a read not finished: where it goes on
41
- // (`after`), when the newest it began with was updated (`from`), the oldest
42
- // it reads to (`stop`) and how many it has read (`read`). And of every pull
40
+ // till all are read once), and where those read reach back no further than
41
+ // a period needed, when the oldest of them was updated (`reach`), every
42
+ // pull request merged since read (one is updated when merged, or after);
43
+ // and a read not finished: where it goes on (`after`), when the newest it
44
+ // began with was updated (`from`), the oldest it reads to (`stop`), how many
45
+ // it has read (`read`) and when the oldest of them was updated (`to`). And
46
+ // of every pull
43
47
  // request merged, an agent's or not, its merge commit and when (`pulls`),
44
48
  // and one rebased so (`pullRebases`), for the commits pushed to the branch
45
49
  // directly to be told (directCommits).
46
- export interface AgentMerges { merges: [string, number][]; rebases: Rebase[]; through: number | null
47
- next: { after: string; from: number; stop: number; read: number } | null; pulls?: [string, number][]; pullRebases?: Rebase[] }
50
+ export interface AgentMerges { merges: [string, number][]; rebases: Rebase[]; through: number | null; reach?: number
51
+ next: { after: string; from: number; stop: number; read: number; to?: number } | null; pulls?: [string, number][]; pullRebases?: Rebase[] }
48
52
  // A pull request rebased onto the branch: its last commit there, when it was
49
53
  // merged, its latest commits' subjects, the oldest first, and how many more
50
54
  // it had than those read (`more`).
@@ -86,20 +90,24 @@ const agentPull = ({ headRefName, labels, author }: MergedNode) => agentBranch(h
86
90
  // first (a pull request is updated when merged, or after), on from what was
87
91
  // `kept`: till one updated before the year, or before the newest read before
88
92
  // was, as many queries an answer as MERGED_QUERIES, a read not finished
89
- // going on from where it was left by the next; one read again whose label
90
- // no longer names an agent, what it was read to bring forgotten.
93
+ // going on from where it was left by the next, or once those merged since
94
+ // `need` are all read; one read again whose label no longer names an agent,
95
+ // what it was read to bring forgotten.
91
96
  export async function agentMerges(fullName: string, token: string, since: number, kept: AgentMerges | null,
92
- fetchImpl: typeof fetch = globalThis.fetch): Promise<AgentMerges> {
97
+ fetchImpl: typeof fetch = globalThis.fetch, need = since): Promise<AgentMerges> {
93
98
  const [owner, name] = fullName.split('/') as [string, string]
94
99
  const merges = new Map((kept?.merges ?? []).filter(([, at]) => at >= since))
95
100
  const rebases = new Map((kept?.rebases ?? []).filter(([, at]) => at >= since).map(([sha, at, subjects, more = 0]) => [sha, [at, subjects, more] as const]))
96
101
  const pulls = new Map((kept?.pulls ?? []).filter(([, at]) => at >= since))
97
102
  const pullRebases = new Map((kept?.pullRebases ?? []).filter(([, at]) => at >= since).map(([sha, at, subjects, more = 0]) => [sha, [at, subjects, more] as const]))
98
- const stop = kept?.next?.stop ?? Math.max(since, kept?.through ?? since)
103
+ // Those read reaching back no further than they needed, and `need`
104
+ // further: read again from the newest on, as far back as it needs.
105
+ const further = !kept?.next && kept?.reach !== undefined && kept.reach >= need
106
+ const stop = kept?.next?.stop ?? (further ? since : Math.max(since, kept?.through ?? since))
99
107
  const listed = (map: typeof rebases) => [...map].map(([sha, [at, subjects, more]]): Rebase => more > 0 ? [sha, at, subjects, more] : [sha, at, subjects])
100
- const result = (through: number | null, next: AgentMerges['next']): AgentMerges => ({ merges: [...merges], rebases: listed(rebases), through, next,
101
- pulls: [...pulls], pullRebases: listed(pullRebases) })
102
- let { after = null, from = null, read = 0 }: { after?: string | null; from?: number | null; read?: number } = kept?.next ?? {}
108
+ const result = (through: number | null, next: AgentMerges['next'], reach?: number): AgentMerges => ({ merges: [...merges], rebases: listed(rebases), through,
109
+ ...reach === undefined ? {} : { reach }, next, pulls: [...pulls], pullRebases: listed(pullRebases) })
110
+ let { after = null, from = null, read = 0, to = null }: { after?: string | null; from?: number | null; read?: number; to?: number | null } = kept?.next ?? {}
103
111
  for (let queries = 0; queries < MERGED_QUERIES; queries++) {
104
112
  const page = (await githubGraphql(MERGED_QUERY, { owner, name, after }, token, fetchImpl) as MergedPage | null)?.repository?.pullRequests
105
113
  let done = page?.pageInfo?.hasNextPage !== true || typeof page.pageInfo.endCursor !== 'string'
@@ -108,6 +116,7 @@ export async function agentMerges(fullName: string, token: string, since: number
108
116
  if (updated === null) continue
109
117
  if (updated < stop) { done = true; break }
110
118
  from ??= updated
119
+ to = updated
111
120
  read++
112
121
  const [merged, merge] = [timeOf(node?.mergedAt), node?.mergeCommit?.oid]
113
122
  if (!node || merged === null || merged < since || typeof merge !== 'string' || !SHA.test(merge)) continue
@@ -131,10 +140,15 @@ export async function agentMerges(fullName: string, token: string, since: number
131
140
  for (const oid of brought) merges.set(oid, merged)
132
141
  if (subjects) rebases.set(merge, [merged, subjects, more])
133
142
  }
134
- if (done) return result(from ?? stop, null)
143
+ // Read as far back as it reads to: the year's start, every one since
144
+ // read; else as far as those read before reached.
145
+ if (done) return result(from ?? stop, null, stop > since ? kept?.reach : undefined)
135
146
  after = page!.pageInfo!.endCursor as string
147
+ // Read as far back as `need`: finished, so far back, those updated
148
+ // since read by the next as ever.
149
+ if (need > since && to !== null && to < need) return result(Math.max(from ?? stop, kept?.through ?? Number.NEGATIVE_INFINITY), null, to)
136
150
  }
137
- return result(kept?.through ?? null, { after: after!, from: from ?? stop, stop, read })
151
+ return result(kept?.through ?? null, { after: after!, from: from ?? stop, stop, read, ...to === null ? {} : { to } }, kept?.reach)
138
152
  }
139
153
 
140
154
  // Whether two reads of the pull requests merged (agentMerges) bring the
@@ -476,20 +476,26 @@ function firstHeader(v: string | string[] | undefined): string | null {
476
476
  return null
477
477
  }
478
478
 
479
+ // A user's avatar, drawn on every tile, comment and review by them: kept by
480
+ // the browser five minutes, then shown as kept for a day while the browser
481
+ // asks again behind it, its image named by its ETag, so that an unchanged one
482
+ // answers with none (304). One changed on another's profile shows within
483
+ // minutes; a user's own change, at once, as their page asks for it again
484
+ // (ui/view/github-users.js refreshAvatar).
485
+ const USER_AVATAR_CACHE = 'private, max-age=300, stale-while-revalidate=86400'
486
+
479
487
  // `key` is the image chosen for `id`; one not cached falls back to `id`'s own.
480
- // A user's is never kept by the browser, as it changes with their profile.
481
- async function serveAvatar(res: ServerResponse, avatarStore: AvatarStore, id: string, key = id, cacheControl = 'no-store'): Promise<void> {
482
- sendAvatar(res, await avatarStore.get(key) ?? (key === id ? null : await avatarStore.get(id)), cacheControl)
488
+ async function serveAvatar(req: IncomingMessage, res: ServerResponse, avatarStore: AvatarStore, id: string, key = id, cacheControl = USER_AVATAR_CACHE): Promise<void> {
489
+ sendAvatar(req, res, await avatarStore.get(key) ?? (key === id ? null : await avatarStore.get(id)), cacheControl)
483
490
  }
484
491
 
485
- function sendAvatar(res: ServerResponse, avatar: CachedAvatar | null, cacheControl = 'no-store'): void {
492
+ function sendAvatar(req: IncomingMessage, res: ServerResponse, avatar: CachedAvatar | null, cacheControl: string): void {
486
493
  if (avatar == null) { sendJson(res, 404, { error: 'no-avatar' }); return }
487
- res.writeHead(200, {
488
- 'content-type': avatar.contentType,
489
- 'content-length': String(avatar.bytes.length),
490
- 'x-content-type-options': 'nosniff',
491
- 'cache-control': cacheControl,
492
- })
494
+ const etag = `"${createHash('sha256').update(`${avatar.contentType}\0`).update(avatar.bytes).digest('base64url').slice(0, 22)}"`
495
+ const headers = { 'etag': etag, 'cache-control': cacheControl, 'x-content-type-options': 'nosniff' }
496
+ const asked = req.headers['if-none-match']
497
+ if (typeof asked === 'string' && asked.split(',').some(tag => ['*', etag, `W/${etag}`].includes(tag.trim()))) { res.writeHead(304, headers); res.end(); return }
498
+ res.writeHead(200, { ...headers, 'content-type': avatar.contentType, 'content-length': String(avatar.bytes.length) })
493
499
  res.end(avatar.bytes)
494
500
  }
495
501
 
@@ -505,15 +511,15 @@ const AGENT_AVATAR = /^(app|user)-([1-9]\d{0,11})$/u
505
511
  // for it alone.
506
512
  const AGENT_AVATAR_CACHE = 'private, max-age=86400'
507
513
 
508
- async function serveAgentAvatar(res: ServerResponse, avatarStore: AvatarStore, avatar: string): Promise<void> {
514
+ async function serveAgentAvatar(req: IncomingMessage, res: ServerResponse, avatarStore: AvatarStore, avatar: string): Promise<void> {
509
515
  const [, kind, id] = AGENT_AVATAR.exec(avatar)!
510
516
  const key = `00000000-0000-0000-000${kind === 'app' ? 0 : 1}-${id!.padStart(12, '0')}`
511
517
  // Read once where it is kept; read again only past fetching it.
512
518
  const kept = await avatarStore.get(key)
513
- if (kept != null) { sendAvatar(res, kept, AGENT_AVATAR_CACHE); return }
519
+ if (kept != null) { sendAvatar(req, res, kept, AGENT_AVATAR_CACHE); return }
514
520
  await cacheAvatar(avatarStore, key, `https://avatars.githubusercontent.com/${kind === 'app' ? 'in' : 'u'}/${id}`, globalThis.fetch)
515
521
  .catch((err) => { console.warn('managed: agent avatar caching failed:', err) })
516
- await serveAvatar(res, avatarStore, key, key, AGENT_AVATAR_CACHE)
522
+ await serveAvatar(req, res, avatarStore, key, key, AGENT_AVATAR_CACHE)
517
523
  }
518
524
 
519
525
  const MAX_JSON_BODY_BYTES = 4096
@@ -2639,7 +2645,7 @@ async function handleRepositoryPulse(res: ServerResponse, deps: ManagedHttpDeps,
2639
2645
  await answerWholeRepository(res, deps, cookie, repoId, async (fullName, token) => {
2640
2646
  const [period, year] = await Promise.all([
2641
2647
  keptAnswer(deps, `${repoId}\0pulse\0${weeks}`, () => repositoryActivity(fullName, token, weeks)),
2642
- pulseCommits(deps, repoId, fullName, token),
2648
+ pulseCommits(deps, repoId, fullName, token, weeks),
2643
2649
  ])
2644
2650
  return { value: { weeks, ...period.value, stats: year.stats && periodStats(year.stats, weeks), reading: year.reading, pulls: year.pulls,
2645
2651
  pending: year.pending }, kept: period.kept && year.kept }
@@ -2654,14 +2660,21 @@ async function handleRepositoryPulse(res: ServerResponse, deps: ManagedHttpDeps,
2654
2660
  // read on by the next answer as the year's commits are, and read again as
2655
2661
  // an answer kept is, a label changing whose a pull request is without
2656
2662
  // moving the head, the year counted again where what they bring changed.
2657
- // A year not read whole yet is how many of its commits are (`reading`), or
2658
- // of the pull requests merged (`pulls`), beside the year last counted from
2659
- // an earlier head over the same weeks, where one is kept, so that a head
2660
- // moved on shows the year as it was till it is counted again; one past
2663
+ // Read no further back than the period asked (`weeks`) needs, its weeks and
2664
+ // as many before them, the rest read only as a longer one asks, its count
2665
+ // so said (`from`). A year not read as far as it needs yet is how many of
2666
+ // its commits are (`reading`), or of the pull requests merged (`pulls`),
2667
+ // beside the year last counted from an earlier head, where one is kept over
2668
+ // the same weeks or a few weeks earlier, so that a head moved on, or the
2669
+ // rules its commits are read by, shows the year as it was till it is
2670
+ // counted again; one past
2661
2671
  // YEAR_COMMITS is GitHub's statistics' (repositoryStats, repositoryLines),
2662
2672
  // kept together as an issue's page is and through GitHub computing them
2663
2673
  // again, `pending` while it first computes them, the year last counted
2664
2674
  // beside it so too.
2675
+ // How many weeks behind the year shown a year last counted may be shown
2676
+ // while it is counted again: the year as it was, its latest weeks missing.
2677
+ const PULSE_LAST_MS = 28 * 24 * 60 * 60_000
2665
2678
  // A day as a pulse's items are narrowed by (YYYY-MM-DD, UTC), when it starts;
2666
2679
  // a GitHub login, as many of them as PULSE_AUTHORS.
2667
2680
  const PULSE_DAY = /^\d{4}-\d{2}-\d{2}$/u
@@ -2693,8 +2706,13 @@ async function handleRepositoryPulseItems(res: ServerResponse, deps: ManagedHttp
2693
2706
  })
2694
2707
  }
2695
2708
 
2696
- async function pulseCommits(deps: ManagedHttpDeps, repoId: number, fullName: string, token: string) {
2697
- const first = pulseSince(52, Date.now())
2709
+ async function pulseCommits(deps: ManagedHttpDeps, repoId: number, fullName: string, token: string, weeks = 52) {
2710
+ const now = Date.now()
2711
+ const first = pulseSince(52, now)
2712
+ // As far back as a period of `weeks` weeks needs the year read whole: its
2713
+ // weeks and as many before them, which it is shown beside; the year's,
2714
+ // all of it.
2715
+ const need = Math.max(first, pulseSince(2 * weeks, now))
2698
2716
  const head = await keptAnswer(deps, `${repoId}\0pulse-head`, async () => ({ sha: await defaultHead(fullName, token) }))
2699
2717
  if (!head.value.sha) return { stats: commitStats([], first), reading: null, pulls: null, pending: false, kept: head.kept }
2700
2718
  const store = repositoryReadStore(deps.db, deps.config.debug)
@@ -2703,7 +2721,7 @@ async function pulseCommits(deps: ManagedHttpDeps, repoId: number, fullName: str
2703
2721
  const mergesKey = `${repoId}\0pulse-merges\0${PULSE_YEAR_VERSION}`
2704
2722
  const last = async () => {
2705
2723
  const kept = (await store.read(lastKey))?.value as { first: number; stats: PulseStats } | undefined
2706
- return kept?.first === first ? kept.stats : null
2724
+ return kept && first - kept.first <= PULSE_LAST_MS ? kept.stats : null
2707
2725
  }
2708
2726
  const sha = head.value.sha
2709
2727
  // What tells whose the year's commits are, kept as read: the agents' pull
@@ -2711,47 +2729,60 @@ async function pulseCommits(deps: ManagedHttpDeps, repoId: number, fullName: str
2711
2729
  // repository (`releases`, release-commits.ts releaseCommits).
2712
2730
  type Attribution = AgentMerges & { releases?: string[] }
2713
2731
  const keptAttribution = async () => ((await store.read(mergesKey))?.value as Attribution | undefined) ?? null
2714
- const attribution = async (kept: Attribution | null): Promise<Attribution> => {
2715
- const [merges, releases] = await Promise.all([agentMerges(fullName, token, first, kept), releaseCommits(fullName, token, first, historyChanges(deps, repoId))])
2732
+ const attribution = async (kept: Attribution | null, whole: number): Promise<Attribution> => {
2733
+ const [merges, releases] = await Promise.all([agentMerges(fullName, token, first, kept, globalThis.fetch, whole),
2734
+ releaseCommits(fullName, token, first, historyChanges(deps, repoId))])
2716
2735
  return { ...merges, releases }
2717
2736
  }
2737
+ // Since when every pull request merged is read (agentMerges `reach`, or
2738
+ // a read not finished's `to`): all of them, -∞; none yet, ∞.
2739
+ const readTo = (read: Attribution) => read.next ? read.next.to ?? Number.POSITIVE_INFINITY : read.reach ?? Number.NEGATIVE_INFINITY
2718
2740
  // The year counted from its `commits` as `read` tells whose they are and
2719
2741
  // which were pushed directly, the branch followed through those it was
2720
- // read on past (`passed`, yearCommits) too; kept by its head and as the
2721
- // year last counted.
2722
- const count = async ({ commits, passed }: { commits: HistoryCommit[]; passed: HistoryCommit[] }, read: Attribution) => {
2742
+ // read on past (`passed`, yearCommits) too, whole from when both the
2743
+ // commits and the pull requests merged are read (`from`, none where all
2744
+ // are); kept by its head and as the year last counted.
2745
+ const count = async ({ commits, passed, from }: { commits: HistoryCommit[]; passed: HistoryCommit[]; from: number | null }, read: Attribution) => {
2723
2746
  const walked = [...commits, ...passed]
2724
- const stats = commitStats(commits, first, agentCommits(read, walked), new Set(read.releases), directCommits(read, walked, sha))
2747
+ const whole = Math.max(from ?? Number.NEGATIVE_INFINITY, readTo(read))
2748
+ const stats = { ...commitStats(commits, first, agentCommits(read, walked), new Set(read.releases), directCommits(read, walked, sha)),
2749
+ ...Number.isFinite(whole) ? { from: whole } : {} }
2725
2750
  await Promise.all([store.write(key, stats, Date.now()), store.write(lastKey, { first, stats }, Date.now())])
2726
2751
  return stats
2727
2752
  }
2728
2753
  // A year kept by its head, what tells whose its commits are read again as
2729
2754
  // an answer kept is (keptAnswer), a label or a tag changing it without
2730
- // moving the head: counted again from its pages kept where what agents'
2731
- // pull requests bring, or the releases, changed, or a read of the pull
2732
- // requests left unfinished is finished; the year as kept answered
2733
- // meanwhile, and where reading them fails.
2755
+ // moving the head: counted again, as far back as it was, from its pages
2756
+ // kept where what agents' pull requests bring, or the releases, changed,
2757
+ // or the pull requests merged are read further back; the year as kept
2758
+ // answered meanwhile, and where reading them fails.
2734
2759
  const relabelled = async (stats: PulseStats) => {
2735
2760
  const recounted: { stats?: PulseStats } = {}
2761
+ const whole = stats.from ?? first
2736
2762
  await keptAnswer(deps, mergesKey, async () => {
2737
2763
  const kept = await keptAttribution()
2738
- const read = await attribution(kept)
2739
- const same = kept && !kept.next && sameMerges(read, kept) && read.releases!.join() === (kept.releases ?? []).join()
2740
- if (read.next || same) return read
2741
- const year = await yearCommits(fullName, token, sha, first, historyPages(deps, repoId, true))
2764
+ const read = await attribution(kept, whole)
2765
+ const same = kept && sameMerges(read, kept) && read.releases!.join() === (kept.releases ?? []).join() && readTo(kept) <= readTo(read)
2766
+ if (readTo(read) >= whole || same) return read
2767
+ const year = await yearCommits(fullName, token, sha, first, historyPages(deps, repoId, true), globalThis.fetch, whole)
2742
2768
  if (year.complete) recounted.stats = await count(year, read)
2743
2769
  return read
2744
2770
  }, () => true).catch((err: unknown) => { console.warn('managed: pulse attribution read failed:', err) })
2745
2771
  return recounted.stats ?? stats
2746
2772
  }
2773
+ // A year kept, read whole as far back as the period needs, as it was;
2774
+ // else its commits and the pull requests merged read on together as far
2775
+ // as it needs, and counted once both are.
2747
2776
  const held = (await store.read(key))?.value as PulseStats | { tooMany: true } | undefined
2748
- if (held && !('tooMany' in held)) return { stats: await relabelled(held), reading: null, pulls: null, pending: false, kept: head.kept }
2749
- if (!held) {
2750
- const year = await yearCommits(fullName, token, sha, first, historyPages(deps, repoId, true))
2777
+ if (held && !('tooMany' in held) && (held.from === undefined || held.from < need)) {
2778
+ return { stats: await relabelled(held), reading: null, pulls: null, pending: false, kept: head.kept }
2779
+ }
2780
+ if (!held || !('tooMany' in held)) {
2781
+ const [year, read] = await Promise.all([yearCommits(fullName, token, sha, first, historyPages(deps, repoId, true), globalThis.fetch, need),
2782
+ keptAttribution().then(kept => attribution(kept, need))])
2783
+ await store.write(mergesKey, read, Date.now())
2751
2784
  if (year.complete) {
2752
- const read = await attribution(await keptAttribution())
2753
- await store.write(mergesKey, read, Date.now())
2754
- if (read.next) return { stats: await last(), reading: null, pulls: read.next.read, pending: false, kept: false }
2785
+ if (readTo(read) >= need) return { stats: await last(), reading: null, pulls: read.next?.read ?? 0, pending: false, kept: false }
2755
2786
  return { stats: await count(year, read), reading: null, pulls: null, pending: false, kept: false }
2756
2787
  }
2757
2788
  if (!year.tooMany) return { stats: await last(), reading: year.read, pulls: null, pending: false, kept: false }
@@ -4851,8 +4882,8 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
4851
4882
  if (path.startsWith(AVATAR_PREFIX)) {
4852
4883
  if (method !== 'GET') { send405(res, 'GET'); return }
4853
4884
  const id = path.slice(AVATAR_PREFIX.length)
4854
- if (AGENT_AVATARS.has(id) && AGENT_AVATAR.test(id)) { await serveAgentAvatar(res, avatarStore, id); return }
4855
- await serveAvatar(res, avatarStore, id, url.searchParams.has('own') ? id : await db.getAvatarKey(id))
4885
+ if (AGENT_AVATARS.has(id) && AGENT_AVATAR.test(id)) { await serveAgentAvatar(req, res, avatarStore, id); return }
4886
+ await serveAvatar(req, res, avatarStore, id, url.searchParams.has('own') ? id : await db.getAvatarKey(id))
4856
4887
  return
4857
4888
  }
4858
4889
  // Admin: list users (admin-only).
@@ -132,7 +132,7 @@ function counted(answer: CommentReview[], read: Authored[], reviewers: Map<strin
132
132
  const { name, version, integrity, patchHash, status } = dependency ?? {}
133
133
  if (typeof name !== 'string' || typeof version !== 'string' || typeof integrity !== 'string' || !VERDICTS.has(status)) return []
134
134
  return integrity.split(/\s+/u).filter(Boolean).map(hash => ({ package: name, version, integrity: hash, ...typeof patchHash === 'string' && patchHash ? { patchHash } : {},
135
- basePackage: null, baseVersion: null, reviewer, status: status as ReviewVerdict, full: true, time }))
135
+ basePackage: null, baseVersion: null, reviewer, login: review.user as string, status: status as ReviewVerdict, full: true, time }))
136
136
  })
137
137
  })
138
138
  }
@@ -107,7 +107,9 @@ const ACCEPTING = new Set(['accept', 'accept-with-nits'])
107
107
  // review of theirs, whole or compared with one of `bases` of its package
108
108
  // (any, for `any`); and how many review it in its pull request's comments
109
109
  // alone (`commented`), which count for it alone: whose reviews passing it,
110
- // else whose latest, are its comments'.
110
+ // else whose latest, are its comments'. Each reviewer too (`reviewers`), by
111
+ // their login, their latest verdict, whether they pass it, and whether that
112
+ // verdict is its comments' (`comment`), which counts for it alone.
111
113
  function stancesOf(pkg: LockedPackage, reviews: ReviewsOf, bases: string[] | 'any') {
112
114
  const hashes = hashesOf(pkg)
113
115
  const by = new Map<string, PublishedReview[]>()
@@ -118,17 +120,21 @@ function stancesOf(pkg: LockedPackage, reviews: ReviewsOf, bases: string[] | 'an
118
120
  const qualifies = (review: PublishedReview) => review.full && ACCEPTING.has(review.status)
119
121
  && (review.basePackage === null || (review.basePackage === pkg.name && (bases === 'any' || bases.includes(review.baseVersion ?? ''))))
120
122
  let commented = 0, passing = 0, rejected = false
123
+ const reviewers: Reviewer[] = []
121
124
  for (const theirs of by.values()) {
122
125
  const latest = theirs.reduce((one, other) => other.time > one.time ? other : one)
126
+ const passes = ACCEPTING.has(latest.status) && theirs.some(qualifies)
123
127
  // In its comments alone where those telling where they stand are: those
124
128
  // passing it, where they pass it, else their latest.
125
- const telling = ACCEPTING.has(latest.status) && theirs.some(qualifies) ? theirs.filter(qualifies) : [latest]
126
- if (telling.every(review => review.comment)) commented++
129
+ const comment = (passes ? theirs.filter(qualifies) : [latest]).every(review => review.comment)
130
+ if (comment) commented++
127
131
  if (latest.status === 'reject') rejected = true
128
- else if (ACCEPTING.has(latest.status) && theirs.some(qualifies)) passing++
132
+ else if (passes) passing++
133
+ reviewers.push({ login: latest.login, status: latest.status, ...passes ? { passes: true as const } : {}, ...latest.comment ? { comment: true as const } : {} })
129
134
  }
130
- return { passing, rejected, commented }
135
+ return { passing, rejected, commented, reviewers }
131
136
  }
137
+ type Reviewer = { login: string; status: PublishedReview['status']; passes?: true; comment?: true }
132
138
 
133
139
  // A version as the client is told it: what its reviews are held to left
134
140
  // out, but whether it is patched.
@@ -148,7 +154,8 @@ function askedOf(pkg: LockedPackage): [string, ReviewedDependency] {
148
154
 
149
155
  // A version's review, and whether it wants one (`wants`), neither passing
150
156
  // nor needing none, as only one that can be reviewed may.
151
- type Review = { unsupported: Unsupported } | { baseline: true } | { reviewed: true } | { reviews: number; rejected?: true; commented?: number } | Record<string, never>
157
+ type Review = { unsupported: Unsupported } | { baseline: true } | { reviewed: true } | { reviews: number; rejected?: true; commented?: number; reviewers?: Reviewer[] }
158
+ | Record<string, never>
152
159
  interface Judged { review: Review; wants: boolean }
153
160
  type Judge = (pkg: LockedPackage, bases: string[] | 'any') => Judged
154
161
  interface Context { state: RepoReviewState; reviews: ReviewsOf; baseline: Set<string> | null }
@@ -157,8 +164,8 @@ function judge(pkg: LockedPackage, bases: string[] | 'any', { state, reviews, ba
157
164
  if (as === 'skip') return { review: {}, wants: false }
158
165
  if (as !== 'version') return { review: { unsupported: as }, wants: false }
159
166
  if (baselineKeys(pkg).some(key => baseline?.has(key))) return { review: { baseline: true }, wants: false }
160
- const { passing, rejected, commented } = stancesOf(pkg, reviews, bases)
161
- const told = { reviews: passing, ...commented > 0 ? { commented } : {} }
167
+ const { passing, rejected, commented, reviewers } = stancesOf(pkg, reviews, bases)
168
+ const told = { reviews: passing, ...commented > 0 ? { commented } : {}, ...reviewers.length > 0 ? { reviewers } : {} }
162
169
  if (rejected) return { review: { ...told, rejected: true }, wants: true }
163
170
  return { review: told, wants: passing < state.required }
164
171
  }
@@ -25,13 +25,14 @@ export interface RepoDependencyReview { enabled: boolean; required: number; sinc
25
25
  // A review published of the version it is of: its package, version and
26
26
  // npm's integrity of it, the patch it is of (none here, the app reviewing no
27
27
  // patch yet), the version it was compared with (none for a whole package's),
28
- // who wrote it (`reviewer`, their id, else their login then), its verdict,
29
- // whether it checked every file (`full`), and when it was published.
28
+ // who wrote it (`reviewer`, their id, else their login then), their login
29
+ // as it was then (`login`), its verdict, whether it checked every file
30
+ // (`full`), and when it was published.
30
31
  // One made in a pull request's comments (`comment`) counts for it alone;
31
32
  // one published here has its `id`.
32
33
  export interface PublishedReview {
33
34
  id?: string; package: string; version: string; integrity: string; patchHash?: string; basePackage: string | null; baseVersion: string | null
34
- reviewer: string; status: ReviewVerdict; full: boolean; time: number; comment?: true
35
+ reviewer: string; login: string; status: ReviewVerdict; full: boolean; time: number; comment?: true
35
36
  }
36
37
  // A repository's review in effect for a reader: its settings, whether they
37
38
  // publish reviews to one of the teams that count (`canReview`), and the
@@ -105,7 +106,7 @@ async function publishedReviews(db: ManagedSql, repoId: number, versions: Review
105
106
  const nonText = Array.isArray(snapshot) ? null : snapshot?.nonTextFiles ?? []
106
107
  const full = summarizeReview(entries.get(row.id) ?? [], files, nonText).coverage === 'full'
107
108
  found.push({ id: row.id, package: row.package, version: row.version, integrity: row.integrity, basePackage: row.basePackage, baseVersion: row.baseVersion,
108
- reviewer: row.userId ?? `login:${row.userLogin.toLowerCase()}`, status: row.status, full, time: Number(row.publishedAt) })
109
+ reviewer: row.userId ?? `login:${row.userLogin.toLowerCase()}`, login: row.userLogin, status: row.status, full, time: Number(row.publishedAt) })
109
110
  }
110
111
  }
111
112
  return found
@@ -76,18 +76,21 @@ export interface PulseCommit { sha: string; at: number; subject: string; login:
76
76
  // each commit's own time zone, and, counted from its commits, each period's
77
77
  // (`punches`, by its weeks); `lines` false where GitHub counts none, null
78
78
  // while it is counting them; counted from its commits, the latest pushed to
79
- // the branch directly (`directCommits`, as many as DIRECT_LISTED).
79
+ // the branch directly (`directCommits`, as many as DIRECT_LISTED); and,
80
+ // counted from its commits read no further back than a period needed, when
81
+ // from it is counted whole (`from`), absent for the year whole.
80
82
  export interface PulseStats { weeks: PulseWeek[]; contributors: PulseContributor[]; punch: number[][]; punches?: Record<number, number[][]>; lines: boolean | null
81
- directCommits?: PulseCommit[] }
83
+ directCommits?: PulseCommit[]; from?: number }
82
84
  // The lines added and removed a week, and each contributor's, as GitHub's
83
85
  // statistics count them, `lines` false where it counts none.
84
86
  export interface PulseLines { weeks: [number, number, number][]; contributors: { login: string; weeks: [number, number, number][] }[]; lines: boolean }
85
87
  // The days (`from`, `to`, both days, UTC; `to` none, on) and the authors (GitHub
86
88
  // logins, any of them) a period's pull requests and issues are narrowed to.
87
89
  export interface PulseNarrowing { from: number; to: number | null; authors: string[] }
88
- // The year's commits read so far: all of them (`commits`), else how many
89
- // (`read`), and whether past YEAR_COMMITS (`tooMany`).
90
- export type YearCommits = { complete: true; commits: HistoryCommit[]; passed: HistoryCommit[] } | { complete: false; read: number; tooMany: boolean }
90
+ // The year's commits read so far: all of them, or as far back as asked
91
+ // (`commits`, `from`), else how many (`read`), and whether past
92
+ // YEAR_COMMITS (`tooMany`).
93
+ export type YearCommits = { complete: true; commits: HistoryCommit[]; passed: HistoryCommit[]; from: number | null } | { complete: false; read: number; tooMany: boolean }
91
94
 
92
95
  const count = (value: unknown) => Number.isSafeInteger(value) && (value as number) >= 0 ? value as number : 0
93
96
  const timeOf = (value: unknown) => {
@@ -204,17 +207,28 @@ export async function defaultHead(fullName: string, token: string, fetchImpl: ty
204
207
  // YEAR_QUERIES, the rest left for the next to ask (`complete` false, how
205
208
  // many are `read`); more than YEAR_COMMITS, too many (`tooMany`). Read
206
209
  // whole, those it was read on past besides (`passed`): undated, or dated
207
- // before it, a clock behind, the branch's commits since beneath them.
210
+ // before it, a clock behind, the branch's commits since beneath them. Read
211
+ // no further than `need` asks, those since it read whole (`complete`, `from`
212
+ // the newest of those read whose parents are not, every commit after it
213
+ // read; null, the year read whole).
208
214
  export async function yearCommits(fullName: string, token: string, head: string, since: number, pages: HistoryPages,
209
- fetchImpl: typeof fetch = globalThis.fetch): Promise<YearCommits> {
215
+ fetchImpl: typeof fetch = globalThis.fetch, need = since): Promise<YearCommits> {
210
216
  const found = new Map<string, HistoryCommit>()
211
217
  const tried = new Set<string>()
212
218
  const within = (commit: HistoryCommit) => commit.committedAt === null || commit.committedAt >= since - YEAR_SKEW_MS
213
219
  const dated = (commit: HistoryCommit) => commit.committedAt !== null && commit.committedAt >= since
214
220
  const read = () => [...found.values()].filter(dated).length
221
+ const passed = () => [...found.values()].filter(commit => !dated(commit) && within(commit))
215
222
  let queries = 0
216
223
  let starts = [head]
217
224
  while (starts.length > 0) {
225
+ // Those not read yet, the head before any, else the parents of those
226
+ // read, no later than the newest of them (but for a clock behind): read
227
+ // whole after it.
228
+ const unread = new Set(starts)
229
+ const from = found.size === 0 ? Number.POSITIVE_INFINITY : Math.max(Number.NEGATIVE_INFINITY, ...[...found.values()]
230
+ .filter(commit => commit.parents.some(sha => unread.has(sha))).map(commit => commit.committedAt ?? Number.POSITIVE_INFINITY))
231
+ if (need > since && from < need) return { complete: true, commits: [...found.values()].filter(dated), passed: passed(), from }
218
232
  if (queries >= YEAR_QUERIES) return { complete: false, read: read(), tooMany: false }
219
233
  const batch = starts.slice(0, YEAR_STARTS)
220
234
  for (const sha of batch) tried.add(sha)
@@ -224,7 +238,7 @@ export async function yearCommits(fullName: string, token: string, head: string,
224
238
  if (read() > YEAR_COMMITS) return { complete: false, read: read(), tooMany: true }
225
239
  starts = [...new Set([...found.values()].filter(within).flatMap(commit => commit.parents))].filter(sha => !found.has(sha) && !tried.has(sha))
226
240
  }
227
- return { complete: true, commits: [...found.values()].filter(dated), passed: [...found.values()].filter(commit => !dated(commit) && within(commit)) }
241
+ return { complete: true, commits: [...found.values()].filter(dated), passed: passed(), from: null }
228
242
  }
229
243
 
230
244
  // Whether an author is a GitHub App's bot: by its login (`<app>[bot]`, as a