Skip to content

Commit 1a77e39

Browse files
authored
Merge branch 'main' into GIT-121
2 parents 6b357c1 + 116a550 commit 1a77e39

17 files changed

Lines changed: 910 additions & 10 deletions

File tree

.github/pull_request_template.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
## Summary
2+
3+
Describe what changed and why.
4+
5+
## ClickUp Task
6+
7+
Task ID: `GIT-`
8+
9+
Task Link: https://app.clickup.com/t/
10+
11+
## Validation
12+
13+
- [ ] Build passes locally
14+
- [ ] Tests pass locally
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
name: ClickUp Linking Checks
2+
3+
on:
4+
pull_request:
5+
types: [opened, edited, synchronize, reopened, ready_for_review]
6+
branches: [main]
7+
8+
jobs:
9+
validate-clickup-linking:
10+
runs-on: ubuntu-latest
11+
steps:
12+
- name: Validate branch and PR include ClickUp task ID
13+
env:
14+
BRANCH_NAME: ${{ github.event.pull_request.head.ref }}
15+
PR_TITLE: ${{ github.event.pull_request.title }}
16+
PR_BODY: ${{ github.event.pull_request.body }}
17+
run: |
18+
set -euo pipefail
19+
20+
BRANCH_NO_PREFIX="${BRANCH_NAME#codex/}"
21+
22+
if [[ ! "$BRANCH_NO_PREFIX" =~ ^GIT-[A-Za-z0-9]+$ ]]; then
23+
echo "Branch name must match codex/GIT-<taskId> (example: codex/GIT-202)." >&2
24+
exit 1
25+
fi
26+
27+
TASK_ID="${BRANCH_NO_PREFIX}"
28+
29+
if [[ "$PR_TITLE" != *"$TASK_ID"* ]] && [[ "${PR_BODY:-}" != *"$TASK_ID"* ]]; then
30+
echo "PR title or body must include task ID: $TASK_ID" >&2
31+
exit 1
32+
fi
33+
34+
echo "ClickUp linking checks passed for task ID: $TASK_ID"
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
name: PR Governance
2+
3+
on:
4+
pull_request:
5+
types: [opened, edited, synchronize, reopened, ready_for_review]
6+
branches: [main]
7+
8+
permissions:
9+
contents: read
10+
pull-requests: read
11+
12+
jobs:
13+
enforce-governance:
14+
runs-on: ubuntu-latest
15+
steps:
16+
- name: Enforce Sonar quality gate and new issues policy
17+
env:
18+
PR_NUMBER: ${{ github.event.pull_request.number }}
19+
SONAR_ORGANIZATION: ${{ vars.SONAR_ORGANIZATION || github.repository_owner }}
20+
SONAR_PROJECT_KEY: ${{ vars.SONAR_PROJECT_KEY || replace(github.repository, '/', '_') }}
21+
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
22+
SONAR_MAX_POLL_ITERATIONS: "30"
23+
SONAR_POLL_INTERVAL_SECONDS: "10"
24+
run: |
25+
set -euo pipefail
26+
27+
MAX_POLL_ITERATIONS="${SONAR_MAX_POLL_ITERATIONS:-30}"
28+
POLL_INTERVAL_SECONDS="${SONAR_POLL_INTERVAL_SECONDS:-10}"
29+
SONAR_AUTH_ARGS=()
30+
if [[ -n "${SONAR_TOKEN:-}" ]]; then
31+
SONAR_AUTH_ARGS=(-u "${SONAR_TOKEN}:")
32+
fi
33+
34+
quality_status=""
35+
attempt=1
36+
while (( attempt <= MAX_POLL_ITERATIONS )); do
37+
response="$(
38+
curl -sS "${SONAR_AUTH_ARGS[@]}" \
39+
"https://sonarcloud.io/api/project_pull_requests/list?organization=${SONAR_ORGANIZATION}&project=${SONAR_PROJECT_KEY}"
40+
)"
41+
quality_status="$(jq -r --arg pr "${PR_NUMBER}" '.pullRequests[]? | select(.key == $pr) | .status.qualityGateStatus' <<<"$response")"
42+
43+
if [[ -n "$quality_status" && "$quality_status" != "NONE" ]]; then
44+
break
45+
fi
46+
47+
sleep "${POLL_INTERVAL_SECONDS}"
48+
attempt=$((attempt + 1))
49+
done
50+
51+
if [[ -z "$quality_status" || "$quality_status" == "NONE" ]]; then
52+
echo "Sonar result not ready for PR #${PR_NUMBER}." >&2
53+
exit 1
54+
fi
55+
56+
if [[ "$quality_status" != "OK" ]]; then
57+
echo "Sonar quality gate must pass. Current status: ${quality_status}" >&2
58+
exit 1
59+
fi
60+
61+
issues_response="$(
62+
curl -sS "${SONAR_AUTH_ARGS[@]}" \
63+
"https://sonarcloud.io/api/issues/search?organization=${SONAR_ORGANIZATION}&componentKeys=${SONAR_PROJECT_KEY}&pullRequest=${PR_NUMBER}&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true&ps=1"
64+
)"
65+
new_issues="$(jq -r '.total // 0' <<<"$issues_response")"
66+
67+
if [[ "$new_issues" != "0" ]]; then
68+
echo "PR introduces ${new_issues} Sonar new issue(s). New issues must be 0." >&2
69+
exit 1
70+
fi
71+
72+
echo "Sonar checks passed: quality gate OK and 0 new issues."
73+
74+
- name: Enforce inline review replies and resolved threads
75+
env:
76+
PR_NUMBER: ${{ github.event.pull_request.number }}
77+
REPO_OWNER: ${{ github.repository_owner }}
78+
REPO_NAME: ${{ github.event.repository.name }}
79+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
80+
run: |
81+
set -euo pipefail
82+
83+
query='
84+
query($owner:String!, $repo:String!, $number:Int!) {
85+
repository(owner:$owner, name:$repo) {
86+
pullRequest(number:$number) {
87+
author { login }
88+
reviewThreads(first:100) {
89+
pageInfo {
90+
hasNextPage
91+
}
92+
nodes {
93+
id
94+
isResolved
95+
isOutdated
96+
comments(first:100) {
97+
pageInfo {
98+
hasNextPage
99+
}
100+
nodes {
101+
author { login }
102+
}
103+
}
104+
}
105+
}
106+
}
107+
}
108+
}'
109+
110+
response="$(gh api graphql -f query="$query" -F owner="$REPO_OWNER" -F repo="$REPO_NAME" -F number="$PR_NUMBER")"
111+
pr_author="$(jq -r '.data.repository.pullRequest.author.login' <<<"$response")"
112+
113+
has_more_threads="$(jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.hasNextPage' <<<"$response")"
114+
has_more_comments="$(jq -r '
115+
.data.repository.pullRequest.reviewThreads.nodes
116+
| any(.comments.pageInfo.hasNextPage == true)
117+
' <<<"$response")"
118+
119+
if [[ "$has_more_threads" == "true" || "$has_more_comments" == "true" ]]; then
120+
echo "Review thread pagination limit reached; increase pagination handling before enforcing this check." >&2
121+
exit 1
122+
fi
123+
124+
unresolved_count="$(jq -r '
125+
.data.repository.pullRequest.reviewThreads.nodes
126+
| map(select(.isOutdated | not))
127+
| map(select(.isResolved | not))
128+
| length
129+
' <<<"$response")"
130+
131+
missing_inline_reply_count="$(jq -r --arg author "$pr_author" '
132+
.data.repository.pullRequest.reviewThreads.nodes
133+
| map(select(.isOutdated | not))
134+
| map(select(([.comments.nodes[]?.author.login] | index($author)) | not))
135+
| length
136+
' <<<"$response")"
137+
138+
if [[ "$missing_inline_reply_count" != "0" ]]; then
139+
echo "Each active review thread must include an inline reply from PR author (${pr_author})." >&2
140+
exit 1
141+
fi
142+
143+
if [[ "$unresolved_count" != "0" ]]; then
144+
echo "All active review threads must be resolved before merge." >&2
145+
exit 1
146+
fi
147+
148+
echo "Review thread checks passed."

