@preventive/triage 1.0.0-alpha.43 → 1.0.0-alpha.45

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.
Files changed (186) hide show
  1. package/common/bundle-compare-diff.js +2 -167
  2. package/common/bundle-compare-versions.js +172 -0
  3. package/common/bundle-comparison.d.ts +8 -0
  4. package/common/bundle-comparison.js +62 -0
  5. package/common/bundle-sources.js +20 -1
  6. package/common/default-scan-models.ts +1 -1
  7. package/common/dependency-reviews.d.ts +0 -5
  8. package/common/dependency-reviews.js +0 -17
  9. package/common/managed/agent-tasks.ts +292 -0
  10. package/common/managed/github-agents.ts +92 -0
  11. package/common/managed/github-logins.ts +49 -0
  12. package/common/managed/roles.ts +7 -0
  13. package/common/managed/routes.js +91 -10
  14. package/common/managed/team-app.d.ts +5 -2
  15. package/common/managed/team-app.js +2 -2
  16. package/common/server-info.ts +11 -0
  17. package/common/workspace-app.js +19 -3
  18. package/out/brotli-fallback.js +3 -3
  19. package/out/chunk-2XUEZHIP.js +18 -18
  20. package/out/chunk-2ZDZWLFY.js +1 -0
  21. package/out/chunk-3PS4KWD2.js +10 -0
  22. package/out/chunk-5DHL5NG3.js +2 -0
  23. package/out/chunk-6HNXNIQY.js +52 -0
  24. package/out/chunk-7C7DWL7I.js +1 -0
  25. package/out/chunk-AFLZH7WG.js +1 -0
  26. package/out/chunk-BC2UCLFJ.js +1 -0
  27. package/out/chunk-C4Z6ZBXV.js +6 -6
  28. package/out/chunk-GDPCLSOS.js +2 -0
  29. package/out/chunk-GO7FT4OD.js +1 -0
  30. package/out/chunk-HMAJDVXN.js +1 -0
  31. package/out/chunk-I2MDGSQX.js +3 -0
  32. package/out/chunk-IYPY47MC.js +1 -0
  33. package/out/chunk-IZ7POWI3.js +18 -0
  34. package/out/chunk-JCIIBLKR.js +1 -0
  35. package/out/chunk-NPW52BM2.js +17 -0
  36. package/out/chunk-NRPC74AM.js +1 -1
  37. package/out/chunk-NV7ES4EW.js +1 -0
  38. package/out/chunk-OKRLLE5I.js +17 -0
  39. package/out/chunk-OOJM4CTU.js +1 -1
  40. package/out/chunk-PNHBZGDD.js +1 -1
  41. package/out/chunk-PTGDYLNS.js +36 -0
  42. package/out/chunk-PXK7EU4V.js +1 -0
  43. package/out/chunk-Q6MVYGYV.js +3 -0
  44. package/out/chunk-QB4CBOX2.js +1 -0
  45. package/out/chunk-QRD6GIG5.js +229 -0
  46. package/out/chunk-R3NZFXVR.js +1 -0
  47. package/out/chunk-R5O6FAW4.js +1 -1
  48. package/out/chunk-SXV2T5C4.js +80 -0
  49. package/out/chunk-TUII34K2.js +1 -1
  50. package/out/chunk-VFWMQ3LG.js +2 -0
  51. package/out/chunk-VH4XMZIW.js +2 -0
  52. package/out/chunk-WFYLMF6Z.js +10 -0
  53. package/out/chunk-WI7Z5A2T.js +6 -0
  54. package/out/chunk-WQ74ZBZF.js +7 -0
  55. package/out/chunk-XVBEOGOS.js +3 -0
  56. package/out/client-managed-import.js +3 -3
  57. package/out/client-managed.js +31 -35
  58. package/out/client-sync.js +11 -11
  59. package/out/compare.js +35 -0
  60. package/out/dialogs.js +51 -0
  61. package/out/github-issue-authorized.html +1 -1
  62. package/out/github-issue-authorized.js +1 -1
  63. package/out/graph.js +17 -17
  64. package/out/index.html +31 -31
  65. package/out/prism.js +14 -14
  66. package/out/scan.js +2 -0
  67. package/out/terminal.js +294 -293
  68. package/out/view.css +1 -1
  69. package/out/view.js +16 -107
  70. package/package.json +55 -6
  71. package/risk/actions/findings.js +156 -0
  72. package/risk/actions/resolve.js +219 -0
  73. package/risk/actions/scripts/commands.js +182 -0
  74. package/risk/actions/scripts/grade.js +113 -0
  75. package/risk/actions/scripts/packages.js +123 -0
  76. package/risk/actions/scripts/shell.js +165 -0
  77. package/risk/actions/uses.js +76 -0
  78. package/risk/actions/workflow.js +110 -0
  79. package/risk/files/hosts.js +6 -6
  80. package/risk/files/readability.js +23 -17
  81. package/risk/files/runs-code.js +18 -6
  82. package/risk/files/script.js +7 -3
  83. package/risk/files/stamps.js +8 -7
  84. package/risk/index.d.ts +68 -1
  85. package/risk/index.js +6 -2
  86. package/risk/npm/assess.js +13 -8
  87. package/risk/npm/compare.js +34 -10
  88. package/risk/npm/dependencies.js +11 -0
  89. package/risk/npm/files.js +413 -2
  90. package/risk/npm/tarball.js +56 -73
  91. package/server-managed/agent-merges.ts +198 -0
  92. package/server-managed/app-agents.ts +30 -0
  93. package/server-managed/avatar-store.ts +1 -0
  94. package/server-managed/commit-coauthors.ts +83 -0
  95. package/server-managed/commit-lockfiles.ts +62 -0
  96. package/server-managed/comparison.ts +41 -0
  97. package/server-managed/db-methods.ts +29 -10
  98. package/server-managed/db-neon.ts +30 -2
  99. package/server-managed/db-schema.ts +9 -2
  100. package/server-managed/db.ts +7 -0
  101. package/server-managed/dependabot-access.ts +60 -0
  102. package/server-managed/dependabot-alerts.ts +185 -0
  103. package/server-managed/dependency-review-comments.ts +97 -0
  104. package/server-managed/dependency-reviews.ts +65 -15
  105. package/server-managed/github-accounts.ts +33 -0
  106. package/server-managed/github-actions.ts +71 -0
  107. package/server-managed/github-app.ts +23 -9
  108. package/server-managed/github-errors.ts +2 -0
  109. package/server-managed/github-link-oauth.ts +69 -0
  110. package/server-managed/github-links.ts +204 -0
  111. package/server-managed/github-oauth.ts +5 -2
  112. package/server-managed/github-users.ts +30 -0
  113. package/server-managed/http.ts +1219 -81
  114. package/server-managed/index.ts +6 -2
  115. package/server-managed/locked-packages.ts +48 -16
  116. package/server-managed/lockfile-scope.ts +49 -0
  117. package/server-managed/npm-delta-worker.js +23 -0
  118. package/server-managed/npm-delta.ts +173 -0
  119. package/server-managed/npm-insights.ts +20 -11
  120. package/server-managed/npm-kept.ts +51 -4
  121. package/server-managed/npm-loads.ts +20 -13
  122. package/server-managed/npm-packages.ts +24 -64
  123. package/server-managed/pretty-print.ts +14 -9
  124. package/server-managed/public-workspace.ts +26 -8
  125. package/server-managed/pull-comment-reviews.ts +161 -0
  126. package/server-managed/pull-dependencies.ts +280 -0
  127. package/server-managed/pull-dependency-reviews.ts +318 -0
  128. package/server-managed/pull-reviews.ts +205 -0
  129. package/server-managed/release-commits.ts +84 -0
  130. package/server-managed/repository-actions.ts +209 -0
  131. package/server-managed/repository-agents.ts +220 -0
  132. package/server-managed/repository-browser.ts +54 -13
  133. package/server-managed/repository-changes.ts +284 -0
  134. package/server-managed/repository-code.ts +52 -0
  135. package/server-managed/repository-dependency-reviews.ts +135 -0
  136. package/server-managed/repository-history.ts +445 -0
  137. package/server-managed/repository-issues.ts +181 -0
  138. package/server-managed/repository-pulse.ts +432 -0
  139. package/server-managed/repository-risk-read.ts +165 -0
  140. package/server-managed/repository-risk.ts +116 -106
  141. package/server-managed/reviewed-dependencies.ts +149 -0
  142. package/server-managed/session.ts +9 -5
  143. package/server-managed/sql.ts +1 -1
  144. package/server-managed/storage-stores.ts +5 -0
  145. package/server-managed/team-app.ts +54 -14
  146. package/server-managed/workspace-shares.ts +4 -1
  147. package/server.ts +11 -0
  148. package/out/chunk-2KBF5TZ7.js +0 -2
  149. package/out/chunk-35VE6CDH.js +0 -2
  150. package/out/chunk-3BZ7JERB.js +0 -1
  151. package/out/chunk-4F6JSF57.js +0 -3
  152. package/out/chunk-4XCDFGDP.js +0 -10
  153. package/out/chunk-7RU5EDRO.js +0 -226
  154. package/out/chunk-A7M4GV7M.js +0 -1
  155. package/out/chunk-CLPKNNC7.js +0 -51
  156. package/out/chunk-CMQA4DM6.js +0 -1
  157. package/out/chunk-CVVJAXVY.js +0 -2
  158. package/out/chunk-DIJJNTRH.js +0 -10
  159. package/out/chunk-GJ4IUCNS.js +0 -1
  160. package/out/chunk-GNXOKIGP.js +0 -80
  161. package/out/chunk-GZ4MS3TE.js +0 -13
  162. package/out/chunk-IU3CCINR.js +0 -3
  163. package/out/chunk-K3X5EJBQ.js +0 -2
  164. package/out/chunk-KEX2ATEW.js +0 -2
  165. package/out/chunk-KYCNJVB7.js +0 -1
  166. package/out/chunk-LAAB5SZT.js +0 -2
  167. package/out/chunk-NWZ4PVPN.js +0 -1
  168. package/out/chunk-OSIMGFUF.js +0 -1
  169. package/out/chunk-RFZQQXSG.js +0 -17
  170. package/out/chunk-RSHMVYNS.js +0 -1
  171. package/out/chunk-TZ5KB2Y5.js +0 -10
  172. package/out/chunk-UNTRGE26.js +0 -2
  173. package/out/chunk-UXNUAIIC.js +0 -33
  174. package/out/chunk-UYAXVRVL.js +0 -1
  175. package/out/chunk-V3ME6FI5.js +0 -2
  176. package/out/chunk-V5O7M2YT.js +0 -1
  177. package/out/chunk-VIHGO77Y.js +0 -1
  178. package/out/chunk-VQJCXGIY.js +0 -7
  179. package/out/chunk-W4J3MGLB.js +0 -1
  180. package/out/chunk-WGG7KQUH.js +0 -1
  181. package/out/chunk-WZDTZE64.js +0 -1
  182. package/out/chunk-XOCAPDCM.js +0 -1
  183. package/out/chunk-XYFXXB2G.js +0 -10
  184. package/out/chunk-YVZH5SHP.js +0 -1
  185. package/out/chunk-ZBBOBQ43.js +0 -1
  186. package/out/chunk-ZMQQCGF6.js +0 -2
