Skip to content
Open
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
240 changes: 240 additions & 0 deletions src/components/OrganizationAutocomplete.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
import React, { useState, useEffect, useRef } from 'react';
import useDebounce from '../hooks/useDebounce';
import useClickOutside from '../hooks/useClickOutside';
import { searchOrganizations } from '../services/github';
import { useApp } from '../context/AppContext';
import { Spinner } from './UI';

// We use a small in-memory LRU cache specifically for autocomplete to prevent
// duplicating API requests during rapid typing and to avoid unnecessarily polluting
// the global IndexedDB cache with partially-typed, short-lived queries.
const cache = new Map();
const MAX_SUGGESTIONS = 8;
const MIN_QUERY_LENGTH = 2;
Comment on lines +8 to +13

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The cache is FIFO, not LRU, and the eviction removes only one entry.

The comment claims an LRU cache, but a read at line 70 does not reorder the key, so line 86 evicts the oldest inserted key. Either reinsert the key on a cache hit to make it a true LRU, or correct the comment. Also consider exporting a reset function so tests do not depend on module state that persists between cases.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/OrganizationAutocomplete.jsx` around lines 8 - 13, Update the
cache access logic in OrganizationAutocomplete so cache hits refresh the entry’s
recency, making eviction from the Map-based cache truly LRU rather than FIFO;
preserve the existing single-entry eviction behavior. Also expose a reset
function for clearing the module-level cache so tests can isolate their state.


export default function OrganizationAutocomplete({
value,
onChange,
onKeyDown,
onBlur,
onSelectOrg,
placeholder,
style
}) {
const { pat } = useApp();
const [suggestions, setSuggestions] = useState([]);
const [loading, setLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState(-1);
const [error, setError] = useState(false);

const containerRef = useRef(null);
const abortControllerRef = useRef(null);

const debouncedValue = useDebounce(value, 400);

useClickOutside(containerRef, () => {
setIsOpen(false);
setHighlightedIndex(-1);
});

// Handle query change directly to hide dropdown and show correct states
useEffect(() => {
if (value.trim().length < MIN_QUERY_LENGTH) {
setIsOpen(false);
setSuggestions([]);
}
}, [value]);
Comment on lines +42 to +47

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The min-length gate is duplicated.

This effect repeats the gate in the effect at lines 49-56. The second effect already clears the suggestions and closes the dropdown once the debounced value falls under MIN_QUERY_LENGTH; this effect exists only to close it before the debounce elapses. Keep it, but state that intent in the comment, or merge both gates into one helper so the two thresholds cannot diverge.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/OrganizationAutocomplete.jsx` around lines 42 - 47, Clarify
the intent of the useEffect watching value by adding a comment that it
immediately closes the dropdown and clears suggestions before the debounced
query effect runs. Keep its MIN_QUERY_LENGTH check aligned with the later
debounced-value effect, without changing the existing behavior.


