Skip to content

Commit 4ac7735

Browse files
KryptosAIclaude
andauthored
feat: 5 CI features — lock files, matrix comments, commit status, nightly scans, trends (#81)
Add lock files, matrix PR comments, commit status checks, nightly scans with auto-issues, trend tracking, and 15 new telemetry enrichment fields. 287/287 tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 13b37ad commit 4ac7735

25 files changed

Lines changed: 2338 additions & 63 deletions
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# MCP Observatory Nightly Scan
2+
# Copy this file to .github/workflows/observatory-nightly.yml in your repo.
3+
# It scans your MCP servers daily and opens an issue if regressions are found.
4+
name: MCP Observatory Nightly Scan
5+
on:
6+
schedule:
7+
- cron: '0 6 * * *' # 6 AM UTC daily
8+
workflow_dispatch: {}
9+
10+
jobs:
11+
scan:
12+
runs-on: ubuntu-latest
13+
permissions:
14+
issues: write
15+
contents: read
16+
steps:
17+
- uses: actions/checkout@v4
18+
- uses: actions/setup-node@v4
19+
with:
20+
node-version: '22'
21+
- name: Install MCP Observatory
22+
run: npm install -g @kryptosai/mcp-observatory
23+
- name: Run nightly scan
24+
run: mcp-observatory scan --no-color
25+
- name: Generate CI report
26+
id: report
27+
run: |
28+
mcp-observatory ci-report --format json > /tmp/observatory-report.json
29+
echo "has_regressions=$(node -e "console.log(JSON.parse(require('fs').readFileSync('/tmp/observatory-report.json','utf8')).hasRegressions)")" >> $GITHUB_OUTPUT
30+
- name: Create or update issue
31+
if: steps.report.outputs.has_regressions == 'true'
32+
env:
33+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
34+
run: |
35+
TITLE=$(node -e "console.log(JSON.parse(require('fs').readFileSync('/tmp/observatory-report.json','utf8')).title)")
36+
BODY_FILE="/tmp/observatory-body.md"
37+
node -e "process.stdout.write(JSON.parse(require('fs').readFileSync('/tmp/observatory-report.json','utf8')).body)" > "$BODY_FILE"
38+
EXISTING=$(gh issue list --label "mcp-observatory" --state open --json number -q '.[0].number // empty' 2>/dev/null || echo "")
39+
if [ -n "$EXISTING" ]; then
40+
gh issue comment "$EXISTING" --body-file "$BODY_FILE"
41+
else
42+
gh issue create --title "$TITLE" --body-file "$BODY_FILE" --label "mcp-observatory"
43+
fi
44+
- name: Upload scan artifacts
45+
uses: actions/upload-artifact@v4
46+
if: always()
47+
with:
48+
name: observatory-nightly-${{ github.run_number }}
49+
path: .mcp-observatory/runs/
50+
retention-days: 30

action/action.yml

Lines changed: 92 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ inputs:
1111
target:
1212
description: "Path to a target config JSON file (alternative to command)"
1313
required: false
14+
targets:
15+
description: "Path to MCP config file for multi-server matrix scan (scans all servers, generates matrix comment)"
16+
required: false
1417
baseline:
1518
description: "Path to a baseline cassette file for regression verification"
1619
required: false
@@ -27,11 +30,15 @@ inputs:
2730
required: false
2831
default: "true"
2932
comment-on-pr:
30-
description: "Post a markdown report as a PR comment"
33+
description: "Post a report as a PR comment"
34+
required: false
35+
default: "true"
36+
set-status:
37+
description: "Set a commit status check (green/red) on the HEAD SHA"
3138
required: false
3239
default: "true"
3340
github-token:
34-
description: "GitHub token for PR comments"
41+
description: "GitHub token for PR comments and commit statuses"
3542
required: false
3643
default: ${{ github.token }}
3744
node-version:
@@ -65,57 +72,79 @@ runs:
6572
env:
6673
INPUT_COMMAND: ${{ inputs.command }}
6774
INPUT_TARGET: ${{ inputs.target }}
75+
INPUT_TARGETS: ${{ inputs.targets }}
6876
INPUT_DEEP: ${{ inputs.deep }}
6977
INPUT_SECURITY: ${{ inputs.security }}
7078
run: |
71-
# Build command as a bash array to prevent injection
72-
CMD_ARRAY=()
73-
74-
if [ -n "$INPUT_TARGET" ]; then
75-
CMD_ARRAY=(mcp-observatory run --target "$INPUT_TARGET")
76-
if [ "$INPUT_DEEP" = "true" ]; then
77-
CMD_ARRAY+=(--invoke)
78-
fi
79-
elif [ -n "$INPUT_COMMAND" ]; then
80-
CMD_ARRAY=(mcp-observatory test "$INPUT_COMMAND")
81-
else
82-
echo "::error::Either 'command' or 'target' input is required"
83-
exit 1
84-
fi
85-
86-
if [ "$INPUT_SECURITY" = "true" ]; then
87-
CMD_ARRAY+=(--security)
88-
fi
89-
90-
CMD_ARRAY+=(--no-color)
91-
92-
# Run and capture output
9379
ARTIFACT_DIR="${RUNNER_TEMP}/observatory"
9480
mkdir -p "$ARTIFACT_DIR"
9581
96-
set +e
97-
"${CMD_ARRAY[@]}" 2>&1 | tee "${ARTIFACT_DIR}/output.txt"
98-
EXIT_CODE=$?
99-
set -e
100-
101-
# Find the artifact file
102-
ARTIFACT_PATH=$(find .mcp-observatory/runs -name "*.json" -type f 2>/dev/null | sort | tail -1)
82+
# Multi-server matrix scan
83+
if [ -n "$INPUT_TARGETS" ]; then
84+
SCAN_ARRAY=(mcp-observatory scan --config "$INPUT_TARGETS")
85+
if [ "$INPUT_SECURITY" = "true" ]; then
86+
SCAN_ARRAY+=(--security)
87+
fi
88+
SCAN_ARRAY+=(--no-color)
89+
90+
set +e
91+
"${SCAN_ARRAY[@]}" 2>&1 | tee "${ARTIFACT_DIR}/output.txt"
92+
set -e
93+
94+
# Collect all artifacts and determine gate
95+
GATE="pass"
96+
for f in .mcp-observatory/runs/*.json; do
97+
[ -f "$f" ] || continue
98+
FILE_GATE=$(node -e "console.log(JSON.parse(require('fs').readFileSync('$f','utf8')).gate)" 2>/dev/null || echo "unknown")
99+
if [ "$FILE_GATE" = "fail" ]; then
100+
GATE="fail"
101+
fi
102+
done
103+
echo "gate=${GATE}" >> "$GITHUB_OUTPUT"
104+
echo "artifact_path=${ARTIFACT_DIR}" >> "$GITHUB_OUTPUT"
103105
104-
if [ -n "$ARTIFACT_PATH" ]; then
105-
cp "$ARTIFACT_PATH" "${ARTIFACT_DIR}/run.json"
106-
echo "artifact_path=${ARTIFACT_DIR}/run.json" >> "$GITHUB_OUTPUT"
106+
# Generate CI report for PR comment
107+
mcp-observatory ci-report --format markdown --no-color > "${ARTIFACT_DIR}/report.md" 2>/dev/null || true
107108
108-
# Extract gate from artifact using node (avoid python dependency)
109-
GATE=$(node -e "const fs=require('fs'); const d=JSON.parse(fs.readFileSync('${ARTIFACT_DIR}/run.json','utf8')); console.log(d.gate)" 2>/dev/null || echo "unknown")
110-
echo "gate=${GATE}" >> "$GITHUB_OUTPUT"
109+
# Single-server scan
111110
else
112-
echo "gate=fail" >> "$GITHUB_OUTPUT"
113-
echo "artifact_path=" >> "$GITHUB_OUTPUT"
114-
fi
111+
CMD_ARRAY=()
112+
if [ -n "$INPUT_TARGET" ]; then
113+
CMD_ARRAY=(mcp-observatory run --target "$INPUT_TARGET")
114+
if [ "$INPUT_DEEP" = "true" ]; then
115+
CMD_ARRAY+=(--invoke)
116+
fi
117+
elif [ -n "$INPUT_COMMAND" ]; then
118+
CMD_ARRAY=(mcp-observatory test "$INPUT_COMMAND")
119+
else
120+
echo "::error::Either 'command', 'target', or 'targets' input is required"
121+
exit 1
122+
fi
115123
116-
# Generate PR comment report
117-
if [ -n "$ARTIFACT_PATH" ]; then
118-
mcp-observatory report "$ARTIFACT_PATH" --format pr-comment --no-color > "${ARTIFACT_DIR}/report.md" 2>/dev/null || true
124+
if [ "$INPUT_SECURITY" = "true" ]; then
125+
CMD_ARRAY+=(--security)
126+
fi
127+
CMD_ARRAY+=(--no-color)
128+
129+
set +e
130+
"${CMD_ARRAY[@]}" 2>&1 | tee "${ARTIFACT_DIR}/output.txt"
131+
set -e
132+
133+
ARTIFACT_PATH=$(find .mcp-observatory/runs -name "*.json" -type f 2>/dev/null | sort | tail -1)
134+
if [ -n "$ARTIFACT_PATH" ]; then
135+
cp "$ARTIFACT_PATH" "${ARTIFACT_DIR}/run.json"
136+
echo "artifact_path=${ARTIFACT_DIR}/run.json" >> "$GITHUB_OUTPUT"
137+
GATE=$(node -e "const fs=require('fs'); const d=JSON.parse(fs.readFileSync('${ARTIFACT_DIR}/run.json','utf8')); console.log(d.gate)" 2>/dev/null || echo "unknown")
138+
echo "gate=${GATE}" >> "$GITHUB_OUTPUT"
139+
else
140+
echo "gate=fail" >> "$GITHUB_OUTPUT"
141+
echo "artifact_path=" >> "$GITHUB_OUTPUT"
142+
fi
143+
144+
# Generate PR comment report
145+
if [ -n "$ARTIFACT_PATH" ]; then
146+
mcp-observatory report "$ARTIFACT_PATH" --format pr-comment --no-color > "${ARTIFACT_DIR}/report.md" 2>/dev/null || true
147+
fi
119148
fi
120149
121150
- name: Verify against baseline
@@ -126,15 +155,12 @@ runs:
126155
INPUT_COMMAND: ${{ inputs.command }}
127156
INPUT_TARGET: ${{ inputs.target }}
128157
run: |
129-
# Build verify command as a bash array
130158
VERIFY_ARRAY=(mcp-observatory verify "$INPUT_BASELINE")
131-
132159
if [ -n "$INPUT_TARGET" ]; then
133160
VERIFY_ARRAY+=(--target "$INPUT_TARGET")
134161
elif [ -n "$INPUT_COMMAND" ]; then
135162
VERIFY_ARRAY+=("$INPUT_COMMAND")
136163
fi
137-
138164
VERIFY_ARRAY+=(--no-color)
139165
140166
set +e
@@ -146,6 +172,26 @@ runs:
146172
echo "::warning::Baseline verification detected changes"
147173
fi
148174
175+
- name: Set commit status
176+
if: inputs.set-status == 'true' && github.event_name == 'pull_request'
177+
shell: bash
178+
env:
179+
GH_TOKEN: ${{ inputs.github-token }}
180+
GATE: ${{ steps.run.outputs.gate }}
181+
run: |
182+
STATE="success"
183+
DESC="All clear"
184+
if [ "$GATE" = "fail" ]; then
185+
STATE="failure"
186+
DESC="Issues detected"
187+
fi
188+
gh api "repos/${{ github.repository }}/statuses/${{ github.event.pull_request.head.sha }}" \
189+
-f state="$STATE" \
190+
-f description="$DESC" \
191+
-f context="MCP Observatory" \
192+
-f target_url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
193+
2>/dev/null || echo "::warning::Could not set commit status"
194+
149195
- name: Comment on PR
150196
if: inputs.comment-on-pr == 'true' && github.event_name == 'pull_request'
151197
shell: bash
@@ -161,7 +207,6 @@ runs:
161207
exit 0
162208
fi
163209
164-
# pr-comment format already includes header and footer
165210
COMMENT_FILE="${RUNNER_TEMP}/observatory/comment.md"
166211
cp "$REPORT_FILE" "$COMMENT_FILE"
167212

src/ci-issue.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { execFile } from "node:child_process";
2+
import { writeFile, unlink } from "node:fs/promises";
3+
import { tmpdir } from "node:os";
4+
import path from "node:path";
5+
6+
/**
7+
* Run a command via execFile and return { stdout, stderr }.
8+
*/
9+
function execCommand(
10+
cmd: string,
11+
args: string[],
12+
): Promise<{ stdout: string; stderr: string }> {
13+
return new Promise((resolve, reject) => {
14+
execFile(cmd, args, (error, stdout, stderr) => {
15+
if (error) {
16+
reject(error as Error);
17+
} else {
18+
resolve({ stdout: stdout ?? "", stderr: stderr ?? "" });
19+
}
20+
});
21+
});
22+
}
23+
24+
/** Executor type for dependency injection (testability). */
25+
export type CommandExecutor = (
26+
cmd: string,
27+
args: string[],
28+
) => Promise<{ stdout: string; stderr: string }>;
29+
30+
/**
31+
* Find an existing open issue with the given label.
32+
* Returns the issue number or null if none found / gh unavailable.
33+
*/
34+
export async function findExistingIssue(
35+
repo: string,
36+
label: string,
37+
exec: CommandExecutor = execCommand,
38+
): Promise<number | null> {
39+
try {
40+
const { stdout } = await exec("gh", [
41+
"issue",
42+
"list",
43+
"--repo",
44+
repo,
45+
"--label",
46+
label,
47+
"--state",
48+
"open",
49+
"--json",
50+
"number",
51+
"--limit",
52+
"1",
53+
]);
54+
const parsed: unknown = JSON.parse(stdout);
55+
if (Array.isArray(parsed) && parsed.length > 0) {
56+
const first = parsed[0] as Record<string, unknown>;
57+
if (typeof first["number"] === "number") {
58+
return first["number"];
59+
}
60+
}
61+
return null;
62+
} catch {
63+
return null;
64+
}
65+
}
66+
67+
/**
68+
* Create a new issue or comment on an existing one.
69+
* Uses --body-file to avoid shell injection.
70+
* Returns the issue number.
71+
*/
72+
export async function createOrUpdateIssue(options: {
73+
repo: string;
74+
title: string;
75+
body: string;
76+
labels: string[];
77+
exec?: CommandExecutor;
78+
}): Promise<number> {
79+
const exec = options.exec ?? execCommand;
80+
const bodyFile = path.join(
81+
tmpdir(),
82+
`mcp-observatory-issue-${Date.now()}.md`,
83+
);
84+
85+
try {
86+
await writeFile(bodyFile, options.body, "utf8");
87+
88+
const existingNumber = await findExistingIssue(
89+
options.repo,
90+
options.labels[0] ?? "mcp-observatory",
91+
exec,
92+
);
93+
94+
if (existingNumber !== null) {
95+
await exec("gh", [
96+
"issue",
97+
"comment",
98+
String(existingNumber),
99+
"--repo",
100+
options.repo,
101+
"--body-file",
102+
bodyFile,
103+
]);
104+
return existingNumber;
105+
}
106+
107+
// Create new issue
108+
const args = [
109+
"issue",
110+
"create",
111+
"--repo",
112+
options.repo,
113+
"--title",
114+
options.title,
115+
"--body-file",
116+
bodyFile,
117+
];
118+
for (const label of options.labels) {
119+
args.push("--label", label);
120+
}
121+
122+
const { stdout } = await exec("gh", args);
123+
// gh issue create prints the URL, e.g. https://github.com/owner/repo/issues/42
124+
const match = /\/issues\/(\d+)/.exec(stdout.trim());
125+
if (match?.[1]) {
126+
return parseInt(match[1], 10);
127+
}
128+
129+
throw new Error(
130+
`Failed to parse issue number from gh output: ${stdout.trim()}`,
131+
);
132+
} finally {
133+
await unlink(bodyFile).catch(() => {});
134+
}
135+
}

src/ci.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,10 @@ import ci from "ci-info";
22

33
export const isCI: boolean = ci.isCI;
44
export const ciName: string | null = ci.name;
5+
6+
export function getGitHubContext(): { sha: string; repo: string } | null {
7+
const sha = process.env["GITHUB_SHA"];
8+
const repo = process.env["GITHUB_REPOSITORY"];
9+
if (!sha || !repo) return null;
10+
return { sha, repo };
11+
}

0 commit comments

Comments
 (0)