diff --git a/.github/workflows/issue-triage.yml b/.github/workflows/issue-triage.yml new file mode 100644 index 0000000..748ff5e --- /dev/null +++ b/.github/workflows/issue-triage.yml @@ -0,0 +1,476 @@ +name: Issue Triage + +on: + workflow_call: + inputs: + app-name: + description: "Nextcloud app ID (e.g. openregister, pipelinq)" + required: true + type: string + project-number: + description: "GitHub project number for the board" + required: false + type: number + default: 1 + project-owner: + description: "GitHub org that owns the project" + required: false + type: string + default: "ConductionNL" + triage-assignee: + description: "GitHub username to auto-assign triage issues to" + required: false + type: string + default: "rubenvdlinde" + backlog-existing: + description: "Set to true to triage all existing untriaged open issues" + required: false + type: boolean + default: false + secrets: + PROJECT_TOKEN: + description: "GitHub token with project and issues scope" + required: true + +jobs: + triage-single: + name: Triage New Issue + runs-on: ubuntu-latest + permissions: + issues: write + contents: read + if: >- + inputs.backlog-existing == false && + github.event_name == 'issues' && + github.event.action == 'opened' && + !contains(github.event.issue.labels.*.name, 'openspec') + + steps: + - name: Triage new issue + uses: actions/github-script@v7 + env: + PROJECT_TOKEN: ${{ secrets.PROJECT_TOKEN }} + PROJECT_NUMBER: ${{ inputs.project-number }} + PROJECT_OWNER: ${{ inputs.project-owner }} + APP_NAME: ${{ inputs.app-name }} + TRIAGE_ASSIGNEE: ${{ inputs.triage-assignee }} + with: + github-token: ${{ secrets.PROJECT_TOKEN }} + script: | + const projectNumber = parseInt(process.env.PROJECT_NUMBER); + const projectOwner = process.env.PROJECT_OWNER; + const appName = process.env.APP_NAME; + const triageAssignee = process.env.TRIAGE_ASSIGNEE; + const issue = context.payload.issue; + + console.log(`Triaging issue #${issue.number}: ${issue.title}`); + + // --- Helper: add issue to project (no status — triage view is separate) --- + async function addToProject(issueNumber) { + const projectQuery = await github.graphql(` + query($owner: String!, $number: Int!) { + organization(login: $owner) { + projectV2(number: $number) { + id + fields(first: 30) { + nodes { + ... on ProjectV2SingleSelectField { id name options { id name } } + ... on ProjectV2Field { id name dataType } + } + } + } + } + } + `, { owner: projectOwner, number: projectNumber }); + + const project = projectQuery.organization.projectV2; + const projectId = project.id; + const appField = project.fields.nodes.find(f => f.name === 'App' && f.options); + const sourceField = project.fields.nodes.find(f => f.name === 'Source' && f.options); + + // Determine source + let source = 'community'; + try { + await github.rest.orgs.checkMembershipForUser({ + org: projectOwner, + username: issue.user.login, + }); + source = 'internal'; + } catch {} + + const issueQuery = await github.graphql(` + query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + issue(number: $number) { id } + } + } + `, { owner: context.repo.owner, repo: context.repo.repo, number: issueNumber }); + + const issueNodeId = issueQuery.repository.issue.id; + + const addResult = await github.graphql(` + mutation($projectId: ID!, $contentId: ID!) { + addProjectV2ItemById(input: { projectId: $projectId, contentId: $contentId }) { + item { id } + } + } + `, { projectId, contentId: issueNodeId }); + + const itemId = addResult.addProjectV2ItemById.item.id; + + // Set App field + if (appField) { + const appOption = appField.options.find(o => o.name === appName); + if (appOption) { + await github.graphql(` + mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId, itemId: $itemId, fieldId: $fieldId, + value: { singleSelectOptionId: $optionId } + }) { projectV2Item { id } } + } + `, { projectId, itemId, fieldId: appField.id, optionId: appOption.id }); + } + } + + // Set Source field + if (sourceField) { + const sourceOption = sourceField.options.find(o => o.name === source); + if (sourceOption) { + await github.graphql(` + mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId, itemId: $itemId, fieldId: $fieldId, + value: { singleSelectOptionId: $optionId } + }) { projectV2Item { id } } + } + `, { projectId, itemId, fieldId: sourceField.id, optionId: sourceOption.id }); + } + } + + return source; + } + + // --- Ensure triage label exists --- + try { + await github.rest.issues.getLabel({ owner: context.repo.owner, repo: context.repo.repo, name: 'triage' }); + } catch { + await github.rest.issues.createLabel({ owner: context.repo.owner, repo: context.repo.repo, name: 'triage', color: 'fbca04', description: 'Awaiting triage' }); + } + + // Add label + await github.rest.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issue.number, labels: ['triage'] }); + + // Auto-assign + if (triageAssignee) { + await github.rest.issues.addAssignees({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issue.number, assignees: [triageAssignee] }); + } + + // Add to project board + const source = await addToProject(issue.number); + console.log(`Added #${issue.number} to project (source: ${source})`); + + // Welcome comment + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + body: [ + `Thank you for opening this issue! It has been added to our [triage board](https://github.com/orgs/${projectOwner}/projects/${projectNumber}/views/2) and assigned to @${triageAssignee} for review.`, + '', + '**What happens next:**', + '1. A maintainer will review this issue and determine the appropriate action', + '2. If accepted, it may be converted into an OpenSpec change for structured development', + '3. You will be notified of any status changes', + '', + '**Triage labels:**', + '- `triage:accept-feature` — accepted as a new feature', + '- `triage:accept-bugfix` — accepted as a bug fix', + '- `triage:needs-info` — more information needed', + '- `triage:wontfix` — will not be addressed', + '- `triage:duplicate` — duplicate of another issue', + ].join('\n'), + }); + + triage-decision: + name: Handle Triage Decision + runs-on: ubuntu-latest + permissions: + issues: write + contents: read + if: >- + inputs.backlog-existing == false && + github.event_name == 'issues' && + github.event.action == 'labeled' + + steps: + - name: Handle triage label + uses: actions/github-script@v7 + env: + PROJECT_TOKEN: ${{ secrets.PROJECT_TOKEN }} + PROJECT_NUMBER: ${{ inputs.project-number }} + PROJECT_OWNER: ${{ inputs.project-owner }} + with: + github-token: ${{ secrets.PROJECT_TOKEN }} + script: | + const label = context.payload.label.name; + const issue = context.payload.issue; + const projectNumber = parseInt(process.env.PROJECT_NUMBER); + const projectOwner = process.env.PROJECT_OWNER; + + if (!label.startsWith('triage:')) return; + + console.log(`Triage decision for #${issue.number}: ${label}`); + + // Remove the generic 'triage' label + try { + await github.rest.issues.removeLabel({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issue.number, name: 'triage' }); + } catch {} + + switch (label) { + case 'triage:accept-feature': + case 'triage:accept-bugfix': { + const specType = label === 'triage:accept-feature' ? 'feature' : 'bugfix'; + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + body: [ + `:white_check_mark: **Accepted as ${specType}**`, + '', + 'This issue has been accepted and will be developed using the OpenSpec workflow.', + `To start the spec process, run: \`/opsx:new ${issue.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')}\``, + '', + 'Once the OpenSpec change is created, a linked `[OpenSpec]` issue will track the specification and implementation progress.', + ].join('\n'), + }); + + // Move to Proposal on the project board + const projectQuery = await github.graphql(` + query($owner: String!, $number: Int!) { + organization(login: $owner) { + projectV2(number: $number) { + id + fields(first: 30) { + nodes { + ... on ProjectV2SingleSelectField { id name options { id name } } + } + } + items(first: 100) { + nodes { + id + content { + ... on Issue { number repository { name } } + } + } + } + } + } + } + `, { owner: projectOwner, number: projectNumber }); + + const project = projectQuery.organization.projectV2; + const statusField = project.fields.nodes.find(f => f.name === 'Status' && f.options); + const proposalStatus = statusField?.options.find(o => o.name === 'Proposal'); + const item = project.items.nodes.find(i => + i.content?.number === issue.number && + i.content?.repository?.name === context.repo.repo + ); + + if (item && proposalStatus) { + await github.graphql(` + mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId, itemId: $itemId, fieldId: $fieldId, + value: { singleSelectOptionId: $optionId } + }) { projectV2Item { id } } + } + `, { projectId: project.id, itemId: item.id, fieldId: statusField.id, optionId: proposalStatus.id }); + } + break; + } + + case 'triage:wontfix': + await github.rest.issues.createComment({ + owner: context.repo.owner, repo: context.repo.repo, issue_number: issue.number, + body: ':no_entry_sign: This issue has been reviewed and will not be addressed at this time. Feel free to reopen with additional context if you believe this should be reconsidered.', + }); + await github.rest.issues.update({ + owner: context.repo.owner, repo: context.repo.repo, issue_number: issue.number, + state: 'closed', state_reason: 'not_planned', + }); + break; + + case 'triage:duplicate': + await github.rest.issues.createComment({ + owner: context.repo.owner, repo: context.repo.repo, issue_number: issue.number, + body: ':link: This issue has been identified as a duplicate. Please check the linked issue for updates.', + }); + await github.rest.issues.update({ + owner: context.repo.owner, repo: context.repo.repo, issue_number: issue.number, + state: 'closed', state_reason: 'not_planned', + }); + break; + + case 'triage:needs-info': + await github.rest.issues.createComment({ + owner: context.repo.owner, repo: context.repo.repo, issue_number: issue.number, + body: [ + ':question: **More information needed**', + '', + 'We need additional details to properly evaluate this issue. Please provide:', + '- Steps to reproduce (for bugs)', + '- Use case / user story (for features)', + '- Expected vs actual behavior', + '- Screenshots or logs if applicable', + ].join('\n'), + }); + break; + } + + backlog-triage: + name: Backlog Triage Existing Issues + runs-on: ubuntu-latest + permissions: + issues: write + contents: read + if: inputs.backlog-existing == true + + steps: + - name: Triage all untriaged open issues + uses: actions/github-script@v7 + env: + PROJECT_TOKEN: ${{ secrets.PROJECT_TOKEN }} + PROJECT_NUMBER: ${{ inputs.project-number }} + PROJECT_OWNER: ${{ inputs.project-owner }} + APP_NAME: ${{ inputs.app-name }} + TRIAGE_ASSIGNEE: ${{ inputs.triage-assignee }} + with: + github-token: ${{ secrets.PROJECT_TOKEN }} + script: | + const projectNumber = parseInt(process.env.PROJECT_NUMBER); + const projectOwner = process.env.PROJECT_OWNER; + const appName = process.env.APP_NAME; + const triageAssignee = process.env.TRIAGE_ASSIGNEE; + const owner = context.repo.owner; + const repo = context.repo.repo; + + // --- Get project info once --- + const projectQuery = await github.graphql(` + query($owner: String!, $number: Int!) { + organization(login: $owner) { + projectV2(number: $number) { + id + fields(first: 30) { + nodes { + ... on ProjectV2SingleSelectField { id name options { id name } } + ... on ProjectV2Field { id name dataType } + } + } + } + } + } + `, { owner: projectOwner, number: projectNumber }); + + const project = projectQuery.organization.projectV2; + const projectId = project.id; + const appField = project.fields.nodes.find(f => f.name === 'App' && f.options); + const appOption = appField?.options.find(o => o.name === appName); + const sourceField = project.fields.nodes.find(f => f.name === 'Source' && f.options); + const internalOption = sourceField?.options.find(o => o.name === 'internal'); + + // --- Ensure triage label exists --- + try { + await github.rest.issues.getLabel({ owner, repo, name: 'triage' }); + } catch { + await github.rest.issues.createLabel({ owner, repo, name: 'triage', color: 'fbca04', description: 'Awaiting triage' }); + } + + // --- Get all open issues, paginating --- + let allIssues = []; + let page = 1; + while (true) { + const { data } = await github.rest.issues.listForRepo({ + owner, repo, state: 'open', per_page: 100, page, + }); + allIssues.push(...data.filter(i => !i.pull_request)); + if (data.length < 100) break; + page++; + } + + // Filter: skip openspec-managed and already-triaged issues + const untriaged = allIssues.filter(i => { + const labels = i.labels.map(l => l.name); + return !labels.includes('openspec') && !labels.includes('triage') && !labels.some(l => l.startsWith('triage:')); + }); + + console.log(`Found ${untriaged.length} untriaged issues out of ${allIssues.length} total open issues`); + + let processed = 0; + for (const issue of untriaged) { + console.log(`[${++processed}/${untriaged.length}] #${issue.number}: ${issue.title}`); + + // Add triage label + await github.rest.issues.addLabels({ owner, repo, issue_number: issue.number, labels: ['triage'] }); + + // Auto-assign if unassigned + if (!issue.assignees || issue.assignees.length === 0) { + await github.rest.issues.addAssignees({ owner, repo, issue_number: issue.number, assignees: [triageAssignee] }); + } + + // Get issue node ID + const issueQuery = await github.graphql(` + query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + issue(number: $number) { id } + } + } + `, { owner, repo, number: issue.number }); + + const issueNodeId = issueQuery.repository.issue.id; + + // Add to project + let itemId; + try { + const addResult = await github.graphql(` + mutation($projectId: ID!, $contentId: ID!) { + addProjectV2ItemById(input: { projectId: $projectId, contentId: $contentId }) { + item { id } + } + } + `, { projectId, contentId: issueNodeId }); + itemId = addResult.addProjectV2ItemById.item.id; + } catch (e) { + console.log(` Skipped (already on project or error): ${e.message}`); + continue; + } + + // Set App + if (appOption) { + await github.graphql(` + mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId, itemId: $itemId, fieldId: $fieldId, + value: { singleSelectOptionId: $optionId } + }) { projectV2Item { id } } + } + `, { projectId, itemId, fieldId: appField.id, optionId: appOption.id }); + } + + // Set Source = internal (existing issues are from the team) + if (internalOption) { + await github.graphql(` + mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId, itemId: $itemId, fieldId: $fieldId, + value: { singleSelectOptionId: $optionId } + }) { projectV2Item { id } } + } + `, { projectId, itemId, fieldId: sourceField.id, optionId: internalOption.id }); + } + + // Small delay to avoid rate limits + await new Promise(r => setTimeout(r, 300)); + } + + console.log(`\nDone! Triaged ${processed} issues.`); diff --git a/.github/workflows/openspec-sync.yml b/.github/workflows/openspec-sync.yml new file mode 100644 index 0000000..13e735e --- /dev/null +++ b/.github/workflows/openspec-sync.yml @@ -0,0 +1,557 @@ +name: OpenSpec Sync + +on: + workflow_call: + inputs: + app-name: + description: "Nextcloud app ID (e.g. openregister, pipelinq)" + required: true + type: string + project-number: + description: "GitHub project number to sync issues to" + required: false + type: number + default: 1 + project-owner: + description: "GitHub org that owns the project" + required: false + type: string + default: "ConductionNL" + secrets: + PROJECT_TOKEN: + description: "GitHub token with project and issues scope" + required: true + +jobs: + sync: + name: Sync OpenSpec to GitHub Issues + runs-on: ubuntu-latest + permissions: + issues: write + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Sync OpenSpec changes to issues + uses: actions/github-script@v7 + env: + PROJECT_TOKEN: ${{ secrets.PROJECT_TOKEN }} + PROJECT_NUMBER: ${{ inputs.project-number }} + PROJECT_OWNER: ${{ inputs.project-owner }} + APP_NAME: ${{ inputs.app-name }} + with: + github-token: ${{ secrets.PROJECT_TOKEN }} + script: | + const fs = require('fs'); + const path = require('path'); + + const appName = process.env.APP_NAME; + const projectNumber = parseInt(process.env.PROJECT_NUMBER); + const projectOwner = process.env.PROJECT_OWNER; + const repoOwner = context.repo.owner; + const repoName = context.repo.repo; + + // --- Phase detection --- + function detectPhase(changePath, isArchived) { + if (isArchived) return 'Archived'; + const has = (f) => fs.existsSync(path.join(changePath, f)); + const hasSpecs = (() => { + const specsDir = path.join(changePath, 'specs'); + if (!fs.existsSync(specsDir)) return false; + try { + const entries = fs.readdirSync(specsDir); + return entries.some(e => { + const specFile = path.join(specsDir, e, 'spec.md'); + return fs.existsSync(specFile); + }); + } catch { return false; } + })(); + + if (has('review.md')) return 'Review'; + if (has('plan.json')) return 'In Progress'; + if (has('tasks.md')) return 'Tasks'; + if (has('design.md')) return 'Design'; + if (hasSpecs) return 'Specs'; + if (has('proposal.md')) return 'Proposal'; + return 'Proposal'; // fallback: has at least .openspec.yaml + } + + // --- Build issue body --- + function buildIssueBody(changePath, changeName, isArchived, repoOwner, repoName, branch) { + const has = (f) => fs.existsSync(path.join(changePath, f)); + const read = (f) => { + try { return fs.readFileSync(path.join(changePath, f), 'utf8'); } + catch { return null; } + }; + + // Relative path for GitHub links + const rel = changePath.replace(process.cwd() + '/', ''); + const ghBase = `https://github.com/${repoOwner}/${repoName}/blob/${branch}/${rel}`; + + let body = ''; + body += '> :warning: **OpenSpec-managed issue** — this content is automatically synced\n'; + body += '> from the `openspec/` directory. Manual edits will be overwritten on next sync.\n\n'; + + // Artifact checklist + body += '## Artifacts\n'; + const artifacts = [ + ['.openspec.yaml', 'Metadata'], + ['proposal.md', 'Proposal'], + ['specs/', 'Specs'], + ['design.md', 'Design'], + ['tasks.md', 'Tasks'], + ['plan.json', 'Plan'], + ['review.md', 'Review'], + ]; + for (const [file, label] of artifacts) { + const exists = has(file); + const icon = exists ? ':white_check_mark:' : ':black_square_button:'; + if (exists) { + body += `- ${icon} [${label}](${ghBase}/${file})\n`; + } else { + body += `- ${icon} ${label}\n`; + } + } + body += '\n'; + + // Proposal summary + const proposal = read('proposal.md'); + if (proposal) { + // Extract summary section + const summaryMatch = proposal.match(/## Summary\n([\s\S]*?)(?=\n## |\n---|\Z)/); + if (summaryMatch) { + body += '## Summary\n'; + body += summaryMatch[1].trim() + '\n\n'; + } + } + + // Specs listing + const specsDir = path.join(changePath, 'specs'); + if (fs.existsSync(specsDir)) { + body += '## Specs\n'; + try { + const specEntries = fs.readdirSync(specsDir); + for (const entry of specEntries) { + const specFile = path.join(specsDir, entry, 'spec.md'); + if (fs.existsSync(specFile)) { + body += `- [:page_facing_up: ${entry}](${ghBase}/specs/${entry}/spec.md)\n`; + } + } + } catch { /* ignore */ } + body += '\n'; + } + + // Tasks as checkboxes + const tasks = read('tasks.md'); + const planJson = read('plan.json'); + if (tasks) { + body += '## Tasks\n'; + + // If plan.json exists, use its status for checkbox state + let taskStatuses = {}; + if (planJson) { + try { + const plan = JSON.parse(planJson); + if (plan.tasks) { + for (const t of plan.tasks) { + taskStatuses[t.id] = t.status; + } + } + } catch { /* ignore */ } + } + + // Parse tasks from tasks.md — look for ### Task lines and sub-items + const taskLines = tasks.split('\n'); + for (const line of taskLines) { + // Match ### Task N.M: Title + const taskHeader = line.match(/^### Task\s+(\d+\.\d+):\s+(.+)/); + if (taskHeader) { + const taskId = taskHeader[1]; + const taskTitle = taskHeader[2]; + const status = taskStatuses[taskId]; + const checked = (status === 'completed') ? 'x' : ' '; + body += `- [${checked}] **${taskId}**: ${taskTitle}\n`; + continue; + } + // Match - [x] or - [ ] sub-items + const checkboxMatch = line.match(/^(\s*)- \[([ x])\]\s+(.+)/); + if (checkboxMatch) { + body += ` ${line.trim()}\n`; + } + } + body += '\n'; + } + + // Design decisions (brief) + if (has('design.md')) { + body += `## Design\n`; + body += `See [design.md](${ghBase}/design.md) for technical design details.\n\n`; + } + + // Review findings + const review = read('review.md'); + if (review) { + body += '## Review\n'; + const recMatch = review.match(/## Recommendation\n([\s\S]*?)(?=\n## |\Z)/); + if (recMatch) { + body += recMatch[1].trim() + '\n\n'; + } else { + body += `See [review.md](${ghBase}/review.md) for verification report.\n\n`; + } + } + + body += '---\n'; + body += `*Synced from \`${rel}\` by OpenSpec workflow*\n`; + body += `*App: \`${appName}\`*\n`; + + return body; + } + + // --- Scan for changes --- + const changesDir = 'openspec/changes'; + const archiveDir = path.join(changesDir, 'archive'); + + let allChanges = []; + + // Active changes + if (fs.existsSync(changesDir)) { + const entries = fs.readdirSync(changesDir); + for (const entry of entries) { + if (entry === 'archive') continue; + const changePath = path.join(changesDir, entry); + if (!fs.statSync(changePath).isDirectory()) continue; + if (!fs.existsSync(path.join(changePath, '.openspec.yaml'))) continue; + allChanges.push({ name: entry, path: changePath, archived: false }); + } + } + + // Archived changes + if (fs.existsSync(archiveDir)) { + const entries = fs.readdirSync(archiveDir); + for (const entry of entries) { + const changePath = path.join(archiveDir, entry); + if (!fs.statSync(changePath).isDirectory()) continue; + // Archived changes may not have .openspec.yaml if they predate it + allChanges.push({ name: entry, path: changePath, archived: true }); + } + } + + console.log(`Found ${allChanges.length} OpenSpec changes (active + archived)`); + + // --- Use the branch that triggered the workflow for file links --- + const defaultBranch = context.ref ? context.ref.replace('refs/heads/', '') : 'development'; + + // --- Get project ID and field info via GraphQL --- + const projectQuery = await github.graphql(` + query($owner: String!, $number: Int!) { + organization(login: $owner) { + projectV2(number: $number) { + id + fields(first: 30) { + nodes { + ... on ProjectV2SingleSelectField { + id + name + options { id name } + } + ... on ProjectV2Field { + id + name + dataType + } + } + } + } + } + } + `, { owner: projectOwner, number: projectNumber }); + + const project = projectQuery.organization.projectV2; + const projectId = project.id; + + // Find Status field and its options + const statusField = project.fields.nodes.find(f => f.name === 'Status' && f.options); + if (!statusField) { + core.setFailed('Status field not found on project'); + return; + } + + const statusOptions = {}; + for (const opt of statusField.options) { + statusOptions[opt.name] = opt.id; + } + console.log('Available statuses:', Object.keys(statusOptions).join(', ')); + + // Find App field (single select) if it exists + const appField = project.fields.nodes.find(f => f.name === 'App' && f.options); + + // Find Change Name field (text) if it exists + const changeNameField = project.fields.nodes.find(f => f.name === 'Change Name' && f.dataType === 'TEXT'); + + // Find Source field (single select) if it exists + const sourceField = project.fields.nodes.find(f => f.name === 'Source' && f.options); + + // --- Process each change --- + for (const change of allChanges) { + const phase = detectPhase(change.path, change.archived); + const issueTitle = `[OpenSpec] ${change.name}`; + + console.log(`Processing: ${change.name} → ${phase}`); + + // Search for existing issue + const searchResult = await github.rest.issues.listForRepo({ + owner: repoOwner, + repo: repoName, + state: 'all', + per_page: 100, + labels: 'openspec', + }); + + let existingIssue = searchResult.data.find(i => i.title === issueTitle); + + // If not found in first page, search more + if (!existingIssue && searchResult.data.length === 100) { + let page = 2; + while (!existingIssue && page <= 10) { + const moreResults = await github.rest.issues.listForRepo({ + owner: repoOwner, + repo: repoName, + state: 'all', + per_page: 100, + page: page, + labels: 'openspec', + }); + existingIssue = moreResults.data.find(i => i.title === issueTitle); + if (moreResults.data.length < 100) break; + page++; + } + } + + // Build issue body + const body = buildIssueBody(change.path, change.name, change.archived, repoOwner, repoName, defaultBranch); + + // Labels + const labels = ['openspec', `openspec:${phase.toLowerCase().replace(' ', '-')}`]; + + // Ensure labels exist + for (const label of labels) { + try { + await github.rest.issues.getLabel({ + owner: repoOwner, + repo: repoName, + name: label, + }); + } catch { + await github.rest.issues.createLabel({ + owner: repoOwner, + repo: repoName, + name: label, + color: label === 'openspec' ? '6f42c1' : 'c5def5', + description: label === 'openspec' + ? 'Managed by OpenSpec workflow' + : `OpenSpec phase: ${phase}`, + }); + } + } + + let issue; + + if (existingIssue) { + // Update existing issue + issue = await github.rest.issues.update({ + owner: repoOwner, + repo: repoName, + issue_number: existingIssue.number, + body: body, + labels: labels, + state: change.archived ? 'closed' : 'open', + }); + issue = issue.data; + console.log(` Updated issue #${issue.number}`); + } else { + // Create new issue + issue = await github.rest.issues.create({ + owner: repoOwner, + repo: repoName, + title: issueTitle, + body: body, + labels: labels, + }); + issue = issue.data; + console.log(` Created issue #${issue.number}`); + + // Close archived issues immediately after creation + if (change.archived) { + await github.rest.issues.update({ + owner: repoOwner, + repo: repoName, + issue_number: issue.number, + state: 'closed', + state_reason: 'completed', + }); + console.log(` Closed issue #${issue.number} (archived)`); + } + } + + // --- Add to project and set Status --- + // Check if issue is already on project + const itemQuery = await github.graphql(` + query($projectId: ID!, $cursor: String) { + node(id: $projectId) { + ... on ProjectV2 { + items(first: 100, after: $cursor) { + nodes { + id + content { + ... on Issue { + number + repository { name } + } + } + } + pageInfo { hasNextPage endCursor } + } + } + } + } + `, { projectId }); + + let projectItemId = null; + for (const item of itemQuery.node.items.nodes) { + if (item.content && + item.content.number === issue.number && + item.content.repository && + item.content.repository.name === repoName) { + projectItemId = item.id; + break; + } + } + + // Add to project if not already there + if (!projectItemId) { + // Get issue node ID + const issueQuery = await github.graphql(` + query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + issue(number: $number) { id } + } + } + `, { owner: repoOwner, repo: repoName, number: issue.number }); + + const issueNodeId = issueQuery.repository.issue.id; + + const addResult = await github.graphql(` + mutation($projectId: ID!, $contentId: ID!) { + addProjectV2ItemById(input: { projectId: $projectId, contentId: $contentId }) { + item { id } + } + } + `, { projectId, contentId: issueNodeId }); + + projectItemId = addResult.addProjectV2ItemById.item.id; + console.log(` Added to project`); + } + + // Set Status field + const targetStatus = statusOptions[phase]; + if (targetStatus) { + await github.graphql(` + mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId + itemId: $itemId + fieldId: $fieldId + value: { singleSelectOptionId: $optionId } + }) { + projectV2Item { id } + } + } + `, { + projectId, + itemId: projectItemId, + fieldId: statusField.id, + optionId: targetStatus, + }); + console.log(` Set status to: ${phase}`); + } else { + console.log(` Warning: no status option for phase "${phase}"`); + } + + // Set App field if it exists + if (appField) { + const appOption = appField.options.find(o => o.name === appName); + if (appOption) { + await github.graphql(` + mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId + itemId: $itemId + fieldId: $fieldId + value: { singleSelectOptionId: $optionId } + }) { + projectV2Item { id } + } + } + `, { + projectId, + itemId: projectItemId, + fieldId: appField.id, + optionId: appOption.id, + }); + } + } + + // Set Change Name field if it exists + if (changeNameField) { + await github.graphql(` + mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $value: String!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId + itemId: $itemId + fieldId: $fieldId + value: { text: $value } + }) { + projectV2Item { id } + } + } + `, { + projectId, + itemId: projectItemId, + fieldId: changeNameField.id, + value: change.name, + }); + } + + // Set Source field if it exists + if (sourceField) { + const sourceOption = sourceField.options.find(o => o.name === 'openspec'); + if (sourceOption) { + await github.graphql(` + mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId + itemId: $itemId + fieldId: $fieldId + value: { singleSelectOptionId: $optionId } + }) { + projectV2Item { id } + } + } + `, { + projectId, + itemId: projectItemId, + fieldId: sourceField.id, + optionId: sourceOption.id, + }); + } + } + + // Small delay to avoid rate limiting + await new Promise(r => setTimeout(r, 500)); + } + + console.log(`\nDone! Processed ${allChanges.length} changes.`);