-
Notifications
You must be signed in to change notification settings - Fork 764
feat(publish): select findings and verify publication payloads #484
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
f15825c
0ae2930
6f789c3
c6272de
4bfc090
14478ee
ba069c3
da1d0b6
2c66d91
b5b97a9
a1e9b03
eede816
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,7 +3,7 @@ import { | |
| spawnSync, | ||
| type ChildProcessWithoutNullStreams, | ||
| } from "node:child_process"; | ||
| import { createHash, randomUUID } from "node:crypto"; | ||
| import { createHash, createHmac, randomUUID } from "node:crypto"; | ||
| import { | ||
| appendFile, | ||
| mkdir, | ||
|
|
@@ -73,6 +73,7 @@ export interface PublishScanOptions { | |
| projectId?: string; | ||
| linearApiKey?: string; | ||
| assigneeId?: string; | ||
| expectedDigest?: string; | ||
| dryRun?: boolean; | ||
| skipExisting?: boolean; | ||
| signal?: AbortSignal; | ||
|
|
@@ -127,6 +128,7 @@ export interface PublishScanResult { | |
| issues?: PreparedPublicationIssue[]; | ||
| indeterminate?: boolean; | ||
| warnings?: string[]; | ||
| payloadDigest: string; | ||
| } | ||
|
|
||
| export type CheckScanPublicationOptions = Pick< | ||
|
|
@@ -274,39 +276,59 @@ export async function publishScanInternal( | |
| options.signal?.throwIfAborted(); | ||
| const environment = dependencies.environment ?? process.env; | ||
| const linearApiKey = publicationApiKey(options, environment); | ||
| const approvedAssignee = | ||
| options.assigneeId === undefined | ||
| ? undefined | ||
| : { id: options.assigneeId, key: linearApiKey! }; | ||
|
|
||
| const preparedScan = await (dependencies.prepare ?? prepareScanPublication)( | ||
| scanDirectory, | ||
| { ...options, environment }, | ||
| ); | ||
| let prepared = preparedScan; | ||
| options.signal?.throwIfAborted(); | ||
| let prepared = selectPublicationFindings(preparedScan, options.findingIds); | ||
| const findingCount = prepared.issues.length; | ||
| let skipped: PublishedScanIssue[] | undefined; | ||
| if (options.skipExisting) { | ||
| const selected = new Set(prepared.issues.map((issue) => issue.findingId)); | ||
| skipped = ( | ||
| await (dependencies.inspectPublicationStore ?? inspectPublicationStore)( | ||
| preparedScan, | ||
| environment, | ||
| options.signal, | ||
| ) | ||
| ).filter((issue) => selected.has(issue.findingId)); | ||
| const recorded = new Set(skipped.map((issue) => issue.findingId)); | ||
| prepared = { | ||
| ...prepared, | ||
| issues: prepared.issues.filter((issue) => !recorded.has(issue.findingId)), | ||
| }; | ||
| options.signal?.throwIfAborted(); | ||
| } | ||
| const payloadDigest = publicationPayloadDigest(prepared, approvedAssignee); | ||
| if ( | ||
| options.expectedDigest !== undefined && | ||
| options.expectedDigest !== payloadDigest | ||
| ) { | ||
| throw new ConfigurationError( | ||
| "The prepared Linear publication does not match the expected digest. Review a new dry run before publishing.", | ||
| ); | ||
| } | ||
| const result: PublishScanResult = { | ||
| scanId: prepared.scanId, | ||
| uploadId: prepared.scanId, | ||
| destination: prepared.destination, | ||
| payloadDigest, | ||
| created: [], | ||
| failed: [], | ||
| ...(skipped === undefined ? {} : { skipped }), | ||
| counts: { | ||
| findings: prepared.issues.length, | ||
| findings: findingCount, | ||
| created: 0, | ||
| failed: 0, | ||
| ...(skipped === undefined ? {} : { skipped: skipped.length }), | ||
| }, | ||
| }; | ||
| if (options.skipExisting) { | ||
| result.skipped = await ( | ||
| dependencies.inspectPublicationStore ?? inspectPublicationStore | ||
| )(preparedScan, environment, options.signal); | ||
| result.counts.skipped = result.skipped.length; | ||
| const recorded = new Set(result.skipped.map((issue) => issue.findingId)); | ||
| prepared = { | ||
| ...preparedScan, | ||
| issues: preparedScan.issues.filter( | ||
| (issue) => !recorded.has(issue.findingId), | ||
| ), | ||
| }; | ||
| options.signal?.throwIfAborted(); | ||
| } | ||
| const saveReceipt = dependencies.writeReceipt ?? writePublicationReceipt; | ||
| if (options.dryRun) { | ||
| return { ...result, dryRun: true, issues: prepared.issues }; | ||
|
|
@@ -574,6 +596,66 @@ export async function publishScanInternal( | |
| return result; | ||
| } | ||
|
|
||
| function selectPublicationFindings( | ||
| publication: PreparedScanPublication, | ||
| findingIds: readonly string[] | undefined, | ||
| ): PreparedScanPublication { | ||
| if (findingIds === undefined) return publication; | ||
| if ( | ||
| !Array.isArray(findingIds) || | ||
| findingIds.some((id) => typeof id !== "string" || !id.trim()) | ||
| ) { | ||
| throw new ConfigurationError( | ||
| "Publication finding IDs must be nonempty strings.", | ||
| ); | ||
| } | ||
| const selected = new Set(findingIds); | ||
| const known = new Set(publication.issues.map((issue) => issue.findingId)); | ||
| for (const findingId of selected) { | ||
| if (!known.has(findingId)) { | ||
|
Comment on lines
+613
to
+615
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an explicitly selected finding has a saved classification with AGENTS.md reference: sdk/typescript/AGENTS.md:L14-L20 Useful? React with 👍 / 👎. |
||
| throw new ConfigurationError( | ||
| `Unknown publication finding ID: ${JSON.stringify(findingId)}.`, | ||
| ); | ||
| } | ||
| } | ||
| return { | ||
| ...publication, | ||
| issues: publication.issues.filter((issue) => selected.has(issue.findingId)), | ||
| }; | ||
| } | ||
|
|
||
| function publicationPayloadDigest( | ||
| publication: PreparedScanPublication, | ||
| assignee: { id: string; key: string } | undefined, | ||
| ): string { | ||
| const { destination } = publication; | ||
| const digest = | ||
| assignee === undefined | ||
| ? createHash("sha256") | ||
| : createHmac("sha256", assignee.key); | ||
| return digest | ||
| .update( | ||
| JSON.stringify({ | ||
| version: assignee === undefined ? 1 : 2, | ||
| scanId: publication.scanId, | ||
| destination: { | ||
| type: destination.type, | ||
| teamId: destination.teamId, | ||
| projectId: destination.projectId ?? null, | ||
| }, | ||
| assigneeId: assignee?.id ?? null, | ||
| issues: publication.issues.map((issue) => ({ | ||
| findingId: issue.findingId, | ||
| occurrenceId: issue.occurrenceId, | ||
| title: issue.title, | ||
| description: issue.description, | ||
| priority: issue.priority ?? null, | ||
| })), | ||
| }), | ||
| ) | ||
| .digest("hex"); | ||
| } | ||
|
|
||
| export async function checkScanPublication( | ||
| scanDirectory: string, | ||
| options: CheckScanPublicationOptions, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When callers supply the already-documented
--finding-id Atogether with the new--finding B, the latter silently overwritesfindingIdsin the publication options, so finding A is ignored rather than combined or rejected. Since--finding-idalready provides repeatable Linear finding selection and remains documented later in the README, introducing a second selector makes the public CLI ambiguous; reuse the existing flag instead.AGENTS.md reference: AGENTS.md:L39-L45
Useful? React with 👍 / 👎.