From 8f8641cfcb68eac1fb744c04a6416671df128bc6 Mon Sep 17 00:00:00 2001 From: Ismail Pelaseyed Date: Thu, 19 Mar 2026 10:24:25 +0100 Subject: [PATCH] feat: add Brin contributor trust check workflow and use OpenTUI native markdown (#157) --- .github/workflows/contributor-check.yml | 184 ++++++++++++++ src/ui/markdown.tsx | 304 +++--------------------- 2 files changed, 222 insertions(+), 266 deletions(-) create mode 100644 .github/workflows/contributor-check.yml diff --git a/.github/workflows/contributor-check.yml b/.github/workflows/contributor-check.yml new file mode 100644 index 0000000..5e84e1c --- /dev/null +++ b/.github/workflows/contributor-check.yml @@ -0,0 +1,184 @@ +name: Contributor Trust Check + +on: + pull_request_target: + types: [opened, reopened, synchronize] + +permissions: + contents: read + pull-requests: write + +jobs: + check: + name: Scan PR author + runs-on: ubuntu-24.04 + concurrency: + group: brin-check-${{ github.event.pull_request.number }} + cancel-in-progress: true + steps: + - name: Query Brin API + id: brin + run: | + AUTHOR="${{ github.event.pull_request.user.login }}" + RESPONSE=$(curl -sf "https://api.brin.sh/contributor/${AUTHOR}?details=true&mode=full" || echo '{}') + echo "response<> "$GITHUB_OUTPUT" + echo "$RESPONSE" >> "$GITHUB_OUTPUT" + echo "BRIN_EOF" >> "$GITHUB_OUTPUT" + + - name: Apply label and comment + uses: actions/github-script@v7 + env: + BRIN_RESPONSE: ${{ steps.brin.outputs.response }} + with: + script: | + const marker = ""; + const pr = context.payload.pull_request; + let data; + try { + data = JSON.parse(process.env.BRIN_RESPONSE); + } catch { + core.warning("Failed to parse Brin API response"); + return; + } + + if (!data.score && data.score !== 0) { + core.warning("Brin API returned no score"); + return; + } + + const score = data.score; + const verdict = data.verdict ?? "unknown"; + const confidence = data.confidence ?? "unknown"; + const isSafe = verdict === "safe"; + + const labels = [ + { name: "contributor:verified", color: "0969da", description: "Contributor passed trust analysis." }, + { name: "contributor:flagged", color: "e16f24", description: "Contributor flagged for review by trust analysis." }, + ]; + + for (const label of labels) { + try { + const { data: existing } = await github.rest.issues.getLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: label.name, + }); + if (existing.color !== label.color || (existing.description ?? "") !== label.description) { + await github.rest.issues.updateLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: label.name, + color: label.color, + description: label.description, + }); + } + } catch (error) { + if (error.status !== 404) throw error; + await github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: label.name, + color: label.color, + description: label.description, + }); + } + } + + const nextLabel = isSafe ? "contributor:verified" : "contributor:flagged"; + const labelNames = labels.map((l) => l.name); + + const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + }); + + const preserved = currentLabels + .map((l) => l.name) + .filter((name) => !labelNames.includes(name)); + + await github.rest.issues.setLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + labels: [...preserved, nextLabel], + }); + + core.info(`PR #${pr.number}: ${verdict} -> ${nextLabel}`); + + if (isSafe) { + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + per_page: 100, + }); + const existing = comments.find((c) => c.body?.includes(marker)); + if (existing) { + await github.rest.issues.deleteComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + }); + } + return; + } + + const sub = data.sub_scores ?? {}; + const fmt = (v) => (v != null ? String(Math.round(v)) : "\u2014"); + + const verdictEmoji = { + caution: "\u26A0\uFE0F", + suspicious: "\u26D4", + dangerous: "\uD83D\uDEA8", + }[verdict] ?? "\u2753"; + + let body = `${marker}\n`; + body += `### ${verdictEmoji} Contributor Trust Check \u2014 Review Recommended\n\n`; + body += `This contributor's profile shows patterns that may warrant additional review. `; + body += `This is based on their GitHub activity, not the contents of this PR.\n\n`; + body += `**[${data.name}](https://github.com/${data.name})** \u00b7 Score: **${score}**/100\n\n`; + + if (data.threats && data.threats.length > 0) { + body += `#### Why was this flagged?\n\n`; + body += `| Signal | Severity | Detail |\n|--------|----------|--------|\n`; + for (const t of data.threats) { + body += `| ${t.type} | ${t.severity} | ${t.detail} |\n`; + } + body += `\n`; + } + + body += `
\nDimension breakdown\n\n`; + body += `| Dimension | Score | What it measures |\n|-----------|-------|------------------|\n`; + body += `| Identity | ${fmt(sub.identity)} | Account age, contribution history, GPG keys, org memberships |\n`; + body += `| Behavior | ${fmt(sub.behavior)} | PR patterns, unsolicited contribution ratio, activity cadence |\n`; + body += `| Content | ${fmt(sub.content)} | PR body substance, issue linkage, contribution quality |\n`; + body += `| Graph | ${fmt(sub.graph)} | Cross-repo trust, co-contributor relationships |\n\n`; + body += `
\n\n`; + + body += `Analyzed by [Brin](https://brin.sh) \u00b7 [Full profile](${data.url}?details=true)\n`; + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + per_page: 100, + }); + + const existingComment = comments.find((c) => c.body?.includes(marker)); + + if (existingComment) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existingComment.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + body, + }); + } diff --git a/src/ui/markdown.tsx b/src/ui/markdown.tsx index 88d7c7b..bcd120a 100644 --- a/src/ui/markdown.tsx +++ b/src/ui/markdown.tsx @@ -1,275 +1,47 @@ -import React from "react"; +import React, { useMemo } from "react"; +import { SyntaxStyle, RGBA } from "@opentui/core"; import type { Theme } from "./theme.js"; -/* ── Block types ─────────────────────────────────────────────── */ - -type Block = - | { kind: "paragraph"; text: string } - | { kind: "heading"; level: number; text: string } - | { kind: "code"; lang: string; code: string } - | { kind: "list"; ordered: boolean; items: string[] } - | { kind: "hr" } - | { kind: "blockquote"; text: string }; - -/* ── Block parser ────────────────────────────────────────────── */ - -function parseBlocks(src: string): Block[] { - const lines = src.split("\n"); - const blocks: Block[] = []; - let i = 0; - - while (i < lines.length) { - const line = lines[i]; - - // Fenced code block - const fenceMatch = line.match(/^(`{3,}|~{3,})(\S*)/); - if (fenceMatch) { - const fence = fenceMatch[1]; - const lang = fenceMatch[2] || ""; - const codeLines: string[] = []; - i++; - while (i < lines.length && !lines[i].startsWith(fence)) { - codeLines.push(lines[i]); - i++; - } - i++; // skip closing fence - blocks.push({ kind: "code", lang, code: codeLines.join("\n") }); - continue; - } - - // Heading - const headingMatch = line.match(/^(#{1,6})\s+(.+)/); - if (headingMatch) { - blocks.push({ kind: "heading", level: headingMatch[1].length, text: headingMatch[2] }); - i++; - continue; - } - - // Horizontal rule - if (/^(\s*[-*_]\s*){3,}$/.test(line)) { - blocks.push({ kind: "hr" }); - i++; - continue; - } - - // Unordered list - if (/^\s*[-*+]\s+/.test(line)) { - const items: string[] = []; - while (i < lines.length && /^\s*[-*+]\s+/.test(lines[i])) { - items.push(lines[i].replace(/^\s*[-*+]\s+/, "")); - i++; - } - blocks.push({ kind: "list", ordered: false, items }); - continue; - } - - // Ordered list - if (/^\s*\d+[.)]\s+/.test(line)) { - const items: string[] = []; - while (i < lines.length && /^\s*\d+[.)]\s+/.test(lines[i])) { - items.push(lines[i].replace(/^\s*\d+[.)]\s+/, "")); - i++; - } - blocks.push({ kind: "list", ordered: true, items }); - continue; - } - - // Blockquote - if (line.startsWith("> ")) { - const bqLines: string[] = []; - while (i < lines.length && lines[i].startsWith("> ")) { - bqLines.push(lines[i].slice(2)); - i++; - } - blocks.push({ kind: "blockquote", text: bqLines.join("\n") }); - continue; - } - - // Empty line — skip - if (line.trim() === "") { - i++; - continue; - } - - // Paragraph — collect until empty line or block start - const paraLines: string[] = []; - while ( - i < lines.length && - lines[i].trim() !== "" && - !lines[i].match(/^(`{3,}|~{3,})/) && - !lines[i].match(/^#{1,6}\s+/) && - !lines[i].match(/^(\s*[-*_]\s*){3,}$/) && - !lines[i].match(/^\s*[-*+]\s+/) && - !lines[i].match(/^\s*\d+[.)]\s+/) && - !lines[i].startsWith("> ") - ) { - paraLines.push(lines[i]); - i++; - } - if (paraLines.length > 0) { - blocks.push({ kind: "paragraph", text: paraLines.join("\n") }); - } - } - - return blocks; +function buildSyntaxStyle(t: Theme): SyntaxStyle { + return SyntaxStyle.fromStyles({ + default: { fg: RGBA.fromHex(t.text) }, + "markup.heading": { fg: RGBA.fromHex(t.mdHeading), bold: true }, + "markup.heading.1": { fg: RGBA.fromHex(t.mdHeading), bold: true }, + "markup.heading.2": { fg: RGBA.fromHex(t.mdHeading), bold: true }, + "markup.heading.3": { fg: RGBA.fromHex(t.mdHeading), bold: true }, + "markup.bold": { fg: RGBA.fromHex(t.mdBold), bold: true }, + "markup.italic": { fg: RGBA.fromHex(t.mdItalic), italic: true }, + "markup.raw": { fg: RGBA.fromHex(t.mdCode) }, + "markup.link": { fg: RGBA.fromHex(t.mdLink), underline: true }, + "markup.link.label": { fg: RGBA.fromHex(t.mdLinkText) }, + "markup.list": { fg: RGBA.fromHex(t.mdListBullet) }, + "markup.quote": { fg: RGBA.fromHex(t.mdItalic), italic: true }, + "markup.separator": { fg: RGBA.fromHex(t.mdHr) }, + code: { fg: RGBA.fromHex(t.mdCodeBlockFg), bg: RGBA.fromHex(t.mdCodeBlockBg) }, + }); } -/* ── Inline parser ───────────────────────────────────────────── */ - -type InlineNode = - | { kind: "text"; text: string } - | { kind: "bold"; text: string } - | { kind: "italic"; text: string } - | { kind: "bolditalic"; text: string } - | { kind: "code"; text: string } - | { kind: "link"; text: string; url: string }; - -function parseInline(src: string): InlineNode[] { - const nodes: InlineNode[] = []; - // Order matters: bold-italic before bold before italic - const pattern = - /(`[^`]+`)|(\*\*\*[^*]+\*\*\*|___[^_]+___)|( \*\*[^*]+\*\*|__[^_]+__)|(\*[^*]+\*|_[^_]+_)|(\[[^\]]+\]\([^)]+\))/g; - - let last = 0; - let match: RegExpExecArray | null; - - while ((match = pattern.exec(src)) !== null) { - if (match.index > last) { - nodes.push({ kind: "text", text: src.slice(last, match.index) }); - } - const m = match[0]; - - if (match[1]) { - // inline code - nodes.push({ kind: "code", text: m.slice(1, -1) }); - } else if (match[2]) { - // bold italic - nodes.push({ kind: "bolditalic", text: m.slice(3, -3) }); - } else if (match[3]) { - // bold - nodes.push({ kind: "bold", text: m.slice(2, -2) }); - } else if (match[4]) { - // italic - nodes.push({ kind: "italic", text: m.slice(1, -1) }); - } else if (match[5]) { - // link - const linkMatch = m.match(/\[([^\]]+)\]\(([^)]+)\)/); - if (linkMatch) { - nodes.push({ kind: "link", text: linkMatch[1], url: linkMatch[2] }); - } - } - - last = match.index + m.length; - } - - if (last < src.length) { - nodes.push({ kind: "text", text: src.slice(last) }); - } - - return nodes; -} - -/* ── Inline renderer ─────────────────────────────────────────── */ - -function InlineContent({ text, t }: { text: string; t: Theme }) { - const nodes = parseInline(text); - return ( - - {nodes.map((node, i) => { - switch (node.kind) { - case "text": - return {node.text}; - case "bold": - return {node.text}; - case "italic": - return {node.text}; - case "bolditalic": - return {node.text}; - case "code": - return {`\`${node.text}\``}; - case "link": - return ( - - {node.text} - {" ("} - {node.url} - {")"} - - ); - } - })} - - ); -} - -/* ── Block renderer ──────────────────────────────────────────── */ - -function renderBlock(block: Block, key: number, t: Theme): React.ReactNode { - switch (block.kind) { - case "heading": - return ( - 0 ? 1 : 0}> - - {block.text} - - - ); - - case "paragraph": - return ( - - - - ); - - case "code": - return ( - - {block.code} - - ); - - case "list": - return ( - - {block.items.map((item, i) => ( - - - - {block.ordered ? `${i + 1}. ` : " • "} - - - - - ))} - - ); - - case "blockquote": - return ( - - - {block.text} - - - ); - - case "hr": - return ( - - {"─".repeat(40)} - - ); - } -} - -/* ── Public component ────────────────────────────────────────── */ +const TABLE_OPTIONS = { + widthMode: "full" as const, + columnFitter: "balanced" as const, + wrapMode: "word" as const, + cellPadding: 1, + borders: true, + outerBorder: true, + borderStyle: "rounded" as const, + borderColor: "#333333", +}; export function Markdown({ content, t }: { content: string; t: Theme }) { - const blocks = parseBlocks(content); + const syntaxStyle = useMemo(() => buildSyntaxStyle(t), [t]); + return ( - - {blocks.map((block, i) => renderBlock(block, i, t))} - + ); }