Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions .github/scripts/gardener-backlog.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
const projectQuery = `
query($cursor: String) {
organization(login: "shop") {
projectV2(number: 432) {
views(first: 100, after: $cursor) {
nodes { number filter }
pageInfo { hasNextPage endCursor }
}
}
}
}
`

const itemsQuery = `
query($filter: String!, $cursor: String) {
organization(login: "shop") {
projectV2(number: 432) {
items(first: 100, after: $cursor, query: $filter) {
nodes {
isArchived
status: fieldValueByName(name: "Status") {
... on ProjectV2ItemFieldSingleSelectValue { name }
}
area: fieldValueByName(name: "Product Area") {
... on ProjectV2ItemFieldSingleSelectValue { name }
}
priority: fieldValueByName(name: "Priority") {
... on ProjectV2ItemFieldSingleSelectValue { name }
}
content {
... on Issue {
number url state createdAt
repository { nameWithOwner }
}
}
}
pageInfo { hasNextPage endCursor }
}
}
}
}
`

async function readPages(graphql, query, connectionName, variables = {}) {
const nodes = []
let cursor = null
do {
const result = await graphql(query, {...variables, cursor})
const connection = result.organization?.projectV2?.[connectionName]
if (!connection) throw new Error('The project is not accessible.')
nodes.push(...connection.nodes)
cursor = connection.pageInfo.hasNextPage ? connection.pageInfo.endCursor : null
} while (cursor)
return nodes
}

function priorityRank(priority) {
return /^P\d+$/.test(priority ?? '') ? Number(priority.slice(1)) : Number.MAX_SAFE_INTEGER
}

export async function readCandidates(graphql) {
const views = await readPages(graphql, projectQuery, 'views')
const view = views.find(({number}) => number === 61)
if (!view?.filter) throw new Error('Gardener Backlog view 61 is missing or has no filter.')
const items = await readPages(graphql, itemsQuery, 'items', {filter: view.filter})
if (items.some((item) => item.content === null)) {
throw new Error('Some project items are inaccessible. Check private repository access.')
}

return items
.filter(
(item) =>
!item.isArchived &&
item.content?.state === 'OPEN' &&
item.area?.name === 'CLI' &&
['Backlog', 'Untriaged'].includes(item.status?.name) &&
item.priority?.name !== 'Deprioritized' &&
['shop/issues-develop', 'shop/issues'].includes(item.content.repository.nameWithOwner),
)
.map((item) => ({
...item.content,
repository: item.content.repository.nameWithOwner,
priority: item.priority?.name ?? null,
branch: `gardener-${item.content.repository.nameWithOwner.replace('/', '-')}-${item.content.number}`,
}))
.sort(
(left, right) =>
priorityRank(left.priority) - priorityRank(right.priority) ||
left.createdAt.localeCompare(right.createdAt) ||
left.url.localeCompare(right.url),
)
}

export function selectCandidate(candidates, pullRequests, repository) {
// PR records retain the head branch after deletion, including closed and merged PRs.
const existingBranches = new Set(
pullRequests
.filter((pullRequest) => pullRequest.head.repo?.full_name === repository)
.map((pullRequest) => pullRequest.head.ref),
)
return candidates.find((candidate) => !existingBranches.has(candidate.branch))
}

export async function readIssueContext(github, candidate) {
const [owner, repo] = candidate.repository.split('/')
const parameters = {owner, repo, issue_number: candidate.number}
const {data: issue} = await github.rest.issues.get(parameters)
if (issue.state !== 'open') return null
const comments = await github.paginate(github.rest.issues.listComments, {...parameters, per_page: 100})
return {
url: issue.html_url,
title: issue.title,
body: issue.body,
priority: candidate.priority,
comments: comments.map(({body}) => body),
}
}
174 changes: 174 additions & 0 deletions .github/scripts/gardener-backlog.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import assert from 'node:assert/strict'
import {test} from 'node:test'
import {readCandidates, readIssueContext, selectCandidate} from './gardener-backlog.js'

function item(number, priority, overrides = {}) {
return {
isArchived: false,
status: {name: 'Backlog'},
area: {name: 'CLI'},
priority: priority ? {name: priority} : null,
content: {
number,
url: `https://github.com/shop/issues-develop/issues/${number}`,
state: 'OPEN',
createdAt: '2026-01-01T00:00:00Z',
repository: {nameWithOwner: 'shop/issues-develop'},
},
...overrides,
}
}

function connection(name, nodes, endCursor = null) {
return {
organization: {
projectV2: {[name]: {nodes, pageInfo: {hasNextPage: endCursor !== null, endCursor}}},
},
}
}

function board(items) {
return async (query) =>
query.includes('views(')
? connection('views', [{number: 61, filter: '-status:Done/Deployed project:Gardener'}])
: connection('items', items)
}

test('orders numbered priorities numerically, then unprioritized work, with oldest first on ties', async () => {
const older = item(9, 'P1')
older.content.createdAt = '2025-01-01T00:00:00Z'
const candidates = await readCandidates(
board([item(1, null), item(2, 'P10'), item(3, 'P2'), item(4, 'P0'), item(5, 'P1'), older]),
)
assert.deepEqual(
candidates.map(({number}) => number),
[4, 9, 5, 3, 2, 1],
)
})

