Skip to content

Commit de37e86

Browse files
committed
Improve CI sync-cac-oscal when rule/var description/options change
Signed-off-by: Sophia Wang <huiwang@redhat.com>
1 parent d7f896b commit de37e86

2 files changed

Lines changed: 218 additions & 7 deletions

File tree

.github/workflows/sync-cac-oscal.yml

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -62,16 +62,48 @@ jobs:
6262
if: ${{ env.SKIP == 'false' }}
6363
id: changed-files
6464
run: |
65-
repo=${{ github.repository }}
65+
OWNER="ComplianceAsCode"
66+
REPO="content"
6667
# Fetch all pages of the files for the pull request
67-
url="repos/$repo/pulls/${{ env.PR_NUMBER }}/files"
68+
url="repos/$OWNER/$REPO/pulls/${{ env.PR_NUMBER }}/files"
6869
response=$(gh api "$url" --paginate)
6970
echo "$response" | jq -r '.[].filename' > filenames.txt
7071
echo "CHANGE_FOUND=false" >> $GITHUB_ENV
71-
if grep -E "controls/|.profile|rule.yml|.var" filenames.txt ; then
72+
cd cac-content
73+
has_change() {
74+
local file="$1"
75+
local key="$2"
76+
! python compare_rule_var.py \
77+
--owner "$OWNER" \
78+
--repo "$REPO" \
79+
"${{ env.PR_NUMBER }}" \
80+
"$file" \
81+
"$key"
82+
}
83+
while IFS= read -r line; do
84+
case "$line" in
85+
*controls/*|*.profile*)
86+
echo "$line" >> updated_filenames.txt
87+
;;
88+
*rule.yml)
89+
if has_change "$line" "description"; then
90+
echo "Change detected: The description in '$line' was updated."
91+
echo "$line" >> updated_filenames.txt
92+
fi
93+
;;
94+
*.var)
95+
if has_change "$line" "description" || has_change "$line" "options"; then
96+
echo "Change detected: The description or options in '$line' were updated."
97+
echo "$line" >> updated_filenames.txt
98+
fi
99+
;;
100+
esac
101+
done < ../filenames.txt
102+
if [[ -f valid_files.txt ]]; then
103+
echo "Shows valid_files:"
104+
cat valid_files.txt
72105
echo "CHANGE_FOUND=true" >> $GITHUB_ENV
73106
fi
74-
cat filenames.txt
75107
env:
76108
GH_TOKEN: ${{ steps.app-token.outputs.token }}
77109
# Step 6: Setup the complyscribe environment
@@ -130,8 +162,7 @@ jobs:
130162
- name: Handle the detected updates
131163
if: ${{ env.CHANGE_FOUND == 'true' }}
132164
run: |
133-
cat filenames.txt
134-
python cac-content/utils/handle_detected_updates.py 'filenames.txt' > updates 2>&1
165+
python cac-content/utils/handle_detected_updates.py 'cac-content/valid_files.txt' > updates 2>&1
135166
cd complyscribe && source venv/bin/activate
136167
RH_PRODUCTS=${{ env.RH_PRODUCTS }}
137168
i=0
@@ -325,7 +356,7 @@ jobs:
325356
commit=$(git log -1 --format=%H)
326357
PR_BODY="This is an auto-generated commit $commit from CAC PR [${{ env.PR_NUMBER }}]("$CAC_PR_URL")"
327358
if [[ "$(git branch --show-current)" == "${{ env.BRANCH_NAME }}" ]]; then
328-
if [ "${{ env.SQUASH_COUNT }} -eq 0" ]; then
359+
if [ "${{ env.SQUASH_COUNT }}" -eq 0 ]; then
329360
echo "No commits from the CAC PR ${{ env.PR_NUMBER }}. Skipping PR creation."
330361
else
331362
# Check if the PR exists

utils/compare_rule_var.py

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
#!/usr/bin/env python3
2+
import sys
3+
import subprocess
4+
import json
5+
import base64
6+
import argparse
7+
from typing import Optional, Tuple, NoReturn
8+
9+
from ssg.rule_yaml import find_section_lines
10+
11+
12+
def run_command(command: list[str]) -> tuple[int, str, str]:
13+
"""Runs a shell command and returns its exit code, stdout, and stderr."""
14+
res = subprocess.run(command, capture_output=True, text=True, check=False)
15+
return res.returncode, res.stdout, res.stderr
16+
17+
18+
def get_file_content_from_pr(owner: str, repo: str, sha: str, file_path: str) -> str:
19+
"""Fetches file content for a given commit SHA using the GitHub API.
20+
The base64 encoding and decoding step is necessary because of how the
21+
GitHub API is designed to transmit file content.
22+
"""
23+
api_path = f"/repos/{owner}/{repo}/contents/{file_path}?ref={sha}"
24+
print(f"-> Fetching file content for '{file_path}' from commit {sha[:7]}...")
25+
returncode, stdout, stderr = run_command(["gh", "api", api_path])
26+
27+
if returncode != 0:
28+
print(f"Exception: Could not fetch file. It may be new or deleted. "
29+
f"(stderr: {stderr.strip()})")
30+
return ""
31+
32+
try:
33+
content_json = json.loads(stdout)
34+
encoded_content = content_json.get('content', '')
35+
if not encoded_content:
36+
return ""
37+
38+
return base64.b64decode(encoded_content).decode('utf-8')
39+
except (json.JSONDecodeError, KeyError, TypeError) as e:
40+
print(f"Exception: Could not parse or decode content. Error: {e}", file=sys.stderr)
41+
return ""
42+
43+
44+
def get_section_from_content(content: str, section: str) -> Optional[str]:
45+
"""
46+
Extracts a section's value from a string containing the file content.
47+
"""
48+
if not content:
49+
return None
50+
51+
lines = content.splitlines()
52+
found_ranges = find_section_lines(lines, section)
53+
54+
if found_ranges:
55+
start, end = found_ranges[0]
56+
section_content = lines[start: end + 1]
57+
return '\n'.join(section_content)
58+
59+
return None
60+
61+
62+
def get_pr_shas(owner: str, repo: str, pr_number: int) -> Tuple[str, str]:
63+
"""
64+
Fetches the base and head commit SHAs for a PR.
65+
"""
66+
gh_pr_command = [
67+
"gh", "pr", "view", str(pr_number),
68+
"--repo", f"{owner}/{repo}",
69+
"--json", "baseRefOid,headRefOid"
70+
]
71+
returncode, stdout, stderr = run_command(gh_pr_command)
72+
73+
if returncode != 0:
74+
raise RuntimeError(f"Failed to get PR details. GitHub CLI stderr: {stderr.strip()}")
75+
76+
try:
77+
shas = json.loads(stdout)
78+
base_sha = shas['baseRefOid']
79+
head_sha = shas['headRefOid']
80+
return base_sha, head_sha
81+
except (json.JSONDecodeError, KeyError) as e:
82+
raise ValueError(f"Could not parse commit SHAs from API response. Error: {e}") from e
83+
84+
85+
def get_value_from_commit(
86+
owner: str,
87+
repo: str,
88+
file_path: str,
89+
key: str,
90+
sha: str
91+
) -> Optional[str]:
92+
"""
93+
Fetches a file from a specific commit and extracts the value of a given key.
94+
95+
Args:
96+
owner: The repository owner.
97+
repo: The repository name.
98+
file_path: The path to the file within the repository.
99+
key: The section to extract from the file's content.
100+
sha: The commit SHA from which to retrieve the file.
101+
102+
Returns:
103+
The extracted value as a string, or None if the file or key is not found.
104+
"""
105+
# 1. Fetch the file's content for the given commit SHA.
106+
content = get_file_content_from_pr(owner, repo, sha, file_path)
107+
108+
# 2. Parse the content to get the desired value.
109+
value = get_section_from_content(content, key)
110+
111+
return value
112+
113+
114+
def compare_value(
115+
owner: str,
116+
repo: str,
117+
pr_number: int,
118+
file_path: str,
119+
key: str
120+
) -> NoReturn:
121+
"""
122+
This function handles the entire process:
123+
1. Fetches the base and head commit SHAs for the PR.
124+
2. Retrieves the value of the specified key from the file at both commits.
125+
3. Compares the two values, prints a report, and exits with a status code.
126+
"""
127+
# 1. Get the base and head commit SHAs.
128+
base_sha, head_sha = get_pr_shas(owner, repo, pr_number)
129+
130+
# 2. Fetch and parse the values from the commits.
131+
before_value = get_value_from_commit(owner, repo, file_path, key, base_sha)
132+
after_value = get_value_from_commit(owner, repo, file_path, key, head_sha)
133+
134+
# 3. Compare the results and print the final output.
135+
print("\n--- Comparison Result ---")
136+
print(f"Value in base branch:\n---\n{before_value or 'Not found'}\n---")
137+
print(f"Value in PR branch:\n---\n{after_value or 'Not found'}\n---")
138+
139+
if before_value == after_value:
140+
print("\nNo changes detected for the specified keys.")
141+
sys.exit(0)
142+
else:
143+
print("\nChange detected for one of the specified keys!")
144+
print("\nCHANGE_FOUND=true")
145+
sys.exit(1)
146+
147+
148+
def parse_args() -> argparse.Namespace:
149+
"""
150+
Parses command-line arguments for the script.
151+
152+
Returns:
153+
An object containing the parsed command-line arguments.
154+
"""
155+
parser = argparse.ArgumentParser(
156+
description="Compare specific keys in a YAML file between a PR's base and head branches."
157+
)
158+
# Required arguments
159+
parser.add_argument("--owner", required=True, help="The owner of the repository.")
160+
parser.add_argument("--repo", required=True, help="The name of the repository.")
161+
parser.add_argument("pr_number", type=int, help="The Pull Request number.")
162+
parser.add_argument("file_path", type=str, help="The file path within the repository.")
163+
parser.add_argument("key", type=str, help="The key to be checked in the file.")
164+
165+
return parser.parse_args()
166+
167+
168+
def main() -> None:
169+
args = parse_args()
170+
compare_value(
171+
args.owner,
172+
args.repo,
173+
args.pr_number,
174+
args.file_path,
175+
args.key
176+
)
177+
178+
179+
if __name__ == "__main__":
180+
main()

0 commit comments

Comments
 (0)