@@ -0,0 +1,292 @@
1
+ // Agents' sessions and their tasks, reconstructed from what each
2
+ // leaves on GitHub (the code viewer's Agents tab, repository-agents.js): a
3
+ // task is a pull request it opened, or a commit it pushed to the default
4
+ // branch without one; a session holds as many as name it, with the branches
5
+ // they are on and the issues they work on. The server reads each artifact's
6
+ // own texts, never others' comments on it (a pull request's body and its
7
+ // head and merge commits' messages, where an agent links its session; a
8
+ // commit's message; a branch's tip commit) for the marks an agent leaves
9
+ // (agentSignals), sending those alone; agentSessions then groups them:
10
+ // those naming one session, a pull request with its branch, a branch with
11
+ // the session its commits name, an issue with the pull requests closing it
12
+ // or a branch named for it. Without a session named, a branch's pull
13
+ // requests are one session, and a commit its own.
14
+ //
15
+ // The agents are Claude and Codex, and those the server knows by their GitHub
16
+ // Apps or accounts (repository-agents.ts APP_AGENTS): each told by its
17
+ // fingerprint, a line of its texts, by the account its work is authored by,
18
+ // or by its branches' prefix, the server's to read, and named to the client
19
+ // by its id, name and avatar (AppAgent). Such an agent names no session:
20
+ // a pull request of its is a session alone, with its branch; Claude's or
21
+ // Codex's marks on its work show those as its tools. A pull request labelled
22
+ // for an agent (labelAgents) is that agent's too, where its own texts and
23
+ // branch name none: the mark one who ran it gives work it left unmarked.
24
+
25
+ // 'claude', 'codex', or an app agent's id.
26
+ export type Agent = string
27
+
28
+ // An agent known by its GitHub App or account, as the server names it to the
29
+ // client: its id, its name, and its avatar, its mark (/api/avatar/<avatar>:
30
+ // `app-<id>` a GitHub App's, `user-<id>` a GitHub account's), where the app
31
+ // draws no mark of its own for it (Copilot's, ui/view/repository-tiles.js).
32
+ export interface AppAgent { id: string; name: string; avatar?: string }
33
+ // How the server tells one: its fingerprint, a line of an artifact's texts
34
+ // starting with it; the GitHub account its work is authored by, its id and
35
+ // the logins GitHub names it by; its branches' prefix.
36
+ export interface AppAgentMarks { id: string; fingerprint?: string; account?: { id: number; logins: readonly string[] }; prefix: string }
37
+ // An artifact's author, as GitHub names its account: its login and id.
38
+ export interface AgentAuthor { login: string; id: number }
39
+
40
+ // What an artifact's texts say of the agents that made it: the Claude Code
41
+ // sessions and Codex tasks they link, whether they carry Claude's marks,
42
+ // its co-author trailer or its "Generated with" line, and Codex's, its
43
+ // co-author trailer, and the app agents whose fingerprints they carry, or
44
+ // whose branch it is.
45
+ export interface AgentSignals { sessions: string[]; tasks: string[]; claude: boolean; codex: boolean; agents: string[] }
46
+
47
+ // Each by its author's login, and their account's numeric id (`authorId`)
48
+ // where GitHub names it.
49
+ export interface AgentPull { number: number; title: string; state: 'open' | 'merged' | 'closed'; draft: boolean; author: string | null; authorId?: number | null
50
+ // Its branch, and the owner of the fork it is in, null where it is in the
51
+ // repository itself.
52
+ createdAt: number | null; updatedAt: number | null; head: string | null; fork: string | null; additions: number; deletions: number; commits: number
53
+ closes: number[]; signals: AgentSignals }
54
+ export interface AgentIssue { number: number; title: string; state: 'open' | 'closed'; author: string | null; authorId?: number | null; createdAt: number | null; updatedAt: number | null }
55
+ export interface AgentBranch { name: string; at: number | null; author: string | null; authorId?: number | null; signals: AgentSignals }
56
+ // A commit to the default branch no pull request merged.
57
+ export interface AgentCommit { oid: string; title: string; at: number | null; author: string | null; authorId?: number | null; additions: number; deletions: number
58
+ signals: AgentSignals }
59
+
60
+ export interface AgentSession {
61
+ id: string
62
+ agents: Agent[]
63
+ // Where to open it: a Claude Code session or a Codex task.
64
+ sessions: string[]
65
+ tasks: string[]
66
+ title: string
67
+ authors: string[]
68
+ startedAt: number | null
69
+ updatedAt: number | null
70
+ state: 'open' | 'merged' | 'closed' | 'branch'
71
+ // Its tasks: the pull requests it opened, and the commits it pushed to
72
+ // the default branch, the newest first.
73
+ pulls: Pick<AgentPull, 'number' | 'title' | 'state' | 'draft'>[]
74
+ commits: Pick<AgentCommit, 'oid' | 'title'>[]
75
+ issues: Pick<AgentIssue, 'number' | 'title' | 'state'>[]
76
+ branches: string[]
77
+ // The commits its pull requests hold, and their lines and its commits'.
78
+ commitCount: number
79
+ additions: number
80
+ deletions: number
81
+ }
82
+
83
+ const SESSION = /https:\/\/claude\.ai\/code\/(session_[A-Za-z\d]{6,64})\b/gu
84
+ const CODEX_TASK = /https:\/\/chatgpt\.com\/codex\/tasks\/(task_[a-z]_[A-Za-z\d]{6,64})\b/gu
85
+ // Claude's marks on its commits and pull requests: its co-author trailer,
86
+ // its "Generated with" line, its session trailer.
87
+ const CLAUDE_MARK = /^co-authored-by: claude\b|generated with \[claude code\]|^claude-session:/imu
88
+ // Codex's: its co-author trailer, by its address, as Codex names itself
89
+ // co-author of the commits it makes as whoever ran it; a trailer of a text's
90
+ // trailers (trailerBlock), not a line before them that shows one.
91
+ const CODEX_MARK = /^co-authored-by:[ \t]*[^<\n]*<codex@openai\.com>[ \t]*$/imu
92
+ const MAX_LINKS = 8
93
+
94
+ // A message's trailers, as git reads them (`git interpret-trailers`): its
95
+ // last paragraph, past its subject's, where each of its lines is a trailer
96
+ // (`Key: value`) or goes on with one (indented), or, where it holds git's
97
+ // own `Signed-off-by:`, a quarter of them are; none where it isn't, as
98
+ // lines a description or example holds, fenced or not, are not.
99
+ const TRAILER_LINE = /^[A-Za-z\d-]+[ \t]*:/u
100
+ export function trailerBlock(text: string): string {
101
+ const paragraphs = text.replaceAll('\r\n', '\n').trim().split(/\n[ \t]*\n/u)
102
+ const block = paragraphs.length > 1 ? paragraphs.at(-1)! : ''
103
+ const lines = block.split('\n')
104
+ const trailers = lines.filter(line => TRAILER_LINE.test(line)).length
105
+ const others = lines.filter((line, at) => !TRAILER_LINE.test(line) && !(at > 0 && /^[ \t]/u.test(line))).length
106
+ return others === 0 || (lines.some(line => /^signed-off-by[ \t]*:/iu.test(line)) && trailers * 3 >= others) ? block : ''
107
+ }
108
+
109
+ // The agents of those whose ids are `ids` that `labels` name (a pull
110
+ // request's): a label an agent's id, regardless of case, alone or after
111
+ // `agent:` or `agent/` (`Codex`, `agent: claude`).
112
+ const LABEL = /^(?:agent[:/] ?)?([a-z][a-z\d-]*)$/u
113
+ export function labelAgents(labels: unknown[], ids: readonly string[]): Agent[] {
114
+ return [...new Set(labels.flatMap(label => {
115
+ const id = typeof label === 'string' ? LABEL.exec(label.trim().toLowerCase())?.[1] : undefined
116
+ return id !== undefined && ids.includes(id) ? [id] : []
117
+ }))]
118
+ }
119
+
120
+ // The sessions and tasks `texts` link, whether they carry Claude's marks and
121
+ // Codex's, and the agents of `apps` whose fingerprints start a line of them
122
+ // (not one a text quotes, as one describing it may), whose account `author`
123
+ // is, by its id and login both, or whose prefix the branch `branch` is under;
124
+ // Claude, Codex or the agents of `apps` its `labels` name (labelAgents).
125
+ export function agentSignals(texts: unknown[], apps: readonly AppAgentMarks[] = [], branch: string | null = null,
126
+ author: AgentAuthor | null = null, labels: unknown[] = []): AgentSignals {
127
+ const strings = texts.filter((text): text is string => typeof text === 'string')
128
+ const found = (pattern: RegExp) => [...new Set(strings.flatMap(text => [...text.matchAll(pattern)].map(match => match[1]!)))].slice(0, MAX_LINKS)
129
+ const lines = strings.flatMap(text => text.split('\n').map(line => line.trimStart()))
130
+ const login = author?.login.toLowerCase()
131
+ const authored = ({ account }: AppAgentMarks) => author !== null && account !== undefined && account.id === author.id
132
+ && account.logins.some(name => name.toLowerCase() === login)
133
+ const labelled = labelAgents(labels, ['claude', 'codex', ...apps.map(app => app.id)])
134
+ return { sessions: found(SESSION), tasks: found(CODEX_TASK), claude: labelled.includes('claude') || strings.some(text => CLAUDE_MARK.test(text)),
135
+ codex: labelled.includes('codex') || strings.some(text => CODEX_MARK.test(trailerBlock(text))),
136
+ agents: apps.filter(app => (app.fingerprint && lines.some(line => line.startsWith(app.fingerprint!)))
137
+ || authored(app) || branch?.startsWith(app.prefix) || labelled.includes(app.id)).map(app => app.id) }
138
+ }
139
+
140
+ // The agent a branch's name says made it, and the issue or pull request the
141
+ // Claude GitHub App names its branch for (`claude/issue-12-…`).
142
+ export function agentBranch(name: string | null): { agent: Agent | null; issue: number | null; pull: number | null } {
143
+ const agent = name?.startsWith('claude/') ? 'claude' : name?.startsWith('codex/') ? 'codex' : null
144
+ const named = agent === 'claude' ? /^claude\/(issue|pr)-([1-9]\d{0,9})-/u.exec(name!) : null
145
+ return { agent, issue: named?.[1] === 'issue' ? Number(named[2]) : null, pull: named?.[1] === 'pr' ? Number(named[2]) : null }
146
+ }
147
+
148
+ // A pull request's branch, as GitHub names one in a fork: `owner:branch`;
149
+ // two forks' of one name, or a fork's and the repository's, are two.
150
+ const headName = (pull: AgentPull) => pull.head && pull.fork ? `${pull.fork}:${pull.head}` : pull.head
151
+
152
+ const signalAgents = (signals: AgentSignals): Agent[] => [...signals.agents, ...signals.sessions.length > 0 || signals.claude ? ['claude'] : [],
153
+ ...signals.tasks.length > 0 || signals.codex ? ['codex'] : []]
154
+ const TOOLS = new Set(['claude', 'codex'])
155
+
156
+ type Artifacts = { pulls?: AgentPull[]; commits?: AgentCommit[]; issues?: AgentIssue[]; branches?: AgentBranch[] }
157
+ type Group = { agents: Set<Agent>; keys: string[] }
158
+
159
+ // The artifacts joined (`pull:<n>`, `commit:<oid>`, `branch:<name>`,
160
+ // `session:<id>`, `task:<id>`), each set with the agents whose marks are on
161
+ // them: by the sessions and tasks they link and the branches pull requests
162
+ // are from. An app agent's join none by what they link, and a pull request
163
+ // of its its branch alone, where no pull request newer is from it, an app
164
+ // agent's or another's: one a session.
165
+ function artifactGroups({ pulls = [], commits = [], branches = [] }: Artifacts): Group[] {
166
+ const parent = new Map<string, string>()
167
+ const find = (key: string): string => {
168
+ let root = key
169
+ while (parent.has(root) && parent.get(root) !== root) root = parent.get(root)!
170
+ parent.set(key, root)
171
+ return root
172
+ }
173
+ const join = (a: string, b: string) => {
174
+ const [x, y] = [find(a), find(b)]
175
+ if (x !== y) parent.set(y, x)
176
+ }
177
+ const agentsOf = new Map<string, Set<Agent>>()
178
+ const mark = (key: string, agents: Agent[]) => { for (const agent of agents) agentsOf.set(key, new Set([...agentsOf.get(key) ?? [], agent])) }
179
+ const linked = (key: string, signals: AgentSignals) => {
180
+ find(key)
181
+ if (signals.agents.length === 0) for (const session of signals.sessions) join(key, `session:${session}`)
182
+ if (signals.agents.length === 0) for (const task of signals.tasks) join(key, `task:${task}`)
183
+ mark(key, signalAgents(signals))
184
+ }
185
+ const branchNames = new Set(branches.map(branch => branch.name))
186
+ for (const branch of branches) {
187
+ const key = `branch:${branch.name}`
188
+ linked(key, branch.signals)
189
+ const { agent } = agentBranch(branch.name)
190
+ if (agent) mark(key, [agent])
191
+ }
192
+ // The newest pull request read from each branch, by its number, as GitHub
193
+ // numbers them as they are opened: they are read as they were updated.
194
+ const newest = new Map<string, number>()
195
+ for (const pull of pulls) {
196
+ const head = headName(pull)
197
+ if (head !== null && pull.number > (newest.get(head) ?? 0)) newest.set(head, pull.number)
198
+ }
199
+ for (const pull of pulls) {
200
+ const key = `pull:${pull.number}`
201
+ linked(key, pull.signals)
202
+ const { agent, pull: named } = agentBranch(pull.head)
203
+ if (agent) mark(key, [agent])
204
+ // Its branch, an agent's or its own, read or not; a fork's its own.
205
+ const head = headName(pull)
206
+ if (pull.signals.agents.length > 0) {
207
+ if (head !== null && newest.get(head) === pull.number) join(key, `branch:${head}`)
208
+ continue
209
+ }
210
+ if (head && (agent || signalAgents(pull.signals).length > 0 || branchNames.has(head))) join(key, `branch:${head}`)
211
+ if (named) join(key, `pull:${named}`)
212
+ }
213
+ for (const commit of commits) linked(`commit:${commit.oid}`, commit.signals)
214
+
215
+ const groups = new Map<string, Group>()
216
+ for (const key of parent.keys()) {
217
+ const root = find(key)
218
+ const group = groups.get(root) ?? { agents: new Set(), keys: [] }
219
+ group.keys.push(key)
220
+ for (const agent of agentsOf.get(key) ?? []) group.agents.add(agent)
221
+ groups.set(root, group)
222
+ }
223
+ return [...groups.values()]
224
+ }
225
+
226
+ const byTime = <T>(time: (item: T) => number | null) => (a: T, b: T) => (time(a) ?? 0) - (time(b) ?? 0)
227
+
228
+ // The session a group of artifacts is, its tasks and what they touched; null
229
+ // for one no agent left marks on, or holding none read.
230
+ function sessionOf({ agents, keys }: Group, { pulls, commits, issues, branches }: {
231
+ pulls: Map<number, AgentPull>; commits: Map<string, AgentCommit>; issues: Map<number, AgentIssue>; branches: Map<string, AgentBranch> }): AgentSession | null {
232
+ if (agents.size === 0) return null
233
+ const of = (prefix: string) => keys.filter(key => key.startsWith(prefix)).map(key => key.slice(prefix.length))
234
+ const taskPulls = of('pull:').flatMap(number => pulls.get(Number(number)) ?? []).toSorted(byTime(pull => pull.createdAt))
235
+ const taskCommits = of('commit:').flatMap(oid => commits.get(oid) ?? []).toSorted(byTime(commit => commit.at))
236
+ const names = [...new Set([...of('branch:'), ...taskPulls.flatMap(pull => headName(pull) ?? [])])]
237
+ // The issues its pull requests close, and those its branches are named
238
+ // for, by a pull request's own name for its branch, a fork's too.
239
+ const issueNumbers = new Set([...taskPulls.flatMap(pull => pull.closes),
240
+ ...taskPulls.flatMap(pull => agentBranch(pull.head).issue ?? []), ...of('branch:').flatMap(name => agentBranch(name).issue ?? [])])
241
+ const taskIssues = [...issueNumbers].flatMap(number => issues.get(number) ?? []).toSorted(byTime(issue => issue.createdAt))
242
+ const taskBranches = names.flatMap(name => branches.get(name) ?? [])
243
+ const items = [...taskPulls, ...taskCommits, ...taskIssues, ...taskBranches]
244
+ if (items.length === 0) return null
245
+ const [codexTasks, sessions] = [of('task:'), of('session:')]
246
+ const started = items.flatMap(item => ('createdAt' in item ? item.createdAt : item.at) ?? [])
247
+ const updated = items.flatMap(item => ('updatedAt' in item ? item.updatedAt : item.at) ?? [])
248
+ const states = new Set(taskPulls.map(pull => pull.state))
249
+ // Its first task names it: the earliest of its pull requests and commits.
250
+ const first = [...taskPulls.map(pull => ({ at: pull.createdAt, title: pull.title })), ...taskCommits.map(commit => ({ at: commit.at, title: commit.title }))]
251
+ .toSorted(byTime(task => task.at))[0]
252
+ const landed = [...taskPulls, ...taskCommits]
253
+ return {
254
+ id: sessions[0] ?? codexTasks[0] ?? (taskPulls[0] ? `pull-${taskPulls[0].number}` : taskCommits[0]?.oid ?? names[0] ?? ''),
255
+ // An app agent's first, the one whose session it is, then the tools it
256
+ // used, Claude's or Codex's.
257
+ agents: [...[...agents].filter(agent => !TOOLS.has(agent)), ...['claude', 'codex'].filter(agent => agents.has(agent))],
258
+ sessions, tasks: codexTasks,
259
+ title: first?.title ?? taskIssues[0]?.title ?? names[0] ?? '',
260
+ authors: [...new Set(items.flatMap(item => item.author ?? []))],
261
+ startedAt: started.length > 0 ? Math.min(...started) : null,
262
+ updatedAt: updated.length > 0 ? Math.max(...updated) : null,
263
+ state: states.has('open') ? 'open' : states.has('merged') || taskCommits.length > 0 ? 'merged' : states.has('closed') ? 'closed' : 'branch',
264
+ pulls: taskPulls.map(({ number, title, state, draft }) => ({ number, title, state, draft })).toReversed(),
265
+ commits: taskCommits.map(({ oid, title }) => ({ oid, title })).toReversed(),
266
+ issues: taskIssues.map(({ number, title, state }) => ({ number, title, state })),
267
+ branches: names,
268
+ commitCount: taskPulls.reduce((sum, pull) => sum + pull.commits, 0) + taskCommits.length,
269
+ additions: landed.reduce((sum, item) => sum + item.additions, 0),
270
+ deletions: landed.reduce((sum, item) => sum + item.deletions, 0),
271
+ }
272
+ }
273
+
274
+ // The sessions `pulls`, `commits`, `issues` and `branches` make, the most
275
+ // recently updated first: each a set of them, joined by the sessions and
276
+ // tasks they link and the branches pull requests are from, with the issues
277
+ // a pull request closes or a branch is named for.
278
+ // Artifacts no agent left marks on belong to none. Where pull requests
279
+ // updated before `branchesSince` are yet to be read, a session of branches
280
+ // alone older than that waits for them: its pull request may be among them.
281
+ export function agentSessions(artifacts: Artifacts, { branchesSince = null }: { branchesSince?: number | null } = {}): AgentSession[] {
282
+ const lookups = {
283
+ pulls: new Map((artifacts.pulls ?? []).map(pull => [pull.number, pull])),
284
+ commits: new Map((artifacts.commits ?? []).map(commit => [commit.oid, commit])),
285
+ issues: new Map((artifacts.issues ?? []).map(issue => [issue.number, issue])),
286
+ branches: new Map((artifacts.branches ?? []).map(branch => [branch.name, branch])),
287
+ }
288
+ const waiting = (session: AgentSession) => branchesSince !== null && session.pulls.length === 0 && session.commits.length === 0
289
+ && (session.updatedAt ?? 0) < branchesSince
290
+ return artifactGroups(artifacts).flatMap(group => sessionOf(group, lookups) ?? []).filter(session => !waiting(session))
291
+ .toSorted(byTime(session => session.updatedAt)).toReversed()
292
+ }
@@ -0,0 +1,92 @@
1
+ // Agents by their own GitHub accounts, where a repository's activity names
2
+ // them: each its name, and how the app draws it, by its mark, as the Agents
3
+ // tab does (ui/view/provider-icons.js), or by its avatar, which
4
+ // /api/avatar/<avatar> serves (server-managed/repository-agents.ts
5
+ // APP_AGENTS, the Agents tab's marks); its accounts, each by the logins GitHub's REST and GraphQL APIs
6
+ // name it by and its numeric id where known, which GitHub never gives
7
+ // another; and the names its commits carry where GitHub names no account for
8
+ // them. Claude Code's commits are authored as Claude <noreply@anthropic.com>,
9
+ // which GitHub names `claude`; the Claude GitHub App's as `claude[bot]`.
10
+ // Codex's GitHub connector's are its own; Codex otherwise commits as whoever
11
+ // ran it, and those commits are theirs, with a
12
+ // `Co-authored-by: Codex <codex@openai.com>` trailer, an address GitHub
13
+ // names someone else's account for, so Codex is known by the address, not
14
+ // that account. Copilot's coding agent authors its commits as its account,
15
+ // Copilot; Copilot's code review reviews as another, its bot, which GitHub
16
+ // names Copilot as well where it is asked to review and in its threads, and
17
+ // `copilot-pull-request-reviewer` (`[bot]` to GitHub's REST API) on its
18
+ // reviews, the accounts' ids telling the two apart: a login may so be more
19
+ // than one account's. Dependabot, its GitHub App, as its bot,
20
+ // `dependabot` to GitHub's GraphQL API, `dependabot[bot]` to its REST API and
21
+ // in its address. GitHub's Agentic Workflows (gh-aw) post, and approve or
22
+ // review pull requests, as GitHub Actions' bot, `github-actions` (41898282,
23
+ // `[bot]` to the REST API), its mark the GitHub Actions App's avatar; other
24
+ // workflows the bot runs are as much its own. Cursor's agents commit as whoever ran them, with a
25
+ // `Co-authored-by: Cursor Agent <cursoragent@cursor.com>` trailer; the app
26
+ // has no mark of Cursor's, and draws it by its name's letter. Each by the
27
+ // addresses (`emails`) its commits name it by, as author or co-author.
28
+ export interface GithubAgentAccount { id?: number; logins: readonly string[] }
29
+ export interface GithubAgent { name: string; mark?: 'anthropic' | 'openai' | 'github-copilot'; avatar?: string; accounts: readonly GithubAgentAccount[]; names: readonly string[]
30
+ emails?: readonly string[] }
31
+
32
+ export const GITHUB_AGENTS = {
33
+ claude: { name: 'Claude', mark: 'anthropic', accounts: [{ id: 81847, logins: ['claude'] }, { logins: ['claude[bot]'] }], names: ['Claude'], emails: ['noreply@anthropic.com'] },
34
+ codex: { name: 'Codex', mark: 'openai', accounts: [{ id: 199175422, logins: ['chatgpt-codex-connector', 'chatgpt-codex-connector[bot]'] }], names: ['Codex'],
35
+ emails: ['codex@openai.com'] },
36
+ copilot: { name: 'Copilot', mark: 'github-copilot', accounts: [{ id: 198982749, logins: ['Copilot', 'copilot-swe-agent'] },
37
+ { id: 175728472, logins: ['Copilot', 'copilot-pull-request-reviewer', 'copilot-pull-request-reviewer[bot]'] }], names: ['Copilot'] },
38
+ dependabot: { name: 'Dependabot', avatar: 'app-29110', accounts: [{ id: 49699333, logins: ['dependabot', 'dependabot[bot]'] }], names: ['dependabot[bot]'] },
39
+ actions: { name: 'GitHub Actions', avatar: 'app-15368', accounts: [{ id: 41898282, logins: ['github-actions', 'github-actions[bot]'] }], names: ['github-actions[bot]'] },
40
+ cursor: { name: 'Cursor', accounts: [], names: ['Cursor Agent'], emails: ['cursoragent@cursor.com'] },
41
+ } as const satisfies Record<string, GithubAgent>
42
+ export type GithubAgentId = keyof typeof GITHUB_AGENTS
43
+
44
+ const agents = Object.entries(GITHUB_AGENTS) as [GithubAgentId, GithubAgent][]
45
+ // Each login's agent, and the ids of the accounts it names, undefined for
46
+ // one whose id the app doesn't know.
47
+ const byLogin = new Map<string, { agent: GithubAgentId; ids: (number | undefined)[] }>()
48
+ for (const [agent, { accounts }] of agents) {
49
+ for (const { id, logins } of accounts) {
50
+ for (const login of logins) byLogin.set(login.toLowerCase(), { agent, ids: [...byLogin.get(login.toLowerCase())?.ids ?? [], id] })
51
+ }
52
+ }
53
+ const byName = new Map(agents.flatMap(([agent, { names }]) => names.map(name => [name.toLowerCase(), agent] as const)))
54
+ const byEmail = new Map(agents.flatMap(([agent, { emails = [] }]) => emails.map(email => [email, agent] as const)))
55
+
56
+ // The agent whose address `email` is, GitHub matching addresses regardless of
57
+ // case; null for anyone else's.
58
+ export const emailAgent = (email: unknown): GithubAgentId | null => typeof email === 'string' ? byEmail.get(email.trim().toLowerCase()) ?? null : null
59
+
60
+ // The agent an author is: by their login, GitHub matching logins regardless
61
+ // of case, their account's id the agent's where both are known, so another
62
+ // account GitHub names by the same login (a user's, the bot's GraphQL login
63
+ // may be) is not; for an author GitHub names no account for, by the name
64
+ // they carry. Null for anyone else: a human's account is theirs, whatever
65
+ // their commits name them.
66
+ export function githubAgent(login: string | null, account: number | null = null, name: string | null = null): GithubAgentId | null {
67
+ if (typeof login !== 'string') return typeof name === 'string' ? byName.get(name.trim().toLowerCase()) ?? null : null
68
+ const known = byLogin.get(login.toLowerCase())
69
+ return known && (account === null || known.ids.includes(undefined) || known.ids.includes(account)) ? known.agent : null
70
+ }
71
+
72
+ // Whether the accounts `ids` a login is named by are all one agent's, as
73
+ // Copilot's coding agent's and its code review's, both named Copilot, are:
74
+ // the login is that agent's whichever it is, rather than no one's.
75
+ export function oneAgent(login: string, ids: Iterable<number>): boolean {
76
+ const theirs = new Set([...ids].map(id => githubAgent(login, id)))
77
+ return theirs.size === 1 && !theirs.has(null)
78
+ }
79
+
80
+ // The accounts agents work as, a person's to GitHub, as the app is told of
81
+ // them, each by its id and logins: the pull requests they open and the
82
+ // commits they push to a branch directly are agents' (the Pulse's,
83
+ // server-managed/agent-merges.ts agentMerges, repository-pulse.ts
84
+ // commitStats), whatever their branches and labels, the rest of what they do
85
+ // theirs. Christopher Turing's.
86
+ export const AGENT_OPERATED = [{ id: 188797125, logins: ['ChristopherTuring'] }] as const satisfies readonly GithubAgentAccount[]
87
+
88
+ // Whether an author is an account agents work as (AGENT_OPERATED): by its id
89
+ // where GitHub gives it, whatever it is renamed to, else by its login,
90
+ // regardless of case.
91
+ export const agentOperated = (login: string | null, id: number | null = null): boolean => AGENT_OPERATED.some(account => id === null
92
+ ? typeof login === 'string' && account.logins.some(name => name.toLowerCase() === login.toLowerCase()) : account.id === id)
@@ -0,0 +1,49 @@
1
+ // The GitHub accounts an answer about a repository's activity names (its
2
+ // commits', issues', pull requests', agents' and pulse's): as an author, one
3
+ // who merged or a contributor, by login, and by the account's numeric id
4
+ // where the answer names it beside the login. The server resolves them to the
5
+ // app's users and agents (server-managed/github-users.ts), and the client
6
+ // keeps those it answered with in place of what it held for them
7
+ // (ui/view/github-users.js).
8
+ import { oneAgent } from './github-agents.ts'
9
+
10
+ // Where answers name a login, each with where the account's id is named
11
+ // beside it.
12
+ const LOGIN_KEYS = new Map([['author', 'authorId'], ['authorLogin', 'authorId'], ['mergedBy', 'mergedById'], ['login', 'id']])
13
+ const LOGIN = /^[A-Za-z\d][A-Za-z\d-]{0,38}(?:\[bot\])?$/u
14
+ const MAX_DEPTH = 8
15
+ const accountId = (value: unknown) => Number.isSafeInteger(value) && (value as number) > 0 ? value as number : null
16
+
17
+ // The logins `answer` names, each once, with the numeric id of the account
18
+ // named beside them (null where none is, or where those named are all one
19
+ // agent's, oneAgent); `ambiguous` where a login, GitHub matching logins
20
+ // regardless of case, is named beside more than one: by an account GitHub
21
+ // named by it once, as what was kept of the history says, and one it names by
22
+ // it now, which is no telling whose each is.
23
+ export function namedAccounts(answer: unknown): Map<string, number | 'ambiguous' | null> {
24
+ const named = new Map<string, Set<number>>()
25
+ collect(answer, named, 0)
26
+ const ids = new Map<string, Set<number>>()
27
+ for (const [login, theirs] of named) ids.set(login.toLowerCase(), new Set([...ids.get(login.toLowerCase()) ?? [], ...theirs]))
28
+ return new Map([...named.keys()].map((login): [string, number | 'ambiguous' | null] => {
29
+ const theirs = ids.get(login.toLowerCase())!
30
+ if (theirs.size <= 1) return [login, [...theirs][0] ?? null]
31
+ return [login, oneAgent(login, theirs) ? null : 'ambiguous']
32
+ }))
33
+ }
34
+
35
+ function collect(answer: unknown, named: Map<string, Set<number>>, depth: number): void {
36
+ if (depth > MAX_DEPTH || answer === null || typeof answer !== 'object') return
37
+ if (Array.isArray(answer)) {
38
+ for (const value of answer) collect(value, named, depth + 1)
39
+ return
40
+ }
41
+ const fields = answer as Record<string, unknown>
42
+ for (const [key, value] of Object.entries(fields)) {
43
+ const idKey = LOGIN_KEYS.get(key)
44
+ if (idKey && typeof value === 'string' && LOGIN.test(value)) {
45
+ const id = accountId(fields[idKey])
46
+ named.set(value, new Set([...named.get(value) ?? [], ...id === null ? [] : [id]]))
47
+ } else collect(value, named, depth + 1)
48
+ }
49
+ }
@@ -26,3 +26,10 @@ export function roleAtLeast(role: Role, min: Role): boolean {
26
26
  const r = ROLES.indexOf(role)
27
27
  return r !== -1 && r <= ROLES.indexOf(min)
28
28
  }
