Skip to content

Commit 168f42f

Browse files
If --id is not provided to shopify app bulk status, show all bulk operations
1 parent 33a2e16 commit 168f42f

File tree

6 files changed

+290
-12
lines changed

6 files changed

+290
-12
lines changed
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/* eslint-disable @typescript-eslint/consistent-type-definitions, @typescript-eslint/no-redundant-type-constituents */
2+
import * as Types from './types.js'
3+
4+
import {TypedDocumentNode as DocumentNode} from '@graphql-typed-document-node/core'
5+
6+
export type ListBulkOperationsQueryVariables = Types.Exact<{
7+
query?: Types.InputMaybe<Types.Scalars['String']['input']>
8+
}>
9+
10+
export type ListBulkOperationsQuery = {
11+
bulkOperations: {
12+
nodes: {
13+
id: string
14+
status: Types.BulkOperationStatus
15+
errorCode?: Types.BulkOperationErrorCode | null
16+
objectCount: unknown
17+
createdAt: unknown
18+
completedAt?: unknown | null
19+
url?: string | null
20+
partialDataUrl?: string | null
21+
}[]
22+
}
23+
}
24+
25+
export const ListBulkOperations = {
26+
kind: 'Document',
27+
definitions: [
28+
{
29+
kind: 'OperationDefinition',
30+
operation: 'query',
31+
name: {kind: 'Name', value: 'ListBulkOperations'},
32+
variableDefinitions: [
33+
{
34+
kind: 'VariableDefinition',
35+
variable: {kind: 'Variable', name: {kind: 'Name', value: 'query'}},
36+
type: {kind: 'NamedType', name: {kind: 'Name', value: 'String'}},
37+
},
38+
],
39+
selectionSet: {
40+
kind: 'SelectionSet',
41+
selections: [
42+
{
43+
kind: 'Field',
44+
name: {kind: 'Name', value: 'bulkOperations'},
45+
arguments: [
46+
{kind: 'Argument', name: {kind: 'Name', value: 'first'}, value: {kind: 'IntValue', value: '100'}},
47+
{
48+
kind: 'Argument',
49+
name: {kind: 'Name', value: 'query'},
50+
value: {kind: 'Variable', name: {kind: 'Name', value: 'query'}},
51+
},
52+
{
53+
kind: 'Argument',
54+
name: {kind: 'Name', value: 'sortKey'},
55+
value: {kind: 'EnumValue', value: 'CREATED_AT'},
56+
},
57+
{kind: 'Argument', name: {kind: 'Name', value: 'reverse'}, value: {kind: 'BooleanValue', value: true}},
58+
],
59+
selectionSet: {
60+
kind: 'SelectionSet',
61+
selections: [
62+
{
63+
kind: 'Field',
64+
name: {kind: 'Name', value: 'nodes'},
65+
selectionSet: {
66+
kind: 'SelectionSet',
67+
selections: [
68+
{kind: 'Field', name: {kind: 'Name', value: 'id'}},
69+
{kind: 'Field', name: {kind: 'Name', value: 'status'}},
70+
{kind: 'Field', name: {kind: 'Name', value: 'errorCode'}},
71+
{kind: 'Field', name: {kind: 'Name', value: 'objectCount'}},
72+
{kind: 'Field', name: {kind: 'Name', value: 'createdAt'}},
73+
{kind: 'Field', name: {kind: 'Name', value: 'completedAt'}},
74+
{kind: 'Field', name: {kind: 'Name', value: 'url'}},
75+
{kind: 'Field', name: {kind: 'Name', value: 'partialDataUrl'}},
76+
{kind: 'Field', name: {kind: 'Name', value: '__typename'}},
77+
],
78+
},
79+
},
80+
{kind: 'Field', name: {kind: 'Name', value: '__typename'}},
81+
],
82+
},
83+
},
84+
],
85+
},
86+
},
87+
],
88+
} as unknown as DocumentNode<ListBulkOperationsQuery, ListBulkOperationsQueryVariables>
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
query ListBulkOperations($query: String) {
2+
bulkOperations(first: 100, query: $query, sortKey: CREATED_AT, reverse: true) {
3+
nodes {
4+
id
5+
status
6+
errorCode
7+
objectCount
8+
createdAt
9+
completedAt
10+
url
11+
partialDataUrl
12+
}
13+
}
14+
}

