bug-tracker.js 8.63 KB
/**
 * DevLoop · bugTracker
 *
 * bugs.ndjson 的 CRUD + 状态机(P-9)。
 * - bugs.ndjson 是 append-only 流。一条 bug 的「当前态」= 该 id 的<b>最新一行</b>。
 * - dedupKey = "{unit_id}:{test_name}"。open() 时同 key 已存在 open/fixing → 不重复,返旧 id。
 * - transition() 走 P-9 状态机校验,非法转换抛错。
 *
 * 规格:docs/prd/newfunction/_spec/sdd-control.md · C-16
 *      docs/prd/newfunction/_spec/sdd-model.md · M-14
 *      docs/prd/newfunction/_spec/sdd-process.md · P-9
 */

const fs = require('fs');
const path = require('path');
const crypto = require('crypto');

const pathResolver = require('./path-resolver');

// 可选依赖:timelineLogger / sseBroker。LoopController 启动时 attach。
let timelineLogger = null;
let sseBroker = null;
function attachTimelineLogger(t) { timelineLogger = t; }
function attachSseBroker(b) { sseBroker = b; }

// ────────────────────────────────────────────────────────────────
// 状态机
// ────────────────────────────────────────────────────────────────

const VALID_TRANSITIONS = {
  open:      ['fixing', 'wontfix', 'duplicate'],
  fixing:    ['fixed', 'open'],
  fixed:     ['verified', 'open'],
  verified:  ['open'],
  wontfix:   [],
  duplicate: [],
};

function canTransition(from, to) {
  if (!VALID_TRANSITIONS[from]) return false;
  return VALID_TRANSITIONS[from].includes(to);
}

class InvalidBugTransitionError extends Error {
  constructor(from, to, bugId) {
    super(`Invalid bug transition for ${bugId}: ${from} → ${to}`);
    this.name = 'InvalidBugTransitionError';
    this.from = from;
    this.to = to;
    this.bugId = bugId;
  }
}

// ────────────────────────────────────────────────────────────────
// 核心
// ────────────────────────────────────────────────────────────────

function dedupKey(bug) {
  const parts = [bug.unit_id || 'unknown', bug.test_name || bug.title || ''];
  return parts.join(':');
}

function _genBugId(now = new Date()) {
  const ts = now.toISOString().replace(/[-:T.Z]/g, '').slice(0, 14); // YYYYMMDDHHMMSS
  const tag = crypto.randomBytes(2).toString('hex');
  return `bug-${ts.slice(0,8)}-${ts.slice(8,12)}-${tag}`;
}

function _now() { return new Date().toISOString(); }

function _logPath(pid) { return pathResolver.resolveBugsLog(pid); }

function _appendLine(pid, obj) {
  const p = _logPath(pid);
  fs.mkdirSync(path.dirname(p), { recursive: true });
  fs.appendFileSync(p, JSON.stringify(obj) + '\n');
}

/** 流式读全部行(小项目 OK;大项目可优化为 readline 流式) */
function _readAll(pid) {
  const p = _logPath(pid);
  if (!fs.existsSync(p)) return [];
  const raw = fs.readFileSync(p, 'utf8');
  return raw.split('\n').filter(Boolean).map(l => {
    try { return JSON.parse(l); } catch { return null; }
  }).filter(Boolean);
}

/**
 * 按 bug id 聚合:最后一行 = 终态。
 * @returns Map<bugId, Bug>
 */
function _aggregateLatest(pid) {
  const lines = _readAll(pid);
  const map = new Map();
  for (const line of lines) {
    if (!line.id) continue;
    map.set(line.id, line);
  }
  return map;
}

// ────────────────────────────────────────────────────────────────
// open / transition
// ────────────────────────────────────────────────────────────────

/**
 * 开 bug。如 dedupKey 命中已存在 status=open|fixing 的 bug → 不重复,返旧 bug。
 *
 * @param {string} pid
 * @param {object} bug - 必填: source, unit_id?, run_id?, test_name?, category, severity, title, detail, affected_units
 * @returns Bug (含 id)
 */