29
+
30
+ // Who reads connected repositories' code (the code viewer): a team member,
31
+ // view or above, the server reading only what their team grants reach and
32
+ // their own GitHub account may read. A public link reads none.
33
+ export function canReadRepositoryCode(session: { role: Role; publicShare?: boolean } | null | undefined): boolean {
34
+ return session != null && !session.publicShare && roleAtLeast(session.role, 'view')
35
+ }
@@ -28,6 +28,13 @@ export const MANAGED_PAGES = Object.freeze({
28
28
  'manage-history': '/manage/history',
29
29
  })
30
30
 
31
+ // A team's lists, each at /team/<slug>/<what it lists>; its dependencies'
32
+ // sub-tabs past the first at /team/<slug>/dependencies/<tab>.
33
+ const WORKSPACE_CONTENT_VIEWS = new Set(['workspace-reports', 'workspace-bundles', 'workspace-repositories', 'workspace-dependencies'])
34
+ export const DEPENDENCY_TABS = new Set(['pulls', 'versions', 'branch', 'reviews'])
35
+ // A route's `dependencyTab` field, where it names one.
36
+ const dependencyTabField = route => route.view === 'workspace-dependencies' && route.dependencyTab != null ? { dependencyTab: route.dependencyTab } : {}
37
+
31
38
  // Dot-only path components are normalised away by URL parsers, even escaped.
