Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci-nudge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ jobs:
app-id: ${{ secrets.TESTSPRITE_HOB_APP_ID || secrets.ALFHEIM_AGENT_APP_ID }}
private-key: ${{ secrets.TESTSPRITE_HOB_PRIVATE_KEY || secrets.ALFHEIM_AGENT_PRIVATE_KEY }}

- uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.0.1
- uses: actions/github-script@v8

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Pin all external action references immutably.

Each workflow uses the mutable actions/github-script@v8 tag:

  • .github/workflows/ci-nudge.yml#L44-L44: pin actions/github-script to the exact 40-character SHA for the selected v8 release.
  • .github/workflows/issue-triage.yml#L52-L52: pin actions/github-script to the exact 40-character SHA for the selected v8 release.
  • .github/workflows/pr-triage.yml#L64-L64: pin actions/github-script to the exact 40-character SHA for the selected v8 release.
📍 Affects 3 files
  • .github/workflows/ci-nudge.yml#L44-L44 (this comment)
  • .github/workflows/issue-triage.yml#L52-L52
  • .github/workflows/pr-triage.yml#L64-L64
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/ci-nudge.yml at line 44, Replace the mutable
actions/github-script@v8 reference with the exact 40-character commit SHA for
the selected v8 release in .github/workflows/ci-nudge.yml:44,
.github/workflows/issue-triage.yml:52, and .github/workflows/pr-triage.yml:64.

Source: Path instructions

env:
APP_TOKEN: ${{ steps.app-token.outputs.token }}
with:
Expand All @@ -51,7 +51,7 @@ jobs:
const marker = process.env.MARKER;

const appClient = process.env.APP_TOKEN
? require('@actions/github').getOctokit(process.env.APP_TOKEN)
? getOctokit(process.env.APP_TOKEN)
: null;
Comment on lines 53 to 55

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== Workflow references ==\n'
rg -n "actions/github-script|getOctokit|APP_TOKEN" .github/workflows/ci-nudge.yml .github/workflows/pr-triage.yml

printf '\n== Relevant workflow snippets ==\n'
for f in .github/workflows/ci-nudge.yml .github/workflows/pr-triage.yml; do
  echo "--- $f ---"
  nl -ba "$f" | sed -n '1,220p' | rg -n "actions/github-script|getOctokit|APP_TOKEN|script:|with:"
done

printf '\n== Upstream github-script v8 source check ==\n'
python3 - <<'PY'
import urllib.request, re
url = "https://raw.githubusercontent.com/actions/github-script/v8.0.0/dist/index.js"
data = urllib.request.urlopen(url, timeout=20).read().decode()
for needle in ["getOctokit", "github-token", "script:", "context", "core"]:
    print(f"{needle}: {'yes' if needle in data else 'no'}")
m = re.search(r"getOctokit", data)
print("first getOctokit index:", m.start() if m else None)
PY

Repository: TestSprite/testsprite-cli

Length of output: 974


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Workflow excerpts =="
python3 - <<'PY'
from pathlib import Path
for f in [Path('.github/workflows/ci-nudge.yml'), Path('.github/workflows/pr-triage.yml')]:
    print(f"\n--- {f} ---")
    lines = f.read_text().splitlines()
    for i, line in enumerate(lines, 1):
        if 'actions/github-script@v8' in line or 'APP_TOKEN' in line or 'getOctokit' in line:
            start = max(1, i-4)
            end = min(len(lines), i+4)
            for j in range(start, end+1):
                print(f"{j:4d}: {lines[j-1]}")
            print()
PY

echo "== github-script v8 source: helper injection =="
python3 - <<'PY'
import urllib.request, re, sys
url = "https://raw.githubusercontent.com/actions/github-script/v8.0.0/dist/index.js"
data = urllib.request.urlopen(url, timeout=20).read().decode()
for needle in ["getOctokit", "context", "github-token", "with:", "script"]:
    print(f"{needle}: {'found' if needle in data else 'missing'}")
for pat in [r"const\s+getOctokit", r"getOctokit\s*[:=]", r"inputs\.github-token"]:
    m = re.search(pat, data)
    print(pat, "->", bool(m), m.start() if m else None)
PY

Repository: TestSprite/testsprite-cli

Length of output: 6451


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import urllib.request, re
url = "https://raw.githubusercontent.com/actions/github-script/v8.0.0/dist/index.js"
data = urllib.request.urlopen(url, timeout=20).read().decode()

