All files / src/channel-gateway format.ts

100% Statements 43/43
96.29% Branches 26/27
100% Functions 7/7
100% Lines 38/38

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100            3x     3x             3x             3x 30x 25x 25x 25x 23x 23x   1x       7x       3x 9x 9x 8x 8x 10x 10x   8x               3x 5x 5x 4x 4x 5x 5x 5x               4x               3x 6x 10x     6x 6x 6x     3x   6x             6x    
/**
 * Pure adapters between MCP tool results and the watch core (issue #483):
 * unwrap an MCP `CallToolResult` ({content:[{text}]}) into its JSON payload,
 * parse a `browse_pipelines`/`jobs` result into `JobState[]`, and render a
 * `WatchEvent` into a channel message (content + meta). No I/O.
 */
import * as z from 'zod';
import type { JobState, WatchEvent } from './watch';
 
const JobSchema = z.object({
  id: z.number(),
  name: z.string(),
  stage: z.string(),
  status: z.string(),
});
 
const DeploymentSchema = z.object({
  id: z.number(),
  status: z.string(),
  environment: z.object({ name: z.string() }).optional(),
});
 
/** MCP tool results wrap JSON in a text content block; unwrap it, else pass through. */
export function parseToolResult(result: unknown): unknown {
  if (result && typeof result === 'object' && 'content' in result) {
    const content = (result as { content?: Array<{ type?: string; text?: string }> }).content;
    const text = Array.isArray(content) ? content.find((c) => c.type === 'text')?.text : undefined;
    if (typeof text === 'string') {
      try {
        return JSON.parse(text);
      } catch {
        return null;
      }
    }
  }
  return result;
}
 
/** Parse a jobs result into JobState[], silently dropping malformed entries. */
export function parseJobs(result: unknown): JobState[] {
  const data = parseToolResult(result);
  if (!Array.isArray(data)) return [];
  const out: JobState[] = [];
  for (const item of data) {
    const parsed = JobSchema.safeParse(item);
    if (parsed.success) out.push(parsed.data);
  }
  return out;
}
 
/**
 * Parse a deployments list into pseudo-jobs the watch core can treat uniformly:
 * one JobState per deployment, named by its environment. Lets a deployment be
 * watched with the same aggregate/diff/terminal machinery as a pipeline.
 */
export function parseDeployments(result: unknown): JobState[] {
  const data = parseToolResult(result);
  if (!Array.isArray(data)) return [];
  const out: JobState[] = [];
  for (const item of data) {
    const parsed = DeploymentSchema.safeParse(item);
    Eif (parsed.success) {
      out.push({
        id: parsed.data.id,
        name: parsed.data.environment?.name ?? `deployment-${parsed.data.id}`,
        stage: 'deploy',
        status: parsed.data.status,
      });
    }
  }
  return out;
}
 
/**
 * Render a watch event into a channel message. `content` is the human-readable
 * body the agent sees; `meta` keys are identifiers ([a-z0-9_]) per the channel
 * protocol (hyphens in keys would be dropped, so only stable keys are used).
 */
export function formatEvent(event: WatchEvent): { content: string; meta: Record<string, string> } {
  const { target, pipelineState, jobs, transitions, terminal } = event;
  const jobsLine = jobs.map((j) => `${j.name}:${j.status}`).join(' ');
  // A deployment is watched through the same job machinery, but the channel
  // message and meta key must name it for what it is, not as a pipeline.
  const label = target.kind === 'deployment' ? 'Deployment' : 'Pipeline';
  const idKey = target.kind === 'deployment' ? 'deployment_id' : 'pipeline_id';
  const content = terminal
    ? `${label} #${target.id} (project ${target.projectId}) finished: ${pipelineState}. Jobs: ${jobsLine}`
    : `${label} #${target.id} (project ${target.projectId}) ${transitions
        .map((t) => `${t.name} ${t.from ?? 'new'}->${t.to}`)
        .join(', ')}. Now: ${jobsLine}`;
  const meta: Record<string, string> = {
    [idKey]: String(target.id),
    project_id: target.projectId,
    kind: target.kind,
    state: pipelineState,
    terminal: String(terminal),
  };
  return { content, meta };
}