function open(pid, bug) {
  // dedup 检查
  const key = dedupKey(bug);
  const all = _aggregateLatest(pid);
  for (const b of all.values()) {
    if ((b.status === 'open' || b.status === 'fixing') &&
        dedupKey(b) === key) {
      return b;
    }
  }

  const id = _genBugId();
  const now = _now();
  const full = {
    id,
    discovered_at: now,
    source:         bug.source || 'unit_test',
    unit_id:        bug.unit_id,
    run_id:         bug.run_id,
    test_name:      bug.test_name,
    category:       bug.category || 'unknown',
    severity:       bug.severity || 'medium',
    title:          (bug.title || '(no title)').slice(0, 120),
    detail:         bug.detail || '',
    affected_units: bug.affected_units || (bug.unit_id ? [bug.unit_id] : []),
    status:         'open',
    status_history: [{
      ts: now, from: '(none)', to: 'open',
      by: bug.by || 'tester',
      note: bug.note,
    }],
    fix_refs: [],
  };
  _appendLine(pid, full);

  if (timelineLogger) {
    timelineLogger.append(pid, {
      source: bug.source || 'tester',
      kind: 'bug_opened',
      ref: id,
      summary: full.title,
    });
  }
  if (sseBroker) {
    sseBroker.emit(pid, 'bug.opened', { id, category: full.category, severity: full.severity, title: full.title });
  }

  return full;
}

/**
 * 状态转换。同步发 SSE + timeline。
 */
function transition(pid, bugId, { to, by, note, fix_refs, duplicate_of } = {}) {
  if (!to) throw new Error('transition requires `to` field');
  const map = _aggregateLatest(pid);
  const b = map.get(bugId);
  if (!b) throw new Error(`bug ${bugId} not found`);

  if (!canTransition(b.status, to)) {
    throw new InvalidBugTransitionError(b.status, to, bugId);
  }

  const now = _now();
  // 复制一份 + 应用变更
  const next = {
    ...b,
    status: to,
    status_history: [
      ...(b.status_history || []),
      { ts: now, from: b.status, to, by: by || 'system', note },
    ],
    fix_refs: fix_refs ? [...(b.fix_refs || []), ...fix_refs] : (b.fix_refs || []),
    ...(duplicate_of ? { duplicate_of } : {}),
  };
  _appendLine(pid, next);

  if (timelineLogger) {
    timelineLogger.append(pid, {
      source: by || 'system',
      kind: to === 'open' ? 'bug_reopened' : 'bug_transitioned',
      ref: bugId,
      summary: `${b.status} → ${to}`,
    });
  }
  if (sseBroker) {
    sseBroker.emit(pid, 'bug.transitioned', { id: bugId, from: b.status, to, by });
  }

  return next;
}

// ────────────────────────────────────────────────────────────────
// 读
// ────────────────────────────────────────────────────────────────

/**
 * 列 bug(每个 id 的最新态)。可按 filter 过滤。
 * @param {object} filter - { status?: string[], severity?: string[], unit_id?, since?: ISO date }
 */
function list(pid, filter = {}) {
  let out = Array.from(_aggregateLatest(pid).values());
  if (filter.status?.length) {
    out = out.filter(b => filter.status.includes(b.status));
  }
  if (filter.severity?.length) {
    out = out.filter(b => filter.severity.includes(b.severity));
  }
  if (filter.unit_id) {
    out = out.filter(b => b.unit_id === filter.unit_id);
  }
  if (filter.since) {
    const t = new Date(filter.since).getTime();
    out = out.filter(b => new Date(b.discovered_at).getTime() >= t);
  }
  return out;
}

function get(pid, bugId) {
  return _aggregateLatest(pid).get(bugId) || null;
}

/** 完整 status_history(所有 history 条目,含 open 时的初始条目) */
function history(pid, bugId) {
  const b = get(pid, bugId);
  if (!b) return [];
  return b.status_history || [];
}

function stats(pid) {
  const all = list(pid);
  const out = {
    open: 0, fixing: 0, fixed: 0, verified: 0, wontfix: 0, duplicate: 0,
    by_severity: { critical: 0, high: 0, medium: 0, low: 0 },
    total: all.length,
  };
  for (const b of all) {
    if (out[b.status] !== undefined) out[b.status]++;
    if (out.by_severity[b.severity] !== undefined) out.by_severity[b.severity]++;
  }
  return out;
}

module.exports = {
  // 写
  open,
  transition,
  // 读
  list,
  get,
  history,
  stats,
  // 工具
  dedupKey,
  canTransition,
  VALID_TRANSITIONS,
  InvalidBugTransitionError,
  // 依赖注入
  attachTimelineLogger,
  attachSseBroker,
};