Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
318 changes: 318 additions & 0 deletions .agents/scripts/contributor-activity-helper.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,17 @@
# git author names to GitHub logins, normalising multiple author name variants
# (e.g., "Marcus Quinn" and "marcusquinn" both map to "marcusquinn").
#
# Session time tracking uses the AI assistant database (OpenCode/Claude Code)
# to measure interactive (human) vs worker/runner (headless) session hours.
# Session type is classified by title pattern matching.
#
# Usage:
# contributor-activity-helper.sh summary <repo-path> [--period day|week|month|year]
# contributor-activity-helper.sh table <repo-path> [--format markdown|json]
# contributor-activity-helper.sh user <repo-path> <github-login>
# contributor-activity-helper.sh cross-repo-summary <repo-path1> [<repo-path2> ...] [--period month]
# contributor-activity-helper.sh session-time <repo-path> [--period month]
# contributor-activity-helper.sh cross-repo-session-time <path1> [path2 ...] [--period month]
#
# Output: markdown table or JSON suitable for embedding in health issues.

Expand Down Expand Up @@ -419,6 +425,305 @@
return 0
}

#######################################
# Session time stats from AI assistant database
#
# Queries the OpenCode/Claude Code SQLite database to compute time spent
# in interactive sessions vs headless worker/runner sessions, per repo.
#
# Session type classification (by title pattern):
# - Worker: "Issue #*", "Supervisor Pulse", contains "/full-loop"
# - Interactive: everything else (root sessions only)
# - Subagent: sessions with parent_id (excluded — time attributed to parent)
#
# Duration: max(message.time_created) - min(message.time_created) per session
# (actual active time between first and last message, not wall clock).
#
# Arguments:
# $1 - repo path (filters sessions by directory)
# --period day|week|month|year (optional, default: month)
# --format markdown|json (optional, default: markdown)
# --db-path <path> (optional, default: auto-detect)
# Output: markdown table or JSON
#######################################
session_time() {
local repo_path=""
local period="month"
local format="markdown"
local db_path=""

# Parse arguments
while [[ $# -gt 0 ]]; do
case "$1" in
--period)
period="${2:-month}"
shift 2
;;
--format)
format="${2:-markdown}"
shift 2
;;
--db-path)
db_path="${2:-}"
shift 2
;;
*)
if [[ -z "$repo_path" ]]; then
repo_path="$1"
fi
shift
;;
esac
done

repo_path="${repo_path:-.}"

# Auto-detect database path
if [[ -z "$db_path" ]]; then
if [[ -f "${HOME}/.local/share/opencode/opencode.db" ]]; then
db_path="${HOME}/.local/share/opencode/opencode.db"
elif [[ -f "${HOME}/.local/share/claude/Claude.db" ]]; then
db_path="${HOME}/.local/share/claude/Claude.db"
else
if [[ "$format" == "json" ]]; then
echo "[]"
else
echo "_Session database not found._"
fi
return 0
fi
fi

if ! command -v sqlite3 &>/dev/null; then
if [[ "$format" == "json" ]]; then
echo "[]"
else
echo "_sqlite3 not available._"
fi
return 0
Comment on lines +488 to +503

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Differentiate empty data from failed collection.

In JSON mode the early exits emit [], the sqlite query path also collapses any failure to [], and cross_repo_session_time() then masks non-zero exits with {}. That makes “DB unavailable”, “query failed”, and “no sessions” indistinguishable, and the [] shape can still crash the downstream .get(...) calls. Keep successful no-data responses as a stable zero-object, and propagate real sqlite failures with a non-zero exit. As per coding guidelines, Automation scripts - focus on: Reliability and robustness; Clear logging and feedback; Proper exit codes; Error recovery mechanisms.