useEffect(() => {
const trimmed = debouncedValue.trim();
if (trimmed.length < MIN_QUERY_LENGTH) {
setSuggestions([]);
setIsOpen(false);
setLoading(false);
return;
}

const fetchOrgs = async () => {
setLoading(true);
setError(false);

if (abortControllerRef.current) {
abortControllerRef.current.abort();
}

const controller = new AbortController();
abortControllerRef.current = controller;

const cacheKey = trimmed.toLowerCase();
if (cache.has(cacheKey)) {
setSuggestions(cache.get(cacheKey));
setIsOpen(true);
setLoading(false);
setHighlightedIndex(-1);
return;
}

try {
const results = await searchOrganizations(trimmed, pat, controller.signal);

const deduplicated = results.filter((item, index, self) =>
index === self.findIndex((t) => t.login.toLowerCase() === item.login.toLowerCase())
).slice(0, MAX_SUGGESTIONS);

if (cache.size > 100) {
const firstKey = cache.keys().next().value;
cache.delete(firstKey);
}
cache.set(cacheKey, deduplicated);

setSuggestions(deduplicated);
setIsOpen(true);
setHighlightedIndex(-1);
} catch (err) {
if (err.name !== 'AbortError') {
setError(true);
setSuggestions([]);
setIsOpen(true);
}
} finally {
if (abortControllerRef.current === controller) {
setLoading(false);
}
}
Comment on lines +62 to +104

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Guard the post-await state writes with the current-controller check.

Lines 91-99 write state without verifying that controller is still the active request. abort() does not cancel a response that already settled, so a request for an earlier query can resolve after a newer request started and then overwrite suggestions, isOpen, and error for a query the user has left. The finally block at line 101 already applies the correct guard; apply it to the success and error paths too.

🐛 Proposed fix
       try {
         const results = await searchOrganizations(trimmed, pat, controller.signal);
+        if (abortControllerRef.current !== controller) return;
 
         const deduplicated = results.filter((item, index, self) => 
           index === self.findIndex((t) => t.login.toLowerCase() === item.login.toLowerCase())
         ).slice(0, MAX_SUGGESTIONS);
@@
         setSuggestions(deduplicated);
         setIsOpen(true);
         setHighlightedIndex(-1);
       } catch (err) {
-        if (err.name !== 'AbortError') {
+        if (err.name !== 'AbortError' && abortControllerRef.current === controller) {
           setError(true);
           setSuggestions([]);
           setIsOpen(true);
         }
       } finally {
📝 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
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
const controller = new AbortController();
abortControllerRef.current = controller;
const cacheKey = trimmed.toLowerCase();
if (cache.has(cacheKey)) {
setSuggestions(cache.get(cacheKey));
setIsOpen(true);
setLoading(false);
setHighlightedIndex(-1);
return;
}
try {
const results = await searchOrganizations(trimmed, pat, controller.signal);
const deduplicated = results.filter((item, index, self) =>
index === self.findIndex((t) => t.login.toLowerCase() === item.login.toLowerCase())
).slice(0, MAX_SUGGESTIONS);
if (cache.size > 100) {
const firstKey = cache.keys().next().value;
cache.delete(firstKey);
}
cache.set(cacheKey, deduplicated);
setSuggestions(deduplicated);
setIsOpen(true);
setHighlightedIndex(-1);
} catch (err) {
if (err.name !== 'AbortError') {
setError(true);
setSuggestions([]);
setIsOpen(true);
}
} finally {
if (abortControllerRef.current === controller) {
setLoading(false);
}
}
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
const controller = new AbortController();
abortControllerRef.current = controller;
const cacheKey = trimmed.toLowerCase();
if (cache.has(cacheKey)) {
setSuggestions(cache.get(cacheKey));
setIsOpen(true);
setLoading(false);
setHighlightedIndex(-1);
return;
}
try {
const results = await searchOrganizations(trimmed, pat, controller.signal);
if (abortControllerRef.current !== controller) return;
const deduplicated = results.filter((item, index, self) =>
index === self.findIndex((t) => t.login.toLowerCase() === item.login.toLowerCase())
).slice(0, MAX_SUGGESTIONS);
if (cache.size > 100) {
const firstKey = cache.keys().next().value;
cache.delete(firstKey);
}
cache.set(cacheKey, deduplicated);
setSuggestions(deduplicated);
setIsOpen(true);
setHighlightedIndex(-1);
} catch (err) {
if (err.name !== 'AbortError' && abortControllerRef.current === controller) {
setError(true);
setSuggestions([]);
setIsOpen(true);
}
} finally {
if (abortControllerRef.current === controller) {
setLoading(false);
}
}
🧰 Tools
🪛 ast-grep (0.45.0)

[warning] 90-90: Avoid using the initial state variable in setState
Context: setSuggestions(deduplicated)
Note: [CWE-710] Improper Adherence to Coding Standards. Security best practice.

(setstate-same-var)

🪛 React Doctor (0.9.3)

[error] 102-102: This resets a loading/busy flag only on the success path: if the awaited call rejects the reset never runs and the flag stays stuck truthy (a spinner that never stops, a button disabled forever). Move the reset into a finally block, or mirror it on every catch, so it clears on rejection too.

A trailing setLoading(false) after an await never runs if the awaited call rejects, so the flag stays stuck truthy; reset it in a finally block (or mirror the reset on every catch) so it clears on both paths.

(no-loading-flag-reset-outside-finally)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/OrganizationAutocomplete.jsx` around lines 62 - 104, Guard all
post-await success and error state updates in the organization search flow with
the same current-controller check used by the finally block. In the request
logic around searchOrganizations, only update cache and suggestions, isOpen,
highlightedIndex, or error when abortControllerRef.current still equals
controller; ensure stale responses cannot overwrite the active query’s state.

Source: Linters/SAST tools

};

fetchOrgs();

return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [debouncedValue, pat]);

const handleKeyDown = (e) => {
if (!isOpen) {
if (onKeyDown) onKeyDown(e);
return;
}

if (e.key === 'ArrowDown') {
e.preventDefault();
setHighlightedIndex(prev => (prev < suggestions.length - 1 ? prev + 1 : 0));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setHighlightedIndex(prev => (prev > 0 ? prev - 1 : suggestions.length - 1));
Comment on lines +122 to +127

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Scroll the highlighted option into view.

Arrow navigation moves highlightedIndex through up to eight options inside a 250 px scroll container. The active option can stay outside the visible area, so keyboard users cannot see the current selection. Track the option nodes with a ref and call scrollIntoView({ block: 'nearest' }) when highlightedIndex changes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/OrganizationAutocomplete.jsx` around lines 122 - 127, Update
OrganizationAutocomplete’s highlightedIndex navigation to track rendered
suggestion option nodes with refs, then scroll the newly highlighted option into
view using scrollIntoView({ block: 'nearest' }) whenever highlightedIndex
changes. Preserve the existing ArrowDown and ArrowUp wrapping behavior.

} else if (e.key === 'Enter') {
if (highlightedIndex >= 0 && highlightedIndex < suggestions.length) {
e.preventDefault();
handleSelect(suggestions[highlightedIndex]);
} else {
setIsOpen(false);
if (onKeyDown) onKeyDown(e);
}
} else if (e.key === 'Escape') {
e.preventDefault();
setIsOpen(false);
setHighlightedIndex(-1);
} else {
if (onKeyDown) onKeyDown(e);
}
};

const handleSelect = (org) => {
onSelectOrg(org.login);
setIsOpen(false);
setHighlightedIndex(-1);
};

const handleBlur = (e) => {
if (onBlur) onBlur(e);
};

return (
<div ref={containerRef} style={{ position: 'relative', flex: 1, minWidth: 160 }}>
<input
value={value}
onChange={onChange}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
placeholder={placeholder}
style={{ ...style, width: '100%', boxSizing: 'border-box' }}
role="combobox"
aria-expanded={isOpen}
aria-controls="organization-suggestions"
aria-autocomplete="list"
aria-activedescendant={highlightedIndex >= 0 ? `suggestion-${highlightedIndex}` : undefined}
/>
Comment on lines +155 to +169

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Give the combobox an accessible name.

The input exposes role="combobox" but has only a placeholder. Screen readers do not treat a placeholder as a reliable accessible name, so the control is announced without a purpose. Add an explicit aria-label prop, and default it for the organization use case.

♿ Proposed fix
       <input
         value={value}
         onChange={onChange}
         onKeyDown={handleKeyDown}
         onBlur={handleBlur}
         placeholder={placeholder}
         style={{ ...style, width: '100%', boxSizing: 'border-box' }}
         role="combobox"
+        aria-label={ariaLabel || 'Search GitHub organizations'}
         aria-expanded={isOpen}
         aria-controls="organization-suggestions"
         aria-autocomplete="list"
+        autoComplete="off"
         aria-activedescendant={highlightedIndex >= 0 ? `suggestion-${highlightedIndex}` : undefined}
       />

Add the prop to the signature at lines 15-23 as 'aria-label': ariaLabel.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/OrganizationAutocomplete.jsx` around lines 155 - 169, Update
the OrganizationAutocomplete component signature to accept an ariaLabel prop
mapped from the “aria-label” attribute, defaulting to an appropriate
organization autocomplete name, then pass it to the combobox input alongside the
existing ARIA attributes.


{isOpen && value.trim().length >= MIN_QUERY_LENGTH && (
<ul
id="organization-suggestions"
role="listbox"
style={{
position: 'absolute',
top: '100%',
left: 0,
right: 0,
marginTop: 4,
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius)',
padding: '4px 0',
listStyle: 'none',
maxHeight: 250,
overflowY: 'auto',
zIndex: 10,
boxShadow: '0 4px 12px rgba(0,0,0,0.1)'
}}
>
Comment on lines +171 to +191

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

A mousedown on the dropdown scrollbar commits the partial query as a chip.

The ul sets maxHeight: 250 and overflowY: 'auto', so a scrollbar appears with eight results. Only the li at line 211 calls preventDefault(). If the user presses the scrollbar, the input loses focus, handleBlur runs, and src/pages/HomePage.jsx line 82 turns the partially typed text into a chip and clears the input. The dropdown then closes and the selection is lost.

Prevent the default on the container as well.

🐛 Proposed fix
         <ul
           id="organization-suggestions"
           role="listbox"
+          onMouseDown={(e) => e.preventDefault()}
           style={{
📝 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
{isOpen && value.trim().length >= MIN_QUERY_LENGTH && (
<ul
id="organization-suggestions"
role="listbox"
style={{
position: 'absolute',
top: '100%',
left: 0,
right: 0,
marginTop: 4,
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius)',
padding: '4px 0',
listStyle: 'none',
maxHeight: 250,
overflowY: 'auto',
zIndex: 10,
boxShadow: '0 4px 12px rgba(0,0,0,0.1)'
}}
>
{isOpen && value.trim().length >= MIN_QUERY_LENGTH && (
<ul
id="organization-suggestions"
role="listbox"
onMouseDown={(e) => e.preventDefault()}
style={{
position: 'absolute',
top: '100%',
left: 0,
right: 0,
marginTop: 4,
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius)',
padding: '4px 0',
listStyle: 'none',
maxHeight: 250,
overflowY: 'auto',
zIndex: 10,
boxShadow: '0 4px 12px rgba(0,0,0,0.1)'
}}
>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/OrganizationAutocomplete.jsx` around lines 171 - 191, Update
the suggestions <ul> container in OrganizationAutocomplete so mousedown events
on the dropdown, including its scrollbar, prevent the input blur from committing
the partial query. Preserve the existing li selection behavior and only apply
this change to the suggestions list.

{loading ? (
<li style={{ padding: '8px 12px', display: 'flex', alignItems: 'center', gap: 8, color: 'var(--text2)', fontSize: 13 }}>
<Spinner size={16} /> Searching...
</li>
) : error ? (
<li style={{ padding: '8px 12px', color: 'var(--red)', fontSize: 13 }}>
Failed to load suggestions
</li>
) : suggestions.length === 0 ? (
<li style={{ padding: '8px 12px', color: 'var(--text2)', fontSize: 13 }}>
No organizations found
</li>
) : (
suggestions.map((org, index) => (
<li
key={org.id || org.login}
id={`suggestion-${index}`}
role="option"
aria-selected={index === highlightedIndex}
onMouseDown={(e) => {
e.preventDefault(); // Prevent blur
handleSelect(org);
}}
onMouseEnter={() => setHighlightedIndex(index)}
style={{
padding: '6px 12px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: 10,
background: index === highlightedIndex ? 'var(--surface2)' : 'transparent',
color: 'var(--text)',
fontSize: 14,
}}
>
<img
src={org.avatar_url}
alt=""
style={{ width: 20, height: 20, borderRadius: 4 }}
/>
<span style={{ fontWeight: 500 }}>{org.login}</span>
</li>
))
)}
</ul>
)}
</div>
);
}
20 changes: 20 additions & 0 deletions src/hooks/useClickOutside.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { useEffect } from 'react';

