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
14 changes: 14 additions & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
## Summary

Describe what changed and why.

## ClickUp Task

Task ID: `GIT-`

Task Link: https://app.clickup.com/t/

## Validation

- [ ] Build passes locally
- [ ] Tests pass locally
34 changes: 34 additions & 0 deletions .github/workflows/clickup-linking.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: ClickUp Linking Checks

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

jobs:
validate-clickup-linking:
runs-on: ubuntu-latest
steps:
- name: Validate branch and PR include ClickUp task ID
env:
BRANCH_NAME: ${{ github.event.pull_request.head.ref }}
PR_TITLE: ${{ github.event.pull_request.title }}
PR_BODY: ${{ github.event.pull_request.body }}
run: |
set -euo pipefail

BRANCH_NO_PREFIX="${BRANCH_NAME#codex/}"

if [[ ! "$BRANCH_NO_PREFIX" =~ ^GIT-[A-Za-z0-9]+_.+ ]]; then
echo "Branch name must match codex/GIT-<taskId>_<taskName> (example: codex/GIT-123abc_fix-mcp-auth)." >&2
exit 1
fi

TASK_ID="${BRANCH_NO_PREFIX%%_*}"

if [[ "$PR_TITLE" != *"$TASK_ID"* ]] && [[ "${PR_BODY:-}" != *"$TASK_ID"* ]]; then
echo "PR title or body must include task ID: $TASK_ID" >&2
exit 1
fi

echo "ClickUp linking checks passed for task ID: $TASK_ID"
148 changes: 148 additions & 0 deletions .github/workflows/pr-governance.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
name: PR Governance

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

permissions:
contents: read
pull-requests: read

jobs:
enforce-governance:
runs-on: ubuntu-latest
steps:
- name: Enforce Sonar quality gate and new issues policy
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
SONAR_ORGANIZATION: ${{ vars.SONAR_ORGANIZATION || github.repository_owner }}
SONAR_PROJECT_KEY: ${{ vars.SONAR_PROJECT_KEY || replace(github.repository, '/', '_') }}
Comment thread
TonyCasey marked this conversation as resolved.
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_MAX_POLL_ITERATIONS: "30"
SONAR_POLL_INTERVAL_SECONDS: "10"
run: |
set -euo pipefail

MAX_POLL_ITERATIONS="${SONAR_MAX_POLL_ITERATIONS:-30}"
POLL_INTERVAL_SECONDS="${SONAR_POLL_INTERVAL_SECONDS:-10}"
SONAR_AUTH_ARGS=()
if [[ -n "${SONAR_TOKEN:-}" ]]; then
SONAR_AUTH_ARGS=(-u "${SONAR_TOKEN}:")
fi

quality_status=""
attempt=1
while (( attempt <= MAX_POLL_ITERATIONS )); do
response="$(
curl -sS "${SONAR_AUTH_ARGS[@]}" \
"https://sonarcloud.io/api/project_pull_requests/list?organization=${SONAR_ORGANIZATION}&project=${SONAR_PROJECT_KEY}"
)"
quality_status="$(jq -r --arg pr "${PR_NUMBER}" '.pullRequests[]? | select(.key == $pr) | .status.qualityGateStatus' <<<"$response")"

if [[ -n "$quality_status" && "$quality_status" != "NONE" ]]; then
break
fi

sleep "${POLL_INTERVAL_SECONDS}"
attempt=$((attempt + 1))
done

if [[ -z "$quality_status" || "$quality_status" == "NONE" ]]; then
echo "Sonar result not ready for PR #${PR_NUMBER}." >&2
exit 1
fi

if [[ "$quality_status" != "OK" ]]; then
echo "Sonar quality gate must pass. Current status: ${quality_status}" >&2
Comment thread
TonyCasey marked this conversation as resolved.
exit 1
fi

issues_response="$(
curl -sS "${SONAR_AUTH_ARGS[@]}" \
"https://sonarcloud.io/api/issues/search?organization=${SONAR_ORGANIZATION}&componentKeys=${SONAR_PROJECT_KEY}&pullRequest=${PR_NUMBER}&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true&ps=1"
)"
new_issues="$(jq -r '.total // 0' <<<"$issues_response")"

