diff --git a/src/server/queue-builder.test.ts b/src/server/queue-builder.test.ts index 2656b07..656818f 100644 --- a/src/server/queue-builder.test.ts +++ b/src/server/queue-builder.test.ts @@ -114,6 +114,54 @@ describe('buildQueueState', () => { expect(perItem).toBeDefined() expect(perItem?.op).toMatchObject({ action: 'close', number: 5 }) expect(perItem?.filePath).toBe(filePath) + expect(perItem?.title).toBe('Sample') + }) + + it('populates entry.title from syncState for yml entries', async () => { + const dir = await createTempDir() + await writeFile(join(dir, 'execute.yml'), '- action: close\n number: 7\n', 'utf8') + + await saveSyncState(dir, { + version: 2, + repo: 'owner/repo', + items: { + 7: { + number: 7, + kind: 'issue', + state: 'open', + lastUpdatedAt: '2026-01-01T00:00:00Z', + lastSyncedAt: '2026-01-01T00:00:00Z', + filePath: 'issues/00007-needs-cleanup.md', + data: { + item: { + number: 7, + kind: 'issue', + state: 'open', + updatedAt: '2026-01-01T00:00:00Z', + createdAt: '2026-01-01T00:00:00Z', + closedAt: null, + title: 'Needs cleanup', + body: null, + author: 'alice', + labels: [], + assignees: [], + milestone: null, + }, + comments: [], + }, + }, + }, + executions: [], + }) + + const queue = await buildQueueState({ + storageDirAbsolute: dir, + executeFilePath: join(dir, 'execute.yml'), + }) + + const entry = queue.entries.find(e => e.op.number === 7) + expect(entry).toBeDefined() + expect(entry?.title).toBe('Needs cleanup') }) it('assigns stable ids based on content and source', async () => { diff --git a/src/server/queue-builder.ts b/src/server/queue-builder.ts index 8deba82..77dd71a 100644 --- a/src/server/queue-builder.ts +++ b/src/server/queue-builder.ts @@ -25,13 +25,14 @@ export async function buildQueueState(options: BuildQueueStateOptions): Promise< ]) const entries: QueueEntry[] = [] + const titleOf = (op: PendingOp): string | undefined => syncState.items[String(op.number)]?.data.item.title for (const [index, op] of yml.ops.entries()) - entries.push(buildEntry(op, 'execute.yml', index)) + entries.push(buildEntry(op, 'execute.yml', index, undefined, titleOf(op))) for (const [index, op] of md.ops.entries()) - entries.push(buildEntry(op, 'execute.md', index)) + entries.push(buildEntry(op, 'execute.md', index, undefined, titleOf(op))) for (const [index, op] of perItem.ops.entries()) { const filePath = syncState.items[String(op.number)]?.filePath - entries.push(buildEntry(op, 'per-item', index, filePath)) + entries.push(buildEntry(op, 'per-item', index, filePath, titleOf(op))) } return { @@ -58,12 +59,13 @@ async function readMdOps(path: string): Promise<{ ops: PendingOp[], warnings: st return { ops: parsed.ops, warnings: parsed.warnings } } -function buildEntry(op: PendingOp, source: QueueSource, index: number, filePath?: string): QueueEntry { +function buildEntry(op: PendingOp, source: QueueSource, index: number, filePath?: string, title?: string): QueueEntry { return { id: hash({ source, index, action: op.action, number: op.number }), source, index, op, filePath, + title, } } diff --git a/src/server/types.ts b/src/server/types.ts index ab0a1e8..8db215b 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -23,6 +23,8 @@ export interface QueueEntry { op: PendingOp /** Relative file path under the storage directory, for per-item entries. */ filePath?: string + /** Title of the target issue/PR, looked up from syncState. Absent if the item isn't synced. */ + title?: string } export interface QueueState { diff --git a/ui/components/hub/QueueList.vue b/ui/components/hub/QueueList.vue index e74487a..2eda478 100644 --- a/ui/components/hub/QueueList.vue +++ b/ui/components/hub/QueueList.vue @@ -1,15 +1,13 @@ + + diff --git a/ui/components/queue/op/Body.vue b/ui/components/queue/op/Body.vue new file mode 100644 index 0000000..bfab880 --- /dev/null +++ b/ui/components/queue/op/Body.vue @@ -0,0 +1,38 @@ + + + diff --git a/ui/components/queue/op/Labels.vue b/ui/components/queue/op/Labels.vue new file mode 100644 index 0000000..9b84298 --- /dev/null +++ b/ui/components/queue/op/Labels.vue @@ -0,0 +1,20 @@ + + + diff --git a/ui/components/queue/op/Merge.vue b/ui/components/queue/op/Merge.vue new file mode 100644 index 0000000..fec2f46 --- /dev/null +++ b/ui/components/queue/op/Merge.vue @@ -0,0 +1,33 @@ + + + diff --git a/ui/components/queue/op/Reaction.vue b/ui/components/queue/op/Reaction.vue new file mode 100644 index 0000000..16c5d55 --- /dev/null +++ b/ui/components/queue/op/Reaction.vue @@ -0,0 +1,38 @@ + + + diff --git a/ui/components/queue/op/Users.vue b/ui/components/queue/op/Users.vue new file mode 100644 index 0000000..0d493f3 --- /dev/null +++ b/ui/components/queue/op/Users.vue @@ -0,0 +1,21 @@ + + + diff --git a/ui/composables/useHubQueue.ts b/ui/composables/useHubQueue.ts index 5e30089..5a02ef7 100644 --- a/ui/composables/useHubQueue.ts +++ b/ui/composables/useHubQueue.ts @@ -10,7 +10,7 @@ const executing = ref(null) let installed = false function totalFor(g: HubQueueGroup): number { - return g.queue.entries.filter(e => e.source !== 'per-item').length + return g.queue.entries.length } export function useHubQueue() { diff --git a/ui/composables/useQueue.ts b/ui/composables/useQueue.ts index 338de43..0d8d6c4 100644 --- a/ui/composables/useQueue.ts +++ b/ui/composables/useQueue.ts @@ -5,10 +5,7 @@ import { useAppState } from './useAppState' export function useQueue() { const state = useAppState() - const entries = computed(() => { - const all = state.payload.value?.queue.entries ?? [] - return all.filter(e => e.source !== 'per-item') - }) + const entries = computed(() => state.payload.value?.queue.entries ?? []) const upCount = computed(() => entries.value.length) diff --git a/ui/uno.config.ts b/ui/uno.config.ts index 519e501..6580d9e 100644 --- a/ui/uno.config.ts +++ b/ui/uno.config.ts @@ -181,5 +181,15 @@ export default defineConfig({ 'i-octicon-alert-fill-16', 'i-octicon-x-16', 'i-octicon-play-16', + // Queue card action icons (ui/utils/actionMeta.ts) + source/meta icons + 'i-octicon-check-16', + 'i-octicon-unlock-16', + 'i-octicon-person-add-16', + 'i-octicon-git-merge-queue-16', + 'i-octicon-smiley-16', + 'i-octicon-file-code-16', + 'i-octicon-markdown-16', + 'i-octicon-clock-16', + 'i-octicon-stack-16', ], }) diff --git a/ui/utils/actionMeta.ts b/ui/utils/actionMeta.ts new file mode 100644 index 0000000..cefabca --- /dev/null +++ b/ui/utils/actionMeta.ts @@ -0,0 +1,40 @@ +import type { ActionName } from '#ghfs/action-colors' + +export interface ActionMeta { + label: string + icon: string +} + +export const ACTION_META: Record = { + 'close': { label: 'Close', icon: 'i-octicon-issue-closed-16' }, + 'close-with-comment': { label: 'Close with comment', icon: 'i-octicon-issue-closed-16' }, + 'reopen': { label: 'Reopen', icon: 'i-octicon-issue-reopened-16' }, + 'set-title': { label: 'Set title', icon: 'i-octicon-pencil-16' }, + 'set-body': { label: 'Set body', icon: 'i-octicon-pencil-16' }, + 'add-comment': { label: 'Add comment', icon: 'i-octicon-comment-16' }, + 'add-labels': { label: 'Add labels', icon: 'i-octicon-tag-16' }, + 'remove-labels': { label: 'Remove labels', icon: 'i-octicon-tag-16' }, + 'set-labels': { label: 'Set labels', icon: 'i-octicon-tag-16' }, + 'add-assignees': { label: 'Add assignees', icon: 'i-octicon-person-add-16' }, + 'remove-assignees': { label: 'Remove assignees', icon: 'i-octicon-person-16' }, + 'set-assignees': { label: 'Set assignees', icon: 'i-octicon-person-16' }, + 'set-milestone': { label: 'Set milestone', icon: 'i-octicon-milestone-16' }, + 'clear-milestone': { label: 'Clear milestone', icon: 'i-octicon-milestone-16' }, + 'lock': { label: 'Lock', icon: 'i-octicon-lock-16' }, + 'unlock': { label: 'Unlock', icon: 'i-octicon-unlock-16' }, + 'request-reviewers': { label: 'Request reviewers', icon: 'i-octicon-eye-16' }, + 'remove-reviewers': { label: 'Remove reviewers', icon: 'i-octicon-eye-16' }, + 'mark-ready-for-review': { label: 'Mark ready for review', icon: 'i-octicon-git-pull-request-16' }, + 'convert-to-draft': { label: 'Convert to draft', icon: 'i-octicon-git-pull-request-draft-16' }, + 'approve': { label: 'Approve', icon: 'i-octicon-check-16' }, + 'request-changes': { label: 'Request changes', icon: 'i-octicon-x-16' }, + 'review-comment': { label: 'Review comment', icon: 'i-octicon-comment-discussion-16' }, + 'merge': { label: 'Merge', icon: 'i-octicon-git-merge-16' }, + 'enqueue-merge': { label: 'Enqueue for merge', icon: 'i-octicon-git-merge-queue-16' }, + 'add-reaction': { label: 'Add reaction', icon: 'i-octicon-smiley-16' }, + 'remove-reaction': { label: 'Remove reaction', icon: 'i-octicon-smiley-16' }, +} + +export function actionMeta(action: ActionName | string): ActionMeta { + return ACTION_META[action as ActionName] ?? { label: action, icon: 'i-octicon-dot-16' } +} diff --git a/ui/utils/queueSummary.ts b/ui/utils/queueSummary.ts deleted file mode 100644 index 88f8c8f..0000000 --- a/ui/utils/queueSummary.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { REACTION_EMOJI } from '../../src/utils/reactions' - -export function summarizeQueueOp(op: Record): string { - const details: string[] = [] - if ('labels' in op && Array.isArray(op.labels)) - details.push((op.labels as string[]).join(', ')) - if ('assignees' in op && Array.isArray(op.assignees)) - details.push((op.assignees as string[]).map(a => `@${a}`).join(', ')) - if ('reviewers' in op && Array.isArray(op.reviewers)) - details.push((op.reviewers as string[]).map(r => `@${r}`).join(', ')) - if ('title' in op && typeof op.title === 'string') - details.push(`"${op.title}"`) - if ('body' in op && typeof op.body === 'string') - details.push(`"${op.body.slice(0, 60)}${op.body.length > 60 ? '…' : ''}"`) - if ('milestone' in op && op.milestone != null) - details.push(String(op.milestone)) - if (op.action === 'merge') - details.push(`(${(op.method as string) ?? 'squash'})`) - if (op.action === 'enqueue-merge') - details.push('(when ready)') - if ('reaction' in op && typeof op.reaction === 'string') { - const emoji = REACTION_EMOJI[op.reaction as keyof typeof REACTION_EMOJI] - details.push(emoji ? `${emoji} ${op.reaction}` : op.reaction) - } - if ('target' in op && op.target && typeof op.target === 'object') { - const t = op.target as { kind?: string, commentId?: number, reviewId?: string } - if (t.kind === 'comment' && t.commentId != null) - details.push(`on comment ${t.commentId}`) - else if (t.kind === 'review' && t.reviewId) - details.push('on review') - } - return details.join(' ') -}