All files / src/channel-gateway format.ts

100% Statements 49/49
97.05% Branches 33/34
100% Functions 7/7
100% Lines 41/41

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 101 102 103            3x     3x             3x             3x 45x 40x 37x 35x 30x 30x 30x 26x 26x   1x       9x       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(),
});
 
/** Read successful native output or legacy JSON text; errors cannot arm watches. */
export function parseToolResult(result: unknown): unknown {
  if (!result || typeof result !== 'object') return result;
  if ('isError' in result && result.isError === true) return null;
  if ('structuredContent' in result) return result.structuredContent;
  if ('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 };
}