-
Notifications
You must be signed in to change notification settings - Fork 0
feat(create-issue): stage project custom fields in native Create modal #53
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
Merged
Merged
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
eb27a73
feat(create-issue): stage project custom fields in native Create modal
fathiraz a0c0ec7
feat(create-issue): own Create flow via API with staged dynamic fields
fathiraz f1bff9c
fix(schemas): resolve two drift bugs in message schema record
fathiraz 47bf1ef
refactor(schemas): add derived ProtocolMap type from Messages record
fathiraz 939777c
fix(create-issue): clear dirty form before close, dismiss discard-con…
fathiraz 6642088
chore: remove hardcoded org references, make project-agnostic
fathiraz edc3692
fix(create-issue): address cubic-dev-ai review comments on PR #53
fathiraz b077b83
fix(errors): surface GraphQL cause message on GithubGraphQLError
fathiraz bf4acb7
fix(create-issue): resolve assignees/labels beyond first-20 page
fathiraz File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,206 @@ | ||
| // ─── Create-issue handler ───────────────────────────────────────────────────── | ||
| // RGP owns the native "Create issue" flow: create → attach to project → apply | ||
| // staged custom fields, reading ids straight from the API responses (no DOM | ||
| // diffing, no network capture). Trimmed clone of duplicate-handlers.ts's | ||
| // create→attach→set-fields task sequence. | ||
|
|
||
| import { onMessage } from '@/lib/messages' | ||
| import type { CreateIssueWithFieldsMessageData } from '@/lib/messages' | ||
| import { gql } from '@/lib/graphql-client' | ||
| import { CLONE_ISSUE, ATTACH_TO_PROJECT, UPDATE_PROJECT_FIELD } from '@/lib/graphql-mutations' | ||
| import { GET_REPO_ASSIGNEES, GET_REPO_LABELS } from '@/lib/graphql-queries' | ||
| import { processQueue, sleep } from '@/lib/queue' | ||
| import type { QueueTask } from '@/lib/queue' | ||
| import { logger } from '@/lib/debug-logger' | ||
|
|
||
| import { isBulkFull, acquireBulk, releaseBulk } from '@/background/concurrency' | ||
| import { broadcastQueue, withRateLimitRetry } from '@/background/rest-helpers' | ||
| import { getRepositoryId } from '@/background/project-helpers' | ||
|
|
||
| // Resolves the dialog's assignee logins / label names to node ids before | ||
| // create. Best-effort: names that don't resolve are dropped rather than | ||
| // failing the create. | ||
| async function resolveAssigneeIds( | ||
| owner: string, | ||
| name: string, | ||
| logins: string[], | ||
| ): Promise<string[]> { | ||
| if (logins.length === 0) return [] | ||
| // Query per login with the login itself as the search filter — an unfiltered | ||
| // (q: '') query only returns the repo's first 20 assignable users, silently | ||
| // dropping any selected login that doesn't sort into that page. | ||
| const results = await Promise.all( | ||
| logins.map((login) => | ||
| gql<{ | ||
| repository: { assignableUsers: { nodes: { id: string; login: string }[] } } | ||
| }>(GET_REPO_ASSIGNEES, { owner, name, q: login }), | ||
| ), | ||
| ) | ||
| return results | ||
| .map( | ||
| (result, i) => | ||
| result.repository?.assignableUsers?.nodes?.find((u) => u.login === logins[i])?.id, | ||
| ) | ||
| .filter((id): id is string => Boolean(id)) | ||
| } | ||
|
|
||
| async function resolveLabelIds( | ||
| owner: string, | ||
| name: string, | ||
| labelNames: string[], | ||
| ): Promise<string[]> { | ||
| if (labelNames.length === 0) return [] | ||
| // Same fix as resolveAssigneeIds: query per label name so selections beyond | ||
| // the first 20 unfiltered results aren't silently dropped. | ||
| const results = await Promise.all( | ||
| labelNames.map((labelName) => | ||
| gql<{ | ||
| repository: { labels: { nodes: { id: string; name: string }[] } } | ||
| }>(GET_REPO_LABELS, { owner, name, q: labelName }), | ||
| ), | ||
| ) | ||
| return results | ||
| .map((result, i) => result.repository?.labels?.nodes?.find((l) => l.name === labelNames[i])?.id) | ||
| .filter((id): id is string => Boolean(id)) | ||
| } | ||
|
|
||
| function toFieldValue(value: unknown): Record<string, unknown> { | ||
| const { singleSelectOptionId, iterationId, text, date, number: num } = value as any | ||
| if (singleSelectOptionId) return { singleSelectOptionId } | ||
| if (iterationId) return { iterationId } | ||
| if (date !== undefined) return { date } | ||
| if (num !== undefined && num !== null) return { number: num } | ||
| return { text } | ||
| } | ||
|
|
||
| async function runCreateIssue(data: CreateIssueWithFieldsMessageData, tabId?: number) { | ||
| if (isBulkFull()) { | ||
| logger.warn('[rgp:bg] max concurrent bulk operations reached, rejecting create-issue') | ||
| return | ||
| } | ||
|
|
||
| acquireBulk() | ||
| const processId = `create-${Date.now()}-${Math.random().toString(36).slice(2, 7)}` | ||
| const totalSteps = 2 + data.updates.length | ||
| const label = `Create issue · ${data.title}` | ||
|
|
||
| await broadcastQueue( | ||
| { total: totalSteps, completed: 0, paused: false, status: 'Creating issue…', processId, label }, | ||
| tabId, | ||
| ) | ||
|
|
||
| try { | ||
| let newIssueId = '' | ||
| let newItemId = '' | ||
|
|
||
| const tasks: QueueTask[] = [ | ||
| { | ||
| id: 'create-issue', | ||
| detail: data.title, | ||
| run: async () => { | ||
| const repositoryId = await getRepositoryId(data.repoOwner, data.repoName) | ||
| const [assigneeIds, labelIds] = await Promise.all([ | ||
| resolveAssigneeIds(data.repoOwner, data.repoName, data.assignees ?? []), | ||
| resolveLabelIds(data.repoOwner, data.repoName, data.labels ?? []), | ||
| ]) | ||
| logger.log('[rgp:bg] creating issue', { repositoryId, title: data.title }) | ||
| interface CreateResult { | ||
| createIssue: { issue: { id: string; databaseId: number; number: number } } | ||
| } | ||
| // Deliberately NOT wrapped in withRateLimitRetry — a blind retry of a | ||
| // create mutation risks creating a duplicate issue. | ||
| const result = await gql<CreateResult>(CLONE_ISSUE, { | ||
| repositoryId, | ||
| title: data.title, | ||
| body: data.body, | ||
|
cubic-dev-ai[bot] marked this conversation as resolved.
|
||
| assigneeIds, | ||
| labelIds, | ||
| }) | ||
| newIssueId = result.createIssue.issue.id | ||
| await sleep(1000) | ||
| }, | ||
| }, | ||
| { | ||
| id: 'attach-project', | ||
| detail: data.repoName, | ||
| run: async () => { | ||
| logger.log('[rgp:bg] attaching to project', { newIssueId, projectId: data.projectId }) | ||
| interface AttachResult { | ||
| addProjectV2ItemById: { item: { id: string } } | ||
| } | ||
| const result = await withRateLimitRetry( | ||
| () => | ||
| gql<AttachResult>(ATTACH_TO_PROJECT, { | ||
| projectId: data.projectId, | ||
| contentId: newIssueId, | ||
| }), | ||
| tabId, | ||
| ) | ||
| newItemId = result.addProjectV2ItemById.item.id | ||
| await sleep(1000) | ||
| }, | ||
| }, | ||
| ...data.updates.map((update) => ({ | ||
| id: `field-${update.fieldId}`, | ||
| detail: data.fieldMeta?.[update.fieldId]?.name ?? update.fieldId, | ||
| run: async () => { | ||
| await withRateLimitRetry( | ||
| () => | ||
| gql(UPDATE_PROJECT_FIELD, { | ||
| projectId: data.projectId, | ||
| itemId: newItemId, | ||
| fieldId: update.fieldId, | ||
| value: toFieldValue(update.value), | ||
| }), | ||
| tabId, | ||
| ) | ||
| await sleep(1000) | ||
| }, | ||
| })), | ||
| ] | ||
|
|
||
| await processQueue( | ||
| tasks, | ||
| async (state) => { | ||
| await broadcastQueue( | ||
| { | ||
| total: totalSteps, | ||
| completed: state.completed, | ||
| paused: state.paused, | ||
| retryAfter: state.retryAfter, | ||
| status: state.completed === 0 ? 'Creating issue…' : 'Applying custom fields…', | ||
| detail: state.detail, | ||
| processId, | ||
| label, | ||
| failedItems: state.failedItems, | ||
| }, | ||
| tabId, | ||
| ) | ||
| }, | ||
| processId, | ||
| ) | ||
|
|
||
| await broadcastQueue( | ||
| { total: 0, completed: 0, paused: false, status: 'Done!', processId, label }, | ||
| tabId, | ||
| ) | ||
| logger.log('[rgp:bg] create-issue complete', { processId }) | ||
| } finally { | ||
| releaseBulk() | ||
| } | ||
| } | ||
|
|
||
| export function registerCreateIssueHandler(): void { | ||
| onMessage('createIssueWithFields', ({ data, sender }) => { | ||
| logger.log('[rgp:bg] createIssueWithFields received', { | ||
| title: data.title, | ||
| projectId: data.projectId, | ||
| }) | ||
| const tabId = sender.tab?.id | ||
| if (isBulkFull()) { | ||
| return { ok: false, reason: 'concurrent' as const } | ||
| } | ||
| void runCreateIssue(data, tabId) | ||
| return { ok: true as const } | ||
| }) | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.