CLAUDE.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,12 +118,14 @@ Rules are automatically loaded as context. See `.claude/rules/`:
118118

119119
## Git Workflow
120120

121-
- Branch per Linear issue, named with ticket number (e.g. `GIT-15`)
121+
- Branch per ClickUp task, named using only custom ID: `codex/GIT-<taskId>` (e.g. `codex/GIT-202`)
122122
- Use the SKILL .claude/skills/github/SKILL.md for interacting with GitHub
123123
- PR workflow use the skill .claude/skills/pr/SKILL.md
124124
- Create the PR
125125
- Wait for 120 seconds to allow for review from coderabbit
126+
- SonarCloud quality gate must pass and Sonar "New issues" must be 0
126127
- Address comments directly inline to the comment
128+
- Do not respond to review feedback in top-level PR comments when an inline thread exists
127129
- if a fix is applied, mark the comment as resolved
128130
- wait for another 120 seconds to allow for review from coderabbit
129131
- repeat the steps until all comments are resolved

src/application/handlers/SessionStartHandler.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
*
44
* Handles the session:start event by loading stored memories
55
* and formatting them as markdown for Claude Code's context.
6+
* Also activates runtime.json for cross-hook agent/model detection.
67
*/
78

89
import type { ISessionStartHandler } from '../interfaces/ISessionStartHandler';
@@ -11,12 +12,20 @@ import type { IEventResult } from '../../domain/interfaces/IEventResult';
1112
import type { IMemoryContextLoader } from '../../domain/interfaces/IMemoryContextLoader';
1213
import type { IContextFormatter } from '../../domain/interfaces/IContextFormatter';
1314
import type { ILogger } from '../../domain/interfaces/ILogger';
15+
import type { IRuntimeService } from '../../domain/interfaces/IRuntimeService';
1416

