Skip to content

Commit 23dbbb9

Browse files
If --id is not provided to shopify app bulk status, show all bulk operations
1 parent 04cb306 commit 23dbbb9

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

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

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

0 commit comments

Comments
 (0)