if [[ "$new_issues" != "0" ]]; then
echo "PR introduces ${new_issues} Sonar new issue(s). New issues must be 0." >&2
exit 1
fi
Comment thread
coderabbitai[bot] marked this conversation as resolved.

echo "Sonar checks passed: quality gate OK and 0 new issues."

- name: Enforce inline review replies and resolved threads
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
REPO_OWNER: ${{ github.repository_owner }}
REPO_NAME: ${{ github.event.repository.name }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail

query='
query($owner:String!, $repo:String!, $number:Int!) {
repository(owner:$owner, name:$repo) {
pullRequest(number:$number) {
author { login }
reviewThreads(first:100) {
pageInfo {
hasNextPage
}
nodes {
id
isResolved
isOutdated
comments(first:100) {
Comment thread
TonyCasey marked this conversation as resolved.
pageInfo {
hasNextPage
}
nodes {
author { login }
}
}
}
}
}
}
}'

response="$(gh api graphql -f query="$query" -F owner="$REPO_OWNER" -F repo="$REPO_NAME" -F number="$PR_NUMBER")"
Comment thread
TonyCasey marked this conversation as resolved.
pr_author="$(jq -r '.data.repository.pullRequest.author.login' <<<"$response")"

has_more_threads="$(jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.hasNextPage' <<<"$response")"
has_more_comments="$(jq -r '
.data.repository.pullRequest.reviewThreads.nodes
| any(.comments.pageInfo.hasNextPage == true)
' <<<"$response")"

if [[ "$has_more_threads" == "true" || "$has_more_comments" == "true" ]]; then
echo "Review thread pagination limit reached; increase pagination handling before enforcing this check." >&2
exit 1
fi

unresolved_count="$(jq -r '
.data.repository.pullRequest.reviewThreads.nodes
| map(select(.isOutdated | not))
| map(select(.isResolved | not))
| length
' <<<"$response")"

missing_inline_reply_count="$(jq -r --arg author "$pr_author" '
.data.repository.pullRequest.reviewThreads.nodes
| map(select(.isOutdated | not))
| map(select(([.comments.nodes[]?.author.login] | index($author)) | not))
| length
' <<<"$response")"
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if [[ "$missing_inline_reply_count" != "0" ]]; then
echo "Each active review thread must include an inline reply from PR author (${pr_author})." >&2
exit 1
fi

if [[ "$unresolved_count" != "0" ]]; then
echo "All active review threads must be resolved before merge." >&2
exit 1
fi

echo "Review thread checks passed."
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,12 +118,14 @@ Rules are automatically loaded as context. See `.claude/rules/`:

## Git Workflow

- Branch per Linear issue, named with ticket number (e.g. `GIT-15`)
- Branch per ClickUp task, named using `codex/GIT-<taskId>_<taskName>` (e.g. `codex/GIT-123abc_fix-mcp-auth`)
- Use the SKILL .claude/skills/github/SKILL.md for interacting with GitHub
- PR workflow use the skill .claude/skills/pr/SKILL.md
- Create the PR
- Wait for 120 seconds to allow for review from coderabbit
- SonarCloud quality gate must pass and Sonar "New issues" must be 0
- Address comments directly inline to the comment
- Do not respond to review feedback in top-level PR comments when an inline thread exists
- if a fix is applied, mark the comment as resolved
- wait for another 120 seconds to allow for review from coderabbit
- repeat the steps until all comments are resolved
Expand Down
61 changes: 61 additions & 0 deletions src/application/handlers/SessionStartHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*
* Handles the session:start event by loading stored memories
* and formatting them as markdown for Claude Code's context.
* Also activates runtime.json for cross-hook agent/model detection.
*/

import type { ISessionStartHandler } from '../interfaces/ISessionStartHandler';
Expand All @@ -11,12 +12,20 @@ import type { IEventResult } from '../../domain/interfaces/IEventResult';
import type { IMemoryContextLoader } from '../../domain/interfaces/IMemoryContextLoader';
import type { IContextFormatter } from '../../domain/interfaces/IContextFormatter';
import type { ILogger } from '../../domain/interfaces/ILogger';
import type { IRuntimeService } from '../../domain/interfaces/IRuntimeService';

export class SessionStartHandler implements ISessionStartHandler {
constructor(
private readonly memoryContextLoader: IMemoryContextLoader,
private readonly contextFormatter: IContextFormatter,
private readonly logger?: ILogger,
private readonly runtimeService?: IRuntimeService,
/**
* Env-only agent detection function. Uses direct env var detection
* to avoid reading runtime.json (which we're about to write).
*/
private readonly detectAgent?: () => string | undefined,
private readonly detectModel?: () => string | undefined,
) {}

async handle(event: ISessionStartEvent): Promise<IEventResult> {
Expand All @@ -26,6 +35,9 @@ export class SessionStartHandler implements ISessionStartHandler {
cwd: event.cwd,
});

// Activate runtime.json for cross-hook agent/model detection
this.activateRuntime(event);

const result = this.memoryContextLoader.load({ cwd: event.cwd });

if (result.memories.length === 0) {
Expand Down Expand Up @@ -65,4 +77,53 @@ export class SessionStartHandler implements ISessionStartHandler {
};
}
}

/**
* Activate runtime.json with current agent/model detection.
* Uses env-only detection to avoid reading runtime.json (circular).
* Never throws — activation errors are logged and ignored.
*/
private activateRuntime(event: ISessionStartEvent): void {
if (!this.runtimeService || !this.detectAgent || !this.detectModel) {
return;
}

try {
// Use env-only detection to avoid reading runtime.json we're about to write
const agent = this.detectAgent();
const model = this.detectModel();

// Determine source based on which env var is set
const source = this.detectSource();

this.runtimeService.activate(
{
sessionId: event.sessionId,
agent,
model,
timestamp: new Date().toISOString(),
source,
},
event.cwd,
);

this.logger?.debug('Runtime activated', { agent, model, source });
} catch (error) {
// Never fail the handler due to runtime activation errors
this.logger?.warn('Failed to activate runtime', {
error: error instanceof Error ? error.message : String(error),
});
}
}

/**
* Determine the source of agent detection from environment variables.
*/
private detectSource(): string {
if (process.env.CLAUDECODE) return 'env:CLAUDECODE';
if (process.env.CLAUDE_CODE) return 'env:CLAUDE_CODE';
if (process.env.CODEX_THREAD_ID) return 'env:CODEX_THREAD_ID';
if (process.env.GIT_MEM_AGENT) return 'env:GIT_MEM_AGENT';
return 'env:unknown';
}
}
26 changes: 26 additions & 0 deletions src/application/handlers/SessionStopHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,21 @@
*
* Handles the session:stop event by capturing memories from
* commits made during the session via SessionCaptureService.
* Also deactivates runtime.json to prevent stale agent/model attribution.
*/