export default function useClickOutside(ref, handler) {
useEffect(() => {
const listener = (event) => {
if (!ref.current || ref.current.contains(event.target)) {
return;
}
handler(event);
};

document.addEventListener('mousedown', listener);
document.addEventListener('touchstart', listener);

return () => {
document.removeEventListener('mousedown', listener);
document.removeEventListener('touchstart', listener);
};
}, [ref, handler]);
Comment on lines +4 to +19

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Keep the handler in a ref to avoid re-subscribing on every render.

handler is in the dependency array. The consumer at src/components/OrganizationAutocomplete.jsx lines 36-39 passes an inline arrow function, so the hook detaches and reattaches both document listeners on every render of that component.

♻️ Proposed refactor
-import { useEffect } from 'react';
+import { useEffect, useRef } from 'react';
 
 export default function useClickOutside(ref, handler) {
+  const handlerRef = useRef(handler);
+  handlerRef.current = handler;
+
   useEffect(() => {
     const listener = (event) => {
       if (!ref.current || ref.current.contains(event.target)) {
         return;
       }
-      handler(event);
+      handlerRef.current(event);
     };
 
     document.addEventListener('mousedown', listener);
     document.addEventListener('touchstart', listener);
 
     return () => {
       document.removeEventListener('mousedown', listener);
       document.removeEventListener('touchstart', listener);
     };
-  }, [ref, handler]);
+  }, [ref]);
 }