1517
export class SessionStartHandler implements ISessionStartHandler {
1618
constructor(
1719
private readonly memoryContextLoader: IMemoryContextLoader,
1820
private readonly contextFormatter: IContextFormatter,
1921
private readonly logger?: ILogger,
22+
private readonly runtimeService?: IRuntimeService,
23+
/**
24+
* Env-only agent detection function. Uses direct env var detection
25+
* to avoid reading runtime.json (which we're about to write).
26+
*/
27+
private readonly detectAgent?: () => string | undefined,
28+
private readonly detectModel?: () => string | undefined,
2029
) {}
2130

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

38+
// Activate runtime.json for cross-hook agent/model detection
39+
this.activateRuntime(event);
40+
2941
const result = this.memoryContextLoader.load({ cwd: event.cwd });
3042

3143
if (result.memories.length === 0) {
@@ -65,4 +77,53 @@ export class SessionStartHandler implements ISessionStartHandler {
6577
};
6678
}
6779
}
80+
81+
/**
82+
* Activate runtime.json with current agent/model detection.
83+
* Uses env-only detection to avoid reading runtime.json (circular).
84+
* Never throws — activation errors are logged and ignored.
85+
*/
86+
private activateRuntime(event: ISessionStartEvent): void {
87+
if (!this.runtimeService || !this.detectAgent || !this.detectModel) {
88+
return;
89+
}
90+
91+
try {
92+
// Use env-only detection to avoid reading runtime.json we're about to write
93+
const agent = this.detectAgent();
94+
const model = this.detectModel();
95+
96+
// Determine source based on which env var is set
97+
const source = this.detectSource();
98+
99+
this.runtimeService.activate(
100+
{
101+
sessionId: event.sessionId,
102+
agent,
103+
model,
104+
timestamp: new Date().toISOString(),
105+
source,
106+
},
107+
event.cwd,
108+
);
109+
110+
this.logger?.debug('Runtime activated', { agent, model, source });
111+
} catch (error) {
112+
// Never fail the handler due to runtime activation errors
113+
this.logger?.warn('Failed to activate runtime', {
114+
error: error instanceof Error ? error.message : String(error),
115+
});
116+
}
117+
}
118+
119+
/**
120+
* Determine the source of agent detection from environment variables.
121+
*/
122+
private detectSource(): string {
123+
if (process.env.CLAUDECODE) return 'env:CLAUDECODE';
124+
if (process.env.CLAUDE_CODE) return 'env:CLAUDE_CODE';
125+
if (process.env.CODEX_THREAD_ID) return 'env:CODEX_THREAD_ID';
126+
if (process.env.GIT_MEM_AGENT) return 'env:GIT_MEM_AGENT';
127+
return 'env:unknown';
128+
}
68129
}

src/application/handlers/SessionStopHandler.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,21 @@
33
*
44
* Handles the session:stop event by capturing memories from
55
* commits made during the session via SessionCaptureService.
6+
* Also deactivates runtime.json to prevent stale agent/model attribution.
67
*/
78

89
import type { ISessionStopHandler } from '../interfaces/ISessionStopHandler';
910
import type { ISessionStopEvent } from '../../domain/events/HookEvents';
1011
import type { IEventResult } from '../../domain/interfaces/IEventResult';
1112
import type { ISessionCaptureService } from '../../domain/interfaces/ISessionCaptureService';
1213
import type { ILogger } from '../../domain/interfaces/ILogger';
14+
import type { IRuntimeService } from '../../domain/interfaces/IRuntimeService';
1315

1416
export class SessionStopHandler implements ISessionStopHandler {
1517
constructor(
1618
private readonly sessionCaptureService: ISessionCaptureService,
1719
private readonly logger?: ILogger,
20+
private readonly runtimeService?: IRuntimeService,
1821
) {}
1922

2023
async handle(event: ISessionStopEvent): Promise<IEventResult> {
@@ -50,6 +53,29 @@ export class SessionStopHandler implements ISessionStopHandler {
5053
success: false,
5154
error: err,
5255
};
56+
} finally {
57+
// Always deactivate runtime.json on session stop, even if capture fails
58+
this.deactivateRuntime(event.cwd);
59+
}
60+
}
61+
62+
/**
63+
* Deactivate runtime.json to prevent stale agent/model attribution.
64+
* Never throws — deactivation errors are logged and ignored.
65+
*/
66+
private deactivateRuntime(cwd: string): void {
67+
if (!this.runtimeService) {
68+
return;
69+
}
70+
71+
try {
72+
this.runtimeService.deactivate(cwd);
73+
this.logger?.debug('Runtime deactivated');
74+
} catch (error) {
75+
// Never fail the handler due to runtime deactivation errors
76+
this.logger?.warn('Failed to deactivate runtime', {
77+
error: error instanceof Error ? error.message : String(error),
78+
});
5379
}
5480
}
5581
}

0 commit comments

Comments
 (0)