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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 75 additions & 6 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,76 @@
Please fill in this template.
<!-- PR title format: [Type] Brief description — e.g. [Feature] Add connection export -->
<!-- Types: [Feature] | [Fix] | [Docs] | [Refactor] | [Chore] | [Test] -->
<!-- Target branch: dev (not main) -->

- [ ] Use a meaningful title for the pull request.
- [ ] Follow the guidelines from the [CONTRIBUTING.md](https://github.com/PowerPlatformToolBox/desktop-app/CONTRIBUTING.md#pull-requests).
- [ ] Mention the bug or the feature number the PR will be targeting.
- [ ] Test the change in your own code. (Compile and run)
- [ ] Resolve all GH Copilot comments.
## Summary

<!-- One or two sentences describing what this PR does and why. -->

Closes #<!-- issue number -->

## Type of change

- [ ] New feature
- [ ] Bug fix
- [ ] Refactor (no functional change)
- [ ] Documentation
- [ ] Chore / maintenance (dependency update, build, config)
- [ ] Test addition / improvement

## Changes

<!-- List the key files/areas changed and what was done. Be specific enough for a reviewer to navigate the diff. -->

-

## Architecture checklist

### Packages (`types` & `validation`)

<!-- Only fill in this section if you touched files under packages/. Otherwise delete it. -->

- [ ] **Not applicable** — no changes to `packages/`

If you did change a package:

- [ ] `@pptb/types` (`types`): type definitions updated and version bumped in `packages/types/package.json`
- [ ] `@pptb/validate` (`validation`): validation rules updated and version bumped in `packages/validation/package.json`

### Code quality

- [ ] `pnpm run typecheck` passes with **0 errors** (warnings are acceptable)
- [ ] `pnpm run lint` passes with **0 errors** (warnings are acceptable)
- [ ] `pnpm run build` completes successfully

## Testing

<!-- Describe how you verified this change works. -->

- [ ] `pnpm run test:unit` passes (for changes to `src/main/`, `src/common/`, or `src/renderer/` utilities)
- [ ] `pnpm run test:e2e` passes (for UI / navigation / end-to-end flows)
- [ ] Manually tested in the running app (`pnpm run dev`)

**Scenario tested:**

<!-- e.g. "Opened the app, navigated to X, confirmed Y behaviour" -->

## Screenshots / recordings

<!-- Attach screenshots or a short screen recording for any UI change. Delete this section if not applicable. -->

## Breaking changes

<!-- Does this change affect the tool API (`toolboxAPI` / `dataverseAPI`), existing IPC channels, or `pptoolbox-types`? -->

- [ ] No breaking changes
- [ ] Yes — describe impact and migration path below:

<!-- Breaking change details -->

## Reviewer notes

<!-- Anything specific you'd like reviewers to focus on, or context that helps them review faster. -->

- [ ] I have added appropriate unit and/or e2e tests for this change
- [ ] I have resolved all GitHub Copilot review comments
- [ ] I have followed the guidelines in [CONTRIBUTING.md](https://github.com/PowerPlatformToolBox/desktop-app/blob/dev/CONTRIBUTING.md#pull-requests)
222 changes: 222 additions & 0 deletions .github/workflows/pr-checklist.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
name: PR Checklist Validation

on:
pull_request:
types: [opened, edited, synchronize, reopened]
branches:
- main
- dev

permissions:
pull-requests: write
issues: write
contents: read

jobs:
validate-checklist:
name: PR Checklist
runs-on: ubuntu-latest

steps:
# -----------------------------------------------------------------
# Parse the PR body, validate required checkboxes, manage labels,
# and post/update a single sticky comment with the result.
# -----------------------------------------------------------------
- name: Validate checklist and apply labels
uses: actions/github-script@v7
with:
script: |
const body = context.payload.pull_request.body || '';
const title = context.payload.pull_request.title || '';
const prNumber = context.payload.pull_request.number;
const author = context.payload.pull_request.user.login;
const existingLabels = context.payload.pull_request.labels.map(l => l.name);

// ── Helpers ─────────────────────────────────────────────────────────

/**
* Extract the content of a Markdown section identified by its heading
* prefix (e.g. "## Testing" or "### Code quality"). Stops as soon as
* it encounters another heading at the same level or higher.
*/
function extractSection(text, headingPrefix) {
const lines = text.split('\n');
const headingLevel = (headingPrefix.match(/^(#+)/) || ['', '#'])[1].length;
let capturing = false;
const result = [];

for (const line of lines) {
if (!capturing && line.startsWith(headingPrefix)) {
capturing = true;
result.push(line);
} else if (capturing) {
const m = line.match(/^(#+)\s/);
if (m && m[1].length <= headingLevel) break;
result.push(line);
}
}
return result.join('\n');
}

/** True if the section contains at least one `- [x]` line. */
function atLeastOneChecked(section) {
return /^- \[x\]/im.test(section);
}

/** True if every checkbox in the section is `- [x]` (none are `- [ ]`). */
function allChecked(section) {
return atLeastOneChecked(section) && !/^- \[ \]/im.test(section);
}

// ── Extract relevant sections ────────────────────────────────────────

const typeSection = extractSection(body, '## Type of change');
const packagesSection = extractSection(body, '### Packages');
const codeQuality = extractSection(body, '### Code quality');
const testingSection = extractSection(body, '## Testing');
const breakingSection = extractSection(body, '## Breaking changes');
const reviewerSection = extractSection(body, '## Reviewer notes');

// ── Validation rules ─────────────────────────────────────────────────

const errors = [];

// PR title must follow [Type] Brief description
if (!/^\[(Feature|Fix|Docs|Refactor|Chore|Test)\] .+/.test(title)) {
errors.push(
'**PR title** must follow `[Type] Brief description` ' +
'— valid types: `Feature` · `Fix` · `Docs` · `Refactor` · `Chore` · `Test`'
);
}

// Type of change — at least one
if (!atLeastOneChecked(typeSection)) {
errors.push('**Type of change** — select at least one option');
}

// Packages — at least one (including "Not applicable")
if (!atLeastOneChecked(packagesSection)) {
errors.push(
'**Packages** — select at least one option ' +
'(tick "Not applicable" if no package changes were made)'
);
}

// Code quality — all boxes must be ticked
if (!allChecked(codeQuality)) {
errors.push('**Code quality** — all checkboxes must be ticked before merging');
}

// Testing — all boxes must be ticked
if (!allChecked(testingSection)) {
errors.push('**Testing** — all checkboxes must be ticked before merging');
}

// Breaking changes — at least one option selected
if (!atLeastOneChecked(breakingSection)) {
errors.push('**Breaking changes** — select at least one option');
}

// Reviewer notes — all boxes must be ticked
if (!allChecked(reviewerSection)) {
errors.push('**Reviewer notes** — all checkboxes must be ticked before merging');
}

// ── Label management ─────────────────────────────────────────────────

// breaking-change: the "Yes — describe impact" line is checked
const isBreakingChange = /^- \[x\].*Yes/im.test(breakingSection);

// package-changed: any @pptb line is checked (i.e. not just "Not applicable")
const isPackageChanged = /^- \[x\].*@pptb/im.test(packagesSection);

async function syncLabel(name, shouldHave) {
const has = existingLabels.includes(name);
if (shouldHave && !has) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
labels: [name],
});
} else if (!shouldHave && has) {
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
name,
});
} catch {
// Label may have already been removed — ignore
}
}
}

await syncLabel('breaking-change', isBreakingChange);
await syncLabel('package-changed', isPackageChanged);

// ── Sticky comment ───────────────────────────────────────────────────
// We post one comment and update it in place on subsequent runs so the
// PR timeline stays clean.

const MARKER = '<!-- pr-checklist-bot -->';

const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
});
const existing = comments.find(c => c.body && c.body.includes(MARKER));