test('only selects open CLI backlog work from supported issue repositories', async () => {
const closed = item(2, 'P0')
closed.content.state = 'CLOSED'
const otherRepository = item(3, 'P0')
otherRepository.content.repository.nameWithOwner = 'shop/world'
const candidates = await readCandidates(
board([
item(1, 'P0', {isArchived: true}),
closed,
otherRepository,
item(4, 'P0', {area: {name: 'Dev Dashboard'}}),
item(5, 'P0', {status: {name: 'In progress'}}),
item(6, 'P0', {status: {name: 'Blocked'}}),
item(7, 'P0', {status: {name: 'In Review'}}),
item(8, 'P0', {status: {name: 'Merged'}}),
item(9, 'Deprioritized'),
item(10, 'P0', {content: {}}),
item(11, 'P2'),
item(12, null, {status: {name: 'Untriaged'}}),
]),
)
assert.deepEqual(
candidates.map(({number}) => number),
[11, 12],
)
})

test('reads all view and item pages and uses the current view filter', async () => {
const cursors = []
const candidates = await readCandidates(async (query, variables) => {
cursors.push(variables.cursor)
if (query.includes('views(')) {
return variables.cursor
? connection('views', [{number: 61, filter: 'project:Gardener status:Backlog'}])
: connection('views', [{number: 1, filter: 'unrelated'}], 'next-view')
}
assert.equal(variables.filter, 'project:Gardener status:Backlog')
return variables.cursor ? connection('items', [item(2, 'P0')]) : connection('items', [item(1, 'P3')], 'next-item')
})
assert.deepEqual(cursors, [null, 'next-view', null, 'next-item'])
assert.deepEqual(
candidates.map(({number}) => number),
[2, 1],
)
})

test('does not turn token failures or a missing project/view into an empty backlog', async () => {
await assert.rejects(
readCandidates(async () => {
throw new Error('Forbidden')
}),
/Forbidden/,
)
await assert.rejects(
readCandidates(async () => ({organization: {projectV2: null}})),
/not accessible/,
)
await assert.rejects(
readCandidates(async () => connection('views', [])),
/view 61/,
)
await assert.rejects(readCandidates(board([item(1, 'P0', {content: null})])), /inaccessible/)
})

test('empty backlog has no candidate', async () => {
assert.deepEqual(await readCandidates(board([])), [])
assert.equal(selectCandidate([], [], 'Shopify/cli'), undefined)
})

test('skips previous open, closed, and merged PR branches, including deleted branches', async () => {
const candidates = await readCandidates(board([item(1, 'P0'), item(2, 'P1'), item(3, 'P2'), item(4, 'P3')]))
const pullRequests = candidates.slice(0, 3).map(({branch}, index) => ({
state: index === 0 ? 'open' : 'closed',
merged_at: index === 2 ? '2026-01-01T00:00:00Z' : null,
head: {ref: branch, repo: {full_name: 'Shopify/cli'}},
}))
assert.equal(selectCandidate(candidates, pullRequests, 'Shopify/cli').number, 4)
assert.equal(selectCandidate(candidates.slice(0, 3), pullRequests, 'Shopify/cli'), undefined)
})

test('fork branches cannot claim an issue by matching its branch name', async () => {
const candidates = await readCandidates(board([item(1, 'P0')]))
const pullRequests = [{head: {ref: candidates[0].branch, repo: {full_name: 'someone/cli'}}}]
assert.equal(selectCandidate(candidates, pullRequests, 'Shopify/cli').number, 1)
})

test('issue branches distinguish the two source repositories', async () => {
const anotherRepository = item(1, 'P0')
anotherRepository.content.repository.nameWithOwner = 'shop/issues'
const candidates = await readCandidates(board([item(1, 'P0'), anotherRepository]))
assert.equal(new Set(candidates.map(({branch}) => branch)).size, 2)
})

test('reads the selected issue and paginates comments with its repository credentials', async () => {
const [candidate] = await readCandidates(board([item(1, 'P0')]))
const listComments = () => {}
const github = {
rest: {
issues: {
get: async (parameters) => {
assert.deepEqual(parameters, {owner: 'shop', repo: 'issues-develop', issue_number: 1})
return {data: {state: 'open', title: 'Example issue', body: 'Issue details', html_url: candidate.url}}
},
listComments,
},
},
paginate: async (method, parameters) => {
assert.equal(method, listComments)
assert.equal(parameters.per_page, 100)
return [{body: 'Clarification'}, {body: 'Acceptance criteria'}]
},
}
assert.deepEqual(await readIssueContext(github, candidate), {
url: candidate.url,
title: 'Example issue',
body: 'Issue details',
priority: 'P0',
comments: ['Clarification', 'Acceptance criteria'],
})
})

test('does not implement an issue closed after selection', async () => {
const [candidate] = await readCandidates(board([item(1, 'P0')]))
const github = {rest: {issues: {get: async () => ({data: {state: 'closed'}})}}}
assert.equal(await readIssueContext(github, candidate), null)
})
Loading
Loading