Also applies to: 535-549, 667-667

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.agents/scripts/contributor-activity-helper.sh around lines 488 - 503, The
early-exit branches that currently echo "[]" for JSON success (when session DB
missing or sqlite3 not found) should instead emit a stable zero-object (e.g.,
"{}" or a named empty object) to represent "no data" and leave exit code 0,
while actual failures must print an error to stderr and exit with a non-zero
code; update the blocks around the checks for the session DB and the sqlite3
availability (the branches that check "$format" and echo "[]") to output a
stable empty JSON object on success and to call >&2 with a clear error message
and return/exit non-zero on real failures so cross_repo_session_time() and
downstream .get(...) can distinguish no-data from failure—apply the same fix to
the other occurrences referenced (around lines 535-549 and 667).

fi

# Determine --since threshold in milliseconds
local since_ms
case "$period" in
day)
since_ms=$(python3 -c "import time; print(int((time.time() - 86400) * 1000))")
;;
week)
since_ms=$(python3 -c "import time; print(int((time.time() - 604800) * 1000))")
;;
month)
since_ms=$(python3 -c "import time; print(int((time.time() - 2592000) * 1000))")
;;
year)
since_ms=$(python3 -c "import time; print(int((time.time() - 31536000) * 1000))")
;;
*)
since_ms=$(python3 -c "import time; print(int((time.time() - 2592000) * 1000))")
;;
esac

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The case statement makes multiple calls to python3 -c to calculate the since_ms timestamp. This can be optimized for performance and readability by calculating the number of seconds in bash and making a single call to Python.

Suggested change
local since_ms
case "$period" in
day)
since_ms=$(python3 -c "import time; print(int((time.time() - 86400) * 1000))")
;;
week)
since_ms=$(python3 -c "import time; print(int((time.time() - 604800) * 1000))")
;;
month)
since_ms=$(python3 -c "import time; print(int((time.time() - 2592000) * 1000))")
;;
year)
since_ms=$(python3 -c "import time; print(int((time.time() - 31536000) * 1000))")
;;
*)
since_ms=$(python3 -c "import time; print(int((time.time() - 2592000) * 1000))")
;;
esac
local since_ms
local seconds
case "$period" in
day) seconds=86400 ;;
week) seconds=604800 ;;
year) seconds=31536000 ;;
*) seconds=2592000 ;; # month or default
esac
since_ms=$(python3 -c "import time; print(int((time.time() - ${seconds}) * 1000))")
References
  1. In shell scripts, move the calculation of loop-invariant variables outside of loops to improve efficiency. This principle extends to pre-calculating values in bash to reduce the number of external calls, thereby improving overall script performance.


# Resolve repo_path to absolute for matching against session.directory
local abs_repo_path
abs_repo_path=$(cd "$repo_path" 2>/dev/null && pwd) || abs_repo_path="$repo_path"

