@preventive/triage 1.0.0-alpha.49 → 1.0.0-alpha.50
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/common/managed/finding-detail.d.ts +2 -0
- package/common/managed/finding-detail.js +32 -0
- package/common/managed/github-agents.ts +6 -3
- package/common/managed/routes.js +14 -2
- package/out/activity.js +4 -4
- package/out/{chunk-ULIF7A7N.js → chunk-2WDZPXVL.js} +1 -1
- package/out/chunk-4XMP7YVH.js +36 -0
- package/out/chunk-6S2U2UYG.js +232 -0
- package/out/{chunk-IO7JTZ6O.js → chunk-75YLHBCM.js} +1 -1
- package/out/chunk-FC65TPUB.js +1 -0
- package/out/{chunk-O6M574JI.js → chunk-FKBXNUW4.js} +1 -1
- package/out/{chunk-PBSOL2SL.js → chunk-HTBYHSPG.js} +1 -1
- package/out/{chunk-SZD5CW5R.js → chunk-UK7RGJT6.js} +2 -2
- package/out/client-managed-import.js +1 -1
- package/out/client-managed.js +2 -2
- package/out/compare.js +14 -14
- package/out/dialogs.js +34 -30
- package/out/graph.js +1 -1
- package/out/index.html +6 -6
- package/out/scan.js +2 -2
- package/out/view.css +1 -1
- package/out/view.js +15 -15
- package/package.json +4 -1
- package/server-managed/agent-merges.ts +31 -136
- package/server-managed/app-agents.ts +4 -0
- package/server-managed/commit-coauthors.ts +46 -24
- package/server-managed/http.ts +115 -37
- package/server-managed/npm-kept.ts +8 -0
- package/server-managed/public-workspace.ts +2 -2
- package/server-managed/repository-agents.ts +189 -57
- package/server-managed/repository-history.ts +51 -13
- package/server-managed/repository-pulls.ts +156 -0
- package/server-managed/repository-pulse.ts +3 -2
- package/server-managed/repository-squashes.ts +29 -6
- package/server-managed/team-reports.ts +33 -18
- package/out/chunk-BC2UCLFJ.js +0 -1
- package/out/chunk-C2AVODGZ.js +0 -36
- package/out/chunk-JEAUAJVF.js +0 -232
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
// What the code viewer's Agents tab reconstructs agents' sessions
|
|
2
2
|
// from (GET /api/admin/repositories/agents, common/managed/agent-tasks.ts):
|
|
3
3
|
// a page of the repository's pull requests, the most recently updated first,
|
|
4
|
-
//
|
|
5
|
-
//
|
|
4
|
+
// as kept for the Pulse too (repository-pulls.ts readPulls), those past
|
|
5
|
+
// them searched for, and of its default branch's commits, the newest first,
|
|
6
|
+
// those no pull request merged, read from its history as the Commits tab
|
|
7
|
+
// keeps it, the
|
|
8
|
+
// marks an agent left kept with each (repository-history.ts commitMarks),
|
|
9
|
+
// whether a pull request brought one looked up for those marked alone, and
|
|
10
|
+
// kept by the commit; on the first page, its latest issues and the newest of
|
|
6
11
|
// its agents' branches (GitHub lists branches by name alone, so every one an
|
|
7
12
|
// agent's prefix names is read, as many as fifty pages of each, and the newest
|
|
8
13
|
// kept). Each artifact's own texts (never comments: an agent links its
|
|
@@ -10,12 +15,14 @@
|
|
|
10
15
|
// for the marks an agent leaves, and a pull request's labels for one naming
|
|
11
16
|
// an agent, and those alone are answered, never the texts. GraphQL alone
|
|
12
17
|
// gives them, so it needs a token.
|
|
13
|
-
import { type AgentAuthor, type AgentBranch, type AgentCommit, type AgentIssue, type AgentPull, type AppAgent, agentSignals } from '../common/managed/agent-tasks.ts'
|
|
18
|
+
import { type AgentAuthor, type AgentBranch, type AgentCommit, type AgentIssue, type AgentPull, type AgentSignals, type AppAgent, agentSignals } from '../common/managed/agent-tasks.ts'
|
|
14
19
|
import { GITHUB_AGENTS, type GithubAgentAccount, type GithubAgentId } from '../common/managed/github-agents.ts'
|
|
15
20
|
import { AGENT_BRANCH_PREFIXES, APP_AGENTS } from './app-agents.ts'
|
|
16
21
|
import { GithubApiError } from './github-app.ts'
|
|
17
|
-
import
|
|
18
|
-
import {
|
|
22
|
+
import type { GraphqlError } from './github-graphql.ts'
|
|
23
|
+
import { ACTOR, ALL_BRANCHES, type HistoryCommit, type HistoryPages, type NamedAccount, actorOf, commitAccount, commitMarks, githubGraphql, historyFrom, historyMarks,
|
|
24
|
+
inBatches, linkNoreplyUsers, messageMarks, variablesOf } from './repository-history.ts'
|
|
25
|
+
import type { PullList } from './repository-pulls.ts'
|
|
19
26
|
|
|
20
27
|
export const AGENT_PULLS_PAGE = 50
|
|
21
28
|
export const AGENT_COMMITS_PAGE = 100
|
|
@@ -30,8 +37,10 @@ const MAX_TITLE = 1000
|
|
|
30
37
|
const MAX_NAME = 256
|
|
31
38
|
const LOGIN = /^[A-Za-z\d][A-Za-z\d-]{0,38}(?:\[bot\])?$/u
|
|
32
39
|
const SHA = /^[a-f\d]{40}$/u
|
|
33
|
-
//
|
|
34
|
-
|
|
40
|
+
// Commits whether a pull request brought each is looked up of, in a query,
|
|
41
|
+
// and queries at once.
|
|
42
|
+
const PULLED_COMMITS = 100
|
|
43
|
+
const PULLED_PARALLEL = 4
|
|
35
44
|
|
|
36
45
|
// The app agents' accounts' no-reply addresses, by each login of theirs.
|
|
37
46
|
const AGENT_EMAILS = APP_AGENTS.flatMap(({ id }) => GITHUB_AGENTS[id as GithubAgentId].accounts
|
|
@@ -58,40 +67,51 @@ const AUTHORED_QUERY = `query($owner: String!, $name: String!, $emails: [String!
|
|
|
58
67
|
defaultBranchRef { target { ... on Commit { history(first: ${AGENT_COMMITS_PAGE}, author: { emails: $emails }) { nodes { ${COMMIT_FIELDS} } } } } }
|
|
59
68
|
}
|
|
60
69
|
}`
|
|
61
|
-
|
|
70
|
+
// What a pull request is read by, for its row (pull): its texts for the
|
|
71
|
+
// marks an agent leaves in them (its body, its last commit's message, its
|
|
72
|
+
// merge commit's), never answered; its commits counted beside (`commits {
|
|
73
|
+
// totalCount }`, as the reads of it ask theirs).
|
|
74
|
+
export const AGENT_PULL_FIELDS = `number title state isDraft createdAt updatedAt author { __typename ${ACTOR} } body headRefName isCrossRepository
|
|
75
|
+
headRepositoryOwner { login } additions deletions last: commits(last: 1) { nodes { commit { message } } } mergeCommit { message }
|
|
76
|
+
closingIssuesReferences(first: ${CLOSES}) { nodes { number } } labels(first: ${LABELS}) { nodes { name } }`
|
|
77
|
+
// On the first page, the default branch's head and the latest issues.
|
|
78
|
+
const PAGE_QUERY = `query($owner: String!, $name: String!) {
|
|
62
79
|
repository(owner: $owner, name: $name) {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
nodes { number title state isDraft createdAt updatedAt author { ${ACTOR} } body headRefName isCrossRepository headRepositoryOwner { login } additions deletions
|
|
66
|
-
commits(last: 1) { totalCount nodes { commit { message } } }
|
|
67
|
-
mergeCommit { message }
|
|
68
|
-
closingIssuesReferences(first: ${CLOSES}) { nodes { number } }
|
|
69
|
-
labels(first: ${LABELS}) { nodes { name } } }
|
|
70
|
-
}
|
|
71
|
-
defaultBranchRef { target { ... on Commit {
|
|
72
|
-
history(first: ${AGENT_COMMITS_PAGE}, after: $commits) @include(if: $readCommits) {
|
|
73
|
-
pageInfo { hasNextPage endCursor }
|
|
74
|
-
nodes { ${COMMIT_FIELDS} }
|
|
75
|
-
}
|
|
76
|
-
} } }
|
|
77
|
-
issues(first: ${AGENT_ISSUES}, orderBy: { field: UPDATED_AT, direction: DESC }) @include(if: $first) {
|
|
80
|
+
defaultBranchRef { target { oid } }
|
|
81
|
+
issues(first: ${AGENT_ISSUES}, orderBy: { field: UPDATED_AT, direction: DESC }) {
|
|
78
82
|
nodes { number title state createdAt updatedAt author { ${ACTOR} } }
|
|
79
83
|
}
|
|
80
84
|
}
|
|
81
85
|
}`
|
|
86
|
+
// A page of the pull requests a search finds, as GitHub sorts them, on from
|
|
87
|
+
// where `after` says its page before ended.
|
|
88
|
+
const SEARCH_QUERY = `query($query: String!, $after: String) {
|
|
89
|
+
search(type: ISSUE, query: $query, first: ${AGENT_PULLS_PAGE}, after: $after) {
|
|
90
|
+
pageInfo { endCursor } nodes { ... on PullRequest { ${AGENT_PULL_FIELDS} commits { totalCount } } }
|
|
91
|
+
}
|
|
92
|
+
}`
|
|
82
93
|
|
|
83
94
|
type Login = { login?: unknown; databaseId?: unknown } | null | undefined
|
|
84
|
-
type PullNode = { number?: unknown; title?: unknown; state?: unknown; isDraft?: unknown; createdAt?: unknown; updatedAt?: unknown; author?: Login; body?: unknown
|
|
85
|
-
headRefName?: unknown; isCrossRepository?: unknown; headRepositoryOwner?: Login; additions?: unknown; deletions?: unknown; commits?: { totalCount?: unknown
|
|
86
|
-
|
|
95
|
+
export type PullNode = { number?: unknown; title?: unknown; state?: unknown; isDraft?: unknown; createdAt?: unknown; updatedAt?: unknown; author?: Login; body?: unknown
|
|
96
|
+
headRefName?: unknown; isCrossRepository?: unknown; headRepositoryOwner?: Login; additions?: unknown; deletions?: unknown; commits?: { totalCount?: unknown } | null
|
|
97
|
+
last?: { nodes?: ({ commit?: { message?: unknown } | null } | null)[] } | null; mergeCommit?: { message?: unknown } | null
|
|
98
|
+
closingIssuesReferences?: { nodes?: ({ number?: unknown } | null)[] }; labels?: { nodes?: ({ name?: unknown } | null)[] } }
|
|
87
99
|
type GitActor = { email?: unknown; user?: Login }
|
|
88
100
|
type CommitNode = { oid?: unknown; messageHeadline?: unknown; message?: unknown; committedDate?: unknown; additions?: unknown; deletions?: unknown
|
|
89
101
|
author?: GitActor | null; associatedPullRequests?: { totalCount?: unknown } }
|
|
90
102
|
type IssueNode = { number?: unknown; title?: unknown; state?: unknown; createdAt?: unknown; updatedAt?: unknown; author?: Login }
|
|
91
103
|
type RefNode = { prefix?: unknown; name?: unknown; target?: { committedDate?: unknown; message?: unknown; author?: GitActor | null } | null }
|
|
92
104
|
type Connection<T> = { pageInfo?: { hasNextPage?: unknown; endCursor?: unknown }; nodes?: (T | null)[] }
|
|
93
|
-
|
|
105
|
+
// Whether a pull request brought each commit, `c<n>` the `n`th's.
|
|
106
|
+
const pulledQuery = (count: number) => `query($owner: String!, $name: String!, ${variablesOf('o', count, 'GitObjectID!')}) {
|
|
107
|
+
repository(owner: $owner, name: $name) {
|
|
108
|
+
${Array.from({ length: count }, (_, at) => `c${at}: object(oid: $o${at}) { ... on Commit { associatedPullRequests(first: 1) { totalCount } } }`).join('\n ')}
|
|
109
|
+
}
|
|
110
|
+
}`
|
|
111
|
+
type Pulled = { repository?: Record<string, { associatedPullRequests?: { totalCount?: unknown } } | null> | null }
|
|
112
|
+
type Page = { repository?: { defaultBranchRef?: { target?: { oid?: unknown; history?: Connection<CommitNode> } | null } | null
|
|
94
113
|
issues?: Connection<IssueNode> } | null }
|
|
114
|
+
type Found = { search?: { pageInfo?: { endCursor?: unknown }; nodes?: (PullNode | null)[] } | null } | null
|
|
95
115
|
type RefsPage = { repository?: { refs?: Connection<RefNode> } | null }
|
|
96
116
|
|
|
97
117
|
const count = (value: unknown) => Number.isSafeInteger(value) && (value as number) >= 0 ? value as number : 0
|
|
@@ -104,7 +124,8 @@ const nameOf = (value: unknown, max = MAX_NAME) => typeof value === 'string' &&
|
|
|
104
124
|
const loginOf = (author: Login) => typeof author?.login === 'string' && LOGIN.test(author.login) ? author.login : null
|
|
105
125
|
const PULL_STATES: Record<string, AgentPull['state']> = { OPEN: 'open', MERGED: 'merged', CLOSED: 'closed' }
|
|
106
126
|
|
|
107
|
-
|
|
127
|
+
// A pull request's row: what it is, and the marks an agent left on it.
|
|
128
|
+
export function pull(node: PullNode | null): AgentPull | null {
|
|
108
129
|
const at = number(node?.number)
|
|
109
130
|
if (!node || !at) return null
|
|
110
131
|
const author = accountOf(node.author)
|
|
@@ -114,7 +135,7 @@ function pull(node: PullNode | null): AgentPull | null {
|
|
|
114
135
|
fork: node.isCrossRepository === true ? loginOf(node.headRepositoryOwner) ?? `pull-${at}` : null,
|
|
115
136
|
additions: count(node.additions), deletions: count(node.deletions), commits: count(node.commits?.totalCount),
|
|
116
137
|
closes: (node.closingIssuesReferences?.nodes ?? []).flatMap(closed => number(closed?.number) ?? []),
|
|
117
|
-
signals: agentSignals([node.body, node.
|
|
138
|
+
signals: agentSignals([node.body, node.last?.nodes?.[0]?.commit?.message, node.mergeCommit?.message], APP_AGENTS, nameOf(node.headRefName),
|
|
118
139
|
author, (node.labels?.nodes ?? []).map(label => label?.name)) }
|
|
119
140
|
}
|
|
120
141
|
|
|
@@ -127,18 +148,67 @@ const accountOf = (actor: Login) => both(actorOf(actor))
|
|
|
127
148
|
const authorOf = (actor: GitActor | null | undefined) => both(commitAccount(actor))
|
|
128
149
|
|
|
129
150
|
// A commit to the default branch no pull request merged, where an agent left
|
|
130
|
-
// its marks on it, or Claude authored it.
|
|
151
|
+
// its marks on it, or Claude authored it (commitMarks).
|
|
131
152
|
function directCommit(node: CommitNode | null): AgentCommit | null {
|
|
132
153
|
if (!node || typeof node.oid !== 'string' || !SHA.test(node.oid) || count(node.associatedPullRequests?.totalCount) > 0) return null
|
|
133
154
|
const author = authorOf(node.author)
|
|
134
|
-
const signals =
|
|
135
|
-
if (
|
|
136
|
-
if (!signals.claude && !signals.codex && signals.agents.length === 0 && signals.sessions.length === 0 && signals.tasks.length === 0) return null
|
|
155
|
+
const signals = commitMarks(messageMarks(node.message), { name: null, email: node.author?.email, account: commitAccount(node.author) })
|
|
156
|
+
if (!signals) return null
|
|
137
157
|
return { oid: node.oid, title: nameOf(node.messageHeadline, MAX_TITLE) ?? '', at: timeOf(node.committedDate),
|
|
138
158
|
author: author?.login ?? loginOf(node.author?.user), authorId: author?.id ?? null,
|
|
139
159
|
additions: count(node.additions), deletions: count(node.deletions), signals }
|
|
140
160
|
}
|
|
141
161
|
|
|
162
|
+
// A commit of the history kept an agent marked (`signals`,
|
|
163
|
+
// repository-history.ts historyMarks), as one of the default branch's.
|
|
164
|
+
const keptCommit = (commit: HistoryCommit, signals: AgentSignals): AgentCommit => ({ oid: commit.sha, title: commit.subject, at: commit.committedAt,
|
|
165
|
+
author: commit.authorLogin, authorId: commit.authorId ?? null, additions: commit.additions ?? 0, deletions: commit.deletions ?? 0, signals })
|
|
166
|
+
|
|
167
|
+
// Whether a pull request brought each commit, kept by the commit.
|
|
168
|
+
export interface CommitPulls {
|
|
169
|
+
read(shas: string[]): Promise<Map<string, boolean>>
|
|
170
|
+
write(sha: string, pulled: boolean): Promise<void>
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// A commit GitHub has none of, as one rewritten away: its field null.
|
|
174
|
+
const notFound = (error: GraphqlError) => error?.type === 'NOT_FOUND'
|
|
175
|
+
|
|
176
|
+
// Those of `shas` a pull request brought: as kept (`kept`), the rest looked
|
|
177
|
+
// up, PULLED_COMMITS a query, and kept; one GitHub has none of, none.
|
|
178
|
+
async function pulledCommits(fullName: string, token: string, shas: string[], kept: CommitPulls, fetchImpl: typeof fetch) {
|
|
179
|
+
const [owner, name] = fullName.split('/') as [string, string]
|
|
180
|
+
const known = await kept.read(shas)
|
|
181
|
+
await inBatches(shas.filter(sha => !known.has(sha)), PULLED_COMMITS, PULLED_PARALLEL, async batch => {
|
|
182
|
+
const data = await githubGraphql(pulledQuery(batch.length), { owner, name, ...Object.fromEntries(batch.map((sha, at) => [`o${at}`, sha])) }, token, fetchImpl,
|
|
183
|
+
notFound) as Pulled
|
|
184
|
+
await Promise.all(batch.map((sha, at) => {
|
|
185
|
+
const node = data?.repository?.[`c${at}`]
|
|
186
|
+
if (!node) return Promise.resolve()
|
|
187
|
+
known.set(sha, count(node.associatedPullRequests?.totalCount) > 0)
|
|
188
|
+
return kept.write(sha, known.get(sha)!)
|
|
189
|
+
}))
|
|
190
|
+
})
|
|
191
|
+
return new Set(shas.filter(sha => known.get(sha) === true))
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Where commits listed stop: those the cursor named but left unread still
|
|
195
|
+
// unlisted (`left`, newer than those below the commits read from the rest),
|
|
196
|
+
// then the parents of those listed not listed, the newest first (each by its
|
|
197
|
+
// child's time), as the Commits tab reads on from (repository-history.js):
|
|
198
|
+
// each read in its turn, as many as ALL_BRANCHES an answer, none dropped
|
|
199
|
+
// till MAX_FRONTIER are left (a cursor's length bounded, a URL's); null
|
|
200
|
+
// where none are.
|
|
201
|
+
function frontier(commits: HistoryCommit[], left: string[]) {
|
|
202
|
+
const listed = new Set(commits.map(commit => commit.sha))
|
|
203
|
+
const at = new Map<string, number>()
|
|
204
|
+
for (const commit of commits) {
|
|
205
|
+
for (const parent of commit.parents) if (!listed.has(parent)) at.set(parent, Math.max(at.get(parent) ?? Number.NEGATIVE_INFINITY, commit.committedAt ?? 0))
|
|
206
|
+
}
|
|
207
|
+
const below = [...at].toSorted((a, b) => b[1] - a[1]).map(([sha]) => sha)
|
|
208
|
+
const starts = [...new Set([...left.filter(sha => !listed.has(sha)), ...below])].slice(0, MAX_FRONTIER)
|
|
209
|
+
return starts.length > 0 ? starts.join(',') : null
|
|
210
|
+
}
|
|
211
|
+
|
|
142
212
|
function issue(node: IssueNode | null): AgentIssue | null {
|
|
143
213
|
const at = number(node?.number)
|
|
144
214
|
if (!node || !at) return null
|
|
@@ -175,46 +245,108 @@ async function agentBranches(owner: string, name: string, prefix: string, token:
|
|
|
175
245
|
|
|
176
246
|
// What its answers are kept by, with their cursors (http.ts
|
|
177
247
|
// handleRepositoryAgents): bumped as what a page reads, or how it tells an
|
|
178
|
-
// agent, changes
|
|
179
|
-
|
|
248
|
+
// agent, changes (with AGENT_MARKS_READ, app-agents.ts, where commits' marks
|
|
249
|
+
// do), so that no answer an older server kept is sent for a day.
|
|
250
|
+
export const AGENTS_READ_VERSION = 9
|
|
251
|
+
|
|
252
|
+
// A cursor of the default branch's commits: those the pages before stopped
|
|
253
|
+
// at (frontier), as many as MAX_FRONTIER, commas between, the first
|
|
254
|
+
// ALL_BRANCHES of them read next; one an older server gave, GitHub's,
|
|
255
|
+
// `<oid> <offset>`, read on from its commit.
|
|
256
|
+
const MAX_FRONTIER = 4 * ALL_BRANCHES
|
|
257
|
+
export const COMMITS_CURSOR = new RegExp(`^(?:[\\da-f]{40}(?:,[\\da-f]{40}){0,${MAX_FRONTIER - 1}}|[\\da-f]{40} \\d{1,9})$`, 'u')
|
|
258
|
+
const startsOf = (cursor: string) => cursor.split(/[ ,]/u).filter(part => SHA.test(part))
|
|
180
259
|
|
|
181
|
-
// A cursor of the
|
|
182
|
-
//
|
|
183
|
-
|
|
260
|
+
// A cursor of the pull requests: the last listed, by when it was updated and
|
|
261
|
+
// its number (`<ms>.<number>`), those below it read next; of a search's page
|
|
262
|
+
// all updated together, the time it searched by and where its page ended too
|
|
263
|
+
// (`~<ms>~<cursor>`), those updated with them read on through the same
|
|
264
|
+
// search; any other, as an older server gave (GitHub's), read from the
|
|
265
|
+
// newest again.
|
|
266
|
+
const POSITION = /^(\d{1,15})\.(\d{1,10})(?:~(\d{1,15})~([\w+/=-]{1,200}))?$/u
|
|
267
|
+
const positionOf = (item: AgentPull) => `${item.updatedAt ?? 0}.${item.number}`
|
|
268
|
+
// Those updated by a time, as GitHub's search takes it, to the second; any
|
|
269
|
+
// where it names none.
|
|
270
|
+
const updatedBy = (at: number) => Number.isFinite(at) ? ` updated:<=${new Date(at).toISOString().replace(/\.\d{3}Z$/u, '+00:00')}` : ''
|
|
184
271
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
272
|
+
// The repository's pull requests as kept (repository-pulls.ts PullList),
|
|
273
|
+
// read as far back as their first page where none are, and those updated
|
|
274
|
+
// since read as they are (http.ts keptPulls).
|
|
275
|
+
export type KeptPulls = () => Promise<PullList>
|
|
276
|
+
|
|
277
|
+
// A page of the repository's pull requests, the most recently updated
|
|
278
|
+
// first, below the one `cursor` names (none: from the newest): of those kept
|
|
279
|
+
// (`kept`), as far back as every one is read; past them, as GitHub's search
|
|
280
|
+
// finds those updated before, a page a query. With where it reads on from,
|
|
281
|
+
// null where none are left: past every one kept where all are.
|
|
282
|
+
async function agentPulls(fullName: string, token: string, cursor: string | null, kept: KeptPulls, fetchImpl: typeof fetch) {
|
|
283
|
+
const at = cursor === null ? null : POSITION.exec(cursor)
|
|
284
|
+
const [time, past] = at ? [Number(at[1]), Number(at[2])] : [Number.POSITIVE_INFINITY, 0]
|
|
285
|
+
const below = (item: AgentPull) => (item.updatedAt ?? 0) < time || ((item.updatedAt ?? 0) === time && item.number < past)
|
|
286
|
+
const list = await kept()
|
|
287
|
+
// Since when every one updated is read (as repository-pulls.ts pullsReadTo
|
|
288
|
+
// tells, which reads its pages by these fields): all since the year's
|
|
289
|
+
// start, -∞.
|
|
290
|
+
const read = list.next ? list.next.to ?? Number.POSITIVE_INFINITY : list.reach ?? Number.NEGATIVE_INFINITY
|
|
291
|
+
// As far back as every one is read: the year's start, where all since are.
|
|
292
|
+
const bottom = Math.max(read, list.since)
|
|
293
|
+
const listed = list.pulls.filter(item => below(item) && (item.updatedAt ?? 0) >= bottom)
|
|
294
|
+
const whole = list.all === true && read === Number.NEGATIVE_INFINITY
|
|
295
|
+
if (listed.length > 0) {
|
|
296
|
+
const page = listed.slice(0, AGENT_PULLS_PAGE)
|
|
297
|
+
return { pulls: page, after: listed.length > page.length || !whole ? positionOf(page.at(-1)!) : null }
|
|
298
|
+
}
|
|
299
|
+
if (whole) return { pulls: [], after: null }
|
|
300
|
+
// Those updated by when the last was, those updated with it again among
|
|
301
|
+
// them (GitHub orders those by no number), the last itself left out; a
|
|
302
|
+
// page all updated together read on through the same search, past them.
|
|
303
|
+
const [by, through] = at?.[3] === undefined ? [Math.min(time, bottom), null] : [Number(at[3]), at[4]!]
|
|
304
|
+
const found = await githubGraphql(SEARCH_QUERY, { query: `repo:${fullName} is:pr sort:updated-desc${updatedBy(by)}`, after: through }, token, fetchImpl) as Found
|
|
305
|
+
const nodes = found?.search?.nodes ?? []
|
|
306
|
+
const rows = nodes.flatMap(node => pull(node) ?? [])
|
|
307
|
+
const last = rows.at(-1)
|
|
308
|
+
const ended = found?.search?.pageInfo?.endCursor
|
|
309
|
+
const together = Number.isFinite(by) && rows.every(item => item.updatedAt === last?.updatedAt) && typeof ended === 'string' && /^[\w+/=-]{1,200}$/u.test(ended)
|
|
310
|
+
const after = nodes.length >= AGENT_PULLS_PAGE && last ? `${positionOf(last)}${together ? `~${by}~${ended}` : ''}` : null
|
|
311
|
+
return { pulls: rows.filter(item => item.updatedAt !== time || item.number !== past), after: after === cursor ? null : after }
|
|
188
312
|
}
|
|
189
313
|
|
|
190
|
-
// A page of what the repository's agents left: its pull requests
|
|
191
|
-
// the cursor `pulls`
|
|
192
|
-
//
|
|
314
|
+
// A page of what the repository's agents left: its pull requests below the
|
|
315
|
+
// one the cursor `pulls` names, as kept (`kept`, agentPulls), its default
|
|
316
|
+
// branch's commits on from those `commits` names, as its history is kept
|
|
317
|
+
// (`pages`, historyFrom: the first page from its head), whether a pull
|
|
318
|
+
// request brought those marked kept (`pulled`) (the first page where
|
|
319
|
+
// neither is named, with its issues, its
|
|
193
320
|
// agents' branches and the commits the app agents' accounts authored, and
|
|
194
321
|
// the app agents it may name), and the cursors reading on from each, null
|
|
195
322
|
// where either is read whole.
|
|
196
|
-
export async function repositoryAgents(fullName: string, token: string, cursors: { pulls: string | null; commits: string | null },
|
|
197
|
-
fetchImpl: typeof fetch = globalThis.fetch) {
|
|
323
|
+
export async function repositoryAgents(fullName: string, token: string, cursors: { pulls: string | null; commits: string | null }, pages: HistoryPages,
|
|
324
|
+
pulled: CommitPulls, kept: KeptPulls, fetchImpl: typeof fetch = globalThis.fetch) {
|
|
198
325
|
const [owner, name] = fullName.split('/') as [string, string]
|
|
199
326
|
const first = cursors.pulls === null && cursors.commits === null
|
|
200
|
-
const [data, branches, authored] = await Promise.all([
|
|
201
|
-
githubGraphql(PAGE_QUERY, { owner, name,
|
|
202
|
-
readPulls: first || cursors.pulls !== null, readCommits: first || cursors.commits !== null }, token, fetchImpl) as Promise<Page>,
|
|
327
|
+
const [data, branches, authored, pulls] = await Promise.all([
|
|
328
|
+
first ? githubGraphql(PAGE_QUERY, { owner, name }, token, fetchImpl) as Promise<Page> : null,
|
|
203
329
|
first ? Promise.all(AGENT_BRANCH_PREFIXES.map(prefix => agentBranches(owner, name, prefix, token, fetchImpl))) : [],
|
|
204
330
|
first ? githubGraphql(AUTHORED_QUERY, { owner, name, emails: AGENT_EMAILS }, token, fetchImpl) as Promise<Page> : null,
|
|
331
|
+
first || cursors.pulls !== null ? agentPulls(fullName, token, cursors.pulls, kept, fetchImpl) : { pulls: [], after: null },
|
|
205
332
|
])
|
|
206
333
|
const repository = data?.repository
|
|
207
|
-
if (!repository) throw new GithubApiError(404, 'github-not-found')
|
|
208
|
-
const
|
|
209
|
-
const
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
const
|
|
334
|
+
if (data !== null && !repository) throw new GithubApiError(404, 'github-not-found')
|
|
335
|
+
const head = repository?.defaultBranchRef?.target?.oid
|
|
336
|
+
const named = cursors.commits === null ? first && typeof head === 'string' && SHA.test(head) ? [head] : [] : startsOf(cursors.commits)
|
|
337
|
+
const starts = named.slice(0, ALL_BRANCHES)
|
|
338
|
+
const history = starts.length > 0 ? (await historyFrom(fullName, token, starts, pages, fetchImpl)).commits : []
|
|
339
|
+
const marked = history.flatMap(commit => { const signals = historyMarks(commit); return signals ? [{ commit, signals }] : [] })
|
|
340
|
+
const nodes = authored?.repository?.defaultBranchRef?.target?.history?.nodes ?? []
|
|
341
|
+
const [brought] = await Promise.all([pulledCommits(fullName, token, marked.map(({ commit }) => commit.sha), pulled, fetchImpl),
|
|
342
|
+
linkNoreplyUsers(nodes.map(node => node?.author), token, fetchImpl)])
|
|
343
|
+
const commits = [...marked.flatMap(({ commit, signals }) => brought.has(commit.sha) ? [] : [keptCommit(commit, signals)]), ...nodes.flatMap(node => directCommit(node) ?? [])]
|
|
344
|
+
const after = { pulls: pulls.after, commits: frontier(history, named.slice(ALL_BRANCHES)) }
|
|
213
345
|
return {
|
|
214
|
-
pulls:
|
|
346
|
+
pulls: pulls.pulls.map(({ merged: _merged, ...row }: AgentPull & { merged?: unknown }) => row),
|
|
215
347
|
commits: commits.filter((commit, index) => commits.findIndex(other => other.oid === commit.oid) === index),
|
|
216
348
|
...first ? {
|
|
217
|
-
issues: (repository
|
|
349
|
+
issues: (repository?.issues?.nodes ?? []).flatMap(node => issue(node) ?? []),
|
|
218
350
|
branches: branches.flat(),
|
|
219
351
|
agents: APP_AGENTS.map(({ id, name: agent, avatar }): AppAgent => ({ id, name: agent, ...avatar ? { avatar } : {} })),
|
|
220
352
|
} : {},
|
|
@@ -9,7 +9,10 @@
|
|
|
9
9
|
// read a page from a commit at a time, each page kept by the commit it starts
|
|
10
10
|
// at (historyFrom): a commit's history never changes, and where a commit read
|
|
11
11
|
// starts a page kept, that page glues on, and those after it.
|
|
12
|
-
import { type
|
|
12
|
+
import { type AgentSignals, agentSignals } from '../common/managed/agent-tasks.ts'
|
|
13
|
+
import { githubAgent } from '../common/managed/github-agents.ts'
|
|
14
|
+
import { AGENT_MARKS_READ, APP_AGENTS } from './app-agents.ts'
|
|
15
|
+
import { type CommitFacts, MAX_COAUTHORS, type Maker, coauthorFacts, coauthorTrailers } from './commit-coauthors.ts'
|
|
13
16
|
import { GithubApiError, githubJson } from './github-app.ts'
|
|
14
17
|
import { type GraphActor, accountOf, commitAccount, linkNoreplyUsers } from './github-accounts.ts'
|
|
15
18
|
import { githubGraphql } from './github-graphql.ts'
|
|
@@ -33,14 +36,18 @@ const MAX_SUBJECT = 1000
|
|
|
33
36
|
const MAX_NAME = 256
|
|
34
37
|
const SHA = /^[a-f\d]{40}$/u
|
|
35
38
|
|
|
36
|
-
// A commit, its author by name and
|
|
37
|
-
// (`authorId
|
|
38
|
-
// (
|
|
39
|
-
// it adds and removes and when it was authored in
|
|
40
|
-
// (`authorZone`, minutes east of UTC), where
|
|
41
|
-
// (null where its REST API lists it)
|
|
42
|
-
|
|
43
|
-
|
|
39
|
+
// A commit, its author by name, address (`authorEmail`) and their account's
|
|
40
|
+
// login and numeric id (`authorId`), and who it names beside them
|
|
41
|
+
// (commit-coauthors.ts coauthorFacts), each absent from pages kept before it
|
|
42
|
+
// was read, with the lines it adds and removes and when it was authored in
|
|
43
|
+
// its author's own time zone (`authorZone`, minutes east of UTC), where
|
|
44
|
+
// GitHub's GraphQL API tells them (null where its REST API lists it); and the
|
|
45
|
+
// marks an agent left in its message, where any (messageMarks), by the rules
|
|
46
|
+
// they were read by (`marked`, AGENT_MARKS_READ; absent from pages kept
|
|
47
|
+
// before). What of them is an agent's is told as they are answered
|
|
48
|
+
// (coauthorsOf, historyMarks), never kept.
|
|
49
|
+
export interface HistoryCommit extends CommitFacts { sha: string; parents: string[]; subject: string; committedAt: number | null; additions?: number | null
|
|
50
|
+
deletions?: number | null; authoredAt?: number | null; authorZone?: number | null; marks?: AgentSignals; marked?: number }
|
|
44
51
|
export interface HistoryRef { name: string; sha: string }
|
|
45
52
|
|
|
46
53
|
const textOf = (value: unknown, max: number) => typeof value === 'string' && value.trim() ? value.trim().slice(0, max) : null
|
|
@@ -49,6 +56,32 @@ const timeOf = (value: unknown) => {
|
|
|
49
56
|
return Number.isSafeInteger(at) ? at : null
|
|
50
57
|
}
|
|
51
58
|
|
|
59
|
+
// The address Claude's own commits are authored with.
|
|
60
|
+
export const CLAUDE_EMAIL = 'noreply@anthropic.com'
|
|
61
|
+
|
|
62
|
+
const marking = (marks: AgentSignals) => marks.claude || marks.codex || marks.agents.length > 0 || marks.sessions.length > 0 || marks.tasks.length > 0
|
|
63
|
+
|
|
64
|
+
// The marks an agent left in a commit's message (agent-tasks.ts
|
|
65
|
+
// agentSignals: sessions' and tasks' links, agents' lines and fingerprints),
|
|
66
|
+
// as its history keeps them; null for one none marked.
|
|
67
|
+
export function messageMarks(message: unknown): AgentSignals | null {
|
|
68
|
+
const marks = agentSignals([message], APP_AGENTS)
|
|
69
|
+
return marking(marks) ? marks : null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// The marks an agent left on a commit, as the Agents tab tells them: its
|
|
73
|
+
// message's (`marks`, messageMarks), its author an app agent's account, by
|
|
74
|
+
// the agents the app knows now, or Claude's address; null for one none marked.
|
|
75
|
+
export function commitMarks(marks: AgentSignals | null | undefined, { email, account }: Maker): AgentSignals | null {
|
|
76
|
+
const authored = account.login !== null && account.id !== null ? githubAgent(account.login, account.id) : null
|
|
77
|
+
const all = { sessions: marks?.sessions ?? [], tasks: marks?.tasks ?? [], claude: (marks?.claude ?? false) || email === CLAUDE_EMAIL, codex: marks?.codex ?? false,
|
|
78
|
+
agents: APP_AGENTS.flatMap(({ id }) => marks?.agents.includes(id) || authored === id ? [id] : []) }
|
|
79
|
+
return marking(all) ? all : null
|
|
80
|
+
}
|
|
81
|
+
// A commit's marks as its history keeps it (commitMarks).
|
|
82
|
+
export const historyMarks = (commit: HistoryCommit) => commitMarks(commit.marks,
|
|
83
|
+
{ name: commit.authorName, email: commit.authorEmail, account: { login: commit.authorLogin, id: commit.authorId ?? null } })
|
|
84
|
+
|
|
52
85
|
const lineCount = (value: unknown) => Number.isSafeInteger(value) && (value as number) >= 0 ? value as number : null
|
|
53
86
|
// The minutes east of UTC an ISO 8601 time is given in: `Z` none.
|
|
54
87
|
const zoneOf = (value: unknown) => {
|
|
@@ -58,24 +91,29 @@ const zoneOf = (value: unknown) => {
|
|
|
58
91
|
|
|
59
92
|
// A commit as the Commits tab reads it, its subject the message's first
|
|
60
93
|
// line, as `--oneline` shows, its author by name and account, and its
|
|
61
|
-
// co-authors (coauthorsOf, `listed` as GitHub's GraphQL API lists them)
|
|
62
|
-
//
|
|
63
|
-
//
|
|
94
|
+
// co-authors (coauthorsOf, `listed` as GitHub's GraphQL API lists them), and
|
|
95
|
+
// the marks an agent left on it, as the Agents tab reads them from the pages
|
|
96
|
+
// kept (commitMarks); null for one GitHub names no commit id for. With
|
|
97
|
+
// `stats`, the GraphQL API's, its lines and its author's time.
|
|
64
98
|
function historyCommit(sha: unknown, parents: unknown[], message: unknown, author: Maker, committedAt: unknown,
|
|
65
99
|
stats: { additions?: unknown; deletions?: unknown; authoredAt?: unknown } | null = null, listed: Maker[] | null = null): HistoryCommit | null {
|
|
66
100
|
if (typeof sha !== 'string' || !SHA.test(sha)) return null
|
|
67
101
|
const lines = stats ? { additions: lineCount(stats.additions), deletions: lineCount(stats.deletions), authoredAt: timeOf(stats.authoredAt),
|
|
68
102
|
authorZone: zoneOf(stats.authoredAt) } : { additions: null, deletions: null, authoredAt: null, authorZone: null }
|
|
103
|
+
const marks = messageMarks(message)
|
|
69
104
|
return {
|
|
70
105
|
sha,
|
|
71
106
|
parents: parents.filter((parent): parent is string => typeof parent === 'string' && SHA.test(parent)).slice(0, MAX_PARENTS),
|
|
72
107
|
subject: typeof message === 'string' ? message.replaceAll('\r\n', '\n').split('\n', 1)[0]!.trim().slice(0, MAX_SUBJECT) : '',
|
|
73
108
|
authorName: textOf(author.name, MAX_NAME),
|
|
109
|
+
authorEmail: textOf(author.email, MAX_NAME),
|
|
74
110
|
authorLogin: author.account.login,
|
|
75
111
|
authorId: author.account.id,
|
|
76
|
-
|
|
112
|
+
...coauthorFacts(message, listed),
|
|
77
113
|
committedAt: timeOf(committedAt),
|
|
78
114
|
...lines,
|
|
115
|
+
...marks ? { marks } : {},
|
|
116
|
+
marked: AGENT_MARKS_READ,
|
|
79
117
|
}
|
|
80
118
|
}
|
|
81
119
|
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// A repository's pull requests, the most recently updated first, as kept
|
|
2
|
+
// once for both of those that read them so (http.ts keptPulls): the Pulse,
|
|
3
|
+
// telling whose its year's commits are by those merged (agent-merges.ts
|
|
4
|
+
// agentMerges), and the Agents tab, its pages of them (repository-agents.ts
|
|
5
|
+
// agentPulls). Those updated since its year's start, a few queries an
|
|
6
|
+
// answer, the rest read by the next, and those updated since read as they
|
|
7
|
+
// are. Each kept as the Agents tab shows it (repository-agents.ts pull: the
|
|
8
|
+
// marks an agent left in its texts, never the texts) and, one merged, what
|
|
9
|
+
// it brought the default branch, as the Pulse tells it: an agent that
|
|
10
|
+
// commits as whoever ran it (Codex's own) leaves its commits no mark, its
|
|
11
|
+
// pull request's branch alone naming it (app-agents.ts
|
|
12
|
+
// AGENT_BRANCH_PREFIXES), or a label one who ran it gave it naming the agent
|
|
13
|
+
// (agent-tasks.ts labelAgents), as the Agents tab tells an agent's work; or
|
|
14
|
+
// its author, an account agents work as (github-agents.ts AGENT_OPERATED),
|
|
15
|
+
// or any GitHub App's bot.
|
|
16
|
+
import { type AgentPull, labelAgents } from '../common/managed/agent-tasks.ts'
|
|
17
|
+
import { GITHUB_AGENTS, agentOperated } from '../common/managed/github-agents.ts'
|
|
18
|
+
import { AGENT_BRANCH_PREFIXES } from './app-agents.ts'
|
|
19
|
+
import { AGENT_PULL_FIELDS, type PullNode, pull } from './repository-agents.ts'
|
|
20
|
+
import { githubGraphql } from './repository-history.ts'
|
|
21
|
+
|
|
22
|
+
// What the pull requests kept are read by, its version: bumped as what a
|
|
23
|
+
// page reads, or how one is told, changes, its pull requests read again
|
|
24
|
+
// (http.ts keptPulls keys them by AGENTS_READ_VERSION and the Pulse's
|
|
25
|
+
// PULSE_YEAR_VERSION too, as either changes how it tells an agent's).
|
|
26
|
+
export const PULLS_READ_VERSION = 1
|
|
27
|
+
// The pull requests read an answer, a page a query, as many queries as
|
|
28
|
+
// PULLS_QUERIES, the rest left for the next to ask; of each merged, as many
|
|
29
|
+
// of its latest commits as MERGED_COMMITS.
|
|
30
|
+
const PULLS_PAGE = 100
|
|
31
|
+
const PULLS_QUERIES = 5
|
|
32
|
+
const MERGED_COMMITS = 100
|
|
33
|
+
const LABEL_AGENTS = Object.keys(GITHUB_AGENTS)
|
|
34
|
+
const SHA = /^[a-f\d]{40}$/u
|
|
35
|
+
|
|
36
|
+
const count = (value: unknown) => Number.isSafeInteger(value) && (value as number) >= 0 ? value as number : 0
|
|
37
|
+
const timeOf = (value: unknown) => {
|
|
38
|
+
const at = typeof value === 'string' ? Date.parse(value) : Number.NaN
|
|
39
|
+
return Number.isSafeInteger(at) ? at : null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// A pull request as read: its row on the Agents tab, and one merged, what
|
|
43
|
+
// it brought the default branch (`merged`).
|
|
44
|
+
export type KeptPull = AgentPull & { merged?: Merged }
|
|
45
|
+
// What a merged pull request brought: its merge commit (`sha`), when it was
|
|
46
|
+
// merged (`at`) and whether it was an agent's (agentPull); an agent's merged
|
|
47
|
+
// with a merge commit, which a year counts none of, its own commits too
|
|
48
|
+
// (`own`, its latest MERGED_COMMITS); and of one of more commits whose merge
|
|
49
|
+
// commit has one parent, its commits' subjects, the oldest first, for those
|
|
50
|
+
// it was rebased as to be told by (agent-merges.ts agentCommits), those of
|
|
51
|
+
// its empty commits left out, as a rebase leaves them, and how many more it
|
|
52
|
+
// had than those read (`more`).
|
|
53
|
+
export interface Merged { sha: string; at: number; agent: boolean; own?: string[]; subjects?: string[]; more?: number }
|
|
54
|
+
// The pull requests updated since a year's start (`since`) as read so far
|
|
55
|
+
// (readPulls), the most recently updated first; when the newest read was
|
|
56
|
+
// last updated, those updated since read again (`through`, null till all
|
|
57
|
+
// are read once), and where those read reach back no further than was
|
|
58
|
+
// needed, when the oldest of them was updated (`reach`); whether every one
|
|
59
|
+
// the repository has is read (`all`); and a read not finished: where it goes
|
|
60
|
+
// on (`after`), when the newest it began with was updated (`from`), the
|
|
61
|
+
// oldest it reads to (`stop`), how many it has read (`read`) and when the
|
|
62
|
+
// oldest of them was updated (`to`).
|
|
63
|
+
export interface PullList { pulls: KeptPull[]; since: number; through: number | null; reach?: number; all?: true
|
|
64
|
+
next: { after: string; from: number; stop: number; read: number; to?: number } | null }
|
|
65
|
+
|
|
66
|
+
// Since when every pull request updated is read: all of them since the
|
|
67
|
+
// year's start, -∞; none yet, ∞.
|
|
68
|
+
export const pullsReadTo = (list: Pick<PullList, 'next' | 'reach'>) => list.next ? list.next.to ?? Number.POSITIVE_INFINITY : list.reach ?? Number.NEGATIVE_INFINITY
|
|
69
|
+
|
|
70
|
+
const PULLS_QUERY = `query($owner: String!, $name: String!, $after: String) {
|
|
71
|
+
repository(owner: $owner, name: $name) {
|
|
72
|
+
pullRequests(first: ${PULLS_PAGE}, after: $after, orderBy: { field: UPDATED_AT, direction: DESC }) {
|
|
73
|
+
pageInfo { hasNextPage endCursor }
|
|
74
|
+
nodes { ${AGENT_PULL_FIELDS} mergedAt mergeCommit { oid parents { totalCount } }
|
|
75
|
+
commits(last: ${MERGED_COMMITS}) { totalCount nodes { commit { oid messageHeadline changedFilesIfAvailable } } } }
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}`
|
|
79
|
+
type MergedNode = PullNode & { mergedAt?: unknown; mergeCommit?: { oid?: unknown; message?: unknown; parents?: { totalCount?: unknown } | null } | null
|
|
80
|
+
author?: PullNode['author'] & { __typename?: unknown }
|
|
81
|
+
commits?: { totalCount?: unknown; nodes?: ({ commit?: { oid?: unknown; messageHeadline?: unknown; changedFilesIfAvailable?: unknown } | null } | null)[] } | null }
|
|
82
|
+
type PullsPage = { repository?: { pullRequests?: { pageInfo?: { hasNextPage?: unknown; endCursor?: unknown }; nodes?: (MergedNode | null)[] } | null } | null }
|
|
83
|
+
|
|
84
|
+
const agentBranch = (name: unknown) => typeof name === 'string' && AGENT_BRANCH_PREFIXES.some(prefix => name.startsWith(prefix))
|
|
85
|
+
// Whether a pull request is an agent's: by its branch, a label naming one,
|
|
86
|
+
// or its author, an account agents work as or a bot (GitHub's GraphQL API
|
|
87
|
+
// naming a bot by its app's login alone, its type telling it).
|
|
88
|
+
const agentPull = ({ headRefName, labels, author }: MergedNode) => agentBranch(headRefName)
|
|
89
|
+
|| labelAgents((labels?.nodes ?? []).map(label => label?.name), LABEL_AGENTS).length > 0 || author?.__typename === 'Bot'
|
|
90
|
+
|| agentOperated(typeof author?.login === 'string' ? author.login : null, Number.isSafeInteger(author?.databaseId) ? author!.databaseId as number : null)
|
|
91
|
+
|
|
92
|
+
// What a pull request merged brought (Merged); null for one not merged.
|
|
93
|
+
function mergedOf(node: MergedNode): Merged | null {
|
|
94
|
+
const [at, sha] = [timeOf(node.mergedAt), node.mergeCommit?.oid]
|
|
95
|
+
if (at === null || typeof sha !== 'string' || !SHA.test(sha)) return null
|
|
96
|
+
const commits = (node.commits?.nodes ?? []).map(item => item?.commit)
|
|
97
|
+
const merging = count(node.mergeCommit?.parents?.totalCount) > 1
|
|
98
|
+
const copied = commits.filter(commit => commit?.changedFilesIfAvailable !== 0)
|
|
99
|
+
// Of one of more commits than its latest read, how many more it had.
|
|
100
|
+
const more = Math.max(0, count(node.commits?.totalCount) - commits.length)
|
|
101
|
+
const subjects = !merging && (copied.length > 1 || more > 0) && copied.length > 0 && copied.every(commit => typeof commit?.messageHeadline === 'string')
|
|
102
|
+
? copied.map(commit => commit!.messageHeadline as string) : null
|
|
103
|
+
const agent = agentPull(node)
|
|
104
|
+
const own = agent && merging ? commits.flatMap(commit => typeof commit?.oid === 'string' && SHA.test(commit.oid) ? [commit.oid] : []) : []
|
|
105
|
+
return { sha, at, agent, ...own.length > 0 ? { own } : {}, ...subjects ? { subjects, ...more > 0 ? { more } : {} } : {} }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// The most recently updated first, the higher number first of those
|
|
109
|
+
// updated together.
|
|
110
|
+
const newestFirst = (a: AgentPull, b: AgentPull) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0) || b.number - a.number
|
|
111
|
+
|
|
112
|
+
// The pull requests updated since `since`, read the most recently updated
|
|
113
|
+
// first (a pull request is updated when merged, or after), on from what was
|
|
114
|
+
// `kept`: till one updated before the year, or before the newest read before
|
|
115
|
+
// was, as many queries an answer as PULLS_QUERIES, a read not finished going
|
|
116
|
+
// on from where it was left by the next, or once those updated since `need`
|
|
117
|
+
// are all read; where those read reached no further back than they needed,
|
|
118
|
+
// and `need` is further, read again from the newest on, as far back as it
|
|
119
|
+
// needs. Each kept as last read, one updated before the year forgotten.
|
|
120
|
+
export async function readPulls(fullName: string, token: string, since: number, kept: PullList | null,
|
|
121
|
+
fetchImpl: typeof fetch = globalThis.fetch, need = since): Promise<PullList> {
|
|
122
|
+
const [owner, name] = fullName.split('/') as [string, string]
|
|
123
|
+
const pulls = new Map((kept?.pulls ?? []).filter(item => (item.updatedAt ?? 0) >= since).map(item => [item.number, item]))
|
|
124
|
+
// Every one read, as long as none was forgotten, the year begun since.
|
|
125
|
+
const whole = kept?.all === true && pulls.size === kept.pulls.length
|
|
126
|
+
const further = !kept?.next && kept?.reach !== undefined && kept.reach > need
|
|
127
|
+
const stop = kept?.next?.stop ?? (further ? since : Math.max(since, kept?.through ?? since))
|
|
128
|
+
const result = (through: number | null, next: PullList['next'], reach: number | undefined, all = whole): PullList => ({
|
|
129
|
+
pulls: [...pulls.values()].toSorted(newestFirst), since, through, ...reach === undefined ? {} : { reach }, ...all ? { all: true as const } : {}, next })
|
|
130
|
+
let { after = null, from = null, read = 0, to = null }: { after?: string | null; from?: number | null; read?: number; to?: number | null } = kept?.next ?? {}
|
|
131
|
+
for (let queries = 0; queries < PULLS_QUERIES; queries++) {
|
|
132
|
+
const page = (await githubGraphql(PULLS_QUERY, { owner, name, after }, token, fetchImpl) as PullsPage | null)?.repository?.pullRequests
|
|
133
|
+
const end = page?.pageInfo?.hasNextPage !== true || typeof page.pageInfo.endCursor !== 'string'
|
|
134
|
+
let crossed = false
|
|
135
|
+
for (const node of page?.nodes ?? []) {
|
|
136
|
+
const updated = timeOf(node?.updatedAt)
|
|
137
|
+
if (!node || updated === null) continue
|
|
138
|
+
if (updated < stop) { crossed = true; break }
|
|
139
|
+
from ??= updated
|
|
140
|
+
to = updated
|
|
141
|
+
read++
|
|
142
|
+
const row = pull(node)
|
|
143
|
+
const merged = mergedOf(node)
|
|
144
|
+
if (row) pulls.set(row.number, merged ? { ...row, merged } : row)
|
|
145
|
+
}
|
|
146
|
+
// Read as far back as it reads to: the year's start, every one since
|
|
147
|
+
// read; else as far as those read before reached. Read to the last
|
|
148
|
+
// the repository has, every one of them.
|
|
149
|
+
if (crossed || end) return result(from ?? stop, null, stop > since ? kept?.reach : undefined, (end && !crossed) || (whole && stop > since))
|
|
150
|
+
after = page!.pageInfo!.endCursor as string
|
|
151
|
+
// Read as far back as `need`: finished, so far back, those updated
|
|
152
|
+
// since read by the next as ever.
|
|
153
|
+
if (need > since && to !== null && to < need) return result(Math.max(from ?? stop, kept?.through ?? Number.NEGATIVE_INFINITY), null, to)
|
|
154
|
+
}
|
|
155
|
+
return result(kept?.through ?? null, { after: after!, from: from ?? stop, stop, read, ...to === null ? {} : { to } }, kept?.reach)
|
|
156
|
+
}
|