feat: add Brin contributor trust check workflow and use OpenTUI native markdown (#157)
This commit is contained in:
1 parent
ee1ef73e93
commit
8f8641cfcb
2 files changed
+222
-266
No files matched your search
@@ -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<<BRIN_EOF" >> "$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 = "<!-- brin-check -->";
|
||||
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 += `<details>\n<summary>Dimension breakdown</summary>\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 += `</details>\n\n`;
|
||||
|
||||
body += `<sub>Analyzed by [Brin](https://brin.sh) \u00b7 [Full profile](${data.url}?details=true)</sub>\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,
|
||||
});
|
||||
}
|
||||
+38
-266
@@ -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 (
|
||||
<text>
|
||||
{nodes.map((node, i) => {
|
||||
switch (node.kind) {
|
||||
case "text":
|
||||
return <span key={i} style={{ fg: t.text }}>{node.text}</span>;
|
||||
case "bold":
|
||||
return <b key={i}><span style={{ fg: t.mdBold }}>{node.text}</span></b>;
|
||||
case "italic":
|
||||
return <i key={i}><span style={{ fg: t.mdItalic }}>{node.text}</span></i>;
|
||||
case "bolditalic":
|
||||
return <b key={i}><i><span style={{ fg: t.mdBold }}>{node.text}</span></i></b>;
|
||||
case "code":
|
||||
return <span key={i} style={{ fg: t.mdCode }}>{`\`${node.text}\``}</span>;
|
||||
case "link":
|
||||
return (
|
||||
<span key={i}>
|
||||
<span style={{ fg: t.mdLinkText }}>{node.text}</span>
|
||||
<span style={{ fg: t.textDim }}>{" ("}</span>
|
||||
<u><span style={{ fg: t.mdLink }}>{node.url}</span></u>
|
||||
<span style={{ fg: t.textDim }}>{")"}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</text>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Block renderer ──────────────────────────────────────────── */
|
||||
|
||||
function renderBlock(block: Block, key: number, t: Theme): React.ReactNode {
|
||||
switch (block.kind) {
|
||||
case "heading":
|
||||
return (
|
||||
<box key={key} marginTop={key > 0 ? 1 : 0}>
|
||||
<text>
|
||||
<b><span style={{ fg: t.mdHeading }}>{block.text}</span></b>
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
|
||||
case "paragraph":
|
||||
return (
|
||||
<box key={key}>
|
||||
<InlineContent text={block.text} t={t} />
|
||||
</box>
|
||||
);
|
||||
|
||||
case "code":
|
||||
return (
|
||||
<box key={key} marginTop={0} marginBottom={0} backgroundColor={t.mdCodeBlockBg} paddingLeft={1} paddingRight={1}>
|
||||
<text fg={t.mdCodeBlockFg}>{block.code}</text>
|
||||
</box>
|
||||
);
|
||||
|
||||
case "list":
|
||||
return (
|
||||
<box key={key} flexDirection="column">
|
||||
{block.items.map((item, i) => (
|
||||
<box key={i} flexDirection="row">
|
||||
<text>
|
||||
<span style={{ fg: t.mdListBullet }}>
|
||||
{block.ordered ? `${i + 1}. ` : " • "}
|
||||
</span>
|
||||
</text>
|
||||
<InlineContent text={item} t={t} />
|
||||
</box>
|
||||
))}
|
||||
</box>
|
||||
);
|
||||
|
||||
case "blockquote":
|
||||
return (
|
||||
<box key={key} paddingLeft={1} borderColor={t.mdItalic} border={["left"]}>
|
||||
<text>
|
||||
<i><span style={{ fg: t.mdItalic }}>{block.text}</span></i>
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
|
||||
case "hr":
|
||||
return (
|
||||
<box key={key} marginTop={0} marginBottom={0}>
|
||||
<text fg={t.mdHr}>{"─".repeat(40)}</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 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 (
|
||||
<box flexDirection="column">
|
||||
{blocks.map((block, i) => renderBlock(block, i, t))}
|
||||
</box>
|
||||
<markdown
|
||||
content={content}
|
||||
syntaxStyle={syntaxStyle}
|
||||
conceal={true}
|
||||
tableOptions={TABLE_OPTIONS}
|
||||
flexShrink={0}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user