📝 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
useEffect(() => {
const listener = (event) => {
if (!ref.current || ref.current.contains(event.target)) {
return;
}
handler(event);
};
document.addEventListener('mousedown', listener);
document.addEventListener('touchstart', listener);
return () => {
document.removeEventListener('mousedown', listener);
document.removeEventListener('touchstart', listener);
};
}, [ref, handler]);
const handlerRef = useRef(handler);
handlerRef.current = handler;
useEffect(() => {
const listener = (event) => {
if (!ref.current || ref.current.contains(event.target)) {
return;
}
handlerRef.current(event);
};
document.addEventListener('mousedown', listener);
document.addEventListener('touchstart', listener);
return () => {
document.removeEventListener('mousedown', listener);
document.removeEventListener('touchstart', listener);
};
}, [ref]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hooks/useClickOutside.js` around lines 4 - 19, Update the useClickOutside
hook to store the latest handler in a ref and invoke that ref from the document
listener, keeping the listener stable across renders. Remove handler from the
effect’s subscription dependencies while preserving ref-based outside-click
detection and cleanup for both mousedown and touchstart.

}
17 changes: 17 additions & 0 deletions src/hooks/useDebounce.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { useState, useEffect } from 'react';

export default function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);

useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);

return () => {
clearTimeout(handler);
};
}, [value, delay]);

return debouncedValue;
}
6 changes: 4 additions & 2 deletions src/pages/HomePage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'
import { FiSearch, FiX } from 'react-icons/fi'
import { useApp } from '../context/AppContext'
import { C, Spinner } from '../components/UI'
import OrganizationAutocomplete from '../components/OrganizationAutocomplete'

const QUICK = ['AOSSIE-Org', 'DjedAlliance', 'StabilityNexus']

Expand Down Expand Up @@ -74,13 +75,14 @@ export default function HomePage() {
<FiX size={12} style={{ cursor: 'pointer', opacity: .7 }} onClick={() => removeChip(c)} />
</span>
))}
<input
<OrganizationAutocomplete
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKey}
onBlur={() => input.trim() && addChip(input)}
onSelectOrg={addChip}
placeholder={chips.length ? 'Add another org...' : 'AOSSIE-Org, StabilityNexus, DjedAlliance...'}
style={{ flex: 1, minWidth: 160, background: 'none', color: 'var(--text)', fontSize: 14, padding: '4px 8px', border: 'none', outline: 'none' }}
style={{ background: 'none', color: 'var(--text)', fontSize: 14, padding: '4px 8px', border: 'none', outline: 'none' }}
/>
<button onClick={() => go()} style={{ ...C.btn('primary'), padding: '8px 22px', flexShrink: 0 }}>
EXPLORE
Expand Down
33 changes: 33 additions & 0 deletions src/services/github.js
Original file line number Diff line number Diff line change
Expand Up @@ -143,3 +143,36 @@ export async function fetchRateLimit(pat) {
return data.rate
} catch { return null }
}

export async function searchOrganizations(query, pat, signal) {
try {
const headers = { Accept: 'application/vnd.github.v3+json' }
if (pat) headers.Authorization = `token ${pat}`

const url = `https://api.github.com/search/users?q=${encodeURIComponent(query)}+type:org&per_page=8`
const res = await fetch(url, { headers, signal })

window.dispatchEvent(
new CustomEvent('rate-limit-update', {
detail: {
limit: Number(res.headers.get('x-ratelimit-limit')),
remaining: Number(res.headers.get('x-ratelimit-remaining')),
used: Number(res.headers.get('x-ratelimit-used')),
reset: Number(res.headers.get('x-ratelimit-reset'))
}
})
)
Comment on lines +155 to +164

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find all listeners and producers of the rate-limit-update event.
rg -n -C 8 "rate-limit-update" --glob '*.{js,jsx,ts,tsx}'

# Find rate-limit rendering surfaces.
rg -n -C 5 -i "ratelimit|rate_limit|remaining" --glob 'src/**/*.{js,jsx}' -g '!src/services/github.js'

Repository: AOSSIE-Org/OrgExplorer

Length of output: 160


🏁 Script executed:

#!/bin/bash
set -u

printf '%s\n' '--- candidate files ---'
git ls-files | rg '(^|/)(github|.*rate.*limit.*|.*github.*)\.(js|jsx|ts|tsx)$' || true

printf '%s\n' '--- service symbols and event references ---'
rg -n -C 12 --glob '*.{js,jsx,ts,tsx}' \
  'rate-limit-update|fetchWithCache|searchOrganizations|x-ratelimit' . || true

printf '%s\n' '--- github.js structure ---'
if [ -f src/services/github.js ]; then
  ast-grep outline src/services/github.js || true
  printf '%s\n' '--- github.js relevant source ---'
  sed -n '1,220p' src/services/github.js
fi

Repository: AOSSIE-Org/OrgExplorer

Length of output: 38028


🏁 Script executed:

#!/bin/bash
set -u

printf '%s\n' '--- AppContext rate-limit state and consumers ---'
sed -n '1,130p' src/context/AppContext.jsx

printf '%s\n' '--- all rate-limit state usages ---'
rg -n -C 10 --glob '*.{js,jsx,ts,tsx}' \
  'rateLimit|oe_rate_limit|x-ratelimit|rate-limit-update' src || true

Repository: AOSSIE-Org/OrgExplorer

Length of output: 25491


Keep search quota separate from core rate-limit state.

AppContext.jsx stores each rate-limit-update payload in the shared rateLimit state and oe_rate_limit storage. A /search/users response can therefore replace the core quota displayed by Navbar, RateLimitBanner, and SettingsPage.

Remove this dispatch, or add a resource: 'search' field and update the state, storage, and consumers to keep search and core quotas separate.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/github.js` around lines 155 - 164, Prevent the `/search/users`
response in the GitHub request flow from dispatching its headers as the shared
`rate-limit-update` event, so it cannot overwrite core quota state used by
`AppContext`, `Navbar`, `RateLimitBanner`, and `SettingsPage`. Remove the
dispatch around the rate-limit payload, or consistently add a search resource
distinction across state, storage, and consumers.


if (res.status === 403) throw new Error('RATE_LIMIT')
if (!res.ok) throw new Error(`HTTP_${res.status}`)
Comment on lines +147 to +167

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the shared header and response-handling logic.

Lines 149-150 and 155-167 repeat fetchWithCache (lines 60-78) verbatim. Duplicated header construction, event dispatch, and status mapping will diverge when one copy changes. Extract one helper that builds headers and one that handles the response, then call both from fetchWithCache, fetchRateLimit, and searchOrganizations.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/github.js` around lines 147 - 167, Extract shared helpers for
GitHub request header construction and response handling, then reuse them in
fetchWithCache, fetchRateLimit, and searchOrganizations. Move the existing
Authorization/Accept setup, rate-limit-update event dispatch, and 403/non-OK
status mapping into those helpers while preserving current behavior and each
function’s request-specific URL or cache logic.


const data = await res.json()
return data.items || []
} catch (err) {
if (err.name === 'AbortError') {
throw err;
}
console.error('Organization search failed:', err);
throw err;
}
}
Loading
Loading