feat(coding-agent): add runner tags for issue analysis
This commit is contained in:
@@ -1,5 +1,13 @@
|
|||||||
# Runs the repo's /is prompt against an issue when the `pi-analyze` label is added
|
# Runs the repo's /is prompt against an issue when the `pi-analyze` label is added
|
||||||
# or when a staff member comments `@issuron analyze` on an issue.
|
# or when a staff member comments `@issuron analyze` anywhere on an issue.
|
||||||
|
#
|
||||||
|
# Comment triggers can include one `#run-on-*` tag anywhere in the text:
|
||||||
|
# @issuron analyze #run-on-linux -> ubuntu-latest (default)
|
||||||
|
# @issuron analyze #run-on-windows -> windows-latest
|
||||||
|
# @issuron analyze #run-on-mac -> macos-latest
|
||||||
|
#
|
||||||
|
# Label triggers always run on the default Linux runner. Runner selection is
|
||||||
|
# intentionally restricted to hardcoded aliases in the authorization step.
|
||||||
#
|
#
|
||||||
# Setup required before this works:
|
# Setup required before this works:
|
||||||
# 1. Create a `pi-analyze` GitHub environment on the repo and add a
|
# 1. Create a `pi-analyze` GitHub environment on the repo and add a
|
||||||
@@ -13,6 +21,10 @@
|
|||||||
# creation permission. The analysis job uses it to upload the exported
|
# creation permission. The analysis job uses it to upload the exported
|
||||||
# session gist.
|
# session gist.
|
||||||
#
|
#
|
||||||
|
# The selected runner must have Node.js support plus fd and ripgrep. GitHub
|
||||||
|
# hosted runners are bootstrapped below; future self-hosted aliases should have
|
||||||
|
# those dependencies preinstalled or installable by the setup steps.
|
||||||
|
#
|
||||||
# The session runs in a high-entropy checkout directory so the recorded cwd is
|
# The session runs in a high-entropy checkout directory so the recorded cwd is
|
||||||
# a unique string. Import the session into a local checkout with the
|
# a unique string. Import the session into a local checkout with the
|
||||||
# /ir extension command (.pi/extensions/import-repro.ts):
|
# /ir extension command (.pi/extensions/import-repro.ts):
|
||||||
@@ -40,6 +52,9 @@ jobs:
|
|||||||
outputs:
|
outputs:
|
||||||
should_run: ${{ steps.verify.outputs.should_run }}
|
should_run: ${{ steps.verify.outputs.should_run }}
|
||||||
extra_instructions: ${{ steps.verify.outputs.extra_instructions }}
|
extra_instructions: ${{ steps.verify.outputs.extra_instructions }}
|
||||||
|
runs_on: ${{ steps.verify.outputs.runs_on }}
|
||||||
|
runner_os: ${{ steps.verify.outputs.runner_os }}
|
||||||
|
runner_profile: ${{ steps.verify.outputs.runner_profile }}
|
||||||
steps:
|
steps:
|
||||||
- name: Verify sender permission
|
- name: Verify sender permission
|
||||||
id: verify
|
id: verify
|
||||||
@@ -49,11 +64,35 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
const ANALYZE_LABEL = 'pi-analyze';
|
const ANALYZE_LABEL = 'pi-analyze';
|
||||||
|
const TRIGGER_RE = /@issuron\s+analyze\b/i;
|
||||||
|
const RUN_ON_TAG_RE = /#run-on-([a-z0-9][a-z0-9_-]*)\b/gi;
|
||||||
|
const RUNNER_PROFILES = {
|
||||||
|
linux: { runsOn: 'ubuntu-latest', os: 'linux' },
|
||||||
|
windows: { runsOn: 'windows-latest', os: 'windows' },
|
||||||
|
mac: { runsOn: 'macos-latest', os: 'macos' },
|
||||||
|
};
|
||||||
|
const RUN_ON_ALIASES = {
|
||||||
|
linux: 'linux',
|
||||||
|
ubuntu: 'linux',
|
||||||
|
'ubuntu-latest': 'linux',
|
||||||
|
windows: 'windows',
|
||||||
|
win: 'windows',
|
||||||
|
'windows-latest': 'windows',
|
||||||
|
mac: 'mac',
|
||||||
|
macos: 'mac',
|
||||||
|
darwin: 'mac',
|
||||||
|
'macos-latest': 'mac',
|
||||||
|
};
|
||||||
|
|
||||||
const username = context.payload.sender.login;
|
const username = context.payload.sender.login;
|
||||||
let extraInstructions = '';
|
let extraInstructions = '';
|
||||||
|
let runnerProfile = 'linux';
|
||||||
|
|
||||||
core.setOutput('should_run', 'false');
|
core.setOutput('should_run', 'false');
|
||||||
core.setOutput('extra_instructions', '');
|
core.setOutput('extra_instructions', '');
|
||||||
|
core.setOutput('runs_on', JSON.stringify(RUNNER_PROFILES.linux.runsOn));
|
||||||
|
core.setOutput('runner_os', RUNNER_PROFILES.linux.os);
|
||||||
|
core.setOutput('runner_profile', runnerProfile);
|
||||||
|
|
||||||
if (context.eventName === 'issues') {
|
if (context.eventName === 'issues') {
|
||||||
if (context.payload.action !== 'labeled' || context.payload.label?.name !== ANALYZE_LABEL) {
|
if (context.payload.action !== 'labeled' || context.payload.label?.name !== ANALYZE_LABEL) {
|
||||||
@@ -66,12 +105,38 @@ jobs:
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const body = context.payload.comment.body || '';
|
const body = context.payload.comment.body || '';
|
||||||
const match = body.match(/^\s*@issuron\s+analyze\b([\s\S]*)$/i);
|
if (!TRIGGER_RE.test(body)) {
|
||||||
if (!match) {
|
console.log('Comment does not contain an @issuron analyze trigger');
|
||||||
console.log('Comment is not an @issuron analyze trigger');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
extraInstructions = match[1].trim();
|
|
||||||
|
const resolvedProfiles = new Set();
|
||||||
|
const unknownTags = [];
|
||||||
|
for (const match of body.matchAll(RUN_ON_TAG_RE)) {
|
||||||
|
const tag = match[1].toLowerCase();
|
||||||
|
const resolved = RUN_ON_ALIASES[tag];
|
||||||
|
if (!resolved) {
|
||||||
|
unknownTags.push(tag);
|
||||||
|
} else {
|
||||||
|
resolvedProfiles.add(resolved);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unknownTags.length > 0) {
|
||||||
|
core.setFailed(`Unknown issue analysis runner tag(s): ${unknownTags.map((tag) => `#run-on-${tag}`).join(', ')}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (resolvedProfiles.size > 1) {
|
||||||
|
core.setFailed(
|
||||||
|
`Conflicting issue analysis runner tags: ${Array.from(resolvedProfiles)
|
||||||
|
.map((profile) => `#run-on-${profile}`)
|
||||||
|
.join(', ')}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
runnerProfile = Array.from(resolvedProfiles)[0] || 'linux';
|
||||||
|
extraInstructions = body.replace(TRIGGER_RE, ' ').replace(RUN_ON_TAG_RE, ' ').trim();
|
||||||
} else {
|
} else {
|
||||||
console.log(`Unsupported event: ${context.eventName}`);
|
console.log(`Unsupported event: ${context.eventName}`);
|
||||||
return;
|
return;
|
||||||
@@ -163,13 +228,18 @@ jobs:
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const profile = RUNNER_PROFILES[runnerProfile];
|
||||||
|
console.log(`Selected issue analysis runner profile: ${runnerProfile} (${JSON.stringify(profile.runsOn)})`);
|
||||||
core.setOutput('should_run', 'true');
|
core.setOutput('should_run', 'true');
|
||||||
core.setOutput('extra_instructions', extraInstructions);
|
core.setOutput('extra_instructions', extraInstructions);
|
||||||
|
core.setOutput('runs_on', JSON.stringify(profile.runsOn));
|
||||||
|
core.setOutput('runner_os', profile.os);
|
||||||
|
core.setOutput('runner_profile', runnerProfile);
|
||||||
|
|
||||||
analyze:
|
analyze:
|
||||||
needs: authorize
|
needs: authorize
|
||||||
if: needs.authorize.outputs.should_run == 'true'
|
if: needs.authorize.outputs.should_run == 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ${{ fromJSON(needs.authorize.outputs.runs_on) }}
|
||||||
environment: pi-analyze
|
environment: pi-analyze
|
||||||
timeout-minutes: 45
|
timeout-minutes: 45
|
||||||
env:
|
env:
|
||||||
@@ -178,7 +248,11 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Create high-entropy working directory name
|
- name: Create high-entropy working directory name
|
||||||
id: workdir
|
id: workdir
|
||||||
run: echo "name=pi-ci-$(openssl rand -hex 16)" >> "$GITHUB_OUTPUT"
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const crypto = require('crypto');
|
||||||
|
core.setOutput('name', `pi-ci-${crypto.randomBytes(16).toString('hex')}`);
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -192,83 +266,218 @@ jobs:
|
|||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: ${{ steps.workdir.outputs.name }}/package-lock.json
|
cache-dependency-path: ${{ steps.workdir.outputs.name }}/package-lock.json
|
||||||
|
|
||||||
- name: Install system dependencies
|
- name: Install system dependencies (Linux)
|
||||||
|
if: needs.authorize.outputs.runner_os == 'linux'
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y fd-find ripgrep
|
sudo apt-get install -y fd-find ripgrep
|
||||||
sudo ln -s "$(which fdfind)" /usr/local/bin/fd
|
sudo ln -sf "$(which fdfind)" /usr/local/bin/fd
|
||||||
|
|
||||||
|
- name: Install system dependencies (macOS)
|
||||||
|
if: needs.authorize.outputs.runner_os == 'macos'
|
||||||
|
run: |
|
||||||
|
if ! command -v fd >/dev/null 2>&1; then
|
||||||
|
brew install fd
|
||||||
|
fi
|
||||||
|
if ! command -v rg >/dev/null 2>&1; then
|
||||||
|
brew install ripgrep
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Install system dependencies (Windows)
|
||||||
|
if: needs.authorize.outputs.runner_os == 'windows'
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
$packages = @()
|
||||||
|
if (-not (Get-Command fd -ErrorAction SilentlyContinue)) {
|
||||||
|
$packages += "fd"
|
||||||
|
}
|
||||||
|
if (-not (Get-Command rg -ErrorAction SilentlyContinue)) {
|
||||||
|
$packages += "ripgrep"
|
||||||
|
}
|
||||||
|
if ($packages.Count -gt 0) {
|
||||||
|
if (-not (Get-Command choco -ErrorAction SilentlyContinue)) {
|
||||||
|
throw "fd and ripgrep must be installed on Windows runners, or Chocolatey must be available to install them."
|
||||||
|
}
|
||||||
|
choco install $packages -y --no-progress
|
||||||
|
}
|
||||||
|
fd --version
|
||||||
|
rg --version
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
working-directory: ${{ steps.workdir.outputs.name }}
|
working-directory: ${{ steps.workdir.outputs.name }}
|
||||||
run: npm ci --ignore-scripts
|
run: npm ci --ignore-scripts
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
working-directory: ${{ steps.workdir.outputs.name }}
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
- name: Write auth.json
|
- name: Write auth.json
|
||||||
|
uses: actions/github-script@v7
|
||||||
env:
|
env:
|
||||||
PI_AUTH_JSON: ${{ secrets.PI_AUTH_JSON }}
|
PI_AUTH_JSON: ${{ secrets.PI_AUTH_JSON }}
|
||||||
run: |
|
with:
|
||||||
if [ -z "$PI_AUTH_JSON" ]; then
|
script: |
|
||||||
echo "PI_AUTH_JSON secret is not configured for the pi-analyze environment" >&2
|
const fs = require('fs');
|
||||||
exit 1
|
const path = require('path');
|
||||||
fi
|
|
||||||
mkdir -p "$RUNNER_TEMP/pi-agent"
|
const authJson = process.env.PI_AUTH_JSON;
|
||||||
printf '%s' "$PI_AUTH_JSON" > "$RUNNER_TEMP/pi-agent/auth.json"
|
if (!authJson) {
|
||||||
chmod 600 "$RUNNER_TEMP/pi-agent/auth.json"
|
throw new Error('PI_AUTH_JSON secret is not configured for the pi-analyze environment');
|
||||||
|
}
|
||||||
|
|
||||||
|
const agentDir = path.join(process.env.RUNNER_TEMP, 'pi-agent');
|
||||||
|
fs.mkdirSync(agentDir, { recursive: true });
|
||||||
|
const authPath = path.join(agentDir, 'auth.json');
|
||||||
|
fs.writeFileSync(authPath, authJson, { mode: 0o600 });
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
fs.chmodSync(authPath, 0o600);
|
||||||
|
}
|
||||||
|
|
||||||
- name: Run pi /is
|
- name: Run pi /is
|
||||||
shell: bash
|
uses: actions/github-script@v7
|
||||||
working-directory: ${{ steps.workdir.outputs.name }}
|
|
||||||
env:
|
env:
|
||||||
PI_CODING_AGENT_DIR: ${{ runner.temp }}/pi-agent
|
PI_CODING_AGENT_DIR: ${{ runner.temp }}/pi-agent
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
ISSUE_URL: ${{ github.event.issue.html_url }}
|
ISSUE_URL: ${{ github.event.issue.html_url }}
|
||||||
EXTRA_INSTRUCTIONS: ${{ needs.authorize.outputs.extra_instructions }}
|
EXTRA_INSTRUCTIONS: ${{ needs.authorize.outputs.extra_instructions }}
|
||||||
run: |
|
WORKDIR: ${{ steps.workdir.outputs.name }}
|
||||||
mkdir -p "$RUNNER_TEMP/pi-out/session"
|
with:
|
||||||
prompt="/is $ISSUE_URL"
|
script: |
|
||||||
if [ -n "$EXTRA_INSTRUCTIONS" ]; then
|
const fs = require('fs');
|
||||||
prompt+=$'\n\nAdditional instructions from @issuron analyze comment:\n'
|
const path = require('path');
|
||||||
prompt+="$EXTRA_INSTRUCTIONS"
|
const { spawn } = require('child_process');
|
||||||
fi
|
|
||||||
./pi-test.sh \
|
const workdir = path.join(process.env.GITHUB_WORKSPACE, process.env.WORKDIR);
|
||||||
-p \
|
const outDir = path.join(process.env.RUNNER_TEMP, 'pi-out');
|
||||||
--approve \
|
const sessionDir = path.join(outDir, 'session');
|
||||||
--session-dir "$RUNNER_TEMP/pi-out/session" \
|
fs.mkdirSync(sessionDir, { recursive: true });
|
||||||
--model "$ISSUE_ANALYSIS_MODEL" \
|
|
||||||
--thinking "$ISSUE_ANALYSIS_THINKING" \
|
let prompt = `/is ${process.env.ISSUE_URL}`;
|
||||||
"$prompt" | tee "$RUNNER_TEMP/pi-out/output.md"
|
if (process.env.EXTRA_INSTRUCTIONS) {
|
||||||
|
prompt += '\n\nAdditional instructions from @issuron analyze comment:\n';
|
||||||
|
prompt += process.env.EXTRA_INSTRUCTIONS;
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputPath = path.join(outDir, 'output.md');
|
||||||
|
const output = fs.createWriteStream(outputPath);
|
||||||
|
const args = [
|
||||||
|
'packages/coding-agent/src/cli.ts',
|
||||||
|
'-p',
|
||||||
|
'--approve',
|
||||||
|
'--session-dir',
|
||||||
|
sessionDir,
|
||||||
|
'--model',
|
||||||
|
process.env.ISSUE_ANALYSIS_MODEL,
|
||||||
|
'--thinking',
|
||||||
|
process.env.ISSUE_ANALYSIS_THINKING,
|
||||||
|
prompt,
|
||||||
|
];
|
||||||
|
|
||||||
|
const exitCode = await new Promise((resolve, reject) => {
|
||||||
|
const child = spawn('node', args, {
|
||||||
|
cwd: workdir,
|
||||||
|
env: process.env,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
child.stdout.on('data', (chunk) => {
|
||||||
|
process.stdout.write(chunk);
|
||||||
|
output.write(chunk);
|
||||||
|
});
|
||||||
|
child.stderr.on('data', (chunk) => {
|
||||||
|
process.stderr.write(chunk);
|
||||||
|
});
|
||||||
|
child.on('error', reject);
|
||||||
|
child.on('close', resolve);
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => output.end(resolve));
|
||||||
|
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
throw new Error(`pi /is failed with exit code ${exitCode}`);
|
||||||
|
}
|
||||||
|
|
||||||
- name: Export session files
|
- name: Export session files
|
||||||
id: export_session_files
|
id: export_session_files
|
||||||
if: always()
|
if: always()
|
||||||
shell: bash
|
uses: actions/github-script@v7
|
||||||
working-directory: ${{ steps.workdir.outputs.name }}
|
|
||||||
env:
|
env:
|
||||||
PI_CODING_AGENT_DIR: ${{ runner.temp }}/pi-agent
|
PI_CODING_AGENT_DIR: ${{ runner.temp }}/pi-agent
|
||||||
run: |
|
WORKDIR: ${{ steps.workdir.outputs.name }}
|
||||||
session_file="$(find "$RUNNER_TEMP/pi-out/session" -type f -name '*.jsonl' | head -n 1)"
|
with:
|
||||||
if [ -z "$session_file" ]; then
|
script: |
|
||||||
echo "No session jsonl file found" >&2
|
const fs = require('fs');
|
||||||
exit 1
|
const path = require('path');
|
||||||
fi
|
const { spawn } = require('child_process');
|
||||||
cp "$session_file" "$RUNNER_TEMP/pi-out/session.jsonl"
|
|
||||||
./pi-test.sh --no-extensions --export "$RUNNER_TEMP/pi-out/session.jsonl" "$RUNNER_TEMP/pi-out/session.html"
|
function findFirstJsonl(dir) {
|
||||||
|
if (!fs.existsSync(dir)) return undefined;
|
||||||
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
const entryPath = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
const nested = findFirstJsonl(entryPath);
|
||||||
|
if (nested) return nested;
|
||||||
|
} else if (entry.isFile() && entry.name.endsWith('.jsonl')) {
|
||||||
|
return entryPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const outDir = path.join(process.env.RUNNER_TEMP, 'pi-out');
|
||||||
|
const sessionFile = findFirstJsonl(path.join(outDir, 'session'));
|
||||||
|
if (!sessionFile) {
|
||||||
|
throw new Error('No session jsonl file found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionJsonl = path.join(outDir, 'session.jsonl');
|
||||||
|
const sessionHtml = path.join(outDir, 'session.html');
|
||||||
|
fs.copyFileSync(sessionFile, sessionJsonl);
|
||||||
|
|
||||||
|
const workdir = path.join(process.env.GITHUB_WORKSPACE, process.env.WORKDIR);
|
||||||
|
const exitCode = await new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(
|
||||||
|
'node',
|
||||||
|
['packages/coding-agent/src/cli.ts', '--no-extensions', '--export', sessionJsonl, sessionHtml],
|
||||||
|
{ cwd: workdir, env: process.env, stdio: 'inherit' },
|
||||||
|
);
|
||||||
|
child.on('error', reject);
|
||||||
|
child.on('close', resolve);
|
||||||
|
});
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
throw new Error(`session export failed with exit code ${exitCode}`);
|
||||||
|
}
|
||||||
|
|
||||||
- name: Upload session gist
|
- name: Upload session gist
|
||||||
id: gist
|
id: gist
|
||||||
if: always() && steps.export_session_files.outcome == 'success'
|
if: always() && steps.export_session_files.outcome == 'success'
|
||||||
shell: bash
|
uses: actions/github-script@v7
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.PI_GIST_TOKEN }}
|
PI_GIST_TOKEN: ${{ secrets.PI_GIST_TOKEN }}
|
||||||
run: |
|
with:
|
||||||
if [ -z "$GH_TOKEN" ]; then
|
github-token: ${{ secrets.PI_GIST_TOKEN }}
|
||||||
echo "PI_GIST_TOKEN is not configured" >&2
|
script: |
|
||||||
exit 1
|
const fs = require('fs');
|
||||||
fi
|
const path = require('path');
|
||||||
gist_url="$(gh gist create --public=false "$RUNNER_TEMP/pi-out/session.html" "$RUNNER_TEMP/pi-out/session.jsonl")"
|
|
||||||
gist_id="${gist_url##*/}"
|
if (!process.env.PI_GIST_TOKEN) {
|
||||||
echo "url=$gist_url" >> "$GITHUB_OUTPUT"
|
throw new Error('PI_GIST_TOKEN is not configured');
|
||||||
echo "id=$gist_id" >> "$GITHUB_OUTPUT"
|
}
|
||||||
echo "share_url=https://pi.dev/session/#$gist_id" >> "$GITHUB_OUTPUT"
|
|
||||||
|
const outDir = path.join(process.env.RUNNER_TEMP, 'pi-out');
|
||||||
|
const files = {};
|
||||||
|
for (const filename of ['session.html', 'session.jsonl']) {
|
||||||
|
files[filename] = { content: fs.readFileSync(path.join(outDir, filename), 'utf8') };
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await github.rest.gists.create({
|
||||||
|
public: false,
|
||||||
|
files,
|
||||||
|
});
|
||||||
|
const gistUrl = response.data.html_url;
|
||||||
|
const gistId = response.data.id;
|
||||||
|
core.setOutput('url', gistUrl);
|
||||||
|
core.setOutput('id', gistId);
|
||||||
|
core.setOutput('share_url', `https://pi.dev/session/#${gistId}`);
|
||||||
|
|
||||||
- name: Comment with session import instructions
|
- name: Comment with session import instructions
|
||||||
if: always() && steps.gist.outcome == 'success'
|
if: always() && steps.gist.outcome == 'success'
|
||||||
|
|||||||
@@ -117,11 +117,106 @@ function decodeExportedHtml(html: string): { header: SessionHeader; jsonl: strin
|
|||||||
return { header, jsonl: `${lines.join("\n")}\n` };
|
return { header, jsonl: `${lines.join("\n")}\n` };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Rewrite all occurrences of the recorded cwd (JSON-escaped) to the target cwd. */
|
type SessionPlatform = "windows" | "unix" | "unknown";
|
||||||
|
|
||||||
|
function escapeJsonString(value: string): string {
|
||||||
|
return JSON.stringify(value).slice(1, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegExp(value: string): string {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimTrailingPathSeparators(value: string): string {
|
||||||
|
return value.replace(/[\\/]+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPathTailName(value: string): string {
|
||||||
|
const trimmed = trimTrailingPathSeparators(value);
|
||||||
|
return trimmed.split(/[\\/]/).filter(Boolean).at(-1) ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWindowsDrivePathParts(value: string): { drive: string; rest: string } | undefined {
|
||||||
|
const trimmed = trimTrailingPathSeparators(value);
|
||||||
|
const driveMatch = trimmed.match(/^([A-Za-z]):[\\/](.*)$/);
|
||||||
|
if (driveMatch) {
|
||||||
|
return { drive: driveMatch[1].toUpperCase(), rest: driveMatch[2].replace(/[\\/]+/g, "/") };
|
||||||
|
}
|
||||||
|
|
||||||
|
const msysMatch = trimmed.match(/^\/([A-Za-z])\/(.*)$/);
|
||||||
|
if (msysMatch) {
|
||||||
|
return { drive: msysMatch[1].toUpperCase(), rest: msysMatch[2].replace(/[\\/]+/g, "/") };
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCwdRewriteVariants(sourceCwd: string): string[] {
|
||||||
|
const trimmed = trimTrailingPathSeparators(sourceCwd);
|
||||||
|
const variants = new Set<string>();
|
||||||
|
if (trimmed) variants.add(trimmed);
|
||||||
|
|
||||||
|
const driveParts = getWindowsDrivePathParts(trimmed);
|
||||||
|
if (driveParts) {
|
||||||
|
const rest = driveParts.rest.replace(/^\/+|\/+$/g, "");
|
||||||
|
const backslashRest = rest.replace(/\//g, "\\");
|
||||||
|
variants.add(`${driveParts.drive}:\\${backslashRest}`);
|
||||||
|
variants.add(`${driveParts.drive}:/${rest}`);
|
||||||
|
variants.add(`/${driveParts.drive.toLowerCase()}/${rest}`);
|
||||||
|
variants.add(`/${driveParts.drive}/${rest}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(variants).filter(Boolean).sort((a, b) => b.length - a.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCiWorkdirName(sourceCwd: string): string | undefined {
|
||||||
|
const name = getPathTailName(sourceCwd);
|
||||||
|
return /^pi-ci-[0-9a-f]{32}$/i.test(name) ? name : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectSessionPlatform(cwd: string): SessionPlatform {
|
||||||
|
if (/^[A-Za-z]:[\\/]/.test(cwd) || /^\/[A-Za-z]\//.test(cwd)) return "windows";
|
||||||
|
if (cwd.startsWith("/")) return "unix";
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLocalPlatform(): Exclude<SessionPlatform, "unknown"> {
|
||||||
|
return process.platform === "win32" ? "windows" : "unix";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPlatformContinuationNotice(sourceCwd: string): string | undefined {
|
||||||
|
const sourcePlatform = detectSessionPlatform(sourceCwd);
|
||||||
|
const localPlatform = getLocalPlatform();
|
||||||
|
if (sourcePlatform === "unknown" || sourcePlatform === localPlatform) return undefined;
|
||||||
|
if (localPlatform === "unix") {
|
||||||
|
return "This session was continued on a non-Windows machine; paths are now Unix style.";
|
||||||
|
}
|
||||||
|
return "This session was continued on a Windows machine; paths are now Windows style.";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rewrite occurrences of the recorded CI cwd (JSON-escaped) to the target cwd. */
|
||||||
function rewriteSessionCwd(raw: string, sourceCwd: string, targetCwd: string): string {
|
function rewriteSessionCwd(raw: string, sourceCwd: string, targetCwd: string): string {
|
||||||
if (sourceCwd === targetCwd) return raw;
|
const target = escapeJsonString(targetCwd);
|
||||||
const escapeJson = (value: string) => JSON.stringify(value).slice(1, -1);
|
let rewritten = raw;
|
||||||
return raw.split(escapeJson(sourceCwd)).join(escapeJson(targetCwd));
|
|
||||||
|
for (const sourceVariant of getCwdRewriteVariants(sourceCwd)) {
|
||||||
|
if (sourceVariant === targetCwd) continue;
|
||||||
|
rewritten = rewritten.split(escapeJsonString(sourceVariant)).join(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ciWorkdirName = getCiWorkdirName(sourceCwd);
|
||||||
|
if (ciWorkdirName) {
|
||||||
|
const escapedName = escapeRegExp(ciWorkdirName);
|
||||||
|
const windowsPathPatterns = [
|
||||||
|
new RegExp(`[A-Za-z]:(?:[^"\\r\\n])*?${escapedName}`, "g"),
|
||||||
|
new RegExp(`/[A-Za-z]/(?:[^"\\r\\n])*?${escapedName}`, "g"),
|
||||||
|
];
|
||||||
|
for (const pattern of windowsPathPatterns) {
|
||||||
|
rewritten = rewritten.replace(pattern, target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rewritten;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchText(url: string): Promise<string> {
|
async function fetchText(url: string): Promise<string> {
|
||||||
@@ -218,6 +313,7 @@ export default function (pi: ExtensionAPI) {
|
|||||||
sourceName = basename(parsedRef.path).replace(/\.html$/, ".jsonl");
|
sourceName = basename(parsedRef.path).replace(/\.html$/, ".jsonl");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const platformNotice = getPlatformContinuationNotice(decoded.header.cwd);
|
||||||
const rewritten = rewriteSessionCwd(decoded.jsonl, decoded.header.cwd, targetCwd);
|
const rewritten = rewriteSessionCwd(decoded.jsonl, decoded.header.cwd, targetCwd);
|
||||||
const destination = join(sessionDir, sourceName);
|
const destination = join(sessionDir, sourceName);
|
||||||
if (existsSync(destination)) {
|
if (existsSync(destination)) {
|
||||||
@@ -233,7 +329,20 @@ export default function (pi: ExtensionAPI) {
|
|||||||
writeFileSync(destination, rewritten);
|
writeFileSync(destination, rewritten);
|
||||||
|
|
||||||
ctx.ui.notify(`Imported session ${decoded.header.id} (cwd ${decoded.header.cwd} -> ${targetCwd})`, "info");
|
ctx.ui.notify(`Imported session ${decoded.header.id} (cwd ${decoded.header.cwd} -> ${targetCwd})`, "info");
|
||||||
await ctx.switchSession(destination);
|
await ctx.switchSession(destination, {
|
||||||
|
withSession: async (nextCtx) => {
|
||||||
|
if (!platformNotice) return;
|
||||||
|
await nextCtx.sendMessage(
|
||||||
|
{
|
||||||
|
customType: "import-repro",
|
||||||
|
content: platformNotice,
|
||||||
|
display: true,
|
||||||
|
details: { sourceCwd: decoded.header.cwd, targetCwd },
|
||||||
|
},
|
||||||
|
{ triggerTurn: false },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ctx.ui.notify(`ir: ${error instanceof Error ? error.message : String(error)}`, "error");
|
ctx.ui.notify(`ir: ${error instanceof Error ? error.message : String(error)}`, "error");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user