packages/app/src/cli/api/graphql/business-platform-organizations/generated/types.d.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
11
/* eslint-disable @typescript-eslint/consistent-type-definitions, @typescript-eslint/naming-convention, @typescript-eslint/no-explicit-any */
2-
import {JsonMapType} from '@shopify/cli-kit/node/toml'
3-
42
export type Maybe<T> = T | null
53
export type InputMaybe<T> = Maybe<T>
64
export type Exact<T extends {[key: string]: unknown}> = {[K in keyof T]: T[K]}
@@ -42,8 +40,6 @@ export type Scalars = {
4240
ISO8601Date: {input: any; output: any}
4341
/** An ISO 8601-encoded datetime */
4442
ISO8601DateTime: {input: any; output: any}
45-
/** Represents untyped JSON */
46-
JSON: {input: JsonMapType | string; output: JsonMapType}
4743
/** The ID for a LegalEntity. */
4844
LegalEntityID: {input: any; output: any}
4945
/** The ID for a OrganizationDomain. */

packages/app/src/cli/commands/app/bulk/status.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import {appFlags} from '../../../flags.js'
22
import AppLinkedCommand, {AppLinkedCommandOutput} from '../../../utilities/app-linked-command.js'
33
import {linkedAppContext} from '../../../services/app-context.js'
44
import {storeContext} from '../../../services/store-context.js'
5-
import {getBulkOperationStatus} from '../../../services/bulk-operations/bulk-operation-status.js'
5+
import {getBulkOperationStatus, listBulkOperations} from '../../../services/bulk-operations/bulk-operation-status.js'
66
import {Flags} from '@oclif/core'
77
import {globalFlags} from '@shopify/cli-kit/node/cli'
88
import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn'
@@ -20,7 +20,6 @@ export default class BulkStatus extends AppLinkedCommand {
2020
id: Flags.string({
2121
description: 'The bulk operation ID.',
2222
env: 'SHOPIFY_FLAG_ID',
23-
required: true,
2423
}),
2524
store: Flags.string({
2625
char: 's',
@@ -46,10 +45,16 @@ export default class BulkStatus extends AppLinkedCommand {
4645
forceReselectStore: flags.reset,
4746
})
4847

49-
await getBulkOperationStatus({
50-
storeFqdn: store.shopDomain,
51-
operationId: flags.id,
52-
})
48+
if (flags.id) {
49+
await getBulkOperationStatus({
50+
storeFqdn: store.shopDomain,
51+
operationId: flags.id,
52+
})
53+
} else {
54+
await listBulkOperations({
55+
storeFqdn: store.shopDomain,
56+
})
57+
}
5358

5459
return {app: appContextResult.app}
5560
}

packages/app/src/cli/services/bulk-operations/bulk-operation-status.test.ts

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import {getBulkOperationStatus} from './bulk-operation-status.js'
1+
import {getBulkOperationStatus, listBulkOperations} from './bulk-operation-status.js'
22
import {GetBulkOperationByIdQuery} from '../../api/graphql/bulk-operations/generated/get-bulk-operation-by-id.js'
3+
import {ListBulkOperationsQuery} from '../../api/graphql/bulk-operations/generated/list-bulk-operations.js'
34
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest'
45
import {ensureAuthenticatedAdmin} from '@shopify/cli-kit/node/session'
56
import {adminRequestDoc} from '@shopify/cli-kit/node/api/admin'
@@ -161,3 +162,114 @@ describe('getBulkOperationStatus', () => {
161162
})
162163
})
163164
})
165+
166+
describe('listBulkOperations', () => {
167+
const storeFqdn = 'test-store.myshopify.com'
168+
169+
beforeEach(() => {
170+
vi.mocked(ensureAuthenticatedAdmin).mockResolvedValue({token: 'test-token', storeFqdn})
171+
})
172+
173+
afterEach(() => {
174+
mockAndCaptureOutput().clear()
175+
})
176+
177+
function mockBulkOperationsList(
178+
operations: Partial<NonNullable<ListBulkOperationsQuery['bulkOperations']['nodes'][0]>>[],
179+
): ListBulkOperationsQuery {
180+
return {
181+
bulkOperations: {
182+
nodes: operations.map((op) => ({
183+
id: 'gid://shopify/BulkOperation/123',
184+
status: 'RUNNING',
185+
errorCode: null,
186+
objectCount: 100,
187+
createdAt: new Date().toISOString(),
188+
completedAt: null,
189+
url: null,
190+
partialDataUrl: null,
191+
...op,
192+
})),
193+
},
194+
}
195+
}
196+
197+
test('renders table with bulk operations', async () => {
198+
vi.mocked(adminRequestDoc).mockResolvedValue(
199+
mockBulkOperationsList([
200+
{
201+
id: 'gid://shopify/BulkOperation/1',
202+
status: 'COMPLETED',
203+
objectCount: 123500,
204+
createdAt: '2025-11-10T12:37:52Z',
205+
completedAt: '2025-11-10T16:37:12Z',
206+
url: 'https://example.com/results.jsonl',
207+
},
208+
{
209+
id: 'gid://shopify/BulkOperation/2',
210+
status: 'RUNNING',
211+
objectCount: 100,
212+
createdAt: '2025-11-11T15:37:52Z',
213+
},
214+
]),
215+
)
216+
217+
const output = mockAndCaptureOutput()
218+
await listBulkOperations({storeFqdn})
219+
220+
expect(output.output()).toMatch(/ion\/1/)
221+
expect(output.output()).toMatch(/ion\/2/)
222+
expect(output.output()).toMatch(/COMPLETE/)
223+
expect(output.output()).toContain('RUNNING')
224+
expect(output.output()).toContain('123.5')
225+
expect(output.output()).toMatch(/downloa/)
226+
})
227+
228+
test('formats large counts correctly', async () => {
229+
vi.mocked(adminRequestDoc).mockResolvedValue(
230+
mockBulkOperationsList([{objectCount: 1200000}, {objectCount: 5500}, {objectCount: 42}]),
231+
)
232+
233+
const output = mockAndCaptureOutput()
234+
await listBulkOperations({storeFqdn})
235+
236+
expect(output.output()).toContain('1.2M')
237+
expect(output.output()).toContain('5.5K')
238+
expect(output.output()).toContain('42')
239+
})
240+
241+
test('shows download for failed operations with partial results', async () => {
242+
vi.mocked(adminRequestDoc).mockResolvedValue(
243+
mockBulkOperationsList([
244+
{
245+
status: 'FAILED',
246+
errorCode: 'ACCESS_DENIED',
247+
partialDataUrl: 'https://example.com/partial.jsonl',
248+
completedAt: '2025-11-10T16:37:12Z',
249+
},
250+
]),
251+
)
252+
253+
const output = mockAndCaptureOutput()
254+
await listBulkOperations({storeFqdn})
255+
256+
expect(output.output()).toMatch(/FAIL/)
257+
expect(output.output()).toMatch(/downloa/)
258+
})
259+
260+
test('filters operations by last 7 days', async () => {
261+
vi.mocked(adminRequestDoc).mockResolvedValue(mockBulkOperationsList([]))
262+
263+
await listBulkOperations({storeFqdn})
264+
265+
const sevenDaysAgo = new Date()
266+
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7)
267+
const expectedDate = sevenDaysAgo.toISOString().split('T')[0]
268+
269+
expect(vi.mocked(adminRequestDoc)).toHaveBeenCalledWith(
270+
expect.objectContaining({
271+
variables: {query: `created_at:>=${expectedDate}`},
272+
}),
273+
)
274+
})
275+
})

