mirror of
https://gitflic.ru/project/openide/openide.git
synced 2026-09-27 10:03:11 +07:00
IJPL-224042 store findings/decisions in comments
GitOrigin-RevId: 461ac0a97f6b312c67ab6693d3e74a08c168b477
This commit is contained in:
committed by
intellij-monorepo-bot
parent
082afa9b94
commit
1345781da8
@@ -26,6 +26,10 @@ export function bd(args) {
|
||||
})
|
||||
}
|
||||
|
||||
export async function bdAddComment(id, text) {
|
||||
await bd(['comments', 'add', id, text])
|
||||
}
|
||||
|
||||
export async function bdJson(args) {
|
||||
const result = await bd([...args, '--json'])
|
||||
return JSON.parse(result)
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
|
||||
import {bdAddComment} from './bd-client.mjs'
|
||||
|
||||
export const FINDING_PREFIX = 'FINDING:'
|
||||
export const DECISION_PREFIX = 'KEY DECISION:'
|
||||
|
||||
function normalizeEntry(entry) {
|
||||
if (typeof entry !== 'string') return null
|
||||
const normalized = entry.trim()
|
||||
return normalized ? normalized : null
|
||||
}
|
||||
|
||||
function normalizePrefixedEntry(entry, prefix) {
|
||||
const normalized = normalizeEntry(entry)
|
||||
if (!normalized) return null
|
||||
if (prefix && normalized.startsWith(prefix)) {
|
||||
const stripped = normalized.slice(prefix.length).trim()
|
||||
return stripped || null
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function normalizeEntries(entries, prefix) {
|
||||
if (!Array.isArray(entries)) return []
|
||||
const normalized = []
|
||||
for (const entry of entries) {
|
||||
const value = normalizePrefixedEntry(entry, prefix)
|
||||
if (value) normalized.push(value)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function mergeEntries(primary, secondary) {
|
||||
const result = []
|
||||
const seen = new Set()
|
||||
for (const entry of [...primary, ...secondary]) {
|
||||
const normalized = normalizeEntry(entry)
|
||||
if (!normalized || seen.has(normalized)) continue
|
||||
seen.add(normalized)
|
||||
result.push(normalized)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Parse notes into structured sections (legacy JSON + text) for backward compatibility.
|
||||
function parseNotesOnly(notes) {
|
||||
const sections = {findings: [], decisions: []}
|
||||
if (!notes) return sections
|
||||
|
||||
const trimmed = notes.trim()
|
||||
if (trimmed.startsWith('{')) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed)
|
||||
sections.findings = normalizeEntries(parsed.findings, FINDING_PREFIX)
|
||||
sections.decisions = normalizeEntries(parsed.decisions, DECISION_PREFIX)
|
||||
if (parsed.pending_close) {
|
||||
sections.pending_close = parsed.pending_close
|
||||
}
|
||||
return sections
|
||||
} catch (e) {
|
||||
// Fall through to text parsing
|
||||
}
|
||||
}
|
||||
|
||||
for (const line of notes.split('\n')) {
|
||||
const trimmedLine = line.trim()
|
||||
if (trimmedLine.startsWith(FINDING_PREFIX)) {
|
||||
const value = normalizePrefixedEntry(trimmedLine, FINDING_PREFIX)
|
||||
if (value) sections.findings.push(value)
|
||||
} else if (trimmedLine.startsWith(DECISION_PREFIX)) {
|
||||
const value = normalizePrefixedEntry(trimmedLine, DECISION_PREFIX)
|
||||
if (value) sections.decisions.push(value)
|
||||
}
|
||||
}
|
||||
return sections
|
||||
}
|
||||
|
||||
function parseComments(comments) {
|
||||
const sections = {findings: [], decisions: []}
|
||||
if (!Array.isArray(comments)) return sections
|
||||
|
||||
for (const comment of comments) {
|
||||
const text = typeof comment?.text === 'string' ? comment.text.trim() : ''
|
||||
if (!text) continue
|
||||
if (text.startsWith(FINDING_PREFIX)) {
|
||||
const value = normalizePrefixedEntry(text, FINDING_PREFIX)
|
||||
if (value) sections.findings.push(value)
|
||||
} else if (text.startsWith(DECISION_PREFIX)) {
|
||||
const value = normalizePrefixedEntry(text, DECISION_PREFIX)
|
||||
if (value) sections.decisions.push(value)
|
||||
}
|
||||
}
|
||||
return sections
|
||||
}
|
||||
|
||||
// Build notes from comments + legacy notes for API compatibility.
|
||||
export function parseNotes(notes, comments) {
|
||||
const hasNotes = typeof notes === 'string' && notes.length > 0
|
||||
const notesSections = parseNotesOnly(notes)
|
||||
const commentSections = parseComments(comments)
|
||||
const findings = mergeEntries(commentSections.findings, notesSections.findings)
|
||||
const decisions = mergeEntries(commentSections.decisions, notesSections.decisions)
|
||||
const result = {findings, decisions}
|
||||
if (notesSections.pending_close) result.pending_close = notesSections.pending_close
|
||||
|
||||
if (findings.length > 0 || decisions.length > 0 || result.pending_close || hasNotes) {
|
||||
return result
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function buildPendingNotes(pendingClose) {
|
||||
if (!pendingClose) return ''
|
||||
return JSON.stringify({pending_close: pendingClose}, null, 2)
|
||||
}
|
||||
|
||||
function filterNewEntries(entries, existingSet) {
|
||||
const result = []
|
||||
if (!Array.isArray(entries)) return result
|
||||
|
||||
for (const entry of entries) {
|
||||
const normalized = normalizeEntry(entry)
|
||||
if (!normalized || existingSet.has(normalized)) continue
|
||||
existingSet.add(normalized)
|
||||
result.push(normalized)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export async function addSectionComments(issueId, findings, decisions) {
|
||||
for (const finding of findings) {
|
||||
await bdAddComment(issueId, `${FINDING_PREFIX} ${finding}`)
|
||||
}
|
||||
for (const decision of decisions) {
|
||||
await bdAddComment(issueId, `${DECISION_PREFIX} ${decision}`)
|
||||
}
|
||||
}
|
||||
|
||||
export function prepareSectionUpdates(issue, incomingFindings, incomingDecisions) {
|
||||
const notesSections = parseNotesOnly(issue.notes)
|
||||
const commentSections = parseComments(issue['comments'])
|
||||
|
||||
const existingFindings = mergeEntries(commentSections.findings, notesSections.findings)
|
||||
const existingDecisions = mergeEntries(commentSections.decisions, notesSections.decisions)
|
||||
|
||||
const normalizedFindings = normalizeEntries(incomingFindings, FINDING_PREFIX)
|
||||
const normalizedDecisions = normalizeEntries(incomingDecisions, DECISION_PREFIX)
|
||||
|
||||
const newFindings = filterNewEntries(normalizedFindings, new Set(existingFindings))
|
||||
const newDecisions = filterNewEntries(normalizedDecisions, new Set(existingDecisions))
|
||||
|
||||
const migrateFindings = filterNewEntries(notesSections.findings, new Set(commentSections.findings))
|
||||
const migrateDecisions = filterNewEntries(notesSections.decisions, new Set(commentSections.decisions))
|
||||
|
||||
const findingsToAdd = mergeEntries(migrateFindings, newFindings)
|
||||
const decisionsToAdd = mergeEntries(migrateDecisions, newDecisions)
|
||||
|
||||
const finalFindings = mergeEntries(existingFindings, newFindings)
|
||||
const finalDecisions = mergeEntries(existingDecisions, newDecisions)
|
||||
|
||||
const shouldStripNotes = notesSections.findings.length > 0 || notesSections.decisions.length > 0
|
||||
|
||||
return {
|
||||
notesSections,
|
||||
finalFindings,
|
||||
finalDecisions,
|
||||
findingsToAdd,
|
||||
decisionsToAdd,
|
||||
shouldStripNotes
|
||||
}
|
||||
}
|
||||
@@ -3,47 +3,7 @@
|
||||
|
||||
import {createMcpServer} from '../shared/mcp-rpc.mjs'
|
||||
import {bd, bdJson, bdShowOne} from './bd-client.mjs'
|
||||
|
||||
// Parse notes into structured sections
|
||||
// Tries JSON format first, falls back to legacy text format for backward compatibility
|
||||
function parseNotes(notes) {
|
||||
if (!notes) return {findings: [], decisions: []}
|
||||
|
||||
// Try JSON first
|
||||
if (notes.trim().startsWith('{')) {
|
||||
try {
|
||||
const parsed = JSON.parse(notes)
|
||||
const result = {
|
||||
findings: parsed.findings || [],
|
||||
decisions: parsed.decisions || []
|
||||
}
|
||||
// Preserve pending_close if present
|
||||
if (parsed.pending_close) {
|
||||
result.pending_close = parsed.pending_close
|
||||
}
|
||||
return result
|
||||
} catch (e) {
|
||||
// Fall through to text parsing
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy text format (backward compat)
|
||||
const sections = {findings: [], decisions: []}
|
||||
for (const line of notes.split('\n')) {
|
||||
if (line.startsWith('FINDING:')) {
|
||||
sections.findings.push(line.replace('FINDING:', '').trim())
|
||||
} else if (line.startsWith('KEY DECISION:')) {
|
||||
sections.decisions.push(line.replace('KEY DECISION:', '').trim())
|
||||
}
|
||||
}
|
||||
return sections
|
||||
}
|
||||
|
||||
// Build notes string from sections
|
||||
// Stores as pretty-printed JSON for human readability
|
||||
function buildNotes(sections) {
|
||||
return JSON.stringify(sections, null, 2)
|
||||
}
|
||||
import {addSectionComments, buildPendingNotes, parseNotes, prepareSectionUpdates} from './notes.mjs'
|
||||
|
||||
// Fetch ready children for an epic (used in task_status and task_epic resume)
|
||||
async function getReadyChildren(epicId) {
|
||||
@@ -174,9 +134,11 @@ const toolHandlers = {
|
||||
if (!issue) {
|
||||
return {error: `Issue ${args.id} not found`}
|
||||
}
|
||||
// Parse notes if present
|
||||
if (issue.notes) {
|
||||
issue.notes = parseNotes(issue.notes)
|
||||
const parsedNotes = parseNotes(issue.notes, issue['comments'])
|
||||
if (parsedNotes) {
|
||||
issue.notes = parsedNotes
|
||||
} else {
|
||||
delete issue.notes
|
||||
}
|
||||
return issue
|
||||
}
|
||||
@@ -251,8 +213,11 @@ const toolHandlers = {
|
||||
await bd(['update', args.resume, '--status', 'in_progress'])
|
||||
|
||||
// Return full issue details (same as task_status(id)) to avoid redundant follow-up call
|
||||
if (issue.notes) {
|
||||
issue.notes = parseNotes(issue.notes)
|
||||
const parsedNotes = parseNotes(issue.notes, issue['comments'])
|
||||
if (parsedNotes) {
|
||||
issue.notes = parsedNotes
|
||||
} else {
|
||||
delete issue.notes
|
||||
}
|
||||
issue.is_new = false
|
||||
|
||||
@@ -282,20 +247,30 @@ const toolHandlers = {
|
||||
if (!issue) {
|
||||
return {error: `Issue ${args.id} not found`}
|
||||
}
|
||||
const sections = parseNotes(issue.notes)
|
||||
|
||||
if (args.findings) sections.findings.push(...args.findings)
|
||||
if (args.decisions) sections.decisions.push(...args.decisions)
|
||||
const update = prepareSectionUpdates(issue, args.findings, args.decisions)
|
||||
if (update.findingsToAdd.length > 0 || update.decisionsToAdd.length > 0) {
|
||||
await addSectionComments(args.id, update.findingsToAdd, update.decisionsToAdd)
|
||||
}
|
||||
|
||||
const newNotes = buildNotes(sections)
|
||||
const updateArgs = ['update', args.id, '--notes', newNotes]
|
||||
const updateArgs = ['update', args.id]
|
||||
if (args.status) updateArgs.push('--status', args.status)
|
||||
await bd(updateArgs)
|
||||
if (update.shouldStripNotes) {
|
||||
updateArgs.push('--notes', buildPendingNotes(update.notesSections.pending_close))
|
||||
}
|
||||
if (updateArgs.length > 2) {
|
||||
await bd(updateArgs)
|
||||
}
|
||||
|
||||
const responseNotes = {findings: update.finalFindings, decisions: update.finalDecisions}
|
||||
if (update.notesSections.pending_close) {
|
||||
responseNotes.pending_close = update.notesSections.pending_close
|
||||
}
|
||||
|
||||
if (args.completed && needsReview(issue)) {
|
||||
return {
|
||||
success: true,
|
||||
notes: sections,
|
||||
notes: responseNotes,
|
||||
status: args.status || issue.status,
|
||||
askUser: {
|
||||
question: `Review completed work (${issue.type}/P${issue.priority ?? 2}). What next?`,
|
||||
@@ -310,7 +285,7 @@ const toolHandlers = {
|
||||
}
|
||||
}
|
||||
|
||||
return {success: true, notes: sections, status: args.status || issue.status}
|
||||
return {success: true, notes: responseNotes, status: args.status || issue.status}
|
||||
},
|
||||
|
||||
task_decompose: async (args) => {
|
||||
@@ -388,15 +363,25 @@ const toolHandlers = {
|
||||
return {error: `Issue ${args.id} not found`}
|
||||
}
|
||||
|
||||
const sections = parseNotes(issue.notes)
|
||||
const update = prepareSectionUpdates(issue, args.findings, args.decisions)
|
||||
|
||||
const applyComments = async () => {
|
||||
if (update.findingsToAdd.length > 0 || update.decisionsToAdd.length > 0) {
|
||||
await addSectionComments(args.id, update.findingsToAdd, update.decisionsToAdd)
|
||||
}
|
||||
}
|
||||
|
||||
const updateNotes = async (pendingClose) => {
|
||||
const pendingCloseChanged = JSON.stringify(pendingClose ?? null)
|
||||
!== JSON.stringify(update.notesSections.pending_close ?? null)
|
||||
if (update.shouldStripNotes || pendingCloseChanged) {
|
||||
await bd(['update', args.id, '--notes', buildPendingNotes(pendingClose)])
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to perform actual close and return result
|
||||
const doClose = async (summary) => {
|
||||
// Clean up pending_close if present
|
||||
if (sections.pending_close) {
|
||||
delete sections.pending_close
|
||||
await bd(['update', args.id, '--notes', buildNotes(sections)])
|
||||
}
|
||||
await updateNotes(null)
|
||||
|
||||
await bd(['close', args.id, '--reason', summary])
|
||||
|
||||
@@ -428,16 +413,12 @@ const toolHandlers = {
|
||||
|
||||
// If confirmed, retrieve stored pending_close data and close
|
||||
if (args.confirmed) {
|
||||
const pending = sections.pending_close
|
||||
const pending = update.notesSections.pending_close
|
||||
if (!pending) {
|
||||
return {error: 'No pending close found. Call task_done with summary first.'}
|
||||
}
|
||||
|
||||
// Merge any final findings/decisions
|
||||
if (args.findings) sections.findings.push(...args.findings)
|
||||
if (args.decisions) sections.decisions.push(...args.decisions)
|
||||
await bd(['update', args.id, '--notes', buildNotes(sections)])
|
||||
|
||||
await applyComments()
|
||||
return await doClose(pending.summary)
|
||||
}
|
||||
|
||||
@@ -447,11 +428,8 @@ const toolHandlers = {
|
||||
return {error: 'summary required for issues that need review'}
|
||||
}
|
||||
|
||||
// Store pending close data and any findings/decisions
|
||||
if (args.findings) sections.findings.push(...args.findings)
|
||||
if (args.decisions) sections.decisions.push(...args.decisions)
|
||||
sections.pending_close = {summary: args.summary}
|
||||
await bd(['update', args.id, '--notes', buildNotes(sections)])
|
||||
await applyComments()
|
||||
await updateNotes({summary: args.summary})
|
||||
|
||||
return {
|
||||
askUser: {
|
||||
@@ -471,13 +449,7 @@ const toolHandlers = {
|
||||
return {error: 'summary required'}
|
||||
}
|
||||
|
||||
// Record final findings/decisions before closing
|
||||
if (args.findings) sections.findings.push(...args.findings)
|
||||
if (args.decisions) sections.decisions.push(...args.decisions)
|
||||
if (args.findings || args.decisions) {
|
||||
await bd(['update', args.id, '--notes', buildNotes(sections)])
|
||||
}
|
||||
|
||||
await applyComments()
|
||||
return await doClose(args.summary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,13 @@ import {execSync, spawn} from 'node:child_process'
|
||||
import {mkdtempSync, rmSync} from 'node:fs'
|
||||
import {tmpdir} from 'node:os'
|
||||
import {dirname, join} from 'node:path'
|
||||
import process from 'node:process'
|
||||
import {fileURLToPath} from 'node:url'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
process['env'].BD_NO_DAEMON = '1'
|
||||
|
||||
// MCP client for testing
|
||||
class McpTestClient {
|
||||
constructor(serverProcess) {
|
||||
@@ -308,6 +311,10 @@ describe('task MCP integration', {timeout: 30000}, () => {
|
||||
assert.ok(result.success)
|
||||
assert.deepEqual(result.notes.findings, ['Found pattern X'])
|
||||
assert.deepEqual(result.notes.decisions, ['Use approach Y'])
|
||||
|
||||
const comments = JSON.parse(execSync(`bd comments ${epic.id} --json`, {cwd: testDir, encoding: 'utf-8'}))
|
||||
assert.ok(comments.some(comment => comment.text === 'FINDING: Found pattern X'))
|
||||
assert.ok(comments.some(comment => comment.text === 'KEY DECISION: Use approach Y'))
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user