# Print short surrounding context around getOctokit occurrences.
for m in re.finditer(r'getOctokit', data):
    s = max(0, m.start() - 220)
    e = min(len(data), m.end() + 220)
    print("..."+data[s:e]+"...\n")
    break
PY

Repository: TestSprite/testsprite-cli

Length of output: 2757


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
for f in [Path('.github/workflows/ci-nudge.yml'), Path('.github/workflows/pr-triage.yml')]:
    print(f"\n--- {f} ---")
    lines = f.read_text().splitlines()
    for i, line in enumerate(lines, 1):
        if 'actions/github-script@v8' in line or 'APP_TOKEN' in line or 'getOctokit' in line:
            start = max(1, i-3)
            end = min(len(lines), i+3)
            for j in range(start, end+1):
                print(f"{j:4d}: {lines[j-1]}")
            print()
PY

python3 - <<'PY'
import urllib.request, re
url = "https://raw.githubusercontent.com/actions/github-script/v8.0.0/dist/index.js"
data = urllib.request.urlopen(url, timeout=20).read().decode()
for needle in ["getOctokit", "github-token", "script", "context"]:
    print(f"{needle}: {'found' if needle in data else 'missing'}")
m = re.search(r'getOctokit', data)
if m:
    s = max(0, m.start()-200)
    e = min(len(data), m.end()+300)
    print("\ncontext:\n")
    print(data[s:e])
PY

Repository: TestSprite/testsprite-cli

Length of output: 5416


🌐 Web query:

actions/github-script v8 getOctokit script parameters documentation

💡 Result:

In actions/github-script v8, the getOctokit function is not available as an injected factory function [1][2]. The getOctokit functionality was introduced in v9 [3][4]. In v8, users typically access the GitHub API via the pre-authenticated github client provided in the script context [5]. If you need a client with a different token in v8, you must manually import the Octokit library or the @actions/github package, though users should be aware that the v9 upgrade (which introduced the built-in getOctokit factory) specifically addressed the limitations and breaking changes associated with manual imports in newer versions of the action [3][6][4]. If you require the getOctokit factory function, you should upgrade to actions/github-script v9 or later [3][4]. In v9, getOctokit is available directly in the script context and accepts the following parameters [7][8]: 1. token (string, required): A GitHub token (e.g., PAT, GitHub App token). 2. opts (object, optional): Octokit constructor options (e.g., userAgent, baseUrl, request, retry). Note that in v9, getOctokit is injected as a function parameter, so attempting to declare it with const or let will result in a SyntaxError [8][4].

Citations:


getOctokit is unavailable in actions/github-script@v8

Both workflows will hit ReferenceError when APP_TOKEN is set:

  • .github/workflows/ci-nudge.yml
  • .github/workflows/pr-triage.yml

Restore require('@actions/github').getOctokit(...), or upgrade actions/github-script to v9+.

📍 Affects 2 files
  • .github/workflows/ci-nudge.yml#L53-L55 (this comment)
  • .github/workflows/pr-triage.yml#L78-L80
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/ci-nudge.yml around lines 53 - 55, Both workflows call
unavailable getOctokit directly under actions/github-script@v8. In
.github/workflows/ci-nudge.yml lines 53-55 and .github/workflows/pr-triage.yml
lines 78-80, restore access through require('`@actions/github`').getOctokit(...)
or upgrade actions/github-script to v9+; apply the same fix at both sites.