packages/app/src/cli/services/bulk-operations/bulk-operation-status.ts

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,15 @@ import {
44
GetBulkOperationById,
55
GetBulkOperationByIdQuery,
66
} from '../../api/graphql/bulk-operations/generated/get-bulk-operation-by-id.js'
7-
import {renderInfo, renderSuccess, renderError} from '@shopify/cli-kit/node/ui'
7+
import {
8+
ListBulkOperations,
9+
ListBulkOperationsQuery,
10+
} from '../../api/graphql/bulk-operations/generated/list-bulk-operations.js'
11+
import {renderInfo, renderSuccess, renderError, renderTable} from '@shopify/cli-kit/node/ui'
812
import {outputContent, outputToken} from '@shopify/cli-kit/node/output'
913
import {ensureAuthenticatedAdmin} from '@shopify/cli-kit/node/session'
1014
import {adminRequestDoc} from '@shopify/cli-kit/node/api/admin'
15+
import {formatDate} from '@shopify/cli-kit/common/string'
1116

1217
const API_VERSION = '2026-01'
1318

@@ -87,3 +92,61 @@ function timeAgo(from: Date, to: Date): string {
8792
function formatTimeUnit(count: number, unit: string): string {
8893
return `${count} ${unit}${count === 1 ? '' : 's'}`
8994
}
95+
96+
interface ListBulkOperationsOptions {
97+
storeFqdn: string
98+
}
99+
100+
export async function listBulkOperations(options: ListBulkOperationsOptions): Promise<void> {
101+
const {storeFqdn} = options
102+
103+
const adminSession = await ensureAuthenticatedAdmin(storeFqdn)
104+
105+
const sevenDaysAgo = new Date()
106+
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7)
107+
const dateFilter = sevenDaysAgo.toISOString().split('T')[0]
108+
109+
const response = await adminRequestDoc<ListBulkOperationsQuery, {query?: string}>({
110+
query: ListBulkOperations,
111+
session: adminSession,
112+
variables: {query: `created_at:>=${dateFilter}`},
113+
version: API_VERSION,
114+
})
115+
116+
const operations = response.bulkOperations.nodes.map((op) => ({
117+
id: op.id,
118+
status: op.status,
119+
count: formatCount(op.objectCount),
120+
dateCreated: formatDate(new Date(String(op.createdAt))),
121+
dateFinished: op.completedAt ? formatDate(new Date(String(op.completedAt))) : '',
122+
results: formatResults(op.url, op.partialDataUrl),
123+
}))
124+
125+
renderTable({
126+
rows: operations,
127+
columns: {
128+
id: {header: 'ID'},
129+
status: {header: 'STATUS'},
130+
count: {header: 'COUNT'},
131+
dateCreated: {header: 'DATE CREATED'},
132+
dateFinished: {header: 'DATE FINISHED'},
133+
results: {header: 'RESULTS'},
134+
},
135+
})
136+
}
137+
138+
function formatCount(count: unknown): string {
139+
const num = Number(count)
140+
if (num >= 1000000) {
141+
return `${(num / 1000000).toFixed(1)}M`
142+
}
143+
if (num >= 1000) {
144+
return `${(num / 1000).toFixed(1)}K`
145+
}
146+
return String(num)
147+
}
148+
149+
function formatResults(url: string | null | undefined, partialDataUrl: string | null | undefined): string {
150+
const downloadUrl = url ?? partialDataUrl
151+
return downloadUrl ? 'download' : ''
152+
}

0 commit comments

Comments
 (0)