32
39
  function findingPath(id) {
33
40
  if (!isLinkableFindingId(id) || id === '.' || id === '..') return null
@@ -38,9 +45,13 @@ export function managedRoutePath(route) {
38
45
  if (!route) return null
39
46
  if (route.view === 'home') return '/'
40
47
  if (route.view === 'reviews') return '/my/reviews'
48
+ if (route.view === 'profile') return '/my/profile'
41
49
  if (route.view === 'npm') return npmPackagePath(route)
42
- if (route.view === 'workspace-reports' || route.view === 'workspace-bundles') {
43
- return /^[A-Za-z0-9_-]+$/u.test(route.teamSlug ?? '') ? `/team/${route.teamSlug}/${route.view.slice(10)}` : null
50
+ if (route.view === 'repository-code') return repositoryCodePath(route)
51
+ if (WORKSPACE_CONTENT_VIEWS.has(route.view)) {
52
+ const tab = dependencyTabField(route).dependencyTab ?? 'pulls'
53
+ if (!/^[A-Za-z0-9_-]+$/u.test(route.teamSlug ?? '') || !DEPENDENCY_TABS.has(tab)) return null
54
+ return `/team/${route.teamSlug}/${route.view.slice(10)}${tab === 'pulls' ? '' : `/${tab}`}`
44
55
  }
45
56
  if (route.view === 'bundles') {
46
57
  if (!/^[A-Za-z0-9_-]+$/u.test(route.bundleSlug ?? '')
@@ -82,11 +93,11 @@ export function managedRoutePath(route) {
82
93
  }
83
94
 
84
95
  // The npm viewer: its lookup page, or a package version's Overview, Code,
85
- // Compare or Treemap tab, `/npm/<name>[@<version or dist-tag>][/<tab>…]`. Code names
96
+ // Search, Compare or Treemap tab, `/npm/<name>[@<version or dist-tag>][/<tab>…]`. Code names
86
97
  // its file by number and its lines in the fragment, as a bundle's Code
87
98
  // does: `/code/<file>#L4`. Compare names the version compared with, then
88
99
  // its mode past the Overview: `/compare/<version>/code` or `/diff`.
89
- export const NPM_TABS = new Set(['overview', 'code', 'compare', 'review', 'treemap'])
100
+ export const NPM_TABS = new Set(['overview', 'code', 'search', 'compare', 'review', 'treemap'])
90
101
  function npmPackagePath(route) {
91
102
  if (route.packageName == null) return '/npm'
92
103
  const tab = route.bundleTab ?? 'overview'
@@ -114,7 +125,7 @@ function parseNpmRoute(path, hash) {
114
125
  return { view: 'npm', packageName: review[1], packageSpec: review[2] ?? null, bundleTab: 'review',
115
126
  ...(review[3] ? { reviewPackage: versionOnly ? review[1] : review[3], reviewSpec: versionOnly ? review[3] : review[4] ?? null } : {}) }
116
127
  }
117
- const match = /^\/npm(?:\/((?:@[^/@]+\/)?[^/@]+)(?:@([^/]+))?(?:\/(code|compare|treemap)(?:\/([^/]+)(?:\/([a-z]+))?)?)?)?$/u.exec(path)
128
+ const match = /^\/npm(?:\/((?:@[^/@]+\/)?[^/@]+)(?:@([^/]+))?(?:\/(code|search|compare|treemap)(?:\/([^/]+)(?:\/([a-z]+))?)?)?)?$/u.exec(path)
118
129
  if (!match) return undefined
119
130
  if (match[1] == null) return { view: 'npm' }
120
131
  if (!isNpmPackageName(match[1]) || (match[2] != null && !isNpmPackageSpec(match[2]))) return null
@@ -128,6 +139,66 @@ function parseNpmRoute(path, hash) {
128
139
  return !match[5] && /^[1-9]\d*$/u.test(match[4]) && Number.isSafeInteger(file) ? { ...route, file, ...codeLines(hash) } : null
129
140
  }
130
141
 
142
+ // The code viewer: a directory of a repository at a commit, on its Code,
143
+ // Search, Treemap, Compare, Commits, Issues, Pull requests, Agents, Pulse
144
+ // or Replay tab, `/repo/<repo id>/<commit>[/<tab>…][?path=<directory>]`,
145
+ // the repository's root where it names no directory. Code, the tab a bare
146
+ // link opens, names its file by number and its lines in the fragment, as a
147
+ // bundle's Code does: `/code/<file>#L4`. Compare names the commit compared
148
+ // with, then its mode past the Overview, as an npm version's does:
149
+ // `/compare/<commit>/code`. Without a commit,
150
+ // `/repo/<repo id>[?path=<directory>]`, it opens on the default
151
+ // branch's head, or on the revision a route held in memory names (`ref`),
152
+ // and its route then names the commit it opened. Links from before it moved
153
+ // name it under `/manage/code/`, and open the same.
154
+ export const REPOSITORY_CODE_TABS = new Set(['code', 'search', 'treemap', 'compare', 'commits', 'issues', 'pulls', 'agents', 'pulse', 'replay'])
155
+ // The tabs reading none of the directory's files, which a directory at a
156
+ // commit shows alone.
157
+ export const REPOSITORY_FILELESS_TABS = new Set(['commits', 'issues', 'pulls', 'agents', 'pulse', 'replay'])
158
+ const COMMIT = /^[a-f\d]{40}$/u
159
+ const MAX_DIRECTORY = 500
160
+ const isRepoId = value => Number.isSafeInteger(value) && value > 0
161
+
162
+ // A directory as the server takes one (server-managed/repo-path.ts), in its
163
+ // canonical form: `''` the root, else names with no empty, `.` or `..` one
164
+ // among them, nor control characters, backslashes or spaces around one.
165
+ export function isRepositoryDirectory(path) {
166
+ return typeof path === 'string' && (path === '' || path.length <= MAX_DIRECTORY && !/[\p{Cc}\\]/u.test(path)
167
+ && path.split('/').every(part => part !== '' && part !== '.' && part !== '..' && part === part.trim()))
168
+ }
169
+
170
+ function repositoryCodePath(route) {
171
+ const tab = route.bundleTab ?? 'code'
172
+ const path = route.path ?? ''
173
+ if (!isRepoId(route.repoId) || (route.commit != null && !COMMIT.test(route.commit)) || !isRepositoryDirectory(path) || !REPOSITORY_CODE_TABS.has(tab)
174
+ || (route.compareCommit != null && !COMMIT.test(route.compareCommit))) return null
175
+ const directory = path ? `?path=${path.split('/').map(encodeURIComponent).join('/')}` : ''
176
+ if (route.commit == null) return `/repo/${route.repoId}${directory}`
177
+ const file = tab === 'code' && isLineNumber(route.file) ? `/code/${route.file}` : tab === 'code' ? '' : `/${tab}`
178
+ const compare = tab === 'compare' && route.compareCommit != null ? `/${route.compareCommit}${compareModeSuffix(route.compareMode)}` : ''
179
+ const lines = tab === 'code' && isLineNumber(route.file) && isLineNumber(route.line)
180
+ ? `#L${route.line}${isLineNumber(route.endLine) && route.endLine > route.line ? `-L${route.endLine}` : ''}` : ''
181
+ return `/repo/${route.repoId}/${route.commit}${file}${compare}${directory}${lines}`
182
+ }
183
+
184
+ function parseRepositoryCodeRoute(url) {
185
+ const match = /^\/(?:repo|manage\/code)\/([1-9]\d*)(?:\/([a-f\d]{40})(?:\/(code|search|treemap|compare|commits|history|issues|pulls|agents|pulse|replay)(?:\/([^/]+)(?:\/([a-z]+))?)?)?)?$/u
186
+ .exec(url.pathname.replace(/\/$/u, ''))
187
+ if (!match) return undefined
188
+ const repoId = Number(match[1])
189
+ const path = url.searchParams.get('path') ?? ''
190
+ // Commits' tab was History's, `/history`, which links still name.
191
+ const tab = match[3] === 'history' ? 'commits' : match[3] ?? 'code'
192
+ if (!isRepoId(repoId) || !isRepositoryDirectory(path)) return null
193
+ const route = { view: 'repository-code', repoId, commit: match[2] ?? null, path, bundleTab: tab }
194
+ if (match[4] == null) return route
195
+ if (tab === 'compare') {
196
+ return COMMIT.test(match[4]) && (match[5] == null || COMPARE_MODES.has(match[5])) ? { ...route, compareCommit: match[4], ...compareModeField(match[5]) } : null
197
+ }
198
+ const file = Number(match[4])
199
+ return tab === 'code' && match[5] == null && /^[1-9]\d*$/u.test(match[4]) && Number.isSafeInteger(file) ? { ...route, file, ...codeLines(url.hash) } : null
200
+ }
201
+
131
202
  function isLineNumber(value) {
132
203
  return Number.isSafeInteger(value) && value > 0
133
204
  }
@@ -144,6 +215,12 @@ export function parseManagedRoute(url) {
144
215
  const path = url.pathname.replace(/\/$/u, '') || '/'
145
216
  if (path === '/' || path === '/index.html') return { view: 'home' }
146
217
  if (path === '/my/reviews') return { view: 'reviews' }
218
+ // Linking an account lands here, naming why GitHub's answer was refused,
219
+ // which the page shows once: the address it settles on drops it.
220
+ if (path === '/my/profile') {
221
+ const linkError = url.searchParams.get('link-error')
222
+ return { view: 'profile', ...(linkError && /^[a-z-]{1,64}$/u.test(linkError) ? { linkError } : {}) }
223
+ }
147
224
  const view = Object.keys(MANAGED_PAGES).find(key => MANAGED_PAGES[key] === path)
148
225
  if (view) {
149
226
  const createRepoId = Number(url.searchParams.get('createRepo'))
@@ -169,8 +246,12 @@ export function parseManagedRoute(url) {
169
246
  }
170
247
  const npm = parseNpmRoute(path, url.hash)
171
248
  if (npm !== undefined) return npm
172
- const contentList = /^\/team\/([A-Za-z0-9_-]+)\/(reports|bundles)$/u.exec(path)
249
+ const code = parseRepositoryCodeRoute(url)
250
+ if (code !== undefined) return code
251
+ const contentList = /^\/team\/([A-Za-z0-9_-]+)\/(reports|bundles|repositories|dependencies)$/u.exec(path)
173
252
  if (contentList) return { view: `workspace-${contentList[2]}`, teamSlug: contentList[1] }
253
+ const dependencies = /^\/team\/([A-Za-z0-9_-]+)\/dependencies\/([a-z]+)$/u.exec(path)
254
+ if (dependencies) return DEPENDENCY_TABS.has(dependencies[2]) ? { view: 'workspace-dependencies', teamSlug: dependencies[1], dependencyTab: dependencies[2] } : null
174
255
  const match = /^\/team\/([^/]+)(?:\/report\/([^/]+))?(?:\/(files)|\/finding\/([^/]+))?$/u.exec(path)
175
256
  if (!match) return null
176
257
  try {
@@ -187,9 +268,9 @@ export function parseManagedRoute(url) {
187
268
  // URL tokens are exact server-assigned slugs. Application state and API calls
188
269
  // continue to use UUIDs; never guess IDs from suffixes or accept ID aliases.
189
270
  export function resolveManagedRoute(route, teams, adminBundles = []) {
190
- if (route.view === 'workspace-reports' || route.view === 'workspace-bundles') {
271
+ if (WORKSPACE_CONTENT_VIEWS.has(route.view)) {
191
272
  const matches = teams.filter(team => team.slug === route.teamSlug)
192
- return matches.length === 1 ? { view: route.view, teamId: matches[0].id } : null
273
+ return matches.length === 1 ? { view: route.view, teamId: matches[0].id, ...dependencyTabField(route) } : null
193
274
  }
194
275
  if (route.view === 'bundles') {
195
276
  const { teamSlug, bundleSlug, compareSlug, compareMode, ...rest } = route
@@ -220,9 +301,9 @@ export function resolveManagedRoute(route, teams, adminBundles = []) {
220
301
  }
221
302
 
222
303
  export function managedRouteForIds(route, teams, adminBundles = []) {
223
- if (route.view === 'workspace-reports' || route.view === 'workspace-bundles') {
304
+ if (WORKSPACE_CONTENT_VIEWS.has(route.view)) {
224
305
  const team = teams.find(entry => entry.id === route.teamId)
225
- const result = { view: route.view, teamSlug: team?.slug }
306
+ const result = { view: route.view, teamSlug: team?.slug, ...dependencyTabField(route) }
226
307
  return team?.slug && resolveManagedRoute(result, teams) ? result : null
227
308
  }
228
309
  if (route.view === 'bundles') {
@@ -1,2 +1,5 @@
1
- export type TeamAppMetadata = { appMode: true; appFindings: number } | { appMode: false }
2
- export function managedTeamAppMetadata(workspace: readonly { filename: string; data: unknown }[] & { links?: readonly (readonly string[])[] }): TeamAppMetadata
1
+ // Set for a team that is not App: `findings`, its rows (with `countFindings`),
2
+ // and `appReports`, set by the managed catalog: how many reports open in App mode alone.
3
+ export type TeamAppMetadata = { appMode: true; appFindings: number } | { appMode: false; appReports?: number; findings?: number }
4
+ export function managedTeamAppMetadata(workspace: readonly { filename: string; data: unknown }[] & { links?: readonly (readonly string[])[] },
5
+ options?: { countFindings?: boolean }): TeamAppMetadata