diff --git a/README.md b/README.md index 8fc42bba..3db184c6 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ Full documentation: **[docs.page/vypdev/copilot](https://docs.page/vypdev/copilot)** +*Maintains state and configuration persisted in issue descriptions for seamless workflow automation.* + --- ## Documentation index diff --git a/_agent/docs/architecture.md b/_agent/docs/architecture.md new file mode 100644 index 00000000..555ff7e5 --- /dev/null +++ b/_agent/docs/architecture.md @@ -0,0 +1,37 @@ +--- +name: Architecture +description: Project architecture, entry points, and use case flows. +--- + +# Architecture & Key Paths + +## Entry and main flow + +1. **GitHub Action**: `src/actions/github_action.ts` reads inputs, builds `Execution`, calls `mainRun(execution)` from `common_action.ts`. +2. **CLI**: `src/actions/local_action.ts` same flow with CLI/config inputs. +3. **common_action.ts**: Sets up; calls `waitForPreviousRuns(execution)` (sequential workflow); then: + - **Single action** → `SingleActionUseCase` + - **Issue** → `IssueCommentUseCase` or `IssueUseCase` + - **Pull request** → `PullRequestReviewCommentUseCase` or `PullRequestUseCase` + - **Push** → `CommitUseCase` + +## Key paths + +| Area | Path | Purpose | +|------|------|--------| +| Action entry | `src/actions/github_action.ts` | Reads inputs, builds Execution | +| CLI entry | `src/cli.ts` → `local_action.ts` | Same flow, local inputs | +| Shared flow | `src/actions/common_action.ts` | mainRun, waitForPreviousRuns, dispatch to use cases | +| Use cases | `src/usecase/` | issue_use_case, pull_request_use_case, commit_use_case, single_action_use_case | +| Single actions | `src/usecase/actions/` | check_progress, detect_errors, recommend_steps, think, initial_setup, create_release, create_tag, publish_github_action, deployed_action | +| Steps (issue) | `src/usecase/steps/issue/` | check_permissions, close_not_allowed_issue, assign_members, update_title, update_issue_type, link_issue_project, check_priority_issue_size, prepare_branches, remove_issue_branches, remove_not_needed_branches, label_deploy_added, label_deployed_added, move_issue_to_in_progress, answer_issue_help_use_case (question/help on open). On issue opened: RecommendStepsUseCase (non release/question/help) or AnswerIssueHelpUseCase (question/help). | +| Steps (PR) | `src/usecase/steps/pull_request/` | update_title, assign_members (issue), assign_reviewers_to_issue, link_pr_project, link_pr_issue, sync_size_and_progress_from_issue, check_priority_pull_request_size, update_description (AI), close_issue_after_merging | +| Steps (commit) | `src/usecase/steps/commit/` | notify commit, check size | +| Steps (issue comment) | `src/usecase/steps/issue_comment/` | check_issue_comment_language (translation) | +| Steps (PR review comment) | `src/usecase/steps/pull_request_review_comment/` | check_pull_request_comment_language (translation) | +| Bugbot autofix & user request | `src/usecase/steps/commit/bugbot/` + `user_request_use_case.ts` | detect_bugbot_fix_intent_use_case (plan agent: is_fix_request, is_do_request, target_finding_ids), BugbotAutofixUseCase + runBugbotAutofixCommitAndPush (fix findings), DoUserRequestUseCase + runUserRequestCommitAndPush (generic “do this”). Permission: ProjectRepository.isActorAllowedToModifyFiles (org member or repo owner). | +| Manager (content) | `src/manager/` | description handlers, configuration_handler, markdown_content_hotfix_handler (PR description, hotfix changelog content) | +| Models | `src/data/model/` | Execution, Issue, PullRequest, SingleAction, etc. | +| Repos | `src/data/repository/` | branch_repository, issue_repository, workflow_repository, ai_repository (OpenCode), file_repository, project_repository | +| Config | `src/utils/constants.ts` | INPUT_KEYS, ACTIONS, defaults | +| Metadata | `action.yml` | Action inputs and defaults | diff --git a/_agent/docs/bugbot.md b/_agent/docs/bugbot.md new file mode 100644 index 00000000..5497e9f9 --- /dev/null +++ b/_agent/docs/bugbot.md @@ -0,0 +1,128 @@ +--- +name: Bugbot +description: Detailed technical reference for Bugbot (detection, markers, context, intent, autofix, do user request, permissions) +--- + +# Bugbot – technical reference + +Bugbot has two main modes: **detection** (on push or single action) and **fix/do** (on issue comment or PR review comment). All Bugbot code lives under `src/usecase/steps/commit/bugbot/` and `src/usecase/steps/commit/` (DetectPotentialProblemsUseCase, user_request_use_case). + +--- + +## 1. Detection flow (push or single action) + +**Entry:** `CommitUseCase` (on push) calls `DetectPotentialProblemsUseCase`; or `SingleActionUseCase` when action is `detect_potential_problems_action`. + +**Steps:** + +1. **Guard:** OpenCode must be configured; `issueNumber !== -1`. +2. **Load context:** `loadBugbotContext(param)` → issue comments + PR review comments parsed for markers; builds `existingByFindingId`, `issueComments`, `openPrNumbers`, `previousFindingsBlock`, `prContext`, `unresolvedFindingsWithBody`. Branch is `param.commit.branch` (or `options.branchOverride` when provided). PR context includes `prHeadSha`, `prFiles`, `pathToFirstDiffLine` for the first open PR. +3. **Build prompt:** `buildBugbotPrompt(param, context)` – repo context, head/base branch, issue number, optional `ai-ignore-files`, and `previousFindingsBlock` (task 2: which previous findings are now resolved). OpenCode is asked to compute the diff itself and return `findings` + `resolved_finding_ids`. +4. **Call OpenCode:** `askAgent(OPENCODE_AGENT_PLAN, prompt, BUGBOT_RESPONSE_SCHEMA)`. +5. **Process response:** Filter findings: safe path (`isSafeFindingFilePath`), not in `ai-ignore-files` (`fileMatchesIgnorePatterns`), `meetsMinSeverity` (min from `bugbot-severity`), `deduplicateFindings`. Apply `applyCommentLimit(findings, bugbot-comment-limit)` → `toPublish`, `overflowCount`, `overflowTitles`. +6. **Mark resolved:** `markFindingsResolved(execution, context, resolvedFindingIds, normalizedResolvedIds)` – for each existing finding in context whose id is in resolved set, update issue comment (and PR review comment if any) via `replaceMarkerInBody` to set `resolved:true`; if PR comment, call `resolveReviewThread` when applicable. +7. **Publish:** `publishFindings(execution, context, toPublish, overflowCount?, overflowTitles?)` – for each finding: add or update **issue comment** (always); add or update **PR review comment** only when `finding.file` is in `prContext.prFiles` (using `pathToFirstDiffLine` when finding has no line). Each comment body is built with `buildCommentBody(finding, resolved)` and includes the **marker** ``. Overflow: one extra issue comment summarizing excess findings. + +**Key paths (detection):** + +- `detect_potential_problems_use_case.ts` – orchestration +- `load_bugbot_context_use_case.ts` – issue/PR comments, markers, previousFindingsBlock, prContext +- `build_bugbot_prompt.ts` – prompt for plan agent (task 1: new findings, task 2: resolved ids) +- `schema.ts` – BUGBOT_RESPONSE_SCHEMA (findings, resolved_finding_ids) +- `marker.ts` – BUGBOT_MARKER_PREFIX, buildMarker, parseMarker, replaceMarkerInBody, extractTitleFromBody, buildCommentBody +- `publish_findings_use_case.ts` – add/update issue comment, create/update PR review comment +- `mark_findings_resolved_use_case.ts` – update comment body with resolved marker, resolve PR thread +- `severity.ts`, `file_ignore.ts`, `path_validation.ts`, `limit_comments.ts`, `deduplicate_findings.ts` + +--- + +## 2. Marker format and context + +**Marker:** Hidden HTML comment in every finding comment (issue and PR): + +`` + +- **Parse:** `parseMarker(body)` returns `{ findingId, resolved }[]`. Used when loading context from issue comments and PR review comments. +- **Build:** `buildMarker(findingId, resolved)`. IDs are sanitized (`sanitizeFindingIdForMarker`) so they cannot break HTML (no `-->`, `<`, `>`, newlines, etc.). +- **Update:** `replaceMarkerInBody(body, findingId, newResolved)` – used when marking a finding as resolved (same comment, body updated with `resolved:true`). + +**Context (`BugbotContext`):** + +- `existingByFindingId[id]`: `{ issueCommentId?, prCommentId?, prNumber?, resolved }` – from parsing all issue + PR comments for markers. +- `issueComments`: raw list from API (for body when building previousFindingsBlock / unresolvedFindingsWithBody). +- `openPrNumbers`, `previousFindingsBlock`, `prContext` (prHeadSha, prFiles, pathToFirstDiffLine), `unresolvedFindingsWithBody`: `{ id, fullBody }[]` for findings that are not resolved (body truncated to MAX_FINDING_BODY_LENGTH when loading). + +--- + +## 3. Fix intent and file-modifying actions (issue comment / PR review comment) + +**Entry:** `IssueCommentUseCase` or `PullRequestReviewCommentUseCase` (after language check). + +**Steps:** + +1. **Intent:** `DetectBugbotFixIntentUseCase.invoke(param)` + - Guards: OpenCode configured, issue number set, comment body non-empty, branch (or branchOverride from `getHeadBranchForIssue` when commit.branch empty). + - `loadBugbotContext(param, { branchOverride })` → unresolved findings. + - Build `UnresolvedFindingSummary[]` (id, title from `extractTitleFromBody`, description = fullBody.slice(0, 4000)). + - If PR review comment and `commentInReplyToId`: fetch parent comment body (`getPullRequestReviewCommentBody`), slice(0,1500).trim for prompt. + - `buildBugbotFixIntentPrompt(commentBody, unresolvedFindings, parentCommentBody?)` → prompt asks: is_fix_request?, target_finding_ids?, is_do_request? + - `askAgent(OPENCODE_AGENT_PLAN, prompt, BUGBOT_FIX_INTENT_RESPONSE_SCHEMA)` → `{ is_fix_request, target_finding_ids, is_do_request }`. + - Payload: `isFixRequest`, `isDoRequest`, `targetFindingIds` (filtered to valid unresolved ids), `context`, `branchOverride`. + +2. **Permission:** `ProjectRepository.isActorAllowedToModifyFiles(owner, actor, token)`. + - If owner is Organization: `orgs.checkMembershipForUser` (204 = allowed). + - If owner is User: allowed only if `actor === owner`. + +3. **Branch A – Bugbot autofix** (when `canRunBugbotAutofix(payload)` and `allowedToModifyFiles`): + - `BugbotAutofixUseCase.invoke({ execution, targetFindingIds, userComment, context, branchOverride })` + - Load context if not provided; filter targets to valid unresolved ids; `buildBugbotFixPrompt(...)` with repo, findings block (truncated fullBody per finding), user comment, verify commands; `copilotMessage(ai, prompt)` (build agent). + - If success: `runBugbotAutofixCommitAndPush(execution, { branchOverride, targetFindingIds })` – optional checkout if branchOverride, run verify commands (from `getBugbotFixVerifyCommands`, max 20), git add/commit/push (message `fix(#N): bugbot autofix - resolve ...`). + - If committed and context: `markFindingsResolved({ execution, context, resolvedFindingIds, normalizedResolvedIds })`. + +4. **Branch B – Do user request** (when `!runAutofix && canRunDoUserRequest(payload)` and `allowedToModifyFiles`): + - `DoUserRequestUseCase.invoke({ execution, userComment, branchOverride })` + - `buildUserRequestPrompt(execution, userComment)` – repo context + sanitized user request; `copilotMessage(ai, prompt)`. + - If success: `runUserRequestCommitAndPush(execution, { branchOverride })` – same verify/checkout/add/commit/push with message `chore(#N): apply user request` or `chore: apply user request`. + +5. **Think** (when no file-modifying action ran): `ThinkUseCase.invoke(param)` – answers the user (e.g. question). + +**Key paths (fix/do):** + +- `detect_bugbot_fix_intent_use_case.ts` – intent detection, branch resolution for issue_comment +- `build_bugbot_fix_intent_prompt.ts` – prompt for is_fix_request / is_do_request / target_finding_ids +- `bugbot_fix_intent_payload.ts` – getBugbotFixIntentPayload, canRunBugbotAutofix, canRunDoUserRequest +- `schema.ts` – BUGBOT_FIX_INTENT_RESPONSE_SCHEMA (is_fix_request, target_finding_ids, is_do_request) +- `bugbot_autofix_use_case.ts` – build prompt, copilotMessage (build agent) +- `build_bugbot_fix_prompt.ts` – fix prompt (findings block, verify commands, truncate finding body to MAX_FINDING_BODY_LENGTH) +- `bugbot_autofix_commit.ts` – runBugbotAutofixCommitAndPush, runUserRequestCommitAndPush (checkout, verify commands max 20, git config, add, commit, push) +- `user_request_use_case.ts` – DoUserRequestUseCase, buildUserRequestPrompt +- `mark_findings_resolved_use_case.ts` – update issue/PR comment with resolved marker +- `project_repository.ts` – isActorAllowedToModifyFiles + +--- + +## 4. Configuration (inputs / Ai model) + +- **bugbot-severity:** Minimum severity to publish (info, low, medium, high). Default low. `getBugbotMinSeverity()`, `normalizeMinSeverity`, `meetsMinSeverity`. +- **bugbot-comment-limit:** Max individual finding comments per issue/PR (overflow gets one summary). Default 20. `getBugbotCommentLimit()`, `applyCommentLimit`. +- **bugbot-fix-verify-commands:** Comma-separated commands run after autofix (and do user request) before commit. `getBugbotFixVerifyCommands()`, parsed with shell-quote; max 20 executed. Stored in `Ai` model; read in `github_action.ts` / `local_action.ts`. +- **ai-ignore-files:** Exclude paths from detection (and from reporting). Used in buildBugbotPrompt and in filtering findings. + +--- + +## 5. Constants and types + +- `BUGBOT_MARKER_PREFIX`: `'copilot-bugbot'` +- `BUGBOT_MAX_COMMENTS`: 20 (default limit) +- `MAX_FINDING_BODY_LENGTH`: 12000 (truncation when loading context and in build_bugbot_fix_prompt) +- `MAX_VERIFY_COMMANDS`: 20 (in bugbot_autofix_commit) +- Types: `BugbotContext`, `BugbotFinding` (id, title, description, file?, line?, severity?, suggestion?), `UnresolvedFindingSummary`, `BugbotFixIntentPayload`. + +--- + +## 6. Sanitization and safety + +- **User comment in prompts:** `sanitizeUserCommentForPrompt(raw)` – trim, escape backslashes, replace `"""`, truncate 4000 with no lone trailing backslash. +- **Finding body in prompts:** `truncateFindingBody(body, MAX_FINDING_BODY_LENGTH)` with suffix `[... truncated for length ...]` (used in load_bugbot_context and build_bugbot_fix_prompt). +- **Verify commands:** Parsed with shell-quote; no shell operators (;, |, etc.); max 20 run. +- **Path:** `isSafeFindingFilePath` (no null byte, no `..`, no absolute); PR review comment only if file in `prFiles`. diff --git a/_agent/docs/code-conventions.md b/_agent/docs/code-conventions.md new file mode 100644 index 00000000..dfe50c53 --- /dev/null +++ b/_agent/docs/code-conventions.md @@ -0,0 +1,33 @@ +--- +name: Code Conventions +description: Copilot – coding conventions and where to change things +--- + +# Code Conventions + +## Logging and constants + +- Use **logger**: `logInfo`, `logError`, `logDebugInfo` from `src/utils/logger`. No ad-hoc `console.log`. +- Use **constants**: `INPUT_KEYS` and `ACTIONS` from `src/utils/constants.ts` for input names and action names. No hardcoded strings for these. + +## Adding a new action input + +1. **`action.yml`**: Add the input with `description` and `default` (if any). +2. **`src/utils/constants.ts`**: Add the key to `INPUT_KEYS` (e.g. `NEW_INPUT: 'new-input'`). +3. **`src/actions/github_action.ts`**: Read the input (e.g. `core.getInput(INPUT_KEYS.NEW_INPUT)`) and pass it into the object used to build `Execution`. +4. **Optional**: If the CLI must support it, add to `local_action.ts` and the corresponding CLI option. + +## Where to change content/descriptions + +- **PR description** (template filling, AI content): `src/manager/description/` (configuration_handler, content interfaces). +- **Hotfix/release changelog** (markdown extraction, formatting): `src/manager/description/markdown_content_hotfix_handler.ts`. + +## Build and bundles + +- The project uses **`@vercel/ncc`** to bundle the action and CLI. Keep imports and dependencies compatible with ncc (no dynamic requires that ncc cannot see). +- **Do not** edit or rely on `build/`; it is generated. Run tests and lint only on `src/`. + +## Style and lint + +- Prefer TypeScript; avoid `any` (lint rule: no-explicit-any). +- Run `npm run lint` before committing; use `npm run lint:fix` when possible. diff --git a/_agent/docs/commit-messages.md b/_agent/docs/commit-messages.md new file mode 100644 index 00000000..f62c894d --- /dev/null +++ b/_agent/docs/commit-messages.md @@ -0,0 +1,32 @@ +--- +name: Commit Messages +description: Commit message nomenclature (prefix = current branch) +--- + +# Commit messages + +The commit message must use the **current branch name as prefix**, with any `/` replaced by `-`. + +## Format + +``` +: + +[optional body] +``` + +- **Prefix**: current branch name, replacing `/` with `-`. Example: branch `feature/292-github-action-rename` → prefix `feature-292-github-action-rename`. +- **Description**: imperative, lowercase, no trailing period. You may optionally include conventional type (`feat`, `fix`, `docs`, etc.) in the description. + +## Examples + +Branch `feature-292-github-action-rename`: + +- `feature-292-github-action-rename: add concurrency and permissions to ci_check` +- `feature-292-github-action-rename: fix docs callouts (Info instead of Note/Tip)` + +Branch `fix/123-docs-anchor` (normalized to `fix-123-docs-anchor`): + +- `fix-123-docs-anchor: remove invalid MDX heading anchors` + +When suggesting or writing a commit message, use the current branch with `/` replaced by `-` as the prefix. If you don't know the branch, tell the user to use their branch name as the prefix. diff --git a/_agent/docs/project-context.md b/_agent/docs/project-context.md new file mode 100644 index 00000000..ca463655 --- /dev/null +++ b/_agent/docs/project-context.md @@ -0,0 +1,40 @@ +--- +name: Project Context +description: Copilot – quick read, commands, and where to find more +--- + +# Copilot – Project Context + +## Quick read (for fast understanding) + +- **What it is**: GitHub Action + CLI that automates Git-Flow: creates branches from issue labels, links issues/PRs to projects, tracks commits; AI via OpenCode (progress, errors, PR descriptions). +- **Entry points**: GitHub Action → `src/actions/github_action.ts`; CLI → `src/cli.ts`. Shared logic in `src/actions/common_action.ts` (single actions vs issue/PR/push). +- **Do**: Use Node 20, run from repo root; edit only `src/`; use `INPUT_KEYS`/`ACTIONS` and `logInfo`/`logError`/`logDebugInfo`. When adding inputs: update `action.yml`, `constants.ts` (INPUT_KEYS), and `github_action.ts` (and optionally `local_action.ts`). +- **Don’t**: Edit or depend on `build/` (generated by `ncc`); run tests/lint on `build/`. + +## Commands (repo root) + +```bash +nvm use 20 +npm install +npm run build +npm test +npm run test:watch +npm run test:coverage +npm run lint +npm run lint:fix +``` + +- **Build**: `npm run build` → bundles `github_action.ts` and `cli.ts` into `build/`. +- **Tests**: Jest; `npm run test:watch` / `npm run test:coverage` as needed. +- **Lint**: ESLint + typescript-eslint on `src/`; `npm run lint:fix` to auto-fix. + +## What to ignore + +- **`build/`** – Generated output; do not edit or run tests/lint against it. +- **`.agent-sessions/`** – Session data; ignore unless debugging. + +## Other rules + +- **Architecture & paths**: see `architecture.md` (entry points, use cases, single actions, key files). +- **Code conventions**: see `code-conventions.md` (logger, constants, adding inputs, ncc). diff --git a/_agent/docs/usecase-flows.md b/_agent/docs/usecase-flows.md new file mode 100644 index 00000000..a729947c --- /dev/null +++ b/_agent/docs/usecase-flows.md @@ -0,0 +1,148 @@ +--- +name: Use Case Flows +description: Schematic overview of all use case flows (common_action → use case → steps) +--- + +# Use case flows (schematic) + +Entry point: `mainRun(execution)` in `src/actions/common_action.ts`. After `execution.setup()` and optionally `waitForPreviousRuns`, the dispatch is: + +``` +mainRun +├── runnedByToken && singleAction → SingleActionUseCase (only if validSingleAction) +├── issueNumber === -1 → SingleActionUseCase (only if isSingleActionWithoutIssue) or skip +├── welcome → log boxen and continue +└── try: + ├── isSingleAction → SingleActionUseCase + ├── isIssue → issue.isIssueComment ? IssueCommentUseCase : IssueUseCase + ├── isPullRequest → pullRequest.isPullRequestReviewComment ? PullRequestReviewCommentUseCase : PullRequestUseCase + ├── isPush → CommitUseCase + └── else → core.setFailed +``` + +--- + +## 1. IssueUseCase (`on: issues`, not a comment) + +**Step order:** + +1. **CheckPermissionsUseCase** → if it fails (not allowed): CloseNotAllowedIssueUseCase and return. +2. **RemoveIssueBranchesUseCase** (only if `cleanIssueBranches`). +3. **AssignMemberToIssueUseCase** +4. **UpdateTitleUseCase** +5. **UpdateIssueTypeUseCase** +6. **LinkIssueProjectUseCase** +7. **CheckPriorityIssueSizeUseCase** +8. **PrepareBranchesUseCase** (if `isBranched`) **or** **RemoveIssueBranchesUseCase** (if not). +9. **RemoveNotNeededBranchesUseCase** +10. **DeployAddedUseCase** (deploy label) +11. **DeployedAddedUseCase** (deployed label) +12. If **issue.opened**: + - If not release and not question/help → **RecommendStepsUseCase** + - If question or help → **AnswerIssueHelpUseCase** + +--- + +## 2. IssueCommentUseCase (`on: issue_comment`) + +**Step order:** + +1. **CheckIssueCommentLanguageUseCase** (translation) +2. **DetectBugbotFixIntentUseCase** → payload: `isFixRequest`, `isDoRequest`, `targetFindingIds`, `context`, `branchOverride` +3. **ProjectRepository.isActorAllowedToModifyFiles(owner, actor, token)** (permission to modify files) +4. Branch A – **if runAutofix && allowed**: + - **BugbotAutofixUseCase** → **runBugbotAutofixCommitAndPush** → if committed: **markFindingsResolved** +5. Branch B – **if !runAutofix && canRunDoUserRequest && allowed**: + - **DoUserRequestUseCase** → **runUserRequestCommitAndPush** +6. **If no file-modifying action ran** → **ThinkUseCase** + +--- + +## 3. PullRequestReviewCommentUseCase (`on: pull_request_review_comment`) + +Same flow as **IssueCommentUseCase**, with: + +- CheckIssueCommentLanguageUseCase → **CheckPullRequestCommentLanguageUseCase** +- User comment: `param.pullRequest.commentBody` +- DetectBugbotFixIntentUseCase may use **parent comment** (commentInReplyToId) in the prompt. + +--- + +## 4. PullRequestUseCase (`on: pull_request`, not a review comment) + +**Branches by PR state:** + +- **pullRequest.isOpened**: + 1. UpdateTitleUseCase + 2. AssignMemberToIssueUseCase + 3. AssignReviewersToIssueUseCase + 4. LinkPullRequestProjectUseCase + 5. LinkPullRequestIssueUseCase + 6. SyncSizeAndProgressLabelsFromIssueToPrUseCase + 7. CheckPriorityPullRequestSizeUseCase + 8. If AI PR description: **UpdatePullRequestDescriptionUseCase** + +- **pullRequest.isSynchronize** (new pushes): + - If AI PR description: **UpdatePullRequestDescriptionUseCase** + +- **pullRequest.isClosed && isMerged**: + - **CloseIssueAfterMergingUseCase** + +--- + +## 5. CommitUseCase (`on: push`) + +**Precondition:** `param.commit.commits.length > 0` (if 0, return with no steps). + +**Order:** + +1. **NotifyNewCommitOnIssueUseCase** +2. **CheckChangesIssueSizeUseCase** +3. **CheckProgressUseCase** (OpenCode: progress + size labels on issue and PRs) +4. **DetectPotentialProblemsUseCase** (Bugbot: detection, publish to issue/PR, resolved markers) + +--- + +## 6. SingleActionUseCase + +Invoked when: +- `runnedByToken && isSingleAction && validSingleAction`, or +- `issueNumber === -1 && isSingleAction && isSingleActionWithoutIssue`, or +- `isSingleAction` in the main try block. + +**Dispatch by action (one per run):** + +| Action | Use case | +|--------|----------| +| `deployed_action` | DeployedActionUseCase | +| `publish_github_action` | PublishGithubActionUseCase | +| `create_release` | CreateReleaseUseCase | +| `create_tag` | CreateTagUseCase | +| `think_action` | ThinkUseCase | +| `initial_setup` | InitialSetupUseCase | +| `check_progress_action` | CheckProgressUseCase | +| `detect_potential_problems_action` | DetectPotentialProblemsUseCase | +| `recommend_steps_action` | RecommendStepsUseCase | + +(Action names in constants: check_progress_action, detect_potential_problems_action, recommend_steps_action.) + +--- + +## 7. Summary by event + +| Event | Use case | Schematic content | +|--------|----------|------------------------| +| **issues** (opened/edited/labeled…) | IssueUseCase | Permissions → close if not ok; branches; assign; title; issue type; project; priority/size; prepare/remove branches; deploy labels; if opened: recommend steps or answer help. | +| **issue_comment** | IssueCommentUseCase | Language → intent (fix/do) → permission → [BugbotAutofix + commit + mark] or [DoUserRequest + commit] or Think. | +| **pull_request** (opened/sync/closed) | PullRequestUseCase | Title, assign, reviewers, project, link issue, sync labels, size, [AI description]; if merged: close issue. | +| **pull_request_review_comment** | PullRequestReviewCommentUseCase | Same as IssueCommentUseCase (language → intent → permission → autofix/do/Think). | +| **push** | CommitUseCase | Notify commit → size → progress (OpenCode) → bugbot detect (OpenCode). | +| **single-action** | SingleActionUseCase | One of: deployed, publish_github_action, create_release, create_tag, think, initial_setup, check_progress, detect_potential_problems, recommend_steps. | + +--- + +## 8. Flow dependencies + +- **Bugbot autofix / Do user request**: require OpenCode, `isActorAllowedToModifyFiles` (org member or repo owner), and on issue_comment optionally branch from PR (`getHeadBranchForIssue`). +- **Think**: used in IssueComment and PullRequestReviewComment when neither autofix nor do user request runs (by intent or by permission). +- **CommitUseCase**: NotifyNewCommitOnIssue, CheckChangesIssueSize, CheckProgress, DetectPotentialProblems (bugbot) always run in that order on every push with commits. diff --git a/build/cli/index.js b/build/cli/index.js index b7f4365c..fcaa4fa0 100755 --- a/build/cli/index.js +++ b/build/cli/index.js @@ -48174,9 +48174,21 @@ class Execution { } this.previousConfiguration = await new configuration_handler_1.ConfigurationHandler().get(this); /** - * Get labels of issue + * Get labels of issue (skip if it's the initial setup and it fails) */ - this.labels.currentIssueLabels = await issueRepository.getLabels(this.owner, this.repo, this.issueNumber, this.tokens.token); + try { + this.labels.currentIssueLabels = await issueRepository.getLabels(this.owner, this.repo, this.issueNumber, this.tokens.token); + } + catch (error) { + const isInitialSetup = this.singleAction.currentSingleAction === constants_1.ACTIONS.INITIAL_SETUP; + if (this.isSingleAction && isInitialSetup) { + (0, logger_1.logDebugInfo)('Skipping initial labels fetch for setup action.'); + this.labels.currentIssueLabels = []; + } + else { + throw error; + } + } /** * Contains release label */ @@ -48211,10 +48223,14 @@ class Execution { } else { this.currentConfiguration.parentBranch = this.previousConfiguration?.parentBranch; + this.currentConfiguration.workingBranch = this.previousConfiguration?.workingBranch; } if (this.currentConfiguration.parentBranch === undefined && this.previousConfiguration?.parentBranch != null) { this.currentConfiguration.parentBranch = this.previousConfiguration.parentBranch; } + if (this.currentConfiguration.workingBranch === undefined && this.previousConfiguration?.workingBranch != null) { + this.currentConfiguration.workingBranch = this.previousConfiguration.workingBranch; + } if (this.isSingleAction) { /** * Nothing to do here (for now) @@ -48269,6 +48285,9 @@ class Execution { this.labels.currentPullRequestLabels = await issueRepository.getLabels(this.owner, this.repo, this.pullRequest.number, this.tokens.token); this.release.active = this.pullRequest.base.indexOf(`${this.branches.releaseTree}/`) > -1; this.hotfix.active = this.pullRequest.base.indexOf(`${this.branches.hotfixTree}/`) > -1; + if (!this.currentConfiguration.parentBranch) { + this.currentConfiguration.parentBranch = this.pullRequest.base; + } } this.currentConfiguration.branchType = this.issueType; // logDebugInfo(`Current configuration: ${JSON.stringify(this.currentConfiguration, null, 2)}`); @@ -49422,8 +49441,8 @@ function logPartsForDebug(parts, context) { } }); } -/** Default OpenCode agent for analysis/planning (read-only, no file edits). */ -exports.OPENCODE_AGENT_PLAN = 'plan'; +/** Default OpenCode agent for analysis/planning (read-only, no file edits). Changed to build to support diffs. */ +exports.OPENCODE_AGENT_PLAN = 'build'; /** OpenCode agent with write/edit/bash for development (e.g. copilot when run locally). */ exports.OPENCODE_AGENT_BUILD = 'build'; /** JSON schema for translation responses: translatedText (required), optional reason if translation failed. */ @@ -50613,12 +50632,22 @@ class IssueRepository { return []; } const octokit = github.getOctokit(token); - const { data: labels } = await octokit.rest.issues.listLabelsOnIssue({ - owner: owner, - repo: repository, - issue_number: issueNumber, - }); - return labels.map(label => label.name); + try { + const { data: labels } = await octokit.rest.issues.listLabelsOnIssue({ + owner: owner, + repo: repository, + issue_number: issueNumber, + }); + return labels.map(label => label.name); + } + catch (error) { + if (error.status === 404) { + (0, logger_1.logDebugInfo)(`Issue #${issueNumber} not found or no access; returning empty labels.`); + return []; + } + (0, logger_1.logError)(`Error fetching labels for issue #${issueNumber}: ${error}`); + return []; + } }; this.setLabels = async (owner, repository, issueNumber, labels, token) => { const octokit = github.getOctokit(token); @@ -52750,10 +52779,11 @@ class ContentInterface { if (description === undefined) { return undefined; } - if (description.indexOf(this.startPattern) === -1 || description.indexOf(this.endPattern) === -1) { + const indices = this.getBlockIndices(description); + if (!indices) { return undefined; } - return description.split(this.startPattern)[1].split(this.endPattern)[0]; + return description.substring(indices.contentStart, indices.endIndex); } catch (error) { (0, logger_1.logError)(`Error reading issue configuration: ${error}`); @@ -52770,13 +52800,14 @@ class ContentInterface { } }; this._updateContent = (description, content) => { - if (description.indexOf(this.startPattern) === -1 || description.indexOf(this.endPattern) === -1) { + const indices = this.getBlockIndices(description); + if (!indices) { (0, logger_1.logError)(`The content has a problem with open-close tags: ${this.startPattern} / ${this.endPattern}`); return undefined; } - const start = description.split(this.startPattern)[0]; + const start = description.substring(0, indices.startIndex); const mid = `${this.startPattern}\n${content}\n${this.endPattern}`; - const end = description.split(this.endPattern)[1]; + const end = description.substring(indices.endIndex + this.endPattern.length); return `${start}${mid}${end}`; }; this.updateContent = (description, content) => { @@ -52811,6 +52842,18 @@ class ContentInterface { } return `${this._id}-end -->`; } + getBlockIndices(description) { + const startIndex = description.indexOf(this.startPattern); + if (startIndex === -1) { + return undefined; + } + const contentStart = startIndex + this.startPattern.length; + const endIndex = description.indexOf(this.endPattern, contentStart); + if (endIndex === -1) { + return undefined; + } + return { startIndex, contentStart, endIndex }; + } } exports.ContentInterface = ContentInterface; @@ -52916,15 +52959,6 @@ exports.ConfigurationHandler = void 0; const config_1 = __nccwpck_require__(1106); const logger_1 = __nccwpck_require__(8836); const issue_content_interface_1 = __nccwpck_require__(9913); -/** Keys that must be preserved from stored config when current has undefined (e.g. when branch already existed). */ -const CONFIG_KEYS_TO_PRESERVE = [ - 'parentBranch', - 'workingBranch', - 'releaseBranch', - 'hotfixBranch', - 'hotfixOriginBranch', - 'branchType', -]; class ConfigurationHandler extends issue_content_interface_1.IssueContentInterface { constructor() { super(...arguments); @@ -52938,14 +52972,14 @@ class ConfigurationHandler extends issue_content_interface_1.IssueContentInterfa parentBranch: current.parentBranch, hotfixOriginBranch: current.hotfixOriginBranch, hotfixBranch: current.hotfixBranch, - results: current.results, branchConfiguration: current.branchConfiguration, }; const storedRaw = await this.internalGetter(execution); if (storedRaw != null && storedRaw.trim().length > 0) { try { const stored = JSON.parse(storedRaw); - for (const key of CONFIG_KEYS_TO_PRESERVE) { + // Merge all fields from stored that are undefined in current payload + for (const key in stored) { if (payload[key] === undefined && stored[key] !== undefined) { payload[key] = stored[key]; } @@ -52955,6 +52989,8 @@ class ConfigurationHandler extends issue_content_interface_1.IssueContentInterfa /* ignore parse errors, save current as-is */ } } + // Ensure results is never saved to prevent payload bloat + delete payload['results']; return await this.internalUpdate(execution, JSON.stringify(payload, null, 4)); } catch (error) { diff --git a/build/cli/src/data/repository/ai_repository.d.ts b/build/cli/src/data/repository/ai_repository.d.ts index e9c48bd4..2e8f9927 100644 --- a/build/cli/src/data/repository/ai_repository.d.ts +++ b/build/cli/src/data/repository/ai_repository.d.ts @@ -1,6 +1,6 @@ import { Ai } from '../model/ai'; -/** Default OpenCode agent for analysis/planning (read-only, no file edits). */ -export declare const OPENCODE_AGENT_PLAN = "plan"; +/** Default OpenCode agent for analysis/planning (read-only, no file edits). Changed to build to support diffs. */ +export declare const OPENCODE_AGENT_PLAN = "build"; /** OpenCode agent with write/edit/bash for development (e.g. copilot when run locally). */ export declare const OPENCODE_AGENT_BUILD = "build"; /** JSON schema for translation responses: translatedText (required), optional reason if translation failed. */ diff --git a/build/cli/src/manager/description/base/content_interface.d.ts b/build/cli/src/manager/description/base/content_interface.d.ts index 0135f3a8..ac690594 100644 --- a/build/cli/src/manager/description/base/content_interface.d.ts +++ b/build/cli/src/manager/description/base/content_interface.d.ts @@ -4,6 +4,7 @@ export declare abstract class ContentInterface { abstract get visibleContent(): boolean; private get startPattern(); private get endPattern(); + private getBlockIndices; getContent: (description: string | undefined) => string | undefined; private _addContent; private _updateContent; diff --git a/build/github_action/index.js b/build/github_action/index.js index 1a9776e6..f5cc571c 100644 --- a/build/github_action/index.js +++ b/build/github_action/index.js @@ -43273,9 +43273,21 @@ class Execution { } this.previousConfiguration = await new configuration_handler_1.ConfigurationHandler().get(this); /** - * Get labels of issue + * Get labels of issue (skip if it's the initial setup and it fails) */ - this.labels.currentIssueLabels = await issueRepository.getLabels(this.owner, this.repo, this.issueNumber, this.tokens.token); + try { + this.labels.currentIssueLabels = await issueRepository.getLabels(this.owner, this.repo, this.issueNumber, this.tokens.token); + } + catch (error) { + const isInitialSetup = this.singleAction.currentSingleAction === constants_1.ACTIONS.INITIAL_SETUP; + if (this.isSingleAction && isInitialSetup) { + (0, logger_1.logDebugInfo)('Skipping initial labels fetch for setup action.'); + this.labels.currentIssueLabels = []; + } + else { + throw error; + } + } /** * Contains release label */ @@ -43310,10 +43322,14 @@ class Execution { } else { this.currentConfiguration.parentBranch = this.previousConfiguration?.parentBranch; + this.currentConfiguration.workingBranch = this.previousConfiguration?.workingBranch; } if (this.currentConfiguration.parentBranch === undefined && this.previousConfiguration?.parentBranch != null) { this.currentConfiguration.parentBranch = this.previousConfiguration.parentBranch; } + if (this.currentConfiguration.workingBranch === undefined && this.previousConfiguration?.workingBranch != null) { + this.currentConfiguration.workingBranch = this.previousConfiguration.workingBranch; + } if (this.isSingleAction) { /** * Nothing to do here (for now) @@ -43368,6 +43384,9 @@ class Execution { this.labels.currentPullRequestLabels = await issueRepository.getLabels(this.owner, this.repo, this.pullRequest.number, this.tokens.token); this.release.active = this.pullRequest.base.indexOf(`${this.branches.releaseTree}/`) > -1; this.hotfix.active = this.pullRequest.base.indexOf(`${this.branches.hotfixTree}/`) > -1; + if (!this.currentConfiguration.parentBranch) { + this.currentConfiguration.parentBranch = this.pullRequest.base; + } } this.currentConfiguration.branchType = this.issueType; // logDebugInfo(`Current configuration: ${JSON.stringify(this.currentConfiguration, null, 2)}`); @@ -44503,8 +44522,8 @@ function logPartsForDebug(parts, context) { } }); } -/** Default OpenCode agent for analysis/planning (read-only, no file edits). */ -exports.OPENCODE_AGENT_PLAN = 'plan'; +/** Default OpenCode agent for analysis/planning (read-only, no file edits). Changed to build to support diffs. */ +exports.OPENCODE_AGENT_PLAN = 'build'; /** OpenCode agent with write/edit/bash for development (e.g. copilot when run locally). */ exports.OPENCODE_AGENT_BUILD = 'build'; /** JSON schema for translation responses: translatedText (required), optional reason if translation failed. */ @@ -45694,12 +45713,22 @@ class IssueRepository { return []; } const octokit = github.getOctokit(token); - const { data: labels } = await octokit.rest.issues.listLabelsOnIssue({ - owner: owner, - repo: repository, - issue_number: issueNumber, - }); - return labels.map(label => label.name); + try { + const { data: labels } = await octokit.rest.issues.listLabelsOnIssue({ + owner: owner, + repo: repository, + issue_number: issueNumber, + }); + return labels.map(label => label.name); + } + catch (error) { + if (error.status === 404) { + (0, logger_1.logDebugInfo)(`Issue #${issueNumber} not found or no access; returning empty labels.`); + return []; + } + (0, logger_1.logError)(`Error fetching labels for issue #${issueNumber}: ${error}`); + return []; + } }; this.setLabels = async (owner, repository, issueNumber, labels, token) => { const octokit = github.getOctokit(token); @@ -47831,10 +47860,11 @@ class ContentInterface { if (description === undefined) { return undefined; } - if (description.indexOf(this.startPattern) === -1 || description.indexOf(this.endPattern) === -1) { + const indices = this.getBlockIndices(description); + if (!indices) { return undefined; } - return description.split(this.startPattern)[1].split(this.endPattern)[0]; + return description.substring(indices.contentStart, indices.endIndex); } catch (error) { (0, logger_1.logError)(`Error reading issue configuration: ${error}`); @@ -47851,13 +47881,14 @@ class ContentInterface { } }; this._updateContent = (description, content) => { - if (description.indexOf(this.startPattern) === -1 || description.indexOf(this.endPattern) === -1) { + const indices = this.getBlockIndices(description); + if (!indices) { (0, logger_1.logError)(`The content has a problem with open-close tags: ${this.startPattern} / ${this.endPattern}`); return undefined; } - const start = description.split(this.startPattern)[0]; + const start = description.substring(0, indices.startIndex); const mid = `${this.startPattern}\n${content}\n${this.endPattern}`; - const end = description.split(this.endPattern)[1]; + const end = description.substring(indices.endIndex + this.endPattern.length); return `${start}${mid}${end}`; }; this.updateContent = (description, content) => { @@ -47892,6 +47923,18 @@ class ContentInterface { } return `${this._id}-end -->`; } + getBlockIndices(description) { + const startIndex = description.indexOf(this.startPattern); + if (startIndex === -1) { + return undefined; + } + const contentStart = startIndex + this.startPattern.length; + const endIndex = description.indexOf(this.endPattern, contentStart); + if (endIndex === -1) { + return undefined; + } + return { startIndex, contentStart, endIndex }; + } } exports.ContentInterface = ContentInterface; @@ -47997,15 +48040,6 @@ exports.ConfigurationHandler = void 0; const config_1 = __nccwpck_require__(1106); const logger_1 = __nccwpck_require__(8836); const issue_content_interface_1 = __nccwpck_require__(9913); -/** Keys that must be preserved from stored config when current has undefined (e.g. when branch already existed). */ -const CONFIG_KEYS_TO_PRESERVE = [ - 'parentBranch', - 'workingBranch', - 'releaseBranch', - 'hotfixBranch', - 'hotfixOriginBranch', - 'branchType', -]; class ConfigurationHandler extends issue_content_interface_1.IssueContentInterface { constructor() { super(...arguments); @@ -48019,14 +48053,14 @@ class ConfigurationHandler extends issue_content_interface_1.IssueContentInterfa parentBranch: current.parentBranch, hotfixOriginBranch: current.hotfixOriginBranch, hotfixBranch: current.hotfixBranch, - results: current.results, branchConfiguration: current.branchConfiguration, }; const storedRaw = await this.internalGetter(execution); if (storedRaw != null && storedRaw.trim().length > 0) { try { const stored = JSON.parse(storedRaw); - for (const key of CONFIG_KEYS_TO_PRESERVE) { + // Merge all fields from stored that are undefined in current payload + for (const key in stored) { if (payload[key] === undefined && stored[key] !== undefined) { payload[key] = stored[key]; } @@ -48036,6 +48070,8 @@ class ConfigurationHandler extends issue_content_interface_1.IssueContentInterfa /* ignore parse errors, save current as-is */ } } + // Ensure results is never saved to prevent payload bloat + delete payload['results']; return await this.internalUpdate(execution, JSON.stringify(payload, null, 4)); } catch (error) { diff --git a/build/github_action/src/data/repository/ai_repository.d.ts b/build/github_action/src/data/repository/ai_repository.d.ts index e9c48bd4..2e8f9927 100644 --- a/build/github_action/src/data/repository/ai_repository.d.ts +++ b/build/github_action/src/data/repository/ai_repository.d.ts @@ -1,6 +1,6 @@ import { Ai } from '../model/ai'; -/** Default OpenCode agent for analysis/planning (read-only, no file edits). */ -export declare const OPENCODE_AGENT_PLAN = "plan"; +/** Default OpenCode agent for analysis/planning (read-only, no file edits). Changed to build to support diffs. */ +export declare const OPENCODE_AGENT_PLAN = "build"; /** OpenCode agent with write/edit/bash for development (e.g. copilot when run locally). */ export declare const OPENCODE_AGENT_BUILD = "build"; /** JSON schema for translation responses: translatedText (required), optional reason if translation failed. */ diff --git a/build/github_action/src/manager/description/base/content_interface.d.ts b/build/github_action/src/manager/description/base/content_interface.d.ts index 0135f3a8..ac690594 100644 --- a/build/github_action/src/manager/description/base/content_interface.d.ts +++ b/build/github_action/src/manager/description/base/content_interface.d.ts @@ -4,6 +4,7 @@ export declare abstract class ContentInterface { abstract get visibleContent(): boolean; private get startPattern(); private get endPattern(); + private getBlockIndices; getContent: (description: string | undefined) => string | undefined; private _addContent; private _updateContent; diff --git a/docs/configuration.mdx b/docs/configuration.mdx index 769514b1..592a8096 100644 --- a/docs/configuration.mdx +++ b/docs/configuration.mdx @@ -121,3 +121,17 @@ Copilot provides extensive configuration options to customize your workflow. Use **Commit images:** `images-commit-automatic`, `images-commit-feature`, `images-commit-bugfix`, `images-commit-docs`, `images-commit-chore`, `images-commit-hotfix`, `images-commit-release` + +## Persistence & Merging + +Copilot persists certain configuration fields directly in the **GitHub Issue description** as a hidden JSON block. This allows the action to "remember" state across different runs and across issues/PRs. + +### Merging Strategy + +When updating the configuration (e.g. after a push or a label change), Copilot follows a protective merging strategy: +1. **Current State**: The action computes the latest state (branches, types, etc.). +2. **Persistence**: It reads the existing JSON block from the issue. +3. **Smart Merge**: It preserves all fields from the stored configuration that are not explicitly overwritten by the current state. This includes `branchConfiguration` and any unknown keys. +4. **Exclusions**: The `results` field is explicitly excluded from being saved to prevent payload bloat. + +This ensures that manual configuration or metadata added by other tools is not lost during routine updates. diff --git a/package-lock.json b/package-lock.json index 17da3ed4..af87df8e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "copilot", - "version": "2.0.3", + "version": "2.0.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "copilot", - "version": "2.0.3", + "version": "2.0.4", "hasInstallScript": true, "license": "ISC", "dependencies": { diff --git a/package.json b/package.json index 298d92fa..e4855de7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "copilot", - "version": "2.0.3", + "version": "2.0.4", "description": "Automates branch management, GitHub project linking, and issue/PR tracking with Git-Flow methodology.", "main": "build/github_action/index.js", "bin": { diff --git a/src/data/model/__tests__/execution.test.ts b/src/data/model/__tests__/execution.test.ts index ece93616..d1c66731 100644 --- a/src/data/model/__tests__/execution.test.ts +++ b/src/data/model/__tests__/execution.test.ts @@ -67,7 +67,7 @@ jest.mock('../../../usecase/steps/common/get_hotfix_version_use_case', () => ({ })), })); -import { INPUT_KEYS } from '../../../utils/constants'; +import { ACTIONS, INPUT_KEYS } from '../../../utils/constants'; import { Ai } from '../ai'; import { Branches } from '../branches'; import { Emoji } from '../emoji'; @@ -750,5 +750,20 @@ describe('Execution', () => { expect(e.hotfix.baseVersion).toBeUndefined(); expect(e.hotfix.version).toBeUndefined(); }); + it('setup skips internal label fetch error when ACTION is INITIAL_SETUP', async () => { + const singleAction = new SingleAction(ACTIONS.INITIAL_SETUP, '1', '', '', ''); + const issue = makeIssue({ eventName: 'issues', issue: { number: 1 } } as never); + const e = buildExecution({ eventName: 'issues', issue: { number: 1 } } as never, { singleAction, issue }); + mockGetLabels.mockRejectedValue(new Error('Label error')); + await e.setup(); + expect(e.labels.currentIssueLabels).toEqual([]); + }); + + it('setup throws when getLabels fails and not INITIAL_SETUP', async () => { + const issue = makeIssue({ eventName: 'issues', issue: { number: 1 } } as never); + const e = buildExecution({ eventName: 'issues', issue: { number: 1 } } as never, { issue }); + mockGetLabels.mockRejectedValue(new Error('Fatal label error')); + await expect(e.setup()).rejects.toThrow('Fatal label error'); + }); }); }); diff --git a/src/data/model/execution.ts b/src/data/model/execution.ts index c5c672d6..27e280e7 100644 --- a/src/data/model/execution.ts +++ b/src/data/model/execution.ts @@ -4,9 +4,9 @@ import { ConfigurationHandler } from "../../manager/description/configuration_ha import { GetHotfixVersionUseCase } from "../../usecase/steps/common/get_hotfix_version_use_case"; import { GetReleaseTypeUseCase } from "../../usecase/steps/common/get_release_type_use_case"; import { GetReleaseVersionUseCase } from "../../usecase/steps/common/get_release_version_use_case"; -import { INPUT_KEYS } from "../../utils/constants"; +import { ACTIONS, INPUT_KEYS } from "../../utils/constants"; import { branchesForManagement, typesForIssue } from "../../utils/label_utils"; -import { setGlobalLoggerDebug } from "../../utils/logger"; +import { logDebugInfo, setGlobalLoggerDebug } from "../../utils/logger"; import { extractIssueNumberFromBranch, extractIssueNumberFromPush } from "../../utils/title_utils"; import { incrementVersion } from "../../utils/version_utils"; import { BranchRepository } from "../repository/branch_repository"; @@ -31,7 +31,7 @@ import { SizeThresholds } from "./size_thresholds"; import { Tokens } from "./tokens"; import { Welcome } from "./welcome"; import { Workflows } from "./workflows"; - + export class Execution { debug: boolean = false; welcome: Welcome | undefined; @@ -220,7 +220,7 @@ export class Execution { setup = async () => { setGlobalLoggerDebug(this.debug, this.inputs === undefined); - + const issueRepository = new IssueRepository(); const projectRepository = new ProjectRepository(); @@ -293,14 +293,24 @@ export class Execution { this.previousConfiguration = await new ConfigurationHandler().get(this) /** - * Get labels of issue + * Get labels of issue (skip if it's the initial setup and it fails) */ - this.labels.currentIssueLabels = await issueRepository.getLabels( - this.owner, - this.repo, - this.issueNumber, - this.tokens.token - ); + try { + this.labels.currentIssueLabels = await issueRepository.getLabels( + this.owner, + this.repo, + this.issueNumber, + this.tokens.token + ); + } catch (error) { + const isInitialSetup = this.singleAction.currentSingleAction === ACTIONS.INITIAL_SETUP; + if (this.isSingleAction && isInitialSetup) { + logDebugInfo('Skipping initial labels fetch for setup action.'); + this.labels.currentIssueLabels = []; + } else { + throw error; + } + } /** * Contains release label @@ -335,12 +345,17 @@ export class Execution { } } else { this.currentConfiguration.parentBranch = this.previousConfiguration?.parentBranch + this.currentConfiguration.workingBranch = this.previousConfiguration?.workingBranch } if (this.currentConfiguration.parentBranch === undefined && this.previousConfiguration?.parentBranch != null) { this.currentConfiguration.parentBranch = this.previousConfiguration.parentBranch; } + if (this.currentConfiguration.workingBranch === undefined && this.previousConfiguration?.workingBranch != null) { + this.currentConfiguration.workingBranch = this.previousConfiguration.workingBranch; + } + if (this.isSingleAction) { /** * Nothing to do here (for now) @@ -401,6 +416,10 @@ export class Execution { ); this.release.active = this.pullRequest.base.indexOf(`${this.branches.releaseTree}/`) > -1 this.hotfix.active = this.pullRequest.base.indexOf(`${this.branches.hotfixTree}/`) > -1 + + if (!this.currentConfiguration.parentBranch) { + this.currentConfiguration.parentBranch = this.pullRequest.base; + } } this.currentConfiguration.branchType = this.issueType diff --git a/src/data/repository/__tests__/issue_repository.test.ts b/src/data/repository/__tests__/issue_repository.test.ts index b591fefd..66d39971 100644 --- a/src/data/repository/__tests__/issue_repository.test.ts +++ b/src/data/repository/__tests__/issue_repository.test.ts @@ -85,6 +85,20 @@ function makeLabels(overrides: { currentIssueLabels?: string[] } = {}): Labels { return labels; } +function makeIssueTypes(): IssueTypes { + return new IssueTypes( + 'Task', 'Task desc', 'BLUE', + 'Bug', 'Bug desc', 'RED', + 'Feature', 'Feature desc', 'GREEN', + 'Docs', 'Docs desc', 'GREY', + 'Maintenance', 'Maint desc', 'GREY', + 'Hotfix', 'Hotfix desc', 'RED', + 'Release', 'Release desc', 'BLUE', + 'Question', 'Q desc', 'PURPLE', + 'Help', 'Help desc', 'PURPLE' + ); +} + describe('IssueRepository', () => { const repo = new IssueRepository(); @@ -460,6 +474,22 @@ describe('IssueRepository', () => { issue_number: 1, }); }); + + it('returns empty array when listLabelsOnIssue returns 404', async () => { + mockRest.issues.listLabelsOnIssue.mockRejectedValue({ + status: 404, + message: 'Not Found' + }); + const result = await repo.getLabels('owner', 'repo', 1, 'token'); + expect(result).toEqual([]); + expect(mockRest.issues.listLabelsOnIssue).toHaveBeenCalled(); + }); + + it('returns empty array when listLabelsOnIssue returns non-404 error', async () => { + mockRest.issues.listLabelsOnIssue.mockRejectedValue(new Error('Some other error')); + const result = await repo.getLabels('owner', 'repo', 1, 'token'); + expect(result).toEqual([]); + }); }); describe('setLabels', () => { @@ -1137,6 +1167,41 @@ describe('IssueRepository', () => { expect(result.errors.length).toBeGreaterThan(0); expect(result.errors.some((e) => e.includes('Create failed'))).toBe(true); }); + + it('setIssueType catches error and continues (re-throws in IssueRepository)', async () => { + const labels = makeLabels({ currentIssueLabels: ['bug'] }); + const issueTypes = makeIssueTypes(); + mockGraphql.mockRejectedValue(new Error('Global error')); + await expect(repo.setIssueType('o', 'r', 1, labels, issueTypes, 't')).rejects.toThrow('Global error'); + }); + + it('createIssueType throws when organization is null', async () => { + mockGraphql.mockResolvedValueOnce({ organization: null }); + await expect(repo.createIssueType('o', 'n', 'd', 'c', 't')).rejects.toThrow('No se pudo obtener la organización o'); + }); + + it('listIssueTypes throws when organization is null', async () => { + mockGraphql.mockResolvedValueOnce({ organization: null }); + await expect(repo.listIssueTypes('o', 't')).rejects.toThrow('No se pudo obtener la organización o'); + }); + + it('createIssueType throws when organization is missing (GraphQL error scenario)', async () => { + mockGraphql.mockResolvedValueOnce({ organization: null }); + await expect(repo.createIssueType('o', 'n', 'd', 'c', 't')).rejects.toThrow('No se pudo obtener la organización o'); + }); + + it('ensureLabel handles 422 error "already exists"', async () => { + mockRest.issues.listLabelsForRepo.mockResolvedValue({ data: [] }); + mockRest.issues.createLabel.mockRejectedValue({ status: 422, message: 'already exists' }); + const result = await repo.ensureLabel('o', 'r', 'new', 'c', 'd', 't'); + expect(result).toEqual({ created: false, existed: true }); + }); + + it('ensureLabel re-throws other errors', async () => { + mockRest.issues.listLabelsForRepo.mockResolvedValue({ data: [] }); + mockRest.issues.createLabel.mockRejectedValue(new Error('Fatal')); + await expect(repo.ensureLabel('o', 'r', 'new', 'c', 'd', 't')).rejects.toThrow('Fatal'); + }); }); }); diff --git a/src/data/repository/ai_repository.ts b/src/data/repository/ai_repository.ts index 9c1876d3..2cf4c2d6 100644 --- a/src/data/repository/ai_repository.ts +++ b/src/data/repository/ai_repository.ts @@ -230,8 +230,8 @@ function logPartsForDebug(parts: unknown[], context: string): void { }); } -/** Default OpenCode agent for analysis/planning (read-only, no file edits). */ -export const OPENCODE_AGENT_PLAN = 'plan'; +/** Default OpenCode agent for analysis/planning (read-only, no file edits). Changed to build to support diffs. */ +export const OPENCODE_AGENT_PLAN = 'build'; /** OpenCode agent with write/edit/bash for development (e.g. copilot when run locally). */ export const OPENCODE_AGENT_BUILD = 'build'; @@ -412,8 +412,8 @@ export async function getSessionDiff( const list = Array.isArray(data) ? data : Array.isArray((data as { data?: OpenCodeFileDiff[] }).data) - ? (data as { data: OpenCodeFileDiff[] }).data - : []; + ? (data as { data: OpenCodeFileDiff[] }).data + : []; logInfo(`OpenCode response [session diff] fileCount=${list.length}`); return list; }, 'session diff'); diff --git a/src/data/repository/issue_repository.ts b/src/data/repository/issue_repository.ts index a0446c29..7d83f1a2 100644 --- a/src/data/repository/issue_repository.ts +++ b/src/data/repository/issue_repository.ts @@ -344,12 +344,21 @@ export class IssueRepository { return []; } const octokit = github.getOctokit(token); - const {data: labels} = await octokit.rest.issues.listLabelsOnIssue({ - owner: owner, - repo: repository, - issue_number: issueNumber, - }); - return labels.map(label => label.name); + try { + const {data: labels} = await octokit.rest.issues.listLabelsOnIssue({ + owner: owner, + repo: repository, + issue_number: issueNumber, + }); + return labels.map(label => label.name); + } catch (error: any) { + if (error.status === 404) { + logDebugInfo(`Issue #${issueNumber} not found or no access; returning empty labels.`); + return []; + } + logError(`Error fetching labels for issue #${issueNumber}: ${error}`); + return []; + } } setLabels = async ( diff --git a/src/manager/description/__tests__/configuration_handler.test.ts b/src/manager/description/__tests__/configuration_handler.test.ts index 030fdec4..82e18855 100644 --- a/src/manager/description/__tests__/configuration_handler.test.ts +++ b/src/manager/description/__tests__/configuration_handler.test.ts @@ -41,7 +41,6 @@ function minimalExecution(overrides: Record = {}): Execution { parentBranch: 'develop', hotfixOriginBranch: undefined, hotfixBranch: undefined, - results: [], branchConfiguration: undefined, }, ...overrides, @@ -107,34 +106,131 @@ describe('ConfigurationHandler', () => { expect(updatedDesc).toMatch(/"workingBranch":\s*"feature\/123"/); }); - it('preserves stored keys when current has undefined', async () => { + it('preserves all stored keys (including unknown ones) when current has undefined', async () => { const storedJson = JSON.stringify({ parentBranch: 'main', - releaseBranch: 'release/1', - branchType: 'hotfix', + unknownKey: 'preserve-me', + branchConfiguration: { name: 'leaf' }, }); mockGetDescription.mockResolvedValue(descriptionWithConfig(storedJson)); mockUpdateDescription.mockResolvedValue(undefined); const execution = minimalExecution({ currentConfiguration: { - branchType: 'feature', - releaseBranch: undefined, - workingBranch: 'feature/123', parentBranch: undefined, + branchConfiguration: undefined, + }, + }); + + await handler.update(execution); + + expect(mockUpdateDescription).toHaveBeenCalled(); + const fullDesc = mockUpdateDescription.mock.calls[0][3]; + const parsed = JSON.parse(handler.getContent(fullDesc)!.trim()); + expect(parsed.parentBranch).toBe('main'); + expect(parsed.unknownKey).toBe('preserve-me'); + expect(parsed.branchConfiguration).toEqual({ name: 'leaf' }); + }); + + it('preserves workingBranch from stored when current workingBranch is undefined (PR edited event)', async () => { + const storedJson = JSON.stringify({ + branchType: 'bugfix', + workingBranch: 'bugfix/319-setup-crash-on-repository-with-no-issues', + parentBranch: 'develop', + results: [], + }); + mockGetDescription.mockResolvedValue(descriptionWithConfig(storedJson)); + mockUpdateDescription.mockResolvedValue(undefined); + + const execution = minimalExecution({ + currentConfiguration: { + branchType: 'bugfix', + workingBranch: undefined, // as in a PR edited event (never assigned in setup()) + parentBranch: 'develop', hotfixOriginBranch: undefined, hotfixBranch: undefined, - results: [], branchConfiguration: undefined, }, }); await handler.update(execution); + const fullDesc = mockUpdateDescription.mock.calls[0][3]; + const parsed = JSON.parse(handler.getContent(fullDesc)!.trim()); + expect(parsed.workingBranch).toBe('bugfix/319-setup-crash-on-repository-with-no-issues'); + expect(parsed.results).toBeUndefined(); + expect(parsed.parentBranch).toBe('develop'); + expect(parsed.branchType).toBe('bugfix'); + }); + + it('always excludes results from the saved payload even if present in stored', async () => { + const storedJson = JSON.stringify({ + results: [{ some: 'result' }], + parentBranch: 'main', + }); + mockGetDescription.mockResolvedValue(descriptionWithConfig(storedJson)); + mockUpdateDescription.mockResolvedValue(undefined); + + const execution = minimalExecution({ + currentConfiguration: { + parentBranch: 'develop', + }, + }); + + await handler.update(execution); + + const fullDesc = mockUpdateDescription.mock.calls[0][3]; + const parsed = JSON.parse(handler.getContent(fullDesc)!.trim()); + expect(parsed.results).toBeUndefined(); + expect(parsed.parentBranch).toBe('develop'); + }); + + it('fails safely when block is mangled (missing end tag)', async () => { + const mangledDesc = `body\n${CONFIG_START}\n{"x":1}\nno end tag here`; + mockGetDescription.mockResolvedValue(mangledDesc); + + const execution = minimalExecution(); + const result = await handler.update(execution); + + // Should log error and return undefined instead of corrupting or crashing + expect(result).toBeUndefined(); + const { logError } = require('../../../utils/logger'); + expect(logError).toHaveBeenCalledWith(expect.stringContaining('problem with open-close tags')); + }); + + it('handles malformed JSON in stored config gracefully', async () => { + mockGetDescription.mockResolvedValue(descriptionWithConfig('invalid { json')); + mockUpdateDescription.mockResolvedValue(undefined); + + const execution = minimalExecution({ + currentConfiguration: { + branchType: 'feature', + workingBranch: 'feat/new', + }, + }); + + await handler.update(execution); + expect(mockUpdateDescription).toHaveBeenCalled(); const fullDesc = mockUpdateDescription.mock.calls[0][3]; - expect(fullDesc).toContain('"parentBranch": "main"'); - expect(fullDesc).toContain('"releaseBranch": "release/1"'); + expect(fullDesc).toContain('"branchType": "feature"'); + expect(fullDesc).toContain('"workingBranch": "feat/new"'); + }); + + it('handles empty stored config block gracefully', async () => { + mockGetDescription.mockResolvedValue(descriptionWithConfig(' ')); + mockUpdateDescription.mockResolvedValue(undefined); + + const execution = minimalExecution({ + currentConfiguration: { + branchType: 'feature', + }, + }); + + await handler.update(execution); + + expect(mockUpdateDescription).toHaveBeenCalled(); + expect(mockUpdateDescription.mock.calls[0][3]).toContain('"branchType": "feature"'); }); it('returns undefined on error', async () => { @@ -146,4 +242,19 @@ describe('ConfigurationHandler', () => { expect(result).toBeUndefined(); }); }); + + describe('edge cases', () => { + it('get returns undefined when internalGetter returns empty string', async () => { + mockGetDescription.mockResolvedValue(''); + const execution = minimalExecution(); + const result = await handler.get(execution); + expect(result).toBeUndefined(); + }); + + it('get throws informative error on invalid JSON', async () => { + mockGetDescription.mockResolvedValue(descriptionWithConfig('{ "broken": ')); + const execution = minimalExecution(); + await expect(handler.get(execution)).rejects.toThrow(/Unexpected end of JSON input|SyntaxError/); + }); + }); }); diff --git a/src/manager/description/base/__tests__/content_interface.test.ts b/src/manager/description/base/__tests__/content_interface.test.ts index ca95800d..acc4f26c 100644 --- a/src/manager/description/base/__tests__/content_interface.test.ts +++ b/src/manager/description/base/__tests__/content_interface.test.ts @@ -58,27 +58,27 @@ describe('ContentInterface', () => { it('logs and rethrows when extraction throws', () => { const desc = `pre\n${start}\ninner\n${end}\npost`; - const originalSplit = String.prototype.split; - (jest.spyOn(String.prototype, 'split') as jest.Mock).mockImplementation( - function (this: string, separator: unknown, limit?: number) { - if (separator === start) { - return ['only-one-element']; + const originalIndexOf = String.prototype.indexOf; + (jest.spyOn(String.prototype, 'indexOf') as jest.Mock).mockImplementation( + function (this: string, searchString: string, position?: number) { + if (searchString === start) { + throw new Error('indexOf failed'); } - return (originalSplit as (sep: string, limit?: number) => string[]).call( + return (originalIndexOf as (searchString: string, position?: number) => number).call( this, - separator as string, - limit, + searchString, + position, ); }, ); const { logError } = require('../../../../utils/logger'); - expect(() => handler.getContent(desc)).toThrow(); + expect(() => handler.getContent(desc)).toThrow('indexOf failed'); expect(logError).toHaveBeenCalledWith( - expect.stringMatching(/Error reading issue configuration/), + expect.stringMatching(/Error reading issue configuration: Error: indexOf failed/), ); - (String.prototype.split as jest.Mock).mockRestore(); + (String.prototype.indexOf as jest.Mock).mockRestore(); }); }); @@ -111,16 +111,16 @@ describe('ContentInterface', () => { it('logs and returns undefined when update throws', () => { const desc = `pre\n${start}\nold\n${end}\npost`; - const originalSplit = String.prototype.split; - (jest.spyOn(String.prototype, 'split') as jest.Mock).mockImplementation( - function (this: string, separator: unknown, limit?: number) { - if (separator === start) { - throw new Error('split failed'); + const originalIndexOf = String.prototype.indexOf; + (jest.spyOn(String.prototype, 'indexOf') as jest.Mock).mockImplementation( + function (this: string, searchString: string, position?: number) { + if (searchString === start) { + throw new Error('indexOf failed'); } - return (originalSplit as (sep: string, limit?: number) => string[]).call( + return (originalIndexOf as (searchString: string, position?: number) => number).call( this, - separator as string, - limit, + searchString, + position, ); }, ); @@ -130,10 +130,10 @@ describe('ContentInterface', () => { expect(result).toBeUndefined(); expect(logError).toHaveBeenCalledWith( - expect.stringMatching(/Error updating issue description/), + expect.stringMatching(/Error updating issue description: Error: indexOf failed/), ); - (String.prototype.split as jest.Mock).mockRestore(); + (String.prototype.indexOf as jest.Mock).mockRestore(); }); }); }); @@ -160,6 +160,25 @@ describe('ContentInterface', () => { const desc = `pre\n${start}\n{"x":1}\n${end}\npost`; expect(handler.getContent(desc)).toBe('\n{"x":1}\n'); }); + + it('returns content cleanly when there is noisy HTML before and after', () => { + const desc = `Some text\n
\n${start}\n{"valid":"true"}\n${end}\n
\nMore text`; + expect(handler.getContent(desc)).toBe('\n{"valid":"true"}\n'); + }); + + it('returns empty string when there is no content between tags', () => { + const desc = `pre\n${start}${end}\npost`; + expect(handler.getContent(desc)).toBe(''); + }); + + it('returns undefined when tags are inverted (end appears before start)', () => { + const desc = `pre\n${end}\nsome content\n${start}\npost`; + expect(handler.getContent(desc)).toBeUndefined(); + }); + + it('returns undefined for an empty description string', () => { + expect(handler.getContent('')).toBeUndefined(); + }); }); describe('updateContent', () => { @@ -174,6 +193,49 @@ describe('ContentInterface', () => { const result = handler.updateContent(desc, 'new'); expect(result).toBe(`pre\n${start}\nnew\n${end}\npost`); }); + + it('safely handles multiple start tags', () => { + const desc = `pre\n${start}\n${start}\nold\n${end}\npost`; + const result = handler.updateContent(desc, 'new'); + expect(result).toBe(`pre\n${start}\nnew\n${end}\npost`); + }); + + it('replaces only the first block when multiple blocks exist', () => { + const desc = `pre\n${start}\nold1\n${end}\npost\n${start}\nold2\n${end}`; + const result = handler.updateContent(desc, 'new'); + expect(result).toBe(`pre\n${start}\nnew\n${end}\npost\n${start}\nold2\n${end}`); + }); + + it('maintains HTML formatting around the block when updated', () => { + const desc = `

Summary

\n

description

\n${start}\n{"old":true}\n${end}\n
bye
`; + const result = handler.updateContent(desc, '{"new":true}'); + expect(result).toBe(`

Summary

\n

description

\n${start}\n{"new":true}\n${end}\n
bye
`); + }); + + it('handles descriptions with Windows CRLF line endings', () => { + const desc = `pre\r\n${start}\r\nold\r\n${end}\r\npost`; + const result = handler.updateContent(desc, 'new'); + expect(result).toBe(`pre\r\n${start}\nnew\n${end}\r\npost`); + }); + + it('returns undefined when description is empty string (should append)', () => { + const desc = ''; + const result = handler.updateContent(desc, 'new'); + expect(result).toBe(`\n\n${start}\nnew\n${end}`); + }); + + it('correctly updates even when substituting with empty content', () => { + const desc = `pre\n${start}\nold\n${end}\npost`; + const result = handler.updateContent(desc, ''); + expect(result).toBe(`pre\n${start}\n\n${end}\npost`); + }); + + it('correctly updates when new content contains tag-like strings', () => { + const desc = `pre\n${start}\nold\n${end}\npost`; + const tagLikeContent = `{"fakeStart": "${start}"}`; + const result = handler.updateContent(desc, tagLikeContent); + expect(result).toBe(`pre\n${start}\n${tagLikeContent}\n${end}\npost`); + }); }); }); }); diff --git a/src/manager/description/base/content_interface.ts b/src/manager/description/base/content_interface.ts index f995f6aa..405f8778 100644 --- a/src/manager/description/base/content_interface.ts +++ b/src/manager/description/base/content_interface.ts @@ -23,16 +23,33 @@ export abstract class ContentInterface { return `${this._id}-end -->` } + private getBlockIndices(description: string): { startIndex: number; contentStart: number; endIndex: number } | undefined { + const startIndex = description.indexOf(this.startPattern); + if (startIndex === -1) { + return undefined; + } + + const contentStart = startIndex + this.startPattern.length; + const endIndex = description.indexOf(this.endPattern, contentStart); + if (endIndex === -1) { + return undefined; + } + + return { startIndex, contentStart, endIndex }; + } + getContent = (description: string | undefined): string | undefined => { try { if (description === undefined) { return undefined; } - if (description.indexOf(this.startPattern) === -1 || description.indexOf(this.endPattern) === -1) { + + const indices = this.getBlockIndices(description); + if (!indices) { return undefined; } - return description.split(this.startPattern)[1].split(this.endPattern)[0] + return description.substring(indices.contentStart, indices.endIndex); } catch (error) { logError(`Error reading issue configuration: ${error}`); throw error; @@ -49,14 +66,15 @@ export abstract class ContentInterface { } private _updateContent = (description: string, content: string) => { - if (description.indexOf(this.startPattern) === -1 || description.indexOf(this.endPattern) === -1) { + const indices = this.getBlockIndices(description); + if (!indices) { logError(`The content has a problem with open-close tags: ${this.startPattern} / ${this.endPattern}`); return undefined; } - const start = description.split(this.startPattern)[0] + const start = description.substring(0, indices.startIndex); const mid = `${this.startPattern}\n${content}\n${this.endPattern}`; - const end = description.split(this.endPattern)[1] + const end = description.substring(indices.endIndex + this.endPattern.length); return `${start}${mid}${end}`; } diff --git a/src/manager/description/configuration_handler.ts b/src/manager/description/configuration_handler.ts index d9f99157..ff3b1cc8 100644 --- a/src/manager/description/configuration_handler.ts +++ b/src/manager/description/configuration_handler.ts @@ -3,15 +3,6 @@ import { Execution } from "../../data/model/execution"; import { logError } from "../../utils/logger"; import { IssueContentInterface } from "./base/issue_content_interface"; -/** Keys that must be preserved from stored config when current has undefined (e.g. when branch already existed). */ -const CONFIG_KEYS_TO_PRESERVE = [ - 'parentBranch', - 'workingBranch', - 'releaseBranch', - 'hotfixBranch', - 'hotfixOriginBranch', - 'branchType', -] as const; export class ConfigurationHandler extends IssueContentInterface { get id(): string { @@ -32,7 +23,6 @@ export class ConfigurationHandler extends IssueContentInterface { parentBranch: current.parentBranch, hotfixOriginBranch: current.hotfixOriginBranch, hotfixBranch: current.hotfixBranch, - results: current.results, branchConfiguration: current.branchConfiguration, }; @@ -40,7 +30,8 @@ export class ConfigurationHandler extends IssueContentInterface { if (storedRaw != null && storedRaw.trim().length > 0) { try { const stored = JSON.parse(storedRaw) as Record; - for (const key of CONFIG_KEYS_TO_PRESERVE) { + // Merge all fields from stored that are undefined in current payload + for (const key in stored) { if (payload[key] === undefined && stored[key] !== undefined) { payload[key] = stored[key]; } @@ -50,6 +41,9 @@ export class ConfigurationHandler extends IssueContentInterface { } } + // Ensure results is never saved to prevent payload bloat + delete payload['results']; + return await this.internalUpdate(execution, JSON.stringify(payload, null, 4)); } catch (error) { logError(`Error updating issue description: ${error}`); diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 709dbbc9..bcfd04fe 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -381,11 +381,11 @@ export const INPUT_KEYS = { PULL_REQUEST_DESIRED_REVIEWERS_COUNT: 'desired-reviewers-count', PULL_REQUEST_MERGE_TIMEOUT: 'merge-timeout', -} as const; +} as const; export const ERRORS = { GIT_REPOSITORY_NOT_FOUND: '❌ Git repository not found' -} as const; +} as const; export const ACTIONS = { DEPLOYED: 'deployed_action', @@ -397,7 +397,7 @@ export const ACTIONS = { CHECK_PROGRESS: 'check_progress_action', DETECT_POTENTIAL_PROBLEMS: 'detect_potential_problems_action', RECOMMEND_STEPS: 'recommend_steps_action', -} as const; +} as const; /** Hidden HTML comment prefix for bugbot findings (issue/PR comments). Format: */ export const BUGBOT_MARKER_PREFIX = 'copilot-bugbot';