const labelNote = [
isBreakingChange ? '🔴 `breaking-change` label applied' : null,
isPackageChanged ? '🔵 `package-changed` label applied' : null,
].filter(Boolean).join('\n');

let commentBody;

const errorList = errors.map(e => `- ${e}`).join('\n');

if (errors.length === 0) {
commentBody =
`${MARKER}\n` +
`### ✅ PR Checklist\n\n` +
`All required checklist items are complete. This PR is ready for review.\n` +
(labelNote ? `\n${labelNote}` : '');
} else {
// On updates we omit the @mention to avoid re-notifying on every push.
const mention = existing ? '' : `@${author} — please address the items below.\n\n`;
commentBody =
`${MARKER}\n` +
`### ❌ PR Checklist — Action Required\n\n` +
`${mention}` +
`The following items must be completed before this PR can be merged:\n\n` +
`${errorList}\n` +
(labelNote ? `\n---\n${labelNote}` : '');
}

if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body: commentBody,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: commentBody,
});
}

// ── Fail the status check ────────────────────────────────────────────

if (errors.length > 0) {
core.setFailed(
`PR checklist incomplete — ${errors.length} item${errors.length > 1 ? 's' : ''} outstanding:\n` +
errors.map(e => ` • ${e.replace(/\*\*/g, '')}`).join('\n')
);
}
16 changes: 16 additions & 0 deletions src/common/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,5 +285,21 @@ export const MODAL_WINDOW_CHANNELS = {
RENDERER_MESSAGE: "modal-window:renderer-message",
} as const;