import type { ISessionStopHandler } from '../interfaces/ISessionStopHandler';
import type { ISessionStopEvent } from '../../domain/events/HookEvents';
import type { IEventResult } from '../../domain/interfaces/IEventResult';
import type { ISessionCaptureService } from '../../domain/interfaces/ISessionCaptureService';
import type { ILogger } from '../../domain/interfaces/ILogger';
import type { IRuntimeService } from '../../domain/interfaces/IRuntimeService';

export class SessionStopHandler implements ISessionStopHandler {
constructor(
private readonly sessionCaptureService: ISessionCaptureService,
private readonly logger?: ILogger,
private readonly runtimeService?: IRuntimeService,
) {}

async handle(event: ISessionStopEvent): Promise<IEventResult> {
Expand Down Expand Up @@ -50,6 +53,29 @@ export class SessionStopHandler implements ISessionStopHandler {
success: false,
error: err,
};
} finally {
// Always deactivate runtime.json on session stop, even if capture fails
this.deactivateRuntime(event.cwd);
}
}

/**
* Deactivate runtime.json to prevent stale agent/model attribution.
* Never throws — deactivation errors are logged and ignored.
*/
private deactivateRuntime(cwd: string): void {
if (!this.runtimeService) {
return;
}

try {
this.runtimeService.deactivate(cwd);
this.logger?.debug('Runtime deactivated');
} catch (error) {
// Never fail the handler due to runtime deactivation errors
this.logger?.warn('Failed to deactivate runtime', {
error: error instanceof Error ? error.message : String(error),
});
}
}
}
Loading