async function write(fn) {
if (appClient) {
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/issue-triage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ jobs:
# of inheriting the App's full installation permissions.
permission-issues: write

- uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.0.1
- uses: actions/github-script@v8
with:
# App token when available (→ testsprite-hob[bot]); else the default
# GITHUB_TOKEN (→ github-actions[bot]). Either way the logic is identical.
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/pr-triage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ jobs:
app-id: ${{ secrets.TESTSPRITE_HOB_APP_ID || secrets.ALFHEIM_AGENT_APP_ID }}
private-key: ${{ secrets.TESTSPRITE_HOB_PRIVATE_KEY || secrets.ALFHEIM_AGENT_PRIVATE_KEY }}

- uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.0.1
- uses: actions/github-script@v8
env:
APP_TOKEN: ${{ steps.app-token.outputs.token }}
with:
Expand All @@ -76,7 +76,7 @@ jobs:
const author = pr.user.login;

const appClient = process.env.APP_TOKEN
? require('@actions/github').getOctokit(process.env.APP_TOKEN)
? getOctokit(process.env.APP_TOKEN)
: null;
// Prefer the App identity; fall back to github-actions[bot] when the
// App is absent or lacks the Pull-requests permission (403/404).
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

34 changes: 33 additions & 1 deletion src/commands/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,11 @@ function healthyDeps(credentialsPath: string, extra: Partial<DoctorDeps> = {}):
credentialsPath,
cwd: '/project',
nodeVersion: '22.9.0',
existsSync: () => true, // skill landing file present
existsSync: () => true, // skill landing file present, and git/gitignore present
readFileSync: (p: string) => {
if (p.endsWith('.gitignore')) return '.testsprite/';
return '';
},
fetchImpl: makeFetch(OK_ME),
...extra,
};
Expand Down Expand Up @@ -195,6 +199,30 @@ describe('runDoctor — warnings do not fail', () => {
expect(out).toContain('Verify skill');
});

it('missing Git repository is a warning, not a failure', async () => {
writeProfile('default', { apiKey: 'sk-abc' }, { path: credentialsPath });
const { capture, deps } = makeCapture();
const report = await runDoctor(
{ profile: 'default', output: 'text', debug: false },
{ ...healthyDeps(credentialsPath, { existsSync: (p) => !p.endsWith('.git') }), ...deps },
);
expect(report.failures).toBe(0);
const out = capture.stdout.join('\n');
expect(out).toContain('[WARN] Git repository');
});

it('missing .gitignore or .testsprite/ not ignored is a warning, not a failure', async () => {
writeProfile('default', { apiKey: 'sk-abc' }, { path: credentialsPath });
const { capture, deps } = makeCapture();
const report = await runDoctor(
{ profile: 'default', output: 'text', debug: false },
{ ...healthyDeps(credentialsPath, { readFileSync: () => 'node_modules/' }), ...deps },
);
expect(report.failures).toBe(0);
const out = capture.stdout.join('\n');
expect(out).toContain('[WARN] Gitignore safety');
});

it('--dry-run skips connectivity and never calls fetch, missing key is a warning', async () => {
const fetchImpl = vi.fn(async () => {
throw new Error('fetch must not be called under --dry-run');
Expand All @@ -208,6 +236,10 @@ describe('runDoctor — warnings do not fail', () => {
cwd: '/project',
nodeVersion: '22.9.0',
existsSync: () => true,
readFileSync: (p: string) => {
if (p.endsWith('.gitignore')) return '.testsprite/';
return '';
},
fetchImpl,
...deps,
},
Expand Down
30 changes: 30 additions & 0 deletions src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { GLOBAL_OPTS_HINT, Output, type OutputMode } from '../lib/output.js';
import { isVerifySkillInstalled } from '../lib/skill-nudge.js';
import { VERSION } from '../version.js';
import { MIN_SUPPORTED_NODE_MAJOR, shouldRejectNodeVersion } from '../version-guard.js';
import { isInsideGitRepo, checkTestspriteIgnored } from '../lib/git-utils.js';

export type DoctorStatus = 'ok' | 'warn' | 'fail';

Expand Down Expand Up @@ -85,6 +86,7 @@ export async function runDoctor(opts: CommonOptions, deps: DoctorDeps = {}): Pro
const checks: DoctorCheck[] = [
{ name: 'CLI version', status: 'ok', detail: VERSION },
checkNodeVersion(nodeVersion),
checkGitRepo(cwd, deps),
{ name: 'Profile', status: 'ok', detail: config.profile },
endpointCheck,
checkCredentials(hasKey, config.profile, opts.dryRun ?? false),
Expand All @@ -93,6 +95,7 @@ export async function runDoctor(opts: CommonOptions, deps: DoctorDeps = {}): Pro
endpointOk: endpointCheck.status === 'ok',
}),
checkSkill(cwd, deps),
await checkGitignoreSafety(cwd, deps),
];

const failures = checks.filter(check => check.status === 'fail').length;
Expand Down Expand Up @@ -171,6 +174,33 @@ function checkSkill(cwd: string, deps: DoctorDeps): DoctorCheck {
};
}

function checkGitRepo(cwd: string, deps: DoctorDeps): DoctorCheck {
const isGit = isInsideGitRepo(cwd, {
existsSync: deps.existsSync,
});
return {
name: 'Git repository',
status: isGit ? 'ok' : 'warn',
detail: isGit
? 'initialized repository'
: 'not a Git repository; agent skills require Git tracking',
};
}

async function checkGitignoreSafety(cwd: string, deps: DoctorDeps): Promise<DoctorCheck> {
const isIgnored = await checkTestspriteIgnored(cwd, {
existsSync: deps.existsSync,
readFile: deps.readFileSync ? async (p) => deps.readFileSync!(p) : undefined,
});
return {
name: 'Gitignore safety',
status: isIgnored ? 'ok' : 'warn',
detail: isIgnored
? '.testsprite/ is ignored'
: '.testsprite/ is not ignored; run setup or add to .gitignore to avoid committing artifacts',
};
}

async function checkConnectivity(
opts: CommonOptions,
deps: DoctorDeps,
Expand Down
34 changes: 34 additions & 0 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { runInstall } from './agent.js';
import { TARGETS, DEFAULT_SKILLS, type AgentTarget } from '../lib/agent-targets.js';
import type { FetchImpl } from '../lib/http.js';
import { readProfile } from '../lib/credentials.js';
import { ensureTestspriteIgnored, checkTestspriteIgnored } from '../lib/git-utils.js';

/** Mirrors auth.ts's DEFAULT_API_URL (kept in sync; auth.ts owns the canonical value). */
const DEFAULT_API_URL = 'https://api.testsprite.com';
Expand Down Expand Up @@ -243,6 +244,18 @@ export async function runInit(opts: InitOptions, deps: InitDeps = {}): Promise<v
);
}

if (!opts.noAgent) {
const projectDir = opts.dir ?? deps.cwd ?? process.cwd();
const gitignoreDeps = {
exists: deps.fs ? async (p: string) => (await deps.fs!.lstat(p)) !== null : undefined,
readFile: deps.fs ? (p: string) => deps.fs!.readFile(p) : undefined,
};
const isIgnored = await checkTestspriteIgnored(projectDir, gitignoreDeps);
if (!isIgnored) {
stderrFn('[dry-run] would ignore .testsprite/ in .gitignore');
}
}

const summary: InitSummary = {
profile: opts.profile,
apiUrl: resolveReportedEndpoint(opts, deps),
Expand Down Expand Up @@ -354,6 +367,27 @@ export async function runInit(opts: InitOptions, deps: InitDeps = {}): Promise<v
}
}

// -------------------------------------------------------------------------
// Step 3.5: Ensure .testsprite/ is ignored in .gitignore
// -------------------------------------------------------------------------
if (!opts.noAgent) {
const projectDir = opts.dir ?? deps.cwd ?? process.cwd();
const gitignoreDeps = {
exists: deps.fs ? async (p: string) => (await deps.fs!.lstat(p)) !== null : undefined,
readFile: deps.fs ? (p: string) => deps.fs!.readFile(p) : undefined,
writeFile: deps.fs ? (p: string, content: string) => deps.fs!.writeFile(p, content) : undefined,
};
try {
const appended = await ensureTestspriteIgnored(projectDir, gitignoreDeps);
if (appended) {
stderrFn('[info] Added .testsprite/ to .gitignore to avoid committing artifacts');
}
} catch (err) {
// Non-blocking warning on gitignore setup error
stderrFn(`[warn] Failed to update .gitignore: ${err instanceof Error ? err.message : String(err)}`);
}
}

// -------------------------------------------------------------------------
// Step 4: Summary
// -------------------------------------------------------------------------
Expand Down
137 changes: 137 additions & 0 deletions src/lib/git-utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { describe, expect, it } from 'vitest';
import { join } from 'node:path';
import {
isInsideGitRepo,
isTestspriteIgnored,
checkTestspriteIgnored,
ensureTestspriteIgnored,
} from './git-utils.js';

describe('git-utils', () => {
describe('isInsideGitRepo', () => {
it('returns true if .git exists in the current directory', () => {
const mockExists = (p: string) => p === join('/foo/bar', '.git');
expect(isInsideGitRepo('/foo/bar', { existsSync: mockExists })).toBe(true);
});

it('returns true if .git exists in a parent directory', () => {
const mockExists = (p: string) => p === join('/foo', '.git');
expect(isInsideGitRepo('/foo/bar/baz', { existsSync: mockExists })).toBe(true);
});

it('returns false if .git is not found anywhere up the tree', () => {
const mockExists = () => false;
expect(isInsideGitRepo('/foo/bar/baz', { existsSync: mockExists })).toBe(false);
});
});

describe('isTestspriteIgnored', () => {
it('returns false for empty content', () => {
expect(isTestspriteIgnored('')).toBe(false);
});

it('returns true if .testsprite is explicitly ignored', () => {
expect(isTestspriteIgnored('.testsprite')).toBe(true);
expect(isTestspriteIgnored('.testsprite/')).toBe(true);
expect(isTestspriteIgnored('**/.testsprite')).toBe(true);
expect(isTestspriteIgnored('**/.testsprite/')).toBe(true);
});

it('returns true if .testsprite subfolders or files are ignored', () => {
expect(isTestspriteIgnored('.testsprite/*')).toBe(true);
expect(isTestspriteIgnored('**/.testsprite/runs')).toBe(true);
});

it('ignores comments and empty lines', () => {
expect(isTestspriteIgnored('# .testsprite/')).toBe(false);
expect(isTestspriteIgnored(' # comment\n.testsprite/')).toBe(true);
});

it('returns false if unrelated folders are ignored', () => {
expect(isTestspriteIgnored('node_modules/\ndist/')).toBe(false);
});

it('correctly handles negation patterns (un-ignoring)', () => {
expect(isTestspriteIgnored('.testsprite/\n!.testsprite/')).toBe(false);
expect(isTestspriteIgnored('!.testsprite/\n.testsprite/')).toBe(true);
expect(isTestspriteIgnored('**/.testsprite/\n!**/.testsprite/')).toBe(false);
expect(isTestspriteIgnored('.testsprite/*\n!.testsprite/')).toBe(false);
});
});

describe('checkTestspriteIgnored', () => {
it('returns false if .gitignore does not exist', async () => {
const mockExists = () => false;
const mockRead = async () => '';
await expect(checkTestspriteIgnored('/foo', { existsSync: mockExists, readFile: mockRead })).resolves.toBe(false);
});

it('returns true if .gitignore exists and ignores .testsprite', async () => {
const mockExists = (p: string) => p === join('/foo', '.gitignore');
const mockRead = async () => 'dist/\n.testsprite/';
await expect(checkTestspriteIgnored('/foo', { existsSync: mockExists, readFile: mockRead })).resolves.toBe(true);
});

it('returns false if .gitignore exists but does not ignore .testsprite', async () => {
const mockExists = (p: string) => p === join('/foo', '.gitignore');
const mockRead = async () => 'dist/';
await expect(checkTestspriteIgnored('/foo', { existsSync: mockExists, readFile: mockRead })).resolves.toBe(false);
});
});

describe('ensureTestspriteIgnored', () => {
it('creates .gitignore and appends .testsprite if file does not exist', async () => {
let writtenContent = '';
const mockExists = () => false;
const mockRead = async () => '';
const mockWrite = async (p: string, content: string) => {
writtenContent = content;
};

const result = await ensureTestspriteIgnored('/foo', {
existsSync: mockExists,
readFile: mockRead,
writeFile: mockWrite,
});

expect(result).toBe(true);
expect(writtenContent).toContain('.testsprite/');
});

it('appends .testsprite if .gitignore exists but does not ignore it', async () => {
let writtenContent = '';
const mockExists = () => true;
const mockRead = async () => 'node_modules/\ndist/';
const mockWrite = async (p: string, content: string) => {
writtenContent = content;
};

const result = await ensureTestspriteIgnored('/foo', {
existsSync: mockExists,
readFile: mockRead,
writeFile: mockWrite,
});

expect(result).toBe(true);
expect(writtenContent).toBe('node_modules/\ndist/\n\n# TestSprite failure artifacts\n.testsprite/\n');
});

it('does not modify .gitignore if .testsprite is already ignored', async () => {
let calledWrite = false;
const mockExists = () => true;
const mockRead = async () => 'node_modules/\n.testsprite/';
const mockWrite = async () => {
calledWrite = true;
};

const result = await ensureTestspriteIgnored('/foo', {
existsSync: mockExists,
readFile: mockRead,
writeFile: mockWrite,
});

expect(result).toBe(false);
expect(calledWrite).toBe(false);
});
});
});
Loading
Loading