// Split layout channels
export const SPLIT_LAYOUT_CHANNELS = {
ACTIVATE: "split-layout:activate",
DEACTIVATE: "split-layout:deactivate",
SET_RATIO: "split-layout:set-ratio",
GET_STATE: "split-layout:get-state",
/** setActiveInPane(pane, instanceId) — make a group member the visible tool in its pane */
SWITCH_PANE: "split-layout:switch-pane",
/** moveToPane(instanceId, targetPane) — move a tab from one group to another */
MOVE_TO_PANE: "split-layout:move-to-pane",
/** setFocusedPane(pane) — set which pane receives newly opened tools */
FOCUS_PANE: "split-layout:focus-pane",
/** Pushed from main to renderer when split state changes */
STATE_CHANGED: "split-layout:state-changed",
} as const;

// Type helper to extract channel names
export type ChannelName<T> = T[keyof T];
38 changes: 38 additions & 0 deletions src/common/types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,41 @@ export interface DataverseAPI {
getEntitySetName: (entityLogicalName: string) => Promise<string>;
}

/**
* Split layout state returned by the main process
*/
export interface SplitLayoutState {
isActive: boolean;
/** Ordered list of instanceIds assigned to the left pane. */
leftGroup: string[];
/** Ordered list of instanceIds assigned to the right pane. */
rightGroup: string[];
/** The currently visible (active) tool in the left pane. */
activeLeftInstanceId: string | null;
/** The currently visible (active) tool in the right pane. */
activeRightInstanceId: string | null;
/** Which pane receives newly opened tools. */
focusedPane: "left" | "right";
ratio: number;
}

/**
* Split Layout API namespace
*/
export interface SplitLayoutAPI {
activate: (leftInstanceId: string, rightInstanceId: string) => Promise<boolean>;
deactivate: () => Promise<boolean>;
setRatio: (ratio: number) => Promise<void>;
getState: () => Promise<SplitLayoutState>;
/** Make instanceId the active (visible) tool in its pane. Also focuses that pane. */
switchPane: (pane: "left" | "right", instanceId: string) => Promise<boolean>;
/** Move instanceId from its current group to targetPane. Collapses split if source becomes empty. */
moveToPane: (instanceId: string, targetPane: "left" | "right") => Promise<boolean>;
/** Set which pane receives newly opened tools. */
focusPane: (pane: "left" | "right") => Promise<void>;
onStateChanged: (callback: (state: SplitLayoutState) => void) => void;
}

/**
* Main ToolboxAPI interface
*/
Expand Down Expand Up @@ -276,6 +311,9 @@ export interface ToolboxAPI {
/** Install the beta (pre-release) npm package for a registry tool and return the loaded Tool. */
installPrereleaseToolFromNpm: (npmPackageName: string) => Promise<Tool>;

// Split layout namespace
splitLayout: SplitLayoutAPI;

// Utils namespace
utils: UtilsAPI;

Expand Down
1 change: 1 addition & 0 deletions src/common/types/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,4 +95,5 @@ export interface UserSettings {
categoryColorThickness?: number; // Thickness in pixels of the category color border under the tab
environmentColorThickness?: number; // Thickness in pixels of the environment color border around the tool panel
mcpAccessToken?: string; // Access token for local MCP server authentication
splitDividerRatio?: number; // Persisted position of the split-pane divider (0.15–0.85)
}
Loading
Loading