From 4b118c3d59b23a3de8ca819522519d7b6e672fe6 Mon Sep 17 00:00:00 2001 From: "Mariia.Fadeeva" Date: Tue, 11 Aug 2026 11:47:00 +0300 Subject: [PATCH 1/6] Add agent artifact exclusion logic and goal mode orchestration support with comprehensive tests --- src/github/junie/junie-tasks.ts | 49 +++++- src/github/junie/types/junie.ts | 5 + src/utils/git-exclude.ts | 107 ++++++++++++++ test/junie-tasks.test.ts | 255 +++++++++++++++++++++++++++----- test/utils/git-exclude.test.ts | 96 ++++++++++++ 5 files changed, 474 insertions(+), 38 deletions(-) create mode 100644 src/utils/git-exclude.ts create mode 100644 test/utils/git-exclude.test.ts diff --git a/src/github/junie/junie-tasks.ts b/src/github/junie/junie-tasks.ts index 075810e1..53c8a7a3 100644 --- a/src/github/junie/junie-tasks.ts +++ b/src/github/junie/junie-tasks.ts @@ -1,10 +1,16 @@ import { isCodeReviewEvent, + isFixCIEvent, isIssueCommentEvent, isIssuesEvent, + isJiraWorkflowDispatchEvent, + isMinorFixEvent, isPullRequestEvent, isPullRequestReviewCommentEvent, isPullRequestReviewEvent, + isPushEvent, + isTriggeredByUserInteraction, + isYouTrackWorkflowDispatchEvent, JunieExecutionContext } from "../context"; import * as core from "@actions/core"; @@ -19,6 +25,38 @@ import {FetchedData} from "../api/queries"; import {CliInput} from "./types/junie"; import {generateMcpToolsPrompt} from "../../mcp/mcp-prompts"; import {junieArgsToString} from "../../utils/junie-args-parser"; +import {addGitExcludePatterns, AGENT_ARTIFACT_PATTERNS} from "../../utils/git-exclude"; + +export function shouldRunInGoalMode(context: JunieExecutionContext): boolean { + if (isMinorFixEvent(context)) { + return false; + } + + return ( + (isTriggeredByUserInteraction(context) && !isPushEvent(context)) || + isFixCIEvent(context) || + isJiraWorkflowDispatchEvent(context) || + isYouTrackWorkflowDispatchEvent(context) || + Boolean(context.inputs.prompt) + ); +} + +const PUBLISHING_POLICY_NOTE = + "\n\nPublishing policy (must be followed exactly):\n" + + "- Do NOT push to the remote, and do NOT create or update a pull request. The workflow " + + "stages, commits, pushes and opens the pull request itself once the task is done.\n" + + "- Do NOT create git worktrees and do NOT switch to another branch. Work on the branch " + + "that is currently checked out and leave the changes there."; + +const PLAN_ARTIFACT_NOTE = + "\n\nDo not stage or commit your plan file or any other scratch file you create while " + + "working. Only the actual code changes the task calls for belong in the commit."; + +const SUMMARY_FORMAT_NOTE = + "\n\nFinal summary format (it is published as the pull request description):\n" + + "- A few sentences: what changed and why, nothing else.\n" + + "- Plain prose or a short bullet list. No headings, no per-step report.\n" + + "- Never paste code, diffs, file contents, command output or logs into it."; function getTriggerTime(context: JunieExecutionContext): string | undefined { if (isIssueCommentEvent(context)) { @@ -85,12 +123,21 @@ export async function prepareJunieTask( description: promptText, diffCommand } + } else if (shouldRunInGoalMode(context)) { + console.log("Running this task in goal mode (orchestrated)"); + + // Keep the agent's own artifacts out of the commit the action builds later. + addGitExcludePatterns(AGENT_ARTIFACT_PATTERNS); + + junieCLITask.orchestratedTask = { + task: promptText + PUBLISHING_POLICY_NOTE + PLAN_ARTIFACT_NOTE + SUMMARY_FORMAT_NOTE + }; } else { junieCLITask.task = promptText; } } - if (!junieCLITask.task && !junieCLITask.mergeTask && !junieCLITask.codeReviewTask) { + if (!junieCLITask.task && !junieCLITask.orchestratedTask && !junieCLITask.mergeTask && !junieCLITask.codeReviewTask) { throw new Error("No task was created. Please check your inputs."); } diff --git a/src/github/junie/types/junie.ts b/src/github/junie/types/junie.ts index eb023174..c446ff11 100644 --- a/src/github/junie/types/junie.ts +++ b/src/github/junie/types/junie.ts @@ -26,8 +26,13 @@ export interface CliOutput { duration_ms?: number; } +export interface OrchestratedTask { + task: string; +} + export interface CliInput { task?: string; + orchestratedTask?: OrchestratedTask; mergeTask?: MergeTask; codeReviewTask?: CodeReview; } \ No newline at end of file diff --git a/src/utils/git-exclude.ts b/src/utils/git-exclude.ts new file mode 100644 index 00000000..345ac141 --- /dev/null +++ b/src/utils/git-exclude.ts @@ -0,0 +1,107 @@ +import {execSync} from "child_process"; +import * as fs from "node:fs"; +import * as path from "node:path"; + +/** + * Artifacts the agent writes into the checkout while it works, which must never + * end up in a commit. + * + * Goal mode's planning sub-agent stores its plan as a markdown file under + * `/.junie/plans` (the CLI resolves that path against the project + * directory, so it lands inside the repository the action commits from) and + * picks a descriptive name per task, e.g. `add-export-feature.md`. The commit + * step runs `git add .`, so without an exclude those plans are committed + * alongside the real change. + * + * `.junie/memory` is written by the agent for the same reason and is equally + * not part of the task's result. + */ +export const AGENT_ARTIFACT_PATTERNS = [ + ".junie/plans/", + ".junie/memory/", +]; + +const EXCLUDE_HEADER = "# junie-github-action: agent artifacts, not part of the task result"; + +/** + * Resolves the directory holding the repository metadata. + * + * `--git-common-dir` rather than `--git-dir`: in a linked worktree the latter + * points at `.git/worktrees/`, which has no `info/exclude` of its own, + * while the common dir is shared by every worktree. + */ +function resolveGitCommonDir(cwd?: string): string | undefined { + try { + const gitDir = execSync("git rev-parse --git-common-dir", { + encoding: "utf-8", + cwd, + stdio: ["ignore", "pipe", "pipe"], + }).trim(); + + if (!gitDir) { + return undefined; + } + + return path.isAbsolute(gitDir) ? gitDir : path.resolve(cwd ?? process.cwd(), gitDir); + } catch (error) { + console.warn(`Could not locate the git directory: ${error instanceof Error ? error.message : String(error)}`); + return undefined; + } +} + +/** + * Adds `patterns` to `.git/info/exclude` so the commit step's `git add .` skips them. + * + * `.git/info/exclude` is used rather than `.gitignore` on purpose: it is local to + * the checkout and is itself never committed, so the action does not modify — and + * cannot accidentally commit a change to — the consuming repository's ignore rules. + * + * Note this only keeps *untracked* files out of a commit. A file the repository + * already tracks stays tracked; excludes do not apply to it. + * + * Writing is best-effort: a repository we cannot write the exclude file for should + * not fail the run, so the failure is logged and the task proceeds. + * + * @returns the patterns that were newly appended (empty if all were already present) + */ +export function addGitExcludePatterns(patterns: string[], cwd?: string): string[] { + if (patterns.length === 0) { + return []; + } + + const gitDir = resolveGitCommonDir(cwd); + if (!gitDir) { + console.warn("Skipping agent artifact excludes: not inside a git repository"); + return []; + } + + const excludePath = path.join(gitDir, "info", "exclude"); + + try { + const existing = fs.existsSync(excludePath) ? fs.readFileSync(excludePath, "utf-8") : ""; + const existingPatterns = new Set( + existing.split("\n").map(line => line.trim()).filter(line => line && !line.startsWith("#")) + ); + + const missing = patterns.filter(pattern => !existingPatterns.has(pattern)); + if (missing.length === 0) { + console.log("Agent artifact excludes are already present"); + return []; + } + + fs.mkdirSync(path.dirname(excludePath), {recursive: true}); + + // Keep whatever the checkout already excluded and append below it. + const prefix = existing === "" || existing.endsWith("\n") ? existing : `${existing}\n`; + fs.writeFileSync(excludePath, `${prefix}${EXCLUDE_HEADER}\n${missing.join("\n")}\n`); + + console.log(`Excluded agent artifacts from commits: ${missing.join(", ")}`); + return missing; + } catch (error) { + console.warn( + `Could not write ${excludePath}, agent artifacts may be committed: ` + + `${error instanceof Error ? error.message : String(error)}` + ); + return []; + } +} diff --git a/test/junie-tasks.test.ts b/test/junie-tasks.test.ts index dab3e57e..a6e9b1bc 100644 --- a/test/junie-tasks.test.ts +++ b/test/junie-tasks.test.ts @@ -1,10 +1,13 @@ -import {describe, test, expect, mock, beforeEach} from "bun:test"; +import {describe, test, expect, mock, beforeEach, beforeAll, afterAll} from "bun:test"; import {prepareJunieTask} from "../src/github/junie/junie-tasks"; import {JunieExecutionContext} from "../src/github/context"; import {BranchInfo} from "../src/github/operations/branch"; import {Octokits} from "../src/github/api/client"; import * as core from "@actions/core"; +import {execSync} from "child_process"; import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; // Mock modules mock.module("@actions/core", () => ({ @@ -198,6 +201,24 @@ describe("prepareJunieTask", () => { isNewBranch: true }; + // Goal-mode runs excludes the agent's artifacts via `.git/info/exclude` of the + // repository they run in. Run from a throwaway repository so these tests do not + // write to this checkout's own git directory. + let originalCwd: string; + let scratchRepo: string; + + beforeAll(() => { + originalCwd = process.cwd(); + scratchRepo = fs.mkdtempSync(path.join(os.tmpdir(), "junie-tasks-test-")); + execSync("git init -q", {cwd: scratchRepo}); + process.chdir(scratchRepo); + }); + + afterAll(() => { + process.chdir(originalCwd); + fs.rmSync(scratchRepo, {recursive: true, force: true}); + }); + beforeEach(() => { (core.setOutput as any).mockClear(); // Set WORKING_DIR for file operations @@ -230,10 +251,11 @@ describe("prepareJunieTask", () => { const result = await prepareJunieTask(context, branchInfo, octokit); expect(result).toBeDefined(); - expect(result.task).toBeDefined(); - expect(result.task).toContain("Do something"); - expect(result.task).toContain(""); - expect(result.task).toContain(""); + expect(result.orchestratedTask).toBeDefined(); + expect(result.task).toBeUndefined(); + expect(result.orchestratedTask?.task).toContain("Do something"); + expect(result.orchestratedTask?.task).toContain(""); + expect(result.orchestratedTask?.task).toContain(""); expect(result.mergeTask).toBeUndefined(); expect(core.setOutput).toHaveBeenCalledWith("JUNIE_INPUT_FILE", expect.any(String)); }); @@ -258,9 +280,10 @@ describe("prepareJunieTask", () => { const result = await prepareJunieTask(context, branchInfo, octokit); expect(result).toBeDefined(); - expect(result.task).toBeDefined(); - expect(result.task).toContain("Do something"); - expect(result.task).toContain("Do NOT commit or push changes"); + expect(result.orchestratedTask).toBeDefined(); + expect(result.task).toBeUndefined(); + expect(result.orchestratedTask?.task).toContain("Do something"); + expect(result.orchestratedTask?.task).toContain("Do NOT commit or push changes"); expect(result.mergeTask).toBeUndefined(); expect(core.setOutput).toHaveBeenCalledWith("JUNIE_INPUT_FILE", expect.any(String)); }); @@ -277,12 +300,13 @@ describe("prepareJunieTask", () => { const result = await prepareJunieTask(context, branchInfo, octokit); expect(result).toBeDefined(); - expect(result.task).toBeDefined(); + expect(result.orchestratedTask).toBeDefined(); + expect(result.task).toBeUndefined(); expect(result.mergeTask).toBeUndefined(); - expect(result.task).toContain(""); - expect(result.task).toContain("@junie-agent help"); - expect(result.task).toContain(""); - expect(result.task).toContain(""); + expect(result.orchestratedTask?.task).toContain(""); + expect(result.orchestratedTask?.task).toContain("@junie-agent help"); + expect(result.orchestratedTask?.task).toContain(""); + expect(result.orchestratedTask?.task).toContain(""); }); }); @@ -311,11 +335,12 @@ describe("prepareJunieTask", () => { const result = await prepareJunieTask(context, branchInfo, octokit); expect(result).toBeDefined(); - expect(result.task).toBeDefined(); + expect(result.orchestratedTask).toBeDefined(); + expect(result.task).toBeUndefined(); expect(result.mergeTask).toBeUndefined(); - expect(result.task).toContain(""); - expect(result.task).toContain("Issue body"); - expect(result.task).toContain(""); + expect(result.orchestratedTask?.task).toContain(""); + expect(result.orchestratedTask?.task).toContain("Issue body"); + expect(result.orchestratedTask?.task).toContain(""); }); }); @@ -352,12 +377,13 @@ describe("prepareJunieTask", () => { const result = await prepareJunieTask(context, branchInfo, octokit); expect(result).toBeDefined(); - expect(result.task).toBeDefined(); + expect(result.orchestratedTask).toBeDefined(); + expect(result.task).toBeUndefined(); expect(result.mergeTask).toBeUndefined(); - expect(result.task).toContain(""); - expect(result.task).toContain("Please fix this"); - expect(result.task).toContain(""); - expect(result.task).toContain(""); + expect(result.orchestratedTask?.task).toContain(""); + expect(result.orchestratedTask?.task).toContain("Please fix this"); + expect(result.orchestratedTask?.task).toContain(""); + expect(result.orchestratedTask?.task).toContain(""); }); }); @@ -389,11 +415,12 @@ describe("prepareJunieTask", () => { const result = await prepareJunieTask(context, branchInfo, octokit); expect(result).toBeDefined(); - expect(result.task).toBeDefined(); + expect(result.orchestratedTask).toBeDefined(); + expect(result.task).toBeUndefined(); expect(result.mergeTask).toBeUndefined(); - expect(result.task).toContain(""); - expect(result.task).toContain("Changes needed"); - expect(result.task).toContain(""); + expect(result.orchestratedTask?.task).toContain(""); + expect(result.orchestratedTask?.task).toContain("Changes needed"); + expect(result.orchestratedTask?.task).toContain(""); }); test("should create codeReviewTask when code-review prompt is provided", async () => { @@ -536,11 +563,12 @@ describe("prepareJunieTask", () => { const result = await prepareJunieTask(context, branchInfo, octokit); expect(result).toBeDefined(); - expect(result.task).toBeDefined(); + expect(result.orchestratedTask).toBeDefined(); + expect(result.task).toBeUndefined(); expect(result.mergeTask).toBeUndefined(); - expect(result.task).toContain(""); - expect(result.task).toContain("Fix this line"); - expect(result.task).toContain(""); + expect(result.orchestratedTask?.task).toContain(""); + expect(result.orchestratedTask?.task).toContain("Fix this line"); + expect(result.orchestratedTask?.task).toContain(""); }); }); @@ -571,12 +599,165 @@ describe("prepareJunieTask", () => { const result = await prepareJunieTask(context, branchInfo, octokit); expect(result).toBeDefined(); - expect(result.task).toBeDefined(); + expect(result.orchestratedTask).toBeDefined(); + expect(result.task).toBeUndefined(); expect(result.mergeTask).toBeUndefined(); - expect(result.task).toContain(""); - expect(result.task).toContain("PR description"); - expect(result.task).toContain(""); - expect(result.task).toContain(""); + expect(result.orchestratedTask?.task).toContain(""); + expect(result.orchestratedTask?.task).toContain("PR description"); + expect(result.orchestratedTask?.task).toContain(""); + expect(result.orchestratedTask?.task).toContain(""); + }); + }); + + describe("goal mode", () => { + test("should keep the agent from publishing, so the action decides where changes land", async () => { + const context = createMockContext({eventName: "issue_comment", isPR: true}); + const octokit = createMockOctokit(); + + const result = await prepareJunieTask(context, branchInfo, octokit); + + const task = result.orchestratedTask?.task ?? ""; + expect(task).toContain("Do NOT push to the remote"); + expect(task).toContain("do NOT create or update a pull request"); + expect(task).toContain("Do NOT create git worktrees"); + }); + + test("should tell the agent not to commit its plan file", async () => { + const context = createMockContext({eventName: "issues"}); + const octokit = createMockOctokit(); + + const result = await prepareJunieTask(context, branchInfo, octokit); + + expect(result.orchestratedTask?.task).toContain("Do not stage or commit your plan file"); + }); + + test("should ask for a short summary, so the PR description keeps its shape", async () => { + const context = createMockContext({eventName: "issues"}); + const octokit = createMockOctokit(); + + const result = await prepareJunieTask(context, branchInfo, octokit); + + const task = result.orchestratedTask?.task ?? ""; + expect(task).toContain("Final summary format"); + expect(task).toContain("A few sentences"); + }); + + test("should not use goal mode for a minor fix", async () => { + const context = createMockContext({ + eventName: "workflow_dispatch", + inputs: { + ...createMockContext().inputs, + prompt: "minor-fix" + }, + payload: { + repository: { + owner: {login: "owner"}, + name: "repo" + } + } as any + }); + const octokit = createMockOctokit(); + + const result = await prepareJunieTask(context, branchInfo, octokit); + + expect(result.task).toBeDefined(); + expect(result.orchestratedTask).toBeUndefined(); + }); + + test("should not use goal mode for a push event", async () => { + const context = createMockContext({ + eventName: "push", + payload: { + ref: "refs/heads/main", + repository: { + owner: {login: "owner"}, + name: "repo" + } + } as any + }); + const octokit = createMockOctokit(); + + const result = await prepareJunieTask(context, branchInfo, octokit); + + expect(result.task).toBeDefined(); + expect(result.orchestratedTask).toBeUndefined(); + }); + + test("should use goal mode for a fix-ci run", async () => { + const context = createMockContext({ + eventName: "workflow_run" as any, + isPR: true, + entityNumber: 123, + inputs: { + ...createMockContext().inputs, + prompt: "fix-ci" + }, + payload: { + action: "completed", + workflow_run: { + id: 12345, + name: "CI", + head_branch: "feature-branch", + head_sha: "abc123", + conclusion: "failure", + pull_requests: [{number: 123}] + }, + repository: { + owner: {login: "owner"}, + name: "repo" + } + } as any + }); + const octokit = createMockOctokit(); + + const result = await prepareJunieTask(context, branchInfo, octokit); + + expect(result.orchestratedTask).toBeDefined(); + expect(result.task).toBeUndefined(); + }); + + test("should not use goal mode for a code review", async () => { + const context = createMockContext({ + eventName: "pull_request", + isPR: true, + entityNumber: 123, + inputs: { + ...createMockContext().inputs, + prompt: "code-review" + }, + payload: { + pull_request: { + number: 123, + title: "Test PR", + updated_at: "2024-01-01T00:00:00Z" + }, + repository: { + owner: {login: "owner"}, + name: "repo" + } + } as any + }); + const octokit = createMockOctokit(); + + const result = await prepareJunieTask(context, branchInfo, octokit); + + expect(result.codeReviewTask).toBeDefined(); + expect(result.orchestratedTask).toBeUndefined(); + }); + + test("should not use goal mode when resolving conflicts", async () => { + const context = createMockContext({ + inputs: { + ...createMockContext().inputs, + resolveConflicts: true + } + }); + const octokit = createMockOctokit(); + + const result = await prepareJunieTask(context, branchInfo, octokit); + + expect(result.mergeTask).toBeDefined(); + expect(result.orchestratedTask).toBeUndefined(); }); }); @@ -664,14 +845,14 @@ describe("prepareJunieTask", () => { const issueContext = createMockContext({eventName: "issue_comment", isPR: false}); const issueResult = await prepareJunieTask(issueContext, branchInfo, octokit); expect(issueResult).toBeDefined(); - expect(issueResult.task).toBeDefined(); + expect(issueResult.orchestratedTask).toBeDefined(); expect(issueResult.mergeTask).toBeUndefined(); // Test PR comment const prContext = createMockContext({eventName: "issue_comment", isPR: true}); const prResult = await prepareJunieTask(prContext, branchInfo, octokit); expect(prResult).toBeDefined(); - expect(prResult.task).toBeDefined(); + expect(prResult.orchestratedTask).toBeDefined(); expect(prResult.mergeTask).toBeUndefined(); // Both should have been processed successfully diff --git a/test/utils/git-exclude.test.ts b/test/utils/git-exclude.test.ts new file mode 100644 index 00000000..d8b99c2c --- /dev/null +++ b/test/utils/git-exclude.test.ts @@ -0,0 +1,96 @@ +import {describe, test, expect, beforeEach, afterEach} from "bun:test"; +import {addGitExcludePatterns, AGENT_ARTIFACT_PATTERNS} from "../../src/utils/git-exclude"; +import {execSync} from "child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +describe("addGitExcludePatterns", () => { + let repoDir: string; + let excludePath: string; + + const readExclude = () => fs.readFileSync(excludePath, "utf-8"); + + beforeEach(() => { + repoDir = fs.mkdtempSync(path.join(os.tmpdir(), "git-exclude-test-")); + execSync("git init -q", {cwd: repoDir}); + excludePath = path.join(repoDir, ".git", "info", "exclude"); + }); + + afterEach(() => { + fs.rmSync(repoDir, {recursive: true, force: true}); + }); + + test("writes the patterns to .git/info/exclude", () => { + const added = addGitExcludePatterns([".junie/plans/"], repoDir); + + expect(added).toEqual([".junie/plans/"]); + expect(readExclude()).toContain(".junie/plans/"); + }); + + test("keeps the patterns the checkout already excluded", () => { + fs.mkdirSync(path.dirname(excludePath), {recursive: true}); + fs.writeFileSync(excludePath, "# existing rules\nbuild/\n"); + + addGitExcludePatterns([".junie/plans/"], repoDir); + + const contents = readExclude(); + expect(contents).toContain("build/"); + expect(contents).toContain(".junie/plans/"); + }); + + test("appends nothing on a second call", () => { + addGitExcludePatterns(AGENT_ARTIFACT_PATTERNS, repoDir); + const afterFirst = readExclude(); + + const added = addGitExcludePatterns(AGENT_ARTIFACT_PATTERNS, repoDir); + + expect(added).toEqual([]); + expect(readExclude()).toBe(afterFirst); + }); + + test("appends only the patterns that are missing", () => { + addGitExcludePatterns([".junie/plans/"], repoDir); + + const added = addGitExcludePatterns([".junie/plans/", ".junie/memory/"], repoDir); + + expect(added).toEqual([".junie/memory/"]); + expect(readExclude().match(/\.junie\/plans\//g)).toHaveLength(1); + }); + + test("does not append a pattern that is only present as a comment", () => { + fs.mkdirSync(path.dirname(excludePath), {recursive: true}); + fs.writeFileSync(excludePath, "# .junie/plans/\n"); + + const added = addGitExcludePatterns([".junie/plans/"], repoDir); + + expect(added).toEqual([".junie/plans/"]); + }); + + test("keeps an excluded plan file out of `git add .`", () => { + addGitExcludePatterns(AGENT_ARTIFACT_PATTERNS, repoDir); + + fs.mkdirSync(path.join(repoDir, ".junie", "plans"), {recursive: true}); + fs.writeFileSync(path.join(repoDir, ".junie", "plans", "add-export-feature.md"), "# plan"); + fs.writeFileSync(path.join(repoDir, "src.ts"), "export const a = 1;"); + + execSync("git add .", {cwd: repoDir}); + const staged = execSync("git diff --cached --name-only", {cwd: repoDir, encoding: "utf-8"}); + + expect(staged).toContain("src.ts"); + expect(staged).not.toContain("add-export-feature.md"); + }); + + test("returns nothing when there are no patterns to add", () => { + expect(addGitExcludePatterns([], repoDir)).toEqual([]); + }); + + test("does not throw outside a git repository", () => { + const plainDir = fs.mkdtempSync(path.join(os.tmpdir(), "not-a-repo-")); + try { + expect(addGitExcludePatterns([".junie/plans/"], plainDir)).toEqual([]); + } finally { + fs.rmSync(plainDir, {recursive: true, force: true}); + } + }); +}); From 6106b39a27d03c946ace4c08d72cfa7d5a9d68d9 Mon Sep 17 00:00:00 2001 From: "Mariia.Fadeeva" Date: Tue, 11 Aug 2026 12:04:01 +0300 Subject: [PATCH 2/6] Added PR title prefix logic with validation, constants, and comprehensive unit tests --- src/constants/github.ts | 13 +++++++-- src/github/junie/junie-tasks.ts | 18 ++++++++++++- test/junie-tasks.test.ts | 48 +++++++++++++++++++++++++++++++++ test/pr-title.test.ts | 41 ++++++++++++++++++++++++++++ 4 files changed, 117 insertions(+), 3 deletions(-) create mode 100644 test/pr-title.test.ts diff --git a/src/constants/github.ts b/src/constants/github.ts index 9a9993dc..2169b248 100644 --- a/src/constants/github.ts +++ b/src/constants/github.ts @@ -183,8 +183,17 @@ ${issueId ? `- 🔗 **Issue:** Fixes: #${issueId}` : ""} ${junieBody} ` -export const PR_TITLE_TEMPLATE = (junieTitle: string) => - `[Junie]: ${junieTitle}` +export const PR_TITLE_PREFIX = "[Junie]: " + +export const PR_TITLE_TEMPLATE = (junieTitle: string) => { + let title = junieTitle.trim() + + while (title.toLowerCase().startsWith(PR_TITLE_PREFIX.toLowerCase().trim())) { + title = title.slice(PR_TITLE_PREFIX.trim().length).trim() + } + + return `${PR_TITLE_PREFIX}${title}` +} export const COMMIT_MESSAGE_TEMPLATE = (junieTitle: string, issueId?: number, actor?: string, actorEmail?: string) => { const baseMessage = `${issueId ? `[issue-${issueId}]\n\n` : ""}${junieTitle}`; diff --git a/src/github/junie/junie-tasks.ts b/src/github/junie/junie-tasks.ts index 53c8a7a3..e9e4761f 100644 --- a/src/github/junie/junie-tasks.ts +++ b/src/github/junie/junie-tasks.ts @@ -45,6 +45,10 @@ const PUBLISHING_POLICY_NOTE = "\n\nPublishing policy (must be followed exactly):\n" + "- Do NOT push to the remote, and do NOT create or update a pull request. The workflow " + "stages, commits, pushes and opens the pull request itself once the task is done.\n" + + "- If this run targets an existing pull request, its title, description, labels, reviewers " + + "and every other field must be left exactly as they are. Do NOT rename, retitle, reword or " + + "otherwise edit the pull request: only the code changes are yours to make, and the workflow " + + "adds your summary on its own.\n" + "- Do NOT create git worktrees and do NOT switch to another branch. Work on the branch " + "that is currently checked out and leave the changes there."; @@ -58,6 +62,17 @@ const SUMMARY_FORMAT_NOTE = "- Plain prose or a short bullet list. No headings, no per-step report.\n" + "- Never paste code, diffs, file contents, command output or logs into it."; +const TITLE_FORMAT_NOTE = + "\n\nTask name (it becomes the pull request title when a new pull request is opened):\n" + + "- Describe the actual code change or the business value it delivers, the way a developer " + + "would title the pull request, e.g. 'Add export functionality to users module' or " + + "'Fix NPE in payment processing'.\n" + + "- One short line. No trailing period.\n" + + "- Never name your own process. The title must not mention steps, plans, reviews, " + + "deliverables or execution, and must not contain wording such as 'Step 1', " + + "'Implementation', 'Deliverables', 'Task execution', 'Orchestrated' or 'Final report'.\n" + + "- Do NOT prefix it with '[Junie]:' or any other tag: the workflow adds that itself."; + function getTriggerTime(context: JunieExecutionContext): string | undefined { if (isIssueCommentEvent(context)) { return context.payload.comment.created_at; @@ -130,7 +145,8 @@ export async function prepareJunieTask( addGitExcludePatterns(AGENT_ARTIFACT_PATTERNS); junieCLITask.orchestratedTask = { - task: promptText + PUBLISHING_POLICY_NOTE + PLAN_ARTIFACT_NOTE + SUMMARY_FORMAT_NOTE + task: promptText + PUBLISHING_POLICY_NOTE + PLAN_ARTIFACT_NOTE + + SUMMARY_FORMAT_NOTE + TITLE_FORMAT_NOTE }; } else { junieCLITask.task = promptText; diff --git a/test/junie-tasks.test.ts b/test/junie-tasks.test.ts index a6e9b1bc..d9be5048 100644 --- a/test/junie-tasks.test.ts +++ b/test/junie-tasks.test.ts @@ -622,6 +622,54 @@ describe("prepareJunieTask", () => { expect(task).toContain("Do NOT create git worktrees"); }); + test("should forbid retitling or editing an existing pull request", async () => { + const context = createMockContext({eventName: "issue_comment", isPR: true}); + const octokit = createMockOctokit(); + + const result = await prepareJunieTask(context, branchInfo, octokit); + + const task = result.orchestratedTask?.task ?? ""; + expect(task).toContain("its title, description, labels, reviewers"); + expect(task).toContain("must be left exactly as they are"); + expect(task).toContain("Do NOT rename, retitle, reword"); + }); + + test("should require a descriptive task name for the pull request title", async () => { + const context = createMockContext({eventName: "issues"}); + const octokit = createMockOctokit(); + + const result = await prepareJunieTask(context, branchInfo, octokit); + + const task = result.orchestratedTask?.task ?? ""; + expect(task).toContain("it becomes the pull request title"); + expect(task).toContain("Describe the actual code change or the business value"); + expect(task).toContain("Add export functionality to users module"); + expect(task).toContain("Fix NPE in payment processing"); + }); + + test("should ban internal workflow phrasing from the task name", async () => { + const context = createMockContext({eventName: "issues"}); + const octokit = createMockOctokit(); + + const result = await prepareJunieTask(context, branchInfo, octokit); + + const task = result.orchestratedTask?.task ?? ""; + expect(task).toContain("Never name your own process"); + for (const banned of ["Step 1", "Implementation", "Deliverables", "Task execution"]) { + expect(task).toContain(`'${banned}'`); + } + }); + + test("should tell the agent not to add the title prefix itself", async () => { + const context = createMockContext({eventName: "issues"}); + const octokit = createMockOctokit(); + + const result = await prepareJunieTask(context, branchInfo, octokit); + + expect(result.orchestratedTask?.task) + .toContain("Do NOT prefix it with '[Junie]:'"); + }); + test("should tell the agent not to commit its plan file", async () => { const context = createMockContext({eventName: "issues"}); const octokit = createMockOctokit(); diff --git a/test/pr-title.test.ts b/test/pr-title.test.ts new file mode 100644 index 00000000..f914c143 --- /dev/null +++ b/test/pr-title.test.ts @@ -0,0 +1,41 @@ +import {describe, expect, test} from "bun:test"; +import {PR_TITLE_PREFIX, PR_TITLE_TEMPLATE} from "../src/constants/github"; + +describe("PR_TITLE_TEMPLATE", () => { + test("prefixes a plain title", () => { + expect(PR_TITLE_TEMPLATE("Add export functionality to users module")) + .toBe("[Junie]: Add export functionality to users module"); + }); + + test("always starts with the prefix", () => { + const titles = [ + "Fix NPE in payment processing", + "[Junie]: Fix NPE in payment processing", + " Fix NPE in payment processing ", + ]; + + for (const title of titles) { + expect(PR_TITLE_TEMPLATE(title).startsWith(PR_TITLE_PREFIX)).toBe(true); + } + }); + + test("does not double the prefix when the agent already added one", () => { + expect(PR_TITLE_TEMPLATE("[Junie]: Fix NPE in payment processing")) + .toBe("[Junie]: Fix NPE in payment processing"); + }); + + test("collapses a repeated prefix", () => { + expect(PR_TITLE_TEMPLATE("[Junie]: [Junie]: Fix NPE in payment processing")) + .toBe("[Junie]: Fix NPE in payment processing"); + }); + + test("recognises the prefix regardless of case or spacing", () => { + expect(PR_TITLE_TEMPLATE("[junie]:Fix NPE in payment processing")) + .toBe("[Junie]: Fix NPE in payment processing"); + }); + + test("trims surrounding whitespace", () => { + expect(PR_TITLE_TEMPLATE(" Add export functionality ")) + .toBe("[Junie]: Add export functionality"); + }); +}); From ecb313a04aafac378a805ee02a9d9c5e4a1eba7d Mon Sep 17 00:00:00 2001 From: "Mariia.Fadeeva" Date: Tue, 11 Aug 2026 12:23:19 +0300 Subject: [PATCH 3/6] Added dynamic branch policy note generation --- action.yml | 1 + src/constants/environment.ts | 2 + src/entrypoints/handle-results.ts | 32 ++++++- src/github/junie/junie-tasks.ts | 63 ++++++++++--- src/utils/pr-title.ts | 95 ++++++++++++++++++++ test/junie-tasks.test.ts | 141 +++++++++++++++++++++++++++-- test/pr-title-resolve.test.ts | 142 ++++++++++++++++++++++++++++++ 7 files changed, 458 insertions(+), 18 deletions(-) create mode 100644 src/utils/pr-title.ts create mode 100644 test/pr-title-resolve.test.ts diff --git a/action.yml b/action.yml index c3adf39a..741fa98a 100644 --- a/action.yml +++ b/action.yml @@ -366,6 +366,7 @@ runs: WORKING_BRANCH: ${{ steps.prepare.outputs.WORKING_BRANCH }} BASE_BRANCH: ${{ steps.prepare.outputs.BASE_BRANCH }} IS_NEW_BRANCH: ${{ steps.prepare.outputs.IS_NEW_BRANCH }} + ENTITY_TITLE: ${{ steps.prepare.outputs.ENTITY_TITLE }} WORKING_DIR: ${{ inputs.junie_work_dir }} APP_TOKEN: ${{ inputs.junie_api_key }} CODE_REVIEW_FEEDBACK_API_BASE_URL: ${{ inputs.code_review_feedback_api_base_url }} diff --git a/src/constants/environment.ts b/src/constants/environment.ts index 84a0d6c7..a81bc0ac 100644 --- a/src/constants/environment.ts +++ b/src/constants/environment.ts @@ -62,6 +62,8 @@ export const OUTPUT_VARS = { EJ_MCP_CONFIG: "EJ_MCP_CONFIG", CUSTOM_JUNIE_ARGS: "CUSTOM_JUNIE_ARGS", + ENTITY_TITLE: "ENTITY_TITLE", + // Exception handling EXCEPTION: "EXCEPTION", diff --git a/src/entrypoints/handle-results.ts b/src/entrypoints/handle-results.ts index fb49ba66..843ccf87 100644 --- a/src/entrypoints/handle-results.ts +++ b/src/entrypoints/handle-results.ts @@ -13,6 +13,29 @@ import * as fs from "node:fs"; import type {CliOutput} from "../github/junie/types/junie"; import {fetchCodeReviewFeedbackLink} from "../utils/code-review-feedback-link"; import {formatJunieErrors, formatJunieExitCodeNote, resolveJunieOutputFile} from "../utils/junie-failure"; +import {resolvePrTitle} from "../utils/pr-title"; + +/** + * Title of the issue or pull request the run was triggered from. + * + * The prepare step resolves it and passes it on, because several payloads carry only the + * entity number: a fix-CI run arrives as `workflow_run`, whose payload has no `issue` or + * `pull_request` at all, and the same holds for `check_suite` and `schedule`. Reading the + * payload is the fallback for events that do carry the entity inline. + */ +function getTriggeringEntityTitle(context: JunieExecutionContext): string | undefined { + const fromPrepare = process.env[OUTPUT_VARS.ENTITY_TITLE]; + if (fromPrepare && fromPrepare.trim() !== "") { + return fromPrepare; + } + + const payload = context.payload as { + issue?: { title?: string }; + pull_request?: { title?: string }; + }; + + return payload.pull_request?.title || payload.issue?.title; +} export enum ActionType { WRITE_COMMENT = 'WRITE_COMMENT', @@ -100,7 +123,14 @@ export async function handleResults() { exportJunieSessionOutputs(sessionId); await exportCodeReviewFeedbackLink(context, sessionId, actionToDo); // Sanitize Junie's output to prevent token leakage and self-triggering - const rawTitle = junieJsonOutput.taskName || (isResolveConflict ? `Resolve conflicts for ${context.entityNumber} PR` : 'Junie finished task successfully') + const defaultTitle = isResolveConflict + ? `Resolve conflicts for ${context.entityNumber} PR` + : 'Junie finished task successfully' + // taskName is a live session name the orchestrated sub-agents overwrite in turn, so the + // triggering issue or pull request title is preferred whenever there is one. + const rawTitle = isResolveConflict + ? (junieJsonOutput.taskName || defaultTitle) + : resolvePrTitle(junieJsonOutput.taskName, getTriggeringEntityTitle(context), defaultTitle) const rawBody = junieJsonOutput.result const triggerPhrase = context.inputs.triggerPhrase diff --git a/src/github/junie/junie-tasks.ts b/src/github/junie/junie-tasks.ts index e9e4761f..0d253aba 100644 --- a/src/github/junie/junie-tasks.ts +++ b/src/github/junie/junie-tasks.ts @@ -49,8 +49,31 @@ const PUBLISHING_POLICY_NOTE = "and every other field must be left exactly as they are. Do NOT rename, retitle, reword or " + "otherwise edit the pull request: only the code changes are yours to make, and the workflow " + "adds your summary on its own.\n" + - "- Do NOT create git worktrees and do NOT switch to another branch. Work on the branch " + - "that is currently checked out and leave the changes there."; + "- Do NOT create git worktrees. Work in the checkout you were given and leave the changes there."; + +function buildBranchPolicyNote(branchInfo: BranchInfo, context: JunieExecutionContext): string { + const branch = branchInfo.workingBranch; + + const stayPut = + "\n\nBranch policy (must be followed exactly):\n" + + `- Branch '${branch}' is checked out and is the only place your changes belong. Commit them ` + + "there. Do NOT create, switch to or rename a branch.\n"; + + if (branchInfo.isNewBranch) { + return stayPut + + `- The workflow created '${branch}' for this run and opens a new pull request from it once ` + + "you finish. Do NOT open that pull request yourself."; + } + + if (context.isPR) { + return stayPut + + `- '${branch}' is the branch of the pull request this run targets. The workflow pushes your ` + + "commits straight onto it, so do NOT open a new pull request under any circumstances."; + } + + return stayPut + + "- The workflow decides what to publish once you finish. Do NOT open a pull request."; +} const PLAN_ARTIFACT_NOTE = "\n\nDo not stage or commit your plan file or any other scratch file you create while " + @@ -63,15 +86,23 @@ const SUMMARY_FORMAT_NOTE = "- Never paste code, diffs, file contents, command output or logs into it."; const TITLE_FORMAT_NOTE = - "\n\nTask name (it becomes the pull request title when a new pull request is opened):\n" + - "- Describe the actual code change or the business value it delivers, the way a developer " + - "would title the pull request, e.g. 'Add export functionality to users module' or " + + "\n\nTask name (the workflow titles the pull request from the issue or pull request that " + + "triggered this run; your name is used only when there is no such issue or pull request, " + + "so it still has to read like a title):\n" + + "- It names the CHANGE, not your work on it. Whenever you report a task name — at any " + + "point, from any step or sub-agent — give the name of the overall change this run delivers. " + + "Never name the step you just finished, the step you are in, or the stage of your own " + + "process. The reviewer sees only this one line and knows nothing about how you work.\n" + + "- Write what a developer would put on the pull request: the change or the business value " + + "it delivers, e.g. 'Add export functionality to users module' or " + "'Fix NPE in payment processing'.\n" + - "- One short line. No trailing period.\n" + - "- Never name your own process. The title must not mention steps, plans, reviews, " + - "deliverables or execution, and must not contain wording such as 'Step 1', " + - "'Implementation', 'Deliverables', 'Task execution', 'Orchestrated' or 'Final report'.\n" + - "- Do NOT prefix it with '[Junie]:' or any other tag: the workflow adds that itself."; + "- One short line, no trailing period.\n" + + "- Banned outright. The title must never contain 'Step', 'Stage', 'Review', " + + "'Implementation', 'Validation', 'Validation Completeness', 'Deliverables', " + + "'Task execution', 'Orchestrated', 'Final report', or any other word describing your " + + "internal process rather than the code. 'Review Step 1 Implementation and Validation " + + "Completeness' is exactly the kind of title that must never be produced.\n" + + "- Do NOT prefix it with '[Junie]:' yourself: the workflow adds that."; function getTriggerTime(context: JunieExecutionContext): string | undefined { if (isIssueCommentEvent(context)) { @@ -115,6 +146,14 @@ export async function prepareJunieTask( fetchedData = await fetcher.fetchIssueData(owner, repo, context.entityNumber, triggerTime); } + // The results step titles the pull request from this. It is resolved here because + // several event payloads (workflow_run for fix-CI, check_suite, schedule) carry the + // entity number but not its title, and this is where the entity is already fetched. + const entityTitle = fetchedData.pullRequest?.title || fetchedData.issue?.title; + if (entityTitle) { + core.setOutput(OUTPUT_VARS.ENTITY_TITLE, entityTitle); + } + const promptResult = await formatter.generatePrompt(context, fetchedData, branchInfo, context.inputs.attachGithubContextToCustomPrompt, isDefaultToken); let promptText = promptResult.prompt; customJunieArgs = promptResult.customJunieArgs; @@ -145,8 +184,8 @@ export async function prepareJunieTask( addGitExcludePatterns(AGENT_ARTIFACT_PATTERNS); junieCLITask.orchestratedTask = { - task: promptText + PUBLISHING_POLICY_NOTE + PLAN_ARTIFACT_NOTE + - SUMMARY_FORMAT_NOTE + TITLE_FORMAT_NOTE + task: promptText + PUBLISHING_POLICY_NOTE + buildBranchPolicyNote(branchInfo, context) + + PLAN_ARTIFACT_NOTE + SUMMARY_FORMAT_NOTE + TITLE_FORMAT_NOTE }; } else { junieCLITask.task = promptText; diff --git a/src/utils/pr-title.ts b/src/utils/pr-title.ts new file mode 100644 index 00000000..a2e43a22 --- /dev/null +++ b/src/utils/pr-title.ts @@ -0,0 +1,95 @@ +/** + * Chooses the title for a pull request the action opens. + * + * The title of the issue or pull request that triggered the run is preferred over anything + * the agent reports. It is written by a person, it always describes the change rather than + * the work, and it is the same on every run — which the agent's own name is not. + * + * The CLI has no field meaning "pull request title". `taskName` is a live session name: + * `OutputWriter` overwrites it on every `AgentTaskNameUpdatedEvent`, and since the emitter + * sits in `AbstractAgentWorker`, every orchestrated sub-agent (plan, code, review, git) + * raises one as it finishes. Whichever ran last wins, so in goal mode `taskName` is that + * sub-agent's summary of its own step — "Review Step 1 Implementation and Validation + * Completeness". Wording varies per run, so no filter over it can be relied on; it is used + * only when there is no issue or pull request to take a title from. + */ + +/** + * Wording that describes the agent's process instead of the code. + * + * Single words that also occur in legitimate titles ("Add review widget") are matched only + * at the start, which is where a step name lands; unambiguous phrases are matched anywhere. + */ +const INTERNAL_WORKFLOW_PATTERNS: RegExp[] = [ + /\bstep\s*\d+/i, + /\bstage\s*\d+/i, + /\bdeliverables?\b/i, + /\bcompleteness\b/i, + /\btask\s+execution\b/i, + /\bfinal\s+report\b/i, + /\borchestrated?\b/i, + /\bsub-?agent\b/i, + + /^\s*(re)?view(s|ed|ing)?\b/i, + /^\s*implement(s|ed|ing|ation|ations)?\b/i, + /^\s*validat(e|es|ed|ing|ion|ions)\b/i, + /^\s*verif(y|ies|ied|ying|ication)\b/i, + /^\s*plan(s|ned|ning)?\b/i, + /^\s*analy[sz](e|es|ed|ing|is)\b/i, + /^\s*summar(y|ies|ise|ize|ised|ized|ising|izing)\b/i, + /^\s*finaliz(e|es|ed|ing)\b/i, + /^\s*complet(e|es|ed|ing|ion)\b/i, +]; + +/** + * Whether `title` names the agent's own process rather than the change. + */ +export function isInternalWorkflowTitle(title: string | undefined | null): boolean { + if (!title || title.trim() === "") { + return true; + } + + return INTERNAL_WORKFLOW_PATTERNS.some(pattern => pattern.test(title)); +} + +/** + * Picks the title to publish, in order of trustworthiness. + * + * 1. The triggering issue or pull request title — human-written and stable. + * 2. `taskName`, only when there is no such entity (for example a `workflow_dispatch` run + * driven by a bare prompt) and only if it does not describe the agent's own workflow. + * 3. The caller's generic fallback. + * + * @param taskName - `taskName` from the CLI output + * @param entityTitle - Title of the issue or pull request that triggered the run + * @param fallback - Used when neither is usable + */ +export function resolvePrTitle( + taskName: string | undefined, + entityTitle: string | undefined, + fallback: string, +): string { + const entity = entityTitle?.trim(); + + if (entity) { + if (taskName && taskName.trim() !== entity) { + console.log( + `Titling from the triggering issue or pull request ("${entity}") ` + + `instead of Junie's task name ("${taskName}").` + ); + } + return entity; + } + + if (!isInternalWorkflowTitle(taskName)) { + return taskName!.trim(); + } + + console.warn( + `No issue or pull request title is available, and Junie reported ` + + `"${taskName}" as the task name, which describes its own workflow rather than ` + + `the change. Using the generic fallback title.` + ); + + return fallback; +} diff --git a/test/junie-tasks.test.ts b/test/junie-tasks.test.ts index d9be5048..c624e227 100644 --- a/test/junie-tasks.test.ts +++ b/test/junie-tasks.test.ts @@ -622,6 +622,84 @@ describe("prepareJunieTask", () => { expect(task).toContain("Do NOT create git worktrees"); }); + // The note must state whatever branch.ts already decided, for every outcome its + // rules can produce — the parameter, the PR author / token owner shortcuts, the + // closed-PR case and silent mode alike. + test("should pin the agent to the existing PR branch when branch.ts kept it", async () => { + const context = createMockContext({eventName: "issue_comment", isPR: true}); + const octokit = createMockOctokit(); + + // create_new_branch_for_pr disabled, or actor is the PR author / token owner. + const existingPrBranch: BranchInfo = { + baseBranch: "main", + workingBranch: "contributor-feature", + isNewBranch: false + }; + + const result = await prepareJunieTask(context, existingPrBranch, octokit); + + const task = result.orchestratedTask?.task ?? ""; + expect(task).toContain("Branch 'contributor-feature' is checked out"); + expect(task).toContain("'contributor-feature' is the branch of the pull request this run targets"); + expect(task).toContain("do NOT open a new pull request under any circumstances"); + }); + + test("should pin the agent to the new branch when branch.ts created one", async () => { + const context = createMockContext({eventName: "issues"}); + const octokit = createMockOctokit(); + + // create_new_branch_for_pr enabled, an external contributor, or an issue run. + const freshBranch: BranchInfo = { + baseBranch: "main", + workingBranch: "junie/issue-123-456", + isNewBranch: true + }; + + const result = await prepareJunieTask(context, freshBranch, octokit); + + const task = result.orchestratedTask?.task ?? ""; + expect(task).toContain("Branch 'junie/issue-123-456' is checked out"); + expect(task).toContain("opens a new pull request from it once you finish"); + expect(task).toContain("Do NOT open that pull request yourself"); + }); + + test("should not claim a pull request exists on a silent-mode issue run", async () => { + const context = createMockContext({eventName: "issues", isPR: false}); + const octokit = createMockOctokit(); + + // Silent mode leaves the base branch checked out and never creates a branch. + const silentModeBranch: BranchInfo = { + baseBranch: "main", + workingBranch: "main", + isNewBranch: false + }; + + const result = await prepareJunieTask(context, silentModeBranch, octokit); + + const task = result.orchestratedTask?.task ?? ""; + expect(task).toContain("Branch 'main' is checked out"); + expect(task).toContain("The workflow decides what to publish"); + expect(task).not.toContain("the branch of the pull request this run targets"); + }); + + test("should forbid creating branches and opening PRs in every branch mode", async () => { + const octokit = createMockOctokit(); + const modes: BranchInfo[] = [ + {baseBranch: "main", workingBranch: "existing-pr-branch", isNewBranch: false}, + {baseBranch: "main", workingBranch: "junie/issue-1-2", isNewBranch: true}, + ]; + + for (const mode of modes) { + const result = await prepareJunieTask( + createMockContext({eventName: "issue_comment", isPR: true}), mode, octokit); + + const task = result.orchestratedTask?.task ?? ""; + expect(task).toContain("do NOT create or update a pull request"); + expect(task).toContain("Do NOT create, switch to or rename a branch"); + expect(task).toContain(`'${mode.workingBranch}'`); + } + }); + test("should forbid retitling or editing an existing pull request", async () => { const context = createMockContext({eventName: "issue_comment", isPR: true}); const octokit = createMockOctokit(); @@ -641,8 +719,9 @@ describe("prepareJunieTask", () => { const result = await prepareJunieTask(context, branchInfo, octokit); const task = result.orchestratedTask?.task ?? ""; - expect(task).toContain("it becomes the pull request title"); - expect(task).toContain("Describe the actual code change or the business value"); + expect(task).toContain("the workflow titles the pull request from the issue"); + expect(task).toContain("It names the CHANGE, not your work on it"); + expect(task).toContain("the change or the business value it delivers"); expect(task).toContain("Add export functionality to users module"); expect(task).toContain("Fix NPE in payment processing"); }); @@ -654,12 +733,30 @@ describe("prepareJunieTask", () => { const result = await prepareJunieTask(context, branchInfo, octokit); const task = result.orchestratedTask?.task ?? ""; - expect(task).toContain("Never name your own process"); - for (const banned of ["Step 1", "Implementation", "Deliverables", "Task execution"]) { - expect(task).toContain(`'${banned}'`); + const banned = [ + "Step", "Stage", "Review", "Implementation", + "Validation", "Validation Completeness", "Deliverables", + "Task execution", "Orchestrated", "Final report", + ]; + for (const phrase of banned) { + expect(task).toContain(`'${phrase}'`); } }); + test("should tell every step to name the change, not the step it just finished", async () => { + const context = createMockContext({eventName: "issues"}); + const octokit = createMockOctokit(); + + const result = await prepareJunieTask(context, branchInfo, octokit); + + const task = result.orchestratedTask?.task ?? ""; + // taskName is last-writer-wins across sub-agents, so the rule has to bind each + // report, not just the final one. + expect(task).toContain("at any point, from any step or sub-agent"); + expect(task).toContain("Never name the step you just finished"); + expect(task).toContain("Review Step 1 Implementation and Validation Completeness"); + }); + test("should tell the agent not to add the title prefix itself", async () => { const context = createMockContext({eventName: "issues"}); const octokit = createMockOctokit(); @@ -731,6 +828,40 @@ describe("prepareJunieTask", () => { expect(result.orchestratedTask).toBeUndefined(); }); + test("should export the PR title on a fix-ci run, whose payload has no entity", async () => { + // workflow_run payloads carry only workflow_run.pull_requests[].number, so without + // this the results step has no title and falls back to a generic one. + const context = createMockContext({ + eventName: "workflow_run" as any, + isPR: true, + entityNumber: 123, + inputs: { + ...createMockContext().inputs, + prompt: "fix-ci" + }, + payload: { + action: "completed", + workflow_run: { + id: 12345, + name: "CI", + head_branch: "feature-branch", + head_sha: "abc123", + conclusion: "failure", + pull_requests: [{number: 123}] + }, + repository: { + owner: {login: "owner"}, + name: "repo" + } + } as any + }); + const octokit = createMockOctokit(); + + await prepareJunieTask(context, branchInfo, octokit); + + expect(core.setOutput).toHaveBeenCalledWith("ENTITY_TITLE", "Test PR"); + }); + test("should use goal mode for a fix-ci run", async () => { const context = createMockContext({ eventName: "workflow_run" as any, diff --git a/test/pr-title-resolve.test.ts b/test/pr-title-resolve.test.ts new file mode 100644 index 00000000..57a75737 --- /dev/null +++ b/test/pr-title-resolve.test.ts @@ -0,0 +1,142 @@ +import {describe, expect, test} from "bun:test"; +import {isInternalWorkflowTitle, resolvePrTitle} from "../src/utils/pr-title"; +import {PR_TITLE_TEMPLATE} from "../src/constants/github"; + +describe("isInternalWorkflowTitle", () => { + test("rejects the title goal mode actually produced", () => { + expect(isInternalWorkflowTitle("Review Step 1 Implementation and Validation Completeness")) + .toBe(true); + }); + + test("rejects step and stage names", () => { + const titles = [ + "Step 2: wire up the parser", + "Stage 3 cleanup", + "Reviewing the plan", + "Review of the code changes", + "Implementation of the export feature", + "Validation Completeness", + "Planning the refactor", + "Task execution summary", + "Final report", + "Orchestrated run results", + "Sub-agent output", + "Deliverables", + ]; + + for (const title of titles) { + expect(isInternalWorkflowTitle(title)).toBe(true); + } + }); + + test("rejects inflected forms of the process verbs", () => { + // A step reported as "Reviewed ..." is the same failure as "Review ...". + const titles = [ + "Reviewed implementation completeness", + "Reviewed and updated export logic", + "Implemented export functionality", + "Validated the payment flow", + "Verifying the migration", + "Planned refactor of auth", + "Summarizing changes", + "Analysis of the parser", + "Completed the export work", + ]; + + for (const title of titles) { + expect(isInternalWorkflowTitle(title)).toBe(true); + } + }); + + test("accepts process words that appear mid-title", () => { + // Only a leading process verb signals a step name; these are real titles. + expect(isInternalWorkflowTitle("Add review widget to the dashboard")).toBe(false); + expect(isInternalWorkflowTitle("Remove deprecated validation helper")).toBe(false); + expect(isInternalWorkflowTitle("Plantable seeds parser fix")).toBe(false); + }); + + test("rejects an empty or missing title", () => { + expect(isInternalWorkflowTitle(undefined)).toBe(true); + expect(isInternalWorkflowTitle("")).toBe(true); + expect(isInternalWorkflowTitle(" ")).toBe(true); + }); + + test("accepts titles that describe the change", () => { + const titles = [ + "Add export functionality to users module", + "Fix NPE in payment processing", + "Cache the user lookup to cut p99 latency", + "Add review widget to the dashboard", + "Support pagination in the search endpoint", + ]; + + for (const title of titles) { + expect(isInternalWorkflowTitle(title)).toBe(false); + } + }); +}); + +describe("resolvePrTitle", () => { + const FALLBACK = "Junie finished task successfully"; + + test("prefers the triggering entity title over the agent's task name", () => { + expect(resolvePrTitle( + "Review Step 1 Implementation and Validation Completeness", + "Users cannot export their data", + FALLBACK, + )).toBe("Users cannot export their data"); + }); + + test("prefers the entity title even when the task name looks fine", () => { + // The entity title is stable across runs; taskName is not. Predictability wins. + expect(resolvePrTitle("Add export functionality to users module", "Add CSV export", FALLBACK)) + .toBe("Add CSV export"); + }); + + test("uses the task name when there is no entity to take a title from", () => { + // e.g. workflow_dispatch driven by a bare prompt + expect(resolvePrTitle("Add export functionality to users module", undefined, FALLBACK)) + .toBe("Add export functionality to users module"); + }); + + test("falls back when there is no entity and the task name is a step name", () => { + expect(resolvePrTitle("Review Step 1 Implementation", undefined, FALLBACK)).toBe(FALLBACK); + }); + + test("falls back when the agent reported no title at all", () => { + expect(resolvePrTitle(undefined, undefined, FALLBACK)).toBe(FALLBACK); + }); + + test("trims both sources", () => { + expect(resolvePrTitle(undefined, " Add CSV export ", FALLBACK)).toBe("Add CSV export"); + expect(resolvePrTitle(" Fix NPE in payment processing ", " ", FALLBACK)) + .toBe("Fix NPE in payment processing"); + }); + + test("a step name can never reach the PR title when an issue exists", () => { + const stepNames = [ + "Review Step 1 Implementation and Validation Completeness", + "Reviewed implementation completeness", + "Implemented export functionality", + "Some phrasing nobody predicted", + ]; + + for (const stepName of stepNames) { + expect(PR_TITLE_TEMPLATE(resolvePrTitle(stepName, "Add CSV export to users module", FALLBACK))) + .toBe("[Junie]: Add CSV export to users module"); + } + }); + + test("produces a prefixed, workflow-free PR title end to end", () => { + const resolved = resolvePrTitle( + "Review Step 1 Implementation and Validation Completeness", + "Add CSV export to the users module", + FALLBACK, + ); + const prTitle = PR_TITLE_TEMPLATE(resolved); + + expect(prTitle).toBe("[Junie]: Add CSV export to the users module"); + expect(prTitle.startsWith("[Junie]: ")).toBe(true); + expect(isInternalWorkflowTitle(resolved)).toBe(false); + }); +}); From b3cba9250e492cf2a24cea3562d5966b5e9e4304 Mon Sep 17 00:00:00 2001 From: "Mariia.Fadeeva" Date: Tue, 11 Aug 2026 15:27:03 +0300 Subject: [PATCH 4/6] Fixed title keywords --- test/integration/fix_ci.test.ts | 2 +- test/integration/issue_trigger.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/integration/fix_ci.test.ts b/test/integration/fix_ci.test.ts index ad19abff..71dca0c9 100644 --- a/test/integration/fix_ci.test.ts +++ b/test/integration/fix_ci.test.ts @@ -72,7 +72,7 @@ async function testFixCi(repoName: string, fixCiInComment: (prNumber: number) => await testClient.waitForPR(testClient.conditionIncludes(["Trigger test"])); await fixCiInComment(pr.number); await testClient.waitForJunieComment(pr.number, INIT_COMMENT_BODY); - const titleKeywords = ["ci", "fail", "fix", "workflow"] + const titleKeywords = ["ci", "fail", "fix", "workflow", "junie"] const foundPR = await testClient.waitForPR(testClient.conditionIncludes(titleKeywords)); const result = await testClient.checkPRFiles(foundPR, testClient.conditionPRFilesInclude({[fileName]: "console.log('fail');"})); diff --git a/test/integration/issue_trigger.test.ts b/test/integration/issue_trigger.test.ts index e182606b..58682ab1 100644 --- a/test/integration/issue_trigger.test.ts +++ b/test/integration/issue_trigger.test.ts @@ -36,7 +36,7 @@ describe("Trigger Junie in Issue", () => { await testClient.waitForJunieComment(issueNumber, INIT_COMMENT_BODY); - const titleKeywords = ["greeting", "hello", "requirements"] + const titleKeywords = ["greeting", "hello", "requirements", "function"] const foundPR = await testClient.waitForPR(testClient.conditionIncludes(titleKeywords)); From 48261eaaf43d9f4e5ea2be62156862a856a396a6 Mon Sep 17 00:00:00 2001 From: "Mariia.Fadeeva" Date: Tue, 11 Aug 2026 20:25:41 +0300 Subject: [PATCH 5/6] Added handling for external tracker titles (Jira, YouTrack) to resolve PR and commit titles, updated workflows, and expanded regex handling logic with tests --- src/entrypoints/handle-results.ts | 23 ++++++++++---- src/github/junie/junie-tasks.ts | 18 +++++++++-- src/utils/pr-title.ts | 29 ++++++++++++----- test/junie-tasks.test.ts | 53 +++++++++++++++++++++++++++++++ test/pr-title-resolve.test.ts | 30 +++++++++++++++++ 5 files changed, 137 insertions(+), 16 deletions(-) diff --git a/src/entrypoints/handle-results.ts b/src/entrypoints/handle-results.ts index 843ccf87..e9b7f672 100644 --- a/src/entrypoints/handle-results.ts +++ b/src/entrypoints/handle-results.ts @@ -32,9 +32,14 @@ function getTriggeringEntityTitle(context: JunieExecutionContext): string | unde const payload = context.payload as { issue?: { title?: string }; pull_request?: { title?: string }; + issueSummary?: string; + issueTitle?: string; }; - return payload.pull_request?.title || payload.issue?.title; + return payload.pull_request?.title + || payload.issue?.title + || payload.issueSummary + || payload.issueTitle; } export enum ActionType { @@ -137,7 +142,9 @@ export async function handleResults() { // Sanitize and truncate to prevent ARG_MAX issues const title = truncateOutput(sanitizeJunieOutput(rawTitle, triggerPhrase), OUTPUT_SIZE_LIMITS.TITLE) const body = truncateOutput(sanitizeJunieOutput(rawBody, triggerPhrase), OUTPUT_SIZE_LIMITS.SUMMARY) - let issueId + const rawCommitTitle = junieJsonOutput.taskName?.trim() || rawTitle + const commitTitle = truncateOutput(sanitizeJunieOutput(rawCommitTitle, triggerPhrase), OUTPUT_SIZE_LIMITS.TITLE) + let issueId: number | undefined if (isTriggeredByUserInteraction(context)) { issueId = context.entityNumber } @@ -145,8 +152,8 @@ export async function handleResults() { // Add co-author only for user-triggered events (issues, PRs, comments) // For system-triggered events (schedule, workflow_dispatch), skip co-author const addCoAuthor = isTriggeredByUserInteraction(context); - const commitMessage = COMMIT_MESSAGE_TEMPLATE( - title, + const buildCommitMessage = (subject: string) => COMMIT_MESSAGE_TEMPLATE( + subject, issueId, addCoAuthor ? context.actor : undefined, addCoAuthor ? context.actorEmail : undefined @@ -161,14 +168,18 @@ export async function handleResults() { title, body, durationMs, - commitMessage, + // The branch is new, so this is the pull request's only commit: the two + // subjects should agree. + buildCommitMessage(title), prTitle, prBody); break; case ActionType.COMMIT_AND_PUSH: + exportResultsOutputs(title, body, durationMs, buildCommitMessage(title)); + break; case ActionType.COMMIT_CHANGES: case ActionType.PUSH: - exportResultsOutputs(title, body, durationMs, commitMessage); + exportResultsOutputs(title, body, durationMs, buildCommitMessage(commitTitle)); break; case ActionType.WRITE_COMMENT: case ActionType.NOTHING: diff --git a/src/github/junie/junie-tasks.ts b/src/github/junie/junie-tasks.ts index 0d253aba..cd1c613e 100644 --- a/src/github/junie/junie-tasks.ts +++ b/src/github/junie/junie-tasks.ts @@ -11,7 +11,9 @@ import { isPushEvent, isTriggeredByUserInteraction, isYouTrackWorkflowDispatchEvent, - JunieExecutionContext + JiraIssuePayload, + JunieExecutionContext, + YouTrackIssuePayload } from "../context"; import * as core from "@actions/core"; import * as fs from "node:fs"; @@ -104,6 +106,16 @@ const TITLE_FORMAT_NOTE = "Completeness' is exactly the kind of title that must never be produced.\n" + "- Do NOT prefix it with '[Junie]:' yourself: the workflow adds that."; +function getExternalTrackerTitle(context: JunieExecutionContext): string | undefined { + if (isJiraWorkflowDispatchEvent(context)) { + return (context.payload as JiraIssuePayload).issueSummary; + } + if (isYouTrackWorkflowDispatchEvent(context)) { + return (context.payload as YouTrackIssuePayload).issueTitle; + } + return undefined; +} + function getTriggerTime(context: JunieExecutionContext): string | undefined { if (isIssueCommentEvent(context)) { return context.payload.comment.created_at; @@ -149,7 +161,9 @@ export async function prepareJunieTask( // The results step titles the pull request from this. It is resolved here because // several event payloads (workflow_run for fix-CI, check_suite, schedule) carry the // entity number but not its title, and this is where the entity is already fetched. - const entityTitle = fetchedData.pullRequest?.title || fetchedData.issue?.title; + const entityTitle = fetchedData.pullRequest?.title + || fetchedData.issue?.title + || getExternalTrackerTitle(context); if (entityTitle) { core.setOutput(OUTPUT_VARS.ENTITY_TITLE, entityTitle); } diff --git a/src/utils/pr-title.ts b/src/utils/pr-title.ts index a2e43a22..c52c5341 100644 --- a/src/utils/pr-title.ts +++ b/src/utils/pr-title.ts @@ -19,7 +19,15 @@ * * Single words that also occur in legitimate titles ("Add review widget") are matched only * at the start, which is where a step name lands; unambiguous phrases are matched anywhere. + * + * The bare stem of a process verb is left alone unless it stands like a step name, because + * imperative present is also how a person opens a change description: "Implement dark mode + * toggle" and "Complete migration to Kotlin 2.0" are real titles, while the sub-agents report + * their steps as past tense, gerunds or nouns ("Implemented export functionality", + * "Reviewing the plan", "Analysis of the parser"). */ +const PROCESS_VERB_STEMS = "(re)?view|implement|validate|verify|plan|analy[sz]e|finalize|complete"; + const INTERNAL_WORKFLOW_PATTERNS: RegExp[] = [ /\bstep\s*\d+/i, /\bstage\s*\d+/i, @@ -30,15 +38,20 @@ const INTERNAL_WORKFLOW_PATTERNS: RegExp[] = [ /\borchestrated?\b/i, /\bsub-?agent\b/i, - /^\s*(re)?view(s|ed|ing)?\b/i, - /^\s*implement(s|ed|ing|ation|ations)?\b/i, - /^\s*validat(e|es|ed|ing|ion|ions)\b/i, - /^\s*verif(y|ies|ied|ying|ication)\b/i, - /^\s*plan(s|ned|ning)?\b/i, - /^\s*analy[sz](e|es|ed|ing|is)\b/i, + // Inflected and nominal forms: never how a change is described. + /^\s*(re)?view(s|ed|ing)\b/i, + /^\s*implement(s|ed|ing|ation|ations)\b/i, + /^\s*validat(es|ed|ing|ion|ions)\b/i, + /^\s*verif(ies|ied|ying|ication)\b/i, + /^\s*plan(s|ned|ning)\b/i, + /^\s*analy[sz](es|ed|ing|is)\b/i, /^\s*summar(y|ies|ise|ize|ised|ized|ising|izing)\b/i, - /^\s*finaliz(e|es|ed|ing)\b/i, - /^\s*complet(e|es|ed|ing|ion)\b/i, + /^\s*finaliz(es|ed|ing)\b/i, + /^\s*complet(es|ed|ing|ion)\b/i, + + // Bare stem standing alone or heading a noun phrase: "Verify", "Review: ...", "Review of ...". + new RegExp(`^\\s*(${PROCESS_VERB_STEMS})\\s*([:\\-–—]|$)`, "i"), + new RegExp(`^\\s*(${PROCESS_VERB_STEMS})\\s+of\\b`, "i"), ]; /** diff --git a/test/junie-tasks.test.ts b/test/junie-tasks.test.ts index c624e227..21b6ffaa 100644 --- a/test/junie-tasks.test.ts +++ b/test/junie-tasks.test.ts @@ -862,6 +862,59 @@ describe("prepareJunieTask", () => { expect(core.setOutput).toHaveBeenCalledWith("ENTITY_TITLE", "Test PR"); }); + test("should export the Jira issue summary as the entity title", async () => { + // A Jira dispatch runs in goal mode but has no GitHub entity to fetch a title from, + // so without reading the payload the PR would be named after a sub-agent's step. + const context = createMockContext({ + eventName: "workflow_dispatch" as any, + isPR: false, + entityNumber: undefined, + payload: { + action: "jira_event", + issueKey: "PROJ-42", + issueSummary: "Users cannot export their data", + issueDescription: "The export button does nothing", + comments: [], + attachments: [], + repository: { + owner: {login: "owner"}, + name: "repo" + } + } as any + }); + const octokit = createMockOctokit(); + + await prepareJunieTask(context, branchInfo, octokit); + + expect(core.setOutput).toHaveBeenCalledWith("ENTITY_TITLE", "Users cannot export their data"); + }); + + test("should export the YouTrack issue title as the entity title", async () => { + const context = createMockContext({ + eventName: "workflow_dispatch" as any, + isPR: false, + entityNumber: undefined, + payload: { + action: "youtrack_event", + issueId: "PROJ-7", + issueUrl: "https://youtrack.example.com/issue/PROJ-7", + issueTitle: "Add CSV export to the users module", + issueDescription: "Support exporting the user list", + youtrackBaseUrl: "https://youtrack.example.com", + youtrackToken: "token", + repository: { + owner: {login: "owner"}, + name: "repo" + } + } as any + }); + const octokit = createMockOctokit(); + + await prepareJunieTask(context, branchInfo, octokit); + + expect(core.setOutput).toHaveBeenCalledWith("ENTITY_TITLE", "Add CSV export to the users module"); + }); + test("should use goal mode for a fix-ci run", async () => { const context = createMockContext({ eventName: "workflow_run" as any, diff --git a/test/pr-title-resolve.test.ts b/test/pr-title-resolve.test.ts index 57a75737..71864dc1 100644 --- a/test/pr-title-resolve.test.ts +++ b/test/pr-title-resolve.test.ts @@ -48,6 +48,36 @@ describe("isInternalWorkflowTitle", () => { } }); + test("accepts imperative present, which is how people write change titles", () => { + // The sub-agents report steps in past tense, gerunds or nouns; "Implement X" is a + // normal PR title and must not be thrown away in favour of the generic fallback. + const titles = [ + "Implement dark mode toggle", + "Complete migration to Kotlin 2.0", + "Verify webhook signatures on ingest", + "Validate user input on the signup form", + "Plan B routing for the payment gateway", + "Finalize the export format", + ]; + + for (const title of titles) { + expect(isInternalWorkflowTitle(title)).toBe(false); + } + }); + + test("still rejects a bare process verb or one heading a noun phrase", () => { + const titles = [ + "Implement", + "Review: the code changes", + "Verify - migration", + "Complete of the export work", + ]; + + for (const title of titles) { + expect(isInternalWorkflowTitle(title)).toBe(true); + } + }); + test("accepts process words that appear mid-title", () => { // Only a leading process verb signals a step name; these are real titles. expect(isInternalWorkflowTitle("Add review widget to the dashboard")).toBe(false); From fc1d3c7e2440a6e9fded63ef549092240b9718cd Mon Sep 17 00:00:00 2001 From: "Mariia.Fadeeva" Date: Thu, 13 Aug 2026 12:00:12 +0300 Subject: [PATCH 6/6] Simplified comments and documentation across utility functions; refined PR title resolution and agent artifact exclusion logic --- src/entrypoints/handle-results.ts | 13 +-------- src/github/junie/junie-tasks.ts | 4 +-- src/utils/git-exclude.ts | 38 +++----------------------- src/utils/pr-title.ts | 44 +++---------------------------- 4 files changed, 9 insertions(+), 90 deletions(-) diff --git a/src/entrypoints/handle-results.ts b/src/entrypoints/handle-results.ts index e9b7f672..f17961b4 100644 --- a/src/entrypoints/handle-results.ts +++ b/src/entrypoints/handle-results.ts @@ -15,14 +15,7 @@ import {fetchCodeReviewFeedbackLink} from "../utils/code-review-feedback-link"; import {formatJunieErrors, formatJunieExitCodeNote, resolveJunieOutputFile} from "../utils/junie-failure"; import {resolvePrTitle} from "../utils/pr-title"; -/** - * Title of the issue or pull request the run was triggered from. - * - * The prepare step resolves it and passes it on, because several payloads carry only the - * entity number: a fix-CI run arrives as `workflow_run`, whose payload has no `issue` or - * `pull_request` at all, and the same holds for `check_suite` and `schedule`. Reading the - * payload is the fallback for events that do carry the entity inline. - */ +// Prefers the title resolved by the prepare step, falling back to the event payload. function getTriggeringEntityTitle(context: JunieExecutionContext): string | undefined { const fromPrepare = process.env[OUTPUT_VARS.ENTITY_TITLE]; if (fromPrepare && fromPrepare.trim() !== "") { @@ -131,8 +124,6 @@ export async function handleResults() { const defaultTitle = isResolveConflict ? `Resolve conflicts for ${context.entityNumber} PR` : 'Junie finished task successfully' - // taskName is a live session name the orchestrated sub-agents overwrite in turn, so the - // triggering issue or pull request title is preferred whenever there is one. const rawTitle = isResolveConflict ? (junieJsonOutput.taskName || defaultTitle) : resolvePrTitle(junieJsonOutput.taskName, getTriggeringEntityTitle(context), defaultTitle) @@ -168,8 +159,6 @@ export async function handleResults() { title, body, durationMs, - // The branch is new, so this is the pull request's only commit: the two - // subjects should agree. buildCommitMessage(title), prTitle, prBody); diff --git a/src/github/junie/junie-tasks.ts b/src/github/junie/junie-tasks.ts index cd1c613e..b1e9953c 100644 --- a/src/github/junie/junie-tasks.ts +++ b/src/github/junie/junie-tasks.ts @@ -158,9 +158,7 @@ export async function prepareJunieTask( fetchedData = await fetcher.fetchIssueData(owner, repo, context.entityNumber, triggerTime); } - // The results step titles the pull request from this. It is resolved here because - // several event payloads (workflow_run for fix-CI, check_suite, schedule) carry the - // entity number but not its title, and this is where the entity is already fetched. + // Resolved here (where the entity is already fetched) for the results step to title the PR. const entityTitle = fetchedData.pullRequest?.title || fetchedData.issue?.title || getExternalTrackerTitle(context); diff --git a/src/utils/git-exclude.ts b/src/utils/git-exclude.ts index 345ac141..dc762fdf 100644 --- a/src/utils/git-exclude.ts +++ b/src/utils/git-exclude.ts @@ -2,20 +2,7 @@ import {execSync} from "child_process"; import * as fs from "node:fs"; import * as path from "node:path"; -/** - * Artifacts the agent writes into the checkout while it works, which must never - * end up in a commit. - * - * Goal mode's planning sub-agent stores its plan as a markdown file under - * `/.junie/plans` (the CLI resolves that path against the project - * directory, so it lands inside the repository the action commits from) and - * picks a descriptive name per task, e.g. `add-export-feature.md`. The commit - * step runs `git add .`, so without an exclude those plans are committed - * alongside the real change. - * - * `.junie/memory` is written by the agent for the same reason and is equally - * not part of the task's result. - */ +// Agent scratch files written into the checkout; must never be committed. export const AGENT_ARTIFACT_PATTERNS = [ ".junie/plans/", ".junie/memory/", @@ -23,13 +10,7 @@ export const AGENT_ARTIFACT_PATTERNS = [ const EXCLUDE_HEADER = "# junie-github-action: agent artifacts, not part of the task result"; -/** - * Resolves the directory holding the repository metadata. - * - * `--git-common-dir` rather than `--git-dir`: in a linked worktree the latter - * points at `.git/worktrees/`, which has no `info/exclude` of its own, - * while the common dir is shared by every worktree. - */ +// Uses --git-common-dir so linked worktrees resolve to the shared info/exclude. function resolveGitCommonDir(cwd?: string): string | undefined { try { const gitDir = execSync("git rev-parse --git-common-dir", { @@ -50,19 +31,8 @@ function resolveGitCommonDir(cwd?: string): string | undefined { } /** - * Adds `patterns` to `.git/info/exclude` so the commit step's `git add .` skips them. - * - * `.git/info/exclude` is used rather than `.gitignore` on purpose: it is local to - * the checkout and is itself never committed, so the action does not modify — and - * cannot accidentally commit a change to — the consuming repository's ignore rules. - * - * Note this only keeps *untracked* files out of a commit. A file the repository - * already tracks stays tracked; excludes do not apply to it. - * - * Writing is best-effort: a repository we cannot write the exclude file for should - * not fail the run, so the failure is logged and the task proceeds. - * - * @returns the patterns that were newly appended (empty if all were already present) + * Appends `patterns` to `.git/info/exclude` (best-effort) so the commit step's + * `git add .` skips untracked agent artifacts. Returns the newly added patterns. */ export function addGitExcludePatterns(patterns: string[], cwd?: string): string[] { if (patterns.length === 0) { diff --git a/src/utils/pr-title.ts b/src/utils/pr-title.ts index c52c5341..a1b70bd2 100644 --- a/src/utils/pr-title.ts +++ b/src/utils/pr-title.ts @@ -1,31 +1,4 @@ -/** - * Chooses the title for a pull request the action opens. - * - * The title of the issue or pull request that triggered the run is preferred over anything - * the agent reports. It is written by a person, it always describes the change rather than - * the work, and it is the same on every run — which the agent's own name is not. - * - * The CLI has no field meaning "pull request title". `taskName` is a live session name: - * `OutputWriter` overwrites it on every `AgentTaskNameUpdatedEvent`, and since the emitter - * sits in `AbstractAgentWorker`, every orchestrated sub-agent (plan, code, review, git) - * raises one as it finishes. Whichever ran last wins, so in goal mode `taskName` is that - * sub-agent's summary of its own step — "Review Step 1 Implementation and Validation - * Completeness". Wording varies per run, so no filter over it can be relied on; it is used - * only when there is no issue or pull request to take a title from. - */ - -/** - * Wording that describes the agent's process instead of the code. - * - * Single words that also occur in legitimate titles ("Add review widget") are matched only - * at the start, which is where a step name lands; unambiguous phrases are matched anywhere. - * - * The bare stem of a process verb is left alone unless it stands like a step name, because - * imperative present is also how a person opens a change description: "Implement dark mode - * toggle" and "Complete migration to Kotlin 2.0" are real titles, while the sub-agents report - * their steps as past tense, gerunds or nouns ("Implemented export functionality", - * "Reviewing the plan", "Analysis of the parser"). - */ +// Wording that describes the agent's own process instead of the change. const PROCESS_VERB_STEMS = "(re)?view|implement|validate|verify|plan|analy[sz]e|finalize|complete"; const INTERNAL_WORKFLOW_PATTERNS: RegExp[] = [ @@ -54,9 +27,6 @@ const INTERNAL_WORKFLOW_PATTERNS: RegExp[] = [ new RegExp(`^\\s*(${PROCESS_VERB_STEMS})\\s+of\\b`, "i"), ]; -/** - * Whether `title` names the agent's own process rather than the change. - */ export function isInternalWorkflowTitle(title: string | undefined | null): boolean { if (!title || title.trim() === "") { return true; @@ -66,16 +36,8 @@ export function isInternalWorkflowTitle(title: string | undefined | null): boole } /** - * Picks the title to publish, in order of trustworthiness. - * - * 1. The triggering issue or pull request title — human-written and stable. - * 2. `taskName`, only when there is no such entity (for example a `workflow_dispatch` run - * driven by a bare prompt) and only if it does not describe the agent's own workflow. - * 3. The caller's generic fallback. - * - * @param taskName - `taskName` from the CLI output - * @param entityTitle - Title of the issue or pull request that triggered the run - * @param fallback - Used when neither is usable + * Picks the title to publish: the triggering issue/pull request title first, then + * `taskName` (unless it names the agent's workflow), then the generic fallback. */ export function resolvePrTitle( taskName: string | undefined,