# Query session data with message-based duration using JSON output.
# JSON avoids pipe-separator issues (session titles can contain '|').
# Filters: root sessions only (no parent_id), within period, matching directory.
# Worktree directories (e.g., ~/Git/aidevops.feature-foo) are matched by prefix.
local query_result
query_result=$(sqlite3 -json "$db_path" "
SELECT
s.title,
(max(m.time_created) - min(m.time_created)) as duration_ms
FROM session s
JOIN message m ON m.session_id = s.id
WHERE s.parent_id IS NULL
AND s.time_created > ${since_ms}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
AND (s.directory = '${abs_repo_path}'
OR s.directory LIKE '${abs_repo_path}.%'
OR s.directory LIKE '${abs_repo_path}-%')
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
GROUP BY s.id
HAVING count(m.id) >= 2
AND duration_ms > 5000
" 2>/dev/null) || query_result="[]"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The SQLite query is constructed using string interpolation with abs_repo_path and since_ms. This is vulnerable to SQL injection if the path contains special characters like a single quote. Please use parameter binding to safely pass values to the query.

Additionally, suppressing stderr with 2>/dev/null hides important error messages from sqlite3, making debugging difficult. The || query_result="[]" already provides a fallback for command failure, so the error suppression is not necessary and can be harmful.

Here's a suggestion that uses parameter binding and removes the stderr suppression:

Suggested change
query_result=$(sqlite3 -json "$db_path" "
SELECT
s.title,
(max(m.time_created) - min(m.time_created)) as duration_ms
FROM session s
JOIN message m ON m.session_id = s.id
WHERE s.parent_id IS NULL
AND s.time_created > ${since_ms}
AND (s.directory = '${abs_repo_path}'
OR s.directory LIKE '${abs_repo_path}.%'
OR s.directory LIKE '${abs_repo_path}-%')
GROUP BY s.id
HAVING count(m.id) >= 2
AND duration_ms > 5000
" 2>/dev/null) || query_result="[]"
query_result=$(sqlite3 -json "$db_path" \
"SELECT
s.title,
(max(m.time_created) - min(m.time_created)) as duration_ms
FROM session s
JOIN message m ON m.session_id = s.id
WHERE s.parent_id IS NULL
AND s.time_created > ?1
AND (s.directory = ?2
OR s.directory LIKE ?3
OR s.directory LIKE ?4)
GROUP BY s.id
HAVING count(m.id) >= 2
AND duration_ms > 5000" \
"$since_ms" \
"$abs_repo_path" \
"${abs_repo_path}.%" \
"${abs_repo_path}-%") || query_result="[]"
References
  1. To prevent SQL injection in shell scripts using sqlite3, create a helper function that uses .param set for safe parameterized bindings instead of direct string interpolation.
  2. Avoid using '2>/dev/null' for blanket suppression of command errors in shell scripts to ensure that authentication, syntax, or system issues remain visible for debugging.


# Process JSON in Python for classification and aggregation
echo "$query_result" | python3 -c "
import sys
import json
import re

format_type = sys.argv[1]
period_name = sys.argv[2]

# Worker session title patterns
worker_patterns = [
re.compile(r'^Issue #\d+'),
re.compile(r'^Supervisor Pulse'),
re.compile(r'/full-loop', re.IGNORECASE),
re.compile(r'^dispatch:', re.IGNORECASE),
re.compile(r'^Worker:', re.IGNORECASE),
]

def classify_session(title):
for pat in worker_patterns:
if pat.search(title):
return 'worker'
return 'interactive'

sessions = json.load(sys.stdin)

interactive_ms = 0
worker_ms = 0
interactive_count = 0
worker_count = 0

for row in sessions:
title = row.get('title', '')
duration_ms = row.get('duration_ms', 0)

session_type = classify_session(title)
if session_type == 'worker':
worker_ms += duration_ms
worker_count += 1
else:
interactive_ms += duration_ms
interactive_count += 1

interactive_hours = round(interactive_ms / 1000 / 3600, 1)
worker_hours = round(worker_ms / 1000 / 3600, 1)
total_hours = round((interactive_ms + worker_ms) / 1000 / 3600, 1)

result = {
'interactive_hours': interactive_hours,
'interactive_sessions': interactive_count,
'worker_hours': worker_hours,
'worker_sessions': worker_count,
'total_hours': total_hours,
'total_sessions': interactive_count + worker_count,
}

if format_type == 'json':
print(json.dumps(result, indent=2))
else:
if interactive_count == 0 and worker_count == 0:
print(f'_No session data for the last {period_name}._')
else:
print(f'| Type | Sessions | Hours |')
print(f'| --- | ---: | ---: |')
print(f'| Interactive (human) | {interactive_count} | {interactive_hours}h |')
print(f'| Workers/Runners | {worker_count} | {worker_hours}h |')
print(f'| **Total** | **{interactive_count + worker_count}** | **{total_hours}h** |')
" "$format" "$period"

return 0
}

#######################################
# Cross-repo session time summary
#
# Aggregates session time across multiple repos. Privacy-safe (no repo names).
#
# Arguments:
# $1..N - repo paths
# --period day|week|month|year (optional, default: month)
# --format markdown|json (optional, default: markdown)
# Output: aggregated table to stdout
#######################################
cross_repo_session_time() {
local period="month"
local format="markdown"
local -a repo_paths=()

while [[ $# -gt 0 ]]; do
case "$1" in
--period)
period="${2:-month}"
shift 2
;;
--format)
format="${2:-markdown}"
shift 2
;;
*)
repo_paths+=("$1")
shift
;;
esac
done

if [[ ${#repo_paths[@]} -eq 0 ]]; then
echo "Error: at least one repo path required" >&2
return 1
fi

# Collect JSON from each repo
local all_json="["
local first="true"
local repo_count=0
for rp in "${repo_paths[@]}"; do
local repo_json
repo_json=$(session_time "$rp" --period "$period" --format json) || repo_json="{}"
if [[ "$first" == "true" ]]; then
first="false"
else
all_json="${all_json},"
fi
all_json="${all_json}${repo_json}"
repo_count=$((repo_count + 1))
done
Comment on lines +668 to +680

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Skip invalid repos instead of counting them.

Unlike cross_repo_summary(), this loop never validates $rp. A typo still increments repo_count, so the markdown can claim “Across N managed repos” while silently dropping one repo’s data. As per coding guidelines, Automation scripts - focus on: Reliability and robustness; Clear logging and feedback.

💡 Suggested change
 	for rp in "${repo_paths[@]}"; do
+		if [[ ! -d "$rp/.git" && ! -f "$rp/.git" ]]; then
+			echo "Warning: $rp is not a git repository, skipping" >&2
+			continue
+		fi
 		local repo_json
 		repo_json=$(session_time "$rp" --period "$period" --format json) || repo_json="{}"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for rp in "${repo_paths[@]}"; do
local repo_json
repo_json=$(session_time "$rp" --period "$period" --format json) || repo_json="{}"
if [[ "$first" == "true" ]]; then
first="false"
else
all_json="${all_json},"
fi
all_json="${all_json}${repo_json}"
repo_count=$((repo_count + 1))
done
for rp in "${repo_paths[@]}"; do
if [[ ! -d "$rp/.git" && ! -f "$rp/.git" ]]; then
echo "Warning: $rp is not a git repository, skipping" >&2
continue
fi
local repo_json
repo_json=$(session_time "$rp" --period "$period" --format json) || repo_json="{}"
if [[ "$first" == "true" ]]; then
first="false"
else
all_json="${all_json},"
fi
all_json="${all_json}${repo_json}"
repo_count=$((repo_count + 1))
done
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.agents/scripts/contributor-activity-helper.sh around lines 665 - 675, The
loop over repo_paths increments repo_count even when session_time fails for an
invalid repo, causing incorrect "Across N managed repos" claims; update the loop
in contributor-activity-helper.sh to validate each $rp by checking the exit of
session_time (repo_json) and only append to all_json and increment repo_count
when session_time succeeds, otherwise log a warning and continue (referencing
repo_paths, session_time, repo_json, all_json, and repo_count), mirroring
cross_repo_summary's validation behavior so invalid repos are skipped and not
counted.

all_json="${all_json}]"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The all_json variable is constructed by string concatenation. This is fragile and can lead to invalid JSON if session_time returns a non-JSON string (like an error message) but exits with code 0. For example, if session_time returns _Session database not found._, the resulting string would be invalid JSON.

A more robust approach is to collect the JSON outputs in a bash array and then use jq to assemble them into a valid JSON array. This ensures the final output is always well-formed.

Suggested change
local all_json="["
local first="true"
local repo_count=0
for rp in "${repo_paths[@]}"; do
local repo_json
repo_json=$(session_time "$rp" --period "$period" --format json) || repo_json="{}"
if [[ "$first" == "true" ]]; then
first="false"
else
all_json="${all_json},"
fi
all_json="${all_json}${repo_json}"
repo_count=$((repo_count + 1))
done
all_json="${all_json}]"
local all_json=""
local repo_count=0
for rp in "${repo_paths[@]}"; do
repo_count=$((repo_count + 1))
local repo_json
repo_json=$(session_time "$rp" --period "$period" --format json) || repo_json="{}"
# Filter out non-JSON responses to avoid breaking the array
if jq -e . >/dev/null 2>&1 <<<"$repo_json"; then
all_json+="${repo_json}"$'\n'
fi
done
all_json=$(echo -n "$all_json" | jq -s '.')
References
  1. In shell scripts, use jq --argjson to safely pass JSON content from variables when merging JSON arrays, instead of direct string interpolation, to prevent parsing errors. The suggested approach of collecting JSON outputs and using jq -s '.' is a robust way to achieve this.


echo "$all_json" | python3 -c "
import sys
import json

format_type = sys.argv[1]
period_name = sys.argv[2]
repo_count = int(sys.argv[3])

repos = json.load(sys.stdin)

totals = {
'interactive_hours': 0,
'interactive_sessions': 0,
'worker_hours': 0,
'worker_sessions': 0,
}

for repo in repos:
totals['interactive_hours'] += repo.get('interactive_hours', 0)
totals['interactive_sessions'] += repo.get('interactive_sessions', 0)
totals['worker_hours'] += repo.get('worker_hours', 0)
totals['worker_sessions'] += repo.get('worker_sessions', 0)

totals['interactive_hours'] = round(totals['interactive_hours'], 1)
totals['worker_hours'] = round(totals['worker_hours'], 1)
total_hours = round(totals['interactive_hours'] + totals['worker_hours'], 1)
total_sessions = totals['interactive_sessions'] + totals['worker_sessions']

if format_type == 'json':
totals['total_hours'] = total_hours
totals['total_sessions'] = total_sessions
totals['repo_count'] = repo_count
print(json.dumps(totals, indent=2))
else:
if total_sessions == 0:
print(f'_No session data across {repo_count} repos for the last {period_name}._')
else:
print(f'_Across {repo_count} managed repos:_')
print()
print(f'| Type | Sessions | Hours |')
print(f'| --- | ---: | ---: |')
print(f'| Interactive (human) | {totals[\"interactive_sessions\"]} | {totals[\"interactive_hours\"]}h |')
print(f'| Workers/Runners | {totals[\"worker_sessions\"]} | {totals[\"worker_hours\"]}h |')
print(f'| **Total** | **{total_sessions}** | **{total_hours}h** |')
" "$format" "$period" "$repo_count"

return 0
}

#######################################
# Main
#######################################
Expand All @@ -431,7 +736,7 @@
local repo_path="${1:-.}"
shift || true
local period="month"
local format="markdown"

Check warning on line 739 in .agents/scripts/contributor-activity-helper.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of using the literal 'markdown' 4 times.

See more on https://sonarcloud.io/project/issues?id=marcusquinn_aidevops&issues=AZzO_f-Ck705V7y5rQE9&open=AZzO_f-Ck705V7y5rQE9&pullRequest=3920
while [[ $# -gt 0 ]]; do
case "$1" in
--period)
Expand Down Expand Up @@ -461,6 +766,12 @@
cross-repo-summary)
cross_repo_summary "$@"
;;
session-time)
session_time "$@"
;;
cross-repo-session-time)
cross_repo_session_time "$@"
;;
help | *)
echo "Usage: $0 <command> [options]"
echo ""
Expand All @@ -469,13 +780,20 @@
echo " table <repo-path> [--period day|week|month|year] [--format markdown|json]"
echo " user <repo-path> <github-login>"
echo " cross-repo-summary <path1> [path2 ...] [--period month] [--format markdown]"
echo " session-time <repo-path> [--period month] [--format markdown]"
echo " cross-repo-session-time <path1> [path2 ...] [--period month] [--format markdown]"
echo ""
echo "Computes contributor activity from immutable git commit history."
echo "Session time stats from AI assistant database (OpenCode/Claude Code)."
echo "GitHub noreply emails are used to normalise author names to logins."
echo ""
echo "Commit types:"
echo " Direct - committer is the author (push, CLI commit)"
echo " PR Merge - committer is noreply@github.com (GitHub squash-merge)"
echo ""
echo "Session types:"
echo " Interactive - human-driven sessions (conversations, debugging)"
echo " Worker - headless dispatched tasks (Issue #N, Supervisor Pulse)"
return 0
;;
esac
Expand Down
Loading
Loading